Files
software-mansion__argent/packages/tool-server/test/boot-device-hotboot.test.ts
Ignacy Łątka 513aca5e2e test(tool-server): stop unit tests inheriting the developer's ARGENT_* overrides (#707)
## Problem

The unit suite inherits the developer's `ARGENT_*` shell configuration,
so tests that assert argent's shipped defaults instead assert a property
of the machine running them.

I hit this as six failures in `boot-device-hotboot.test.ts` on a Linux
box:

```
AssertionError: expected [ '-avd', 'Pixel_7_API_34', …(9) ] to deeply equal ArrayContaining ["-gpu", "swiftshader"]
-   "swiftshader"
+   "host"
```

`swiftshader` is the Linux default and `auto` the macOS one. `host` is
neither — it came from `ARGENT_EMULATOR_GPU_MODE=host` exported in my
shell, which is the documented escape hatch for hosts where swiftshader
is too slow.

`selectGpuMode` (`boot-device.ts:210`) reads the env override and
returns **before** the `process.platform === "linux" ? "swiftshader" :
"auto"` fallback. That precedence is correct and is not what this
changes. The `it.each(PLATFORMS)` parameterisation is also fine —
`setPlatform` really does control `process.platform`; the env override
simply shadows it.

Proof that the source is not implicated:

```
env -u ARGENT_EMULATOR_GPU_MODE npx vitest run test/boot-device-hotboot.test.ts
→ Tests  25 passed (25)      # unchanged source
```

## The failure reads as a source regression

This is the reason it is worth fixing rather than documenting. Someone
who has ever set `ARGENT_EMULATOR_GPU_MODE` — the variable we tell
people to set — gets six red tests naming a `-gpu` value they did not
choose, in a file they did not touch, and nothing in the output mentions
the environment. The obvious next move is to go read `selectGpuMode`,
which is correct.

## Not one file

`ARGENT_EMULATOR_GPU_MODE` is the one I tripped over; the class is every
`ARGENT_*` override, since each is by definition a user-facing switch
that changes asserted behaviour. Measured by exporting each and
re-running:

| variable | file | failures |
|---|---|---|
| `ARGENT_EMULATOR_GPU_MODE` | `boot-device-hotboot.test.ts` | 6 |
| `ARGENT_EMULATOR_NO_WINDOW` | `boot-device-hotboot.test.ts` | 4 |
| `ARGENT_SIMULATOR_NO_WINDOW` | `boot-device.test.ts` | 1 (suppresses
the `open -a Simulator.app` it asserts) |
| `ARGENT_PORT` / `ARGENT_HOST` | `bind-failure-telemetry.test.ts` | 2
each |

`bind-failure-telemetry.test.ts` is the pointed one: its header comment
already worries about colliding with the developer's real tool-server
and mocks the probe for exactly that reason — but it never considered
the env override that retargets the bind.

With all eleven `ARGENT_*` variables set at once, across the three
files:

```
before →  Test Files  3 failed (3)      Tests  9 failed | 42 passed (51)
after  →  Test Files  3 passed (3)      Tests  51 passed (51)
```

## Fix

A suite-wide setup file deletes every `ARGENT_*` key from `process.env`
before the test module graph is imported, so module-level env reads see
the cleared state too.

Clearing the prefix rather than a hand-maintained allowlist is
deliberate: an override added to `src` later is covered without a second
edit here, which is precisely the kind of follow-up edit that does not
happen. A test that exercises an override sets it itself, so nothing
depends on the ambient value — verified by the full suite staying green.

Also reorders the four `it.each(PLATFORMS)` titles. Their `%s`
placeholders ran against the `[platform, gpu]` tuple and rendered as
`"picks the hot-boot spawn args + -gpu linux on swiftshader"` — platform
and GPU mode transposed, in the first string a diagnoser reads.

## Deliberately not scrubbed

`ANDROID_HOME`, `ANDROID_SDK_ROOT`, `ANDROID_AVD_HOME`,
`ANDROID_USER_HOME`, `ANDROID_SDK_HOME`, `XDG_CONFIG_HOME`,
`LOCALAPPDATA`. I ran the full package suite with all seven pointed at
nonexistent paths: 297/297 green, so those tests already stub what they
need. Scrubbing them would be defensiveness nothing pins, and they are
shared SDK variables a test could legitimately want to read.

Tests that pin `process.platform` explicitly and restore it
(`android-binary-windows`, `command-on-path`,
`lens-tools-platform-gate`, `linux-preflight`) are correct as written
and untouched. `adb-resolve-avd-path.test.ts:54`'s
`it.skipIf(process.platform !== "win32")` is a deliberate gate on
Windows-only path parsing and stays.

## Verification

