Skip to content

Commit 503b991

Browse files
authored
Merge pull request #10 from baskduf/refactor/split-test-suite
test: split monolithic script tests
2 parents 8ef46e7 + 3756e2e commit 503b991

12 files changed

Lines changed: 1613 additions & 1345 deletions

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ jobs:
3636
plugins/codex-fable5/skills/codex-fable5/scripts/codex_goals.py \
3737
plugins/codex-fable5/skills/codex-fable5/scripts/fable_coverage.py \
3838
plugins/codex-fable5/skills/codex-fable5/scripts/make_litellm_config.py \
39-
tests/test_scripts.py
39+
tests/*.py
4040
sh -n plugins/codex-fable5/bin/codex-fable5
4141
sh -n plugins/codex-fable5/bin/codex-findings
4242
sh -n plugins/codex-fable5/bin/codex-goals

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ python3 -m py_compile \
2727
plugins/codex-fable5/skills/codex-fable5/scripts/codex_goals.py \
2828
plugins/codex-fable5/skills/codex-fable5/scripts/fable_coverage.py \
2929
plugins/codex-fable5/skills/codex-fable5/scripts/make_litellm_config.py \
30-
tests/test_scripts.py
30+
tests/*.py
3131
sh -n plugins/codex-fable5/bin/codex-fable5
3232
sh -n plugins/codex-fable5/bin/codex-findings
3333
sh -n plugins/codex-fable5/bin/codex-goals

docs/RELEASING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ python3 -m py_compile \
1717
plugins/codex-fable5/skills/codex-fable5/scripts/codex_goals.py \
1818
plugins/codex-fable5/skills/codex-fable5/scripts/fable_coverage.py \
1919
plugins/codex-fable5/skills/codex-fable5/scripts/make_litellm_config.py \
20-
tests/test_scripts.py
20+
tests/*.py
2121
sh -n plugins/codex-fable5/bin/codex-fable5
2222
sh -n plugins/codex-fable5/bin/codex-findings
2323
sh -n plugins/codex-fable5/bin/codex-goals

tests/__init__.py

Whitespace-only changes.

tests/support.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from __future__ import annotations
2+
3+
import importlib.util
4+
import json
5+
import os
6+
import re
7+
import subprocess
8+
import sys
9+
import tempfile
10+
import textwrap
11+
import unittest
12+
from pathlib import Path
13+
from types import SimpleNamespace
14+
15+
16+
ROOT = Path(__file__).resolve().parents[1]
17+
SKILL_ROOT = ROOT / "plugins" / "codex-fable5" / "skills" / "codex-fable5"
18+
SCRIPTS = SKILL_ROOT / "scripts"
19+
BIN = ROOT / "plugins" / "codex-fable5" / "bin"
20+
21+
22+
def load_script(name: str):
23+
path = SCRIPTS / f"{name}.py"
24+
spec = importlib.util.spec_from_file_location(name, path)
25+
if spec is None or spec.loader is None:
26+
raise RuntimeError(f"could not load {path}")
27+
module = importlib.util.module_from_spec(spec)
28+
spec.loader.exec_module(module)
29+
return module
30+
31+
32+
def read_skill_body() -> str:
33+
skill = (SKILL_ROOT / "SKILL.md").read_text(encoding="utf-8")
34+
match = re.match(r"\A---\r?\n.*?\r?\n---\r?\n(?P<body>.*)\Z", skill, re.DOTALL)
35+
if match is None:
36+
raise AssertionError("SKILL.md frontmatter block not found")
37+
return match.group("body")
38+
39+
40+
def parse_routing_map(body: str) -> list[tuple[str, str]]:
41+
match = re.search(r"^## Routing Map\r?\n\r?\n(?P<table>.*?)(?:\r?\n## |\Z)", body, re.DOTALL | re.MULTILINE)
42+
if match is None:
43+
raise AssertionError("Routing Map section not found")
44+
rows: list[tuple[str, str]] = []
45+
for line in match.group("table").splitlines():
46+
if not line.startswith("|"):
47+
continue
48+
cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
49+
if len(cells) != 2 or cells[0] == "Signal" or set(cells[0]) == {"-"}:
50+
continue
51+
rows.append((cells[0], cells[1]))
52+
return rows
53+
54+
55+
def read_readme_fable_pin() -> str:
56+
readme = (ROOT / "README.md").read_text(encoding="utf-8")
57+
match = re.search(
58+
r"`elder-plinius/CL4R1T4S`\s+`ANTHROPIC/CLAUDE-FABLE-5\.md`\s+"
59+
r"at commit\s+`([0-9a-f]{40})`",
60+
readme,
61+
)
62+
if match is None:
63+
raise AssertionError("pinned FABLE-5 SHA not found in README")
64+
return match.group(1)
65+
66+
67+
class ScriptTestBase(unittest.TestCase):
68+
@classmethod
69+
def setUpClass(cls) -> None:
70+
cls.fable_coverage = load_script("fable_coverage")
71+
cls.codex_goals = load_script("codex_goals")
72+
cls.codex_findings = load_script("codex_findings")
73+
cls.make_litellm_config = load_script("make_litellm_config")

tests/test_ci_release.py

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
from __future__ import annotations
2+
3+
try:
4+
from tests.support import (
5+
BIN,
6+
Path,
7+
ROOT,
8+
SCRIPTS,
9+
SKILL_ROOT,
10+
ScriptTestBase,
11+
SimpleNamespace,
12+
json,
13+
os,
14+
parse_routing_map,
15+
read_readme_fable_pin,
16+
read_skill_body,
17+
re,
18+
subprocess,
19+
sys,
20+
tempfile,
21+
textwrap,
22+
)
23+
except ModuleNotFoundError: # unittest discovery with tests/ as top-level.
24+
from support import (
25+
BIN,
26+
Path,
27+
ROOT,
28+
SCRIPTS,
29+
SKILL_ROOT,
30+
ScriptTestBase,
31+
SimpleNamespace,
32+
json,
33+
os,
34+
parse_routing_map,
35+
read_readme_fable_pin,
36+
read_skill_body,
37+
re,
38+
subprocess,
39+
sys,
40+
tempfile,
41+
textwrap,
42+
)
43+
44+
45+
class CiReleaseTests(ScriptTestBase):
46+
def test_ci_workflow_runs_project_verification(self) -> None:
47+
workflow = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8")
48+
49+
self.assertIn("actions/checkout@v6", workflow)
50+
self.assertIn("actions/setup-python@v6", workflow)
51+
self.assertIn("python3 -m unittest discover -s tests -v", workflow)
52+
self.assertIn("python3 -m py_compile", workflow)
53+
self.assertIn("codex_findings.py", workflow)
54+
self.assertIn("sh -n plugins/codex-fable5/bin/codex-fable5", workflow)
55+
self.assertIn("sh -n plugins/codex-fable5/bin/codex-findings", workflow)
56+
self.assertIn("sh -n plugins/codex-fable5/bin/codex-goals", workflow)
57+
self.assertIn("fable_coverage.py", workflow)
58+
self.assertIn('python-version: ["3.11", "3.12", "3.13"]', workflow)
59+
60+
def test_ci_workflow_validates_against_pinned_source(self) -> None:
61+
"""The CI workflow must fetch the pinned upstream source and pass
62+
it to the validator. Without this, the default `--matrix`-only run
63+
is self-consistent and can hide a fabricated row."""
64+
workflow = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8")
65+
self.assertIn("CLAUDE-FABLE-5.md", workflow)
66+
self.assertIn("elder-plinius/CL4R1T4S", workflow)
67+
fetch_step = re.search(
68+
r" - name: Fetch pinned FABLE-5 source\n(?P<body>.*?)(?:\n - name: |\Z)",
69+
workflow,
70+
re.DOTALL,
71+
)
72+
self.assertIsNotNone(fetch_step, "CI workflow must define the pinned source fetch step")
73+
assert fetch_step is not None # for type checkers
74+
fetch_step_body = fetch_step.group("body")
75+
self.assertIn(
76+
"raw.githubusercontent.com/elder-plinius/CL4R1T4S/${PIN}/ANTHROPIC/CLAUDE-FABLE-5.md",
77+
fetch_step_body,
78+
"CI should fetch the public pinned source directly, matching the release checklist",
79+
)
80+
self.assertNotIn(
81+
"Authorization: Bearer",
82+
fetch_step_body,
83+
"CI source fetch should not rely on a hand-built Authorization header; malformed quoting can hide curl failures",
84+
)
85+
pin_match = re.search(r'PIN="([0-9a-f]{40})"', workflow)
86+
self.assertIsNotNone(pin_match, "CI workflow must define the pinned upstream SHA")
87+
assert pin_match is not None # for type checkers
88+
self.assertEqual(
89+
read_readme_fable_pin(),
90+
pin_match.group(1),
91+
"CI workflow pin must match README source note pin",
92+
)
93+
# The validator invocation must include --source.
94+
# Look for lines that invoke fable_coverage.py and contain --source.
95+
has_source_arg = False
96+
for line in workflow.splitlines():
97+
if "fable_coverage.py" in line and "--source" in line:
98+
has_source_arg = True
99+
break
100+
self.assertTrue(
101+
has_source_arg,
102+
"CI workflow must pass --source to fable_coverage.py "
103+
"so the matrix is validated against the upstream FABLE-5 source",
104+
)
105+
106+
def test_release_checklist_validates_against_pinned_source(self) -> None:
107+
releasing = (ROOT / "docs" / "RELEASING.md").read_text(encoding="utf-8")
108+
109+
self.assertIn("README.md", releasing)
110+
self.assertIn("raw.githubusercontent.com/elder-plinius/CL4R1T4S/${PIN}", releasing)
111+
self.assertIn("--source build/fable5/CLAUDE-FABLE-5.md", releasing)
112+
self.assertIn(r"\s+`ANTHROPIC/CLAUDE-FABLE-5\.md`\s+at commit\s+", releasing)
113+
self.assertNotIn(r"\\s+", releasing)
114+
115+
def test_user_facing_wrappers_run_from_path(self) -> None:
116+
env = {**os.environ, "PATH": f"{BIN}{os.pathsep}{os.environ['PATH']}"}
117+
for command in ["codex-fable5", "codex-findings", "codex-goals"]:
118+
with self.subTest(command=command):
119+
wrapper = BIN / command
120+
self.assertTrue(wrapper.is_file())
121+
self.assertTrue(os.access(wrapper, os.X_OK))
122+
123+
syntax = subprocess.run(
124+
["sh", "-n", str(wrapper)],
125+
cwd=ROOT,
126+
text=True,
127+
capture_output=True,
128+
check=False,
129+
)
130+
self.assertEqual(syntax.returncode, 0, syntax.stderr)
131+
132+
with tempfile.TemporaryDirectory() as tmp:
133+
status = subprocess.run(
134+
["codex-fable5", "status"],
135+
cwd=tmp,
136+
env=env,
137+
text=True,
138+
capture_output=True,
139+
check=False,
140+
)
141+
self.assertEqual(status.returncode, 0, status.stderr)
142+
self.assertIn("0 findings", status.stdout)
143+
self.assertIn("no goal plan", status.stdout)
144+
145+
created = subprocess.run(
146+
[
147+
"codex-fable5",
148+
"goals",
149+
"create",
150+
"--brief",
151+
"Wrapper smoke",
152+
"--goal",
153+
"inspect::Check wrapper path",
154+
],
155+
cwd=tmp,
156+
env=env,
157+
text=True,
158+
capture_output=True,
159+
check=False,
160+
)
161+
self.assertEqual(created.returncode, 0, created.stderr)
162+
self.assertIn("plan created", created.stdout)
163+
164+
started = subprocess.run(
165+
["codex-fable5", "goals", "next"],
166+
cwd=tmp,
167+
env=env,
168+
text=True,
169+
capture_output=True,
170+
check=False,
171+
)
172+
self.assertEqual(started.returncode, 0, started.stderr)
173+
174+
added = subprocess.run(
175+
[
176+
"codex-fable5",
177+
"findings",
178+
"add",
179+
"--title",
180+
"Wrapper finding",
181+
"--evidence",
182+
"PATH wrapper should call the findings script.",
183+
],
184+
cwd=tmp,
185+
env=env,
186+
text=True,
187+
capture_output=True,
188+
check=False,
189+
)
190+
self.assertEqual(added.returncode, 0, added.stderr)
191+
self.assertIn("goal=G001", added.stdout)
192+
193+
status_with_plan = subprocess.run(
194+
["codex-fable5", "status"],
195+
cwd=tmp,
196+
env=env,
197+
text=True,
198+
capture_output=True,
199+
check=False,
200+
)
201+
self.assertEqual(status_with_plan.returncode, 0, status_with_plan.stderr)
202+
self.assertIn("1 open", status_with_plan.stdout)
203+
self.assertIn("0/1 complete", status_with_plan.stdout)

0 commit comments

Comments
 (0)