Skip to content

Commit eed618e

Browse files
illeatmyhatclaude
andcommitted
test(plugin-source): redesign build pipeline tests + ship smoke harness
The previous TestRender / TestCheckDrift suite had two brittle patterns that caused the CI failures from commit fc94634: - Iterated `manifest.files × entry.platforms` and asserted every combo was emitted, without honoring `cfg.excludes(...)`. Failed the moment any platform had a non-empty `target_excludes`. - Picked the alphabetically-first verbatim entry × first platform for drift detection, so when that file happened to be excluded for that platform, the perturbation landed in a path check_drift skipped and no `drift:` message was emitted. Redesign: - Shared `isolated_repo` / `rendered_repo` fixtures monkeypatch REPO_ROOT and PLUGIN_SOURCE_DIR so render / check operate against an isolated tmp tree. - Headline invariant test: render then check is silent and returns 0. - Property tests: render is idempotent; render wipes orphans under each plugin_root. - Iteration tests now honor `cfg.excludes(...)` so reintroducing an exclude can't silently break the contract. - Drift tests address files by name (claude learn/SKILL.md, claude learn/scripts/on_stop.py, bob's evolve-lite-learn.md) instead of by `next(...)` over a sorted manifest, so they're stable as the file tree changes. - New TestPerPlatformRouting covers the `_<platform>/` prefix convention; new TestBobCommandGeneration covers the 1:1 skill → command auto-generation, the dash-form body, the description-from- frontmatter rule, and the no-`name:` constraint. Also adds tests/smoke_skills.py (the three-platform skill harness) as part of the PR test plan. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f84f6b9 commit eed618e

2 files changed

Lines changed: 1656 additions & 56 deletions

File tree

Lines changed: 220 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
11
"""Tests for plugin-source/build_plugins.py — the plugin source compilation pipeline.
22
3-
These tests exercise the build pipeline end-to-end: render plugin-source/ into a
4-
temp tree, verify each manifested file lands at its declared per-platform path,
5-
and confirm the drift detector fires when the committed output is perturbed.
3+
The build pipeline has three observable contracts that these tests pin down:
4+
5+
1. Render is the inverse of check: rendering plugin-source/ into a tree, then
6+
running check_drift against that tree, returns 0.
7+
2. Render is hermetic: each platform's plugin_root is wiped before write, so
8+
a stale orphan in platform-integrations/<platform>/ is gone after render.
9+
3. Render is deterministic: rendering twice into the same tree produces
10+
byte-identical output.
11+
12+
Plus targeted tests for the routing conventions (`_<platform>/` prefix, bob's
13+
1:1 commands generation, the per-platform Jinja context). Where order-sensitive
14+
behavior used to slip in (e.g. "pick the alphabetically-first verbatim entry"),
15+
these tests now address files by name so they don't break when the file tree
16+
shifts.
617
718
Refs #219.
819
"""
@@ -35,9 +46,35 @@ def build_module():
3546
return _import_build_module()
3647

3748

49+
@pytest.fixture
50+
def isolated_repo(tmp_path, build_module, monkeypatch):
51+
"""Copy plugin-source/ into tmp_path and monkeypatch REPO_ROOT / PLUGIN_SOURCE_DIR
52+
so render_to and check_drift operate against an isolated tree. Returns tmp_path."""
53+
shutil.copytree(REPO_ROOT / "plugin-source", tmp_path / "plugin-source")
54+
monkeypatch.setattr(build_module, "REPO_ROOT", tmp_path)
55+
monkeypatch.setattr(build_module, "PLUGIN_SOURCE_DIR", tmp_path / "plugin-source")
56+
return tmp_path
57+
58+
59+
@pytest.fixture
60+
def rendered_repo(isolated_repo, build_module):
61+
"""isolated_repo + a fresh render — for tests that inspect rendered output."""
62+
build_module.render_to(isolated_repo)
63+
return isolated_repo
64+
65+
66+
def _plugin_root(manifest, platform: str) -> Path:
67+
"""Absolute path of the platform's plugin_root from the (possibly
68+
monkeypatched) manifest. After `isolated_repo` patches `build_module.REPO_ROOT`,
69+
this already points into the test's tmp_path."""
70+
return Path(manifest.platforms[platform].plugin_root)
71+
72+
3873
@pytest.mark.platform_integrations
3974
@pytest.mark.unit
4075
class TestManifest:
76+
"""Manifest sanity — purely structural, no I/O on platform-integrations/."""
77+
4178
def test_manifest_loads_without_error(self, build_module):
4279
manifest = build_module.load_manifest()
4380
assert manifest.platforms, "manifest declares no platforms"
@@ -48,7 +85,7 @@ def test_every_manifest_source_exists(self, build_module):
4885
for entry in manifest.files:
4986
assert entry.source.is_file(), f"manifest references missing source: {entry.source}"
5087