- The six `-gpu` failures reproduced at `origin/main` with the variable
exported, and pass with it cleared — same command, same shell.
- Hostile-env sweep above: 9 → 0.
- **Mutation:** changing the Linux arm of `selectGpuMode` from
`swiftshader` to `auto` is killed by four named `on linux …` tests,
while the `darwin` arm stays green. Without this change that mutation is
masked whenever the variable is set.
- Full `packages/tool-server`: **297 files, 3090 passed, 1 skipped, 0
failed** (was `6 failed | 3084 passed`).
- `eslint .` 0, `prettier --check .` 0, `tsc --build` 0 — run unpiped,
exit codes read directly.

## Unrelated, found while sweeping

`test/vega-cli-timeout.test.ts` fails roughly 1 run in 5 **at
`origin/main` with this change absent** — it touches only `PATH`, never
`ARGENT_*`. Two real-subprocess timing tests (`rejects on its own
deadline when the CLI never returns`, `reaps the ENTIRE worker tree on
timeout`) racing under parallel load. Not caused by this branch and not
fixed here.
2026-08-17 21:50:21 +02:00

784 lines
34 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import type { Registry } from "@argent/registry";
// Parametrize platform-dependent assertions so a single CI runner exercises
// both branches. Without this, macOS CI never tests the linux branch and
// linux CI never tests the darwin branch — a regression that swapped them
// would pass on both runners. See selectGpuMode in boot-device.ts.
const PLATFORMS: ReadonlyArray<readonly [NodeJS.Platform, string]> = [
["linux", "swiftshader"],
["darwin", "auto"],
];
const originalPlatform = process.platform;
function setPlatform(value: NodeJS.Platform) {
Object.defineProperty(process, "platform", { value, configurable: true });
}
const execFileMock = vi.fn();
const spawnMock = vi.fn();
const hasSnapshotMock = vi.fn();
const probeMock = vi.fn();
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
execFile: (
cmd: string,
args: readonly string[],
opts: unknown,
cb?: (err: Error | null, out: { stdout: string; stderr: string }) => void
) => {
const callback = typeof opts === "function" ? opts : cb!;
const options = typeof opts === "function" ? undefined : opts;
const result = execFileMock(cmd, args, options);
if (result instanceof Error) callback(result, { stdout: "", stderr: "" });
else callback(null, result ?? { stdout: "", stderr: "" });
},
spawn: (cmd: string, args: string[], opts: unknown) => spawnMock(cmd, args, opts),
};
});
// Stub the two filesystem/probe helpers so tests don't depend on a real AVD or
// a real `emulator -check-snapshot-loadable` spawn.
vi.mock("../src/utils/adb", async () => {
const actual = await vi.importActual<typeof import("../src/utils/adb")>("../src/utils/adb");
return {
...actual,
hasDefaultBootSnapshot: (...a: unknown[]) => hasSnapshotMock(...a),
checkSnapshotLoadable: (...a: unknown[]) => probeMock(...a),
};
});
// `boot-device` now goes through `resolveAndroidBinary` for both ensureDep
// and the spawn path. Stub the resolver to return the bare name so existing
// `cmd === "adb" / "emulator"` and `spawnMock("emulator", ...)` matchers fire
// regardless of host SDK install state.
vi.mock("../src/utils/android-binary", () => ({
resolveAndroidBinary: vi.fn(async (name: "adb" | "emulator") => name),
__resetAndroidBinaryCacheForTesting: () => {},
}));
import {
__resetInFlightBootsForTesting,
createBootDeviceTool,
} from "../src/tools/devices/boot-device";
const registry: Registry = { resolveService: async () => ({}) } as unknown as Registry;
interface FakeChild extends EventEmitter {
unref: () => void;
kill: (sig?: string) => void;
exitCode: number | null;
signalCode: string | null;
}
function fakeChild(): FakeChild {
const proc = new EventEmitter() as FakeChild;
proc.unref = () => {};
proc.kill = () => {};
proc.exitCode = null;
proc.signalCode = null;
return proc;
}
beforeEach(() => {
execFileMock.mockReset();
spawnMock.mockReset();
hasSnapshotMock.mockReset();
probeMock.mockReset();
spawnMock.mockImplementation(() => fakeChild());
// Per-AVD in-flight coalescing carries leaked promises across tests; reset
// so each test starts with an empty boot map. (See note in adjacent
// boot-device-hardening.test.ts.)
__resetInFlightBootsForTesting();
});
afterEach(() => {
// Restore process.platform after every test, even ones that don't pin it,
// so a forgotten cleanup in one test can't leak into the next.
setPlatform(originalPlatform);
});
/**
* Common happy-path mock: AVD exists, adb is healthy, `adb devices` reveals
* one new emulator after spawn, wait-for-device succeeds, getprop returns 1,
* pm path answers. Used to isolate the branch-selection logic under test.
*/
function mockHappyBootChain(newSerial = "emulator-5554") {
let devicesCalls = 0;
execFileMock.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "emulator" && args[0] === "-list-avds") {
return { stdout: "Pixel_7_API_34\n", stderr: "" };
}
if (cmd === "adb" && args[0] === "version") {
return { stdout: "Android Debug Bridge\n", stderr: "" };
}
if (cmd === "adb" && args[0] === "start-server") return { stdout: "", stderr: "" };
if (cmd === "adb" && args[0] === "devices") {
devicesCalls += 1;
const emuLine = devicesCalls >= 2 ? `${newSerial}\tdevice\n` : "";
return { stdout: `List of devices attached\n${emuLine}`, stderr: "" };
}
if (cmd === "adb" && args[0] === "-s" && args[2] === "wait-for-device") {
return { stdout: "", stderr: "" };
}
if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") {
const shellCmd = args[3] ?? "";
if (shellCmd.startsWith("getprop sys.boot_completed")) {
return { stdout: "1\n", stderr: "" };
}
if (shellCmd.startsWith("getprop")) return { stdout: "unknown\n", stderr: "" };
if (shellCmd === "pm path android") {
return { stdout: "package:/system/framework/framework-res.apk\n", stderr: "" };
}
if (shellCmd.startsWith("screencap")) return { stdout: "1\n", stderr: "" };
return { stdout: "\n", stderr: "" };
}
return { stdout: "", stderr: "" };
});
}
describe("boot-device Android — hot-boot with cold-boot fallback", () => {
it.each(PLATFORMS)(
"on %s picks the hot-boot spawn args + `-gpu %s` when default_boot probes Loadable",
async (platform, expectedGpu) => {
setPlatform(platform);
hasSnapshotMock.mockResolvedValue(true);
probeMock.mockResolvedValue({ loadable: true, reason: null });
mockHappyBootChain();
const tool = createBootDeviceTool(registry);
const result = await tool.execute!({}, { avdName: "Pixel_7_API_34" });
expect(result).toMatchObject({
platform: "android",
serial: "emulator-5554",
avdName: "Pixel_7_API_34",
booted: true,
});
// Exactly one emulator spawn and it is the hot-boot arg set.
expect(spawnMock).toHaveBeenCalledTimes(1);
const hotArgs = spawnMock.mock.calls[0]![1];
expect(hotArgs).toContain("-force-snapshot-load");
expect(hotArgs).toContain("-no-snapshot-save");
expect(hotArgs).not.toContain("-no-snapshot-load");
// Window is visible by default — `-no-window` only appears when the
// user opts in via `ARGENT_EMULATOR_NO_WINDOW`. The opt-in path is
// exercised by a separate test below.
expect(hotArgs).not.toContain("-no-window");
// `-gpu` arg must be present and platform-appropriate. Linux uses
// `swiftshader` for universal compatibility (sidesteps the host GL
// stack, which silently fails on Optimus / dual-GPU / Wayland-with-
// NVIDIA setups); every other host uses `auto`. See `selectGpuMode`.
// These are the no-override defaults, so they only hold with
// `ARGENT_EMULATOR_GPU_MODE` unset — `test/setup/clear-argent-env.ts`
// strips it from the runner's environment for the whole suite.
const gpuIdx = hotArgs.indexOf("-gpu");
expect(gpuIdx).toBeGreaterThanOrEqual(0);
expect(hotArgs[gpuIdx + 1]).toBe(expectedGpu);
}
);
it.each(PLATFORMS)(
"on %s hands `-gpu %s` to both probe and hot-boot spawn",
async (platform, expectedGpu) => {
// Sibling test of the assertion above, focused on parity: the probe
// argv and the spawn argv must agree on every renderer-affecting flag,
// or the emulator's `-check-snapshot-loadable` resolves a different
// renderer than the boot does and rejects perfectly loadable snapshots.
// The bug this guards against is "every boot is cold on Linux even
// with a fresh snapshot on disk" — caught only end-to-end because both
// unit-mocked halves test green in isolation.
setPlatform(platform);
hasSnapshotMock.mockResolvedValue(true);
probeMock.mockResolvedValue({ loadable: true, reason: null });
mockHappyBootChain();
const tool = createBootDeviceTool(registry);
await tool.execute!({}, { avdName: "Pixel_7_API_34" });
expect(probeMock).toHaveBeenCalledTimes(1);
const [, , probeOptions] = probeMock.mock.calls[0]!;
// Renderer args (`-gpu`) AND launch-hardening args (`-noaudio`,
// `-netfast`) all change qemu device topology — any one of them
// differing between probe and boot is enough to reject a perfectly
// loadable snapshot. Assert both halves arrive in the probe's extraArgs
// so the parity test catches a regression in either group.
expect(probeOptions).toMatchObject({
extraArgs: expect.arrayContaining(["-gpu", expectedGpu, "-noaudio", "-netfast"]),
});
expect(spawnMock).toHaveBeenCalledTimes(1);
const hotArgs = spawnMock.mock.calls[0]![1] as string[];
const gpuIdx = hotArgs.indexOf("-gpu");
expect(gpuIdx).toBeGreaterThanOrEqual(0);
expect(hotArgs[gpuIdx + 1]).toBe(expectedGpu);
}
);
it("passes launch-hardening flags on the hot-boot spawn", async () => {
// `-noaudio`, `-no-boot-anim`, `-netfast` are the perf cuts; the consent
// dialogs that `-crash-report-mode never` and `-no-metrics` suppress are
// what block MCP-driven boots on emulator crash or first run. All five
// must reach the spawn or argent loses the qemu device parity the probe
// relies on (and the dialog protection). See LAUNCH_HARDENING_ARGS in
// boot-device.ts for the per-flag rationale.
hasSnapshotMock.mockResolvedValue(true);
probeMock.mockResolvedValue({ loadable: true, reason: null });
mockHappyBootChain();
const tool = createBootDeviceTool(registry);
await tool.execute!({}, { avdName: "Pixel_7_API_34" });
expect(spawnMock).toHaveBeenCalledTimes(1);
const hotArgs = spawnMock.mock.calls[0]![1] as string[];
expect(hotArgs).toEqual(
expect.arrayContaining([
"-noaudio",
"-no-boot-anim",
"-netfast",
"-crash-report-mode",
"never",
"-no-metrics",
])
);
});
it("passes the same launch-hardening flags on the cold-boot spawn", async () => {
// Cold boot WRITES a snapshot (no `-no-snapshot-save`); the next hot boot
// reads it. If cold-boot's qemu config differs from hot-boot's, the saved
// snapshot is unloadable and argent re-enters cold-boot forever.
hasSnapshotMock.mockResolvedValue(false);
mockHappyBootChain();
const tool = createBootDeviceTool(registry);
await tool.execute!({}, { avdName: "Pixel_7_API_34" });
expect(spawnMock).toHaveBeenCalledTimes(1);
const coldArgs = spawnMock.mock.calls[0]![1] as string[];
expect(coldArgs).toEqual(
expect.arrayContaining([
"-noaudio",
"-no-boot-anim",
"-netfast",
"-crash-report-mode",
"never",
"-no-metrics",
])
);
});
it("honors ARGENT_EMULATOR_GPU_MODE env override", async () => {
// Escape hatch for power users with verified-working `-gpu host`.
hasSnapshotMock.mockResolvedValue(false);
mockHappyBootChain();
const prev = process.env.ARGENT_EMULATOR_GPU_MODE;
process.env.ARGENT_EMULATOR_GPU_MODE = "host";
try {
const tool = createBootDeviceTool(registry);
await tool.execute!({}, { avdName: "Pixel_7_API_34" });
const args = spawnMock.mock.calls[0]![1];
const gpuIdx = args.indexOf("-gpu");
expect(args[gpuIdx + 1]).toBe("host");
} finally {
if (prev === undefined) delete process.env.ARGENT_EMULATOR_GPU_MODE;
else process.env.ARGENT_EMULATOR_GPU_MODE = prev;
}
});
it("appends -no-window when ARGENT_EMULATOR_NO_WINDOW is set", async () => {
// Opt-in for CI / containers / Wayland-only sessions where the emulator
// can't open a Qt window. See selectExtraEmulatorArgs.
hasSnapshotMock.mockResolvedValue(false);
mockHappyBootChain();
const prev = process.env.ARGENT_EMULATOR_NO_WINDOW;
process.env.ARGENT_EMULATOR_NO_WINDOW = "1";
try {
const tool = createBootDeviceTool(registry);
await tool.execute!({}, { avdName: "Pixel_7_API_34" });
const args = spawnMock.mock.calls[0]![1];
expect(args).toContain("-no-window");
} finally {
if (prev === undefined) delete process.env.ARGENT_EMULATOR_NO_WINDOW;
else process.env.ARGENT_EMULATOR_NO_WINDOW = prev;
}
});
it.each([
["empty", ""],
["whitespace", " "],
["zero", "0"],
["false", "false"],
["no", "no"],
])("treats ARGENT_EMULATOR_NO_WINDOW=%s as off", async (_label, value) => {
// `export FOO=` is a common shell mis-setting; `0`/`false`/`no` all
// read as "disable" — only `1`, `true`, `yes` activate -no-window.
hasSnapshotMock.mockResolvedValue(false);
mockHappyBootChain();
const prev = process.env.ARGENT_EMULATOR_NO_WINDOW;
process.env.ARGENT_EMULATOR_NO_WINDOW = value;
try {
const tool = createBootDeviceTool(registry);
await tool.execute!({}, { avdName: "Pixel_7_API_34" });
const args = spawnMock.mock.calls[0]![1];
expect(args).not.toContain("-no-window");
} finally {
if (prev === undefined) delete process.env.ARGENT_EMULATOR_NO_WINDOW;
else process.env.ARGENT_EMULATOR_NO_WINDOW = prev;
}
});
it.each(PLATFORMS)(
"on %s ignores empty/whitespace ARGENT_EMULATOR_GPU_MODE, falls through to the `%s` default",
async (platform, expectedGpu) => {
// `export FOO=` foot-gun: fall through to platform default, don't crash.
setPlatform(platform);
hasSnapshotMock.mockResolvedValue(false);
mockHappyBootChain();
const prev = process.env.ARGENT_EMULATOR_GPU_MODE;
process.env.ARGENT_EMULATOR_GPU_MODE = " ";
try {
const tool = createBootDeviceTool(registry);
await tool.execute!({}, { avdName: "Pixel_7_API_34" });
const args = spawnMock.mock.calls[0]![1];
const gpuIdx = args.indexOf("-gpu");
expect(args[gpuIdx + 1]).toBe(expectedGpu);
} finally {
if (prev === undefined) delete process.env.ARGENT_EMULATOR_GPU_MODE;
else process.env.ARGENT_EMULATOR_GPU_MODE = prev;
}
}
);
it("throws on an unknown ARGENT_EMULATOR_GPU_MODE rather than letting emulator -gpu fail later", async () => {
// A typoed override (`ARGENT_EMULATOR_GPU_MODE=hsot`) used to be passed
// through verbatim and the emulator binary rejected it mid-launch — that
// path burns the full hot-boot budget before surfacing the error. We
// validate against the whitelist at boot-start so the user sees the
// mistake immediately.
hasSnapshotMock.mockResolvedValue(false);
mockHappyBootChain();
const prev = process.env.ARGENT_EMULATOR_GPU_MODE;
process.env.ARGENT_EMULATOR_GPU_MODE = "hsot";
try {
const tool = createBootDeviceTool(registry);
await expect(tool.execute!({}, { avdName: "Pixel_7_API_34" })).rejects.toThrow(
/ARGENT_EMULATOR_GPU_MODE=.*not a known emulator -gpu value/
);
} finally {
if (prev === undefined) delete process.env.ARGENT_EMULATOR_GPU_MODE;
else process.env.ARGENT_EMULATOR_GPU_MODE = prev;
}
});
it.each(PLATFORMS)(
"on %s skips hot-boot and cold-boots with `-gpu %s` when no snapshot exists",
async (platform, expectedGpu) => {
setPlatform(platform);
hasSnapshotMock.mockResolvedValue(false);
mockHappyBootChain();
const tool = createBootDeviceTool(registry);
await tool.execute!({}, { avdName: "Pixel_7_API_34" });
expect(probeMock).not.toHaveBeenCalled();
expect(spawnMock).toHaveBeenCalledTimes(1);
const args = spawnMock.mock.calls[0]![1];
expect(args).toContain("-no-snapshot-load");
expect(args).not.toContain("-force-snapshot-load");
expect(args).not.toContain("-no-window");
// Cold boot must also pass the platform-appropriate `-gpu` value so the
// snapshot it eventually saves matches the renderer the next launch's
// probe will resolve. Without this, the cold-boot fallback bakes a
// renderer mismatch into the saved snapshot and re-enters the "every
// boot is cold" cycle.
expect(args).toEqual(expect.arrayContaining(["-gpu", expectedGpu]));
}
);
it("skips the hot-boot attempt and cold-boots when -check-snapshot-loadable rejects", async () => {
hasSnapshotMock.mockResolvedValue(true);
probeMock.mockResolvedValue({ loadable: false, reason: "different renderer configured" });
mockHappyBootChain();
const tool = createBootDeviceTool(registry);
await tool.execute!({}, { avdName: "Pixel_7_API_34" });
// One spawn, and it is cold-boot args (no -force-snapshot-load).
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock.mock.calls[0]![1]).toContain("-no-snapshot-load");
});
it("throws after cold boot when SurfaceFlinger never composites a real frame", async () => {
// Cold-boot post-condition: under Linux + Weston-headless + SwiftShader
// the lockscreen composite lags sys.boot_completed by 5–60 s. boot-device
// must not return `booted:true` until `screencap` reports at least one
// non-zero pixel byte, else callers chaining boot → screenshot get a
// silent all-black PNG. Here we keep screencap stuck on "0" forever and
// assert the cold-boot path eventually surfaces the timeout instead of
// returning a serial whose screenshots would all be blank.
vi.useFakeTimers();
try {
hasSnapshotMock.mockResolvedValue(false);
let devicesCalls = 0;
execFileMock.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "emulator" && args[0] === "-list-avds")
return { stdout: "Pixel_7_API_34\n", stderr: "" };
if (cmd === "adb" && args[0] === "version")
return { stdout: "Android Debug Bridge\n", stderr: "" };
if (cmd === "adb" && args[0] === "start-server") return { stdout: "", stderr: "" };
if (cmd === "adb" && args[0] === "devices") {
devicesCalls += 1;
const emuLine = devicesCalls >= 2 ? "emulator-5554\tdevice\n" : "";
return { stdout: `List of devices attached\n${emuLine}`, stderr: "" };
}
if (cmd === "adb" && args[0] === "-s" && args[2] === "wait-for-device")
return { stdout: "", stderr: "" };
if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") {
const shellCmd = args[3] ?? "";
if (shellCmd.startsWith("getprop sys.boot_completed"))
return { stdout: "1\n", stderr: "" };
if (shellCmd === "pm path android")
return { stdout: "package:/system/framework/framework-res.apk\n", stderr: "" };
if (shellCmd.startsWith("screencap")) return { stdout: "0\n", stderr: "" };
return { stdout: "\n", stderr: "" };
}
return { stdout: "", stderr: "" };
});
const tool = createBootDeviceTool(registry);
const resultP = tool.execute!({}, { avdName: "Pixel_7_API_34" }).then(
(v) => ({ ok: true, value: v }) as const,
(e) => ({ ok: false, error: e as Error }) as const
);
// 90 s firstRealFrame budget + the cold-boot stages all park on
// setTimeout. 200 s of fake time is ample to drain the deadline.
await vi.advanceTimersByTimeAsync(200_000);
const outcome = await resultP;
expect(outcome.ok).toBe(false);
if (!outcome.ok) {
expect(outcome.error.message).toMatch(/SurfaceFlinger did not composite a real frame/);
}
} finally {
vi.useRealTimers();
}
});
it("falls back to cold boot when hot-boot child exits early (ram.bin corruption class)", async () => {
// Fake timers: the impl parks the stage-2 adb-register loop on a real
// 1s setTimeout while it waits for the hot child to crash. Driving it
// with fake timers keeps the behavior identical (crash at 10ms, loop
// observes earlyExitError, falls back to cold boot) but collapses ~1s of
// real wall time. adb is module-mocked above, so the only async left is
// microtasks + the faked timers — fully deterministic.
vi.useFakeTimers();
try {
hasSnapshotMock.mockResolvedValue(true);
probeMock.mockResolvedValue({ loadable: true, reason: null });
// First spawn crashes immediately. Second spawn is healthy.
let spawnCount = 0;
spawnMock.mockImplementation(() => {
const child = fakeChild();
spawnCount += 1;
if (spawnCount === 1) {
setTimeout(() => child.emit("exit", 134, null), 10);
}
return child;
});
// Device-list mock must be spawn-aware: while the first (crashing) hot
// attempt is in flight, `adb devices` shows no new emulator so the inner
// boot loop stays in the wait and observes earlyExitError. Once the
// second (cold) spawn happens, the new serial appears.
let coldSerialVisible = false;
execFileMock.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "emulator" && args[0] === "-list-avds")
return { stdout: "Pixel_7_API_34\n", stderr: "" };
if (cmd === "adb" && args[0] === "version")
return { stdout: "Android Debug Bridge\n", stderr: "" };
if (cmd === "adb" && args[0] === "start-server") return { stdout: "", stderr: "" };
if (cmd === "adb" && args[0] === "devices") {
if (spawnCount >= 2) coldSerialVisible = true;
const line = coldSerialVisible ? "emulator-5554\tdevice\n" : "";
return { stdout: `List of devices attached\n${line}`, stderr: "" };
}
if (cmd === "adb" && args[0] === "-s" && args[2] === "wait-for-device") {
return { stdout: "", stderr: "" };
}
if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") {
const shellCmd = args[3] ?? "";
if (shellCmd.startsWith("getprop sys.boot_completed"))
return { stdout: "1\n", stderr: "" };
if (shellCmd === "pm path android")
return { stdout: "package:/system/framework/framework-res.apk\n", stderr: "" };
if (shellCmd.startsWith("screencap")) return { stdout: "1\n", stderr: "" };
return { stdout: "\n", stderr: "" };
}
return { stdout: "", stderr: "" };
});
const tool = createBootDeviceTool(registry);
const resultP = tool.execute!({}, { avdName: "Pixel_7_API_34" });
// t=10 hot child crashes; t=1000 the stage-2 poll wakes, sees the
// earlyExitError and falls back to cold boot (which then resolves fast
// since the serial is immediately visible). 5s of fake time is ample.
await vi.advanceTimersByTimeAsync(5_000);
const result = await resultP;
expect(spawnMock).toHaveBeenCalledTimes(2);
expect(spawnMock.mock.calls[0]![1]).toContain("-force-snapshot-load");
expect(spawnMock.mock.calls[1]![1]).toContain("-no-snapshot-load");
expect(spawnMock.mock.calls[1]![1]).not.toContain("-force-snapshot-load");
expect(result).toMatchObject({ serial: "emulator-5554" });
} finally {
vi.useRealTimers();
}
});
it("falls back to cold boot when hot-restore leaves screencap returning a blank frame", async () => {
// assertScreencapAlive polls within firstRealFrameHot (8 s) before
// declaring a sticky-blank state — uses fake timers so we don't wall-clock
// through the budget. Without this the test would real-time wait 8 s.
vi.useFakeTimers();
try {
hasSnapshotMock.mockResolvedValue(true);
probeMock.mockResolvedValue({ loadable: true, reason: null });
// First spawn (hot) boots cleanly but screencap returns a blank frame —
// the SurfaceFlinger composite-restore artefact. Second spawn (cold) is
// fully healthy. Each spawn registers a distinct serial so the cold-boot
// poll picks up a genuinely new emulator.
let spawnCount = 0;
spawnMock.mockImplementation(() => {
spawnCount += 1;
return fakeChild();
});
execFileMock.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "emulator" && args[0] === "-list-avds")
return { stdout: "Pixel_7_API_34\n", stderr: "" };
if (cmd === "adb" && args[0] === "version")
return { stdout: "Android Debug Bridge\n", stderr: "" };
if (cmd === "adb" && args[0] === "start-server") return { stdout: "", stderr: "" };
if (cmd === "adb" && args[0] === "devices") {
let lines = "";
if (spawnCount >= 1) lines += "emulator-5554\tdevice\n";
if (spawnCount >= 2) lines += "emulator-5556\tdevice\n";
return { stdout: `List of devices attached\n${lines}`, stderr: "" };
}
if (cmd === "adb" && args[0] === "-s" && args[2] === "wait-for-device")
return { stdout: "", stderr: "" };
if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") {
const serial = args[1];
const shellCmd = args[3] ?? "";
if (shellCmd.startsWith("getprop sys.boot_completed"))
return { stdout: "1\n", stderr: "" };
if (shellCmd.startsWith("getprop")) return { stdout: "unknown\n", stderr: "" };
if (shellCmd === "pm path android")
return { stdout: "package:/system/framework/framework-res.apk\n", stderr: "" };
if (shellCmd.startsWith("screencap")) {
return { stdout: serial === "emulator-5554" ? "0\n" : "1\n", stderr: "" };
}
return { stdout: "\n", stderr: "" };
}
return { stdout: "", stderr: "" };
});
const tool = createBootDeviceTool(registry);
const resultP = tool.execute!({}, { avdName: "Pixel_7_API_34" });
// Drain the 8 s polling budget + a margin for the post-fail cold-boot
// chain (also parks on setTimeout).
await vi.advanceTimersByTimeAsync(15_000);
const result = await resultP;
expect(spawnMock).toHaveBeenCalledTimes(2);
expect(spawnMock.mock.calls[0]![1]).toContain("-force-snapshot-load");
expect(spawnMock.mock.calls[1]![1]).toContain("-no-snapshot-load");
expect(result).toMatchObject({ serial: "emulator-5556" });
} finally {
vi.useRealTimers();
}
});
it("returns the already-running emulator without spawning when the AVD is live and its framebuffer is healthy", async () => {
// adb devices reports the AVD already attached; getprop answers with the
// matching AVD name. screencap returns a healthy frame ("1") so the
// wedged-framebuffer guard passes. No snapshot probe, no spawn.
execFileMock.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "adb" && args[0] === "version")
return { stdout: "Android Debug Bridge\n", stderr: "" };
if (cmd === "adb" && args[0] === "start-server") return { stdout: "", stderr: "" };
if (cmd === "emulator" && args[0] === "-list-avds")
return { stdout: "Pixel_7_API_34\n", stderr: "" };
if (cmd === "adb" && args[0] === "devices") {
return { stdout: "List of devices attached\nemulator-5554\tdevice\n", stderr: "" };
}
if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") {
const shellCmd = args[3] ?? "";
if (shellCmd === "getprop ro.boot.qemu.avd_name")
return { stdout: "Pixel_7_API_34\n", stderr: "" };
if (shellCmd.startsWith("getprop")) return { stdout: "unknown\n", stderr: "" };
if (shellCmd.startsWith("screencap")) return { stdout: "1\n", stderr: "" };
}
return { stdout: "", stderr: "" };
});
const tool = createBootDeviceTool(registry);
const result = await tool.execute!({}, { avdName: "Pixel_7_API_34" });
expect(result).toMatchObject({
platform: "android",
serial: "emulator-5554",
avdName: "Pixel_7_API_34",
booted: true,
});
expect(spawnMock).not.toHaveBeenCalled();
expect(hasSnapshotMock).not.toHaveBeenCalled();
expect(probeMock).not.toHaveBeenCalled();
});
it("kills the running AVD and respawns when its framebuffer is wedged on the reuse path", async () => {
// The fast-path's BUG GUARD: if a long-running emulator drifts into the
// sticky-blank screencap state, returning that serial unchanged would
// hand the caller a device whose screenshots are silently all-zero.
// The guard kills the wedged emulator and falls through to a fresh boot.
// Fake timers: assertScreencapAlive polls for firstRealFrameHot (8 s)
// before declaring a wedge, then the cold-boot fallback parks on its own
// setTimeout cadence. Without fake timers this would real-time wait.
vi.useFakeTimers();
try {
hasSnapshotMock.mockResolvedValue(false); // force the cold-boot path post-kill
let killed = false;
let spawned = false;
spawnMock.mockImplementation(() => {
spawned = true;
return fakeChild();
});
execFileMock.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "adb" && args[0] === "version")
return { stdout: "Android Debug Bridge\n", stderr: "" };
if (cmd === "adb" && args[0] === "start-server") return { stdout: "", stderr: "" };
if (cmd === "emulator" && args[0] === "-list-avds")
return { stdout: "Pixel_7_API_34\n", stderr: "" };
if (cmd === "adb" && args[0] === "devices") {
// Pre-kill: wedged emulator-5554 is listed. After kill but before
// the cold-boot spawn registers: empty (so emulator-5556 is *new*
// when it appears). Post-spawn: emulator-5556 is listed.
let line = "";
if (!killed) line = "emulator-5554\tdevice\n";
else if (spawned) line = "emulator-5556\tdevice\n";
return { stdout: `List of devices attached\n${line}`, stderr: "" };
}
if (cmd === "adb" && args[0] === "-s" && args[2] === "wait-for-device")
return { stdout: "", stderr: "" };
if (cmd === "adb" && args[0] === "-s" && args.includes("emu") && args.includes("kill")) {
killed = true;
return { stdout: "OK\n", stderr: "" };
}
if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") {
const serial = args[1];
const shellCmd = args[3] ?? "";
if (shellCmd === "getprop ro.boot.qemu.avd_name")
return { stdout: "Pixel_7_API_34\n", stderr: "" };
if (shellCmd.startsWith("getprop sys.boot_completed"))
return { stdout: "1\n", stderr: "" };
if (shellCmd.startsWith("getprop")) return { stdout: "unknown\n", stderr: "" };
if (shellCmd === "pm path android")
return { stdout: "package:/system/framework/framework-res.apk\n", stderr: "" };
if (shellCmd.startsWith("screencap")) {
// Wedged frame on the original serial; healthy on the respawn.
return { stdout: serial === "emulator-5554" ? "0\n" : "1\n", stderr: "" };
}
}
return { stdout: "", stderr: "" };
});
const tool = createBootDeviceTool(registry);
const resultP = tool.execute!({}, { avdName: "Pixel_7_API_34" });
await vi.advanceTimersByTimeAsync(15_000);
const result = await resultP;
expect(killed).toBe(true);
// Exactly one fresh spawn (the cold-boot fallback after the kill).
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock.mock.calls[0]![1]).toContain("-no-snapshot-load");
expect(result).toMatchObject({ serial: "emulator-5556" });
} finally {
vi.useRealTimers();
}
});
it("returns the cold-booted emulator as booted when PackageManager stays slow on the final attempt (does not tear it down)", async () => {
// Regression: a slow-but-alive guest (pm never answers within the probe
// window even though sys.boot_completed=1 and adb registered the serial)
// must NOT be torn down on the final cold attempt — there is no fallback
// left and the device is usable via gRPC. Previously a single 10 s pm
// probe failure killed the emulator and failed the whole boot.
hasSnapshotMock.mockResolvedValue(false); // skip hot boot -> straight to cold
let killed = false;
let devicesCalls = 0;
execFileMock.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "emulator" && args[0] === "-list-avds")
return { stdout: "Pixel_7_API_34\n", stderr: "" };
if (cmd === "adb" && args[0] === "version")
return { stdout: "Android Debug Bridge\n", stderr: "" };
if (cmd === "adb" && args[0] === "start-server") return { stdout: "", stderr: "" };
if (cmd === "adb" && args[0] === "devices") {
devicesCalls += 1;
const emuLine = devicesCalls >= 2 ? "emulator-5554\tdevice\n" : "";
return { stdout: `List of devices attached\n${emuLine}`, stderr: "" };
}
if (cmd === "adb" && args[0] === "-s" && args[2] === "wait-for-device")
return { stdout: "", stderr: "" };
if (cmd === "adb" && args[0] === "-s" && args.includes("emu") && args.includes("kill")) {
killed = true;
return { stdout: "OK\n", stderr: "" };
}
if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") {
const shellCmd = args[3] ?? "";
if (shellCmd.startsWith("getprop sys.boot_completed")) return { stdout: "1\n", stderr: "" };
if (shellCmd.startsWith("getprop")) return { stdout: "unknown\n", stderr: "" };
// pm never answers — simulate the adb call being SIGKILLed on timeout.
if (shellCmd === "pm path android")
return new Error(
"Command failed: adb -s emulator-5554 shell pm path (killed=true signal=SIGKILL)"
);
// screencap returns a real (non-zero) frame so awaitFirstRealFrame passes.
if (shellCmd.startsWith("screencap")) return { stdout: "1\n", stderr: "" };
return { stdout: "\n", stderr: "" };
}
return { stdout: "", stderr: "" };
});
vi.useFakeTimers();
try {
const tool = createBootDeviceTool(registry);
const resultP = tool.execute!({}, { avdName: "Pixel_7_API_34" });
// Drive past the ~45 s PM-probe retry budget instantly. The guest is
// alive (screencap returns a real frame, so awaitFirstRealFrame passes),
// so the final cold attempt returns it as booted instead of killing it.
await vi.advanceTimersByTimeAsync(50_000);
const result = await resultP;
expect(result).toMatchObject({
platform: "android",
serial: "emulator-5554",
booted: true,
});
// The healthy-but-slow guest must be left running, not killed.
expect(killed).toBe(false);
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock.mock.calls[0]![1]).toContain("-no-snapshot-load");
} finally {
vi.useRealTimers();
}
});
});