diff --git a/.gitignore b/.gitignore index 427afc934d..494dec71aa 100644 --- a/.gitignore +++ b/.gitignore @@ -670,6 +670,7 @@ backend/backend/settings/* !backend/backend/settings/base.py !backend/backend/settings/dev.py !backend/backend/settings/test.py +!backend/backend/settings/test_base.py # Local Dependencies for docker testing unstract/unstract-sdk/ diff --git a/backend/backend/settings/test.py b/backend/backend/settings/test.py index 04fa607682..192fefa4c3 100644 --- a/backend/backend/settings/test.py +++ b/backend/backend/settings/test.py @@ -1,16 +1,8 @@ -from backend.settings.base import * # noqa: F401, F403 - -DEBUG = True +"""OSS test settings: the production base plus the shared test-only deltas. -# Django's default PBKDF2 hasher is deliberately slow; suites that seed several -# users per test spend most of their time there. Test fixtures need speed, not -# resistance to offline cracking. -PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] +The deltas live in `test_base` rather than here because `copy_cloud_deps` +replaces this file on a cloud build. +""" -# The organization-scoped MCP server ships disabled (see -# MCP_PLATFORM_SERVER_ENABLED in base.py), but its tests must still exercise -# it: several drive requests through the full URL stack precisely so the auth -# middleware runs, and an unmounted route would make them 404 — turning a suite -# that checks a credential is *rejected* into one that passes because nothing -# is there. Shipping-off and untested are different things. -MCP_PLATFORM_SERVER_ENABLED = True +from backend.settings.base import * # noqa: F401, F403 +from backend.settings.test_base import * # noqa: F401, F403 diff --git a/backend/backend/settings/test_base.py b/backend/backend/settings/test_base.py new file mode 100644 index 0000000000..76af7b3323 --- /dev/null +++ b/backend/backend/settings/test_base.py @@ -0,0 +1,19 @@ +"""Test-only setting deltas, shared by the OSS and cloud test settings. + +Imports nothing on purpose: `copy_cloud_deps` replaces the OSS `settings/test.py` +on a cloud build, so deltas kept only there are lost, and star-importing a base +module here would re-export its names over whatever the importer derived. +""" + +DEBUG = True + +# PBKDF2 is deliberately slow, and suites seeding users per test pay it repeatedly. +PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] + +# Prod enforces HTTPS-only cookies; tests run over plain HTTP. +SESSION_COOKIE_SECURE = False +CSRF_COOKIE_SECURE = False + +# Ships disabled, but its tests drive the full URL stack to exercise the auth +# middleware — an unmounted route would 404 and pass them vacuously. +MCP_PLATFORM_SERVER_ENABLED = True diff --git a/tests/rig/cli.py b/tests/rig/cli.py index f93fe622f4..caeafae07a 100644 --- a/tests/rig/cli.py +++ b/tests/rig/cli.py @@ -371,8 +371,19 @@ def cmd_report(args: argparse.Namespace) -> int: print(f"[rig] {exc}", file=sys.stderr) baseline = None baseline_corrupt = True + # A skipped tier emits no junit, so scoping to the groups that reported keeps + # its paths as gaps rather than regressions — nothing regressed, it never ran. + # A group that ran and went red still reports, so real regressions survive. + # `optional` groups are excluded: they are documented as non-blocking, and + # leaving them in scope would let a red one gate the build through a + # regression instead. + scope_groups = [r.name for r in group_results if not manifest.get(r.name).optional] statuses = cp.evaluate( - registry, groups_run_green=green, baseline=baseline, marker_proven=proven + registry, + groups_run_green=green, + baseline=baseline, + scope_groups=scope_groups, + marker_proven=proven, ) write_summary( reports_dir=reports_dir, @@ -388,6 +399,42 @@ def cmd_report(args: argparse.Namespace) -> int: f"(not in tests/critical_paths.yaml)", file=sys.stderr, ) + # cmd_run gates on the regressions it can see, but only within its own tier. + # This is the only cross-tier evaluation, so it is the only place a regression + # spanning tiers can be gated on. + # A covering group that ran green without attesting means its marked test + # was skipped or unmarked; a group that went red means the test failed. The + # remedies differ, so the two are reported apart rather than as one count. + unproven: list[cp.CriticalPathStatus] = [] + uncovered: list[cp.CriticalPathStatus] = [] + for s in statuses: + if s.state != "regression": + continue + target = unproven if any(g in green for g in s.path.covered_by) else uncovered + target.append(s) + regressions = unproven + uncovered + if uncovered: + ids = ", ".join(s.path.id for s in uncovered) + print( + f"\n[rig] ❌ {len(uncovered)} critical-path regression(s) — no covering " + f"group ran green: {ids}", + file=sys.stderr, + ) + if unproven: + ids = ", ".join(s.path.id for s in unproven) + print( + f"\n[rig] ❌ {len(unproven)} critical-path regression(s) — covering group " + f"ran green but no passing @pytest.mark.critical_path test attested " + f"them (skipped or unmarked?): {ids}", + file=sys.stderr, + ) + if regressions: + print( + "[rig] to accept a deliberate removal, drop or re-point the path in " + "tests/critical_paths.yaml in the same PR — the baseline is keyed off " + "that registry.", + file=sys.stderr, + ) if args.update_baseline: red = [ r.name @@ -404,7 +451,9 @@ def cmd_report(args: argparse.Namespace) -> int: return 1 cp.merge_into_baseline(statuses, baseline_path) print(f"[rig] merged into baseline: {baseline_path}") - return 1 if unknown_marker_ids else 0 + # A corrupt baseline makes "regression" unreachable, so the gate above would + # pass vacuously; it has to fail on its own. + return 1 if unknown_marker_ids or regressions or baseline_corrupt else 0 def cmd_run(args: argparse.Namespace) -> int: diff --git a/tests/rig/critical_paths.py b/tests/rig/critical_paths.py index e9cb3ccbbb..3b6a72c814 100644 --- a/tests/rig/critical_paths.py +++ b/tests/rig/critical_paths.py @@ -226,35 +226,56 @@ def merge_into_baseline(statuses: list[CriticalPathStatus], destination: Path) - treated as empty: silently dropping previously-covered paths would erase the other tier's contribution and turn the next build into a regression festival. CI should delete the cache and retry on this exception. + + Ids absent from the registry are pruned. The union alone never forgets, so a + deliberately retired path would otherwise sit in the cache forever; pruning + makes editing ``critical_paths.yaml`` the way to accept a removal. """ existing: set[str] = set() if destination.exists(): try: - parsed = json.loads(destination.read_text()) - existing = set(parsed.get("covered_paths") or []) - except (json.JSONDecodeError, OSError) as exc: + existing = set(_read_baseline(destination)["covered_paths"]) + except (OSError, ValueError) as exc: raise BaselineCorruptError( f"refusing to merge into corrupt baseline {destination}: {exc}. " "Delete the cache entry and re-run." ) from exc fresh = {s.path.id for s in statuses if s.state == "covered"} - payload = {"covered_paths": sorted(existing | fresh)} + known = {s.path.id for s in statuses} + payload = {"covered_paths": sorted((existing | fresh) & known)} destination.parent.mkdir(parents=True, exist_ok=True) destination.write_text(json.dumps(payload, indent=2)) +def _read_baseline(source: Path) -> dict[str, Any]: + """Parse a baseline file and check its shape. + + Well-formed JSON of the wrong shape is as unusable as a truncated file, and + would otherwise surface as an ``AttributeError``/``TypeError`` from whichever + caller touched it first. Raises ``OSError`` or ``ValueError`` + (``JSONDecodeError`` is one) for callers to translate. + """ + parsed = json.loads(source.read_text()) + if not isinstance(parsed, dict): + raise ValueError(f"expected a JSON object, got {type(parsed).__name__}") + covered = parsed.get("covered_paths") or [] + if not isinstance(covered, list) or not all(isinstance(p, str) for p in covered): + raise ValueError("'covered_paths' must be a list of strings") + return {**parsed, "covered_paths": covered} + + def load_baseline(source: Path) -> dict[str, Any] | None: """Load the cached baseline. Returns None if the file doesn't exist (first build / fresh cache). - Raises :class:`BaselineCorruptError` if the file exists but is unreadable - or unparseable — see :func:`merge_into_baseline` for the rationale. + Raises :class:`BaselineCorruptError` if the file exists but is unreadable, + unparseable, or misshapen — see :func:`merge_into_baseline` for the rationale. """ if not source.exists(): return None try: - return json.loads(source.read_text()) - except (json.JSONDecodeError, OSError) as exc: + return _read_baseline(source) + except (OSError, ValueError) as exc: raise BaselineCorruptError( f"baseline at {source} is unreadable: {exc}. " "Delete the cache entry and re-run." diff --git a/tests/rig/reporting.py b/tests/rig/reporting.py index 4c8ed8eda4..897b9f895e 100644 --- a/tests/rig/reporting.py +++ b/tests/rig/reporting.py @@ -318,7 +318,15 @@ def _render_markdown( if critical_statuses: regressions = [s for s in critical_statuses if s.state == "regression"] - gaps = [s for s in critical_statuses if s.state == "gap"] + # A path whose covering groups exist but reported nothing is not "not yet + # covered" — listing it as such reads as missing tests. + unexercised: list[CriticalPathStatus] = [] + gaps: list[CriticalPathStatus] = [] + for s in critical_statuses: + if s.state != "gap": + continue + target = unexercised if not s.in_scope and s.path.covered_by else gaps + target.append(s) covered = [s for s in critical_statuses if s.state == "covered"] lines.append("## Critical paths") @@ -339,6 +347,21 @@ def _render_markdown( f"(declared coverage: {covers})" ) lines.append("") + if unexercised: + lines.append( + "
💤 Covered, but not exercised in this " + "build" + ) + lines.append("") + for s in unexercised: + covers = ", ".join(s.path.covered_by) + lines.append( + f"- **{s.path.id}** — {s.path.description} " + f"(covered by {covers}; no result reported in this build)" + ) + lines.append("") + lines.append("
") + lines.append("") if covered: lines.append("
✅ Covered critical paths") lines.append("") diff --git a/tests/rig/tests/test_cli.py b/tests/rig/tests/test_cli.py index 17138bf6dc..53495a24db 100644 --- a/tests/rig/tests/test_cli.py +++ b/tests/rig/tests/test_cli.py @@ -955,3 +955,162 @@ def test_resolve_configfile_ignores_commented_section(tmp_path: Path) -> None: child.mkdir() assert cli._resolve_pytest_configfile(child) is None + + +def _drive_cmd_report( + tmp_path: Path, + monkeypatch, + *, + reporting_groups: dict[str, int], + baseline_blob: str = '{"covered_paths": ["int-path"]}', + optional: bool = False, +) -> tuple[int, dict[str, str]]: + """Drive ``cmd_report`` over one integration group covering ``int-path``. + + ``reporting_groups`` maps group name -> failure count for the groups that + emitted junit this build. A group absent from it emitted nothing, standing + in for a tier the CI path filter skipped. ``baseline_blob`` is written as the + cached baseline verbatim, so a caller can hand over an unparseable one. + Returns the exit code and each path's final state. + """ + from tests.rig.reporting import GroupResult + + test_dir = Path(__file__).parent + manifest_yaml = ( + "version: 1\n" + "groups:\n" + " int-g:\n" + " tier: integration\n" + f" workdir: {test_dir}\n" + " paths: [.]\n" + f" optional: {str(optional).lower()}\n" + ) + (tmp_path / "groups.yaml").write_text(manifest_yaml) + (tmp_path / "critical_paths.yaml").write_text( + "version: 1\n" + "paths:\n" + " - id: int-path\n" + " description: covered only by the integration tier\n" + " covered_by: [int-g]\n" + ) + + import tests.rig.cli as cli_mod + import tests.rig.critical_paths as cp_mod + import tests.rig.groups as groups_mod + + monkeypatch.setattr(groups_mod, "DEFAULT_MANIFEST", tmp_path / "groups.yaml") + monkeypatch.setattr(cp_mod, "DEFAULT_REGISTRY", tmp_path / "critical_paths.yaml") + # Coverage combining needs real .coverage files and is not under test here. + monkeypatch.setattr(cli_mod, "combine_and_report", lambda reports_dir: None) + + def fake_parse_junit(name, tier, reports_dir): + if name not in reporting_groups: + return None + failed = reporting_groups[name] + return GroupResult( + name=name, + tier=tier, + exit_code=1 if failed else 0, + passed=0 if failed else 1, + failed=failed, + errors=0, + skipped=0, + duration_seconds=0.01, + ) + + monkeypatch.setattr(cli_mod, "parse_junit", fake_parse_junit) + + states: dict[str, str] = {} + real_evaluate = cp_mod.evaluate + + def spy_evaluate(*args, **kwargs): + statuses = real_evaluate(*args, **kwargs) + states.update({s.path.id: s.state for s in statuses}) + return statuses + + monkeypatch.setattr(cli_mod.cp, "evaluate", spy_evaluate) + + reports_dir = tmp_path / "reports" + reports_dir.mkdir() + # Baseline says the path was green on main, which is what makes the + # not-covered-now decision a regression-or-gap question at all. + (reports_dir / "previous-summary.json").write_text(baseline_blob) + + args = cli_mod._build_parser().parse_args( + [ + "report", + "combine", + "--reports-dir", + str(reports_dir), + "--baseline", + str(reports_dir / "previous-summary.json"), + ] + ) + return cli_mod.cmd_report(args), states + + +def test_cmd_report_skipped_tier_is_a_gap_not_a_regression( + tmp_path: Path, monkeypatch +) -> None: + """A path filter can skip a whole tier, whose groups then emit no junit. Its + paths must degrade to ``gap``: nothing regressed, the tier never ran. + """ + exit_code, states = _drive_cmd_report(tmp_path, monkeypatch, reporting_groups={}) + + assert states["int-path"] == "gap", ( + "a path whose tier did not run must not be called a regression; " + f"got {states['int-path']}" + ) + assert exit_code == 0 + + +def test_cmd_report_gates_on_a_real_regression(tmp_path: Path, monkeypatch) -> None: + """The covering group ran and went red, so the path really did regress. + Being the only cross-tier evaluation, ``cmd_report`` has to gate on it rather + than just render it into the comment. + """ + exit_code, states = _drive_cmd_report( + tmp_path, monkeypatch, reporting_groups={"int-g": 1} + ) + + assert states["int-path"] == "regression" + assert exit_code == 1, ( + "a regression must fail the report job, not just print into the comment" + ) + + +def test_cmd_report_does_not_gate_on_an_optional_group( + tmp_path: Path, monkeypatch +) -> None: + """``optional`` groups are documented as non-blocking, and ``cmd_run`` honours + that. A red optional group must not reach the gate here through a regression, + or the two commands disagree about the same result. + """ + exit_code, states = _drive_cmd_report( + tmp_path, monkeypatch, reporting_groups={"int-g": 1}, optional=True + ) + + assert states["int-path"] == "gap", ( + f"an optional group's red result must not read as a regression; " + f"got {states['int-path']}" + ) + assert exit_code == 0 + + +def test_cmd_report_gates_on_a_corrupt_baseline(tmp_path: Path, monkeypatch) -> None: + """An unreadable baseline leaves ``previously_covered`` empty, making the + regression state unreachable. Without gating on the corruption itself, the + job goes green with regression detection silently off. + """ + exit_code, states = _drive_cmd_report( + tmp_path, + monkeypatch, + reporting_groups={"int-g": 1}, + baseline_blob='{"covered_paths": ["int-pat', + ) + + assert states["int-path"] == "gap", ( + "with no usable baseline the regression state is unreachable, which is " + "why the corrupt flag must gate on its own" + ) + assert exit_code == 1 diff --git a/tests/rig/tests/test_critical_paths.py b/tests/rig/tests/test_critical_paths.py index 214c958d62..903e7c0d5e 100644 --- a/tests/rig/tests/test_critical_paths.py +++ b/tests/rig/tests/test_critical_paths.py @@ -59,17 +59,20 @@ def test_regression_when_baseline_covered_but_now_not() -> None: def test_baseline_merge_unions_with_existing(tmp_path: Path) -> None: - """Two tier runs in sequence must both contribute to the baseline.""" + """Two tier runs in sequence must both contribute to the baseline. + + Every invocation reads the same registry and differs only in which groups + ran, so each one sees the other tier's paths as uncovered — the union is + what stops the second run erasing the first. + """ baseline = tmp_path / "previous-summary.json" - registry_a = _registry(("p1", ("g1",))) - statuses_a = evaluate( - registry_a, groups_run_green={"g1"}, baseline=None - ) + registry = _registry(("p1", ("g1",)), ("p2", ("g2",))) + + statuses_a = evaluate(registry, groups_run_green={"g1"}, baseline=None) merge_into_baseline(statuses_a, baseline) - registry_b = _registry(("p2", ("g2",))) statuses_b = evaluate( - registry_b, groups_run_green={"g2"}, baseline=load_baseline(baseline) + registry, groups_run_green={"g2"}, baseline=load_baseline(baseline) ) merge_into_baseline(statuses_b, baseline) @@ -77,6 +80,29 @@ def test_baseline_merge_unions_with_existing(tmp_path: Path) -> None: assert sorted(final["covered_paths"]) == ["p1", "p2"] +def test_baseline_merge_prunes_paths_dropped_from_the_registry(tmp_path: Path) -> None: + """Retiring a path must be able to clear it from the baseline. + + The union never forgets on its own, so without pruning a deliberately + removed path stays cached forever and there is no in-repo way to accept the + removal. + """ + baseline = tmp_path / "previous-summary.json" + before = _registry(("p1", ("g1",)), ("p2", ("g2",))) + merge_into_baseline( + evaluate(before, groups_run_green={"g1", "g2"}, baseline=None), baseline + ) + assert sorted((load_baseline(baseline) or {})["covered_paths"]) == ["p1", "p2"] + + after = _registry(("p1", ("g1",))) + merge_into_baseline( + evaluate(after, groups_run_green={"g1"}, baseline=load_baseline(baseline)), + baseline, + ) + + assert (load_baseline(baseline) or {})["covered_paths"] == ["p1"] + + def test_by_id_lookup_caches() -> None: registry = _registry(("p1", ("g1",)), ("p2", ())) # Two lookups must return identical instances; tests both correctness and @@ -117,6 +143,27 @@ def test_load_baseline_raises_on_corrupt_file(tmp_path: Path) -> None: load_baseline(baseline) +@pytest.mark.parametrize( + "blob", ["[]", '{"covered_paths": 1}', '{"covered_paths": ["ok", 2]}'] +) +def test_baseline_of_the_wrong_shape_is_corrupt(tmp_path: Path, blob: str) -> None: + """Parseable JSON of the wrong shape is as unusable as a truncated file. + + Left unchecked it surfaces as an ``AttributeError``/``TypeError`` from + whichever caller touched it first, rather than the handled corrupt path. + """ + baseline = tmp_path / "previous-summary.json" + baseline.write_text(blob) + registry = _registry(("p1", ("g1",))) + + with pytest.raises(BaselineCorruptError): + load_baseline(baseline) + with pytest.raises(BaselineCorruptError): + merge_into_baseline( + evaluate(registry, groups_run_green={"g1"}, baseline=None), baseline + ) + + def test_merge_raises_on_corrupt_existing_baseline(tmp_path: Path) -> None: """merge_into_baseline must not silently overwrite a corrupt file — that would erase the other tier's previously-covered paths.