51-
def test_every_manifest_target_platform_is_declared(self, build_module):
88+
def test_every_target_platform_is_declared(self, build_module):
5289
manifest = build_module.load_manifest()
5390
declared = set(manifest.platforms)
5491
for entry in manifest.files:
@@ -58,95 +95,222 @@ def test_every_manifest_target_platform_is_declared(self, build_module):
5895

5996
@pytest.mark.platform_integrations
6097
@pytest.mark.unit
61-
class TestRender:
62-
def test_render_into_temp_dir_produces_every_manifest_target(self, tmp_path, build_module):
63-
"""Rendering into a fresh dir should write every declared (file × platform) target."""
64-
written = build_module.render_to(tmp_path)
65-
assert written, "render produced no output"
98+
class TestRenderInverseOfCheck:
99+
"""The headline invariant: render then check is silent and returns 0."""
100+
101+
def test_render_then_check_is_clean(self, isolated_repo, build_module, capsys):
102+
build_module.render_to(isolated_repo)
103+
rc = build_module.check_drift()
104+
captured = capsys.readouterr()
105+
assert rc == 0, f"check_drift returned {rc} on a fresh render. stderr:\n{captured.err}"
106+
assert captured.err == "", f"check_drift emitted output on a fresh render:\n{captured.err}"
107+
108+
109+
@pytest.mark.platform_integrations
110+
@pytest.mark.unit
111+
class TestRenderProperties:
112+
def test_render_is_idempotent(self, isolated_repo, build_module):
113+
"""Rendering twice into the same tree must produce byte-identical output."""
114+
build_module.render_to(isolated_repo)
115+
first = {p.relative_to(isolated_repo): p.read_bytes() for p in (isolated_repo / "platform-integrations").rglob("*") if p.is_file()}
116+
build_module.render_to(isolated_repo)
117+
second = {p.relative_to(isolated_repo): p.read_bytes() for p in (isolated_repo / "platform-integrations").rglob("*") if p.is_file()}
118+
assert first.keys() == second.keys(), "second render produced a different file set"
119+
for path, body in first.items():
120+
assert body == second[path], f"non-deterministic output: {path}"
121+
122+
def test_render_wipes_orphans_under_each_plugin_root(self, isolated_repo, build_module):
123+
"""Stale files under any platform's plugin_root must be removed before write."""
124+
manifest = build_module.load_manifest()
125+
orphans = []
126+
for platform in manifest.platforms:
127+
root = _plugin_root(manifest, platform)
128+
root.mkdir(parents=True, exist_ok=True)
129+
orphan = root / "leftover-orphan.txt"
130+
orphan.write_text("stale content")
131+
orphans.append(orphan)
132+
133+
build_module.render_to(isolated_repo)
134+
135+
for orphan in orphans:
136+
assert not orphan.exists(), f"render did not wipe orphan {orphan}"
137+
138+
def test_every_non_excluded_target_is_rendered(self, rendered_repo, build_module):
139+
"""For every (file, platform) declared by the manifest that the platform
140+
doesn't exclude, the rendered output exists at the expected path."""
66141
manifest = build_module.load_manifest()
67142
for entry in manifest.files:
68143
for platform in entry.platforms:
69144
cfg = manifest.platforms[platform]
70-
plugin_root_rel = cfg.plugin_root.relative_to(REPO_ROOT)
71-
rendered = tmp_path / plugin_root_rel / cfg.rewrite_target(entry.target_rel)
145+
if cfg.excludes(entry.target_rel):
146+
continue
147+
rendered = _plugin_root(manifest, platform) / cfg.rewrite_target(entry.target_rel)
72148
assert rendered.is_file(), f"render did not emit {rendered}"
73149

