mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
513aca5e2e
## 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.
784 lines
34 KiB
TypeScript
784 lines
34 KiB
TypeScript
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();
|
||
}
|
||
});
|
||
});
|