From 9f3b3c62419992069310079f8d277904453f10e7 Mon Sep 17 00:00:00 2001 From: chen Date: Thu, 4 Jun 2026 02:48:00 +0800 Subject: [PATCH 1/6] ci: add windows-latest to test matrix --- .github/workflows/ci.yml | 20 +++++++++++++---- CONTRIBUTING.md | 4 +++- tests/test_export_state_store.py | 28 +++++++++++++++++++++++- tests/test_session_path.py | 37 ++++++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 6 deletions(-) create mode 100644 tests/test_session_path.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47ea79c..78b7ec5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,11 @@ jobs: PY pytest: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] permissions: contents: read steps: @@ -94,7 +98,11 @@ jobs: integration-tests: name: API integration tests + coverage - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] permissions: contents: read actions: write @@ -120,12 +128,16 @@ jobs: - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: coverage-report + name: coverage-report-${{ matrix.os }} path: coverage.xml js-tests: name: Frontend unit tests (vitest) - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] permissions: contents: read steps: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4be1f81..b83790a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,6 +9,8 @@ Thanks for considering a patch. This repo is a small Flask app plus a hash-route - **Python 3.12** (matches CI) - **Node 20+** (only if you change `static/js/` or run frontend unit tests) +CI runs **`pytest`**, **integration tests**, and **Vitest** on **ubuntu-latest** and **windows-latest** (Python 3.12, Node 20). Type-check (`mypy`) and production install smoke run on Ubuntu only. + ### Bootstrap (Windows PowerShell) ```powershell @@ -105,7 +107,7 @@ npm run test:coverage # optional - PR checklist: - [ ] `pytest -q` green locally - [ ] `npm test` green if JS changed - - [ ] CI jobs green (`pytest`, `integration-tests`, `js-tests`, `prod-install-smoke`) + - [ ] CI jobs green (`pytest`, `integration-tests`, `js-tests` on Ubuntu + Windows; `mypy`, `prod-install-smoke` on Ubuntu) - [ ] PR description includes a **Test plan** section - [ ] API changes update [`docs/api-reference.md`](docs/api-reference.md) if behavior or errors change diff --git a/tests/test_export_state_store.py b/tests/test_export_state_store.py index 5d47d8a..629518c 100644 --- a/tests/test_export_state_store.py +++ b/tests/test_export_state_store.py @@ -3,9 +3,16 @@ from __future__ import annotations import json +import sys from pathlib import Path -from utils.export_state_store import load_export_state_from_disk +import pytest + +from utils.export_state_store import ( + atomic_write_export_state, + export_state_lock, + load_export_state_from_disk, +) def test_load_rejects_non_object_json(tmp_path: Path): @@ -81,3 +88,22 @@ def locking(fd, mode, nbytes): with mod.export_state_lock(str(state_file)): assert (FakeMsvcrt.LK_LOCK, 1) in calls assert calls[-1] == (FakeMsvcrt.LK_UNLCK, 1) + + +@pytest.mark.skipif(sys.platform != "win32", reason="requires Windows msvcrt") +def test_export_state_lock_real_msvcrt_roundtrip(tmp_path: Path) -> None: + """Exercise real ``msvcrt.locking`` on a Windows runner (not FakeMsvcrt).""" + state_file = tmp_path / "export_state.json" + state_file.write_text("{}", encoding="utf-8") + payload = { + "sessions": {}, + "lastExportTime": "2026-01-01T00:00:00", + "exportedCount": 0, + } + + with export_state_lock(str(state_file)): + atomic_write_export_state(payload, str(state_file)) + + loaded = load_export_state_from_disk(str(state_file)) + assert loaded.get("exportedCount") == 0 + assert loaded.get("sessions") == {} diff --git a/tests/test_session_path.py b/tests/test_session_path.py new file mode 100644 index 0000000..84a256a --- /dev/null +++ b/tests/test_session_path.py @@ -0,0 +1,37 @@ +"""Tests for utils/session_path platform-specific home resolution.""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import pytest + +from utils import session_path + + +def test_get_claude_projects_dir_uses_userprofile_on_windows( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + profile = tmp_path / "Users" / "chen" + profile.mkdir(parents=True) + monkeypatch.setattr(session_path.platform, "system", lambda: "Windows") + monkeypatch.setenv("USERPROFILE", str(profile)) + monkeypatch.delenv("HOME", raising=False) + + got = session_path.get_claude_projects_dir() + assert got == os.path.join(str(profile), ".claude", "projects") + + +@pytest.mark.skipif(sys.platform != "win32", reason="native Windows runner") +def test_get_claude_projects_dir_on_windows_runner( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + profile = tmp_path / "profile" + profile.mkdir() + monkeypatch.setenv("USERPROFILE", str(profile)) + + got = session_path.get_claude_projects_dir() + assert got.startswith(str(profile)) + assert got.endswith(os.path.join(".claude", "projects")) From cac39948f3c7657761f813042e78cb37761f14cd Mon Sep 17 00:00:00 2001 From: chen Date: Thu, 4 Jun 2026 04:16:51 +0800 Subject: [PATCH 2/6] test(ci): polish Windows CI tests and matrix job names --- .github/workflows/ci.yml | 5 ++-- tests/test_export_state_store.py | 41 ++++++++++++++++++++++++++++---- tests/test_session_path.py | 9 +++---- 3 files changed, 42 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78b7ec5..8c81704 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,7 @@ jobs: PY pytest: + name: pytest (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -97,7 +98,7 @@ jobs: run: mypy tests --config-file mypy-tests.ini --follow-imports skip integration-tests: - name: API integration tests + coverage + name: API integration tests + coverage (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -132,7 +133,7 @@ jobs: path: coverage.xml js-tests: - name: Frontend unit tests (vitest) + name: Frontend unit tests (vitest) (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false diff --git a/tests/test_export_state_store.py b/tests/test_export_state_store.py index 629518c..0ea067f 100644 --- a/tests/test_export_state_store.py +++ b/tests/test_export_state_store.py @@ -3,11 +3,15 @@ from __future__ import annotations import json +import subprocess import sys +import time from pathlib import Path import pytest +REPO_ROOT = Path(__file__).resolve().parent.parent + from utils.export_state_store import ( atomic_write_export_state, export_state_lock, @@ -96,14 +100,41 @@ def test_export_state_lock_real_msvcrt_roundtrip(tmp_path: Path) -> None: state_file = tmp_path / "export_state.json" state_file.write_text("{}", encoding="utf-8") payload = { - "sessions": {}, - "lastExportTime": "2026-01-01T00:00:00", - "exportedCount": 0, + "sessions": {"sess-msvcrt-roundtrip": 1740000123.5}, + "lastExportTime": "2026-06-04T18:30:00Z", + "exportedCount": 42, } with export_state_lock(str(state_file)): atomic_write_export_state(payload, str(state_file)) loaded = load_export_state_from_disk(str(state_file)) - assert loaded.get("exportedCount") == 0 - assert loaded.get("sessions") == {} + assert loaded.get("exportedCount") == 42 + assert loaded.get("sessions") == {"sess-msvcrt-roundtrip": 1740000123.5} + assert loaded.get("lastExportTime") == "2026-06-04T18:30:00Z" + + +@pytest.mark.skipif(sys.platform != "win32", reason="requires Windows msvcrt") +def test_export_state_lock_blocks_second_process(tmp_path: Path) -> None: + """While the parent holds ``msvcrt`` lock, a child process cannot acquire it.""" + state_file = tmp_path / "export_state.json" + state_file.write_text("{}", encoding="utf-8") + marker = tmp_path / "child_acquired.lock" + child = ( + "import sys\n" + "from pathlib import Path\n" + f"sys.path.insert(0, {str(REPO_ROOT)!r})\n" + "from utils.export_state_store import export_state_lock\n" + "path, marker = sys.argv[1], sys.argv[2]\n" + "with export_state_lock(path):\n" + " Path(marker).write_text('ok', encoding='utf-8')\n" + ) + with export_state_lock(str(state_file)): + proc = subprocess.Popen( + [sys.executable, "-c", child, str(state_file), str(marker)], + cwd=str(REPO_ROOT), + ) + time.sleep(0.5) + assert not marker.is_file(), "child acquired lock while parent still holds it" + assert proc.wait(timeout=10) == 0 + assert marker.read_text(encoding="utf-8") == "ok" diff --git a/tests/test_session_path.py b/tests/test_session_path.py index 84a256a..e2f8e4d 100644 --- a/tests/test_session_path.py +++ b/tests/test_session_path.py @@ -14,11 +14,9 @@ def test_get_claude_projects_dir_uses_userprofile_on_windows( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - profile = tmp_path / "Users" / "chen" - profile.mkdir(parents=True) + profile = tmp_path / "Users" / "testuser" monkeypatch.setattr(session_path.platform, "system", lambda: "Windows") monkeypatch.setenv("USERPROFILE", str(profile)) - monkeypatch.delenv("HOME", raising=False) got = session_path.get_claude_projects_dir() assert got == os.path.join(str(profile), ".claude", "projects") @@ -29,9 +27,8 @@ def test_get_claude_projects_dir_on_windows_runner( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: profile = tmp_path / "profile" - profile.mkdir() monkeypatch.setenv("USERPROFILE", str(profile)) got = session_path.get_claude_projects_dir() - assert got.startswith(str(profile)) - assert got.endswith(os.path.join(".claude", "projects")) + expected = os.path.join(str(profile), ".claude", "projects") + assert got == expected From a000e308adc7cf40458fd46bda6add20bcfa2d23 Mon Sep 17 00:00:00 2001 From: chen Date: Thu, 4 Jun 2026 04:26:11 +0800 Subject: [PATCH 3/6] test: poll child started marker in msvcrt cross-process lock test --- tests/test_export_state_store.py | 34 +++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/tests/test_export_state_store.py b/tests/test_export_state_store.py index 0ea067f..4ebfc30 100644 --- a/tests/test_export_state_store.py +++ b/tests/test_export_state_store.py @@ -12,6 +12,15 @@ REPO_ROOT = Path(__file__).resolve().parent.parent + +def _wait_for_file(path: Path, timeout: float = 5.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if path.is_file(): + return + time.sleep(0.01) + raise AssertionError(f"timed out waiting for {path}") + from utils.export_state_store import ( atomic_write_export_state, export_state_lock, @@ -119,22 +128,33 @@ def test_export_state_lock_blocks_second_process(tmp_path: Path) -> None: """While the parent holds ``msvcrt`` lock, a child process cannot acquire it.""" state_file = tmp_path / "export_state.json" state_file.write_text("{}", encoding="utf-8") - marker = tmp_path / "child_acquired.lock" + started_marker = tmp_path / "child_started.lock" + acquired_marker = tmp_path / "child_acquired.lock" child = ( "import sys\n" "from pathlib import Path\n" f"sys.path.insert(0, {str(REPO_ROOT)!r})\n" "from utils.export_state_store import export_state_lock\n" - "path, marker = sys.argv[1], sys.argv[2]\n" + "path, started, acquired = sys.argv[1], sys.argv[2], sys.argv[3]\n" + "Path(started).write_text('ok', encoding='utf-8')\n" "with export_state_lock(path):\n" - " Path(marker).write_text('ok', encoding='utf-8')\n" + " Path(acquired).write_text('ok', encoding='utf-8')\n" ) with export_state_lock(str(state_file)): proc = subprocess.Popen( - [sys.executable, "-c", child, str(state_file), str(marker)], + [ + sys.executable, + "-c", + child, + str(state_file), + str(started_marker), + str(acquired_marker), + ], cwd=str(REPO_ROOT), ) - time.sleep(0.5) - assert not marker.is_file(), "child acquired lock while parent still holds it" + _wait_for_file(started_marker) + assert not acquired_marker.is_file(), ( + "child acquired lock while parent still holds it" + ) assert proc.wait(timeout=10) == 0 - assert marker.read_text(encoding="utf-8") == "ok" + assert acquired_marker.read_text(encoding="utf-8") == "ok" From dd06045cc081b3c0161c8bfa6ad66e86a5cc64ca Mon Sep 17 00:00:00 2001 From: chen Date: Thu, 4 Jun 2026 05:42:53 +0800 Subject: [PATCH 4/6] test(ci): harden msvcrt lock test and document CI job overlap --- .github/workflows/ci.yml | 4 ++- tests/test_export_state_store.py | 59 ++++++++++++++++++++------------ tests/test_session_path.py | 6 ++++ 3 files changed, 47 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c81704..cefe358 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,7 @@ jobs: - name: Install dev dependencies (Flask + pytest) run: pip install -r requirements-dev.txt + # Full suite gate (pyproject addopts include --cov + 60% fail-under). - name: Run tests run: pytest --tb=short -q @@ -123,7 +124,8 @@ jobs: - name: Install dev dependencies run: pip install -r requirements-dev.txt - # Subset run: skip fail-under (full suite enforces 60% in pytest job). + # Intentional overlap with pytest job: re-runs API/search subset with --cov + # and uploads coverage.xml per OS (pytest job is the full-suite gate). - name: Run integration tests with coverage run: pytest tests/test_api_integration.py tests/test_search.py -v --cov=api --cov=utils --cov-report=xml --cov-fail-under=0 diff --git a/tests/test_export_state_store.py b/tests/test_export_state_store.py index 4ebfc30..221563b 100644 --- a/tests/test_export_state_store.py +++ b/tests/test_export_state_store.py @@ -21,6 +21,15 @@ def _wait_for_file(path: Path, timeout: float = 5.0) -> None: time.sleep(0.01) raise AssertionError(f"timed out waiting for {path}") + +def _assert_file_stays_absent(path: Path, timeout: float = 0.5) -> None: + """While parent holds the lock, child must not write *path* (still blocked).""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if path.is_file(): + raise AssertionError(f"{path} appeared while parent still holds the lock") + time.sleep(0.01) + from utils.export_state_store import ( atomic_write_export_state, export_state_lock, @@ -128,33 +137,41 @@ def test_export_state_lock_blocks_second_process(tmp_path: Path) -> None: """While the parent holds ``msvcrt`` lock, a child process cannot acquire it.""" state_file = tmp_path / "export_state.json" state_file.write_text("{}", encoding="utf-8") - started_marker = tmp_path / "child_started.lock" + attempting_marker = tmp_path / "child_attempting.lock" acquired_marker = tmp_path / "child_acquired.lock" child = ( "import sys\n" "from pathlib import Path\n" f"sys.path.insert(0, {str(REPO_ROOT)!r})\n" "from utils.export_state_store import export_state_lock\n" - "path, started, acquired = sys.argv[1], sys.argv[2], sys.argv[3]\n" - "Path(started).write_text('ok', encoding='utf-8')\n" + "path, attempting, acquired = sys.argv[1], sys.argv[2], sys.argv[3]\n" + "Path(attempting).write_text('ok', encoding='utf-8')\n" "with export_state_lock(path):\n" " Path(acquired).write_text('ok', encoding='utf-8')\n" ) - with export_state_lock(str(state_file)): - proc = subprocess.Popen( - [ - sys.executable, - "-c", - child, - str(state_file), - str(started_marker), - str(acquired_marker), - ], - cwd=str(REPO_ROOT), - ) - _wait_for_file(started_marker) - assert not acquired_marker.is_file(), ( - "child acquired lock while parent still holds it" - ) - assert proc.wait(timeout=10) == 0 - assert acquired_marker.read_text(encoding="utf-8") == "ok" + proc = subprocess.Popen( + [ + sys.executable, + "-c", + child, + str(state_file), + str(attempting_marker), + str(acquired_marker), + ], + cwd=str(REPO_ROOT), + ) + try: + with export_state_lock(str(state_file)): + _wait_for_file(attempting_marker) + _assert_file_stays_absent(acquired_marker) + try: + assert proc.wait(timeout=10) == 0 + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + raise + assert acquired_marker.read_text(encoding="utf-8") == "ok" + finally: + if proc.poll() is None: + proc.kill() + proc.wait() diff --git a/tests/test_session_path.py b/tests/test_session_path.py index e2f8e4d..279c9da 100644 --- a/tests/test_session_path.py +++ b/tests/test_session_path.py @@ -14,6 +14,12 @@ def test_get_claude_projects_dir_uses_userprofile_on_windows( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: + """Linux/Windows CI smoke: patch ``session_path.platform.system`` (needs ``import platform`` in module). + + If ``session_path`` ever switches to ``from platform import system``, this patch + no-ops but may still pass on Linux via ``expanduser("~")``. Real Windows behavior + is covered by ``test_get_claude_projects_dir_on_windows_runner`` (win32-only). + """ profile = tmp_path / "Users" / "testuser" monkeypatch.setattr(session_path.platform, "system", lambda: "Windows") monkeypatch.setenv("USERPROFILE", str(profile)) From 88bf398597f59d06a2696297de26ac6fa51cad91 Mon Sep 17 00:00:00 2001 From: chen Date: Thu, 4 Jun 2026 05:57:42 +0800 Subject: [PATCH 5/6] test: hold export lock before spawning child in msvcrt test --- tests/test_export_state_store.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/tests/test_export_state_store.py b/tests/test_export_state_store.py index 221563b..33c4151 100644 --- a/tests/test_export_state_store.py +++ b/tests/test_export_state_store.py @@ -149,21 +149,23 @@ def test_export_state_lock_blocks_second_process(tmp_path: Path) -> None: "with export_state_lock(path):\n" " Path(acquired).write_text('ok', encoding='utf-8')\n" ) - proc = subprocess.Popen( - [ - sys.executable, - "-c", - child, - str(state_file), - str(attempting_marker), - str(acquired_marker), - ], - cwd=str(REPO_ROOT), - ) + proc: subprocess.Popen[str] | None = None try: with export_state_lock(str(state_file)): + proc = subprocess.Popen( + [ + sys.executable, + "-c", + child, + str(state_file), + str(attempting_marker), + str(acquired_marker), + ], + cwd=str(REPO_ROOT), + ) _wait_for_file(attempting_marker) _assert_file_stays_absent(acquired_marker) + assert proc is not None try: assert proc.wait(timeout=10) == 0 except subprocess.TimeoutExpired: @@ -172,6 +174,6 @@ def test_export_state_lock_blocks_second_process(tmp_path: Path) -> None: raise assert acquired_marker.read_text(encoding="utf-8") == "ok" finally: - if proc.poll() is None: + if proc is not None and proc.poll() is None: proc.kill() proc.wait() From f227c7b416fb241f51ce0cf2afa2d79abe44932f Mon Sep 17 00:00:00 2001 From: chen Date: Thu, 4 Jun 2026 05:59:18 +0800 Subject: [PATCH 6/6] fix(test): use Popen[bytes] annotation for msvcrt lock test --- tests/test_export_state_store.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_export_state_store.py b/tests/test_export_state_store.py index 33c4151..8c57556 100644 --- a/tests/test_export_state_store.py +++ b/tests/test_export_state_store.py @@ -149,7 +149,7 @@ def test_export_state_lock_blocks_second_process(tmp_path: Path) -> None: "with export_state_lock(path):\n" " Path(acquired).write_text('ok', encoding='utf-8')\n" ) - proc: subprocess.Popen[str] | None = None + proc: subprocess.Popen[bytes] | None = None try: with export_state_lock(str(state_file)): proc = subprocess.Popen(