"""Real fixture processes and Git worktrees; never invoke product/remote mutations.""" from argparse import Namespace import argparse import json from pathlib import Path import subprocess import sys import threading import time from unittest.mock import patch import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/devkit")) from govoplan_devkit import runner from govoplan_devkit.checkpoints import Checkpoints from govoplan_devkit.common import ( atomic_json, digest, read_json, resource_lock, state_root, ) from govoplan_devkit.workspace import load_project, source_fingerprint @pytest.fixture def example(tmp_path, monkeypatch): monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "xdg-state")) workspace = tmp_path / "workspace" repo = workspace / "example" repo.mkdir(parents=True) subprocess.run(["git", "init", "-q", str(repo)], check=True) (repo / "source.txt").write_text("first\n") subprocess.run(["git", "-C", str(repo), "add", "source.txt"], check=True) subprocess.run( [ "git", "-C", str(repo), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-qm", "fixture", ], check=True, ) project = tmp_path / "project.json" project.write_text( json.dumps( { "schema_version": 1, "name": "Example", "repositories": [{"name": "example", "path": "example"}], "checks": [], "profiles": {}, } ) ) args = Namespace( workspace_root=workspace, project=project, state_dir=tmp_path / "state", dry_run=False, jobs=2, profile="quick", resume=None, ) return args, repo def stage(repo, name="one", code="print('ok')", **extra): return { "id": name, "title": name, "argv": [sys.executable, "-c", code], "cwd": str(repo), "timeout_seconds": 10, **extra, } def run(args, stages): # Environment inspection is tested separately; keep fixture executions cheap/deterministic. with patch.object(runner, "environment_fingerprint", return_value="fixture-env"): return runner.run_checks(args, stages) def test_success_receipt_and_compact_summary(example): args, repo = example result = run(args, [stage(repo)]) assert result["status"] == "passed" receipt = runner.read_receipt(args.workspace_root, args.state_dir, result["run_id"]) assert receipt["stages"][0]["exit_code"] == 0 assert Path(receipt["stages"][0]["log_path"]).read_text() == "ok\n" assert runner.summarize(receipt)["counts"] == {"passed": 1} assert Path(result["receipt_path"]).stat().st_mode & 0o777 == 0o600 def test_native_environment_binds_selected_checkout(tmp_path, monkeypatch): from govoplan_devkit.environment import execution_environment monkeypatch.setenv("GOVOPLAN_WORKSPACE_ROOT", "/another/workspace") monkeypatch.setenv("GOVOPLAN_CORE_ROOT", "/another/core") monkeypatch.setenv("GOVOPLAN_CORE_SOURCE_ROOT", "/another/source") project = load_project(tmp_path) env = execution_environment( tmp_path, project, {"python": sys.executable, "node": "node", "npm": "npm"} ) assert env["GOVOPLAN_WORKSPACE_ROOT"] == str(tmp_path) assert env["GOVOPLAN_CORE_ROOT"] == str(tmp_path / "govoplan-core") assert env["GOVOPLAN_CORE_SOURCE_ROOT"] == str(tmp_path / "govoplan-core") def test_portable_environment_does_not_inject_govoplan_scope(example, monkeypatch): from govoplan_devkit.environment import execution_environment args, _ = example for key in ( "GOVOPLAN_WORKSPACE_ROOT", "GOVOPLAN_CORE_ROOT", "GOVOPLAN_CORE_SOURCE_ROOT", ): monkeypatch.delenv(key, raising=False) env = execution_environment( args.workspace_root, load_project(args.workspace_root, args.project), {"python": sys.executable, "node": "node", "npm": "npm"}, ) assert "GOVOPLAN_WORKSPACE_ROOT" not in env def test_failure_skips_dependents_but_runs_independent_check(example): args, repo = example result = run( args, [ stage(repo, "bad", "raise SystemExit(4)"), stage(repo, "dependent", deps=["bad"]), stage(repo, "independent"), ], ) states = {item["id"]: item["status"] for item in result["stages"]} assert states == {"bad": "failed", "dependent": "skipped", "independent": "passed"} assert result["_exit_code"] == 1 def test_timeout_is_not_a_pass(example): args, repo = example result = run( args, [stage(repo, code="import time; time.sleep(10)", timeout_seconds=0.1)] ) assert result["stages"][0]["status"] == "timed_out" def test_dry_run_does_not_create_state(example): args, repo = example args.dry_run = True result = run(args, [stage(repo, code="raise SystemExit(9)")]) assert result["status"] == "planned" assert not args.state_dir.exists() def test_empty_plan_is_not_claimed_as_verified(example): args, _ = example assert run(args, [])["status"] == "not_run" def test_plan_validation_rejects_cycles_unknowns_duplicate_ids_escape(example): args, repo = example invalid = [ [stage(repo, deps=["unknown"])], [stage(repo, "a", deps=["b"]), stage(repo, "b", deps=["a"])], [stage(repo), stage(repo)], [stage(repo, "../escape")], [stage(repo.parent.parent)], ] for plan in invalid: with pytest.raises(ValueError): runner.validate_stages(plan, args.workspace_root, {}) def test_source_identity_includes_staged_unstaged_and_untracked_bytes(example): args, repo = example project = load_project(args.workspace_root, args.project) first = source_fingerprint(project) (repo / "source.txt").write_text("second\n") second = source_fingerprint(project) assert second != first subprocess.run(["git", "-C", str(repo), "add", "source.txt"], check=True) third = source_fingerprint(project) assert third != second (repo / "extra.txt").write_text("extra") assert source_fingerprint(project) != third def test_source_mutation_during_run_invalidates_receipt(example): args, repo = example result = run( args, [ stage( repo, code="from pathlib import Path; Path('source.txt').write_text('changed')", ) ], ) assert result["stages"][0]["status"] == "stale" assert result["stages"][0]["checkpoint_verified"] is False assert result["status"] == "stale" def test_resume_reuses_only_identical_plan_and_inputs(example): args, repo = example plan = [stage(repo)] first = run(args, plan) args.resume = first["run_id"] second = run(args, plan) assert second["stages"][0]["reused_from"] == first["run_id"] changed_plan = run(args, [stage(repo, code="print('different')")]) assert changed_plan["status"] == "passed" assert "reused_from" not in changed_plan["stages"][0] assert Path(changed_plan["stages"][0]["log_path"]).read_text() == "different\n" (repo / "source.txt").write_text("changed") changed_source = run(args, plan) assert changed_source["status"] == "passed" assert "reused_from" not in changed_source["stages"][0] assert changed_source["stages"][0]["cache_key"] != first["stages"][0]["cache_key"] def test_receipt_integrity_and_foreign_id_are_rejected(example): args, repo = example result = run(args, [stage(repo)]) path = Path(result["receipt_path"]) record = read_json(path) record["status"] = "fabricated" atomic_json(path, record) with pytest.raises(ValueError, match="integrity"): runner.read_receipt(args.workspace_root, args.state_dir, result["run_id"]) with pytest.raises(ValueError): runner.read_receipt(args.workspace_root, args.state_dir, "../other") def test_shared_resources_serialize_stages(example): args, repo = example active = 0 maximum = 0 lock = threading.Lock() original = runner._execute_stage_command def execute(*args, **kwargs): nonlocal active, maximum with lock: active += 1 maximum = max(maximum, active) try: time.sleep(0.04) return original(*args, **kwargs) finally: with lock: active -= 1 with patch.object(runner, "_execute_stage_command", side_effect=execute): result = run( args, [ stage(repo, "a", resources=["shared"]), stage(repo, "b", resources=["shared"]), ], ) assert result["status"] == "passed" assert maximum == 1 def test_cross_process_resource_conflict_is_explicit(example): args, repo = example locks = state_root(args.workspace_root) / "resource-locks" with resource_lock(locks, "shared"): result = run(args, [stage(repo, resources=["shared"])]) assert result["stages"][0]["status"] == "blocked" def test_output_is_bounded_and_known_secrets_redacted(example, monkeypatch): args, repo = example monkeypatch.setenv("FIXTURE_SECRET", "private-fixture-credential") with patch.object(runner, "MAX_LOG_BYTES", 1024): result = run( args, [ stage( repo, code="import os; print(os.environ['FIXTURE_SECRET']); print('x'*10000)", ) ], ) record = result["stages"][0] output = Path(record["log_path"]).read_text() assert record["output_truncated"] is True assert "private-fixture-credential" not in output assert "[redacted]" in output assert len(output) < 2000 def test_symlinked_state_is_rejected(example, tmp_path): args, repo = example target = tmp_path / "target" target.mkdir() args.state_dir.symlink_to(target, target_is_directory=True) with pytest.raises(ValueError, match="symlink"): run(args, [stage(repo)]) @pytest.mark.parametrize("flag", ["--assume-unchanged", "--skip-worktree"]) def test_hidden_tracked_edits_invalidate_source_identity(example, flag): args, repo = example subprocess.run( ["git", "-C", str(repo), "update-index", flag, "source.txt"], check=True ) project = load_project(args.workspace_root, args.project) initial = source_fingerprint(project) (repo / "source.txt").write_text("hidden edit\n") assert source_fingerprint(project) != initial def test_failed_final_snapshot_never_persists_a_passing_receipt(example): args, repo = example events = [] args.on_progress = events.append original = Checkpoints.source def fingerprint(self, *values, **kwargs): if events and events[-1]["phase"] == "finalizing": raise ValueError("unreadable source") return original(self, *values, **kwargs) with patch.object(Checkpoints, "source", fingerprint): result = run(args, [stage(repo)]) assert result["status"] == "stale" assert result["snapshot_verified"] is False assert result["_exit_code"] == 1 assert "unreadable source" in result["invalidated_reason"] assert ( runner.read_receipt(args.workspace_root, args.state_dir, result["run_id"])[ "status" ] == "stale" ) def test_resume_rejects_a_modified_cached_log(example): args, repo = example plan = [stage(repo)] first = run(args, plan) Path(first["stages"][0]["log_path"]).write_text("altered") args.resume = first["run_id"] result = run(args, plan) assert result["status"] == "failed" assert result["stages"][0]["status"] == "failed" assert "log integrity" in result["stages"][0]["error"] assert "reused_from" not in result["stages"][0] def test_receipt_argument_redaction_applies_to_unknown_separate_values(example): args, repo = example plan = stage(repo) plan["argv"].extend(["--password", "fixture-not-from-environment"]) result = run(args, [plan]) assert ( "fixture-not-from-environment" not in Path(result["receipt_path"]).read_text() ) def recovery_parser(): parser = argparse.ArgumentParser() runner.register(parser.add_subparsers()) return parser def test_recovery_requires_manual_survivor_confirmation(example): args, repo = example result = run(args, [stage(repo)]) record = read_json(Path(result["receipt_path"])) record["status"] = "running" record["snapshot_verified"] = False atomic_json(Path(result["receipt_path"]), runner._seal(record)) parsed = recovery_parser().parse_args( ["recover", result["run_id"], "--apply"], namespace=args ) with pytest.raises(ValueError, match="manual verification"): parsed.handler(parsed) def test_recovery_does_not_overwrite_completion_between_reads(example): args, repo = example result = run(args, [stage(repo)]) completed = runner.read_receipt( args.workspace_root, args.state_dir, result["run_id"] ) initial = {**completed, "status": "running"} parsed = recovery_parser().parse_args( ["recover", result["run_id"], "--apply", "--confirm-processes-stopped"], namespace=args, ) with ( patch.object(runner, "read_receipt", side_effect=[initial, completed]), patch.object(runner, "atomic_json") as write, ): assert parsed.handler(parsed)["status"] == "unchanged" write.assert_not_called() def test_active_owner_lock_blocks_recovery(example): args, repo = example result = run(args, [stage(repo)]) record = read_json(Path(result["receipt_path"])) record.update(status="running", snapshot_verified=False) atomic_json(Path(result["receipt_path"]), runner._seal(record)) parsed = recovery_parser().parse_args( ["recover", result["run_id"], "--apply", "--confirm-processes-stopped"], namespace=args, ) with resource_lock( state_root(args.workspace_root, args.state_dir) / "locks", "run:" + result["run_id"], ): with pytest.raises(RuntimeError, match="busy"): parsed.handler(parsed) @pytest.mark.parametrize( "mutation", [ "unknown_status", "unverified_pass", "duplicate_stage", "nonboolean_verification", "nonpassing_stage", ], ) def test_even_correctly_hashed_receipts_must_have_consistent_states(example, mutation): args, repo = example result = run(args, [stage(repo)]) path = Path(result["receipt_path"]) record = read_json(path) if mutation == "unknown_status": record["status"] = "wonderful" elif mutation == "unverified_pass": record["snapshot_verified"] = False elif mutation == "duplicate_stage": record["stages"].append(record["stages"][0]) elif mutation == "nonboolean_verification": record["snapshot_verified"] = "true" else: record["stages"][0]["status"] = "failed" # Deliberately construct an externally corrupted but correctly hashed record; # the runner's writer now rejects inconsistent checkpoint state before sealing. record["integrity_sha256"] = digest( {key: value for key, value in record.items() if key != "integrity_sha256"} ) atomic_json(path, record) with pytest.raises(ValueError): runner.read_receipt(args.workspace_root, args.state_dir, result["run_id"])