feat(harness): P4 — custom-test ergonomics (rcust)
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
Placement RULE: discovery.custom_tests covers ONLY functional/ + playwright/ — the top-level test_*.py glob for recipe dirs is removed (top level is reserved for lifecycle overlays; zero in-repo users of top-level custom tests, verified by sweep). Lifecycle-name exclusion inside the subdirs stays as the double-run safety net. HC2 default-deny unchanged (repo-local custom now pinned via functional/ in the gate test). New conftest fixture op_state: parses $CCCI_OP_STATE_FILE (op context: versions, artifact paths), skipping with a clear reason when unset/absent/unparseable — overlay tests read op facts from the fixture instead of hand-parsing env (zero existing hand-parsers found; the fixture is the documented path forward). deps fixture landed in P2d. Unit tests: placement-rule discovery tests (top-level custom NOT discovered; functional/playwright are; misfiled lifecycle names excluded), op_state fixture contract (reads file / skips without env / skips on missing file), deps fixture attribute sugar. Verified on cc-ci: cc-ci-run -m pytest tests/unit -q -> 184 passed; scripts/lint.sh -> PASS.
This commit is contained in:
@ -11,7 +11,8 @@ hook; the orchestrator decides additive-vs-skip. Sources, in precedence order
|
|||||||
> cc-ci tests/<recipe>/test_<op>.py
|
> cc-ci tests/<recipe>/test_<op>.py
|
||||||
(the generic tests/_generic/test_<op>.py is the always-present floor, run separately by default)
|
(the generic tests/_generic/test_<op>.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.
|
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]]:
|
def custom_tests(recipe: str, repo_local_dir: str | None) -> list[tuple[str, str]]:
|
||||||
"""All non-lifecycle test_*.py from cc-ci's tests/<recipe>/ and (if approved) the recipe's
|
"""All custom-tier test_*.py from cc-ci's tests/<recipe>/ and (if approved) the recipe's
|
||||||
repo-local tests/. Discovered locations (Phase 2 §4.1):
|
repo-local tests/. PLACEMENT RULE (rcust P4): custom tests live ONLY under
|
||||||
- the top-level dir tests/<recipe>/test_*.py (legacy + cross-cutting)
|
- functional/ tests/<recipe>/functional/test_*.py (parity ports + recipe-specific)
|
||||||
- functional/ tests/<recipe>/functional/test_*.py (parity ports + recipe-specific)
|
- playwright/ tests/<recipe>/playwright/test_*.py (UI flows)
|
||||||
- playwright/ tests/<recipe>/playwright/test_*.py (UI flows P6)
|
A top-level test_*.py is a LIFECYCLE OVERLAY (test_<op>.py) and nothing else — top-level
|
||||||
Files named `test_<op>.py` (lifecycle ops) are excluded from this list — the orchestrator runs
|
non-lifecycle files are NOT discovered (zero users at the time of the change; the lifecycle-
|
||||||
those in their lifecycle tier, not the custom one. Repo-local is consulted only for
|
name exclusion below stays as a safety net so a misfiled test_<op>.py can never double-run).
|
||||||
allowlist-approved recipes (HC2)."""
|
Repo-local is consulted only for allowlist-approved recipes (HC2)."""
|
||||||
lifecycle_names = {f"test_{op}.py" for op in LIFECYCLE_OPS}
|
lifecycle_names = {f"test_{op}.py" for op in LIFECYCLE_OPS}
|
||||||
subdirs = ("functional", "playwright")
|
subdirs = ("functional", "playwright")
|
||||||
found: list[tuple[str, str]] = []
|
found: list[tuple[str, str]] = []
|
||||||
for source, d in (("cc-ci", cc_ci_dir(recipe)), ("repo-local", _gated(recipe, repo_local_dir))):
|
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):
|
if not d or not os.path.isdir(d):
|
||||||
continue
|
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 sub in subdirs:
|
||||||
for p in sorted(glob.glob(os.path.join(d, sub, "test_*.py"))):
|
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:
|
if os.path.basename(p) not in lifecycle_names:
|
||||||
found.append((source, p))
|
found.append((source, p))
|
||||||
return found
|
return found
|
||||||
|
|||||||
@ -40,6 +40,28 @@ def live_app() -> str:
|
|||||||
return domain
|
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):
|
class _DepEntry(dict):
|
||||||
"""One provisioned dep (full creds dict) with attribute sugar: `entry.domain`, `entry.realm`,
|
"""One provisioned dep (full creds dict) with attribute sugar: `entry.domain`, `entry.realm`,
|
||||||
`entry.client_secret`, ... — dict-style access works too (rcust P2d)."""
|
`entry.client_secret`, ... — dict-style access works too (rcust P2d)."""
|
||||||
|
|||||||
48
tests/unit/test_conftest_fixtures.py
Normal file
48
tests/unit/test_conftest_fixtures.py
Normal file
@ -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
|
||||||
@ -71,17 +71,18 @@ def test_repo_local_wins_when_approved(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def test_custom_tests_repo_local_gated(tmp_path, monkeypatch):
|
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
|
# Use a synthetic recipe name + monkeypatched cc_ci_dir so this is independent of what
|
||||||
# tests/<real-recipe>/ ships (Phase-2 custom-html now also ships functional/ + playwright/,
|
# tests/<real-recipe>/ ships (F2-1).
|
||||||
# which would legitimately appear in custom_tests for "custom-html" — F2-1).
|
|
||||||
fake_recipe = "ccci-hc2-fixture"
|
fake_recipe = "ccci-hc2-fixture"
|
||||||
monkeypatch.setattr(discovery, "cc_ci_dir", lambda r: str(tmp_path / "cc-ci" / r))
|
monkeypatch.setattr(discovery, "cc_ci_dir", lambda r: str(tmp_path / "cc-ci" / r))
|
||||||
(tmp_path / "cc-ci" / fake_recipe).mkdir(parents=True)
|
(tmp_path / "cc-ci" / fake_recipe).mkdir(parents=True)
|
||||||
rl = tmp_path / "repo"
|
rl = tmp_path / "repo"
|
||||||
rl.mkdir()
|
(rl / "functional").mkdir(parents=True)
|
||||||
(rl / "test_sso.py").write_text("# repo-local custom\n")
|
(rl / "functional" / "test_sso.py").write_text("# repo-local custom\n")
|
||||||
(rl / "test_install.py").write_text("# lifecycle name -> excluded from custom\n")
|
(rl / "functional" / "test_install.py").write_text("# lifecycle name -> excluded from custom\n")
|
||||||
|
|
||||||
_approve(tmp_path) # not approved -> repo-local custom ignored
|
_approve(tmp_path) # not approved -> repo-local custom ignored
|
||||||
assert discovery.custom_tests(fake_recipe, str(rl)) == []
|
assert discovery.custom_tests(fake_recipe, str(rl)) == []
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"""Unit tests for Phase-2 discovery additions (plan §4.1).
|
"""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_<op>.py` names and
|
subdirs as well as the top-level dir, while still excluding lifecycle `test_<op>.py` names and
|
||||||
honouring the HC2 repo-local approval gate.
|
honouring the HC2 repo-local approval gate.
|
||||||
|
|
||||||
@ -27,16 +27,16 @@ def teardown_function():
|
|||||||
os.environ.pop("CCCI_REPO_LOCAL_APPROVED_FILE", None)
|
os.environ.pop("CCCI_REPO_LOCAL_APPROVED_FILE", None)
|
||||||
|
|
||||||
|
|
||||||
def test_custom_tests_recurses_functional_and_playwright(tmp_path, monkeypatch):
|
def test_custom_tests_placement_rule_functional_playwright_only(tmp_path, monkeypatch):
|
||||||
"""A Phase-2 cc-ci recipe layout: functional/test_*.py + playwright/test_*.py + top-level
|
"""Placement rule (rcust P4): custom tests are discovered ONLY under functional/ +
|
||||||
test_*.py — all are discovered as custom tests; the lifecycle names are excluded."""
|
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
|
# Point cc-ci's per-recipe dir at a fake recipe in tmp_path
|
||||||
fake_recipe = "ccci-phase2-fixture"
|
fake_recipe = "ccci-phase2-fixture"
|
||||||
fake_dir = tmp_path / "tests" / fake_recipe
|
fake_dir = tmp_path / "tests" / fake_recipe
|
||||||
(fake_dir / "functional").mkdir(parents=True)
|
(fake_dir / "functional").mkdir(parents=True)
|
||||||
(fake_dir / "playwright").mkdir()
|
(fake_dir / "playwright").mkdir()
|
||||||
# legitimate custom tests at multiple levels
|
(fake_dir / "test_sso_smoke.py").write_text("# top-level — NOT discovered since P4\n")
|
||||||
(fake_dir / "test_sso_smoke.py").write_text("# top-level cross-cutting\n")
|
|
||||||
(fake_dir / "functional" / "test_health_check.py").write_text("# parity port\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 / "functional" / "test_content_roundtrip.py").write_text("# recipe-specific\n")
|
||||||
(fake_dir / "playwright" / "test_login_flow.py").write_text("# UI flow\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)
|
customs = discovery.custom_tests(fake_recipe, None)
|
||||||
names = sorted((src, os.path.basename(p)) for src, p in customs)
|
names = sorted((src, os.path.basename(p)) for src, p in customs)
|
||||||
|
|
||||||
# Top-level + functional/ + playwright/ all discovered; lifecycle name excluded
|
# functional/ + playwright/ discovered; top-level custom + lifecycle name are NOT
|
||||||
assert ("cc-ci", "test_sso_smoke.py") in names
|
|
||||||
assert ("cc-ci", "test_health_check.py") in names
|
assert ("cc-ci", "test_health_check.py") in names
|
||||||
assert ("cc-ci", "test_content_roundtrip.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_login_flow.py") in names
|
||||||
|
assert ("cc-ci", "test_sso_smoke.py") not in names
|
||||||
assert ("cc-ci", "test_install.py") not in names
|
assert ("cc-ci", "test_install.py") not in names
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user