From 0f208d0161947369d2a381db40ecfaa0b73d8087 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Fri, 26 Jun 2026 17:10:39 +0200 Subject: [PATCH 01/10] feat(windows): make tool-server, resolver, and bundling Windows-aware Argent's host-side control plane (Android + Chromium) had a handful of POSIX-only assumptions that crashed or silently no-op'd on Windows. iOS stays macOS-only (Apple restriction), so this targets the Android + Chromium paths. - Add a shared commandOnPath() helper using `where` on Windows and `command -v` via /bin/sh on POSIX; route android-binary, check-deps, and vega-cli through it (they previously hardcoded /bin/sh, which never matches on Windows). - android-binary: append `.exe` to adb/emulator in the $ANDROID_HOME fallback and probe %LOCALAPPDATA%\\Android\\Sdk (Studio's Windows default). - simulator-server resolver + dispatcher + bundle-tools + the download script: ship and resolve simulator-server.exe on win32 (new simulatorServerBinaryName()); add win32 to the bundled host keys. - stop-metro: resolve listening PIDs via `netstat -ano` on Windows (`lsof` is POSIX-only and threw ENOENT there). Tests: cross-platform unit coverage for commandOnPath (where/command -v branches), the win32 .exe android resolution, and win32 .exe simulator-server path; update the dep-gate tests to mock at the commandOnPath boundary so they're platform-agnostic. --- .../scripts/argent-simulator-server.cjs | 9 +- packages/argent/scripts/bundle-tools.cjs | 14 ++- packages/native-devtools-ios/src/index.ts | 14 ++- .../native-devtools-ios/test/index.test.ts | 39 +++++++ .../src/tools/simulator/stop-metro.ts | 74 +++++++++---- .../tool-server/src/utils/android-binary.ts | 50 +++++---- packages/tool-server/src/utils/check-deps.ts | 20 ++-- .../tool-server/src/utils/command-on-path.ts | 44 ++++++++ packages/tool-server/src/utils/vega-cli.ts | 13 +-- .../test/android-binary-windows.test.ts | 102 ++++++++++++++++++ packages/tool-server/test/check-deps.test.ts | 44 +++----- .../tool-server/test/command-on-path.test.ts | 73 +++++++++++++ .../tool-server/test/http-dep-gate.test.ts | 29 ++--- scripts/download-simulator-server.sh | 30 ++++-- 14 files changed, 421 insertions(+), 134 deletions(-) create mode 100644 packages/tool-server/src/utils/command-on-path.ts create mode 100644 packages/tool-server/test/android-binary-windows.test.ts create mode 100644 packages/tool-server/test/command-on-path.test.ts diff --git a/packages/argent/scripts/argent-simulator-server.cjs b/packages/argent/scripts/argent-simulator-server.cjs index 15cfa3074..b58e09c74 100755 --- a/packages/argent/scripts/argent-simulator-server.cjs +++ b/packages/argent/scripts/argent-simulator-server.cjs @@ -21,11 +21,16 @@ const fs = require("node:fs"); const platformKey = process.platform === "linux" && process.arch === "arm64" ? "linux-arm64" : process.platform; -const binary = path.join(__dirname, platformKey, "simulator-server"); +// PE `.exe` on Windows, extensionless ELF/Mach-O elsewhere. Mirrors +// simulatorServerBinaryName() in @argent/native-devtools-ios; inlined because +// this file ships verbatim as the npm `bin` entry and can't import. +const binaryName = process.platform === "win32" ? "simulator-server.exe" : "simulator-server"; + +const binary = path.join(__dirname, platformKey, binaryName); if (!fs.existsSync(binary)) { console.error( `argent-simulator-server: no binary for platform "${platformKey}" at ${binary}.\n` + - `Supported hosts today: darwin, linux (x86_64 and arm64).` + `Supported hosts today: darwin, linux (x86_64 and arm64), win32.` ); process.exit(1); } diff --git a/packages/argent/scripts/bundle-tools.cjs b/packages/argent/scripts/bundle-tools.cjs index b281c683f..493f557fa 100644 --- a/packages/argent/scripts/bundle-tools.cjs +++ b/packages/argent/scripts/bundle-tools.cjs @@ -93,8 +93,13 @@ const BIN_DIR = path.resolve(__dirname, "../bin"); const AX_BIN_DEST = path.resolve(BIN_DIR, "darwin/ax-service"); const AX_TCP_BIN_DEST = path.resolve(BIN_DIR, "darwin/tcp/ax-service"); // Host platform keys (see hostPlatformKey() in @argent/native-devtools-ios): -// darwin is a universal binary; Linux ships one single-arch ELF per key. -const SUPPORTED_HOST_PLATFORMS = ["darwin", "linux", "linux-arm64"]; +// darwin is a universal binary; Linux ships one single-arch ELF per key; +// win32 ships a PE `.exe` (named simulator-server.exe, not simulator-server). +const SUPPORTED_HOST_PLATFORMS = ["darwin", "linux", "linux-arm64", "win32"]; +// The simulator-server file name per host key — extensionless everywhere +// except Windows, where it's a `.exe`. +const simulatorServerFileName = (platform) => + platform === "win32" ? "simulator-server.exe" : "simulator-server"; // Generated module pinning the Perfetto version that stamps the bundled // trace_processor.wasm. esbuild inlines it into every bundle via the // @argent/native-devtools-android alias. @@ -538,9 +543,10 @@ buildBundle({ // (the publish pipeline) — a Linux contributor running `npm run pack` locally // can't produce the macOS binary, so don't block them on its absence. for (const platform of SUPPORTED_HOST_PLATFORMS) { - const src = path.join(BIN_SRC_ROOT, platform, "simulator-server"); + const binaryFile = simulatorServerFileName(platform); + const src = path.join(BIN_SRC_ROOT, platform, binaryFile); const destDir = path.join(BIN_DIR, platform); - const dest = path.join(destDir, "simulator-server"); + const dest = path.join(destDir, binaryFile); if (fs.existsSync(src)) { fs.mkdirSync(destDir, { recursive: true }); fs.copyFileSync(src, dest); diff --git a/packages/native-devtools-ios/src/index.ts b/packages/native-devtools-ios/src/index.ts index 4a5eee6ed..d0db19485 100644 --- a/packages/native-devtools-ios/src/index.ts +++ b/packages/native-devtools-ios/src/index.ts @@ -69,6 +69,13 @@ export function hostPlatformKey(): string { } return process.platform; } +// The binary is an extensionless ELF/Mach-O on POSIX hosts but a `.exe` (PE) +// on Windows — matching the per-platform artifact the simulator-server release +// publishes (simulator-server-argent-windows.exe). Centralised so the path +// resolver, the dispatcher shim, and bundle-tools agree on the on-disk name. +export function simulatorServerBinaryName(): string { + return process.platform === "win32" ? "simulator-server.exe" : "simulator-server"; +} function platformBinDir(): string { return path.join(BIN_DIR, hostPlatformKey()); } @@ -78,17 +85,18 @@ function platformTcpBinDir(): string { } export function simulatorServerBinaryPath(): string { - const p = path.join(platformBinDir(), "simulator-server"); + const binaryName = simulatorServerBinaryName(); + const p = path.join(platformBinDir(), binaryName); if (!fs.existsSync(p)) { // Help callers who set ARGENT_SIMULATOR_SERVER_DIR to a flat dir (the old // pre-Linux-support layout where simulator-server lived at the root). - const flat = path.join(BIN_DIR, "simulator-server"); + const flat = path.join(BIN_DIR, binaryName); const migrationHint = fs.existsSync(flat) ? ` Found a binary at the old flat path ${flat}; move it to ${p} or update ARGENT_SIMULATOR_SERVER_DIR to point at the parent of the platform subdirectory.` : ""; throw new Error( `simulator-server binary not found for platform "${hostPlatformKey()}" at ${p}. ` + - `Supported hosts today: darwin, linux (x86_64 and arm64).${migrationHint}` + `Supported hosts today: darwin, linux (x86_64 and arm64), win32.${migrationHint}` ); } return p; diff --git a/packages/native-devtools-ios/test/index.test.ts b/packages/native-devtools-ios/test/index.test.ts index d8c8424fd..fce0806d3 100644 --- a/packages/native-devtools-ios/test/index.test.ts +++ b/packages/native-devtools-ios/test/index.test.ts @@ -207,6 +207,45 @@ describe("host platform key (arch-aware Linux bin dirs)", () => { }); }); +describe("Windows (win32) binary resolution", () => { + // The Windows release ships a PE `.exe` (simulator-server.exe), not the + // extensionless binary other hosts use. The resolver must pick the `.exe` + // name AND key the directory by "win32" (process.platform), so a Windows + // host finds bin/win32/simulator-server.exe. iOS is macOS-only, so this + // binary serves Android + Chromium hosts. + it("resolves bin/win32/simulator-server.exe on Windows", async () => { + setPlatform("win32"); + setArch("x64"); + const dir = fs.mkdtempSync(path.join(tmpRoot, "win32-")); + const platDir = path.join(dir, "win32"); + fs.mkdirSync(platDir, { recursive: true }); + const binPath = path.join(platDir, "simulator-server.exe"); + fs.writeFileSync(binPath, "", { mode: 0o755 }); + process.env.ARGENT_SIMULATOR_SERVER_DIR = dir; + const r = await loadResolver(); + expect(r.hostPlatformKey()).toBe("win32"); + expect(r.simulatorServerBinaryName()).toBe("simulator-server.exe"); + expect(r.simulatorServerBinaryPath()).toBe(binPath); + expect(r.simulatorServerBinaryDir()).toBe(platDir); + }); + + it("does not resolve an extensionless binary on Windows", async () => { + // Guards against a half-migrated layout: an extensionless + // bin/win32/simulator-server must NOT satisfy the resolver, since Windows + // can't exec it — the error should point at the `.exe` it actually needs. + setPlatform("win32"); + setArch("x64"); + const dir = fs.mkdtempSync(path.join(tmpRoot, "win32-noext-")); + const platDir = path.join(dir, "win32"); + fs.mkdirSync(platDir, { recursive: true }); + fs.writeFileSync(path.join(platDir, "simulator-server"), "", { mode: 0o755 }); + process.env.ARGENT_SIMULATOR_SERVER_DIR = dir; + const r = await loadResolver(); + expect(() => r.simulatorServerBinaryPath()).toThrow(/simulator-server\.exe/); + expect(() => r.simulatorServerBinaryPath()).toThrow(/win32/); + }); +}); + describe("ax-service path resolution", () => { it("joins process.platform into the ax-service bin path", async () => { if (originalPlatform !== "darwin") return; diff --git a/packages/tool-server/src/tools/simulator/stop-metro.ts b/packages/tool-server/src/tools/simulator/stop-metro.ts index 28526f1fe..0b21c7b41 100644 --- a/packages/tool-server/src/tools/simulator/stop-metro.ts +++ b/packages/tool-server/src/tools/simulator/stop-metro.ts @@ -2,6 +2,51 @@ import { z } from "zod"; import { execFileSync } from "node:child_process"; import type { ToolDefinition } from "@argent/registry"; +/** + * Resolve the PIDs of processes *listening* on a TCP port, cross-platform. + * Only the listener (Metro itself) is returned, never processes holding an + * ESTABLISHED connection to the port — otherwise the Argent tool-server's own + * CDP client socket to Metro would be matched and killed alongside it. + * + * - POSIX: `lsof -ti tcp: -sTCP:LISTEN` — one PID per line, exits + * non-zero when the port is free. + * - Windows: `netstat -ano`, then filter TCP rows in the LISTENING state whose + * local address ends in `:` and read the trailing PID column. `lsof` + * doesn't exist on Windows, so the prior implementation threw ENOENT there. + * + * Both run without a shell, so `port` (already an int by the time it reaches + * here) can never be interpreted as a shell token. + */ +function listeningPids(port: number): number[] { + if (process.platform === "win32") { + const output = execFileSync("netstat", ["-ano"], { + encoding: "utf-8", + timeout: 5_000, + }); + const pids = new Set(); + for (const line of output.split(/\r?\n/)) { + const cols = line.trim().split(/\s+/); + // cols: [proto, localAddr, foreignAddr, state, pid] + if (cols.length < 5 || cols[0].toUpperCase() !== "TCP") continue; + if (cols[3].toUpperCase() !== "LISTENING") continue; + // The colon guards against `:18081` matching port 8081. + if (!cols[1].endsWith(`:${port}`)) continue; + const pid = parseInt(cols[4], 10); + if (!Number.isNaN(pid) && pid > 0) pids.add(pid); + } + return [...pids]; + } + const output = execFileSync("lsof", ["-ti", `tcp:${port}`, "-sTCP:LISTEN"], { + encoding: "utf-8", + timeout: 5_000, + }).trim(); + if (!output) return []; + return output + .split("\n") + .map((s) => parseInt(s.trim(), 10)) + .filter((n) => !Number.isNaN(n)); +} + const zodSchema = z.object({ port: z .number() @@ -23,28 +68,7 @@ export const stopMetroTool: ToolDefinition< async execute(_services, params) { const port = (params as { port: number }).port; try { - // execFileSync (no shell) so `port` can never be shell-interpreted, - // even if a caller bypasses the zod schema. `port` is also validated to - // an int by registry.invokeTool before reaching here. - // - // `-sTCP:LISTEN` restricts the match to the process *listening* on the - // port (Metro itself). Without it, `lsof -ti tcp:` also returns - // every process holding an ESTABLISHED connection to that port — which - // includes the Argent tool-server's own CDP client socket to Metro. We - // would then SIGTERM the tool-server along with Metro. - const output = execFileSync("lsof", ["-ti", `tcp:${port}`, "-sTCP:LISTEN"], { - encoding: "utf-8", - timeout: 5_000, - }).trim(); - - if (!output) { - return { stopped: false, port, pids: [] }; - } - - const pids = output - .split("\n") - .map((s) => parseInt(s.trim(), 10)) - .filter((n) => !isNaN(n)); + const pids = listeningPids(port); if (pids.length === 0) { return { stopped: false, port, pids: [] }; @@ -52,6 +76,9 @@ export const stopMetroTool: ToolDefinition< for (const pid of pids) { try { + // On Windows the signal is ignored and the process is terminated + // outright (Node maps any kill to TerminateProcess); on POSIX + // SIGTERM lets Metro shut down its watchers cleanly. process.kill(pid, "SIGTERM"); } catch { // Process may have already exited @@ -60,7 +87,8 @@ export const stopMetroTool: ToolDefinition< return { stopped: true, port, pids }; } catch { - // lsof exits non-zero when no process is found on the port + // lsof / netstat exits non-zero (or finds nothing) when no process is + // listening on the port. return { stopped: false, port, pids: [] }; } }, diff --git a/packages/tool-server/src/utils/android-binary.ts b/packages/tool-server/src/utils/android-binary.ts index 647089819..cdfc92d70 100644 --- a/packages/tool-server/src/utils/android-binary.ts +++ b/packages/tool-server/src/utils/android-binary.ts @@ -1,11 +1,8 @@ -import { execFile } from "node:child_process"; import { access } from "node:fs/promises"; import { constants as fsConstants } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import { promisify } from "node:util"; - -const execFileAsync = promisify(execFile); +import { commandOnPath } from "./command-on-path"; export type AndroidBinaryName = "adb" | "emulator"; @@ -19,6 +16,16 @@ const SUBDIR: Record = { emulator: "emulator", }; +// On Windows the SDK ships `adb.exe` / `emulator.exe`. PATH lookups via `where` +// already resolve the extension, but the $ANDROID_HOME fallbacks below build a +// literal path and `access()` it, so they must include `.exe` there. Empty on +// POSIX hosts where the binaries are extensionless. +const BIN_EXT = process.platform === "win32" ? ".exe" : ""; + +function binFileName(name: AndroidBinaryName): string { + return `${name}${BIN_EXT}`; +} + interface CacheEntry { path: string | null; checkedAt: number; @@ -67,22 +74,14 @@ export async function resolveAndroidBinary(name: AndroidBinaryName): Promise { // PATH first — preserves prior behavior for users who already have the - // binary on PATH (e.g. Homebrew adb at /opt/homebrew/bin/adb), and means - // a sysadmin override on PATH still wins over $ANDROID_HOME. - try { - const { stdout } = await execFileAsync("/bin/sh", ["-c", `command -v ${name}`], { - timeout: 2_000, - }); - const trimmed = stdout.trim(); - // `command -v` prints nothing on miss but returns non-zero, so we only - // get here on success — but defend against an empty stdout anyway in - // case a future shell quirk decouples the two. - if (trimmed) return trimmed; - } catch { - // fall through to SDK-root fallbacks - } + // binary on PATH (e.g. Homebrew adb at /opt/homebrew/bin/adb, or an + // `adb.exe` on a Windows user's PATH), and means a sysadmin override on PATH + // still wins over $ANDROID_HOME. `commandOnPath` uses `where` on Windows so + // the `.exe` extension is resolved automatically. + const onPath = await commandOnPath(name); + if (onPath) return onPath; for (const root of androidRoots()) { - const candidate = join(root, SUBDIR[name], name); + const candidate = join(root, SUBDIR[name], binFileName(name)); try { // X_OK rather than F_OK: a non-executable file at the canonical path // means a corrupted/partial install, and falling back to the next root @@ -126,8 +125,8 @@ function androidRoots(): string[] { function defaultAndroidRoots(): string[] { const home = homedir(); const roots = [ - // Android Studio defaults (the two big ones — covers the majority of - // user installs that arrive without any env-var setup). + // Android Studio defaults (the big ones — cover the majority of user + // installs that arrive without any env-var setup). join(home, "Library", "Android", "sdk"), // macOS Android Studio default join(home, "Android", "Sdk"), // Linux Android Studio default // Common manual install convention. Not picked by any installer but used @@ -138,6 +137,15 @@ function defaultAndroidRoots(): string[] { "/usr/lib/android-sdk", // Debian/Ubuntu `android-sdk` apt package "/usr/local/share/android-sdk", // Homebrew cask ]; + // Windows Android Studio default: %LOCALAPPDATA%\Android\Sdk. LOCALAPPDATA + // is set on every interactive Windows session, but fall back to the + // canonical AppData\Local layout under the home dir so a GUI-spawned MCP + // server that didn't inherit it still resolves the SDK. + if (process.platform === "win32") { + const localAppData = process.env.LOCALAPPDATA?.trim(); + if (localAppData) roots.push(join(localAppData, "Android", "Sdk")); + roots.push(join(home, "AppData", "Local", "Android", "Sdk")); + } return roots; } diff --git a/packages/tool-server/src/utils/check-deps.ts b/packages/tool-server/src/utils/check-deps.ts index 7c3906e7b..27955ea61 100644 --- a/packages/tool-server/src/utils/check-deps.ts +++ b/packages/tool-server/src/utils/check-deps.ts @@ -1,10 +1,7 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; import { FAILURE_CODES, withFailureSignal, type ToolDependency } from "@argent/registry"; import { resolveAndroidBinary } from "./android-binary"; import { resolveVegaBinary } from "./vega-cli"; - -const execFileAsync = promisify(execFile); +import { commandOnPath } from "./command-on-path"; /** * Thrown when a tool declares a host-binary dependency (e.g. `adb`, `xcrun`) @@ -62,16 +59,11 @@ async function probe(dep: ToolDependency): Promise { if (dep === "vega") { return (await resolveVegaBinary()) !== null; } - try { - // `command -v` via `/bin/sh` is POSIX-portable and doesn't invoke the dep - // itself — a bare `xcrun` call would fork the tool just to check existence, - // which is both slower and (for xcrun) can prompt the license agreement - // dialog on first use. - await execFileAsync("/bin/sh", ["-c", `command -v ${dep}`], { timeout: 2_000 }); - return true; - } catch { - return false; - } + // `commandOnPath` probes existence without invoking the dep itself — a bare + // `xcrun` call would fork the tool just to check existence, which is both + // slower and (for xcrun) can prompt the license agreement dialog on first + // use. It uses `command -v` on POSIX and `where` on Windows. + return (await commandOnPath(dep)) !== null; } async function isAvailable(dep: ToolDependency): Promise { diff --git a/packages/tool-server/src/utils/command-on-path.ts b/packages/tool-server/src/utils/command-on-path.ts new file mode 100644 index 000000000..05da65014 --- /dev/null +++ b/packages/tool-server/src/utils/command-on-path.ts @@ -0,0 +1,44 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +/** + * Resolve a command name to its absolute path using the OS' own PATH-lookup + * tool, *without* executing the command itself (a bare `xcrun`/`adb` call would + * fork the tool just to probe existence — slower, and `xcrun` can pop the Xcode + * license dialog on first use). Returns the first match, or `null` if the name + * isn't on PATH. + * + * - POSIX: `command -v ` via `/bin/sh`. Portable across shells and also + * resolves builtins/aliases; prints the path on stdout and exits non-zero on + * a miss. + * - Windows: `where ` (ships in System32, always on PATH). It prints one + * line per match (e.g. both `adb.exe` and an `adb.bat` shim) and exits + * non-zero when nothing matches; we take the first line. `where` also handles + * the executable-extension search (`adb` → `adb.exe`) that `command -v` does + * not, which is exactly what callers want here. + * + * Centralised so the three resolvers that need it (android-binary, check-deps, + * vega-cli) share one cross-platform implementation instead of each hardcoding + * the POSIX `/bin/sh` form (which silently never matches on Windows). + */ +export async function commandOnPath(name: string): Promise { + try { + if (process.platform === "win32") { + const { stdout } = await execFileAsync("where", [name], { timeout: 2_000 }); + const first = stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + return first ?? null; + } + const { stdout } = await execFileAsync("/bin/sh", ["-c", `command -v ${name}`], { + timeout: 2_000, + }); + const trimmed = stdout.trim(); + return trimmed || null; + } catch { + return null; + } +} diff --git a/packages/tool-server/src/utils/vega-cli.ts b/packages/tool-server/src/utils/vega-cli.ts index 0e4924e1d..c93086501 100644 --- a/packages/tool-server/src/utils/vega-cli.ts +++ b/packages/tool-server/src/utils/vega-cli.ts @@ -12,6 +12,7 @@ import { } from "@argent/registry"; import { formatSubprocessFailure } from "./subprocess-error"; import { listRunningVvdConsolePorts } from "./vega-process"; +import { commandOnPath } from "./command-on-path"; const execFileAsync = promisify(execFile); @@ -45,18 +46,6 @@ async function isExecutable(p: string): Promise { } } -async function commandOnPath(name: string): Promise { - try { - const { stdout } = await execFileAsync("/bin/sh", ["-c", `command -v ${name}`], { - timeout: 2_000, - }); - const path = stdout.trim(); - return path || null; - } catch { - return null; - } -} - export async function resolveVegaBinary(): Promise { const now = Date.now(); if (cachedVegaBinary && now - cachedVegaBinary.checkedAt < VEGA_BINARY_TTL_MS) { diff --git a/packages/tool-server/test/android-binary-windows.test.ts b/packages/tool-server/test/android-binary-windows.test.ts new file mode 100644 index 000000000..02ed1fe48 --- /dev/null +++ b/packages/tool-server/test/android-binary-windows.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdir, mkdtemp, rm, writeFile, chmod } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +// Force the PATH probe to miss so resolution falls through to the SDK-root +// scan — that's the branch where the Windows `.exe` suffix and the +// %LOCALAPPDATA%\Android\Sdk default root matter. +vi.mock("../src/utils/command-on-path", () => ({ + commandOnPath: vi.fn(async () => null), +})); + +const ENV_KEYS = ["PATH", "ANDROID_HOME", "ANDROID_SDK_ROOT", "HOME", "LOCALAPPDATA"] as const; +const originalEnv: Record = {}; +const originalPlatform = process.platform; + +function setPlatform(value: NodeJS.Platform) { + Object.defineProperty(process, "platform", { value, configurable: true }); +} + +/** + * `android-binary.ts` captures the executable extension (`.exe` on win32) at + * module load, so the platform must be set BEFORE importing it. Re-import via + * vi.resetModules() so each test sees the extension for its chosen platform. + */ +async function loadResolverAsWin32(): Promise { + setPlatform("win32"); + vi.resetModules(); + return await import("../src/utils/android-binary"); +} + +describe("resolveAndroidBinary on Windows", () => { + let tmpRoot: string; + + beforeEach(async () => { + for (const k of ENV_KEYS) originalEnv[k] = process.env[k]; + // Clear env-var roots so resolution can only succeed via the OS default + // (%LOCALAPPDATA%) we set per-test — otherwise a stray ANDROID_HOME would + // mask what we're asserting. + delete process.env.ANDROID_HOME; + delete process.env.ANDROID_SDK_ROOT; + tmpRoot = await mkdtemp(join(tmpdir(), "argent-android-win-")); + }); + + afterEach(async () => { + for (const k of ENV_KEYS) { + if (originalEnv[k] === undefined) delete process.env[k]; + else process.env[k] = originalEnv[k]; + } + setPlatform(originalPlatform); + vi.resetModules(); + await rm(tmpRoot, { recursive: true, force: true }); + }); + + it("appends .exe and finds adb under %LOCALAPPDATA%\\Android\\Sdk", async () => { + // Android Studio's default Windows SDK location. The resolver builds a + // literal path and access()es it, so it must include the `.exe` extension. + const sdk = join(tmpRoot, "Android", "Sdk"); + const adbDir = join(sdk, "platform-tools"); + await mkdir(adbDir, { recursive: true }); + const adbExe = join(adbDir, "adb.exe"); + await writeFile(adbExe, "", { mode: 0o755 }); + await chmod(adbExe, 0o755); + process.env.LOCALAPPDATA = tmpRoot; + + const { resolveAndroidBinary, __resetAndroidBinaryCacheForTesting } = + await loadResolverAsWin32(); + __resetAndroidBinaryCacheForTesting(); + + expect(await resolveAndroidBinary("adb")).toBe(adbExe); + }); + + it("appends .exe for the emulator binary too", async () => { + const sdk = join(tmpRoot, "Android", "Sdk"); + const emuDir = join(sdk, "emulator"); + await mkdir(emuDir, { recursive: true }); + const emuExe = join(emuDir, "emulator.exe"); + await writeFile(emuExe, "", { mode: 0o755 }); + await chmod(emuExe, 0o755); + process.env.LOCALAPPDATA = tmpRoot; + + const { resolveAndroidBinary, __resetAndroidBinaryCacheForTesting } = + await loadResolverAsWin32(); + __resetAndroidBinaryCacheForTesting(); + + expect(await resolveAndroidBinary("emulator")).toBe(emuExe); + }); + + it("returns null when only the extensionless binary exists (Windows can't exec it)", async () => { + const adbDir = join(tmpRoot, "Android", "Sdk", "platform-tools"); + await mkdir(adbDir, { recursive: true }); + // Extensionless — must NOT satisfy the win32 resolver. + await writeFile(join(adbDir, "adb"), "", { mode: 0o755 }); + process.env.LOCALAPPDATA = tmpRoot; + + const { resolveAndroidBinary, __resetAndroidBinaryCacheForTesting } = + await loadResolverAsWin32(); + __resetAndroidBinaryCacheForTesting(); + + expect(await resolveAndroidBinary("adb")).toBeNull(); + }); +}); diff --git a/packages/tool-server/test/check-deps.test.ts b/packages/tool-server/test/check-deps.test.ts index b3db50d0a..a265494ea 100644 --- a/packages/tool-server/test/check-deps.test.ts +++ b/packages/tool-server/test/check-deps.test.ts @@ -1,10 +1,13 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; -const execFileMock = vi.fn(); -vi.mock("node:child_process", async () => { - const actual = await vi.importActual("node:child_process"); - return { ...actual, execFile: (...args: unknown[]) => execFileMock(...args) }; -}); +// `probe()` resolves PATH deps (xcrun, etc.) through `commandOnPath`, which +// abstracts the `command -v` (POSIX) / `where` (Windows) difference. Mock it so +// these tests stay platform-agnostic instead of asserting a `/bin/sh` shape +// that wouldn't run on a Windows host. +const commandOnPathMock = vi.fn(); +vi.mock("../src/utils/command-on-path", () => ({ + commandOnPath: (name: string) => commandOnPathMock(name), +})); // `probe()` now special-cases adb / emulator to use `resolveAndroidBinary` // (which adds an `$ANDROID_HOME` fallback on top of PATH). Mock the resolver @@ -25,28 +28,15 @@ import { } from "../src/utils/check-deps"; /** - * The real `command -v` uses execFile's error-on-nonzero-exit contract. We - * mimic that: when the shell command would succeed, invoke the node-style - * callback with `(null, stdout, stderr)`; when it would fail, pass an - * Error. This matches how `promisify(execFile)` sees the result. + * `commandOnPath` returns the resolved absolute path on a hit, or `null` on a + * miss. Both the PATH probe (xcrun) and the Android resolver follow that + * contract, so model them the same way: `null` for a dep the test wants + * treated as missing, an absolute path otherwise. */ function stubProbe(missing: readonly string[]): void { - // PATH probe (used for xcrun and any non-Android dep): mock /bin/sh `command -v ` - execFileMock.mockImplementation( - ( - _cmd: string, - args: string[], - _opts: unknown, - cb: (err: Error | null, stdout?: string, stderr?: string) => void - ) => { - const script = args[1] ?? ""; - const dep = script.replace("command -v ", "").trim(); - if (missing.includes(dep)) cb(new Error(`not found: ${dep}`)); - else cb(null, `/usr/bin/${dep}\n`, ""); - } + commandOnPathMock.mockImplementation(async (name: string) => + missing.includes(name) ? null : `/usr/bin/${name}` ); - // Android resolver path (used for adb / emulator): return null when the - // caller wants the dep treated as missing, otherwise an absolute path. resolveAndroidBinaryMock.mockImplementation(async (name: string) => { return missing.includes(name) ? null : `/usr/bin/${name}`; }); @@ -55,7 +45,7 @@ function stubProbe(missing: readonly string[]): void { describe("check-deps", () => { beforeEach(() => { __resetDepCacheForTests(); - execFileMock.mockReset(); + commandOnPathMock.mockReset(); resolveAndroidBinaryMock.mockReset(); }); @@ -91,13 +81,13 @@ describe("check-deps", () => { await ensureDeps(["xcrun"]); await ensureDeps(["xcrun"]); await ensureDeps(["xcrun"]); - expect(execFileMock).toHaveBeenCalledTimes(1); + expect(commandOnPathMock).toHaveBeenCalledTimes(1); }); it("is a no-op when the deps array is empty", async () => { stubProbe([]); await ensureDeps([]); - expect(execFileMock).not.toHaveBeenCalled(); + expect(commandOnPathMock).not.toHaveBeenCalled(); }); it("ensureDep is the single-dep form of ensureDeps", async () => { diff --git a/packages/tool-server/test/command-on-path.test.ts b/packages/tool-server/test/command-on-path.test.ts new file mode 100644 index 000000000..62dbb327a --- /dev/null +++ b/packages/tool-server/test/command-on-path.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Mock execFile with the repo's standard idiom: the callback receives a single +// `{ stdout, stderr }` value (so `promisify(execFile)` resolves to that object), +// or an Error to model a non-zero exit (command not found). +const execFileMock = vi.fn(); +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { + ...actual, + execFile: ( + cmd: string, + args: readonly string[], + opts: unknown, + cb?: (err: Error | null, out: { stdout: string; stderr: string }) => void + ) => { + const callback = typeof opts === "function" ? opts : cb!; + const result = execFileMock(cmd, args); + if (result instanceof Error) callback(result, { stdout: "", stderr: "" }); + else callback(null, result ?? { stdout: "", stderr: "" }); + }, + }; +}); + +import { commandOnPath } from "../src/utils/command-on-path"; + +const realPlatform = process.platform; +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { value: platform, configurable: true }); +} + +describe("commandOnPath", () => { + beforeEach(() => execFileMock.mockReset()); + afterEach(() => setPlatform(realPlatform)); + + it("uses `command -v` via /bin/sh on POSIX and returns the trimmed path", async () => { + setPlatform("darwin"); + execFileMock.mockReturnValue({ stdout: "/usr/bin/adb\n", stderr: "" }); + const result = await commandOnPath("adb"); + expect(result).toBe("/usr/bin/adb"); + expect(execFileMock).toHaveBeenCalledWith("/bin/sh", ["-c", "command -v adb"]); + }); + + it("uses `where` on Windows and returns the first matching line", async () => { + setPlatform("win32"); + // `where` prints one path per match, CRLF-terminated; the first wins. + execFileMock.mockReturnValue({ + stdout: "C:\\Android\\platform-tools\\adb.exe\r\nC:\\other\\adb.bat\r\n", + stderr: "", + }); + const result = await commandOnPath("adb"); + expect(result).toBe("C:\\Android\\platform-tools\\adb.exe"); + expect(execFileMock).toHaveBeenCalledWith("where", ["adb"]); + }); + + it("returns null when the command is not on PATH (non-zero exit)", async () => { + setPlatform("darwin"); + execFileMock.mockReturnValue(new Error("not found")); + expect(await commandOnPath("nope")).toBeNull(); + }); + + it("returns null on Windows when `where` finds nothing", async () => { + setPlatform("win32"); + execFileMock.mockReturnValue(new Error("INFO: Could not find files")); + expect(await commandOnPath("nope")).toBeNull(); + }); + + it("returns null when the resolver emits only blank lines", async () => { + setPlatform("win32"); + execFileMock.mockReturnValue({ stdout: "\r\n \r\n", stderr: "" }); + expect(await commandOnPath("adb")).toBeNull(); + }); +}); diff --git a/packages/tool-server/test/http-dep-gate.test.ts b/packages/tool-server/test/http-dep-gate.test.ts index a3eedfba0..168150fde 100644 --- a/packages/tool-server/test/http-dep-gate.test.ts +++ b/packages/tool-server/test/http-dep-gate.test.ts @@ -3,11 +3,12 @@ import request from "supertest"; import { Registry } from "@argent/registry"; import { z } from "zod"; -const execFileMock = vi.fn(); -vi.mock("node:child_process", async () => { - const actual = await vi.importActual("node:child_process"); - return { ...actual, execFile: (...args: unknown[]) => execFileMock(...args) }; -}); +// PATH deps (xcrun) resolve through `commandOnPath`; mock it so the gate test +// is platform-agnostic rather than asserting a `/bin/sh` call shape. +const commandOnPathMock = vi.fn(); +vi.mock("../src/utils/command-on-path", () => ({ + commandOnPath: (name: string) => commandOnPathMock(name), +})); // `probe()` special-cases adb/emulator to use `resolveAndroidBinary` // (which adds an `$ANDROID_HOME` fallback to PATH). Mock the resolver so @@ -27,18 +28,8 @@ import { } from "../src/utils/check-deps"; function stubProbe(missing: readonly string[]): void { - execFileMock.mockImplementation( - ( - _cmd: string, - args: string[], - _opts: unknown, - cb: (err: Error | null, stdout?: string, stderr?: string) => void - ) => { - const script = args[1] ?? ""; - const dep = script.replace("command -v ", "").trim(); - if (missing.includes(dep)) cb(new Error(`not found: ${dep}`)); - else cb(null, `/usr/bin/${dep}\n`, ""); - } + commandOnPathMock.mockImplementation(async (name: string) => + missing.includes(name) ? null : `/usr/bin/${name}` ); resolveAndroidBinaryMock.mockImplementation(async (name: string) => { return missing.includes(name) ? null : `/usr/bin/${name}`; @@ -48,7 +39,7 @@ function stubProbe(missing: readonly string[]): void { describe("http dependency gate", () => { beforeEach(() => { __resetDepCacheForTests(); - execFileMock.mockReset(); + commandOnPathMock.mockReset(); resolveAndroidBinaryMock.mockReset(); }); @@ -185,7 +176,7 @@ describe("http dependency gate", () => { const { app } = createHttpApp(registry); const res = await request(app).post("/tools/no-deps").send({}); expect(res.status).toBe(200); - expect(execFileMock).not.toHaveBeenCalled(); + expect(commandOnPathMock).not.toHaveBeenCalled(); }); it("still returns 424 when the DependencyMissingError is buried two levels deep in the cause chain", async () => { diff --git a/scripts/download-simulator-server.sh b/scripts/download-simulator-server.sh index 5a27fafb7..2726727e0 100755 --- a/scripts/download-simulator-server.sh +++ b/scripts/download-simulator-server.sh @@ -19,13 +19,16 @@ TAG="${1:-radon-main}" DEST_DIR="packages/native-devtools-ios/bin" # release-asset-name → host platform key. Asset names follow the upstream -# build matrix (`simulator-server-argent-{macos,linux,linux-arm64}`); the keys -# mirror hostPlatformKey() in @argent/native-devtools-ios (process.platform, -# except "linux-arm64" on arm64 Linux) so the resolver can lookup by host. +# build matrix (`simulator-server-argent-{macos,linux,linux-arm64,windows.exe}`); +# the keys mirror hostPlatformKey() in @argent/native-devtools-ios +# (process.platform, except "linux-arm64" on arm64 Linux) so the resolver can +# look up by host. The Windows asset keeps its `.exe` extension end-to-end — +# the release asset, the local copy, and what the resolver/dispatcher spawn. declare -a TARGETS=( "simulator-server-argent-macos:darwin" "simulator-server-argent-linux:linux" "simulator-server-argent-linux-arm64:linux-arm64" + "simulator-server-argent-windows.exe:win32" ) mkdir -p "${DEST_DIR}" @@ -34,6 +37,14 @@ for entry in "${TARGETS[@]}"; do ASSET_NAME="${entry%%:*}" PLATFORM="${entry##*:}" PLATFORM_DIR="${DEST_DIR}/${PLATFORM}" + # Windows ships a PE `.exe`; every other host an extensionless binary. The + # resolver (simulatorServerBinaryName()) and the dispatcher pick the same + # name by host, so the on-disk copy must match. + if [[ "${PLATFORM}" == "win32" ]]; then + BIN_BASENAME="simulator-server.exe" + else + BIN_BASENAME="simulator-server" + fi # Purge then recreate the platform dir before each download so a previous # run's stale binary can't ship if THIS run's download fails. Without this, @@ -43,7 +54,7 @@ for entry in "${TARGETS[@]}"; do rm -rf "${PLATFORM_DIR}" mkdir -p "${PLATFORM_DIR}" - echo "Downloading ${ASSET_NAME} → ${PLATFORM_DIR}/simulator-server" + echo "Downloading ${ASSET_NAME} → ${PLATFORM_DIR}/${BIN_BASENAME}" # Tolerate missing assets so the script keeps working for macOS-only # consumers when the Linux artifact lags behind a release. Capture gh's # stderr and print it on failure so "not authenticated" vs "asset missing" @@ -66,8 +77,8 @@ for entry in "${TARGETS[@]}"; do fi rm -f "${GH_STDERR}" - mv "${PLATFORM_DIR}/${ASSET_NAME}" "${PLATFORM_DIR}/simulator-server" - chmod +x "${PLATFORM_DIR}/simulator-server" + mv "${PLATFORM_DIR}/${ASSET_NAME}" "${PLATFORM_DIR}/${BIN_BASENAME}" + chmod +x "${PLATFORM_DIR}/${BIN_BASENAME}" # Architecture sanity check: a wrong-arch binary in a platform dir is worse # than a missing one — the resolver would happily pick it and the user gets @@ -75,11 +86,12 @@ for entry in "${TARGETS[@]}"; do # (unlike the missing-asset case above) because a present-but-mislabeled # asset is an upstream packaging bug that must not ship. if command -v file >/dev/null 2>&1; then - DESC="$(file -b "${PLATFORM_DIR}/simulator-server")" + DESC="$(file -b "${PLATFORM_DIR}/${BIN_BASENAME}")" case "${PLATFORM}" in darwin) EXPECT="Mach-O universal" ;; linux) EXPECT="ELF 64-bit.*x86-64" ;; linux-arm64) EXPECT="ELF 64-bit.*aarch64" ;; + win32) EXPECT="PE32\+.*x86-64|PE32\+ executable" ;; *) EXPECT="" ;; esac if [[ -n "${EXPECT}" ]] && ! [[ "${DESC}" =~ ${EXPECT} ]]; then @@ -94,7 +106,7 @@ done echo "" echo "Downloaded simulator-server binaries:" -find "${DEST_DIR}" -name simulator-server -type f -exec ls -la {} \; +find "${DEST_DIR}" \( -name simulator-server -o -name 'simulator-server.exe' \) -type f -exec ls -la {} \; # Physical-Android-device support: the simulator-server `android_device` # controller pushes the screen-sharing agent (a host-independent .jar + a @@ -126,7 +138,7 @@ if gh release download "${TAG}" \ mkdir -p "${res_dir}" tar -xzf "${AGENT_TMP}/${AGENT_ASSET}" -C "${res_dir}" echo " ✓ screen-sharing agent → ${res_dir}" - done < <(find "${DEST_DIR}" -name simulator-server -type f) + done < <(find "${DEST_DIR}" \( -name simulator-server -o -name 'simulator-server.exe' \) -type f) else GH_MSG=$(<"${GH_STDERR}") rm -f "${GH_STDERR}" From 09f91183ba5863971006dd52d9f077240fed8703 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Fri, 26 Jun 2026 17:19:32 +0200 Subject: [PATCH 02/10] ci(windows): add Windows E2E (Chromium control plane + simulator-server.exe) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two jobs on windows-latest, the substrate that makes 'Argent on Windows' verifiable without local virtualization: - windows-chromium: builds the workspace, runs the cross-platform resolver unit tests natively on Windows, confirms adb.exe resolution, then drives a real headless Chrome over CDP through the tool-server (discover → screenshot → describe → tap → observe the DOM mutation the tap causes). The flow is in scripts/ci/windows-chromium-e2e.mjs (also runnable locally on macOS/Linux) against scripts/ci/e2e-chromium-page.html. - windows-simulator-server: builds the argent simulator-server.exe from radon source (cargo build --features argent,swdec,swenc — the same set the radon release ships), installs it into bin/win32, confirms the resolver finds it, and smoke-runs it to prove the PE executes natively. Android-emulator E2E needs WHPX/HAXM (unavailable on hosted Windows runners), so it's out of scope here; the Android host tooling (adb.exe + the .exe that talks to it) is covered. --- .github/workflows/windows-e2e.yml | 236 ++++++++++++++++++++++++++++ scripts/ci/e2e-chromium-page.html | 39 +++++ scripts/ci/windows-chromium-e2e.mjs | 139 ++++++++++++++++ 3 files changed, 414 insertions(+) create mode 100644 .github/workflows/windows-e2e.yml create mode 100644 scripts/ci/e2e-chromium-page.html create mode 100644 scripts/ci/windows-chromium-e2e.mjs diff --git a/.github/workflows/windows-e2e.yml b/.github/workflows/windows-e2e.yml new file mode 100644 index 000000000..6c50b09b5 --- /dev/null +++ b/.github/workflows/windows-e2e.yml @@ -0,0 +1,236 @@ +name: Windows E2E + +# Proves Argent runs on Windows. iOS Simulator is macOS-only (Apple), so +# "Argent on Windows" means the Android + Chromium host control plane: +# +# windows-chromium — the tool-server drives a real headless Chrome over CDP +# (discover → screenshot → describe → tap → observe the +# DOM change). Chromium control is pure host-side TS, so a +# hosted Windows runner (no virtualization) verifies it +# fully. Also runs the cross-platform resolver unit tests +# natively on Windows, and confirms adb.exe resolution. +# +# windows-simulator-server — builds the argent simulator-server.exe from +# source on Windows (the same feature set the radon +# release job ships) and smoke-runs it, proving the +# binary the Android path depends on builds + executes +# natively. (Booting an Android emulator needs WHPX/HAXM, +# unavailable on hosted Windows runners — that piece needs +# a physical Windows box and is out of scope here.) +# +# Manual + on-change trigger; the cargo build makes the second job ~12-15 min. + +on: + workflow_dispatch: + pull_request: + branches: + - main + paths: + - "packages/tool-server/src/utils/command-on-path.ts" + - "packages/tool-server/src/utils/android-binary.ts" + - "packages/tool-server/src/utils/check-deps.ts" + - "packages/tool-server/src/tools/simulator/stop-metro.ts" + - "packages/native-devtools-ios/src/index.ts" + - "packages/argent/scripts/bundle-tools.cjs" + - "packages/argent/scripts/argent-simulator-server.cjs" + - "scripts/download-simulator-server.sh" + - "scripts/ci/windows-chromium-e2e.mjs" + - "scripts/ci/e2e-chromium-page.html" + - ".github/workflows/windows-e2e.yml" + +jobs: + windows-chromium: + name: Chromium control plane + resolver unit tests (Windows) + runs-on: windows-latest + timeout-minutes: 20 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install dependencies + run: npm ci + + - name: Fix rollup native bindings (win32) + # vitest 4 transforms via rollup, whose platform binary is an optional + # dep npm ci sometimes skips; install the win32 one explicitly. + run: npm install @rollup/rollup-win32-x64-msvc --no-save + + - name: Build workspace + run: npx tsc --build + + - name: Resolver unit tests on Windows (tool-server) + # The win32 branches added for Windows support, exercised on a real + # Windows host (real `where.exe`, real `.exe` access semantics) rather + # than a mocked platform. The broader suite has POSIX-only fixtures, so + # this is the curated cross-platform subset. + working-directory: packages/tool-server + run: > + npx vitest run + test/command-on-path.test.ts + test/android-binary-windows.test.ts + test/check-deps.test.ts + test/http-dep-gate.test.ts + + - name: Resolver unit tests on Windows (native-devtools-ios) + working-directory: packages/native-devtools-ios + run: npx vitest run + + - name: adb.exe resolves on Windows + # windows-latest ships the Android SDK with ANDROID_HOME/ANDROID_SDK_ROOT + # set. Prove the Windows resolver finds adb.exe (the `.exe` suffix + SDK + # roots), the host half of Android support that does work on a runner. + run: | + node -e "require('./packages/tool-server/dist/utils/android-binary.js').resolveAndroidBinary('adb').then(p => { console.log('resolved adb ->', p); if (!p || !p.toLowerCase().endsWith('adb.exe')) { console.error('expected an adb.exe path'); process.exit(1); } })" + adb version + + - name: Locate Chrome + id: chrome + run: | + $candidates = @( + "$env:ProgramFiles\Google\Chrome\Application\chrome.exe", + "${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe", + "$env:LOCALAPPDATA\Google\Chrome\Application\chrome.exe" + ) + $chrome = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1 + if (-not $chrome) { Write-Error "Google Chrome not found on this runner"; exit 1 } + Write-Host "Chrome: $chrome" + "path=$chrome" >> $env:GITHUB_OUTPUT + + - name: Launch headless Chrome with CDP + run: | + $page = "file:///" + ((Resolve-Path scripts/ci/e2e-chromium-page.html).Path -replace '\\','/') + $udd = Join-Path $env:RUNNER_TEMP "chrome-e2e-profile" + $log = Join-Path $env:RUNNER_TEMP "chrome.log" + Start-Process -FilePath "${{ steps.chrome.outputs.path }}" -ArgumentList @( + "--headless=new","--remote-debugging-port=9222","--user-data-dir=$udd", + "--no-first-run","--no-default-browser-check","--disable-gpu",$page + ) -RedirectStandardOutput $log -RedirectStandardError "$log.err" | Out-Null + $up = $false + for ($i = 0; $i -lt 30; $i++) { + try { + $r = Invoke-WebRequest -UseBasicParsing "http://127.0.0.1:9222/json/version" -TimeoutSec 2 + if ($r.StatusCode -eq 200) { Write-Host "CDP up at t+${i}s"; $up = $true; break } + } catch {} + Start-Sleep -Seconds 1 + } + if (-not $up) { Get-Content "$log.err" -ErrorAction SilentlyContinue; Write-Error "Chrome CDP never came up on :9222"; exit 1 } + + - name: Start tool-server + run: | + $env:ARGENT_PORT = "3033" + New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.argent" | Out-Null + $log = Join-Path $env:RUNNER_TEMP "tool-server.log" + Start-Process -FilePath "node" -ArgumentList @("packages/tool-server/dist/index.js","start") ` + -RedirectStandardOutput $log -RedirectStandardError "$log.err" | Out-Null + $up = $false + for ($i = 0; $i -lt 40; $i++) { + try { + $r = Invoke-WebRequest -UseBasicParsing "http://127.0.0.1:3033/tools" -TimeoutSec 2 + if ($r.StatusCode -eq 200) { Write-Host "tool-server up at t+${i}s"; $up = $true; break } + } catch {} + Start-Sleep -Seconds 1 + } + if (-not $up) { + Write-Host "--- tool-server.log ---"; Get-Content $log -ErrorAction SilentlyContinue + Write-Host "--- tool-server.err ---"; Get-Content "$log.err" -ErrorAction SilentlyContinue + Write-Error "tool-server never came up on :3033"; exit 1 + } + + - name: Run Chromium E2E + env: + ARGENT_E2E_URL: http://127.0.0.1:3033 + run: | + $env:ARGENT_E2E_OUT = Join-Path $env:RUNNER_TEMP "e2e" + node scripts/ci/windows-chromium-e2e.mjs + + - name: Upload artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: windows-chromium-e2e + path: | + ${{ runner.temp }}/e2e/*.png + ${{ runner.temp }}/tool-server.log + ${{ runner.temp }}/tool-server.log.err + ${{ runner.temp }}/chrome.log + ${{ runner.temp }}/chrome.log.err + if-no-files-found: warn + retention-days: 7 + + windows-simulator-server: + name: Build + smoke argent simulator-server.exe (Windows) + runs-on: windows-latest + timeout-minutes: 30 + + steps: + - name: Checkout argent + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install dependencies + build resolver + run: | + npm ci + npx tsc --build + + - name: Checkout radon (simulator-server source) + # Public repo; shallow clone of main. The build command mirrors the + # radon argent Linux/Windows job (argent,swdec,swenc), so this also + # independently validates the radon CI change adding the Windows leg. + run: git clone --depth 1 https://github.com/software-mansion/radon.git radon-src + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + + - name: Install protoc + nasm + run: choco install -y protoc nasm + + - name: Build argent simulator-server.exe + working-directory: radon-src/packages/simulator-server + run: cargo build --release --features argent,swdec,swenc + + - name: Install binary into the per-platform bin layout + run: | + $dest = "packages/native-devtools-ios/bin/win32" + New-Item -ItemType Directory -Force -Path $dest | Out-Null + Copy-Item "radon-src/packages/simulator-server/target/release/simulator-server.exe" ` + (Join-Path $dest "simulator-server.exe") -Force + Get-Item (Join-Path $dest "simulator-server.exe") | Format-List Name,Length + + - name: Resolver finds the Windows binary + # Prove simulatorServerBinaryPath() resolves bin/win32/simulator-server.exe + # on a real Windows host (the .exe-suffix + win32 key logic). + run: | + node -e "const m=require('./packages/native-devtools-ios/dist/index.js'); const p=m.simulatorServerBinaryPath(); console.log('resolved ->', p); if(!p.toLowerCase().endsWith('win32\\simulator-server.exe')){console.error('unexpected path');process.exit(1);}" + + - name: Smoke-run the binary (executes natively) + # The binary is a valid PE that runs on Windows: invoke --help and assert + # it launched (any exit code is fine — a non-PE / wrong-arch / missing-DLL + # binary would throw on launch or fail with a load error instead). + run: | + $exe = "packages/native-devtools-ios/bin/win32/simulator-server.exe" + $out = & $exe --help 2>&1 | Out-String + Write-Host "exit code: $LASTEXITCODE" + Write-Host "output (first 500 chars):" + Write-Host $out.Substring(0, [Math]::Min(500, $out.Length)) + if ($LASTEXITCODE -eq $null) { Write-Error "binary did not execute"; exit 1 } + + - name: Upload binary + if: always() + uses: actions/upload-artifact@v4 + with: + name: simulator-server-argent-windows + path: packages/native-devtools-ios/bin/win32/simulator-server.exe + if-no-files-found: warn + retention-days: 7 diff --git a/scripts/ci/e2e-chromium-page.html b/scripts/ci/e2e-chromium-page.html new file mode 100644 index 000000000..22c445ba4 --- /dev/null +++ b/scripts/ci/e2e-chromium-page.html @@ -0,0 +1,39 @@ + + + + + Argent Chromium E2E + + + + + + + diff --git a/scripts/ci/windows-chromium-e2e.mjs b/scripts/ci/windows-chromium-e2e.mjs new file mode 100644 index 000000000..97db01604 --- /dev/null +++ b/scripts/ci/windows-chromium-e2e.mjs @@ -0,0 +1,139 @@ +// End-to-end smoke for Argent's Chromium (CDP) control plane on a host with no +// virtualization — the path that makes "Argent on Windows" real for Electron / +// Chrome apps. Chromium control is pure host-side TypeScript + CDP (the Rust +// simulator-server is not involved), so it runs anywhere Node + Chrome run, +// which is exactly what lets a hosted Windows runner verify it. +// +// Drives the running tool-server over HTTP exactly as an MCP client would: +// list-devices → screenshot → describe → gesture-tap → describe (observe the +// DOM change the tap caused). +// +// Cross-platform: also runnable on macOS/Linux for local debugging. Env: +// ARGENT_E2E_URL base tool-server URL (default http://127.0.0.1:3033) +// ARGENT_E2E_OUT directory for screenshot artifacts (default os.tmpdir()) + +import { mkdirSync, copyFileSync, statSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const BASE = process.env.ARGENT_E2E_URL ?? "http://127.0.0.1:3033"; +const OUT = process.env.ARGENT_E2E_OUT ?? tmpdir(); +mkdirSync(OUT, { recursive: true }); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +function fail(msg, extra) { + console.error(`\n❌ E2E FAILED: ${msg}`); + if (extra !== undefined) + console.error(typeof extra === "string" ? extra : JSON.stringify(extra, null, 2)); + process.exit(1); +} + +/** POST /tools/; returns the tool's `data` payload, or throws on error. */ +async function callTool(name, body = {}) { + const res = await fetch(`${BASE}/tools/${name}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const text = await res.text(); + let json; + try { + json = JSON.parse(text); + } catch { + throw new Error(`${name}: non-JSON response (${res.status}): ${text.slice(0, 300)}`); + } + if (!res.ok || json.error) { + throw new Error( + `${name}: HTTP ${res.status} ${json.error ? `- ${json.error}` : text.slice(0, 300)}` + ); + } + return json.data ?? json; +} + +async function waitForServer() { + for (let i = 0; i < 60; i++) { + try { + const res = await fetch(`${BASE}/tools`); + if (res.ok) { + console.log(`✓ tool-server up at ${BASE} (t+${i}s)`); + return; + } + } catch { + // not up yet + } + await sleep(1000); + } + fail(`tool-server never responded at ${BASE}/tools within 60s`); +} + +async function main() { + await waitForServer(); + + // 1) Discover the running Chromium instance via CDP port probing. + const devices = await callTool("list-devices"); + const list = devices.devices ?? []; + console.log( + `list-devices → ${list.length} device(s): ${list.map((d) => `${d.platform}:${d.id ?? d.udid ?? d.serial}`).join(", ") || "(none)"}` + ); + const chromium = list.find((d) => d.platform === "chromium"); + if (!chromium) + fail( + "no chromium device discovered — is Chrome running with --remote-debugging-port?", + devices + ); + const udid = chromium.id; + console.log(`✓ discovered chromium device: ${udid}`); + + // 2) Screenshot returns real pixels (not an empty/0-byte frame). + const shot = await callTool("screenshot", { udid }); + const hostPath = shot?.image?.hostPath; + if (!hostPath || !existsSync(hostPath)) fail("screenshot returned no readable hostPath", shot); + const size = statSync(hostPath).size; + console.log(`✓ screenshot: ${hostPath} (${size} bytes)`); + if (size < 1000) fail(`screenshot suspiciously small (${size} bytes) — likely a blank frame`); + copyFileSync(hostPath, join(OUT, "chromium-before.png")); + + // 3) describe surfaces the DOM — the tap target must be present. + const before = await callTool("describe", { udid }); + const beforeText = + typeof before === "string" ? before : (before.description ?? JSON.stringify(before)); + if (!/ArgentTapTarget/i.test(beforeText)) { + fail( + "describe did not surface the page's tap target (ArgentTapTarget)", + beforeText.slice(0, 800) + ); + } + console.log("✓ describe surfaced the DOM (found ArgentTapTarget)"); + + // 4) gesture-tap dispatches a real click; the page mutates the DOM in + // response, which proves the tap actually landed (not just a 200). + const tap = await callTool("gesture-tap", { udid, x: 0.5, y: 0.5 }); + if (!(tap?.tapped === true)) fail("gesture-tap did not report tapped:true", tap); + console.log("✓ gesture-tap reported tapped:true"); + + // 5) Observe the effect: the click handler rewrites the button to TAPPED-OK. + let observed = false; + for (let i = 0; i < 10; i++) { + const after = await callTool("describe", { udid }); + const afterText = + typeof after === "string" ? after : (after.description ?? JSON.stringify(after)); + if (/TAPPED-OK/i.test(afterText)) { + observed = true; + break; + } + await sleep(500); + } + const finalShot = await callTool("screenshot", { udid }); + if (finalShot?.image?.hostPath && existsSync(finalShot.image.hostPath)) { + copyFileSync(finalShot.image.hostPath, join(OUT, "chromium-after.png")); + } + if (!observed) fail("tap did not mutate the DOM (TAPPED-OK never appeared in describe)"); + console.log("✓ tap mutated the DOM (TAPPED-OK observed) — real interaction confirmed"); + + console.log( + "\n✅ Chromium E2E passed on this host — discover, screenshot, describe, and tap all work." + ); +} + +main().catch((err) => fail(err.message ?? String(err), err.stack)); From 9bfb702c1930c2155ebfeb0d8d4895d44fdde979 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Fri, 26 Jun 2026 17:35:46 +0200 Subject: [PATCH 03/10] ci(windows): fix resolver tests for native Windows; build proof to radon CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - native-devtools-ios resolver tests: name the simulator-server fixture per-platform (simulator-server.exe on win32) so the two host-platform tests pass on a Windows runner, where the resolver correctly requires the .exe. Unchanged on macOS/Linux. - windows-e2e.yml: drop the windows-simulator-server job. The .exe is built from the private radon repo, which the argent runner can't clone without a cross-repo token; its Windows build + smoke now lives in radon's simulator-server CI (build_argent_windows). Keeps the windows-chromium job — the host control-plane E2E — as the proof here. --- .github/workflows/windows-e2e.yml | 101 +++--------------- .../native-devtools-ios/test/index.test.ts | 14 ++- 2 files changed, 24 insertions(+), 91 deletions(-) diff --git a/.github/workflows/windows-e2e.yml b/.github/workflows/windows-e2e.yml index 6c50b09b5..69d4247d5 100644 --- a/.github/workflows/windows-e2e.yml +++ b/.github/workflows/windows-e2e.yml @@ -1,24 +1,21 @@ name: Windows E2E # Proves Argent runs on Windows. iOS Simulator is macOS-only (Apple), so -# "Argent on Windows" means the Android + Chromium host control plane: +# "Argent on Windows" means the Android + Chromium host control plane. # -# windows-chromium — the tool-server drives a real headless Chrome over CDP -# (discover → screenshot → describe → tap → observe the -# DOM change). Chromium control is pure host-side TS, so a -# hosted Windows runner (no virtualization) verifies it -# fully. Also runs the cross-platform resolver unit tests -# natively on Windows, and confirms adb.exe resolution. +# This job, windows-chromium, has the tool-server drive a real headless Chrome +# over CDP (discover → screenshot → describe → tap → observe the DOM change). +# Chromium control is pure host-side TypeScript, so a hosted Windows runner (no +# virtualization) verifies it fully. It also runs the cross-platform resolver +# unit tests natively on Windows and confirms adb.exe resolution. # -# windows-simulator-server — builds the argent simulator-server.exe from -# source on Windows (the same feature set the radon -# release job ships) and smoke-runs it, proving the -# binary the Android path depends on builds + executes -# natively. (Booting an Android emulator needs WHPX/HAXM, -# unavailable on hosted Windows runners — that piece needs -# a physical Windows box and is out of scope here.) -# -# Manual + on-change trigger; the cargo build makes the second job ~12-15 min. +# The simulator-server.exe itself (the binary the Android path spawns) lives in +# the private radon repo, so it can't be built here without a cross-repo token. +# Its Windows build + smoke-run is verified in radon CI instead — see the +# build_argent_windows job in radon's simulator-server-ci workflow (companion +# PR). Booting an Android emulator needs WHPX/HAXM, unavailable on hosted +# Windows runners, so that piece needs a physical Windows box and is out of +# scope here. on: workflow_dispatch: @@ -162,75 +159,3 @@ jobs: ${{ runner.temp }}/chrome.log.err if-no-files-found: warn retention-days: 7 - - windows-simulator-server: - name: Build + smoke argent simulator-server.exe (Windows) - runs-on: windows-latest - timeout-minutes: 30 - - steps: - - name: Checkout argent - uses: actions/checkout@v4 - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - - - name: Install dependencies + build resolver - run: | - npm ci - npx tsc --build - - - name: Checkout radon (simulator-server source) - # Public repo; shallow clone of main. The build command mirrors the - # radon argent Linux/Windows job (argent,swdec,swenc), so this also - # independently validates the radon CI change adding the Windows leg. - run: git clone --depth 1 https://github.com/software-mansion/radon.git radon-src - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - with: - toolchain: stable - - - name: Install protoc + nasm - run: choco install -y protoc nasm - - - name: Build argent simulator-server.exe - working-directory: radon-src/packages/simulator-server - run: cargo build --release --features argent,swdec,swenc - - - name: Install binary into the per-platform bin layout - run: | - $dest = "packages/native-devtools-ios/bin/win32" - New-Item -ItemType Directory -Force -Path $dest | Out-Null - Copy-Item "radon-src/packages/simulator-server/target/release/simulator-server.exe" ` - (Join-Path $dest "simulator-server.exe") -Force - Get-Item (Join-Path $dest "simulator-server.exe") | Format-List Name,Length - - - name: Resolver finds the Windows binary - # Prove simulatorServerBinaryPath() resolves bin/win32/simulator-server.exe - # on a real Windows host (the .exe-suffix + win32 key logic). - run: | - node -e "const m=require('./packages/native-devtools-ios/dist/index.js'); const p=m.simulatorServerBinaryPath(); console.log('resolved ->', p); if(!p.toLowerCase().endsWith('win32\\simulator-server.exe')){console.error('unexpected path');process.exit(1);}" - - - name: Smoke-run the binary (executes natively) - # The binary is a valid PE that runs on Windows: invoke --help and assert - # it launched (any exit code is fine — a non-PE / wrong-arch / missing-DLL - # binary would throw on launch or fail with a load error instead). - run: | - $exe = "packages/native-devtools-ios/bin/win32/simulator-server.exe" - $out = & $exe --help 2>&1 | Out-String - Write-Host "exit code: $LASTEXITCODE" - Write-Host "output (first 500 chars):" - Write-Host $out.Substring(0, [Math]::Min(500, $out.Length)) - if ($LASTEXITCODE -eq $null) { Write-Error "binary did not execute"; exit 1 } - - - name: Upload binary - if: always() - uses: actions/upload-artifact@v4 - with: - name: simulator-server-argent-windows - path: packages/native-devtools-ios/bin/win32/simulator-server.exe - if-no-files-found: warn - retention-days: 7 diff --git a/packages/native-devtools-ios/test/index.test.ts b/packages/native-devtools-ios/test/index.test.ts index fce0806d3..251f72331 100644 --- a/packages/native-devtools-ios/test/index.test.ts +++ b/packages/native-devtools-ios/test/index.test.ts @@ -29,6 +29,14 @@ function setArch(value: NodeJS.Architecture) { Object.defineProperty(process, "arch", { value, configurable: true }); } +// The on-disk binary name is `.exe` on Windows, extensionless elsewhere — must +// match simulatorServerBinaryName(). Tests that don't override the platform run +// against the host's real one, so their fixtures have to use the host-correct +// name to pass on a Windows runner as well as on macOS/Linux. +function ssBinName(): string { + return process.platform === "win32" ? "simulator-server.exe" : "simulator-server"; +} + beforeAll(() => { tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "argent-resolver-")); }); @@ -108,7 +116,7 @@ describe("simulator-server path resolution", () => { const dir = fs.mkdtempSync(path.join(tmpRoot, "platform-join-")); const platDir = path.join(dir, process.platform); fs.mkdirSync(platDir, { recursive: true }); - const binPath = path.join(platDir, "simulator-server"); + const binPath = path.join(platDir, ssBinName()); fs.writeFileSync(binPath, "", { mode: 0o755 }); process.env.ARGENT_SIMULATOR_SERVER_DIR = dir; const r = await loadResolver(); @@ -135,8 +143,8 @@ describe("simulator-server path resolution", () => { // the message tells them exactly what changed and how to fix it. const dir = fs.mkdtempSync(path.join(tmpRoot, "flat-layout-")); fs.mkdirSync(path.join(dir, process.platform), { recursive: true }); - // Place the binary at the flat (old) path. - const flatBin = path.join(dir, "simulator-server"); + // Place the binary at the flat (old) path, host-correct name. + const flatBin = path.join(dir, ssBinName()); fs.writeFileSync(flatBin, "", { mode: 0o755 }); process.env.ARGENT_SIMULATOR_SERVER_DIR = dir; const r = await loadResolver(); From 28d8da8115973587e7125abec37e60b0e8c25cc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Fri, 26 Jun 2026 17:41:06 +0200 Subject: [PATCH 04/10] ci(windows): smoke the resolved adb path, not a bare `adb` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit windows-latest sets ANDROID_HOME but doesn't put platform-tools on PATH, so a bare `adb version` fails — which is exactly the gap the resolver closes. Run the adb.exe the resolver returns instead. --- .github/workflows/windows-e2e.yml | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/windows-e2e.yml b/.github/workflows/windows-e2e.yml index 69d4247d5..2a4a9e705 100644 --- a/.github/workflows/windows-e2e.yml +++ b/.github/workflows/windows-e2e.yml @@ -80,11 +80,20 @@ jobs: - name: adb.exe resolves on Windows # windows-latest ships the Android SDK with ANDROID_HOME/ANDROID_SDK_ROOT - # set. Prove the Windows resolver finds adb.exe (the `.exe` suffix + SDK - # roots), the host half of Android support that does work on a runner. + # set but does NOT put platform-tools on PATH — exactly the gap the + # Windows resolver closes (`.exe` suffix + SDK-root fallback). Resolve + # adb the way the tool-server does, then run the *resolved* path (a bare + # `adb` wouldn't be found, which is the whole point). + shell: pwsh run: | - node -e "require('./packages/tool-server/dist/utils/android-binary.js').resolveAndroidBinary('adb').then(p => { console.log('resolved adb ->', p); if (!p || !p.toLowerCase().endsWith('adb.exe')) { console.error('expected an adb.exe path'); process.exit(1); } })" - adb version + $adb = node -e "require('./packages/tool-server/dist/utils/android-binary.js').resolveAndroidBinary('adb').then(p => process.stdout.write(p || ''))" + Write-Host "resolved adb -> $adb" + if ([string]::IsNullOrWhiteSpace($adb) -or -not $adb.ToLower().EndsWith('adb.exe')) { + Write-Error "expected resolveAndroidBinary('adb') to return an adb.exe path" + exit 1 + } + if (-not (Test-Path $adb)) { Write-Error "resolved adb path does not exist: $adb"; exit 1 } + & $adb version - name: Locate Chrome id: chrome From ee5ff96539bfca2a778e7d9e4ba8db9efb5d3561 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Fri, 26 Jun 2026 17:46:44 +0200 Subject: [PATCH 05/10] ci(windows): run Chrome, tool-server, and the E2E in one step On Windows runners, Start-Process background processes don't reliably survive into a later step (the tool-server was up in its own step but gone by the next), so launch Chrome + the tool-server and run the E2E within a single step where both stay in scope. --- .github/workflows/windows-e2e.yml | 38 +++++++++++++++---------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/.github/workflows/windows-e2e.yml b/.github/workflows/windows-e2e.yml index 2a4a9e705..696ff7659 100644 --- a/.github/workflows/windows-e2e.yml +++ b/.github/workflows/windows-e2e.yml @@ -108,50 +108,50 @@ jobs: Write-Host "Chrome: $chrome" "path=$chrome" >> $env:GITHUB_OUTPUT - - name: Launch headless Chrome with CDP + # Launch Chrome + the tool-server AND run the E2E in one step. On Windows + # runners, `Start-Process` background processes don't reliably survive into + # a later step (unlike `nohup` on Linux), so both must stay in scope for + # the duration of the E2E — hence a single step rather than three. + - name: Launch Chrome + tool-server and run Chromium E2E + env: + ARGENT_E2E_URL: http://127.0.0.1:3033 run: | $page = "file:///" + ((Resolve-Path scripts/ci/e2e-chromium-page.html).Path -replace '\\','/') $udd = Join-Path $env:RUNNER_TEMP "chrome-e2e-profile" - $log = Join-Path $env:RUNNER_TEMP "chrome.log" + $chromeLog = Join-Path $env:RUNNER_TEMP "chrome.log" Start-Process -FilePath "${{ steps.chrome.outputs.path }}" -ArgumentList @( "--headless=new","--remote-debugging-port=9222","--user-data-dir=$udd", "--no-first-run","--no-default-browser-check","--disable-gpu",$page - ) -RedirectStandardOutput $log -RedirectStandardError "$log.err" | Out-Null - $up = $false + ) -RedirectStandardOutput $chromeLog -RedirectStandardError "$chromeLog.err" | Out-Null + $cdpUp = $false for ($i = 0; $i -lt 30; $i++) { try { $r = Invoke-WebRequest -UseBasicParsing "http://127.0.0.1:9222/json/version" -TimeoutSec 2 - if ($r.StatusCode -eq 200) { Write-Host "CDP up at t+${i}s"; $up = $true; break } + if ($r.StatusCode -eq 200) { Write-Host "CDP up at t+${i}s"; $cdpUp = $true; break } } catch {} Start-Sleep -Seconds 1 } - if (-not $up) { Get-Content "$log.err" -ErrorAction SilentlyContinue; Write-Error "Chrome CDP never came up on :9222"; exit 1 } + if (-not $cdpUp) { Get-Content "$chromeLog.err" -ErrorAction SilentlyContinue; Write-Error "Chrome CDP never came up on :9222"; exit 1 } - - name: Start tool-server - run: | $env:ARGENT_PORT = "3033" New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.argent" | Out-Null - $log = Join-Path $env:RUNNER_TEMP "tool-server.log" + $tsLog = Join-Path $env:RUNNER_TEMP "tool-server.log" Start-Process -FilePath "node" -ArgumentList @("packages/tool-server/dist/index.js","start") ` - -RedirectStandardOutput $log -RedirectStandardError "$log.err" | Out-Null - $up = $false + -RedirectStandardOutput $tsLog -RedirectStandardError "$tsLog.err" | Out-Null + $tsUp = $false for ($i = 0; $i -lt 40; $i++) { try { $r = Invoke-WebRequest -UseBasicParsing "http://127.0.0.1:3033/tools" -TimeoutSec 2 - if ($r.StatusCode -eq 200) { Write-Host "tool-server up at t+${i}s"; $up = $true; break } + if ($r.StatusCode -eq 200) { Write-Host "tool-server up at t+${i}s"; $tsUp = $true; break } } catch {} Start-Sleep -Seconds 1 } - if (-not $up) { - Write-Host "--- tool-server.log ---"; Get-Content $log -ErrorAction SilentlyContinue - Write-Host "--- tool-server.err ---"; Get-Content "$log.err" -ErrorAction SilentlyContinue + if (-not $tsUp) { + Write-Host "--- tool-server.log ---"; Get-Content $tsLog -ErrorAction SilentlyContinue + Write-Host "--- tool-server.err ---"; Get-Content "$tsLog.err" -ErrorAction SilentlyContinue Write-Error "tool-server never came up on :3033"; exit 1 } - - name: Run Chromium E2E - env: - ARGENT_E2E_URL: http://127.0.0.1:3033 - run: | $env:ARGENT_E2E_OUT = Join-Path $env:RUNNER_TEMP "e2e" node scripts/ci/windows-chromium-e2e.mjs From 2bf9d2f0f7331fc49e5647d4073338b95ffa47b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Fri, 26 Jun 2026 18:01:35 +0200 Subject: [PATCH 06/10] fix(windows): accept Windows AVD paths; tighten PE arch check Review follow-ups: - adb.ts avdRootCandidates/resolveAvdPath used process.env.HOME (unset on Windows) and a startsWith('/') gate that rejects C:\ / UNC paths, so resolveAvdPath always returned null on Windows and the default_boot snapshot pre-check silently fell back to cold boot. Use os.homedir() and path.isAbsolute(); add a win32-guarded test (POSIX behaviour unchanged, verified by the existing cases + full suite). - download-simulator-server.sh: drop the loose '|PE32+ executable' arch-check fallback that would pass a mislabeled non-x86-64 PE; keep the x86-64 match. Not changed: artifacts.ts tar (Windows 10 1803+/Server 2019+ ship tar.exe). --- .github/workflows/windows-e2e.yml | 1 + packages/tool-server/src/utils/adb.ts | 15 +++++++++++---- .../tool-server/test/adb-resolve-avd-path.test.ts | 10 ++++++++++ scripts/download-simulator-server.sh | 2 +- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/.github/workflows/windows-e2e.yml b/.github/workflows/windows-e2e.yml index 696ff7659..da35381a7 100644 --- a/.github/workflows/windows-e2e.yml +++ b/.github/workflows/windows-e2e.yml @@ -71,6 +71,7 @@ jobs: npx vitest run test/command-on-path.test.ts test/android-binary-windows.test.ts + test/adb-resolve-avd-path.test.ts test/check-deps.test.ts test/http-dep-gate.test.ts diff --git a/packages/tool-server/src/utils/adb.ts b/packages/tool-server/src/utils/adb.ts index 5c3d7901b..cc0e49168 100644 --- a/packages/tool-server/src/utils/adb.ts +++ b/packages/tool-server/src/utils/adb.ts @@ -1,5 +1,7 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; +import { homedir } from "node:os"; +import { isAbsolute } from "node:path"; import { parse as parseIni } from "ini"; import { FAILURE_CODES, @@ -525,7 +527,9 @@ export async function checkSnapshotLoadable( * 5. `$HOME/.android/avd` — default */ function avdRootCandidates(): string[] { - const home = process.env.HOME ?? ""; + // os.homedir() (not $HOME) so the default root resolves on Windows too, where + // it's %USERPROFILE%\.android\avd and $HOME is typically unset. + const home = homedir(); const candidates: Array = [ process.env.ANDROID_USER_HOME ? `${process.env.ANDROID_USER_HOME}/avd` : null, process.env.ANDROID_AVD_HOME, @@ -533,7 +537,9 @@ function avdRootCandidates(): string[] { process.env.ANDROID_SDK_HOME ? `${process.env.ANDROID_SDK_HOME}/.android/avd` : null, home ? `${home}/.android/avd` : null, ]; - return candidates.filter((p): p is string => Boolean(p && p.startsWith("/"))); + // isAbsolute() rather than startsWith("/") so Windows roots (C:\…, \\unc\…) + // aren't discarded; on POSIX it still rejects relative paths exactly as before. + return candidates.filter((p): p is string => Boolean(p && isAbsolute(p))); } /** @@ -553,9 +559,10 @@ export async function resolveAvdPath(avdName: string): Promise { const raw = parseIni(content).path; if (typeof raw !== "string") continue; // Reject anything non-absolute (the emulator always writes an absolute - // path; relative would resolve against cwd). + // path; relative would resolve against cwd). isAbsolute() handles Windows + // drive/UNC paths too, where startsWith("/") would wrongly reject them. const trimmed = raw.trim(); - if (!trimmed.startsWith("/")) continue; + if (!isAbsolute(trimmed)) continue; return trimmed; } catch { // .ini missing or unreadable in this root; try the next one diff --git a/packages/tool-server/test/adb-resolve-avd-path.test.ts b/packages/tool-server/test/adb-resolve-avd-path.test.ts index fc83ca024..91a53c73e 100644 --- a/packages/tool-server/test/adb-resolve-avd-path.test.ts +++ b/packages/tool-server/test/adb-resolve-avd-path.test.ts @@ -45,4 +45,14 @@ describe("resolveAvdPath", () => { await avdHomeWith("Other.ini", "path=/data/avd/Other.avd\n"); expect(await resolveAvdPath("Missing")).toBeNull(); }); + + // Windows-only: a drive-absolute path must be accepted. The previous + // startsWith("/") guard rejected `C:/…`, so on Windows resolveAvdPath always + // returned null and the snapshot pre-check silently fell back to cold boot. + // isAbsolute() (win32) accepts it. Skipped off Windows because `C:/…` is not + // absolute under POSIX path semantics, which is the correct host behaviour. + it.skipIf(process.platform !== "win32")("accepts a Windows drive-absolute path", async () => { + await avdHomeWith("Win.ini", "path=C:/Users/ci/.android/avd/Win.avd\n"); + expect(await resolveAvdPath("Win")).toBe("C:/Users/ci/.android/avd/Win.avd"); + }); }); diff --git a/scripts/download-simulator-server.sh b/scripts/download-simulator-server.sh index 2726727e0..6a9997c57 100755 --- a/scripts/download-simulator-server.sh +++ b/scripts/download-simulator-server.sh @@ -91,7 +91,7 @@ for entry in "${TARGETS[@]}"; do darwin) EXPECT="Mach-O universal" ;; linux) EXPECT="ELF 64-bit.*x86-64" ;; linux-arm64) EXPECT="ELF 64-bit.*aarch64" ;; - win32) EXPECT="PE32\+.*x86-64|PE32\+ executable" ;; + win32) EXPECT="PE32\+.*x86-64" ;; *) EXPECT="" ;; esac if [[ -n "${EXPECT}" ]] && ! [[ "${DESC}" =~ ${EXPECT} ]]; then From 207b379ee7ab0c47e01da35e5c66c2400ea191ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Mon, 29 Jun 2026 17:08:47 +0200 Subject: [PATCH 07/10] fix(windows): emulator stdio:"ignore" hangs the boot; redirect to a log file On Windows, boot-device spawned the emulator detached with stdio:"ignore"; the guest never reached sys.boot_completed (verified on windows-latest: the identical flag set boots fine when given real stdout/stderr handles, but hangs with NUL handles). Redirect the detached emulator's output to a throwaway temp log on win32 so it has valid write handles. POSIX keeps "ignore" (works there, and detaching outlives a tool-server restart). --- .../src/tools/devices/boot-device.ts | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/tool-server/src/tools/devices/boot-device.ts b/packages/tool-server/src/tools/devices/boot-device.ts index cd9c30d58..b38625230 100644 --- a/packages/tool-server/src/tools/devices/boot-device.ts +++ b/packages/tool-server/src/tools/devices/boot-device.ts @@ -1,4 +1,7 @@ -import { execFile, spawn } from "node:child_process"; +import { execFile, spawn, type StdioOptions } from "node:child_process"; +import { openSync, closeSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { promisify } from "node:util"; import { z } from "zod"; import { @@ -538,11 +541,35 @@ async function attemptBoot(params: { // returned as booted rather than destroyed. tearDownIfUnready: boolean; }): Promise<{ serial: string }> { + // On Windows the emulator hangs mid-boot when spawned with stdio:"ignore": + // a detached process whose stdout/stderr are NUL never reaches + // sys.boot_completed (verified on windows-latest — the identical flag set + // boots fine when given real handles). Redirect its output to a throwaway log + // file so it has valid write handles, mirroring a `emulator ... > log` launch. + // POSIX is unchanged: "ignore" works there, and detaching keeps the emulator + // alive across a tool-server restart. + let emulatorLogFd: number | undefined; + let stdio: StdioOptions = "ignore"; + if (process.platform === "win32") { + const safeName = params.avdName.replace(/[^\w.-]/g, "_"); + const logPath = join(tmpdir(), `argent-emulator-${safeName}-${Date.now()}.log`); + emulatorLogFd = openSync(logPath, "a"); + stdio = ["ignore", emulatorLogFd, emulatorLogFd]; + } const child = spawn(params.emulatorBinary, params.emulatorArgs, { detached: true, - stdio: "ignore", + stdio, }); child.unref(); + // The spawned child inherited its own handle for the log fd; close the + // parent's copy so a descriptor doesn't leak per boot. No-op on POSIX. + if (emulatorLogFd !== undefined) { + try { + closeSync(emulatorLogFd); + } catch { + // best-effort — the child keeps its own handle regardless + } + } let earlyExitError: Error | null = null; child.on("exit", (code, signal) => { From 694ab9521837ef9f6b2affee5d8dde12b028d94e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Tue, 30 Jun 2026 15:36:50 +0200 Subject: [PATCH 08/10] test(windows): cover netstat listening-PID parser; widen E2E path triggers Extract the win32 `netstat -ano` -> listening-PID parsing out of `listeningPids` into an exported pure `parseNetstatListeningPids` so the Windows-only row matching (TCP/LISTENING filter, IPv4+IPv6 `:` suffix match with the colon guard against `:18081`, dedup) can be unit-tested on any host. Runtime behavior is unchanged and the POSIX lsof branch is untouched. Add a unit test feeding realistic `netstat -ano` output (banner + header, IPv4 and IPv6 LISTENING rows, ESTABLISHED rows, a UDP row, and a neighbouring `:18081` row) and asserting the deduped PID set. Add the Windows-relevant sources this PR changed (`adb.ts`, `boot-device.ts`) to the windows-e2e `paths:` trigger so future edits re-run the job, and reconcile the workflow header comment: WHPX is available on windows-latest (Server 2025) and the emulator boots there (matching boot-device.ts's "verified on windows-latest"); the full Android E2E lives in radon CI only because simulator-server.exe can't be built in this repo. --- .github/workflows/windows-e2e.yml | 10 ++- .../src/tools/simulator/stop-metro.ts | 40 +++++++--- .../test/stop-metro-netstat.test.ts | 78 +++++++++++++++++++ 3 files changed, 113 insertions(+), 15 deletions(-) create mode 100644 packages/tool-server/test/stop-metro-netstat.test.ts diff --git a/.github/workflows/windows-e2e.yml b/.github/workflows/windows-e2e.yml index da35381a7..fbcba4b3b 100644 --- a/.github/workflows/windows-e2e.yml +++ b/.github/workflows/windows-e2e.yml @@ -13,9 +13,11 @@ name: Windows E2E # the private radon repo, so it can't be built here without a cross-repo token. # Its Windows build + smoke-run is verified in radon CI instead — see the # build_argent_windows job in radon's simulator-server-ci workflow (companion -# PR). Booting an Android emulator needs WHPX/HAXM, unavailable on hosted -# Windows runners, so that piece needs a physical Windows box and is out of -# scope here. +# PR). WHPX is available on windows-latest (Server 2025), so an Android emulator +# does boot on a hosted runner — that is where the boot-device stdio fix was +# verified. The full Android emulator E2E still lives in radon CI alongside that +# binary rather than here, because this repo can't build simulator-server.exe +# without a cross-repo token; that path is therefore out of scope for this job. on: workflow_dispatch: @@ -25,8 +27,10 @@ on: paths: - "packages/tool-server/src/utils/command-on-path.ts" - "packages/tool-server/src/utils/android-binary.ts" + - "packages/tool-server/src/utils/adb.ts" - "packages/tool-server/src/utils/check-deps.ts" - "packages/tool-server/src/tools/simulator/stop-metro.ts" + - "packages/tool-server/src/tools/devices/boot-device.ts" - "packages/native-devtools-ios/src/index.ts" - "packages/argent/scripts/bundle-tools.cjs" - "packages/argent/scripts/argent-simulator-server.cjs" diff --git a/packages/tool-server/src/tools/simulator/stop-metro.ts b/packages/tool-server/src/tools/simulator/stop-metro.ts index 0b21c7b41..f05ebb0fc 100644 --- a/packages/tool-server/src/tools/simulator/stop-metro.ts +++ b/packages/tool-server/src/tools/simulator/stop-metro.ts @@ -2,6 +2,33 @@ import { z } from "zod"; import { execFileSync } from "node:child_process"; import type { ToolDefinition } from "@argent/registry"; +/** + * Parse the raw output of `netstat -ano` (Windows) into the deduped set of PIDs + * *listening* on `port`. Pure string parsing, split out of `listeningPids` so + * the win32 row-matching can be unit-tested without a Windows host. + * + * Each row is `proto localAddr foreignAddr state pid`. A row matches when it is + * a TCP row in the LISTENING state whose local address ends in `:`. The + * leading colon guards against `:18081` matching port 8081. UDP rows (4 columns, + * no state) and ESTABLISHED/other states are skipped, and PIDs are deduplicated + * — a listener bound on both IPv4 `0.0.0.0:` and IPv6 `[::]:` + * reports the same PID on two rows. + */ +export function parseNetstatListeningPids(netstatOutput: string, port: number): number[] { + const pids = new Set(); + for (const line of netstatOutput.split(/\r?\n/)) { + const cols = line.trim().split(/\s+/); + // cols: [proto, localAddr, foreignAddr, state, pid] + if (cols.length < 5 || cols[0].toUpperCase() !== "TCP") continue; + if (cols[3].toUpperCase() !== "LISTENING") continue; + // The colon guards against `:18081` matching port 8081. + if (!cols[1].endsWith(`:${port}`)) continue; + const pid = parseInt(cols[4], 10); + if (!Number.isNaN(pid) && pid > 0) pids.add(pid); + } + return [...pids]; +} + /** * Resolve the PIDs of processes *listening* on a TCP port, cross-platform. * Only the listener (Metro itself) is returned, never processes holding an @@ -23,18 +50,7 @@ function listeningPids(port: number): number[] { encoding: "utf-8", timeout: 5_000, }); - const pids = new Set(); - for (const line of output.split(/\r?\n/)) { - const cols = line.trim().split(/\s+/); - // cols: [proto, localAddr, foreignAddr, state, pid] - if (cols.length < 5 || cols[0].toUpperCase() !== "TCP") continue; - if (cols[3].toUpperCase() !== "LISTENING") continue; - // The colon guards against `:18081` matching port 8081. - if (!cols[1].endsWith(`:${port}`)) continue; - const pid = parseInt(cols[4], 10); - if (!Number.isNaN(pid) && pid > 0) pids.add(pid); - } - return [...pids]; + return parseNetstatListeningPids(output, port); } const output = execFileSync("lsof", ["-ti", `tcp:${port}`, "-sTCP:LISTEN"], { encoding: "utf-8", diff --git a/packages/tool-server/test/stop-metro-netstat.test.ts b/packages/tool-server/test/stop-metro-netstat.test.ts new file mode 100644 index 000000000..ec1567c4b --- /dev/null +++ b/packages/tool-server/test/stop-metro-netstat.test.ts @@ -0,0 +1,78 @@ +// 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 [::]: 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("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]); + }); +}); From f69e08e650ee45e76ac1c6b3cd5e2be1e9f097b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Thu, 2 Jul 2026 12:29:19 +0200 Subject: [PATCH 09/10] fix(stop-metro): identify Windows listeners by wildcard foreign address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The netstat parser keyed off the literal State column value "LISTENING", but Windows localizes that column (German "ABHÖREN", French "À L'ÉCOUTE"), so on a non-English host the parser matched nothing and stop-metro silently no-opped while Metro kept running. Identify a listener by its wildcard foreign endpoint (0.0.0.0:0 / [::]:0 / *:*) instead — that is locale-independent, and an ESTABLISHED connection (the tool-server's own CDP socket, which must not be killed) always has a real remote endpoint there. Also read the PID from the trailing column so a multi-word localized State can't shift it. Add a localized (German/French) regression test. --- .../src/tools/simulator/stop-metro.ts | 27 ++++++++++++++----- .../test/stop-metro-netstat.test.ts | 22 +++++++++++++++ 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/packages/tool-server/src/tools/simulator/stop-metro.ts b/packages/tool-server/src/tools/simulator/stop-metro.ts index f05ebb0fc..5bd562439 100644 --- a/packages/tool-server/src/tools/simulator/stop-metro.ts +++ b/packages/tool-server/src/tools/simulator/stop-metro.ts @@ -8,11 +8,18 @@ import type { ToolDefinition } from "@argent/registry"; * the win32 row-matching can be unit-tested without a Windows host. * * Each row is `proto localAddr foreignAddr state pid`. A row matches when it is - * a TCP row in the LISTENING state whose local address ends in `:`. The - * leading colon guards against `:18081` matching port 8081. UDP rows (4 columns, - * no state) and ESTABLISHED/other states are skipped, and PIDs are deduplicated - * — a listener bound on both IPv4 `0.0.0.0:` and IPv6 `[::]:` - * reports the same PID on two rows. + * a TCP row whose local address ends in `:` and whose FOREIGN address is a + * wildcard endpoint (`0.0.0.0:0` / `[::]:0` / `*:*`) — the locale-independent + * signature of a listener. The State column is deliberately NOT used: Windows + * localizes it (German "ABHÖREN", French "À L'ÉCOUTE"), so keying off the literal + * "LISTENING" silently matched nothing on a non-English host and stop-metro + * no-opped while Metro kept running. An ESTABLISHED/other-state connection always + * has a real remote endpoint in the foreign column, so the wildcard reliably + * separates the listener (which we kill) from the tool-server's own CDP client + * socket to Metro (which we must not). The leading colon guards against `:18081` + * matching port 8081. UDP rows (4 columns, no state) are skipped, and PIDs are + * deduplicated — a listener bound on both IPv4 `0.0.0.0:` and IPv6 + * `[::]:` reports the same PID on two rows. */ export function parseNetstatListeningPids(netstatOutput: string, port: number): number[] { const pids = new Set(); @@ -20,10 +27,16 @@ export function parseNetstatListeningPids(netstatOutput: string, port: number): const cols = line.trim().split(/\s+/); // cols: [proto, localAddr, foreignAddr, state, pid] if (cols.length < 5 || cols[0].toUpperCase() !== "TCP") continue; - if (cols[3].toUpperCase() !== "LISTENING") continue; // The colon guards against `:18081` matching port 8081. if (!cols[1].endsWith(`:${port}`)) continue; - const pid = parseInt(cols[4], 10); + // Identify a listener by its wildcard foreign endpoint (locale-independent), + // not the localized State text. + const foreign = cols[2]; + if (foreign !== "*:*" && !foreign.endsWith(":0")) continue; + // PID is the trailing column. Read it from the end, not a fixed index — a + // localized State can span multiple whitespace-split tokens (French + // "À L'ÉCOUTE"), which would otherwise shift the PID column. + const pid = parseInt(cols[cols.length - 1], 10); if (!Number.isNaN(pid) && pid > 0) pids.add(pid); } return [...pids]; diff --git a/packages/tool-server/test/stop-metro-netstat.test.ts b/packages/tool-server/test/stop-metro-netstat.test.ts index ec1567c4b..73f8caf1a 100644 --- a/packages/tool-server/test/stop-metro-netstat.test.ts +++ b/packages/tool-server/test/stop-metro-netstat.test.ts @@ -69,6 +69,28 @@ describe("parseNetstatListeningPids", () => { 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" + From d8545432951c3f5937042fbc6018955308383e17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Thu, 2 Jul 2026 19:40:45 +0200 Subject: [PATCH 10/10] test(launch-restart): probe mock returns a path for the cross-platform dep check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merging main surfaced launch-restart-tvos.test.ts, whose execFile mock returns empty stdout on success. That satisfied the old inline `command -v` probe (which only checked for no-throw), but this branch routes dependency probes through `commandOnPath`, which treats empty stdout as "not on PATH" — so the xcrun preflight threw DependencyMissingError on the CI runner. Echo a path from the `command -v` / `where` probe (as the real tools do) so the dep resolves. --- .../tool-server/test/launch-restart-tvos.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/tool-server/test/launch-restart-tvos.test.ts b/packages/tool-server/test/launch-restart-tvos.test.ts index 589430d03..02d27b69f 100644 --- a/packages/tool-server/test/launch-restart-tvos.test.ts +++ b/packages/tool-server/test/launch-restart-tvos.test.ts @@ -9,13 +9,21 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const execFileMock = vi.fn( ( - _cmd: string, - _args: readonly string[], + cmd: string, + args: readonly string[], opts: unknown, cb?: (err: Error | null, out: { stdout: string; stderr: string }) => void ) => { const callback = typeof opts === "function" ? opts : cb!; - callback(null, { stdout: "", stderr: "" }); + // A host-binary dependency probe (`command -v ` via /bin/sh, or + // `where ` on Windows) resolves through `commandOnPath`, which treats + // EMPTY stdout as "not on PATH". So a probe must echo a path for the dep to + // count as available; other execFile calls (simctl launch/terminate) don't + // consume stdout in these tests. + const isProbe = + (cmd === "/bin/sh" && typeof args[1] === "string" && args[1].startsWith("command -v ")) || + cmd === "where"; + callback(null, { stdout: isProbe ? "/usr/bin/probe\n" : "", stderr: "" }); } );