Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions .github/workflows/windows-e2e.yml
Original file line number Diff line number Diff line change
@@ -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
9 changes: 7 additions & 2 deletions packages/argent/scripts/argent-simulator-server.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
14 changes: 10 additions & 4 deletions packages/argent/scripts/bundle-tools.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 11 additions & 3 deletions packages/native-devtools-ios/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand All @@ -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;
Expand Down
53 changes: 50 additions & 3 deletions packages/native-devtools-ios/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-"));
});
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -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;
Expand Down
31 changes: 29 additions & 2 deletions packages/tool-server/src/tools/devices/boot-device.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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) => {
Expand Down
Loading
Loading