diff --git a/pyproject.toml b/pyproject.toml index a1e99adc..2b686ac1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,7 @@ markers = [ "integration: Integration tests with realistic JSONL data", "slow: Slower tests that measure performance (may take several seconds)", "benchmark: Performance benchmarks that output to GitHub Job Summary", + "default_cache_path: Leave CLAUDE_CODE_LOG_CACHE_PATH unset, so the cache lands at its default location (test/conftest.py)", ] [tool.ty.environment] diff --git a/test/conftest.py b/test/conftest.py index fd905eca..a21f166c 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -122,6 +122,55 @@ def has_marker(tag: str, /, **_kwargs: "str | int | bool | None") -> bool: return None +# ========== Cache isolation: every test gets its own cache database ========== +# A CacheManager built without a db_path, and a conversion run without +# CLAUDE_CODE_LOG_CACHE_PATH, put the cache at +# `/claude-code-log-cache.db`. A test whose project sits directly +# in the system temp dir (`tempfile.TemporaryDirectory()`) therefore shares +# `/claude-code-log-cache.db` with every other such test, every run and +# every checkout on the machine. Two things break: the file outlives the run +# and keeps whatever schema the last code to open it migrated it to, so a +# checkout with an older migration chain fails on columns it does not know; +# and on a fresh temp dir several xdist workers run the migrations on the +# same new file at once (`UNIQUE constraint failed: _schema_version.version`). +# +# So every test starts with CLAUDE_CODE_LOG_CACHE_PATH pointing at a file of +# its own. The directory is a fresh one beside the test's `tmp_path`, not +# inside it, so what a test finds in `tmp_path` is only what it put there. +# Subprocesses and render workers inherit the variable. A test that sets the +# variable itself overrides this one; a test about the default location takes +# `@pytest.mark.default_cache_path`, which unsets it instead. +# +# A fixture wider than a test (module, session) is set up before this one and +# sees the variable as the run started, normally unset: it resolves the +# default location, under its own temp dir. A test that looks that cache up again must resolve the same path, +# with the marker (test_minimal_pages_browser.py) or an explicit db_path. +# +# It holds a MonkeyPatch of its own rather than requesting `monkeypatch`: an +# autouse fixture that requests it sets it up before the xunit +# `setup_method` fixture, so the test's patches would be undone only after +# `teardown_method` ran, and a `teardown_method` that relies on them being +# gone fails (test_commit_linkifier.py's, for one). + + +@pytest.fixture(autouse=True) +def _isolated_cache_path( + request: pytest.FixtureRequest, + tmp_path_factory: pytest.TempPathFactory, +) -> Generator[None, None, None]: + """Point CLAUDE_CODE_LOG_CACHE_PATH at a cache database for this test only.""" + with pytest.MonkeyPatch.context() as mp: + if request.node.get_closest_marker("default_cache_path"): + mp.delenv("CLAUDE_CODE_LOG_CACHE_PATH", raising=False) + else: + cache_dir = tmp_path_factory.mktemp("cache") + mp.setenv( + "CLAUDE_CODE_LOG_CACHE_PATH", + str(cache_dir / "claude-code-log-cache.db"), + ) + yield + + # ========== Cache Test Fixtures ========== # These fixtures use explicit db_path for true test isolation, # enabling parallel test execution without database conflicts. diff --git a/test/test_cache.py b/test/test_cache.py index dd0f93aa..992fdd4b 100644 --- a/test/test_cache.py +++ b/test/test_cache.py @@ -98,6 +98,7 @@ def sample_entries(): class TestCacheManager: """Test the CacheManager class.""" + @pytest.mark.default_cache_path def test_initialization(self, temp_project_dir, mock_version): """Test cache manager initialization.""" cache_manager = CacheManager(temp_project_dir, mock_version) @@ -111,6 +112,7 @@ def test_initialization(self, temp_project_dir, mock_version): ) assert cache_manager.db_path.exists() + @pytest.mark.default_cache_path def test_database_path(self, cache_manager, temp_project_dir): """Test that SQLite database is created at the correct location.""" # Database should be at parent level (projects_dir/claude-code-log-cache.db) @@ -898,6 +900,7 @@ def test_cache_directory_permissions(self, temp_project_dir, mock_version): class TestCachePathEnvVar: """Test CLAUDE_CODE_LOG_CACHE_PATH environment variable.""" + @pytest.mark.default_cache_path def test_default_path_without_env_var(self, tmp_path): """Test default cache path when env var is not set.""" project_dir = tmp_path / "project" diff --git a/test/test_cache_isolation.py b/test/test_cache_isolation.py new file mode 100644 index 00000000..b110ed9b --- /dev/null +++ b/test/test_cache_isolation.py @@ -0,0 +1,58 @@ +"""Every test gets a cache database of its own (test/conftest.py). + +Without that, a project made directly in the system temp dir puts its cache +at `/claude-code-log-cache.db`, which every such test, every run and +every checkout on the machine shares. +""" + +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +from claude_code_log.cache import CacheManager + +SHARED = Path(tempfile.gettempdir()) / "claude-code-log-cache.db" + + +def test_a_project_in_the_temp_dir_gets_a_cache_of_its_own( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + with tempfile.TemporaryDirectory() as project: + cache = CacheManager(Path(project), "1.0.0-test") + + assert cache.db_path != SHARED + assert tmp_path_factory.getbasetemp() in cache.db_path.parents + + +def test_a_subprocess_inherits_the_test_cache() -> None: + """CLI and render-worker subprocesses resolve the same per-test path.""" + out = subprocess.run( + [ + sys.executable, + "-c", + "from pathlib import Path;" + "from claude_code_log.cache import get_cache_db_path;" + "print(get_cache_db_path(Path('.')))", + ], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + + assert out == os.environ["CLAUDE_CODE_LOG_CACHE_PATH"] + assert Path(out) != SHARED + + +@pytest.mark.default_cache_path +def test_the_marker_leaves_the_default_location(tmp_path: Path) -> None: + assert "CLAUDE_CODE_LOG_CACHE_PATH" not in os.environ + project = tmp_path / "project" + project.mkdir() + + assert CacheManager(project, "1.0.0-test").db_path == ( + tmp_path / "claude-code-log-cache.db" + ) diff --git a/test/test_codex_walker.py b/test/test_codex_walker.py index 0118ea5e..5fe887cc 100644 --- a/test/test_codex_walker.py +++ b/test/test_codex_walker.py @@ -675,6 +675,7 @@ def _survived(page: Path) -> bool: return _TAMPER in page.read_text(encoding="utf-8") +@pytest.mark.default_cache_path def test_walker_cache_db_lives_under_output_root(tmp_path: Path) -> None: out = tmp_path / "ccl" render_provider_wholesale("codex", SESSIONS_ROOT, out, silent=True) @@ -748,6 +749,7 @@ def test_walker_cache_rerenders_only_changed_session(tmp_path: Path) -> None: assert _survived(out / "-proj-b/combined_transcripts.html") +@pytest.mark.default_cache_path def test_walker_no_cache_rewrites_every_run(tmp_path: Path) -> None: out = tmp_path / "ccl" render_provider_wholesale("codex", SESSIONS_ROOT, out, use_cache=False, silent=True) @@ -771,6 +773,7 @@ def _snapshot_tree(root: Path) -> dict[str, bytes]: } +@pytest.mark.default_cache_path def test_cli_provider_no_cache_renders_without_db( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -788,6 +791,7 @@ def test_cli_provider_no_cache_renders_without_db( assert not (out / "claude-code-log-cache.db").exists() +@pytest.mark.default_cache_path def test_cli_provider_clear_cache_removes_db( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/test/test_entry_store.py b/test/test_entry_store.py index 06c3656e..7894c578 100644 --- a/test/test_entry_store.py +++ b/test/test_entry_store.py @@ -650,6 +650,11 @@ def _spy(self: Any, jsonl_path: Path, all_entries: Any, appended: Any, **kw: Any return calls +# Each test compares two projects, and each project's rows are read from the +# cache at its own default location (`_message_rows` reads every row of the +# file). One shared cache would hold both projects' rows and compare them with +# themselves. +@pytest.mark.default_cache_path class TestAppendOnlyWrites: """Only the new rows are written — and the table must not tell.""" diff --git a/test/test_incremental_cache_refresh.py b/test/test_incremental_cache_refresh.py index 5697aafc..87353b7f 100644 --- a/test/test_incremental_cache_refresh.py +++ b/test/test_incremental_cache_refresh.py @@ -198,6 +198,10 @@ def _run_scenario( assert _html_files(inc_dir) == _html_files(full_dir) +# Each test compares an incremental refresh with a full one in two copies of +# the project, and `_db_state` reads every row of the copy's cache. With one +# shared cache both sides would read both copies' rows and compare equal. +@pytest.mark.default_cache_path class TestIncrementalEquivalence: def test_new_session_file( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -557,6 +561,10 @@ def test_new_session_persists_synthetic_agent_parent( assert any("#agent-e1c84ba5" in sid for sid in sidecar.parents) +# Each test compares an incremental refresh with a full one in two copies of +# the project, and `_db_state` reads every row of the copy's cache. With one +# shared cache both sides would read both copies' rows and compare equal. +@pytest.mark.default_cache_path class TestIncrementalDeclines: @pytest.mark.parametrize("rewrite", ["timestamp", "uuidless", "duplicate"]) def test_non_append_rewrite_with_same_uuid_set_declines( diff --git a/test/test_integration_realistic.py b/test/test_integration_realistic.py index 1770d4ee..5aff045f 100644 --- a/test/test_integration_realistic.py +++ b/test/test_integration_realistic.py @@ -282,6 +282,7 @@ def test_all_projects_with_custom_dir(self, temp_projects_copy: Path) -> None: assert "Successfully processed" in result.output assert (temp_projects_copy / "index.html").exists() + @pytest.mark.default_cache_path def test_clear_cache_with_projects_dir(self, temp_projects_copy: Path) -> None: """Test cache clearing with custom projects directory.""" runner = CliRunner() @@ -461,6 +462,7 @@ def test_working_directories_in_all_projects( class TestCacheWithRealData: """Test cache functionality with realistic project data.""" + @pytest.mark.default_cache_path def test_cache_creation_all_projects(self, temp_projects_copy: Path) -> None: """Test cache is created correctly for all projects.""" process_projects_hierarchy(temp_projects_copy) diff --git a/test/test_minimal_pages_browser.py b/test/test_minimal_pages_browser.py index f52c3b2c..f697f18c 100644 --- a/test/test_minimal_pages_browser.py +++ b/test/test_minimal_pages_browser.py @@ -29,6 +29,11 @@ DARK_FG = "rgb(228, 230, 233)" # --fg #e4e6e9 PROJECT = "-home-u-testproj" +# The module-scoped `archive` builds its cache before the per-test cache path +# is set (test/conftest.py), so it lands at the default location; each test's +# `server` must resolve the same one. +pytestmark = pytest.mark.default_cache_path + @pytest.fixture(scope="module") def archive(tmp_path_factory: pytest.TempPathFactory) -> Path: