Files
software-mansion__argent/packages/tool-server/test/android-binary-windows.test.ts
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

103 lines
4.0 KiB
TypeScript

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<string, string | undefined> = {};
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<typeof import("../src/utils/android-binary")> {
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();
});
});