diff --git a/.github/workflows/windows-e2e.yml b/.github/workflows/windows-e2e.yml new file mode 100644 index 000000000..fbcba4b3b --- /dev/null +++ b/.github/workflows/windows-e2e.yml @@ -0,0 +1,175 @@ +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. +# +# 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. +# +# 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). 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: + 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/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" + - "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/adb-resolve-avd-path.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 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: | + $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 + 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 + + # 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" + $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 $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"; $cdpUp = $true; break } + } catch {} + Start-Sleep -Seconds 1 + } + if (-not $cdpUp) { Get-Content "$chromeLog.err" -ErrorAction SilentlyContinue; Write-Error "Chrome CDP never came up on :9222"; exit 1 } + + $env:ARGENT_PORT = "3033" + New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.argent" | Out-Null + $tsLog = Join-Path $env:RUNNER_TEMP "tool-server.log" + Start-Process -FilePath "node" -ArgumentList @("packages/tool-server/dist/index.js","start") ` + -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"; $tsUp = $true; break } + } catch {} + Start-Sleep -Seconds 1 + } + 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 + } + + $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 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 e4a793c6d..7765605eb 100644 --- a/packages/argent/scripts/bundle-tools.cjs +++ b/packages/argent/scripts/bundle-tools.cjs @@ -99,8 +99,13 @@ const TVOS_HID_BIN_SRC = path.resolve(BIN_SRC_ROOT, "darwin/tvos-hid-daemon"); const TVOS_AX_BIN_DEST = path.resolve(BIN_DIR, "darwin/tvos-ax-service"); const TVOS_HID_BIN_DEST = path.resolve(BIN_DIR, "darwin/tvos-hid-daemon"); // 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. @@ -582,9 +587,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 b03cde3bb..83118ec9b 100644 --- a/packages/native-devtools-ios/src/index.ts +++ b/packages/native-devtools-ios/src/index.ts @@ -98,6 +98,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()); } @@ -107,17 +114,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..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(); @@ -207,6 +215,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/devices/boot-device.ts b/packages/tool-server/src/tools/devices/boot-device.ts index c74a66bd0..2928ab937 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 { @@ -668,11 +671,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) => { diff --git a/packages/tool-server/src/tools/simulator/stop-metro.ts b/packages/tool-server/src/tools/simulator/stop-metro.ts index 28526f1fe..5bd562439 100644 --- a/packages/tool-server/src/tools/simulator/stop-metro.ts +++ b/packages/tool-server/src/tools/simulator/stop-metro.ts @@ -2,6 +2,80 @@ 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 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(); + 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; + // The colon guards against `:18081` matching port 8081. + if (!cols[1].endsWith(`:${port}`)) continue; + // 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]; +} + +/** + * 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, + }); + return parseNetstatListeningPids(output, port); + } + 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 +97,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 +105,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 +116,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/adb.ts b/packages/tool-server/src/utils/adb.ts index bc6a9fa4a..3d5ac0dbd 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, @@ -732,7 +734,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, @@ -740,7 +744,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))); } /** @@ -760,9 +766,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/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 79050eecc..ba54c9839 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`) @@ -66,16 +63,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 216d1f787..957acdab3 100644 --- a/packages/tool-server/src/utils/vega-cli.ts +++ b/packages/tool-server/src/utils/vega-cli.ts @@ -13,6 +13,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); @@ -46,18 +47,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/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/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/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: "" }); } ); 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..73f8caf1a --- /dev/null +++ b/packages/tool-server/test/stop-metro-netstat.test.ts @@ -0,0 +1,100 @@ +// 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("matches listeners on a LOCALIZED (non-English) Windows host", () => { + // Windows localizes the State column, so keying off the literal "LISTENING" + // used to return [] on a German/French host and stop-metro silently no-opped. + // The wildcard foreign address (0.0.0.0:0 / [::]:0) still identifies the + // listener, while a localized ESTABLISHED row (real remote endpoint) is + // still correctly skipped. + const german = [ + " Proto Lokale Adresse Remoteadresse Status PID", + " TCP 0.0.0.0:8081 0.0.0.0:0 ABHÖREN 1234", + " TCP [::]:8081 [::]:0 ABHÖREN 5678", + " TCP 127.0.0.1:8081 127.0.0.1:52345 HERGESTELLT 9999", + ].join("\r\n"); + expect(parseNetstatListeningPids(german, 8081)).toEqual([1234, 5678]); + + const french = [ + " Proto Adresse locale Adresse distante État PID", + " TCP 0.0.0.0:8081 0.0.0.0:0 À L'ÉCOUTE 2468", + " TCP 127.0.0.1:8081 127.0.0.1:60000 ESTABLISHED 9999", + ].join("\r\n"); + expect(parseNetstatListeningPids(french, 8081)).toEqual([2468]); + }); + + it("handles bare LF line endings as well as CRLF", () => { + const netstat = + "Proto Local Address Foreign Address State PID\n" + + " TCP 0.0.0.0:8081 0.0.0.0:0 LISTENING 1515\n"; + expect(parseNetstatListeningPids(netstat, 8081)).toEqual([1515]); + }); +}); 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)); diff --git a/scripts/download-simulator-server.sh b/scripts/download-simulator-server.sh index 5a27fafb7..6a9997c57 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" ;; *) 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}"