|
4 | 4 | import json |
5 | 5 | import os |
6 | 6 | import subprocess |
| 7 | +from dataclasses import dataclass, field |
7 | 8 | from pathlib import Path |
8 | 9 |
|
| 10 | +import pytest |
| 11 | + |
9 | 12 | SCRIPT_PATH = Path(__file__).resolve().parent.parent.parent / "scripts" / "discover-repos.sh" |
10 | 13 |
|
11 | 14 |
|
@@ -88,97 +91,160 @@ def _make_repo(parent: Path, name: str, identity: dict | None = None) -> Path: |
88 | 91 | return repo |
89 | 92 |
|
90 | 93 |
|
91 | | -def test_tier0_add_dir_appears_in_peers(tmp_path: Path) -> None: |
92 | | - """A path in CLOSEDLOOP_ADD_DIRS should appear in peers with discoveryMethod add_dir.""" |
93 | | - current = _make_repo(tmp_path, "current") |
94 | | - extra = _make_repo(tmp_path, "extra", {"name": "extra-svc", "type": "service"}) |
95 | | - |
96 | | - result = _run_discover_with_env(current, {"CLOSEDLOOP_ADD_DIRS": str(extra)}) |
97 | | - |
98 | | - assert result.returncode == 0, result.stderr |
99 | | - payload = json.loads(result.stdout) |
100 | | - peer_paths = {p["path"] for p in payload["peers"]} |
101 | | - assert str(extra) in peer_paths |
102 | | - |
103 | | - extra_peer = next(p for p in payload["peers"] if p["path"] == str(extra)) |
104 | | - assert extra_peer["discoveryMethod"] == "add_dir" |
105 | | - assert extra_peer["name"] == "extra-svc" |
106 | | - assert extra_peer["type"] == "service" |
107 | | - |
108 | | - |
109 | | -def test_tier0_add_dir_falls_back_to_basename_without_identity(tmp_path: Path) -> None: |
110 | | - """Tier 0 peer with no identity file should use the directory basename as name.""" |
111 | | - current = _make_repo(tmp_path, "current") |
112 | | - anon = _make_repo(tmp_path, "my-anon-repo") |
113 | | - |
114 | | - result = _run_discover_with_env(current, {"CLOSEDLOOP_ADD_DIRS": str(anon)}) |
115 | | - |
116 | | - assert result.returncode == 0, result.stderr |
117 | | - payload = json.loads(result.stdout) |
118 | | - peer = next((p for p in payload["peers"] if p["path"] == str(anon)), None) |
119 | | - assert peer is not None, f"Expected peer for {anon}, got {payload['peers']}" |
120 | | - assert peer["name"] == "my-anon-repo" |
121 | | - |
122 | | - |
123 | | -def test_tier0_multiple_add_dirs_pipe_separated(tmp_path: Path) -> None: |
124 | | - """Multiple pipe-separated paths in CLOSEDLOOP_ADD_DIRS should all appear as peers.""" |
125 | | - current = _make_repo(tmp_path, "current") |
126 | | - repo_a = _make_repo(tmp_path, "repo-a") |
127 | | - repo_b = _make_repo(tmp_path, "repo-b") |
128 | | - |
129 | | - add_dirs = f"{repo_a}|{repo_b}" |
130 | | - result = _run_discover_with_env(current, {"CLOSEDLOOP_ADD_DIRS": add_dirs}) |
131 | | - |
132 | | - assert result.returncode == 0, result.stderr |
133 | | - payload = json.loads(result.stdout) |
134 | | - peer_paths = {p["path"] for p in payload["peers"]} |
135 | | - assert str(repo_a) in peer_paths |
136 | | - assert str(repo_b) in peer_paths |
137 | | - |
138 | | - |
139 | | -def test_tier0_skips_current_repo(tmp_path: Path) -> None: |
140 | | - """A CLOSEDLOOP_ADD_DIRS entry equal to the current repo path should be skipped.""" |
141 | | - current = _make_repo(tmp_path, "current") |
142 | | - |
143 | | - result = _run_discover_with_env(current, {"CLOSEDLOOP_ADD_DIRS": str(current)}) |
144 | | - |
145 | | - assert result.returncode == 0, result.stderr |
146 | | - payload = json.loads(result.stdout) |
147 | | - peer_paths = [p["path"] for p in payload["peers"]] |
148 | | - assert str(current) not in peer_paths, f"Current repo should not appear in peers: {peer_paths}" |
149 | | - |
150 | | - |
151 | | -def test_tier0_deduplicates_with_tier2_sibling_scan(tmp_path: Path) -> None: |
152 | | - """A sibling that is also in CLOSEDLOOP_ADD_DIRS should appear only once in peers.""" |
153 | | - workspace = tmp_path / "workspace" |
154 | | - current = _make_repo(workspace, "current", {"name": "current", "type": "service"}) |
155 | | - sibling = _make_repo( |
156 | | - workspace, "sibling-svc", {"name": "sibling-svc", "type": "library", "discoverable": True} |
157 | | - ) |
| 94 | +# --------------------------------------------------------------------------- |
| 95 | +# Tier 0 harness: scenarios are declarative — one test drives them all. |
| 96 | +# |
| 97 | +# Each Tier0Scenario builds a set of repos under a temp dir, runs |
| 98 | +# discover-repos.sh with CLOSEDLOOP_ADD_DIRS derived from scenario keys, and |
| 99 | +# validates the peer list against declarative PeerExpect entries. |
| 100 | +# --------------------------------------------------------------------------- |
158 | 101 |
|
159 | | - # The sibling is both a Tier 0 add-dir AND a Tier 2 sibling |
160 | | - result = _run_discover_with_env(current, {"CLOSEDLOOP_ADD_DIRS": str(sibling)}) |
161 | 102 |
|
| 103 | +# Sentinel used in `add_dir_keys` to reference the current repo's own path. |
| 104 | +_CURRENT = "__current__" |
| 105 | + |
| 106 | + |
| 107 | +@dataclass(frozen=True) |
| 108 | +class RepoSpec: |
| 109 | + """Declarative description of a repo to create on disk for a scenario.""" |
| 110 | + |
| 111 | + key: str # identifier used to reference the repo within the scenario |
| 112 | + dirname: str # directory name under the scenario root |
| 113 | + identity: dict | None = None # contents of .closedloop-ai/.repo-identity.json, or None to skip |
| 114 | + is_current: bool = False # exactly one RepoSpec per scenario must set this |
| 115 | + |
| 116 | + |
| 117 | +@dataclass(frozen=True) |
| 118 | +class PeerExpect: |
| 119 | + """Declarative assertion over a peer entry in the discover-repos.sh output.""" |
| 120 | + |
| 121 | + key: str # references a RepoSpec.key in the same scenario |
| 122 | + count: int = 1 # expected number of peer entries with this repo's path |
| 123 | + discovery_method: str | None = None |
| 124 | + name: str | None = None |
| 125 | + type: str | None = None |
| 126 | + |
| 127 | + |
| 128 | +@dataclass(frozen=True) |
| 129 | +class Tier0Scenario: |
| 130 | + id: str |
| 131 | + repos: tuple[RepoSpec, ...] |
| 132 | + add_dir_keys: tuple[str, ...] # repo keys (or _CURRENT) to join into CLOSEDLOOP_ADD_DIRS |
| 133 | + workspace_subdir: bool = False # place repos under tmp_path/workspace/ (enables Tier 2 sibling scan) |
| 134 | + expect_peers: tuple[PeerExpect, ...] = field(default_factory=tuple) |
| 135 | + forbidden_keys: tuple[str, ...] = field(default_factory=tuple) # repo keys that must NOT appear as peers |
| 136 | + |
| 137 | + |
| 138 | +TIER0_SCENARIOS: tuple[Tier0Scenario, ...] = ( |
| 139 | + Tier0Scenario( |
| 140 | + id="add_dir_appears_in_peers", |
| 141 | + repos=( |
| 142 | + RepoSpec("current", "current", is_current=True), |
| 143 | + RepoSpec("extra", "extra", identity={"name": "extra-svc", "type": "service"}), |
| 144 | + ), |
| 145 | + add_dir_keys=("extra",), |
| 146 | + expect_peers=( |
| 147 | + PeerExpect("extra", discovery_method="add_dir", name="extra-svc", type="service"), |
| 148 | + ), |
| 149 | + ), |
| 150 | + Tier0Scenario( |
| 151 | + id="basename_fallback_without_identity", |
| 152 | + repos=( |
| 153 | + RepoSpec("current", "current", is_current=True), |
| 154 | + RepoSpec("anon", "my-anon-repo"), # no identity → name falls back to basename |
| 155 | + ), |
| 156 | + add_dir_keys=("anon",), |
| 157 | + expect_peers=(PeerExpect("anon", name="my-anon-repo"),), |
| 158 | + ), |
| 159 | + Tier0Scenario( |
| 160 | + id="multiple_add_dirs_pipe_separated", |
| 161 | + repos=( |
| 162 | + RepoSpec("current", "current", is_current=True), |
| 163 | + RepoSpec("a", "repo-a"), |
| 164 | + RepoSpec("b", "repo-b"), |
| 165 | + ), |
| 166 | + add_dir_keys=("a", "b"), |
| 167 | + expect_peers=(PeerExpect("a"), PeerExpect("b")), |
| 168 | + ), |
| 169 | + Tier0Scenario( |
| 170 | + id="skips_current_repo", |
| 171 | + repos=(RepoSpec("current", "current", is_current=True),), |
| 172 | + add_dir_keys=(_CURRENT,), |
| 173 | + forbidden_keys=("current",), |
| 174 | + ), |
| 175 | + # A sibling that is ALSO listed in CLOSEDLOOP_ADD_DIRS must appear exactly |
| 176 | + # once AND be marked `add_dir` (Tier 0 wins over Tier 2 sibling scan). |
| 177 | + Tier0Scenario( |
| 178 | + id="add_dir_wins_over_sibling_scan", |
| 179 | + workspace_subdir=True, |
| 180 | + repos=( |
| 181 | + RepoSpec("current", "current", identity={"name": "current", "type": "service"}, is_current=True), |
| 182 | + RepoSpec( |
| 183 | + "sibling", |
| 184 | + "sibling-svc", |
| 185 | + identity={"name": "sibling-svc", "type": "library", "discoverable": True}, |
| 186 | + ), |
| 187 | + ), |
| 188 | + add_dir_keys=("sibling",), |
| 189 | + expect_peers=(PeerExpect("sibling", count=1, discovery_method="add_dir"),), |
| 190 | + ), |
| 191 | +) |
| 192 | + |
| 193 | + |
| 194 | +@pytest.mark.parametrize("scenario", TIER0_SCENARIOS, ids=lambda s: s.id) |
| 195 | +def test_tier0_add_dirs(tmp_path: Path, scenario: Tier0Scenario) -> None: |
| 196 | + """Drives every Tier 0 scenario through a single harness. |
| 197 | +
|
| 198 | + Build repos, invoke discover-repos.sh with the scenario's CLOSEDLOOP_ADD_DIRS, |
| 199 | + then validate peer count and per-field attributes declaratively. |
| 200 | + """ |
| 201 | + # 1. Materialize repos on disk |
| 202 | + root = tmp_path / "workspace" if scenario.workspace_subdir else tmp_path |
| 203 | + paths: dict[str, Path] = { |
| 204 | + spec.key: _make_repo(root, spec.dirname, spec.identity) for spec in scenario.repos |
| 205 | + } |
| 206 | + current_specs = [s for s in scenario.repos if s.is_current] |
| 207 | + assert len(current_specs) == 1, f"Scenario {scenario.id!r} must declare exactly one current repo" |
| 208 | + current_path = paths[current_specs[0].key] |
| 209 | + |
| 210 | + # 2. Build CLOSEDLOOP_ADD_DIRS, resolving _CURRENT sentinel against the current repo |
| 211 | + def _resolve(key: str) -> Path: |
| 212 | + return current_path if key == _CURRENT else paths[key] |
| 213 | + |
| 214 | + add_dirs = "|".join(str(_resolve(k)) for k in scenario.add_dir_keys) |
| 215 | + |
| 216 | + # 3. Invoke the script |
| 217 | + result = _run_discover_with_env(current_path, {"CLOSEDLOOP_ADD_DIRS": add_dirs}) |
162 | 218 | assert result.returncode == 0, result.stderr |
163 | 219 | payload = json.loads(result.stdout) |
164 | | - paths = [p["path"] for p in payload["peers"]] |
165 | | - assert paths.count(str(sibling)) == 1, ( |
166 | | - f"Sibling should appear exactly once; got peers: {payload['peers']}" |
167 | | - ) |
168 | | - |
169 | | - |
170 | | -def test_tier0_peer_marked_as_add_dir_not_sibling_scan(tmp_path: Path) -> None: |
171 | | - """When a sibling is in Tier 0, the peer's discoveryMethod must be 'add_dir', not sibling_scan.""" |
172 | | - workspace = tmp_path / "workspace" |
173 | | - current = _make_repo(workspace, "current", {"name": "current", "type": "service"}) |
174 | | - sibling = _make_repo( |
175 | | - workspace, "shared-lib", {"name": "shared-lib", "type": "library", "discoverable": True} |
176 | | - ) |
177 | | - |
178 | | - result = _run_discover_with_env(current, {"CLOSEDLOOP_ADD_DIRS": str(sibling)}) |
| 220 | + peers = payload["peers"] |
| 221 | + |
| 222 | + # 4. Forbidden paths must not appear at all |
| 223 | + peer_paths = [p["path"] for p in peers] |
| 224 | + for key in scenario.forbidden_keys: |
| 225 | + forbidden = str(paths[key]) |
| 226 | + assert forbidden not in peer_paths, ( |
| 227 | + f"[{scenario.id}] {key!r} should not appear in peers; got: {peer_paths}" |
| 228 | + ) |
179 | 229 |
|
180 | | - assert result.returncode == 0, result.stderr |
181 | | - payload = json.loads(result.stdout) |
182 | | - peer = next((p for p in payload["peers"] if p["path"] == str(sibling)), None) |
183 | | - assert peer is not None |
184 | | - assert peer["discoveryMethod"] == "add_dir" |
| 230 | + # 5. Each expectation: check occurrence count and per-field attributes |
| 231 | + for exp in scenario.expect_peers: |
| 232 | + target = str(paths[exp.key]) |
| 233 | + matches = [p for p in peers if p["path"] == target] |
| 234 | + assert len(matches) == exp.count, ( |
| 235 | + f"[{scenario.id}] expected {exp.count} peer(s) for {exp.key!r}, " |
| 236 | + f"got {len(matches)}; peers={peers}" |
| 237 | + ) |
| 238 | + if exp.count == 0: |
| 239 | + continue |
| 240 | + peer = matches[0] |
| 241 | + for attr, field_name in ( |
| 242 | + (exp.discovery_method, "discoveryMethod"), |
| 243 | + (exp.name, "name"), |
| 244 | + (exp.type, "type"), |
| 245 | + ): |
| 246 | + if attr is not None: |
| 247 | + assert peer.get(field_name) == attr, ( |
| 248 | + f"[{scenario.id}] peer {exp.key!r} {field_name}: " |
| 249 | + f"expected {attr!r}, got {peer.get(field_name)!r}" |
| 250 | + ) |
0 commit comments