74-
def test_verbatim_files_match_source_byte_for_byte(self, tmp_path, build_module):
75-
"""Non-template (.py, .md, etc) files should be copied byte-for-byte."""
76-
build_module.render_to(tmp_path)
150+
def test_verbatim_files_match_source_byte_for_byte(self, rendered_repo, build_module):
151+
"""Non-template files are copied byte-for-byte (excludes-aware)."""
77152
manifest = build_module.load_manifest()
78153
for entry in manifest.files:
79154
if build_module._is_template(entry.source):
80155
continue
81156
for platform in entry.platforms:
82157
cfg = manifest.platforms[platform]
83-
plugin_root_rel = cfg.plugin_root.relative_to(REPO_ROOT)
84-
rendered = tmp_path / plugin_root_rel / cfg.rewrite_target(entry.target_rel)
85-
assert filecmp.cmp(entry.source, rendered, shallow=False), f"verbatim file {rendered} differs from source {entry.source}"
158+
if cfg.excludes(entry.target_rel):
159+
continue
160+
rendered = _plugin_root(manifest, platform) / cfg.rewrite_target(entry.target_rel)
161+
assert filecmp.cmp(entry.source, rendered, shallow=False), f"verbatim mismatch at {rendered}"
162+
163+
164+
@pytest.mark.platform_integrations
165+
@pytest.mark.unit
166+
class TestPerPlatformRouting:
167+
"""Files under plugin-source/_<platform>/ ship only to that platform, and the
168+
`_<platform>/` prefix is stripped from the output target."""
169+
170+
def test_underscore_platform_files_route_to_only_that_platform(self, build_module):
171+
manifest = build_module.load_manifest()
172+
for src, platforms in build_module._walk_sources():
173+
rel = src.relative_to(build_module.PLUGIN_SOURCE_DIR)
174+
head = rel.parts[0]
175+
if head.startswith("_") and head[1:] in manifest.platforms:
176+
expected = (head[1:],)
177+
assert platforms == expected, f"{rel} routes to {platforms}, expected {expected}"
178+
179+
def test_underscore_platform_prefix_stripped_from_output(self, rendered_repo, build_module):
180+
"""A file at _<platform>/<rest> renders to <plugin_root>/<rest>, not <plugin_root>/_<platform>/<rest>."""
181+
manifest = build_module.load_manifest()
182+
for src, platforms in build_module._walk_sources():
183+
rel = src.relative_to(build_module.PLUGIN_SOURCE_DIR)
184+
head = rel.parts[0]
185+
if not (head.startswith("_") and head[1:] in manifest.platforms):
186+
continue
187+
(platform,) = platforms
188+
target_rel = build_module._target_for(src)
189+
rendered = _plugin_root(manifest, platform) / target_rel
190+
assert rendered.is_file(), f"per-platform source {src} did not render to {rendered}"
191+
# And nothing under a `_<platform>/` subpath should appear in the output.
192+
stray = _plugin_root(manifest, platform) / head
193+
assert not stray.exists(), f"render leaked the `_<platform>/` prefix into {stray}"
194+
195+
196+
@pytest.mark.platform_integrations
197+
@pytest.mark.unit
198+
class TestBobCommandGeneration:
199+
"""Bob commands are auto-generated 1:1 from the skills walk; description is
200+
pulled from each skill's SKILL.md.j2 frontmatter and the body uses the
201+
dash-form folder reference (since bob resolves skills by folder name)."""
202+
203+
def _bob_commands_dir(self, rendered_repo, build_module) -> Path:
204+
manifest = build_module.load_manifest()
205+
return _plugin_root(manifest, "bob") / "commands"
206+
207+
def test_one_command_per_skill(self, rendered_repo, build_module):
208+
skill_names = sorted(d.name for d in build_module._discover_skills())
209+
commands = sorted(p.stem.removeprefix("evolve-lite-") for p in self._bob_commands_dir(rendered_repo, build_module).glob("*.md"))
210+
assert commands == skill_names, "bob commands are not 1:1 with skills"
211+
212+
def test_command_body_references_dash_form(self, rendered_repo, build_module):
213+
for cmd_file in self._bob_commands_dir(rendered_repo, build_module).glob("*.md"):
214+
skill = cmd_file.stem.removeprefix("evolve-lite-")
215+
body = cmd_file.read_text()
216+
assert f"`evolve-lite-{skill}`" in body, f"{cmd_file.name} body should reference the dash-form folder"
217+
assert f"evolve-lite:{skill}" not in body, f"{cmd_file.name} body should not use the colon form (bob resolves by folder)"
218+
219+
def test_command_description_comes_from_skill_frontmatter(self, rendered_repo, build_module):
220+
for skill_dir in build_module._discover_skills():
221+
description = build_module._read_skill_description(skill_dir)
222+
cmd_file = self._bob_commands_dir(rendered_repo, build_module) / f"evolve-lite-{skill_dir.name}.md"
223+
assert f"description: {description}\n" in cmd_file.read_text()
224+
225+
def test_command_frontmatter_has_no_name_field(self, rendered_repo, build_module):
226+
"""Bob's command schema only honors `description` / `argument-hints`;
227+
an explicit `name:` would be silently ignored or rejected."""
228+
for cmd_file in self._bob_commands_dir(rendered_repo, build_module).glob("*.md"):
229+
text = cmd_file.read_text()
230+
# Frontmatter is the block between the first two `---` lines.
231+
_, frontmatter, _ = text.split("---", 2)
232+
assert "\nname:" not in frontmatter, f"{cmd_file.name} has a `name:` field bob doesn't support"
86233

