diff --git a/runner/harness/discovery.py b/runner/harness/discovery.py index d872b3c..87de859 100644 --- a/runner/harness/discovery.py +++ b/runner/harness/discovery.py @@ -11,7 +11,8 @@ hook; the orchestrator decides additive-vs-skip. Sources, in precedence order > cc-ci tests//test_.py (the generic tests/_generic/test_.py is the always-present floor, run separately by default) - custom (non-lifecycle) test_*.py — ALL run, additively, from BOTH locations (opt-in). + custom test_*.py (functional/ + playwright/ ONLY, rcust P4 placement rule) — ALL run, + additively, from BOTH locations (opt-in). install-steps hook — install_steps.sh: repo-local > cc-ci, or none. @@ -100,29 +101,22 @@ def resolve_op(recipe: str, op: str, repo_local_dir: str | None) -> tuple[str, s def custom_tests(recipe: str, repo_local_dir: str | None) -> list[tuple[str, str]]: - """All non-lifecycle test_*.py from cc-ci's tests// and (if approved) the recipe's - repo-local tests/. Discovered locations (Phase 2 §4.1): - - the top-level dir tests//test_*.py (legacy + cross-cutting) - - functional/ tests//functional/test_*.py (parity ports + recipe-specific) - - playwright/ tests//playwright/test_*.py (UI flows P6) - Files named `test_.py` (lifecycle ops) are excluded from this list — the orchestrator runs - those in their lifecycle tier, not the custom one. Repo-local is consulted only for - allowlist-approved recipes (HC2).""" + """All custom-tier test_*.py from cc-ci's tests// and (if approved) the recipe's + repo-local tests/. PLACEMENT RULE (rcust P4): custom tests live ONLY under + - functional/ tests//functional/test_*.py (parity ports + recipe-specific) + - playwright/ tests//playwright/test_*.py (UI flows) + A top-level test_*.py is a LIFECYCLE OVERLAY (test_.py) and nothing else — top-level + non-lifecycle files are NOT discovered (zero users at the time of the change; the lifecycle- + name exclusion below stays as a safety net so a misfiled test_.py can never double-run). + Repo-local is consulted only for allowlist-approved recipes (HC2).""" lifecycle_names = {f"test_{op}.py" for op in LIFECYCLE_OPS} subdirs = ("functional", "playwright") found: list[tuple[str, str]] = [] for source, d in (("cc-ci", cc_ci_dir(recipe)), ("repo-local", _gated(recipe, repo_local_dir))): if not d or not os.path.isdir(d): continue - # top-level (legacy / cross-cutting tests not under functional/playwright) - for p in sorted(glob.glob(os.path.join(d, "test_*.py"))): - if os.path.basename(p) not in lifecycle_names: - found.append((source, p)) - # functional/ and playwright/ subdirs (Phase 2 §4.1) for sub in subdirs: for p in sorted(glob.glob(os.path.join(d, sub, "test_*.py"))): - # Phase-2 layout: lifecycle ops never live under functional/playwright, but be - # explicit so a misfiled file doesn't silently get double-run. if os.path.basename(p) not in lifecycle_names: found.append((source, p)) return found diff --git a/tests/conftest.py b/tests/conftest.py index 35d47bd..9dfbee9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,6 +40,28 @@ def live_app() -> str: return domain +@pytest.fixture +def op_state() -> dict: + """The orchestrator's run-scoped op context (rcust P4): versions, artifact paths — written to + `$CCCI_OP_STATE_FILE` after each lifecycle op (e.g. `{"upgrade": {"before": {...}, + "head_ref": ...}, "backup": {"snapshot_id": ...}}`). Overlay tests read op facts from here + instead of hand-parsing env/JSON. Skips with a clear reason outside an orchestrator run.""" + import json + + path = os.environ.get("CCCI_OP_STATE_FILE") + if not path: + pytest.skip( + "CCCI_OP_STATE_FILE not set — op_state is only available under the orchestrator" + ) + if not os.path.exists(path): + pytest.skip(f"op-state file missing ({path}) — orchestrator has not performed an op yet") + try: + with open(path) as f: + return json.load(f) + except ValueError: + pytest.skip(f"op-state file unreadable/not JSON ({path})") + + class _DepEntry(dict): """One provisioned dep (full creds dict) with attribute sugar: `entry.domain`, `entry.realm`, `entry.client_secret`, ... — dict-style access works too (rcust P2d).""" diff --git a/tests/unit/test_conftest_fixtures.py b/tests/unit/test_conftest_fixtures.py new file mode 100644 index 0000000..3a3aed9 --- /dev/null +++ b/tests/unit/test_conftest_fixtures.py @@ -0,0 +1,48 @@ +"""Unit tests for the shared conftest fixtures added/reshaped by the rcust restructure (P2d/P4): +`op_state` (run-scoped op context from $CCCI_OP_STATE_FILE) and `deps` (consolidated dep creds +with attribute sugar). Pure — exercised via request.getfixturevalue with env monkeypatched.""" + +from __future__ import annotations + +import json + +import pytest + + +def test_op_state_fixture_reads_file(tmp_path, monkeypatch, request): + f = tmp_path / "op.json" + f.write_text(json.dumps({"backup": {"snapshot_id": "abc123"}, "upgrade": {"head_ref": "h"}})) + monkeypatch.setenv("CCCI_OP_STATE_FILE", str(f)) + st = request.getfixturevalue("op_state") + assert st["backup"]["snapshot_id"] == "abc123" + assert st["upgrade"]["head_ref"] == "h" + + +def test_op_state_fixture_skips_without_env(monkeypatch, request): + monkeypatch.delenv("CCCI_OP_STATE_FILE", raising=False) + with pytest.raises(pytest.skip.Exception, match="orchestrator"): + request.getfixturevalue("op_state") + + +def test_op_state_fixture_skips_on_missing_file(tmp_path, monkeypatch, request): + monkeypatch.setenv("CCCI_OP_STATE_FILE", str(tmp_path / "nope.json")) + with pytest.raises(pytest.skip.Exception, match="missing"): + request.getfixturevalue("op_state") + + +def test_deps_fixture_entries_expose_attributes(tmp_path, monkeypatch, request): + """`deps` (session-scoped) coerces the run deps file into entries with .domain/.realm/... + attribute sugar while keeping dict-style access (rcust P2d). Single test for the session- + cached fixture (one instantiation).""" + f = tmp_path / "deps.json" + f.write_text( + json.dumps( + {"keycloak": {"recipe": "keycloak", "domain": "kc.x", "client_secret": "s3cret"}} + ) + ) + monkeypatch.setenv("CCCI_DEPS_FILE", str(f)) + deps = request.getfixturevalue("deps") + assert deps["keycloak"].domain == "kc.x" + assert deps["keycloak"]["client_secret"] == "s3cret" + with pytest.raises(AttributeError): + _ = deps["keycloak"].not_a_field diff --git a/tests/unit/test_discovery.py b/tests/unit/test_discovery.py index 68f881a..e170f86 100644 --- a/tests/unit/test_discovery.py +++ b/tests/unit/test_discovery.py @@ -71,17 +71,18 @@ def test_repo_local_wins_when_approved(tmp_path): def test_custom_tests_repo_local_gated(tmp_path, monkeypatch): - # non-lifecycle test_*.py from repo-local only count for approved recipes; lifecycle names excluded + # custom test_*.py from repo-local only count for approved recipes (HC2); placement rule + # (rcust P4): custom tests live under functional/ (or playwright/) — top-level files are + # lifecycle overlays only, so the repo-local custom here sits in functional/. # Use a synthetic recipe name + monkeypatched cc_ci_dir so this is independent of what - # tests// ships (Phase-2 custom-html now also ships functional/ + playwright/, - # which would legitimately appear in custom_tests for "custom-html" — F2-1). + # tests// ships (F2-1). fake_recipe = "ccci-hc2-fixture" monkeypatch.setattr(discovery, "cc_ci_dir", lambda r: str(tmp_path / "cc-ci" / r)) (tmp_path / "cc-ci" / fake_recipe).mkdir(parents=True) rl = tmp_path / "repo" - rl.mkdir() - (rl / "test_sso.py").write_text("# repo-local custom\n") - (rl / "test_install.py").write_text("# lifecycle name -> excluded from custom\n") + (rl / "functional").mkdir(parents=True) + (rl / "functional" / "test_sso.py").write_text("# repo-local custom\n") + (rl / "functional" / "test_install.py").write_text("# lifecycle name -> excluded from custom\n") _approve(tmp_path) # not approved -> repo-local custom ignored assert discovery.custom_tests(fake_recipe, str(rl)) == [] diff --git a/tests/unit/test_discovery_phase2.py b/tests/unit/test_discovery_phase2.py index 8c0e06f..882b4e6 100644 --- a/tests/unit/test_discovery_phase2.py +++ b/tests/unit/test_discovery_phase2.py @@ -1,6 +1,6 @@ """Unit tests for Phase-2 discovery additions (plan §4.1). -Proves the `custom_tests` discovery recurses into the per-recipe `functional/` + `playwright/` +Proves the `custom_tests` discovery covers exactly the per-recipe `functional/` + `playwright/` subdirs as well as the top-level dir, while still excluding lifecycle `test_.py` names and honouring the HC2 repo-local approval gate. @@ -27,16 +27,16 @@ def teardown_function(): os.environ.pop("CCCI_REPO_LOCAL_APPROVED_FILE", None) -def test_custom_tests_recurses_functional_and_playwright(tmp_path, monkeypatch): - """A Phase-2 cc-ci recipe layout: functional/test_*.py + playwright/test_*.py + top-level - test_*.py — all are discovered as custom tests; the lifecycle names are excluded.""" +def test_custom_tests_placement_rule_functional_playwright_only(tmp_path, monkeypatch): + """Placement rule (rcust P4): custom tests are discovered ONLY under functional/ + + playwright/. A top-level non-lifecycle test_*.py is NOT discovered (top level is reserved + for lifecycle overlays); lifecycle names inside the subdirs stay excluded (defensive).""" # Point cc-ci's per-recipe dir at a fake recipe in tmp_path fake_recipe = "ccci-phase2-fixture" fake_dir = tmp_path / "tests" / fake_recipe (fake_dir / "functional").mkdir(parents=True) (fake_dir / "playwright").mkdir() - # legitimate custom tests at multiple levels - (fake_dir / "test_sso_smoke.py").write_text("# top-level cross-cutting\n") + (fake_dir / "test_sso_smoke.py").write_text("# top-level — NOT discovered since P4\n") (fake_dir / "functional" / "test_health_check.py").write_text("# parity port\n") (fake_dir / "functional" / "test_content_roundtrip.py").write_text("# recipe-specific\n") (fake_dir / "playwright" / "test_login_flow.py").write_text("# UI flow\n") @@ -49,11 +49,11 @@ def test_custom_tests_recurses_functional_and_playwright(tmp_path, monkeypatch): customs = discovery.custom_tests(fake_recipe, None) names = sorted((src, os.path.basename(p)) for src, p in customs) - # Top-level + functional/ + playwright/ all discovered; lifecycle name excluded - assert ("cc-ci", "test_sso_smoke.py") in names + # functional/ + playwright/ discovered; top-level custom + lifecycle name are NOT assert ("cc-ci", "test_health_check.py") in names assert ("cc-ci", "test_content_roundtrip.py") in names assert ("cc-ci", "test_login_flow.py") in names + assert ("cc-ci", "test_sso_smoke.py") not in names assert ("cc-ci", "test_install.py") not in names