From 24cc9738290163a8eb7dc059d0ad20733771d8b8 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 8 Nov 2026 19:57:23 +0100 Subject: [PATCH 1/3] Give every test a cache database of its own 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 (test_tui.py's temp_project_dir, for one) therefore shares /claude-code-log-cache.db with every other such test, every run and every checkout on the machine. Two failures follow from that: - The file outlives the run and keeps the schema the last code to open it migrated it to. A DB migrated one step past this tree (a fake 017 dropping cached_files.content_version) fails 38 tests on fc19a6d4: 24 in test_tui.py, 13 in test_pagination.py, 1 in test_template_rendering.py, all "no such column: content_version". - On a fresh temp dir, xdist workers run the migration chain on the same new file at once: test_tui.py -n auto failed 5, 5 and 4 tests in three runs ("UNIQUE constraint failed: _schema_version.version", and a DDL statement applied twice). An autouse fixture in test/conftest.py now points CLAUDE_CODE_LOG_CACHE_PATH at a fresh directory beside each test's tmp_path; subprocesses and render workers inherit it, and a test that sets the variable itself still wins. Tests about the default location take @pytest.mark.default_cache_path, which unsets it instead: - test_cache.py: test_initialization, test_database_path, test_default_path_without_env_var (they assert where it lands). - test_integration_realistic.py: test_clear_cache_with_projects_dir, test_cache_creation_all_projects (the DB under --projects-dir). - test_codex_walker.py: the four tests that assert the DB is, or is not, under the output root. The two absence checks would pass vacuously with the cache moved elsewhere. - test_entry_store.py: TestAppendOnlyWrites, whose tests compare two projects' cache rows read from each one's own default-location file; one shared file would compare the rows with themselves. test_cli_sqlite_requirement.py already unsets the variable in its fixture. test_cache_isolation.py pins the fixture (a project in the temp dir, and a subprocess, get the per-test path) and the marker. Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 1 + test/conftest.py | 36 +++++++++++++++++++ test/test_cache.py | 3 ++ test/test_cache_isolation.py | 58 ++++++++++++++++++++++++++++++ test/test_codex_walker.py | 4 +++ test/test_entry_store.py | 5 +++ test/test_integration_realistic.py | 2 ++ 7 files changed, 109 insertions(+) create mode 100644 test/test_cache_isolation.py 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..4907ec36 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -122,6 +122,42 @@ 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. + + +@pytest.fixture(autouse=True) +def _isolated_cache_path( + request: pytest.FixtureRequest, + monkeypatch: pytest.MonkeyPatch, + tmp_path_factory: pytest.TempPathFactory, +) -> None: + """Point CLAUDE_CODE_LOG_CACHE_PATH at a cache database for this test only.""" + if request.node.get_closest_marker("default_cache_path"): + monkeypatch.delenv("CLAUDE_CODE_LOG_CACHE_PATH", raising=False) + return + cache_dir = tmp_path_factory.mktemp("cache") + monkeypatch.setenv( + "CLAUDE_CODE_LOG_CACHE_PATH", str(cache_dir / "claude-code-log-cache.db") + ) + + # ========== 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_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) From f5645477a7c9958a6c59f0b8deb2a1542fe566e0 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 8 Nov 2026 20:02:55 +0100 Subject: [PATCH 2/3] Keep the test-cache fixture off the test's own monkeypatch Two things the first `just test` with the fixture showed. The fixture requested `monkeypatch`. An autouse fixture that does so sets it up before the xunit `setup_method` fixture, so the test's own patches were undone only after `teardown_method`. test_commit_linkifier.py's `teardown_method` calls `_git_remote_for.cache_clear()` while the test's plain-function patch was still in place, and its 11 tests errored. The fixture now holds a `MonkeyPatch.context()` of its own. test_incremental_cache_refresh.py's TestIncrementalEquivalence and TestIncrementalDeclines compare an incremental refresh with a full one in two copies of a project, through `_db_state`, which reads every row of the cache it opens. Under one per-test cache both sides read both copies' rows. Two tests failed on the doubled junction rows; the other fifteen passed because each side compared the union with itself. Both classes now take @pytest.mark.default_cache_path. Finding these took a probe that records which project dirs each test resolves through `get_cache_db_path`. In `just test` it flagged 41 tests that resolve two or more dirs to the one per-test cache. The 24 besides the 17 above read no cache rows themselves: they compare rendered pages, index summaries or exit status, which the code reads per project. Every cache table is keyed by project or by file, so two projects sharing one file is what production does, and those tests are left as they are. Co-Authored-By: Claude Opus 5.5 --- test/conftest.py | 26 +++++++++++++++++--------- test/test_incremental_cache_refresh.py | 8 ++++++++ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/test/conftest.py b/test/conftest.py index 4907ec36..70699f2f 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -140,22 +140,30 @@ def has_marker(tag: str, /, **_kwargs: "str | int | bool | None") -> bool: # 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. +# +# 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, - monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory, -) -> None: +) -> Generator[None, None, None]: """Point CLAUDE_CODE_LOG_CACHE_PATH at a cache database for this test only.""" - if request.node.get_closest_marker("default_cache_path"): - monkeypatch.delenv("CLAUDE_CODE_LOG_CACHE_PATH", raising=False) - return - cache_dir = tmp_path_factory.mktemp("cache") - monkeypatch.setenv( - "CLAUDE_CODE_LOG_CACHE_PATH", str(cache_dir / "claude-code-log-cache.db") - ) + 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 ========== 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( From d75de0046cb045f56fe0933f453166d59d9ed661 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 8 Nov 2026 20:09:33 +0100 Subject: [PATCH 3/3] Keep the minimal pages' search server on the archive's own cache test_minimal_pages_browser.py builds its archive in a module-scoped fixture, which is set up before the per-test cache fixture and so puts the cache at the default location under its own temp dir. Each test's `server` looked the cache up again through get_cache_db_path, now the per-test path, and served search from an empty database: four search tests timed out waiting for `#app[data-ready]` in `just test-browser`. The module now takes @pytest.mark.default_cache_path, so both resolve the same file. No other module-, session- or class-scoped fixture has a test that resolves its cache again; the conftest comment now says what such a test has to do. Co-Authored-By: Claude Opus 5.5 --- test/conftest.py | 5 +++++ test/test_minimal_pages_browser.py | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/test/conftest.py b/test/conftest.py index 70699f2f..a21f166c 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -141,6 +141,11 @@ def has_marker(tag: str, /, **_kwargs: "str | int | bool | None") -> bool: # 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 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: