diff --git a/conftest.py b/conftest.py new file mode 100644 index 000000000..dfab6e8ef --- /dev/null +++ b/conftest.py @@ -0,0 +1,92 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc +"""Pytest fixture-leak detector. + +Captures `git status --porcelain -- tests/fixtures/` at session start and +diffs at session end. Fails the session with a named-path error when tests +leave the fixture tree dirty. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +_FIXTURE_ROOT = "tests/fixtures" +_BASELINE: set[tuple[str, str]] | None = None +_GIT_AVAILABLE = True + + +def _capture_status(repo_root: Path) -> set[tuple[str, str]] | None: + """Return the set of (status_XY, path) tuples from fixture-tree git status. + + Returns None when git is unavailable or the command fails (e.g. not a git + repo). + """ + try: + result = subprocess.run( + ["git", "status", "--porcelain", "--", _FIXTURE_ROOT], + cwd=repo_root, + capture_output=True, + text=True, + check=False, + ) + except FileNotFoundError: + return None + if result.returncode != 0: + return None + + entries: set[tuple[str, str]] = set() + for line in result.stdout.splitlines(): + if len(line) < 4: + continue + status = line[:2] + path = line[3:] + if " -> " in path: + path = path.split(" -> ", 1)[1] + entries.add((status, path)) + return entries + + +def _format_leak_message(new_entries: set[tuple[str, str]]) -> str: + lines = [f" {status} {path}" for status, path in sorted(new_entries)] + return ( + "\n" + "solstone fixture-leak detector: tests left tests/fixtures/ dirty\n" + + "\n".join(lines) + + "\n\n" + "To fix, use one of these isolation mechanisms:\n" + " - journal_copy fixture (tests/conftest.py:57) — copies tracked fixtures to tmp_path\n" + " - point _SOLSTONE_JOURNAL_OVERRIDE at a tmp_path directly\n" + " - mock the subprocess/write path so code never touches tests/fixtures/\n" + "\n" + "Prior incidents: f6f382a6, 2996e072\n" + ) + + +def pytest_sessionstart(session): + global _BASELINE, _GIT_AVAILABLE + + repo_root = session.config.rootpath + _BASELINE = _capture_status(repo_root) + if _BASELINE is None: + _GIT_AVAILABLE = False + sys.stderr.write("solstone fixture-leak detector: git unavailable, skipping\n") + + +def pytest_sessionfinish(session, exitstatus): + if not _GIT_AVAILABLE or _BASELINE is None: + return + + repo_root = session.config.rootpath + current = _capture_status(repo_root) + if current is None: + return + + new_entries = current - _BASELINE + if not new_entries: + return + + sys.stderr.write(_format_leak_message(new_entries)) + session.exitstatus = 1 diff --git a/tests/test_fixture_leak_detector.py b/tests/test_fixture_leak_detector.py new file mode 100644 index 000000000..1e472a68d --- /dev/null +++ b/tests/test_fixture_leak_detector.py @@ -0,0 +1,128 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc +"""Canary for the fixture-leak detector in the project-root conftest.py.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +pytest_plugins = ["pytester"] + +_ROOT_CONFTEST = Path(__file__).resolve().parent.parent / "conftest.py" + + +def _prime_git_repo(project_dir: Path) -> None: + """Initialise a miniature git repo with one tracked file under tests/fixtures/.""" + subprocess.run(["git", "init", "-q"], cwd=project_dir, check=True) + subprocess.run( + [ + "git", + "-c", + "commit.gpgsign=false", + "-c", + "user.email=canary@example.invalid", + "-c", + "user.name=canary", + "commit", + "--allow-empty", + "-q", + "-m", + "init", + ], + cwd=project_dir, + check=True, + ) + fixtures_dir = project_dir / "tests" / "fixtures" + fixtures_dir.mkdir(parents=True, exist_ok=True) + (fixtures_dir / "keep").write_text("kept\n", encoding="utf-8") + subprocess.run(["git", "add", "tests/fixtures/keep"], cwd=project_dir, check=True) + subprocess.run( + [ + "git", + "-c", + "commit.gpgsign=false", + "-c", + "user.email=canary@example.invalid", + "-c", + "user.name=canary", + "commit", + "-q", + "-m", + "fixtures", + ], + cwd=project_dir, + check=True, + ) + + +def _install_detector(pytester: pytest.Pytester) -> None: + """Copy the real root conftest.py into the pytester project root.""" + pytester.makepyfile(conftest=_ROOT_CONFTEST.read_text(encoding="utf-8")) + + +def _run_nested(pytester: pytest.Pytester) -> pytest.RunResult: + basetemp = pytester.path / "basetemp" + return pytester.run( + sys.executable, + "-mpytest", + "-q", + "-p", + "no:cacheprovider", + "--basetemp", + str(basetemp), + ) + + +@pytest.mark.timeout(30) +def test_detector_fires_on_leaked_file(pytester: pytest.Pytester) -> None: + _install_detector(pytester) + _prime_git_repo(pytester.path) + pytester.makepyfile( + test_leak=""" + from pathlib import Path + + def test_writes_into_fixtures(tmp_path): + Path("tests/fixtures/leak_probe.tmp").write_text("x") + """ + ) + result = _run_nested(pytester) + assert result.ret != 0, result.stderr.str() + result.stdout.str() + combined = result.stderr.str() + result.stdout.str() + assert "solstone fixture-leak detector" in combined + assert "tests/fixtures/leak_probe.tmp" in combined + assert "journal_copy fixture" in combined + + +@pytest.mark.timeout(30) +def test_detector_silent_on_clean(pytester: pytest.Pytester) -> None: + _install_detector(pytester) + _prime_git_repo(pytester.path) + pytester.makepyfile( + test_clean=""" + def test_noop(): + assert True + """ + ) + result = _run_nested(pytester) + assert result.ret == 0, result.stderr.str() + result.stdout.str() + combined = result.stderr.str() + result.stdout.str() + assert "fixture-leak detector" not in combined + + +@pytest.mark.timeout(30) +def test_detector_skips_without_git_repo(pytester: pytest.Pytester) -> None: + _install_detector(pytester) + pytester.makepyfile( + test_clean=""" + def test_noop(): + assert True + """ + ) + result = _run_nested(pytester) + assert result.ret == 0, result.stderr.str() + result.stdout.str() + combined = result.stderr.str() + result.stdout.str() + assert "git unavailable" in combined