Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
98 changes: 97 additions & 1 deletion tests/test_export_state_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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()
40 changes: 40 additions & 0 deletions tests/test_session_path.py
Original file line number Diff line number Diff line change
@@ -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")
Comment thread
clean6378-max-it marked this conversation as resolved.
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
Loading