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

264 lines
10 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from "vitest";
import request from "supertest";
import { Registry } from "@argent/registry";
import { z } from "zod";
// 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
// each test controls availability per-dep instead of fighting the host's
// real $ANDROID_HOME.
const resolveAndroidBinaryMock = vi.fn();
vi.mock("../src/utils/android-binary", () => ({
resolveAndroidBinary: (name: "adb" | "emulator") => resolveAndroidBinaryMock(name),
__resetAndroidBinaryCacheForTesting: () => {},
}));
import { createHttpApp } from "../src/http";
import {
DependencyMissingError,
__resetDepCacheForTests,
ensureDep,
} from "../src/utils/check-deps";
import { InvalidToolInputError } from "../src/utils/capability";
function stubProbe(missing: readonly string[]): void {
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}`;
});
}
describe("http dependency gate", () => {
beforeEach(() => {
__resetDepCacheForTests();
commandOnPathMock.mockReset();
resolveAndroidBinaryMock.mockReset();
});
it("returns 424 with a pretty message when a pre-flight dep is missing", async () => {
stubProbe(["adb"]);
const registry = new Registry();
registry.registerTool({
id: "android-thing",
requires: ["adb"],
zodSchema: z.object({}),
services: () => ({}),
async execute() {
// Should never run — the dep gate blocks the request before execute.
throw new Error("execute should have been skipped");
},
});
const { app } = createHttpApp(registry);
const res = await request(app).post("/tools/android-thing").send({});
expect(res.status).toBe(424);
expect(res.body.error).toMatch(/android-platform-tools/);
});
it("records a static failure signal when a pre-flight dep is missing", async () => {
stubProbe(["adb"]);
const recordFailure = vi.fn();
const registry = new Registry();
registry.registerTool({
id: "android-thing",
requires: ["adb"],
zodSchema: z.object({}),
services: () => ({}),
async execute() {
throw new Error("execute should have been skipped");
},
});
const { app } = createHttpApp(registry, { recordFailure });
const res = await request(app).post("/tools/android-thing").send({});
expect(res.status).toBe(424);
expect(recordFailure).toHaveBeenCalledWith(
"android-thing",
{},
{
error_code: "HTTP_DEPENDENCY_PREFLIGHT_MISSING",
failure_stage: "http_dependency_preflight",
failure_area: "http",
error_kind: "dependency_missing",
},
expect.any(Number)
);
});
it("records a static failure signal when request validation fails", async () => {
stubProbe([]);
const recordFailure = vi.fn();
const registry = new Registry();
registry.registerTool({
id: "validated-thing",
zodSchema: z.object({ count: z.number() }),
services: () => ({}),
async execute() {
throw new Error("execute should have been skipped");
},
});
const { app } = createHttpApp(registry, { recordFailure });
const res = await request(app).post("/tools/validated-thing").send({ count: "nope" });
expect(res.status).toBe(400);
expect(recordFailure).toHaveBeenCalledWith(
"validated-thing",
{},
{
error_code: "HTTP_ZOD_VALIDATION_FAILED",
failure_stage: "http_zod_validation",
failure_area: "http",
error_kind: "validation",
},
expect.any(Number)
);
expect(JSON.stringify(recordFailure.mock.calls)).not.toContain("Expected number");
});
it("invokes the tool normally when declared deps are present", async () => {
stubProbe([]);
const registry = new Registry();
registry.registerTool({
id: "ios-thing",
requires: ["xcrun"],
zodSchema: z.object({}),
services: () => ({}),
async execute() {
return { ran: true };
},
});
const { app } = createHttpApp(registry);
const res = await request(app).post("/tools/ios-thing").send({});
expect(res.status).toBe(200);
expect(res.body.data).toEqual({ ran: true });
});
it("surfaces a DependencyMissingError thrown from inside execute (post-classify path) as 424", async () => {
// Two probes expected: the first stubs all missing; the second call (for
// the cross-platform tool's in-execute ensureDep) re-probes adb and finds
// it still missing. This is the cross-platform tool pattern: `requires`
// is absent so the pre-flight gate doesn't fire, and the dep check
// happens after classifyDevice has picked android.
stubProbe(["adb"]);
const registry = new Registry();
registry.registerTool({
id: "cross-platform-thing",
zodSchema: z.object({}),
services: () => ({}),
async execute() {
// Simulate the classify → ensureDep pattern used by launch-app etc.
await ensureDep("adb");
return { ran: true };
},
});
const { app } = createHttpApp(registry);
const res = await request(app).post("/tools/cross-platform-thing").send({});
expect(res.status).toBe(424);
expect(res.body.error).toMatch(/android-platform-tools/);
});
it("maps an InvalidToolInputError thrown from execute to 400, not 500", async () => {
// A tool rejecting its (well-typed) arguments — e.g. a newline / non-ASCII
// char in Android `keyboard` text, an unknown named key — is a client input
// error, not an internal fault. It reaches the dispatcher wrapped in
// ToolExecutionError, so the mapping must walk the cause chain.
stubProbe([]);
const recordFailure = vi.fn();
const registry = new Registry();
registry.registerTool({
id: "picky-input",
zodSchema: z.object({}),
services: () => ({}),
async execute() {
throw new InvalidToolInputError("that argument can't be carried out on this device");
},
});
const { app } = createHttpApp(registry, { recordFailure });
const res = await request(app).post("/tools/picky-input").send({});
expect(res.status).toBe(400);
expect(res.body.error).toBe("that argument can't be carried out on this device");
// An InvalidToolInputError thrown FROM execute() is recorded once, by the
// registry's failure listener (the error carries its own signal out of
// execute) — the HTTP layer must NOT also record it, or the failure
// double-counts. `emitHttpFailure` fires only on pre-execute HTTP-layer
// faults (zod / capability / device-resolution / dep-preflight), never on
// this execute-catch 400 path.
expect(recordFailure).not.toHaveBeenCalled();
});
it("does not call the dep probe for tools without a `requires` declaration", async () => {
stubProbe([]);
const registry = new Registry();
registry.registerTool({
id: "no-deps",
zodSchema: z.object({}),
services: () => ({}),
async execute() {
return { ran: true };
},
});
const { app } = createHttpApp(registry);
const res = await request(app).post("/tools/no-deps").send({});
expect(res.status).toBe(200);
expect(commandOnPathMock).not.toHaveBeenCalled();
});
it("prefers 424 over 400 when a dependency error is nested under an InvalidToolInputError", async () => {
// Pins the ordering invariant documented at the dispatcher's
// InvalidToolInputError → 400 check (http.ts): `findDependencyMissing`
// scans the WHOLE cause chain first, so for a chain carrying both classes
// the 424 wins. Reordering the two checks — or a throw site nesting a
// DependencyMissingError under an InvalidToolInputError — flips this test
// instead of silently flipping the status code.
stubProbe([]);
const registry = new Registry();
registry.registerTool({
id: "dual-class-error",
zodSchema: z.object({}),
services: () => ({}),
async execute() {
const err = new InvalidToolInputError(
"that argument needs a missing dependency"
) as InvalidToolInputError & { cause?: unknown };
err.cause = new DependencyMissingError(["adb"], "install adb please");
throw err;
},
});
const { app } = createHttpApp(registry);
const res = await request(app).post("/tools/dual-class-error").send({});
expect(res.status).toBe(424);
expect(res.body.error).toBe("install adb please");
});
it("still returns 424 when the DependencyMissingError is buried two levels deep in the cause chain", async () => {
// The registry wraps execute() errors in ToolExecutionError with `cause`.
// If a future middleware adds a second wrap (or something else does), a
// naive one-level `.cause` check regresses 424 → 500. Walk the chain.
stubProbe([]);
const registry = new Registry();
registry.registerTool({
id: "double-wrap",
zodSchema: z.object({}),
services: () => ({}),
async execute() {
const inner = new DependencyMissingError(["adb"], "install adb please");
const middle = new Error("outer wrap") as Error & { cause?: unknown };
middle.cause = inner;
const outer = new Error("tool failed") as Error & { cause?: unknown };
outer.cause = middle;
throw outer;
},
});
const { app } = createHttpApp(registry);
const res = await request(app).post("/tools/double-wrap").send({});
expect(res.status).toBe(424);
expect(res.body.error).toBe("install adb please");
});
});