Navegador

navegador / tests / test_cicd.py
Blame History Raw 486 lines
1
"""Tests for navegador.cicd — CI/CD mode, CICDReporter, and `navegador ci` commands."""
2
3
import json
4
import os
5
from io import StringIO
6
from pathlib import Path
7
from unittest.mock import MagicMock, patch
8
9
import pytest
10
from click.testing import CliRunner
11
12
from navegador.cicd import (
13
EXIT_ERROR,
14
EXIT_SUCCESS,
15
EXIT_WARN,
16
CICDReporter,
17
detect_ci,
18
is_ci,
19
is_github_actions,
20
)
21
from navegador.cli.commands import main
22
23
24
# ── Helpers ───────────────────────────────────────────────────────────────────
25
26
27
def _clear_ci_env(monkeypatch):
28
"""Remove all known CI indicator env vars so each test starts clean."""
29
for var in ("GITHUB_ACTIONS", "CI", "GITLAB_CI", "CIRCLECI", "JENKINS_URL"):
30
monkeypatch.delenv(var, raising=False)
31
32
33
def _mock_store():
34
store = MagicMock()
35
store.query.return_value = MagicMock(result_set=[])
36
return store
37
38
39
# ── CI detection ──────────────────────────────────────────────────────────────
40
41
42
class TestDetectCI:
43
def test_returns_none_outside_ci(self, monkeypatch):
44
_clear_ci_env(monkeypatch)
45
assert detect_ci() is None
46
47
def test_detects_github_actions(self, monkeypatch):
48
_clear_ci_env(monkeypatch)
49
monkeypatch.setenv("GITHUB_ACTIONS", "true")
50
assert detect_ci() == "github_actions"
51
52
def test_detects_generic_ci(self, monkeypatch):
53
_clear_ci_env(monkeypatch)
54
monkeypatch.setenv("CI", "true")
55
assert detect_ci() == "ci"
56
57
def test_detects_gitlab_ci(self, monkeypatch):
58
_clear_ci_env(monkeypatch)
59
monkeypatch.setenv("GITLAB_CI", "true")
60
assert detect_ci() == "gitlab_ci"
61
62
def test_detects_circleci(self, monkeypatch):
63
_clear_ci_env(monkeypatch)
64
monkeypatch.setenv("CIRCLECI", "true")
65
assert detect_ci() == "circleci"
66
67
def test_detects_jenkins(self, monkeypatch):
68
_clear_ci_env(monkeypatch)
69
monkeypatch.setenv("JENKINS_URL", "http://jenkins.local/")
70
assert detect_ci() == "jenkins"
71
72
def test_github_actions_takes_priority_over_ci(self, monkeypatch):
73
_clear_ci_env(monkeypatch)
74
monkeypatch.setenv("GITHUB_ACTIONS", "true")
75
monkeypatch.setenv("CI", "true")
76
assert detect_ci() == "github_actions"
77
78
79
class TestIsCI:
80
def test_false_outside_ci(self, monkeypatch):
81
_clear_ci_env(monkeypatch)
82
assert is_ci() is False
83
84
def test_true_in_ci(self, monkeypatch):
85
_clear_ci_env(monkeypatch)
86
monkeypatch.setenv("CI", "true")
87
assert is_ci() is True
88
89
90
class TestIsGitHubActions:
91
def test_false_outside_gha(self, monkeypatch):
92
_clear_ci_env(monkeypatch)
93
assert is_github_actions() is False
94
95
def test_false_for_generic_ci(self, monkeypatch):
96
_clear_ci_env(monkeypatch)
97
monkeypatch.setenv("CI", "true")
98
assert is_github_actions() is False
99
100
def test_true_for_github_actions(self, monkeypatch):
101
_clear_ci_env(monkeypatch)
102
monkeypatch.setenv("GITHUB_ACTIONS", "true")
103
assert is_github_actions() is True
104
105
106
# ── CICDReporter — exit codes ─────────────────────────────────────────────────
107
108
109
class TestExitCodes:
110
def test_success_when_clean(self):
111
r = CICDReporter()
112
assert r.exit_code() == EXIT_SUCCESS
113
114
def test_error_when_error_added(self):
115
r = CICDReporter()
116
r.add_error("something broke")
117
assert r.exit_code() == EXIT_ERROR
118
119
def test_warn_when_only_warnings(self):
120
r = CICDReporter()
121
r.add_warning("heads up")
122
assert r.exit_code() == EXIT_WARN
123
124
def test_error_takes_priority_over_warning(self):
125
r = CICDReporter()
126
r.add_warning("minor issue")
127
r.add_error("fatal issue")
128
assert r.exit_code() == EXIT_ERROR
129
130
def test_exit_code_constants(self):
131
assert EXIT_SUCCESS == 0
132
assert EXIT_ERROR == 1
133
assert EXIT_WARN == 2
134
135
136
# ── CICDReporter — JSON output ────────────────────────────────────────────────
137
138
139
class TestJSONOutput:
140
def _emit_to_str(self, reporter, monkeypatch, data=None) -> dict:
141
_clear_ci_env(monkeypatch)
142
buf = StringIO()
143
reporter.emit(data=data, file=buf)
144
return json.loads(buf.getvalue())
145
146
def test_status_success(self, monkeypatch):
147
r = CICDReporter()
148
out = self._emit_to_str(r, monkeypatch)
149
assert out["status"] == "success"
150
151
def test_status_error(self, monkeypatch):
152
r = CICDReporter()
153
r.add_error("boom")
154
out = self._emit_to_str(r, monkeypatch)
155
assert out["status"] == "error"
156
157
def test_status_warning(self, monkeypatch):
158
r = CICDReporter()
159
r.add_warning("careful")
160
out = self._emit_to_str(r, monkeypatch)
161
assert out["status"] == "warning"
162
163
def test_errors_list_in_payload(self, monkeypatch):
164
r = CICDReporter()
165
r.add_error("err1")
166
r.add_error("err2")
167
out = self._emit_to_str(r, monkeypatch)
168
assert out["errors"] == ["err1", "err2"]
169
170
def test_warnings_list_in_payload(self, monkeypatch):
171
r = CICDReporter()
172
r.add_warning("w1")
173
out = self._emit_to_str(r, monkeypatch)
174
assert out["warnings"] == ["w1"]
175
176
def test_data_included_when_provided(self, monkeypatch):
177
r = CICDReporter()
178
out = self._emit_to_str(r, monkeypatch, data={"files": 5, "functions": 20})
179
assert out["data"] == {"files": 5, "functions": 20}
180
181
def test_data_absent_when_not_provided(self, monkeypatch):
182
r = CICDReporter()
183
out = self._emit_to_str(r, monkeypatch)
184
assert "data" not in out
185
186
def test_output_is_valid_json(self, monkeypatch):
187
_clear_ci_env(monkeypatch)
188
r = CICDReporter()
189
r.add_error("oops")
190
r.add_warning("watch out")
191
buf = StringIO()
192
r.emit(data={"key": "value"}, file=buf)
193
parsed = json.loads(buf.getvalue())
194
assert isinstance(parsed, dict)
195
196
197
# ── CICDReporter — GitHub Actions annotations ─────────────────────────────────
198
199
200
class TestGitHubActionsAnnotations:
201
def test_annotations_emitted_in_gha(self, monkeypatch):
202
_clear_ci_env(monkeypatch)
203
monkeypatch.setenv("GITHUB_ACTIONS", "true")
204
205
r = CICDReporter()
206
r.add_error("bad thing")
207
r.add_warning("odd thing")
208
209
buf = StringIO()
210
r.emit(file=buf)
211
output = buf.getvalue()
212
213
assert "::error::bad thing" in output
214
assert "::warning::odd thing" in output
215
216
def test_no_annotations_outside_gha(self, monkeypatch):
217
_clear_ci_env(monkeypatch)
218
monkeypatch.setenv("CI", "true")
219
220
r = CICDReporter()
221
r.add_error("something")
222
223
buf = StringIO()
224
r.emit(file=buf)
225
output = buf.getvalue()
226
227
assert "::error::" not in output
228
229
def test_multiple_errors_all_annotated(self, monkeypatch):
230
_clear_ci_env(monkeypatch)
231
monkeypatch.setenv("GITHUB_ACTIONS", "true")
232
233
r = CICDReporter()
234
r.add_error("e1")
235
r.add_error("e2")
236
237
buf = StringIO()
238
r.emit(file=buf)
239
output = buf.getvalue()
240
241
assert "::error::e1" in output
242
assert "::error::e2" in output
243
244
245
# ── CICDReporter — GitHub Actions step summary ────────────────────────────────
246
247
248
class TestGitHubStepSummary:
249
def test_writes_summary_file(self, monkeypatch, tmp_path):
250
_clear_ci_env(monkeypatch)
251
monkeypatch.setenv("GITHUB_ACTIONS", "true")
252
summary = tmp_path / "summary.md"
253
monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary))
254
255
r = CICDReporter()
256
r.emit(data={"files": 3}, file=StringIO())
257
258
content = summary.read_text()
259
assert "Navegador" in content
260
assert "files" in content
261
262
def test_summary_includes_errors(self, monkeypatch, tmp_path):
263
_clear_ci_env(monkeypatch)
264
monkeypatch.setenv("GITHUB_ACTIONS", "true")
265
summary = tmp_path / "summary.md"
266
monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary))
267
268
r = CICDReporter()
269
r.add_error("ingest failed")
270
r.emit(file=StringIO())
271
272
content = summary.read_text()
273
assert "ingest failed" in content
274
275
def test_summary_includes_warnings(self, monkeypatch, tmp_path):
276
_clear_ci_env(monkeypatch)
277
monkeypatch.setenv("GITHUB_ACTIONS", "true")
278
summary = tmp_path / "summary.md"
279
monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary))
280
281
r = CICDReporter()
282
r.add_warning("no files found")
283
r.emit(file=StringIO())
284
285
content = summary.read_text()
286
assert "no files found" in content
287
288
def test_no_summary_without_env_var(self, monkeypatch, tmp_path):
289
_clear_ci_env(monkeypatch)
290
monkeypatch.setenv("GITHUB_ACTIONS", "true")
291
monkeypatch.delenv("GITHUB_STEP_SUMMARY", raising=False)
292
293
r = CICDReporter()
294
# Should not raise even when GITHUB_STEP_SUMMARY is absent
295
r.emit(file=StringIO())
296
297
def test_summary_appends_not_overwrites(self, monkeypatch, tmp_path):
298
_clear_ci_env(monkeypatch)
299
monkeypatch.setenv("GITHUB_ACTIONS", "true")
300
summary = tmp_path / "summary.md"
301
summary.write_text("# Previous content\n")
302
monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary))
303
304
r = CICDReporter()
305
r.emit(file=StringIO())
306
307
content = summary.read_text()
308
assert "# Previous content" in content
309
assert "Navegador" in content
310
311
def test_summary_handles_oserror_gracefully(self, monkeypatch, tmp_path):
312
_clear_ci_env(monkeypatch)
313
monkeypatch.setenv("GITHUB_ACTIONS", "true")
314
# Point to a directory instead of a file — open() will raise OSError
315
monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(tmp_path))
316
317
r = CICDReporter()
318
# Should not raise
319
r.emit(file=StringIO())
320
321
def test_annotations_default_to_stdout(self, monkeypatch, capsys):
322
_clear_ci_env(monkeypatch)
323
monkeypatch.setenv("GITHUB_ACTIONS", "true")
324
monkeypatch.delenv("GITHUB_STEP_SUMMARY", raising=False)
325
326
r = CICDReporter()
327
r.add_error("test error")
328
r._emit_github_annotations()
329
captured = capsys.readouterr()
330
assert "::error::test error" in captured.out
331
332
333
# ── CLI: navegador ci ingest ──────────────────────────────────────────────────
334
335
336
class TestCIIngestCommand:
337
def test_success_outputs_json(self, monkeypatch):
338
_clear_ci_env(monkeypatch)
339
runner = CliRunner()
340
with runner.isolated_filesystem():
341
Path("src").mkdir()
342
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \
343
patch("navegador.ingestion.RepoIngester") as MockRI:
344
MockRI.return_value.ingest.return_value = {"files": 5, "functions": 20}
345
result = runner.invoke(main, ["ci", "ingest", "src"])
346
assert result.exit_code == 0
347
payload = json.loads(result.output)
348
assert payload["status"] == "success"
349
assert payload["data"]["files"] == 5
350
351
def test_warning_when_no_files_ingested(self, monkeypatch):
352
_clear_ci_env(monkeypatch)
353
runner = CliRunner()
354
with runner.isolated_filesystem():
355
Path("src").mkdir()
356
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \
357
patch("navegador.ingestion.RepoIngester") as MockRI:
358
MockRI.return_value.ingest.return_value = {"files": 0, "functions": 0}
359
result = runner.invoke(main, ["ci", "ingest", "src"])
360
assert result.exit_code == 2
361
payload = json.loads(result.output)
362
assert payload["status"] == "warning"
363
assert payload["warnings"]
364
365
def test_error_on_ingest_exception(self, monkeypatch):
366
_clear_ci_env(monkeypatch)
367
runner = CliRunner()
368
with runner.isolated_filesystem():
369
Path("src").mkdir()
370
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \
371
patch("navegador.ingestion.RepoIngester") as MockRI:
372
MockRI.return_value.ingest.side_effect = RuntimeError("DB unavailable")
373
result = runner.invoke(main, ["ci", "ingest", "src"])
374
assert result.exit_code == 1
375
payload = json.loads(result.output)
376
assert payload["status"] == "error"
377
assert "DB unavailable" in payload["errors"][0]
378
379
def test_output_is_valid_json(self, monkeypatch):
380
_clear_ci_env(monkeypatch)
381
runner = CliRunner()
382
with runner.isolated_filesystem():
383
Path("src").mkdir()
384
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \
385
patch("navegador.ingestion.RepoIngester") as MockRI:
386
MockRI.return_value.ingest.return_value = {"files": 1}
387
result = runner.invoke(main, ["ci", "ingest", "src"])
388
parsed = json.loads(result.output)
389
assert isinstance(parsed, dict)
390
391
392
# ── CLI: navegador ci stats ───────────────────────────────────────────────────
393
394
395
class TestCIStatsCommand:
396
def _store_with_counts(self):
397
store = MagicMock()
398
399
def _query(cypher, *args, **kwargs):
400
result = MagicMock()
401
if "NODE" in cypher.upper() or "node" in cypher.lower():
402
result.result_set = [["Function", 10], ["Class", 3]]
403
else:
404
result.result_set = [["CALLS", 25]]
405
return result
406
407
store.query.side_effect = _query
408
return store
409
410
def test_outputs_json_stats(self, monkeypatch):
411
_clear_ci_env(monkeypatch)
412
runner = CliRunner()
413
with patch("navegador.cli.commands._get_store", return_value=self._store_with_counts()):
414
result = runner.invoke(main, ["ci", "stats"])
415
assert result.exit_code == 0
416
payload = json.loads(result.output)
417
assert payload["status"] == "success"
418
assert "data" in payload
419
assert "total_nodes" in payload["data"]
420
assert "total_edges" in payload["data"]
421
422
def test_error_on_store_failure(self, monkeypatch):
423
_clear_ci_env(monkeypatch)
424
runner = CliRunner()
425
with patch("navegador.cli.commands._get_store", side_effect=RuntimeError("no db")):
426
result = runner.invoke(main, ["ci", "stats"])
427
assert result.exit_code == 1
428
payload = json.loads(result.output)
429
assert payload["status"] == "error"
430
431
432
# ── CLI: navegador ci check ───────────────────────────────────────────────────
433
434
435
class TestCICheckCommand:
436
def test_success_when_schema_current(self, monkeypatch):
437
_clear_ci_env(monkeypatch)
438
from navegador.graph.migrations import CURRENT_SCHEMA_VERSION
439
440
store = MagicMock()
441
store.query.return_value = MagicMock(result_set=[[CURRENT_SCHEMA_VERSION]])
442
443
runner = CliRunner()
444
with patch("navegador.cli.commands._get_store", return_value=store):
445
result = runner.invoke(main, ["ci", "check"])
446
assert result.exit_code == 0
447
payload = json.loads(result.output)
448
assert payload["status"] == "success"
449
assert payload["data"]["schema_version"] == CURRENT_SCHEMA_VERSION
450
451
def test_warning_when_migration_needed(self, monkeypatch):
452
_clear_ci_env(monkeypatch)
453
store = MagicMock()
454
store.query.return_value = MagicMock(result_set=[[0]])
455
456
runner = CliRunner()
457
with patch("navegador.cli.commands._get_store", return_value=store):
458
result = runner.invoke(main, ["ci", "check"])
459
assert result.exit_code == 2
460
payload = json.loads(result.output)
461
assert payload["status"] == "warning"
462
assert payload["warnings"]
463
464
def test_error_on_store_failure(self, monkeypatch):
465
_clear_ci_env(monkeypatch)
466
runner = CliRunner()
467
with patch("navegador.cli.commands._get_store", side_effect=RuntimeError("no db")):
468
result = runner.invoke(main, ["ci", "check"])
469
assert result.exit_code == 1
470
payload = json.loads(result.output)
471
assert payload["status"] == "error"
472
473
def test_payload_includes_version_info(self, monkeypatch):
474
_clear_ci_env(monkeypatch)
475
from navegador.graph.migrations import CURRENT_SCHEMA_VERSION
476
477
store = MagicMock()
478
store.query.return_value = MagicMock(result_set=[[CURRENT_SCHEMA_VERSION]])
479
480
runner = CliRunner()
481
with patch("navegador.cli.commands._get_store", return_value=store):
482
result = runner.invoke(main, ["ci", "check"])
483
payload = json.loads(result.output)
484
assert "schema_version" in payload["data"]
485
assert "current_schema_version" in payload["data"]
486

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button