mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
11df817616
The slowest unit tests were waiting on real wall-clock timers, not doing
work. This makes them deterministic and fast with **no production
behavior change and no production test seams**.
## Speedup
**Suite-level**
| Scope | Before | After | Faster |
|---|---|---|---|
| tool-server — wall clock | 2.72s | ~1.3s | ~2× |
| tool-server — aggregate test time | 9.70s | 4.03s | 2.4× |
| argent-mcp — aggregate test time | 0.90s | 0.28s | 3.2× |
**Per file**
| File | Before | After | Faster |
|---|---|---|---|
| simulator-server-blueprint.test.ts | 2.55s | 0.05s | ~50× |
| boot-device-hardening.test.ts | 2.17s | 0.18s | 12× |
| boot-device-spawn-error.test.ts | 1.11s | 0.005s | ~200× |
| boot-device-hotboot.test.ts | 1.01s | 0.06s | 16× |
| fetch-timeout.test.ts | 0.81s | 0.18s | 4.5× |
| flow-tools.test.ts (delayMs test) | 0.31s | 0.04s | ~7× |
**Worst individual tests, before → after**
| Test | Before | After |
|---|---|---|
| rejects the boot promise…when spawn emits error | 1.06s | ~1ms |
| reports the emulator crash error… | 1.00s | ~2ms |
| falls back to cold boot when hot-boot child exits early | 1.00s | ~1ms
|
| reports a signal-terminated emulator (SIGSEGV)… | 1.00s | ~1ms |
| 5× simulator-server-blueprint tests | ~0.50s ea | ~0.001–0.02s ea |
| throws after retries when POST never responds | 0.60s | 0.14s |
| sleeps the step's delayMs | 0.31s | 0.04s |
No test exceeds ~0.7s anymore (the slowest,
`workspace-reader-integration`, is genuine subprocess I/O — `node
--version` etc. — left as-is on purpose; it is not a timeout).
## What changed
- **`boot-device.ts` (only production change):** the three hardcoded
poll cadences are extracted into a single immutable
`BOOT_POLL_INTERVALS_MS` constant (values unchanged: 1500/1000/500ms)
and referenced at the four `setTimeout` sites. Pure readability
extraction — **no mutable state, no exported test hooks, no runtime
behavior change.**
- **Boot-device tests:** the timing-sensitive tests now use vitest fake
timers (`vi.useFakeTimers()` + `vi.advanceTimersByTimeAsync`).
`boot-device-hardening` / `-spawn-error` add the same partial
`vi.mock("../src/utils/adb")` already used by
`boot-device-hotboot.test.ts`, so pre-flight does no real filesystem I/O
and the timers drive deterministically.
- **`simulator-server-blueprint.test.ts`:** `signalReady` now emits
`stream_ready` then `api_ready` (matching the real streaming server's
ordering) so the blueprint resolves immediately instead of waiting out
the 500ms `STREAM_GRACE_MS` fallback. Adds a dedicated fake-timer test
that explicitly covers the non-streaming grace path (coverage gained,
not lost).
- **`fetch-timeout.test.ts` / `flow-tools.test.ts`:** smaller timing
knobs; same behaviors asserted.
## No regressions
- 1011/1011 tests pass (tool-server 656, argent-mcp 53, argent 6,
registry 64, tools-client 1, installer 231); `tsc --build` and Prettier
clean. The converted boot-device, simulator-server-blueprint, and
flow-tools tests are deterministic under fake timers (verified across
stress runs).
- No production behavior change; `simulator-server.ts` is byte-identical
to `main`; net +1 test.
- Heads-up: `argent-mcp/test/fetch-timeout.test.ts` has a
**pre-existing** macOS flake (~10–20% rate; `connect EADDRNOTAVAIL
127.0.0.1:<port> - Local (0.0.0.0:0)` — kernel source-port exhaustion
under rapid-fire loopback fetches). Stress-testing showed it on `main`'s
pristine file too (2/10 failures vs 1/10 on this branch), so this PR
does not introduce or worsen it. Fixing it (e.g., EADDRNOTAVAIL retry,
or moving the test off real loopback fetch) is worth a follow-up.
377 lines
16 KiB
TypeScript
377 lines
16 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { EventEmitter } from "node:events";
|
|
import type { Registry } from "@argent/registry";
|
|
|
|
const execFileMock = vi.fn();
|
|
const spawnMock = 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),
|
|
};
|
|
});
|
|
|
|
// `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: () => {},
|
|
}));
|
|
|
|
// Stub the snapshot probes so pre-flight does no real ~/.android filesystem
|
|
// I/O. That keeps the whole boot path on microtasks + faked timers (vitest
|
|
// fake timers can't deterministically drive real libuv fs callbacks).
|
|
// `false` forces the cold path — behaviour-identical to the real probe for a
|
|
// fake AVD with no default_boot snapshot. (Same partial-mock shape as
|
|
// boot-device-hotboot.test.ts.)
|
|
vi.mock("../src/utils/adb", async () => {
|
|
const actual = await vi.importActual<typeof import("../src/utils/adb")>("../src/utils/adb");
|
|
return {
|
|
...actual,
|
|
hasDefaultBootSnapshot: async () => false,
|
|
checkSnapshotLoadable: async () => ({ loadable: false, reason: "test" }),
|
|
};
|
|
});
|
|
|
|
import {
|
|
__resetInFlightBootsForTesting,
|
|
createBootDeviceTool,
|
|
} from "../src/tools/devices/boot-device";
|
|
|
|
const registry: Registry = { resolveService: async () => ({}) } as unknown as Registry;
|
|
|
|
beforeEach(() => {
|
|
execFileMock.mockReset();
|
|
spawnMock.mockReset();
|
|
// Tests in this file intentionally abandon some bootAndroid promises
|
|
// (kick them off, attach .catch, move on). Without this reset the in-flight
|
|
// coalescing map would hand the leaked promise to the next test that boots
|
|
// the same AVD, causing cascade timeouts.
|
|
__resetInFlightBootsForTesting();
|
|
// Default: every spawned emulator process is a well-behaved child that
|
|
// never exits on its own. Individual tests override as needed.
|
|
spawnMock.mockImplementation(() => {
|
|
const proc = new EventEmitter() as EventEmitter & { unref: () => void };
|
|
proc.unref = () => {};
|
|
return proc;
|
|
});
|
|
});
|
|
|
|
describe("boot-device Android — adb pre-flight check (review #11)", () => {
|
|
it("fails before spawning the emulator when adb is unavailable", async () => {
|
|
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 new Error("adb: command not found");
|
|
}
|
|
return { stdout: "", stderr: "" };
|
|
});
|
|
|
|
const tool = createBootDeviceTool(registry);
|
|
await expect(tool.execute!({}, { avdName: "Pixel_7_API_34" })).rejects.toThrow(
|
|
/`adb` is not available on PATH/
|
|
);
|
|
// The emulator binary must NOT have been spawned — otherwise we orphan
|
|
// a detached process that the user has to kill manually.
|
|
expect(spawnMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("boot-device Android — serialsBefore snapshot ordering (review #2)", () => {
|
|
/**
|
|
* If the adb daemon is down when bootAndroid starts, snapshotting the
|
|
* device list *before* `adb start-server` makes `listAndroidSerials`
|
|
* return []. Then once the daemon comes up, every already-connected
|
|
* emulator looks "new" and the tool could hand back a pre-existing
|
|
* emulator as the one the caller just booted.
|
|
*
|
|
* Fix: `adb start-server` runs BEFORE the snapshot. We verify by
|
|
* checking that when listAndroidDevices returns a pre-existing emulator,
|
|
* the tool keeps waiting for a genuinely new one.
|
|
*/
|
|
|
|
it("does not adopt a pre-existing emulator as the one we just booted", async () => {
|
|
// Sequence: adb version OK, then we spawn emulator, then `adb devices`
|
|
// returns the SAME pre-existing emulator for the full adb-register budget.
|
|
// The tool must time out and never return the stale serial as booted.
|
|
const preExisting = "emulator-5554";
|
|
const callLog: string[] = [];
|
|
execFileMock.mockImplementation((cmd: string, args: string[]) => {
|
|
const key = `${cmd} ${args.join(" ")}`;
|
|
callLog.push(key);
|
|
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") {
|
|
return { stdout: `List of devices attached\n${preExisting}\tdevice\n`, stderr: "" };
|
|
}
|
|
// Enrichment getprops — return anything so snapshotting can enrich.
|
|
if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") {
|
|
return { stdout: "\n", stderr: "" };
|
|
}
|
|
return { stdout: "", stderr: "" };
|
|
});
|
|
|
|
const tool = createBootDeviceTool(registry);
|
|
const promise = tool.execute!(
|
|
{},
|
|
{
|
|
avdName: "Pixel_7_API_34",
|
|
bootTimeoutMs: 30_000, // hits min bound; the real wait is capped by adb-register budget
|
|
}
|
|
);
|
|
|
|
// Verify critical ordering: start-server runs BEFORE the first `adb devices` call.
|
|
// If snapshot happened first, we'd see `adb devices` before `adb start-server`.
|
|
// We race this assertion against the promise (which is slow) — use a short delay.
|
|
await new Promise((r) => setTimeout(r, 150));
|
|
const startServerIdx = callLog.indexOf("adb start-server");
|
|
const firstDevicesIdx = callLog.indexOf("adb devices");
|
|
expect(startServerIdx).toBeGreaterThanOrEqual(0);
|
|
expect(firstDevicesIdx).toBeGreaterThan(startServerIdx);
|
|
|
|
// Clean up — reject the outstanding promise deterministically. The tool
|
|
// will eventually throw its own register-timeout but we don't want to
|
|
// wait the full budget. Swallow whatever it throws.
|
|
promise.catch(() => {});
|
|
}, 5_000);
|
|
});
|
|
|
|
describe("boot-device Android — earlyExitError surfaces promptly (review #4)", () => {
|
|
it("reports the emulator crash error instead of an adb wait-for-device timeout", async () => {
|
|
// Simulate: emulator spawns, registers in adb, then crashes. Stage 3
|
|
// (wait-for-device) would previously block for the full 180s budget
|
|
// and throw a generic timeout. The fix surfaces earlyExitError instead.
|
|
//
|
|
// Fake timers: the impl parks the stage-2 adb-register poll on a real
|
|
// 1s setTimeout (the pre-existing serial is in the before-snapshot, so
|
|
// no "new" serial appears). Driving it with fake timers keeps behaviour
|
|
// identical but collapses ~1s of real wall time. adb is module-mocked so
|
|
// pre-flight is microtasks only — fully deterministic.
|
|
vi.useFakeTimers();
|
|
try {
|
|
const serial = "emulator-5554";
|
|
const proc = new EventEmitter() as EventEmitter & { unref: () => void };
|
|
proc.unref = () => {};
|
|
spawnMock.mockReturnValue(proc);
|
|
|
|
execFileMock.mockImplementation((cmd: string, args: string[]) => {
|
|
if (cmd === "emulator") return { stdout: "Pixel_7_API_34\n", stderr: "" };
|
|
if (cmd === "adb" && args[0] === "version") return { stdout: "adb ok\n", stderr: "" };
|
|
if (cmd === "adb" && args[0] === "start-server") return { stdout: "", stderr: "" };
|
|
if (cmd === "adb" && args[0] === "devices") {
|
|
return { stdout: `List of devices attached\n${serial}\tdevice\n`, stderr: "" };
|
|
}
|
|
if (cmd === "adb" && args.includes("wait-for-device")) {
|
|
// Simulate a slow adb that will never return; the race must win.
|
|
return new Promise(() => {}) as unknown as { stdout: string; stderr: string };
|
|
}
|
|
if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") {
|
|
return { stdout: "\n", stderr: "" };
|
|
}
|
|
if (cmd === "adb" && args[0] === "-s" && args.includes("emu") && args.includes("kill")) {
|
|
return { stdout: "OK\n", stderr: "" };
|
|
}
|
|
return { stdout: "", stderr: "" };
|
|
});
|
|
|
|
const tool = createBootDeviceTool(registry);
|
|
const promise = tool.execute!({}, { avdName: "Pixel_7_API_34", bootTimeoutMs: 30_000 });
|
|
promise.catch(() => {}); // detach so the rejection can't escape between advances
|
|
|
|
// Reach the parked stage-2 poll, then crash the emulator.
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
proc.emit("exit", 1);
|
|
// Wake the poll (>1s, the production cadence) so it throws the crash
|
|
// error instead of hanging.
|
|
await vi.advanceTimersByTimeAsync(2_000);
|
|
|
|
await expect(promise).rejects.toThrow(/emulator binary exited with code 1/);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
}, 10_000);
|
|
|
|
it("coalesces concurrent boot calls for the same AVD onto a single spawn", async () => {
|
|
// Two callers race in for the same AVD before either emulator registers.
|
|
// Without the in-flight coalescing both would spawn QEMU; the second
|
|
// collides on the AVD lock and bails after the deadline. Verify that
|
|
// exactly one spawn fires and both callers see the same result.
|
|
const serial = "emulator-5554";
|
|
let devicesPolls = 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: "ok\n", stderr: "" };
|
|
if (cmd === "adb" && args[0] === "start-server") return { stdout: "", stderr: "" };
|
|
if (cmd === "adb" && args[0] === "devices") {
|
|
devicesPolls++;
|
|
// First poll: empty. Subsequent polls: emulator visible.
|
|
if (devicesPolls <= 1) return { stdout: "List of devices attached\n", stderr: "" };
|
|
return { stdout: `List of devices attached\n${serial}\tdevice\n`, stderr: "" };
|
|
}
|
|
if (cmd === "adb" && args.includes("wait-for-device")) {
|
|
return { stdout: "", stderr: "" };
|
|
}
|
|
if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") {
|
|
return { stdout: "1\n", stderr: "" };
|
|
}
|
|
return { stdout: "", stderr: "" };
|
|
});
|
|
|
|
const tool = createBootDeviceTool(registry);
|
|
const [a, b] = await Promise.all([
|
|
tool.execute!({}, { avdName: "Pixel_7_API_34", bootTimeoutMs: 30_000 }),
|
|
tool.execute!({}, { avdName: "Pixel_7_API_34", bootTimeoutMs: 30_000 }),
|
|
]);
|
|
|
|
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
expect(a).toEqual(b);
|
|
}, 15_000);
|
|
|
|
it("reports a signal-terminated emulator (e.g. QEMU SIGSEGV) as a crash, not a hang", async () => {
|
|
// Regression for the "sometimes silently crashes" complaint: when QEMU
|
|
// segfaults on a bad ram.bin restore the child exits with `code === null,
|
|
// signal !== null`. The previous `code !== null` guard treated this as a
|
|
// normal exit so the outer wait blocked for the full per-stage budget.
|
|
// Fake timers: same rationale as the sibling crash test above.
|
|
vi.useFakeTimers();
|
|
try {
|
|
const serial = "emulator-5554";
|
|
const proc = new EventEmitter() as EventEmitter & { unref: () => void };
|
|
proc.unref = () => {};
|
|
spawnMock.mockReturnValue(proc);
|
|
|
|
execFileMock.mockImplementation((cmd: string, args: string[]) => {
|
|
if (cmd === "emulator") return { stdout: "Pixel_7_API_34\n", stderr: "" };
|
|
if (cmd === "adb" && args[0] === "version") return { stdout: "adb ok\n", stderr: "" };
|
|
if (cmd === "adb" && args[0] === "start-server") return { stdout: "", stderr: "" };
|
|
if (cmd === "adb" && args[0] === "devices") {
|
|
return { stdout: `List of devices attached\n${serial}\tdevice\n`, stderr: "" };
|
|
}
|
|
if (cmd === "adb" && args.includes("wait-for-device")) {
|
|
return new Promise(() => {}) as unknown as { stdout: string; stderr: string };
|
|
}
|
|
if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") {
|
|
return { stdout: "\n", stderr: "" };
|
|
}
|
|
if (cmd === "adb" && args[0] === "-s" && args.includes("emu") && args.includes("kill")) {
|
|
return { stdout: "OK\n", stderr: "" };
|
|
}
|
|
return { stdout: "", stderr: "" };
|
|
});
|
|
|
|
const tool = createBootDeviceTool(registry);
|
|
const promise = tool.execute!({}, { avdName: "Pixel_7_API_34", bootTimeoutMs: 30_000 });
|
|
promise.catch(() => {}); // detach so the rejection can't escape between advances
|
|
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
proc.emit("exit", null, "SIGSEGV");
|
|
await vi.advanceTimersByTimeAsync(2_000);
|
|
|
|
await expect(promise).rejects.toThrow(/terminated by signal SIGSEGV/);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
}, 10_000);
|
|
});
|
|
|
|
describe("boot-device Android — orphan protection on stage-2 timeout (review feedback R1#1)", () => {
|
|
/**
|
|
* Before this fix, spawn(..., {detached: true, stdio: "ignore"}) + unref()
|
|
* meant that if the adb-register stage timed out (emulator started but
|
|
* never appeared in `adb devices`), `killEmulatorQuietly(null)` was a
|
|
* no-op — the detached emulator kept running and the user had to find
|
|
* and kill the PID by hand. The fix retains the ChildProcess and signals
|
|
* SIGTERM (with SIGKILL escalation) on any throw before a serial is
|
|
* resolved.
|
|
*/
|
|
it("SIGTERMs the detached emulator child when no serial registers within the budget", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const proc = new EventEmitter() as EventEmitter & {
|
|
unref: () => void;
|
|
kill: (sig?: string) => boolean;
|
|
exitCode: number | null;
|
|
signalCode: string | null;
|
|
};
|
|
proc.unref = () => {};
|
|
proc.exitCode = null;
|
|
proc.signalCode = null;
|
|
const killSignals: (string | undefined)[] = [];
|
|
proc.kill = (sig?: string) => {
|
|
killSignals.push(sig);
|
|
return true;
|
|
};
|
|
spawnMock.mockReturnValue(proc);
|
|
|
|
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: "" };
|
|
// `adb devices` always returns empty — no emulator ever registers,
|
|
// forcing the adb-register stage to exhaust its budget.
|
|
if (cmd === "adb" && args[0] === "devices") {
|
|
return { stdout: "List of devices attached\n", stderr: "" };
|
|
}
|
|
return { stdout: "", stderr: "" };
|
|
});
|
|
|
|
const tool = createBootDeviceTool(registry);
|
|
// bootTimeoutMs floor is 30_000 (zod). Burn that in fake time so the
|
|
// test completes in milliseconds of real time.
|
|
const promise = tool.execute!({}, { avdName: "Pixel_7_API_34", bootTimeoutMs: 30_000 });
|
|
promise.catch(() => {});
|
|
await vi.advanceTimersByTimeAsync(31_000);
|
|
|
|
await expect(promise).rejects.toThrow(/did not register within/);
|
|
// The detached child MUST have been signalled — SIGTERM fire-and-forget.
|
|
expect(killSignals[0]).toBe("SIGTERM");
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
}, 5_000);
|
|
});
|
|
|
|
describe("boot-device Android — missing AVD (existing guard)", () => {
|
|
it("throws a useful error when the requested avdName is not installed", async () => {
|
|
execFileMock.mockImplementation((cmd: string, args: string[]) => {
|
|
if (cmd === "emulator" && args[0] === "-list-avds") {
|
|
return { stdout: "Pixel_3a_API_29\nPixel_7_API_34\n", stderr: "" };
|
|
}
|
|
return { stdout: "", stderr: "" };
|
|
});
|
|
|
|
const tool = createBootDeviceTool(registry);
|
|
await expect(tool.execute!({}, { avdName: "Does_Not_Exist" })).rejects.toThrow(
|
|
/AVD "Does_Not_Exist" not found.*Pixel_3a_API_29.*Pixel_7_API_34/
|
|
);
|
|
});
|
|
});
|