87234

88235
@pytest.mark.platform_integrations
89236
@pytest.mark.unit
90237
class TestCheckDrift:
91-
def test_check_passes_on_clean_committed_tree(self, build_module, capsys):
92-
"""The committed platform-integrations/ should match plugin-source/ at HEAD."""
238+
"""Drift detection — pin specific failure modes by file name, not by index."""
239+
240+
def test_committed_tree_is_clean(self, build_module, capsys):
241+
"""The real committed platform-integrations/ matches a fresh render of plugin-source/."""
93242
rc = build_module.check_drift()
94243
captured = capsys.readouterr()
95-
assert rc == 0, (
96-
f"check_drift returned {rc} on a clean tree. stderr:\n{captured.err}\n"
97-
f"This means committed platform-integrations/ has drifted from plugin-source/. "
98-
f"Run `just compile-plugins` and commit the result."
99-
)
244+
assert rc == 0, f"check_drift returned {rc}. stderr:\n{captured.err}\nRun `just compile-plugins` and commit the result."
100245

101-
def test_check_fails_when_committed_file_is_perturbed(self, tmp_path, build_module, monkeypatch, capsys):
102-
"""When a committed managed file has been edited, drift detection must fire.
246+
def test_perturbed_template_is_detected_as_drift(self, rendered_repo, build_module, capsys):
247+
target = rendered_repo / "platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/learn/SKILL.md"
248+
assert target.is_file(), "test prerequisite missing — claude learn/SKILL.md not rendered"
249+
target.write_bytes(target.read_bytes() + b"\n# perturbation\n")
103250

104-
Points the build script at a temp REPO_ROOT whose plugin-source/ matches
105-
the real one but whose platform-integrations/ has a perturbed copy of one
106-
managed file. Picks a verbatim (non-template) file so we can compare bytes
107-
directly without re-rendering.
108-
"""
109-
manifest = build_module.load_manifest()
110-
verbatim_entry = next(e for e in manifest.files if not build_module._is_template(e.source))
111-
first_platform = verbatim_entry.platforms[0]
112-
plugin_root_rel = manifest.platforms[first_platform].plugin_root.relative_to(REPO_ROOT)
251+
rc = build_module.check_drift()
252+
captured = capsys.readouterr()
253+
assert rc == 1
254+
assert "drift:" in captured.err
113255

114-
fake_root = tmp_path / "fake_repo"
115-
fake_plugin_source = fake_root / "plugin-source"
116-
shutil.copytree(REPO_ROOT / "plugin-source", fake_plugin_source)
256+
def test_perturbed_verbatim_file_is_detected_as_drift(self, rendered_repo, build_module, capsys):
257+
target = rendered_repo / "platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/learn/scripts/on_stop.py"
258+
assert target.is_file(), "test prerequisite missing — claude learn/scripts/on_stop.py not rendered"
259+
target.write_bytes(target.read_bytes() + b"\n# perturbation\n")
117260

