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
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Bash scripts and Git hooks must keep their shebangs usable with core.autocrlf.
*.sh text eol=lf
.githooks/* text eol=lf
7 changes: 6 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@ concurrency:
jobs:
core:
# The install the README promises: openjiuwen alone, no game or report extras, no keys.
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
matrix:
os: [ubuntu-latest]
python: ["3.11", "3.13"]
include:
- os: windows-latest
python: "3.11"
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v10.2.0
Expand All @@ -32,6 +36,7 @@ jobs:
- run: uv run ty check
- run: uv build
- run: scripts/smoke.sh
shell: bash
- run: uv run pytest -q

full:
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); ver

## Unreleased

### Fixed

- Windows development checks: the smoke script accepts CRLF output, shell scripts and Git hooks retain LF
line endings, and tests check socket closure and invalid output directories without Unix-specific behavior.
The core CI matrix now covers Windows with Python 3.11.

### Added

- `docs/benchmarks.md`: the Google Flights driver comparison rerun on 2026-09-23 from Poland, every arm three times on
Expand Down
11 changes: 7 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,13 @@ uv run pytest -q
scripts/smoke.sh
```

CI runs these on two installs: the core (`uv sync --extra dev`, on Python 3.11 and 3.13, where the game tests
skip) and the contributor install above (where the ALFWorld tests skip and the GIF test renders in the Chromium
that `playwright install` fetches). `uv build` and `ty check` run in the core job only, since the extras resolve
the optional imports `ty` is told to ignore.
On Windows, run `scripts/smoke.sh` and the Git hooks from Git Bash with the native Windows `uv` on `PATH`.
The shell scripts and hooks keep LF line endings even when Git's `core.autocrlf` is enabled.

CI runs these on two installs: the core (`uv sync --extra dev`, on Linux with Python 3.11 and 3.13 and on
Windows with Python 3.11, where the game tests skip) and the contributor install above (on Linux, where the
ALFWorld tests skip and the GIF test renders in the Chromium that `playwright install` fetches). `uv build`
and `ty check` run in the core job only, since the extras resolve the optional imports `ty` is told to ignore.
`scripts/smoke.sh` is the contract for the core install: `list`, `--help` for every agent, `decide` without a key
and the MCP listing must work with no extra installed. A game whose extra is missing must say which one on stderr.

Expand Down
3 changes: 2 additions & 1 deletion scripts/smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ AGENTS="alfworld allrecipes blackjack desktop flights game2048 injection_guard m

fail() { echo "smoke: $*" >&2; exit 1; }

listed=$($S1A list)
# Native Windows Python emits CRLF even when launched from Git Bash.
listed=$($S1A list | tr -d '\r')
[ "$(echo "$listed" | tr '\n' ' ' | sed 's/ $//')" = "$AGENTS" ] || fail "list printed: $listed"
echo "list: $AGENTS"

Expand Down
17 changes: 10 additions & 7 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,13 +291,16 @@ def test_outside_a_checkout_the_import_is_one_line_on_stderr_exit_1_and_writes_n
self.assertEqual(sorted(p.name for p in Path(tmp).iterdir()), ["s1a"])

def test_an_unusable_s1a_home_is_one_line_on_stderr_and_exit_1(self) -> None:
done = subprocess.run(
[sys.executable, "-c", "import s1a.cli"],
capture_output=True,
text=True,
timeout=120,
env={**os.environ, "S1A_HOME": "/dev/null/x"},
)
with tempfile.TemporaryDirectory() as tmp:
blocked = Path(tmp) / "file"
blocked.write_text("not a directory", encoding="utf-8")
done = subprocess.run(
[sys.executable, "-c", "import s1a.cli"],
capture_output=True,
text=True,
timeout=120,
env={**os.environ, "S1A_HOME": str(blocked / "x")},
)
self.assertEqual((done.returncode, done.stdout), (1, ""))
self.assertEqual(len(done.stderr.strip().splitlines()), 1, done.stderr)
self.assertIn("S1A_HOME", done.stderr)
Expand Down
19 changes: 6 additions & 13 deletions tests/test_tool_hands.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,14 +165,6 @@ async def shutdown(self) -> None:
self.stopped.append("shutdown")


def _refused(port: int) -> bool:
try:
socket.create_connection(("127.0.0.1", port), timeout=1.0).close()
except ConnectionRefusedError:
return True
return False


class TestBrowserHandsLifecycle(IsolatedAsyncioTestCase):
async def test_a_failed_start_still_stops_the_runtime(self) -> None:
with patch.object(hands_module, "BrowserAgentRuntime", FakeRuntime):
Expand All @@ -192,11 +184,12 @@ async def test_page_not_ready_names_the_last_probe_error(self) -> None:
async def test_serving_shuts_the_static_server_down_when_the_session_ends(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
site = serve_static(Path(tmp))
port = site.server_address[1]
self.assertFalse(_refused(port))
self.assertGreaterEqual(site.fileno(), 0)
async with serving(site, nullcontext()):
self.assertFalse(_refused(port))
self.assertTrue(_refused(port))
with socket.create_connection(site.server_address, timeout=1.0):
pass
# Check our socket directly: a closed-port connection can time out on Windows.
self.assertEqual(site.fileno(), -1)

async def test_serving_shuts_the_static_server_down_when_the_session_fails_to_open(self) -> None:
@asynccontextmanager
Expand All @@ -209,4 +202,4 @@ async def failing() -> AsyncIterator[None]:
with self.assertRaisesRegex(RuntimeError, "no browser"):
async with serving(site, failing()):
pass
self.assertTrue(_refused(site.server_address[1]))
self.assertEqual(site.fileno(), -1)