diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2b74d30 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f25c13b..ea90276 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index 21b9dd7..5778f9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2107ee6..9af4d53 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 6315fb6..1ac2452 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -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" diff --git a/tests/test_cli.py b/tests/test_cli.py index 729f365..7895649 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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) diff --git a/tests/test_tool_hands.py b/tests/test_tool_hands.py index 56567c4..7bb4033 100644 --- a/tests/test_tool_hands.py +++ b/tests/test_tool_hands.py @@ -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): @@ -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 @@ -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)