diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47ea79c..cefe358 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,12 @@ jobs: PY pytest: - runs-on: ubuntu-latest + name: pytest (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] permissions: contents: read steps: @@ -63,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 @@ -93,8 +99,12 @@ jobs: run: mypy tests --config-file mypy-tests.ini --follow-imports skip integration-tests: - name: API integration tests + coverage - runs-on: ubuntu-latest + name: API integration tests + coverage (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] permissions: contents: read actions: write @@ -114,18 +124,23 @@ 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 - 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 + name: Frontend unit tests (vitest) (${{ matrix.os }}) + 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..8c57556 100644 --- a/tests/test_export_state_store.py +++ b/tests/test_export_state_store.py @@ -3,9 +3,38 @@ from __future__ import annotations import json +import subprocess +import sys +import time from pathlib import Path -from utils.export_state_store import load_export_state_from_disk +import pytest + +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}") + + +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, + load_export_state_from_disk, +) def test_load_rejects_non_object_json(tmp_path: Path): @@ -81,3 +110,70 @@ 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": {"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") == 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") + 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, 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" + ) + proc: subprocess.Popen[bytes] | 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: + proc.kill() + proc.wait() + raise + assert acquired_marker.read_text(encoding="utf-8") == "ok" + finally: + if proc is not None and proc.poll() is None: + proc.kill() + proc.wait() diff --git a/tests/test_session_path.py b/tests/test_session_path.py new file mode 100644 index 0000000..279c9da --- /dev/null +++ b/tests/test_session_path.py @@ -0,0 +1,40 @@ +"""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: + """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)) + + 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" + monkeypatch.setenv("USERPROFILE", str(profile)) + + got = session_path.get_claude_projects_dir() + expected = os.path.join(str(profile), ".claude", "projects") + assert got == expected