Files
software-mansion__argent/packages/tool-server/test/adb-hardening.test.ts
Ignacy Łątka 324d8c6049 fix(android): stop forcing cold boot on every emulator launch (#251)
## Summary

`hasDefaultBootSnapshot` enforced a 60s mtime-skew guard between
`snapshot.pb` and `ram.bin`. In practice the emulator updates
`snapshot.pb` metadata on every load (load count, last-loaded timestamp)
even with `-no-snapshot-save`, so the two mtimes drift apart by hours or
days after a few hot-boot sessions and the guard rejects every valid
snapshot — forcing a cold boot every time.

The bug hits macOS too; Linux just makes it painful because cold boot is
slow there. Confirmed against my own `Pixel_6_API_34`, whose
`snapshot.pb` is 4 days newer than `ram.bin` while the snapshot itself
is fully usable.

## What changed

- **Drop the mtime-skew guard.** `-check-snapshot-loadable` + the
hot-boot spawn's `-force-snapshot-load` already make truncated / corrupt
`ram.bin` fail loudly via `attemptBoot`'s `earlyExitError` race. The
on-disk pre-check only needs to confirm both files exist and `ram.bin`
is non-empty.
- **Resolve the `.avd` folder via the AVD's `.ini` `path=` line**,
matching what the emulator binary itself does
(`external/qemu/android/android-emu/android/avd/util.cpp`).
Snap-installed Studio on Linux and users who relocate AVDs to faster
disks both put `.avd` outside the convention root, where the old
`<root>/<name>.avd` assumption silently failed.
- **Honor `$ANDROID_USER_HOME`, `$XDG_CONFIG_HOME`,
`$ANDROID_SDK_HOME`** in priority order alongside `$ANDROID_AVD_HOME`
and `$HOME/.android/avd`, mirroring the emulator binary's own lookup
precedence.
- **Reject whitespace-only and relative `path=` values.** The regex's
non-greedy `(.+?)` still captures a single space for `path= `, which
would have leaked past `if (!avdPath)` as truthy; a relative `path=`
would have resolved against `process.cwd()` and silently mis-located
snapshots.

## Test plan

- [x] 18 new unit tests in `avd-snapshot.test.ts` covering: priority
order across all five env-var candidates, relocated AVDs, drifted mtimes
(4-day skew explicitly regression-tested), zero-length / missing /
directory-shaped files, whitespace-only paths, relative paths,
bad-then-good fallthrough.
- [x] Full `npm run test -w @argent/tool-server` — 703 passing.
- [x] `npm run typecheck:tests -w @argent/tool-server` — clean.
- [x] `npm run build` from workspace root — clean.
- [x] `npx prettier --check` on changed files — clean.
- [x] End-to-end repro against a real local AVD: drifted
`Pixel_3a_API_34`'s `snapshot.pb` mtime to 5 days newer than `ram.bin`
(the exact pathology in the wild), drove `boot-device` through this
branch's built code, observed `qemu-system-aarch64 ...
-force-snapshot-load -no-snapshot-save` in the spawned process — i.e.
the hot-boot path was taken with a snapshot the old check would have
rejected.

## Risk

Pre-check is now more lenient on disk shape. If `ram.bin` is non-empty
but truncated/corrupt, the hot-boot spawn still catches it via
`-force-snapshot-load` (loud early child-exit) and `attemptBoot` falls
back to cold boot. So the worst case has not regressed; it just surfaces
at spawn-time instead of pre-spawn.
2026-05-27 16:24:31 +02:00

235 lines
8.4 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
const execFileMock = 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: "" });
},
};
});
// `runAdb` / `listAvds` now resolve adb / emulator to an absolute path before
// spawning, so a bare `cmd === "adb"` matcher would never fire on real hosts.
// Stub the resolver to return the bare name so existing test mocks keep
// working, regardless of whether the host has the SDK installed.
vi.mock("../src/utils/android-binary", () => ({
resolveAndroidBinary: vi.fn(async (name: "adb" | "emulator") => name),
__resetAndroidBinaryCacheForTesting: () => {},
}));
import { checkSnapshotLoadable, listAndroidDevices, listAvds } from "../src/utils/adb";
beforeEach(() => {
execFileMock.mockReset();
});
describe("readAvdName — modern property, legacy fallback (review #3)", () => {
/**
* Emulator release 30 (Android 11+) moved the AVD name from
* `ro.kernel.qemu.avd_name` to `ro.boot.qemu.avd_name`. Reading only the
* old property makes modern images report `avdName: null`, which in turn
* breaks `findSerialByAvdName` disambiguation when two emulators boot
* concurrently.
*
* The fix probes the new prop first and falls back to the old one. These
* tests pin both paths.
*/
function mockAdbGetProps(
serial: string,
props: Partial<{
"ro.product.model": string;
"ro.build.version.sdk": string;
"ro.boot.qemu.avd_name": string;
"ro.kernel.qemu.avd_name": string;
}>
): void {
execFileMock.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "adb" && args[0] === "devices" && args.length === 1) {
return { stdout: `List of devices attached\n${serial}\tdevice\n`, stderr: "" };
}
if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") {
const shell = args[3] ?? "";
for (const [prop, value] of Object.entries(props)) {
if (shell === `getprop ${prop}`) return { stdout: `${value}\n`, stderr: "" };
}
return { stdout: "\n", stderr: "" };
}
return { stdout: "", stderr: "" };
});
}
it("reads ro.boot.qemu.avd_name on modern images (Android 11+)", async () => {
mockAdbGetProps("emulator-5554", {
"ro.product.model": "sdk_gphone64",
"ro.build.version.sdk": "34",
"ro.boot.qemu.avd_name": "Pixel_7_API_34",
"ro.kernel.qemu.avd_name": "",
});
const devices = await listAndroidDevices();
expect(devices).toHaveLength(1);
expect(devices[0]!.avdName).toBe("Pixel_7_API_34");
});
it("falls back to ro.kernel.qemu.avd_name on legacy images", async () => {
mockAdbGetProps("emulator-5554", {
"ro.product.model": "sdk_gphone",
"ro.build.version.sdk": "29",
"ro.boot.qemu.avd_name": "",
"ro.kernel.qemu.avd_name": "Pixel_3a_API_29",
});
const devices = await listAndroidDevices();
expect(devices[0]!.avdName).toBe("Pixel_3a_API_29");
});
it("prefers the modern property when both are present (some images double-set)", async () => {
mockAdbGetProps("emulator-5554", {
"ro.product.model": "sdk_gphone64",
"ro.build.version.sdk": "34",
"ro.boot.qemu.avd_name": "Pixel_7_API_34",
"ro.kernel.qemu.avd_name": "Pixel_7_API_34_stale",
});
const devices = await listAndroidDevices();
expect(devices[0]!.avdName).toBe("Pixel_7_API_34");
});
it("returns null when neither property is set (physical device)", async () => {
mockAdbGetProps("R5CT12345678", {
"ro.product.model": "SM-G990B",
"ro.build.version.sdk": "33",
});
execFileMock.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "adb" && args[0] === "devices") {
return { stdout: `List of devices attached\nR5CT12345678\tdevice\n`, stderr: "" };
}
if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") {
const shell = args[3] ?? "";
if (shell === "getprop ro.product.model") return { stdout: "SM-G990B\n", stderr: "" };
if (shell === "getprop ro.build.version.sdk") return { stdout: "33\n", stderr: "" };
return { stdout: "\n", stderr: "" };
}
return { stdout: "", stderr: "" };
});
const devices = await listAndroidDevices();
expect(devices[0]!.avdName).toBeNull();
});
});
describe("listAvds noise filter (review #9)", () => {
/**
* Old filter was prefix-only — any AVD name starting with INFO/HAX was
* silently dropped. Real `emulator -list-avds` noise is diagnostic
* header/footer lines that contain whitespace or colons (e.g.
* `INFO | Android emulator version ...`), while AVD names are
* identifier-only. The new filter accepts identifier-shaped lines only.
*/
it("accepts an AVD name that happens to start with HAX (e.g. HAX-Pixel-6)", async () => {
execFileMock.mockImplementation((cmd: string) => {
if (cmd === "emulator") {
return { stdout: "HAX-Pixel-6\nINFO_BuildBot_Pixel7\nPixel_7_API_34\n", stderr: "" };
}
return { stdout: "", stderr: "" };
});
const avds = await listAvds();
expect(avds.map((a) => a.name)).toEqual([
"HAX-Pixel-6",
"INFO_BuildBot_Pixel7",
"Pixel_7_API_34",
]);
});
it("filters out genuine noise lines with whitespace / pipe characters", async () => {
// Real emulator output on at least some installs prints a log-format header.
execFileMock.mockImplementation((cmd: string) => {
if (cmd === "emulator") {
return {
stdout: [
"INFO | Android emulator version 33.1.11.0",
"HAX is working and emulator runs in fast virt mode.",
"Pixel_7_API_34",
"Pixel_3a_API_29",
"",
].join("\n"),
stderr: "",
};
}
return { stdout: "", stderr: "" };
});
const avds = await listAvds();
expect(avds.map((a) => a.name)).toEqual(["Pixel_7_API_34", "Pixel_3a_API_29"]);
});
});
describe("checkSnapshotLoadable extraArgs (renderer-args parity)", () => {
/**
* The probe must receive the same renderer-affecting flags as the actual
* boot spawn, otherwise it resolves a different renderer than the boot
* does and falsely reports `Not loadable | Reason: different renderer
* configured` for a snapshot the boot would have happily loaded.
* `boot-device` defines a single `RENDERER_ARGS` constant and threads it
* through both call sites; these tests pin the argv shape end-to-end.
*/
it("inserts extraArgs between -avd <name> and -check-snapshot-loadable <snap>", async () => {
let observedArgs: readonly string[] | null = null;
execFileMock.mockImplementation((cmd: string, args: readonly string[]) => {
if (cmd === "emulator" && args.includes("-check-snapshot-loadable")) {
observedArgs = args;
return { stdout: "snapshot name default_boot\nLoadable\n", stderr: "" };
}
return { stdout: "", stderr: "" };
});
const result = await checkSnapshotLoadable("Pixel_7_API_34", "default_boot", {
extraArgs: ["-gpu", "auto"],
});
expect(result).toEqual({ loadable: true, reason: null });
expect(observedArgs).toEqual([
"-avd",
"Pixel_7_API_34",
"-gpu",
"auto",
"-check-snapshot-loadable",
"default_boot",
]);
});
it("omits extraArgs entirely when none are passed", async () => {
let observedArgs: readonly string[] | null = null;
execFileMock.mockImplementation((cmd: string, args: readonly string[]) => {
if (cmd === "emulator" && args.includes("-check-snapshot-loadable")) {
observedArgs = args;
return { stdout: "Loadable\n", stderr: "" };
}
return { stdout: "", stderr: "" };
});
await checkSnapshotLoadable("Pixel_7_API_34");
expect(observedArgs).toEqual([
"-avd",
"Pixel_7_API_34",
"-check-snapshot-loadable",
"default_boot",
]);
});
});