From 831b9141a5b2461a973ee48d49a459686fbe7033 Mon Sep 17 00:00:00 2001 From: Paul Hernandez <60959+phernandez@users.noreply.github.com> Date: Tue, 9 Jun 2026 19:20:08 -0500 Subject: [PATCH] ci(ci): use pytestmon and Depot runners (#928) Signed-off-by: phernandez --- .github/workflows/docker.yml | 14 +- .github/workflows/test.yml | 93 ++++++++++++-- justfile | 65 ++++++---- scripts/testmon_cache.py | 225 +++++++++++++++++++++++++++++++++ tests/ci/test_testmon_cache.py | 114 +++++++++++++++++ 5 files changed, 471 insertions(+), 40 deletions(-) create mode 100644 scripts/testmon_cache.py create mode 100644 tests/ci/test_testmon_cache.py diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index c0877ad6..8fae7784 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -13,9 +13,10 @@ env: jobs: docker: - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 permissions: contents: read + id-token: write packages: write steps: @@ -24,10 +25,8 @@ jobs: with: fetch-depth: 0 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - with: - platforms: linux/amd64,linux/arm64 + - name: Set up Depot + uses: depot/setup-action@v1 - name: Log in to GitHub Container Registry uses: docker/login-action@v4 @@ -49,13 +48,12 @@ jobs: type=raw,value=latest,enable={{is_default_branch}} - name: Build and push Docker image - uses: docker/build-push-action@v7 + uses: depot/build-push-action@v1 with: + project: ${{ vars.DEPOT_BASIC_MEMORY_PROJECT_ID || vars.DEPOT_PROJECT_ID }} context: . file: ./Dockerfile platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 34587957..ae750499 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,12 +13,15 @@ on: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + # Required CI records pytest-testmon data but still runs the full selected suite. + # The selective mode stays on explicit developer flows such as `just testmon`. + BASIC_MEMORY_TESTMON_FLAGS: "--testmon-noselect" jobs: static-checks: name: Static Checks (Python 3.12) timeout-minutes: 20 - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 steps: - uses: actions/checkout@v6 @@ -60,10 +63,12 @@ jobs: fail-fast: false matrix: include: - - os: ubuntu-latest + - os: depot-ubuntu-24.04 python-version: "3.12" - - os: ubuntu-latest + - os: depot-ubuntu-24.04 python-version: "3.13" + # Python 3.14 unit tests are the longest full-suite slice; keep this + # one on GitHub-hosted runners after Depot terminated it mid-suite. - os: ubuntu-latest python-version: "3.14" - os: windows-latest @@ -87,6 +92,19 @@ jobs: - uses: extractions/setup-just@v4 + - name: Cache pytest-testmon results + uses: actions/cache@v4 + with: + path: | + .testmondata + .testmondata-shm + .testmondata-wal + key: ${{ runner.os }}-testmon-sqlite-unit-py${{ matrix.python-version }}-${{ github.ref_name }}-${{ github.run_id }} + restore-keys: | + ${{ runner.os }}-testmon-sqlite-unit-py${{ matrix.python-version }}-${{ github.ref_name }}- + ${{ runner.os }}-testmon-sqlite-unit-py${{ matrix.python-version }}-main- + ${{ runner.os }}-testmon-sqlite-unit-py${{ matrix.python-version }}- + - name: Create virtual env run: | uv venv @@ -106,11 +124,11 @@ jobs: fail-fast: false matrix: include: - - os: ubuntu-latest + - os: depot-ubuntu-24.04 python-version: "3.12" - - os: ubuntu-latest + - os: depot-ubuntu-24.04 python-version: "3.13" - - os: ubuntu-latest + - os: depot-ubuntu-24.04 python-version: "3.14" - os: windows-latest python-version: "3.12" @@ -133,6 +151,19 @@ jobs: - uses: extractions/setup-just@v4 + - name: Cache pytest-testmon results + uses: actions/cache@v4 + with: + path: | + .testmondata + .testmondata-shm + .testmondata-wal + key: ${{ runner.os }}-testmon-sqlite-integration-py${{ matrix.python-version }}-${{ github.ref_name }}-${{ github.run_id }} + restore-keys: | + ${{ runner.os }}-testmon-sqlite-integration-py${{ matrix.python-version }}-${{ github.ref_name }}- + ${{ runner.os }}-testmon-sqlite-integration-py${{ matrix.python-version }}-main- + ${{ runner.os }}-testmon-sqlite-integration-py${{ matrix.python-version }}- + - name: Create virtual env run: | uv venv @@ -153,9 +184,14 @@ jobs: matrix: include: - python-version: "3.12" + os: depot-ubuntu-24.04 - python-version: "3.13" + os: depot-ubuntu-24.04 + # Match the SQLite unit slice: this full Python 3.14 path outlived + # the Depot runner and was terminated mid-suite. - python-version: "3.14" - runs-on: ubuntu-latest + os: ubuntu-latest + runs-on: ${{ matrix.os }} services: postgres: image: pgvector/pgvector:pg16 @@ -190,6 +226,19 @@ jobs: - uses: extractions/setup-just@v4 + - name: Cache pytest-testmon results + uses: actions/cache@v4 + with: + path: | + .testmondata + .testmondata-shm + .testmondata-wal + key: ${{ runner.os }}-testmon-postgres-unit-py${{ matrix.python-version }}-${{ github.ref_name }}-${{ github.run_id }} + restore-keys: | + ${{ runner.os }}-testmon-postgres-unit-py${{ matrix.python-version }}-${{ github.ref_name }}- + ${{ runner.os }}-testmon-postgres-unit-py${{ matrix.python-version }}-main- + ${{ runner.os }}-testmon-postgres-unit-py${{ matrix.python-version }}- + - name: Create virtual env run: | uv venv @@ -212,7 +261,7 @@ jobs: - python-version: "3.12" - python-version: "3.13" - python-version: "3.14" - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 services: postgres: image: pgvector/pgvector:pg16 @@ -247,6 +296,19 @@ jobs: - uses: extractions/setup-just@v4 + - name: Cache pytest-testmon results + uses: actions/cache@v4 + with: + path: | + .testmondata + .testmondata-shm + .testmondata-wal + key: ${{ runner.os }}-testmon-postgres-integration-py${{ matrix.python-version }}-${{ github.ref_name }}-${{ github.run_id }} + restore-keys: | + ${{ runner.os }}-testmon-postgres-integration-py${{ matrix.python-version }}-${{ github.ref_name }}- + ${{ runner.os }}-testmon-postgres-integration-py${{ matrix.python-version }}-main- + ${{ runner.os }}-testmon-postgres-integration-py${{ matrix.python-version }}- + - name: Create virtual env run: | uv venv @@ -262,7 +324,7 @@ jobs: test-semantic: name: Test Semantic (Python 3.12) timeout-minutes: 45 - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 steps: - uses: actions/checkout@v6 @@ -281,6 +343,19 @@ jobs: - uses: extractions/setup-just@v4 + - name: Cache pytest-testmon results + uses: actions/cache@v4 + with: + path: | + .testmondata + .testmondata-shm + .testmondata-wal + key: ${{ runner.os }}-testmon-semantic-py3.12-${{ github.ref_name }}-${{ github.run_id }} + restore-keys: | + ${{ runner.os }}-testmon-semantic-py3.12-${{ github.ref_name }}- + ${{ runner.os }}-testmon-semantic-py3.12-main- + ${{ runner.os }}-testmon-semantic-py3.12- + - name: Create virtual env run: | uv venv diff --git a/justfile b/justfile index ecbd12ef..2c4c04ab 100644 --- a/justfile +++ b/justfile @@ -1,5 +1,9 @@ # Basic Memory - Modern Command Runner +TESTMON_FLAGS := env_var_or_default("BASIC_MEMORY_TESTMON_FLAGS", "--testmon-noselect") +TESTMON_SELECT_FLAGS := env_var_or_default("BASIC_MEMORY_TESTMON_SELECT_FLAGS", "--testmon --testmon-forceselect") +TESTMON_REFRESH_FLAGS := env_var_or_default("BASIC_MEMORY_TESTMON_REFRESH_FLAGS", "--testmon-noselect") + # Install dependencies install: uv sync @@ -35,40 +39,55 @@ test-sqlite: test-unit-sqlite test-int-sqlite test-postgres: test-unit-postgres test-int-postgres # Run unit tests against SQLite -test-unit-sqlite: - BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov tests +test-unit-sqlite: testmon-seed + BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov {{TESTMON_FLAGS}} --testmon-env=unit-sqlite tests # Run unit tests against Postgres -test-unit-postgres: - BASIC_MEMORY_ENV=test BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov tests +test-unit-postgres: testmon-seed + BASIC_MEMORY_ENV=test BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov {{TESTMON_FLAGS}} --testmon-env=unit-postgres tests # Run integration tests against SQLite (excludes semantic benchmarks — use just test-semantic) -test-int-sqlite: - BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov -m "not semantic" test-int +test-int-sqlite: testmon-seed + BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov {{TESTMON_FLAGS}} --testmon-env=int-sqlite -m "not semantic" test-int # Run integration tests against Postgres # Note: Uses timeout due to FastMCP Client + asyncpg cleanup hang (tests pass, process hangs on exit) # See: https://github.com/jlowin/fastmcp/issues/1311 -test-int-postgres: +test-int-postgres: testmon-seed #!/usr/bin/env bash set -euo pipefail # Use gtimeout (macOS/Homebrew) or timeout (Linux) TIMEOUT_CMD=$(command -v gtimeout || command -v timeout || echo "") if [[ -n "$TIMEOUT_CMD" ]]; then - $TIMEOUT_CMD --signal=KILL 600 bash -c 'BASIC_MEMORY_ENV=test BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov -m "not semantic" test-int' || test $? -eq 137 + $TIMEOUT_CMD --signal=KILL 600 bash -c 'BASIC_MEMORY_ENV=test BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov {{TESTMON_FLAGS}} --testmon-env=int-postgres -m "not semantic" test-int' || test $? -eq 137 else echo "⚠️ No timeout command found, running without timeout..." - BASIC_MEMORY_ENV=test BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov -m "not semantic" test-int + BASIC_MEMORY_ENV=test BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov {{TESTMON_FLAGS}} --testmon-env=int-postgres -m "not semantic" test-int fi # Run tests impacted by recent changes (requires pytest-testmon) # Pass paths or node ids after `just testmon` to limit the candidate set further. -testmon *args: - BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov --testmon {{args}} +testmon *args: testmon-seed + BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov {{TESTMON_SELECT_FLAGS}} --testmon-env=local {{args}} + +# Seed pytest-testmon data into this worktree from the shared Git cache. +testmon-seed: + uv run python scripts/testmon_cache.py seed + +# Refresh the shared pytest-testmon cache from a full backend test run. +testmon-refresh: + #!/usr/bin/env bash + set -euo pipefail + BASIC_MEMORY_TESTMON_FLAGS="{{TESTMON_REFRESH_FLAGS}}" just test + uv run python scripts/testmon_cache.py refresh + +# Show local and shared pytest-testmon cache locations. +testmon-status: + uv run python scripts/testmon_cache.py status # Run MCP smoke test (fast end-to-end loop) -test-smoke: - BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov -m smoke test-int/mcp/test_smoke_integration.py +test-smoke: testmon-seed + BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov {{TESTMON_FLAGS}} --testmon-env=smoke -m smoke test-int/mcp/test_smoke_integration.py # Fast local loop: lint, format, typecheck, impacted tests via pytest-testmon fast-check: @@ -97,18 +116,18 @@ postgres-migrate: # Run Windows-specific tests only (only works on Windows platform) # These tests verify Windows-specific database optimizations (locking mode, NullPool) # Will be skipped automatically on non-Windows platforms -test-windows: - BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov -m windows tests test-int +test-windows: testmon-seed + BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov {{TESTMON_FLAGS}} --testmon-env=windows -m windows tests test-int # Run benchmark tests only (performance testing) # These are slow tests that measure sync performance with various file counts # Excluded from default test runs to keep CI fast -test-benchmark: - BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov -m benchmark tests test-int +test-benchmark: testmon-seed + BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov {{TESTMON_FLAGS}} --testmon-env=benchmark -m benchmark tests test-int # Run semantic search quality benchmarks (all combos) -test-semantic: - BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov -m semantic test-int/semantic/ +test-semantic: testmon-seed + BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov {{TESTMON_FLAGS}} --testmon-env=semantic -m semantic test-int/semantic/ # Run semantic benchmarks with JSON artifact output, then show report test-semantic-report: @@ -120,8 +139,8 @@ test-litellm-live *args: BASIC_MEMORY_ENV=test BASIC_MEMORY_RUN_LITELLM_INTEGRATION=1 PYTHONPATH=test-int:src uv run python -m semantic.litellm_live_harness {{args}} # Run semantic benchmarks (Postgres combos only) -test-semantic-postgres: - BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov -m semantic -k postgres test-int/semantic/ +test-semantic-postgres: testmon-seed + BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov {{TESTMON_FLAGS}} --testmon-env=semantic-postgres -m semantic -k postgres test-int/semantic/ # View semantic benchmark results (rich formatted table) # Usage: just semantic-report [--filter-combo sqlite] [--filter-suite paraphrase] [--sort-by avg_latency_ms] @@ -137,8 +156,8 @@ benchmark-compare baseline candidate *args: # Run all tests including Windows, Postgres, and Benchmarks (for CI/comprehensive testing) # Use this before releasing to ensure everything works across all backends and platforms -test-all: - BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov tests test-int +test-all: testmon-seed + BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov {{TESTMON_FLAGS}} --testmon-env=all tests test-int # Generate HTML coverage report coverage: diff --git a/scripts/testmon_cache.py b/scripts/testmon_cache.py new file mode 100644 index 00000000..77bab89b --- /dev/null +++ b/scripts/testmon_cache.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Seed and refresh shared pytest-testmon data for Git worktrees.""" + +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import NamedTuple + +TESTMON_FILENAMES = (".testmondata", ".testmondata-shm", ".testmondata-wal") +TESTMON_CACHE_ENV = "BM_TESTMON_CACHE_DIR" + + +class TestmonCacheResult(NamedTuple): + status: str + source_dir: Path + destination_dir: Path + copied: tuple[Path, ...] + + +def _run_git(args: list[str], cwd: Path) -> str: + return subprocess.check_output(["git", *args], cwd=cwd, text=True).strip() + + +def resolve_repo_root(repo_root: Path | None = None) -> Path: + if repo_root is not None: + return repo_root.expanduser().resolve() + + return Path(_run_git(["rev-parse", "--show-toplevel"], Path.cwd())).resolve() + + +def resolve_cache_dir(repo_root: Path, cache_dir: Path | None = None) -> Path: + if cache_dir is not None: + return cache_dir.expanduser().resolve() + + if env_cache_dir := os.environ.get(TESTMON_CACHE_ENV): + return Path(env_cache_dir).expanduser().resolve() + + git_common_dir = Path(_run_git(["rev-parse", "--git-common-dir"], repo_root)) + if not git_common_dir.is_absolute(): + git_common_dir = repo_root / git_common_dir + + return git_common_dir.resolve() / "testmon-cache" / "main" + + +def _testmon_datafile(directory: Path) -> Path: + return directory / ".testmondata" + + +def _testmon_files(directory: Path) -> list[Path]: + return [ + directory / filename for filename in TESTMON_FILENAMES if (directory / filename).is_file() + ] + + +def _remove_path(path: Path) -> None: + if path.is_dir(): + shutil.rmtree(path) + elif path.exists(): + path.unlink() + + +def _copy_testmon_files(source_dir: Path, destination_dir: Path) -> tuple[Path, ...]: + destination_dir.mkdir(parents=True, exist_ok=True) + + copied: list[Path] = [] + for source in _testmon_files(source_dir): + destination = destination_dir / source.name + shutil.copy2(source, destination) + copied.append(destination) + + return tuple(copied) + + +def seed_testmon_data(repo_root: Path, cache_dir: Path) -> TestmonCacheResult: + local_datafile = _testmon_datafile(repo_root) + shared_datafile = _testmon_datafile(cache_dir) + + if local_datafile.exists(): + return TestmonCacheResult( + status="exists", + source_dir=cache_dir, + destination_dir=repo_root, + copied=(), + ) + + if not shared_datafile.exists(): + return TestmonCacheResult( + status="missing", + source_dir=cache_dir, + destination_dir=repo_root, + copied=(), + ) + + # A worktree with sidecars but no main database is stale; replace the set + # together so SQLite never sees a mixed local/cache snapshot. + for filename in TESTMON_FILENAMES: + _remove_path(repo_root / filename) + + copied = _copy_testmon_files(cache_dir, repo_root) + return TestmonCacheResult( + status="seeded", + source_dir=cache_dir, + destination_dir=repo_root, + copied=copied, + ) + + +def refresh_testmon_data(repo_root: Path, cache_dir: Path) -> TestmonCacheResult: + local_datafile = _testmon_datafile(repo_root) + + if not local_datafile.exists(): + raise FileNotFoundError( + f"No local pytest-testmon data at {local_datafile}; run tests first." + ) + + cache_parent = cache_dir.parent + cache_parent.mkdir(parents=True, exist_ok=True) + temp_dir = Path(tempfile.mkdtemp(prefix=f".{cache_dir.name}.", dir=cache_parent)) + backup_dir = cache_parent / f".{cache_dir.name}.previous-{os.getpid()}" + copied: tuple[Path, ...] = () + + try: + copied = _copy_testmon_files(repo_root, temp_dir) + + _remove_path(backup_dir) + if cache_dir.exists(): + cache_dir.rename(backup_dir) + + try: + temp_dir.rename(cache_dir) + except Exception: + if backup_dir.exists() and not cache_dir.exists(): + backup_dir.rename(cache_dir) + raise + finally: + _remove_path(temp_dir) + _remove_path(backup_dir) + + return TestmonCacheResult( + status="refreshed", + source_dir=repo_root, + destination_dir=cache_dir, + copied=tuple(cache_dir / path.name for path in copied), + ) + + +def _print_seed_result(result: TestmonCacheResult) -> None: + if result.status == "seeded": + print(f"Seeded pytest-testmon data from {result.source_dir} into {result.destination_dir}") + elif result.status == "exists": + print(f"Local pytest-testmon data already exists at {result.destination_dir}") + elif result.status == "missing": + print( + f"No shared pytest-testmon baseline at {result.source_dir}; " + "run `just testmon-refresh` after a full backend test run to create one." + ) + else: + raise ValueError(f"Unexpected seed result: {result.status}") + + +def _print_refresh_result(result: TestmonCacheResult) -> None: + print(f"Published pytest-testmon data from {result.source_dir} to {result.destination_dir}") + + +def _print_status(repo_root: Path, cache_dir: Path) -> None: + print(f"Repo root: {repo_root}") + print(f"Worktree data: {_testmon_datafile(repo_root)}") + print(f"Shared cache: {_testmon_datafile(cache_dir)}") + print(f"Worktree ready: {_testmon_datafile(repo_root).exists()}") + print(f"Cache ready: {_testmon_datafile(cache_dir).exists()}") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--repo-root", + type=Path, + help="Repository root to operate on (default: git rev-parse --show-toplevel)", + ) + parser.add_argument( + "--cache-dir", + type=Path, + help=( + "Shared testmon cache directory " + f"(default: ${TESTMON_CACHE_ENV} or /testmon-cache/main)" + ), + ) + + subparsers = parser.add_subparsers(dest="command", required=True) + subparsers.add_parser("seed", help="Copy shared testmon data into this worktree if missing") + subparsers.add_parser("refresh", help="Publish this worktree's testmon data to the cache") + subparsers.add_parser("status", help="Show local and shared testmon data paths") + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + repo_root = resolve_repo_root(args.repo_root) + cache_dir = resolve_cache_dir(repo_root, args.cache_dir) + + if args.command == "seed": + _print_seed_result(seed_testmon_data(repo_root=repo_root, cache_dir=cache_dir)) + return 0 + + if args.command == "refresh": + _print_refresh_result(refresh_testmon_data(repo_root=repo_root, cache_dir=cache_dir)) + return 0 + + if args.command == "status": + _print_status(repo_root=repo_root, cache_dir=cache_dir) + return 0 + + parser.error(f"Unknown command: {args.command}") + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/ci/test_testmon_cache.py b/tests/ci/test_testmon_cache.py new file mode 100644 index 00000000..92fb8518 --- /dev/null +++ b/tests/ci/test_testmon_cache.py @@ -0,0 +1,114 @@ +from pathlib import Path + +import pytest + +from scripts import testmon_cache + + +def _write_testmon_file(directory: Path, filename: str, content: str) -> Path: + directory.mkdir(parents=True, exist_ok=True) + path = directory / filename + path.write_text(content, encoding="utf-8") + return path + + +def test_seed_testmon_data_reports_missing_shared_cache(tmp_path: Path) -> None: + repo_root = tmp_path / "repo" + cache_dir = tmp_path / "cache" + repo_root.mkdir() + + result = testmon_cache.seed_testmon_data(repo_root=repo_root, cache_dir=cache_dir) + + assert result.status == "missing" + assert result.copied == () + assert not (repo_root / ".testmondata").exists() + + +def test_seed_testmon_data_keeps_existing_local_data(tmp_path: Path) -> None: + repo_root = tmp_path / "repo" + cache_dir = tmp_path / "cache" + local_datafile = _write_testmon_file(repo_root, ".testmondata", "local") + _write_testmon_file(cache_dir, ".testmondata", "shared") + + result = testmon_cache.seed_testmon_data(repo_root=repo_root, cache_dir=cache_dir) + + assert result.status == "exists" + assert result.copied == () + assert local_datafile.read_text(encoding="utf-8") == "local" + + +def test_seed_testmon_data_replaces_stale_sidecars(tmp_path: Path) -> None: + repo_root = tmp_path / "repo" + cache_dir = tmp_path / "cache" + _write_testmon_file(repo_root, ".testmondata-shm", "stale sidecar") + _write_testmon_file(cache_dir, ".testmondata", "shared main") + _write_testmon_file(cache_dir, ".testmondata-wal", "shared wal") + + result = testmon_cache.seed_testmon_data(repo_root=repo_root, cache_dir=cache_dir) + + assert result.status == "seeded" + assert {path.name for path in result.copied} == {".testmondata", ".testmondata-wal"} + assert (repo_root / ".testmondata").read_text(encoding="utf-8") == "shared main" + assert (repo_root / ".testmondata-wal").read_text(encoding="utf-8") == "shared wal" + assert not (repo_root / ".testmondata-shm").exists() + + +def test_refresh_testmon_data_requires_local_data(tmp_path: Path) -> None: + repo_root = tmp_path / "repo" + cache_dir = tmp_path / "cache" + repo_root.mkdir() + + with pytest.raises(FileNotFoundError): + testmon_cache.refresh_testmon_data(repo_root=repo_root, cache_dir=cache_dir) + + +def test_refresh_testmon_data_replaces_shared_cache(tmp_path: Path) -> None: + repo_root = tmp_path / "repo" + cache_dir = tmp_path / "cache" + _write_testmon_file(repo_root, ".testmondata", "local main") + _write_testmon_file(repo_root, ".testmondata-shm", "local shm") + _write_testmon_file(cache_dir, ".testmondata", "old main") + _write_testmon_file(cache_dir, ".testmondata-wal", "old wal") + + result = testmon_cache.refresh_testmon_data(repo_root=repo_root, cache_dir=cache_dir) + + assert result.status == "refreshed" + assert {path.name for path in result.copied} == {".testmondata", ".testmondata-shm"} + assert (cache_dir / ".testmondata").read_text(encoding="utf-8") == "local main" + assert (cache_dir / ".testmondata-shm").read_text(encoding="utf-8") == "local shm" + assert not (cache_dir / ".testmondata-wal").exists() + + +def test_resolve_cache_dir_prefers_explicit_path_over_env( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + repo_root = tmp_path / "repo" + env_cache_dir = tmp_path / "env-cache" + explicit_cache_dir = tmp_path / "explicit-cache" + repo_root.mkdir() + monkeypatch.setenv(testmon_cache.TESTMON_CACHE_ENV, str(env_cache_dir)) + + assert testmon_cache.resolve_cache_dir(repo_root) == env_cache_dir.resolve() + assert ( + testmon_cache.resolve_cache_dir(repo_root, explicit_cache_dir) + == explicit_cache_dir.resolve() + ) + + +def test_status_command_prints_local_and_shared_paths( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + repo_root = tmp_path / "repo" + cache_dir = tmp_path / "cache" + repo_root.mkdir() + _write_testmon_file(repo_root, ".testmondata", "local main") + + exit_code = testmon_cache.main( + ["--repo-root", str(repo_root), "--cache-dir", str(cache_dir), "status"] + ) + + assert exit_code == 0 + output = capsys.readouterr().out + assert f"Repo root: {repo_root.resolve()}" in output + assert "Worktree ready: True" in output + assert "Cache ready: False" in output