Files
software-mansion__argent/packages/tool-server/test/device-shutdown.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

157 lines
5.7 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock the callback-style execFile that device-shutdown promisifies, plus the
// device classifier, so this unit test asserts the platform dispatch + argv +
// error contracts with no real simctl/adb.
const execFileMock = vi.fn();
vi.mock("node:child_process", () => ({
execFile: (...args: unknown[]) => execFileMock(...args),
}));
const resolveDeviceMock = vi.fn();
vi.mock("../src/utils/device-info", () => ({
resolveDevice: (id: string) => resolveDeviceMock(id),
}));
const resolveAndroidBinaryMock = vi.fn();
vi.mock("../src/utils/android-binary", () => ({
resolveAndroidBinary: (name: string) => resolveAndroidBinaryMock(name),
}));
import {
shutdownOwnedDevice,
shutdownOwnedDevices,
shutdownDevice,
} from "../src/utils/device-shutdown";
// Default: exec succeeds (callback style: (file, args, cb) => cb(err, {stdout,stderr})).
function execSucceeds() {
execFileMock.mockImplementation(
(_file: string, _args: string[], cb: (e: unknown, r: unknown) => void) =>
cb(null, { stdout: "", stderr: "" })
);
}
function execFails(message: string) {
execFileMock.mockImplementation((_file: string, _args: string[], cb: (e: unknown) => void) =>
cb(new Error(message))
);
}
beforeEach(() => {
execFileMock.mockReset();
resolveDeviceMock.mockReset();
resolveAndroidBinaryMock.mockReset().mockResolvedValue("/sdk/platform-tools/adb");
execSucceeds();
});
describe("shutdownOwnedDevice (best-effort, swallows errors)", () => {
it("iOS -> xcrun simctl shutdown <udid>", async () => {
resolveDeviceMock.mockReturnValue({ platform: "ios", kind: "simulator" });
await shutdownOwnedDevice("UDID-1");
expect(execFileMock).toHaveBeenCalledWith(
"xcrun",
["simctl", "shutdown", "UDID-1"],
expect.any(Function)
);
});
it("Android -> resolved adb -s <serial> emu kill", async () => {
resolveDeviceMock.mockReturnValue({ platform: "android", kind: "emulator" });
await shutdownOwnedDevice("emulator-5554");
expect(resolveAndroidBinaryMock).toHaveBeenCalledWith("adb");
expect(execFileMock).toHaveBeenCalledWith(
"/sdk/platform-tools/adb",
["-s", "emulator-5554", "emu", "kill"],
expect.any(Function)
);
});
it('Android with unresolvable adb -> falls back to bare "adb"', async () => {
resolveDeviceMock.mockReturnValue({ platform: "android", kind: "emulator" });
resolveAndroidBinaryMock.mockResolvedValue(null);
await shutdownOwnedDevice("emulator-5554");
expect(execFileMock).toHaveBeenCalledWith(
"adb",
["-s", "emulator-5554", "emu", "kill"],
expect.any(Function)
);
});
it("chromium / vega -> no exec (nothing to shut down)", async () => {
resolveDeviceMock.mockReturnValue({ platform: "chromium", kind: "chromium" });
await shutdownOwnedDevice("chromium-cdp-9222");
resolveDeviceMock.mockReturnValue({ platform: "vega", kind: "vvd" });
await shutdownOwnedDevice("amazon-vvd-1");
expect(execFileMock).not.toHaveBeenCalled();
});
it("swallows an exec failure (must never break session teardown)", async () => {
resolveDeviceMock.mockReturnValue({ platform: "ios", kind: "simulator" });
execFails("simctl: boom");
await expect(shutdownOwnedDevice("UDID-1")).resolves.toBeUndefined();
});
it("swallows an unclassifiable id (resolveDevice throws)", async () => {
resolveDeviceMock.mockImplementation(() => {
throw new Error("bad id");
});
await expect(shutdownOwnedDevice("???")).resolves.toBeUndefined();
expect(execFileMock).not.toHaveBeenCalled();
});
it("shutdownOwnedDevices runs every id", async () => {
resolveDeviceMock.mockReturnValue({ platform: "ios", kind: "simulator" });
await shutdownOwnedDevices(["A", "B"]);
expect(execFileMock).toHaveBeenCalledTimes(2);
});
});
describe("shutdownDevice (surfaces the outcome)", () => {
it("iOS -> ok:true and simctl argv", async () => {
resolveDeviceMock.mockReturnValue({ platform: "ios", kind: "simulator" });
expect(await shutdownDevice("UDID-1")).toEqual({ ok: true });
expect(execFileMock).toHaveBeenCalledWith(
"xcrun",
["simctl", "shutdown", "UDID-1"],
expect.any(Function)
);
});
it("Android emulator -> ok:true and resolved adb emu kill", async () => {
resolveDeviceMock.mockReturnValue({ platform: "android", kind: "emulator" });
expect(await shutdownDevice("emulator-5554")).toEqual({ ok: true });
expect(execFileMock).toHaveBeenCalledWith(
"/sdk/platform-tools/adb",
["-s", "emulator-5554", "emu", "kill"],
expect.any(Function)
);
});
it("physical Android device -> ok:false with a reason, no exec", async () => {
resolveDeviceMock.mockReturnValue({ platform: "android", kind: "device" });
const r = await shutdownDevice("PHONE123");
expect(r.ok).toBe(false);
expect(r.error).toMatch(/physical Android/i);
expect(execFileMock).not.toHaveBeenCalled();
});
it("chromium / vega -> ok:false, unsupported", async () => {
resolveDeviceMock.mockReturnValue({ platform: "chromium", kind: "chromium" });
expect((await shutdownDevice("chromium-cdp-9222")).ok).toBe(false);
});
it("unknown id (resolveDevice throws) -> ok:false", async () => {
resolveDeviceMock.mockImplementation(() => {
throw new Error("bad id");
});
const r = await shutdownDevice("???");
expect(r.ok).toBe(false);
expect(r.error).toMatch(/Unknown device/i);
});
it("surfaces an exec failure as ok:false with the message", async () => {
resolveDeviceMock.mockReturnValue({ platform: "ios", kind: "simulator" });
execFails("simctl: boom");
const r = await shutdownDevice("UDID-1");
expect(r).toEqual({ ok: false, error: "simctl: boom" });
});
});