diff --git a/heart/checks/test_run.py b/heart/checks/test_run.py index b12bce6..c9ec0be 100644 --- a/heart/checks/test_run.py +++ b/heart/checks/test_run.py @@ -225,10 +225,6 @@ def run(results_dir: Path | None = None, fetch_cloud: bool | None = None) -> dic summary["cloud_url"] = cloud["url"] summary["source"] = "cloud" - sys.path.insert(0, str(HEART_HOME)) - from heart import state - - state.atomic_write_json(HEART_STATE_DIR / "test_run.json", summary) return summary @@ -237,6 +233,12 @@ def main(argv: list[str]) -> int: summary = run(results_dir) sys.path.insert(0, str(HEART_HOME)) + from heart import state + + # Persist only here, at the tick/CLI entrypoint — run() is side-effect-free + # so library callers (and the test suite) can never clobber live state. + state.atomic_write_json(HEART_STATE_DIR / "test_run.json", summary) + from heart.heart_color import c_ok, c_warn, c_fail, c_info, c_meta, glyph_ok, glyph_warn, glyph_fail if not summary: diff --git a/heart/checks/version_skew.py b/heart/checks/version_skew.py index 0f330eb..7e36086 100644 --- a/heart/checks/version_skew.py +++ b/heart/checks/version_skew.py @@ -157,16 +157,18 @@ def run(root: Path = PYAUTO_ROOT) -> dict[str, Any]: } ) result = {"workspaces": workspaces} - sys.path.insert(0, str(HEART_HOME)) - from heart import state - - state.atomic_write_json(HEART_STATE_DIR / "version_skew.json", result) return result def main(argv: list[str]) -> int: result = run() sys.path.insert(0, str(HEART_HOME)) + from heart import state + + # Persist only here, at the tick/CLI entrypoint — run() is side-effect-free + # so library callers (and the test suite) can never clobber live state. + state.atomic_write_json(HEART_STATE_DIR / "version_skew.json", result) + from heart.heart_color import c_ok, c_warn, c_fail, c_info, c_meta, glyph_ok, glyph_warn, glyph_fail workspaces = result["workspaces"] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7fc695e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,16 @@ +"""tests/conftest.py — sandbox HEART_STATE_DIR for the whole suite. + +heart/checks/* resolve HEART_STATE_DIR at import time, so this assignment must +happen before any test module imports them; conftest import is pytest's +earliest hook, which is why this is a module-level statement and not a fixture. +Belt-and-braces on top of the checks' run()/main() split (state writes live +only in main()): no test, present or future, can reach the developer's live +~/.pyauto-heart — that state is the input to the release gate. +""" + +from __future__ import annotations + +import os +import tempfile + +os.environ["HEART_STATE_DIR"] = tempfile.mkdtemp(prefix="pyauto-heart-test-state-") diff --git a/tests/test_test_run.py b/tests/test_test_run.py index 984c2fb..eb45884 100644 --- a/tests/test_test_run.py +++ b/tests/test_test_run.py @@ -150,3 +150,30 @@ def test_server_verdict_prefers_agent_file_over_gh(monkeypatch, tmp_path): def test_agent_supplied_verdict_absent_is_none(monkeypatch, tmp_path): monkeypatch.setattr(tr, "VALIDATION_FILE", tmp_path / "nope.json") assert tr._agent_supplied_verdict() is None + + +# --- state-dir isolation (the 2026-07-15 clobber incident) --------------------- + +def test_run_writes_nothing_to_state_dir(tmp_path): + """run() must be side-effect-free: the write lives in main() only, so tests + (and any library caller) can never clobber live Heart state.""" + import os + from pathlib import Path + state_dir = Path(os.environ["HEART_STATE_DIR"]) + before = set(state_dir.glob("**/*")) if state_dir.exists() else set() + (tmp_path / "report.json").write_text(json.dumps({ + "ready": True, "run_label": "R1", "summary": {"passed": 1}})) + tr.run(results_dir=tmp_path) + after = set(state_dir.glob("**/*")) if state_dir.exists() else set() + assert after == before + + +def test_main_persists_summary_to_state_dir(tmp_path): + """The tick path (python -m heart.checks.test_run) must still persist.""" + import os + from pathlib import Path + (tmp_path / "report.json").write_text(json.dumps({ + "ready": True, "run_label": "R1", "summary": {"passed": 1}})) + assert tr.main(["test_run", str(tmp_path)]) == 0 + written = json.loads((Path(os.environ["HEART_STATE_DIR"]) / "test_run.json").read_text()) + assert written["ready"] is True and written["passed"] == 1 diff --git a/tests/test_version_skew.py b/tests/test_version_skew.py index 28424f6..0fc255e 100644 --- a/tests/test_version_skew.py +++ b/tests/test_version_skew.py @@ -119,3 +119,28 @@ def test_autolens_assistant_is_a_pinned_workspace(): mapping = vs.workspace_library() assert "autolens_assistant" in mapping assert mapping["autolens_assistant"] == ("PyAutoLens", "autolens") + + +# --- state-dir isolation (the 2026-07-15 clobber incident's sibling) ----------- + +def test_run_writes_nothing_to_state_dir(tmp_path): + """run() must be side-effect-free: the write lives in main() only, so tests + (and any library caller) can never clobber live Heart state.""" + import os + from pathlib import Path + state_dir = Path(os.environ["HEART_STATE_DIR"]) + before = set(state_dir.glob("**/*")) if state_dir.exists() else set() + vs.run(root=tmp_path) + after = set(state_dir.glob("**/*")) if state_dir.exists() else set() + assert after == before + + +def test_main_persists_result_to_state_dir(monkeypatch): + """The tick path (python -m heart.checks.version_skew) must still persist.""" + import json + import os + from pathlib import Path + monkeypatch.setattr(vs, "run", lambda root=vs.PYAUTO_ROOT: {"workspaces": []}) + assert vs.main(["version_skew"]) == 0 + written = json.loads((Path(os.environ["HEART_STATE_DIR"]) / "version_skew.json").read_text()) + assert written == {"workspaces": []}