Skip to content

Commit 44942f9

Browse files
ci: add windows-latest to test matrix (#65)
* ci: add windows-latest to test matrix * test(ci): polish Windows CI tests and matrix job names * test: poll child started marker in msvcrt cross-process lock test * test(ci): harden msvcrt lock test and document CI job overlap * test: hold export lock before spawning child in msvcrt test * fix(test): use Popen[bytes] annotation for msvcrt lock test
1 parent 965618b commit 44942f9

4 files changed

Lines changed: 162 additions & 9 deletions

File tree

.github/workflows/ci.yml

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,12 @@ jobs:
4444
PY
4545
4646
pytest:
47-
runs-on: ubuntu-latest
47+
name: pytest (${{ matrix.os }})
48+
runs-on: ${{ matrix.os }}
49+
strategy:
50+
fail-fast: false
51+
matrix:
52+
os: [ubuntu-latest, windows-latest]
4853
permissions:
4954
contents: read
5055
steps:
@@ -63,6 +68,7 @@ jobs:
6368
- name: Install dev dependencies (Flask + pytest)
6469
run: pip install -r requirements-dev.txt
6570

71+
# Full suite gate (pyproject addopts include --cov + 60% fail-under).
6672
- name: Run tests
6773
run: pytest --tb=short -q
6874

@@ -93,8 +99,12 @@ jobs:
9399
run: mypy tests --config-file mypy-tests.ini --follow-imports skip
94100

95101
integration-tests:
96-
name: API integration tests + coverage
97-
runs-on: ubuntu-latest
102+
name: API integration tests + coverage (${{ matrix.os }})
103+
runs-on: ${{ matrix.os }}
104+
strategy:
105+
fail-fast: false
106+
matrix:
107+
os: [ubuntu-latest, windows-latest]
98108
permissions:
99109
contents: read
100110
actions: write
@@ -114,18 +124,23 @@ jobs:
114124
- name: Install dev dependencies
115125
run: pip install -r requirements-dev.txt
116126

117-
# Subset run: skip fail-under (full suite enforces 60% in pytest job).
127+
# Intentional overlap with pytest job: re-runs API/search subset with --cov
128+
# and uploads coverage.xml per OS (pytest job is the full-suite gate).
118129
- name: Run integration tests with coverage
119130
run: pytest tests/test_api_integration.py tests/test_search.py -v --cov=api --cov=utils --cov-report=xml --cov-fail-under=0
120131

121132
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
122133
with:
123-
name: coverage-report
134+
name: coverage-report-${{ matrix.os }}
124135
path: coverage.xml
125136

126137
js-tests:
127-
name: Frontend unit tests (vitest)
128-
runs-on: ubuntu-latest
138+
name: Frontend unit tests (vitest) (${{ matrix.os }})
139+
runs-on: ${{ matrix.os }}
140+
strategy:
141+
fail-fast: false
142+
matrix:
143+
os: [ubuntu-latest, windows-latest]
129144
permissions:
130145
contents: read
131146
steps:

CONTRIBUTING.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ Thanks for considering a patch. This repo is a small Flask app plus a hash-route
99
- **Python 3.12** (matches CI)
1010
- **Node 20+** (only if you change `static/js/` or run frontend unit tests)
1111

12+
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.
13+
1214
### Bootstrap (Windows PowerShell)
1315

1416
```powershell
@@ -105,7 +107,7 @@ npm run test:coverage # optional
105107
- PR checklist:
106108
- [ ] `pytest -q` green locally
107109
- [ ] `npm test` green if JS changed
108-
- [ ] CI jobs green (`pytest`, `integration-tests`, `js-tests`, `prod-install-smoke`)
110+
- [ ] CI jobs green (`pytest`, `integration-tests`, `js-tests` on Ubuntu + Windows; `mypy`, `prod-install-smoke` on Ubuntu)
109111
- [ ] PR description includes a **Test plan** section
110112
- [ ] API changes update [`docs/api-reference.md`](docs/api-reference.md) if behavior or errors change
111113

tests/test_export_state_store.py

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,38 @@
33
from __future__ import annotations
44

55
import json
6+
import subprocess
7+
import sys
8+
import time
69
from pathlib import Path
710

8-
from utils.export_state_store import load_export_state_from_disk
11+
import pytest
12+
13+
REPO_ROOT = Path(__file__).resolve().parent.parent
14+
15+
16+
def _wait_for_file(path: Path, timeout: float = 5.0) -> None:
17+
deadline = time.monotonic() + timeout
18+
while time.monotonic() < deadline:
19+
if path.is_file():
20+
return
21+
time.sleep(0.01)
22+
raise AssertionError(f"timed out waiting for {path}")
23+
24+
25+
def _assert_file_stays_absent(path: Path, timeout: float = 0.5) -> None:
26+
"""While parent holds the lock, child must not write *path* (still blocked)."""
27+
deadline = time.monotonic() + timeout
28+
while time.monotonic() < deadline:
29+
if path.is_file():
30+
raise AssertionError(f"{path} appeared while parent still holds the lock")
31+
time.sleep(0.01)
32+
33+
from utils.export_state_store import (
34+
atomic_write_export_state,
35+
export_state_lock,
36+
load_export_state_from_disk,
37+
)
938

1039

1140
def test_load_rejects_non_object_json(tmp_path: Path):
@@ -81,3 +110,70 @@ def locking(fd, mode, nbytes):
81110
with mod.export_state_lock(str(state_file)):
82111
assert (FakeMsvcrt.LK_LOCK, 1) in calls
83112
assert calls[-1] == (FakeMsvcrt.LK_UNLCK, 1)
113+
114+
115+
@pytest.mark.skipif(sys.platform != "win32", reason="requires Windows msvcrt")
116+
def test_export_state_lock_real_msvcrt_roundtrip(tmp_path: Path) -> None:
117+
"""Exercise real ``msvcrt.locking`` on a Windows runner (not FakeMsvcrt)."""
118+
state_file = tmp_path / "export_state.json"
119+
state_file.write_text("{}", encoding="utf-8")
120+
payload = {
121+
"sessions": {"sess-msvcrt-roundtrip": 1740000123.5},
122+
"lastExportTime": "2026-06-04T18:30:00Z",
123+
"exportedCount": 42,
124+
}
125+
126+
with export_state_lock(str(state_file)):
127+
atomic_write_export_state(payload, str(state_file))
128+
129+
loaded = load_export_state_from_disk(str(state_file))
130+
assert loaded.get("exportedCount") == 42
131+
assert loaded.get("sessions") == {"sess-msvcrt-roundtrip": 1740000123.5}
132+
assert loaded.get("lastExportTime") == "2026-06-04T18:30:00Z"
133+
134+
135+
@pytest.mark.skipif(sys.platform != "win32", reason="requires Windows msvcrt")
136+
def test_export_state_lock_blocks_second_process(tmp_path: Path) -> None:
137+
"""While the parent holds ``msvcrt`` lock, a child process cannot acquire it."""
138+
state_file = tmp_path / "export_state.json"
139+
state_file.write_text("{}", encoding="utf-8")
140+
attempting_marker = tmp_path / "child_attempting.lock"
141+
acquired_marker = tmp_path / "child_acquired.lock"
142+
child = (
143+
"import sys\n"
144+
"from pathlib import Path\n"
145+
f"sys.path.insert(0, {str(REPO_ROOT)!r})\n"
146+
"from utils.export_state_store import export_state_lock\n"
147+
"path, attempting, acquired = sys.argv[1], sys.argv[2], sys.argv[3]\n"
148+
"Path(attempting).write_text('ok', encoding='utf-8')\n"
149+
"with export_state_lock(path):\n"
150+
" Path(acquired).write_text('ok', encoding='utf-8')\n"
151+
)
152+
proc: subprocess.Popen[bytes] | None = None
153+
try:
154+
with export_state_lock(str(state_file)):
155+
proc = subprocess.Popen(
156+
[
157+
sys.executable,
158+
"-c",
159+
child,
160+
str(state_file),
161+
str(attempting_marker),
162+
str(acquired_marker),
163+
],
164+
cwd=str(REPO_ROOT),
165+
)
166+
_wait_for_file(attempting_marker)
167+
_assert_file_stays_absent(acquired_marker)
168+
assert proc is not None
169+
try:
170+
assert proc.wait(timeout=10) == 0
171+
except subprocess.TimeoutExpired:
172+
proc.kill()
173+
proc.wait()
174+
raise
175+
assert acquired_marker.read_text(encoding="utf-8") == "ok"
176+
finally:
177+
if proc is not None and proc.poll() is None:
178+
proc.kill()
179+
proc.wait()

tests/test_session_path.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Tests for utils/session_path platform-specific home resolution."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import sys
7+
from pathlib import Path
8+
9+
import pytest
10+
11+
from utils import session_path
12+
13+
14+
def test_get_claude_projects_dir_uses_userprofile_on_windows(
15+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
16+
) -> None:
17+
"""Linux/Windows CI smoke: patch ``session_path.platform.system`` (needs ``import platform`` in module).
18+
19+
If ``session_path`` ever switches to ``from platform import system``, this patch
20+
no-ops but may still pass on Linux via ``expanduser("~")``. Real Windows behavior
21+
is covered by ``test_get_claude_projects_dir_on_windows_runner`` (win32-only).
22+
"""
23+
profile = tmp_path / "Users" / "testuser"
24+
monkeypatch.setattr(session_path.platform, "system", lambda: "Windows")
25+
monkeypatch.setenv("USERPROFILE", str(profile))
26+
27+
got = session_path.get_claude_projects_dir()
28+
assert got == os.path.join(str(profile), ".claude", "projects")
29+
30+
31+
@pytest.mark.skipif(sys.platform != "win32", reason="native Windows runner")
32+
def test_get_claude_projects_dir_on_windows_runner(
33+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
34+
) -> None:
35+
profile = tmp_path / "profile"
36+
monkeypatch.setenv("USERPROFILE", str(profile))
37+
38+
got = session_path.get_claude_projects_dir()
39+
expected = os.path.join(str(profile), ".claude", "projects")
40+
assert got == expected

0 commit comments

Comments
 (0)