Navegador

navegador / tests / test_monorepo.py
Blame History Raw 479 lines
1
"""Tests for navegador.monorepo — WorkspaceDetector, MonorepoIngester, CLI flag."""
2
3
import json
4
import tempfile
5
from pathlib import Path
6
from unittest.mock import MagicMock, call, patch
7
8
import pytest
9
from click.testing import CliRunner
10
11
from navegador.cli.commands import main
12
from navegador.monorepo import MonorepoIngester, WorkspaceConfig, WorkspaceDetector
13
14
15
# ── Helpers ───────────────────────────────────────────────────────────────────
16
17
18
def _mock_store():
19
store = MagicMock()
20
store.query.return_value = MagicMock(result_set=[])
21
return store
22
23
24
def _write(path: Path, content: str) -> None:
25
path.parent.mkdir(parents=True, exist_ok=True)
26
path.write_text(content, encoding="utf-8")
27
28
29
# ── WorkspaceDetector — positive cases ────────────────────────────────────────
30
31
32
class TestWorkspaceDetectorTurborepo:
33
def test_detects_type(self, tmp_path):
34
_write(tmp_path / "turbo.json", '{"pipeline": {}}')
35
# No package.json workspaces — fallback scan
36
(tmp_path / "packages" / "app").mkdir(parents=True)
37
_write(tmp_path / "packages" / "app" / "package.json", '{"name": "app"}')
38
config = WorkspaceDetector().detect(tmp_path)
39
assert config is not None
40
assert config.type == "turborepo"
41
42
def test_root_is_resolved(self, tmp_path):
43
_write(tmp_path / "turbo.json", '{"pipeline": {}}')
44
config = WorkspaceDetector().detect(tmp_path)
45
assert config.root == tmp_path.resolve()
46
47
def test_uses_package_json_workspaces(self, tmp_path):
48
_write(tmp_path / "turbo.json", '{}')
49
_write(
50
tmp_path / "package.json",
51
json.dumps({"workspaces": ["packages/*"]}),
52
)
53
(tmp_path / "packages" / "alpha").mkdir(parents=True)
54
(tmp_path / "packages" / "beta").mkdir(parents=True)
55
config = WorkspaceDetector().detect(tmp_path)
56
assert config is not None
57
names = [p.name for p in config.packages]
58
assert "alpha" in names
59
assert "beta" in names
60
61
def test_name_defaults_to_dirname(self, tmp_path):
62
_write(tmp_path / "turbo.json", '{}')
63
config = WorkspaceDetector().detect(tmp_path)
64
assert config.name == tmp_path.name
65
66
67
class TestWorkspaceDetectorNx:
68
def test_detects_type(self, tmp_path):
69
_write(tmp_path / "nx.json", '{}')
70
config = WorkspaceDetector().detect(tmp_path)
71
assert config is not None
72
assert config.type == "nx"
73
74
def test_finds_apps_and_libs(self, tmp_path):
75
_write(tmp_path / "nx.json", '{}')
76
(tmp_path / "apps" / "web").mkdir(parents=True)
77
(tmp_path / "libs" / "ui").mkdir(parents=True)
78
config = WorkspaceDetector().detect(tmp_path)
79
names = [p.name for p in config.packages]
80
assert "web" in names
81
assert "ui" in names
82
83
def test_empty_nx_has_no_packages(self, tmp_path):
84
_write(tmp_path / "nx.json", '{}')
85
config = WorkspaceDetector().detect(tmp_path)
86
# No apps/libs dirs — falls back to package.json scan which also finds nothing
87
assert config is not None
88
assert config.packages == []
89
90
91
class TestWorkspaceDetectorPnpm:
92
def test_detects_type(self, tmp_path):
93
_write(
94
tmp_path / "pnpm-workspace.yaml",
95
"packages:\n - 'packages/*'\n",
96
)
97
(tmp_path / "packages" / "foo").mkdir(parents=True)
98
config = WorkspaceDetector().detect(tmp_path)
99
assert config is not None
100
assert config.type == "pnpm"
101
102
def test_resolves_glob_packages(self, tmp_path):
103
_write(
104
tmp_path / "pnpm-workspace.yaml",
105
"packages:\n - 'pkgs/*'\n",
106
)
107
(tmp_path / "pkgs" / "core").mkdir(parents=True)
108
(tmp_path / "pkgs" / "utils").mkdir(parents=True)
109
config = WorkspaceDetector().detect(tmp_path)
110
names = [p.name for p in config.packages]
111
assert "core" in names
112
assert "utils" in names
113
114
def test_empty_yaml_returns_fallback(self, tmp_path):
115
_write(tmp_path / "pnpm-workspace.yaml", "")
116
config = WorkspaceDetector().detect(tmp_path)
117
assert config is not None
118
assert config.type == "pnpm"
119
120
121
class TestWorkspaceDetectorYarn:
122
def test_detects_type(self, tmp_path):
123
_write(
124
tmp_path / "package.json",
125
json.dumps({"name": "root", "workspaces": ["packages/*"]}),
126
)
127
(tmp_path / "packages" / "a").mkdir(parents=True)
128
config = WorkspaceDetector().detect(tmp_path)
129
assert config is not None
130
assert config.type == "yarn"
131
132
def test_yarn_berry_workspaces_packages_key(self, tmp_path):
133
_write(
134
tmp_path / "package.json",
135
json.dumps({"workspaces": {"packages": ["apps/*"]}}),
136
)
137
(tmp_path / "apps" / "web").mkdir(parents=True)
138
config = WorkspaceDetector().detect(tmp_path)
139
assert config is not None
140
assert config.type == "yarn"
141
assert any(p.name == "web" for p in config.packages)
142
143
def test_explicit_package_path(self, tmp_path):
144
pkg_dir = tmp_path / "my-package"
145
pkg_dir.mkdir()
146
_write(
147
tmp_path / "package.json",
148
json.dumps({"workspaces": ["my-package"]}),
149
)
150
config = WorkspaceDetector().detect(tmp_path)
151
assert config is not None
152
assert any(p.name == "my-package" for p in config.packages)
153
154
155
class TestWorkspaceDetectorCargo:
156
def test_detects_type(self, tmp_path):
157
_write(
158
tmp_path / "Cargo.toml",
159
'[workspace]\nmembers = ["crates/core", "crates/utils"]\n',
160
)
161
(tmp_path / "crates" / "core").mkdir(parents=True)
162
(tmp_path / "crates" / "utils").mkdir(parents=True)
163
config = WorkspaceDetector().detect(tmp_path)
164
assert config is not None
165
assert config.type == "cargo"
166
167
def test_resolves_member_paths(self, tmp_path):
168
_write(
169
tmp_path / "Cargo.toml",
170
'[workspace]\nmembers = ["crates/alpha"]\n',
171
)
172
(tmp_path / "crates" / "alpha").mkdir(parents=True)
173
config = WorkspaceDetector().detect(tmp_path)
174
assert any(p.name == "alpha" for p in config.packages)
175
176
def test_non_workspace_cargo_returns_none(self, tmp_path):
177
_write(
178
tmp_path / "Cargo.toml",
179
'[package]\nname = "myapp"\nversion = "0.1.0"\n',
180
)
181
config = WorkspaceDetector().detect(tmp_path)
182
assert config is None
183
184
185
class TestWorkspaceDetectorGo:
186
def test_detects_type(self, tmp_path):
187
_write(tmp_path / "go.work", "go 1.21\nuse ./api\nuse ./worker\n")
188
(tmp_path / "api").mkdir()
189
(tmp_path / "worker").mkdir()
190
config = WorkspaceDetector().detect(tmp_path)
191
assert config is not None
192
assert config.type == "go"
193
194
def test_resolves_use_paths(self, tmp_path):
195
_write(tmp_path / "go.work", "go 1.21\nuse ./pkg/a\n")
196
(tmp_path / "pkg" / "a").mkdir(parents=True)
197
config = WorkspaceDetector().detect(tmp_path)
198
assert any(p.name == "a" for p in config.packages)
199
200
def test_missing_dirs_skipped(self, tmp_path):
201
_write(tmp_path / "go.work", "go 1.21\nuse ./missing\n")
202
config = WorkspaceDetector().detect(tmp_path)
203
assert config is not None
204
assert config.packages == []
205
206
207
# ── WorkspaceDetector — negative case ────────────────────────────────────────
208
209
210
class TestWorkspaceDetectorNoWorkspace:
211
def test_plain_repo_returns_none(self, tmp_path):
212
# Just a bare directory — no workspace config files
213
_write(tmp_path / "main.py", "print('hello')")
214
config = WorkspaceDetector().detect(tmp_path)
215
assert config is None
216
217
def test_package_json_without_workspaces_returns_none(self, tmp_path):
218
_write(tmp_path / "package.json", '{"name": "single-package"}')
219
config = WorkspaceDetector().detect(tmp_path)
220
assert config is None
221
222
def test_empty_directory_returns_none(self, tmp_path):
223
config = WorkspaceDetector().detect(tmp_path)
224
assert config is None
225
226
227
# ── WorkspaceConfig ───────────────────────────────────────────────────────────
228
229
230
class TestWorkspaceConfig:
231
def test_name_defaults_to_root_dirname(self, tmp_path):
232
cfg = WorkspaceConfig(type="yarn", root=tmp_path, packages=[])
233
assert cfg.name == tmp_path.name
234
235
def test_explicit_name_preserved(self, tmp_path):
236
cfg = WorkspaceConfig(type="yarn", root=tmp_path, packages=[], name="my-repo")
237
assert cfg.name == "my-repo"
238
239
def test_packages_list_stored(self, tmp_path):
240
pkgs = [tmp_path / "a", tmp_path / "b"]
241
cfg = WorkspaceConfig(type="pnpm", root=tmp_path, packages=pkgs)
242
assert cfg.packages == pkgs
243
244
245
# ── MonorepoIngester ──────────────────────────────────────────────────────────
246
247
248
class TestMonorepoIngesterFallback:
249
def test_no_workspace_falls_back_to_single_ingest(self, tmp_path):
250
"""When no workspace config is detected, ingest as a regular repo."""
251
store = _mock_store()
252
_write(tmp_path / "main.py", "x = 1")
253
254
with patch(
255
"navegador.monorepo.WorkspaceDetector.detect", return_value=None
256
), patch("navegador.monorepo.RepoIngester") as MockRI:
257
MockRI.return_value.ingest.return_value = {
258
"files": 1, "functions": 0, "classes": 0, "edges": 0, "skipped": 0
259
}
260
ingester = MonorepoIngester(store)
261
stats = ingester.ingest(tmp_path)
262
263
assert stats["packages"] == 0
264
assert stats["workspace_type"] == "none"
265
MockRI.return_value.ingest.assert_called_once()
266
267
def test_raises_on_missing_path(self):
268
store = _mock_store()
269
ingester = MonorepoIngester(store)
270
with pytest.raises(FileNotFoundError):
271
ingester.ingest("/this/does/not/exist")
272
273
274
class TestMonorepoIngesterWithWorkspace:
275
def _setup_yarn_monorepo(self, tmp_path):
276
"""Create a minimal Yarn workspace fixture."""
277
_write(
278
tmp_path / "package.json",
279
json.dumps({"name": "root", "workspaces": ["packages/*"]}),
280
)
281
(tmp_path / "packages" / "app").mkdir(parents=True)
282
(tmp_path / "packages" / "lib").mkdir(parents=True)
283
_write(
284
tmp_path / "packages" / "app" / "package.json",
285
json.dumps({"name": "app", "dependencies": {"lib": "*"}}),
286
)
287
_write(
288
tmp_path / "packages" / "lib" / "package.json",
289
json.dumps({"name": "lib"}),
290
)
291
292
def test_creates_root_repository_node(self, tmp_path):
293
self._setup_yarn_monorepo(tmp_path)
294
store = _mock_store()
295
296
with patch("navegador.monorepo.RepoIngester") as MockRI:
297
MockRI.return_value.ingest.return_value = {
298
"files": 0, "functions": 0, "classes": 0, "edges": 0, "skipped": 0
299
}
300
MonorepoIngester(store).ingest(tmp_path)
301
302
# Root Repository node must have been created
303
create_node_calls = store.create_node.call_args_list
304
labels = [c[0][0] for c in create_node_calls]
305
from navegador.graph.schema import NodeLabel
306
assert NodeLabel.Repository in labels
307
308
def test_ingest_called_per_package(self, tmp_path):
309
self._setup_yarn_monorepo(tmp_path)
310
store = _mock_store()
311
312
with patch("navegador.monorepo.RepoIngester") as MockRI:
313
MockRI.return_value.ingest.return_value = {
314
"files": 2, "functions": 3, "classes": 1, "edges": 1, "skipped": 0
315
}
316
stats = MonorepoIngester(store).ingest(tmp_path)
317
318
# Two packages → ingest called twice
319
assert MockRI.return_value.ingest.call_count == 2
320
assert stats["packages"] == 2
321
322
def test_aggregates_stats(self, tmp_path):
323
self._setup_yarn_monorepo(tmp_path)
324
store = _mock_store()
325
326
with patch("navegador.monorepo.RepoIngester") as MockRI:
327
MockRI.return_value.ingest.return_value = {
328
"files": 3, "functions": 5, "classes": 2, "edges": 4, "skipped": 0
329
}
330
stats = MonorepoIngester(store).ingest(tmp_path)
331
332
# 2 packages × per-package values
333
assert stats["files"] == 6
334
assert stats["functions"] == 10
335
assert stats["workspace_type"] == "yarn"
336
337
def test_clear_calls_store_clear(self, tmp_path):
338
self._setup_yarn_monorepo(tmp_path)
339
store = _mock_store()
340
341
with patch("navegador.monorepo.RepoIngester") as MockRI:
342
MockRI.return_value.ingest.return_value = {
343
"files": 0, "functions": 0, "classes": 0, "edges": 0, "skipped": 0
344
}
345
MonorepoIngester(store).ingest(tmp_path, clear=True)
346
347
store.clear.assert_called_once()
348
349
def test_depends_on_edges_created_for_sibling_deps(self, tmp_path):
350
"""app depends on lib — a DEPENDS_ON edge should be created."""
351
self._setup_yarn_monorepo(tmp_path)
352
store = _mock_store()
353
354
with patch("navegador.monorepo.RepoIngester") as MockRI:
355
MockRI.return_value.ingest.return_value = {
356
"files": 0, "functions": 0, "classes": 0, "edges": 0, "skipped": 0
357
}
358
MonorepoIngester(store).ingest(tmp_path)
359
360
from navegador.graph.schema import EdgeType
361
edge_calls = store.create_edge.call_args_list
362
depends_on_edges = [
363
c for c in edge_calls
364
if c[1].get("edge_type") == EdgeType.DEPENDS_ON
365
or (len(c[0]) > 2 and c[0][2] == EdgeType.DEPENDS_ON)
366
]
367
# At minimum one DEPENDS_ON call should have been attempted
368
# (exact count depends on resolution; we verify the mechanism fired)
369
assert store.create_edge.called
370
371
372
class TestMonorepoIngesterCargo:
373
def test_cargo_workspace_type(self, tmp_path):
374
_write(
375
tmp_path / "Cargo.toml",
376
'[workspace]\nmembers = ["crates/core"]\n',
377
)
378
(tmp_path / "crates" / "core").mkdir(parents=True)
379
store = _mock_store()
380
381
with patch("navegador.monorepo.RepoIngester") as MockRI:
382
MockRI.return_value.ingest.return_value = {
383
"files": 0, "functions": 0, "classes": 0, "edges": 0, "skipped": 0
384
}
385
stats = MonorepoIngester(store).ingest(tmp_path)
386
387
assert stats["workspace_type"] == "cargo"
388
assert stats["packages"] == 1
389
390
391
class TestMonorepoIngesterGo:
392
def test_go_workspace_type(self, tmp_path):
393
(tmp_path / "svc").mkdir()
394
_write(tmp_path / "go.work", "go 1.21\nuse ./svc\n")
395
store = _mock_store()
396
397
with patch("navegador.monorepo.RepoIngester") as MockRI:
398
MockRI.return_value.ingest.return_value = {
399
"files": 0, "functions": 0, "classes": 0, "edges": 0, "skipped": 0
400
}
401
stats = MonorepoIngester(store).ingest(tmp_path)
402
403
assert stats["workspace_type"] == "go"
404
assert stats["packages"] == 1
405
406
407
# ── CLI flag ──────────────────────────────────────────────────────────────────
408
409
410
class TestIngestMonorepoFlag:
411
def _mock_store_fn(self):
412
return _mock_store()
413
414
def test_monorepo_flag_calls_monorepo_ingester(self, tmp_path):
415
runner = CliRunner()
416
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \
417
patch("navegador.monorepo.MonorepoIngester") as MockMI:
418
MockMI.return_value.ingest.return_value = {
419
"files": 4, "functions": 10, "classes": 2,
420
"edges": 3, "skipped": 0, "packages": 2, "workspace_type": "yarn"
421
}
422
result = runner.invoke(main, ["ingest", str(tmp_path), "--monorepo"])
423
424
assert result.exit_code == 0
425
MockMI.return_value.ingest.assert_called_once()
426
427
def test_monorepo_flag_with_json_output(self, tmp_path):
428
runner = CliRunner()
429
expected = {
430
"files": 4, "functions": 10, "classes": 2,
431
"edges": 3, "skipped": 0, "packages": 2, "workspace_type": "pnpm"
432
}
433
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \
434
patch("navegador.monorepo.MonorepoIngester") as MockMI:
435
MockMI.return_value.ingest.return_value = expected
436
result = runner.invoke(
437
main, ["ingest", str(tmp_path), "--monorepo", "--json"]
438
)
439
440
assert result.exit_code == 0
441
data = json.loads(result.output)
442
assert data["packages"] == 2
443
assert data["workspace_type"] == "pnpm"
444
445
def test_monorepo_flag_passes_clear(self, tmp_path):
446
runner = CliRunner()
447
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \
448
patch("navegador.monorepo.MonorepoIngester") as MockMI:
449
MockMI.return_value.ingest.return_value = {
450
"files": 0, "functions": 0, "classes": 0,
451
"edges": 0, "skipped": 0, "packages": 0, "workspace_type": "none"
452
}
453
result = runner.invoke(
454
main, ["ingest", str(tmp_path), "--monorepo", "--clear"]
455
)
456
457
assert result.exit_code == 0
458
_, kwargs = MockMI.return_value.ingest.call_args
459
assert kwargs.get("clear") is True
460
461
def test_without_monorepo_flag_uses_repo_ingester(self, tmp_path):
462
"""Sanity: the regular ingest path is not affected by the new flag."""
463
runner = CliRunner()
464
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \
465
patch("navegador.ingestion.RepoIngester") as MockRI:
466
MockRI.return_value.ingest.return_value = {
467
"files": 1, "functions": 2, "classes": 0, "edges": 0, "skipped": 0
468
}
469
result = runner.invoke(main, ["ingest", str(tmp_path)])
470
471
assert result.exit_code == 0
472
MockRI.return_value.ingest.assert_called_once()
473
474
def test_monorepo_flag_help_text(self):
475
runner = CliRunner()
476
result = runner.invoke(main, ["ingest", "--help"])
477
assert result.exit_code == 0
478
assert "--monorepo" in result.output
479

Keyboard Shortcuts

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