Files
Ignacy Łątka 7400179b5b Windows support: Android + Chromium host control plane (#421)
## What

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

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

## Changes

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

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

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

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

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

---------

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

377 lines
18 KiB
TypeScript

import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
// Static import primes vite's module graph so the dynamic re-import below
// resolves to the same node; we re-import via the SAME specifier after
// vi.resetModules() to reset captured env per-test.
import type * as ResolverModule from "../src/index";
// Unit tests for the resolver in this package. The resolver is what gates
// iOS-only binaries from being looked up on Linux callers, and what joins
// `process.platform` into the simulator-server bin path. Both behaviors
// only execute on resolver invocation, so we test the exported functions
// directly. We override ARGENT_NATIVE_DEVTOOLS_DIR /
// ARGENT_SIMULATOR_SERVER_DIR per-test so the resolver looks at a tmpdir we
// control rather than the real packages/native-devtools-ios layout.
let tmpRoot = "";
const originalPlatform = process.platform;
const originalArch = process.arch;
const originalDevtoolsDir = process.env.ARGENT_NATIVE_DEVTOOLS_DIR;
const originalSimulatorDir = process.env.ARGENT_SIMULATOR_SERVER_DIR;
const originalDevtoolsTcpDir = process.env.ARGENT_NATIVE_DEVTOOLS_TCP_DIR;
const originalSimulatorTcpDir = process.env.ARGENT_SIMULATOR_SERVER_TCP_DIR;
function setPlatform(value: NodeJS.Platform) {
Object.defineProperty(process, "platform", { value, configurable: true });
}
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-"));
});
afterAll(() => {
if (tmpRoot) fs.rmSync(tmpRoot, { recursive: true, force: true });
setPlatform(originalPlatform);
setArch(originalArch);
if (originalDevtoolsDir === undefined) delete process.env.ARGENT_NATIVE_DEVTOOLS_DIR;
else process.env.ARGENT_NATIVE_DEVTOOLS_DIR = originalDevtoolsDir;
if (originalSimulatorDir === undefined) delete process.env.ARGENT_SIMULATOR_SERVER_DIR;
else process.env.ARGENT_SIMULATOR_SERVER_DIR = originalSimulatorDir;
if (originalDevtoolsTcpDir === undefined) delete process.env.ARGENT_NATIVE_DEVTOOLS_TCP_DIR;
else process.env.ARGENT_NATIVE_DEVTOOLS_TCP_DIR = originalDevtoolsTcpDir;
if (originalSimulatorTcpDir === undefined) delete process.env.ARGENT_SIMULATOR_SERVER_TCP_DIR;
else process.env.ARGENT_SIMULATOR_SERVER_TCP_DIR = originalSimulatorTcpDir;
});
afterEach(() => {
setPlatform(originalPlatform);
setArch(originalArch);
delete process.env.ARGENT_NATIVE_DEVTOOLS_TCP_DIR;
delete process.env.ARGENT_SIMULATOR_SERVER_TCP_DIR;
});
/**
* Re-import the resolver after env / platform mutations. The module captures
* ARGENT_NATIVE_DEVTOOLS_DIR / ARGENT_SIMULATOR_SERVER_DIR at top-level on
* first load; vi.resetModules() clears vite's cache so the next import()
* re-executes the module body and picks up the current env. The dynamic
* specifier must match the static import above byte-for-byte so vite's
* module graph resolves to the same node.
*/
async function loadResolver(): Promise<typeof ResolverModule> {
vi.resetModules();
return await import("../src/index");
}
describe("requireDarwin gating", () => {
it("throws with a root-cause message on linux for iOS-only dylibs", async () => {
// A Linux caller asking for an iOS Simulator dylib should get the root
// cause ("requires a macOS host") not a misleading "file not found",
// which is what happened before this PR and led to confused bug reports.
setPlatform("linux");
const r = await loadResolver();
expect(() => r.bootstrapDylibPath()).toThrow(/requires a macOS host/);
expect(() => r.nativeDevtoolsDylibPath()).toThrow(/requires a macOS host/);
expect(() => r.keyboardPatchDylibPath()).toThrow(/requires a macOS host/);
});
it("resolves TCP dylibs on linux (remote upload artifacts, not gated)", async () => {
// TCP variants are payloads uploaded to a remote Mac orchestrator via
// sim-remote, so a Linux host must be able to resolve them. They are guarded
// only by the file-existence check below, NOT by requireDarwin.
setPlatform("linux");
const dir = fs.mkdtempSync(path.join(tmpRoot, "tcp-dylibs-"));
fs.writeFileSync(path.join(dir, "libArgentInjectionBootstrap.dylib"), "");
fs.writeFileSync(path.join(dir, "libNativeDevtoolsIos.dylib"), "");
fs.writeFileSync(path.join(dir, "libKeyboardPatch.dylib"), "");
process.env.ARGENT_NATIVE_DEVTOOLS_TCP_DIR = dir;
const r = await loadResolver();
expect(r.bootstrapDylibPathTcp()).toBe(path.join(dir, "libArgentInjectionBootstrap.dylib"));
expect(r.nativeDevtoolsDylibPathTcp()).toBe(path.join(dir, "libNativeDevtoolsIos.dylib"));
expect(r.keyboardPatchDylibPathTcp()).toBe(path.join(dir, "libKeyboardPatch.dylib"));
});
it("resolves TCP dylibs from the default dylibs/tcp on linux (neutral, no override)", async () => {
// Without the tcp override env, the tcp dylib dir defaults to DYLIB_DIR/tcp —
// platform-NEUTRAL, exactly like the tcp ax-service's bin/tcp. Resolving from
// the base dir on a Linux host must land under tcp/, never a host-platform
// subdir, since these are darwin artifacts uploaded to the remote orchestrator.
setPlatform("linux");
const base = fs.mkdtempSync(path.join(tmpRoot, "dylibs-base-"));
const tcpDir = path.join(base, "tcp");
fs.mkdirSync(tcpDir, { recursive: true });
fs.writeFileSync(path.join(tcpDir, "libArgentInjectionBootstrap.dylib"), "");
fs.writeFileSync(path.join(tcpDir, "libNativeDevtoolsIos.dylib"), "");
fs.writeFileSync(path.join(tcpDir, "libKeyboardPatch.dylib"), "");
process.env.ARGENT_NATIVE_DEVTOOLS_DIR = base;
try {
const r = await loadResolver();
expect(r.bootstrapDylibPathTcp()).toBe(
path.join(tcpDir, "libArgentInjectionBootstrap.dylib")
);
expect(r.nativeDevtoolsDylibPathTcp()).toBe(path.join(tcpDir, "libNativeDevtoolsIos.dylib"));
expect(r.keyboardPatchDylibPathTcp()).toBe(path.join(tcpDir, "libKeyboardPatch.dylib"));
} finally {
if (originalDevtoolsDir === undefined) delete process.env.ARGENT_NATIVE_DEVTOOLS_DIR;
else process.env.ARGENT_NATIVE_DEVTOOLS_DIR = originalDevtoolsDir;
}
});
it("throws a plain not-found (not a macOS-host error) for missing TCP dylibs on linux", async () => {
// When the TCP artifacts haven't been downloaded, the file-existence check
// gives a "not found" error — never the "requires a macOS host" gate.
setPlatform("linux");
const dir = fs.mkdtempSync(path.join(tmpRoot, "tcp-dylibs-missing-"));
process.env.ARGENT_NATIVE_DEVTOOLS_TCP_DIR = dir;
const r = await loadResolver();
expect(() => r.bootstrapDylibPathTcp()).toThrow(/dylib not found/);
expect(() => r.bootstrapDylibPathTcp()).not.toThrow(/requires a macOS host/);
// The error must be self-servicing: name the override env var so a user
// hitting a stale/dev build learns how to relocate the lookup.
expect(() => r.bootstrapDylibPathTcp()).toThrow(/ARGENT_NATIVE_DEVTOOLS_TCP_DIR/);
});
it("throws an actionable not-found (naming the override env) for a missing TCP ax-service", async () => {
// A missing TCP ax-service otherwise surfaces deep in describe as a
// misleading "reboot the simulator" hint; the resolver error must instead
// point at the override env so the real cause is diagnosable.
setPlatform("darwin");
const dir = fs.mkdtempSync(path.join(tmpRoot, "ax-tcp-missing-"));
fs.mkdirSync(path.join(dir, "tcp"), { recursive: true });
process.env.ARGENT_SIMULATOR_SERVER_DIR = dir;
const r = await loadResolver();
expect(() => r.axServiceBinaryPathTcp()).toThrow(/TCP-transport binary not found/);
expect(() => r.axServiceBinaryPathTcp()).toThrow(/ARGENT_SIMULATOR_SERVER_TCP_DIR/);
});
it("throws with a root-cause message on linux for ax-service", async () => {
// ax-service is darwin-only (runs INSIDE the iOS Simulator), so the
// resolver must throw the platform error BEFORE the file-existence check.
setPlatform("linux");
process.env.ARGENT_SIMULATOR_SERVER_DIR = tmpRoot;
const r = await loadResolver();
expect(() => r.axServiceBinaryPath()).toThrow(/requires a macOS host/);
});
it("resolves the ax-service TCP binary from the platform-neutral bin/tcp on linux", async () => {
// The TCP ax-service is uploaded to and `simctl spawn`d on the remote Mac
// orchestrator, so a Linux host must resolve it from the neutral bin/tcp —
// NOT bin/linux/tcp, which would never hold the darwin-built binary.
setPlatform("linux");
setArch("x64");
const dir = fs.mkdtempSync(path.join(tmpRoot, "ax-tcp-linux-"));
const tcpDir = path.join(dir, "tcp");
fs.mkdirSync(tcpDir, { recursive: true });
const binPath = path.join(tcpDir, "ax-service");
fs.writeFileSync(binPath, "", { mode: 0o755 });
process.env.ARGENT_SIMULATOR_SERVER_DIR = dir;
const r = await loadResolver();
expect(r.axServiceBinaryPathTcp()).toBe(binPath);
});
});
describe("simulator-server path resolution", () => {
it("joins process.platform into the bin path", async () => {
// The whole point of the per-platform bin layout. We're verifying that
// the resolver constructs <BIN_ROOT>/<platform>/simulator-server, not
// <BIN_ROOT>/simulator-server.
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, ssBinName());
fs.writeFileSync(binPath, "", { mode: 0o755 });
process.env.ARGENT_SIMULATOR_SERVER_DIR = dir;
const r = await loadResolver();
expect(r.simulatorServerBinaryPath()).toBe(binPath);
expect(r.simulatorServerBinaryDir()).toBe(platDir);
});
it("throws when the per-platform binary is missing", async () => {
// Empty per-platform dir: the resolver should fail loudly with both the
// platform name and the expected path in the message so the user can
// act on it (re-run the download script, switch hosts, etc).
const dir = fs.mkdtempSync(path.join(tmpRoot, "missing-"));
fs.mkdirSync(path.join(dir, process.platform), { recursive: true });
process.env.ARGENT_SIMULATOR_SERVER_DIR = dir;
const r = await loadResolver();
expect(() => r.simulatorServerBinaryPath()).toThrow(/simulator-server binary not found/);
expect(() => r.simulatorServerBinaryPath()).toThrow(new RegExp(process.platform));
});
it("includes a migration hint when ARGENT_SIMULATOR_SERVER_DIR points at the old flat layout", async () => {
// Prior to the Linux per-platform layout, ARGENT_SIMULATOR_SERVER_DIR
// was the directory that contained simulator-server directly. A user
// with that setup now gets a "not found" error; the migration hint in
// 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, 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();
expect(() => r.simulatorServerBinaryPath()).toThrow(/old flat path/);
expect(() => r.simulatorServerBinaryPath()).toThrow(new RegExp(process.platform));
});
});
describe("host platform key (arch-aware Linux bin dirs)", () => {
// Linux binaries are single-arch ELFs (unlike the universal macOS binary),
// so arm64 Linux resolves to its own "linux-arm64" directory while x86_64
// keeps the pre-arm64 "linux" name. Without this split, an arm64 host would
// silently pick up the x86_64 ELF and fail with ENOEXEC at spawn time.
it("resolves bin/linux-arm64 on arm64 Linux", async () => {
setPlatform("linux");
setArch("arm64");
const dir = fs.mkdtempSync(path.join(tmpRoot, "linux-arm64-"));
const platDir = path.join(dir, "linux-arm64");
fs.mkdirSync(platDir, { recursive: true });
const binPath = path.join(platDir, "simulator-server");
fs.writeFileSync(binPath, "", { mode: 0o755 });
process.env.ARGENT_SIMULATOR_SERVER_DIR = dir;
const r = await loadResolver();
expect(r.hostPlatformKey()).toBe("linux-arm64");
expect(r.simulatorServerBinaryPath()).toBe(binPath);
expect(r.simulatorServerBinaryDir()).toBe(platDir);
});
it("keeps resolving bin/linux on x86_64 Linux", async () => {
setPlatform("linux");
setArch("x64");
const dir = fs.mkdtempSync(path.join(tmpRoot, "linux-x64-"));
const platDir = path.join(dir, "linux");
fs.mkdirSync(platDir, { recursive: true });
const binPath = path.join(platDir, "simulator-server");
fs.writeFileSync(binPath, "", { mode: 0o755 });
process.env.ARGENT_SIMULATOR_SERVER_DIR = dir;
const r = await loadResolver();
expect(r.hostPlatformKey()).toBe("linux");
expect(r.simulatorServerBinaryPath()).toBe(binPath);
});
it("keeps resolving bin/darwin on arm64 macOS (universal binary)", async () => {
setPlatform("darwin");
setArch("arm64");
const dir = fs.mkdtempSync(path.join(tmpRoot, "darwin-arm64-"));
const platDir = path.join(dir, "darwin");
fs.mkdirSync(platDir, { recursive: true });
const binPath = path.join(platDir, "simulator-server");
fs.writeFileSync(binPath, "", { mode: 0o755 });
process.env.ARGENT_SIMULATOR_SERVER_DIR = dir;
const r = await loadResolver();
expect(r.hostPlatformKey()).toBe("darwin");
expect(r.simulatorServerBinaryPath()).toBe(binPath);
});
it("names the linux-arm64 key in the missing-binary error", async () => {
// The error must name the directory actually searched ("linux-arm64"),
// not bare process.platform, so an arm64 user re-running the download
// script can tell which asset is absent.
setPlatform("linux");
setArch("arm64");
const dir = fs.mkdtempSync(path.join(tmpRoot, "linux-arm64-missing-"));
fs.mkdirSync(path.join(dir, "linux-arm64"), { recursive: true });
process.env.ARGENT_SIMULATOR_SERVER_DIR = dir;
const r = await loadResolver();
expect(() => r.simulatorServerBinaryPath()).toThrow(/linux-arm64/);
});
});
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;
const dir = fs.mkdtempSync(path.join(tmpRoot, "ax-platform-"));
const platDir = path.join(dir, "darwin");
fs.mkdirSync(platDir, { recursive: true });
const binPath = path.join(platDir, "ax-service");
fs.writeFileSync(binPath, "", { mode: 0o755 });
process.env.ARGENT_SIMULATOR_SERVER_DIR = dir;
const r = await loadResolver();
expect(r.axServiceBinaryPath()).toBe(binPath);
});
it("resolves the ax-service TCP binary from the platform-neutral bin/tcp", async () => {
// The tcp ax-service is host-platform-independent (uploaded to the remote
// orchestrator), so it resolves at bin/tcp — not bin/<hostPlatform>/tcp.
const dir = fs.mkdtempSync(path.join(tmpRoot, "ax-tcp-"));
const tcpDir = path.join(dir, "tcp");
fs.mkdirSync(tcpDir, { recursive: true });
const binPath = path.join(tcpDir, "ax-service");
fs.writeFileSync(binPath, "", { mode: 0o755 });
process.env.ARGENT_SIMULATOR_SERVER_DIR = dir;
const r = await loadResolver();
expect(r.axServiceBinaryPathTcp()).toBe(binPath);
});
});
describe("ARGENT_NATIVE_DEVTOOLS_DIR override", () => {
it("uses the env-var dir for dylib resolution on darwin", async () => {
// The launcher script swaps the dylib dir at runtime via this env var
// (matches the ARGENT_SIMULATOR_SERVER_DIR pattern). Linux callers can't
// exercise the happy path because requireDarwin throws first, so this
// test only meaningfully runs on darwin — skip elsewhere rather than
// pretend to test it.
if (originalPlatform !== "darwin") return;
const dir = fs.mkdtempSync(path.join(tmpRoot, "dylib-override-"));
const dylib = path.join(dir, "libNativeDevtoolsIos.dylib");
fs.writeFileSync(dylib, "");
process.env.ARGENT_NATIVE_DEVTOOLS_DIR = dir;
setPlatform("darwin");
const r = await loadResolver();
expect(r.nativeDevtoolsDylibPath()).toBe(dylib);
});
});