118-
committed = fake_root / plugin_root_rel / manifest.platforms[first_platform].rewrite_target(verbatim_entry.target_rel)
119-
committed.parent.mkdir(parents=True, exist_ok=True)
120-
committed.write_bytes(verbatim_entry.source.read_bytes() + b"\n# perturbation\n")
261+
rc = build_module.check_drift()
262+
captured = capsys.readouterr()
263+
assert rc == 1
264+
assert "drift:" in captured.err
121265

122-
monkeypatch.setattr(build_module, "REPO_ROOT", fake_root)
123-
monkeypatch.setattr(build_module, "PLUGIN_SOURCE_DIR", fake_plugin_source)
266+
def test_perturbed_bob_command_is_detected_as_drift(self, rendered_repo, build_module, capsys):
267+
"""Bob commands are generated, not source-tracked — their drift is also caught."""
268+
target = rendered_repo / "platform-integrations/bob/evolve-lite/commands/evolve-lite-learn.md"
269+
assert target.is_file(), "test prerequisite missing — bob's evolve-lite-learn command not rendered"
270+
target.write_bytes(target.read_bytes() + b"\n# perturbation\n")
124271

125272
rc = build_module.check_drift()
126273
captured = capsys.readouterr()
127-
assert rc == 1, "check_drift should return 1 when a managed file is perturbed"
128-
assert "drift:" in captured.err, "drift message should be printed to stderr"
274+
assert rc == 1
275+
assert "drift:" in captured.err
276+
277+
def test_missing_rendered_file_is_detected(self, rendered_repo, build_module, capsys):
278+
target = rendered_repo / "platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/learn/SKILL.md"
279+
assert target.is_file()
280+
target.unlink()
281+
282+
rc = build_module.check_drift()
283+
captured = capsys.readouterr()
284+
assert rc == 1
285+
assert "missing managed file:" in captured.err
129286

130287

131288
@pytest.mark.platform_integrations
132289
@pytest.mark.unit
133290
class TestJinjaTemplating:
134-
def test_template_renders_with_per_platform_context(self, tmp_path, build_module):
135-
"""A .j2 source rendered for two platforms should produce platform-specific output."""
291+
def test_template_renders_with_per_platform_context(self, rendered_repo, build_module):
292+
"""A .j2 source rendered for two non-excluded platforms produces platform-specific output."""
136293
manifest = build_module.load_manifest()
137-
template_entry = next((e for e in manifest.files if build_module._is_template(e.source)), None)
138-
if template_entry is None or len(template_entry.platforms) < 2:
139-
pytest.skip("manifest has no .j2 file shared between two platforms yet")
294+
candidate = next(
295+
(
296+
e
297+
for e in manifest.files
298+
if build_module._is_template(e.source)
299+
and sum(1 for p in e.platforms if not manifest.platforms[p].excludes(e.target_rel)) >= 2
300+
),
301+
None,
302+
)
303+
if candidate is None:
304+
pytest.skip("manifest has no templated source shipped to two non-excluded platforms")
140305

141-
build_module.render_to(tmp_path)
142306
outputs = []
143-
for platform in template_entry.platforms:
307+
for platform in candidate.platforms:
144308
cfg = manifest.platforms[platform]
145-
plugin_root_rel = cfg.plugin_root.relative_to(REPO_ROOT)
146-
rendered = tmp_path / plugin_root_rel / cfg.rewrite_target(template_entry.target_rel)
309+
if cfg.excludes(candidate.target_rel):
310+
continue
311+
rendered = _plugin_root(manifest, platform) / cfg.rewrite_target(candidate.target_rel)
147312
outputs.append(rendered.read_bytes())
148313

149314
assert any(a != b for a, b in zip(outputs, outputs[1:])), (
150-
"expected at least one pair of platform renderings to differ for a templated source; "
151-
"if every platform produces the same bytes, the .j2 file does not actually use its context"
315+
"every platform produced the same bytes for a templated source — the .j2 file is not actually using its per-platform context"
152316
)

0 commit comments

Comments
 (0)