Files
software-mansion__argent/packages/tool-server/test/stop-metro-netstat.test.ts
Ignacy Łątka 7400179b5b Windows support: Android + Chromium host control plane (#421)
## What

Makes Argent run on Windows. iOS Simulator is macOS-only (Apple), so on
Windows that means the **Android + Chromium** host control plane. The
blocker was twofold: (1) no Windows `simulator-server` binary, and (2)
POSIX-only assumptions in the tool-server.

Companion radon PR builds the binary and runs the Android emulator E2E
on Windows: software-mansion/radon#134.

## Changes

**Windows-aware tool-server / resolver / bundling**
- New shared `commandOnPath()` helper — `where` on Windows, `command -v`
via `/bin/sh` on POSIX. `android-binary`, `check-deps`, and `vega-cli`
previously hardcoded `/bin/sh`, which never matches on Windows.
- `android-binary`: append `.exe` to `adb`/`emulator` in the
`$ANDROID_HOME` fallback; probe `%LOCALAPPDATA%\Android\Sdk` (Android
Studio's Windows default).
- simulator-server resolver + dispatcher + `bundle-tools` +
`download-simulator-server.sh`: ship and resolve `simulator-server.exe`
on `win32` (new `simulatorServerBinaryName()`), add `win32` to the
bundled host keys, add the `simulator-server-argent-windows.exe`
download target with PE arch validation.
- `stop-metro`: resolve listening PIDs via `netstat -ano` on Windows
(`lsof` is POSIX-only and threw ENOENT).
- `adb.ts`: AVD root resolution used `$HOME` + `startsWith("/")`
(rejects `C:\…`); now `os.homedir()` + `path.isAbsolute()`.
- **`boot-device`**: the emulator was spawned `detached` with
`stdio:"ignore"`; on Windows a detached emulator with NUL stdout/stderr
never reaches `sys.boot_completed` (verified on `windows-latest`: the
identical flag set boots fine with real handles, hangs with NUL).
Redirect the detached emulator's output to a temp log file on win32.
POSIX keeps `"ignore"`.

**Tests** — cross-platform unit coverage for `commandOnPath` (both
branches), win32 `.exe` Android resolution, win32 `.exe`
simulator-server resolution, and a win32 AVD drive-path case; dep-gate
tests now mock at the `commandOnPath` boundary so they're
platform-agnostic.

**CI** — `windows-e2e.yml` (`windows-chromium` on `windows-latest`):
runs the resolver/dep unit tests natively on Windows, confirms `adb.exe`
resolution via the SDK root, then drives a **real headless Chrome over
CDP** through the tool-server (discover → screenshot → describe → tap →
observe the DOM mutation). Flow in `scripts/ci/windows-chromium-e2e.mjs`
(also runnable locally / in Docker). The `simulator-server.exe` build +
the full **Android-emulator E2E on Windows** live in radon CI (the
binary's source is the private radon repo).

## Verification (all green)
- **Chromium control plane on real Windows** (`windows-latest`): `✓
discovered chromium-cdp-9222 → ✓ screenshot → ✓ describe → ✓ gesture-tap
→ ✓ DOM mutation observed`. Resolver/dep unit tests run natively;
`adb.exe` resolves via the SDK root (it's *not* on PATH on the runner —
the gap the resolver closes).
- **Android emulator on real Windows** (radon#134, `windows-latest` /
WHPX): the real Argent path — `boot-device` → `{ booted: true }` →
screenshot via `simulator-server.exe` → gesture-tap — all green. (The
boot-device fix above is what makes the detached emulator reach
`boot_completed`.)
- **Local clean-room E2E**: the Chromium flow runs green in a headless
Linux Docker container (fresh `npm ci`, no host pollution).
- **No regressions**: full tool-server suite (1624) +
native-devtools-ios (16); ESLint/Prettier/typecheck clean; **Wayland
E2E** (Linux AVD boot) and **Vega VVD E2E** still pass (they exercise
the changed `adb.ts` / `boot-device.ts` / `vega-cli.ts` paths).

## Notes
Android-emulator E2E needs WHPX nested virtualization — confirmed
available on the current hosted `windows-latest` image (Server 2025);
the emulator boots hardware-accelerated in ~3 min.

---------

Co-authored-by: Ignacy Łątka <claude-hubert.gancarczyk@swmansion.com>
Co-authored-by: Ignacy Łątka <latekvo@users.noreply.github.com>
2026-07-22 13:53:32 +02:00

101 lines
4.9 KiB
TypeScript

// Unit coverage for the win32 `netstat -ano` → listening-PID parser. The logic
// only runs on Windows in production (POSIX uses lsof), but the parser is a
// pure function, so it is exercised here on any host. Fixtures mirror real
// `netstat -ano` output (CRLF line endings, the "Active Connections" banner and
// column header, IPv4 + IPv6 LISTENING rows, ESTABLISHED rows, a UDP row, and a
// row for a neighbouring port that must not be matched).
import { describe, it, expect } from "vitest";
import { parseNetstatListeningPids } from "../src/tools/simulator/stop-metro";
describe("parseNetstatListeningPids", () => {
it("returns the deduped listening PIDs for the port across IPv4 and IPv6, ignoring everything else", () => {
// PID 1234 listens on both 0.0.0.0:8081 (IPv4) and 127.0.0.1:8081 (a second
// IPv4 row) — the duplicate must collapse.
// PID 5678 listens on [::]:8081 (IPv6) — must be picked up.
// PID 9999 has an ESTABLISHED connection on :8081 — must be ignored (it is
// the tool-server's own CDP client socket; killing it is the bug guarded
// against).
// PID 4444 LISTENS on 0.0.0.0:18081 — the leading-colon guard must keep
// `:18081` from matching port 8081.
// PID 4321 is a UDP row (4 columns, no state) on :8081 — must be ignored.
const netstat = [
"",
"Active Connections",
"",
" Proto Local Address Foreign Address State PID",
" TCP 0.0.0.0:8081 0.0.0.0:0 LISTENING 1234",
" TCP 127.0.0.1:8081 0.0.0.0:0 LISTENING 1234",
" TCP [::]:8081 [::]:0 LISTENING 5678",
" TCP 127.0.0.1:8081 127.0.0.1:52345 ESTABLISHED 9999",
" TCP 0.0.0.0:18081 0.0.0.0:0 LISTENING 4444",
" UDP 0.0.0.0:8081 *:* 4321",
"",
].join("\r\n");
const pids = parseNetstatListeningPids(netstat, 8081);
// Deduped, in first-seen order: 1234 (IPv4) then 5678 (IPv6).
expect(pids).toEqual([1234, 5678]);
// The colon guard kept the :18081 listener out.
expect(pids).not.toContain(4444);
// ESTABLISHED and UDP rows were ignored.
expect(pids).not.toContain(9999);
expect(pids).not.toContain(4321);
});
it("matches an IPv6-only listener via the [::]:<port> row", () => {
const netstat = [
" Proto Local Address Foreign Address State PID",
" TCP [::]:8081 [::]:0 LISTENING 7777",
].join("\r\n");
expect(parseNetstatListeningPids(netstat, 8081)).toEqual([7777]);
});
it("returns an empty list when nothing is listening on the port", () => {
const netstat = [
"Active Connections",
" Proto Local Address Foreign Address State PID",
" TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING 2222",
" TCP 127.0.0.1:8081 127.0.0.1:50000 ESTABLISHED 3333",
].join("\r\n");
expect(parseNetstatListeningPids(netstat, 8081)).toEqual([]);
});
it("returns an empty list for empty or header-only output", () => {
expect(parseNetstatListeningPids("", 8081)).toEqual([]);
expect(parseNetstatListeningPids("\r\nActive Connections\r\n", 8081)).toEqual([]);
});
it("matches listeners on a LOCALIZED (non-English) Windows host", () => {
// Windows localizes the State column, so keying off the literal "LISTENING"
// used to return [] on a German/French host and stop-metro silently no-opped.
// The wildcard foreign address (0.0.0.0:0 / [::]:0) still identifies the
// listener, while a localized ESTABLISHED row (real remote endpoint) is
// still correctly skipped.
const german = [
" Proto Lokale Adresse Remoteadresse Status PID",
" TCP 0.0.0.0:8081 0.0.0.0:0 ABHÖREN 1234",
" TCP [::]:8081 [::]:0 ABHÖREN 5678",
" TCP 127.0.0.1:8081 127.0.0.1:52345 HERGESTELLT 9999",
].join("\r\n");
expect(parseNetstatListeningPids(german, 8081)).toEqual([1234, 5678]);
const french = [
" Proto Adresse locale Adresse distante État PID",
" TCP 0.0.0.0:8081 0.0.0.0:0 À L'ÉCOUTE 2468",
" TCP 127.0.0.1:8081 127.0.0.1:60000 ESTABLISHED 9999",
].join("\r\n");
expect(parseNetstatListeningPids(french, 8081)).toEqual([2468]);
});
it("handles bare LF line endings as well as CRLF", () => {
const netstat =
"Proto Local Address Foreign Address State PID\n" +
" TCP 0.0.0.0:8081 0.0.0.0:0 LISTENING 1515\n";
expect(parseNetstatListeningPids(netstat, 8081)).toEqual([1515]);
});
});