Files
Ignacy Łątka 11df817616 test: drop real-time timeouts from the slowest unit tests (#236)
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.
2026-05-27 16:24:07 +02:00

120 lines
4.1 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.
// Tests for the per-request fetch timeout in fetchWithReconnect.
// Before this fix, a hanging POST /tools/:name wedged the MCP parent
// indefinitely. Now each fetch attempt uses an AbortController with a
// configurable timeout (ARGENT_FETCH_TIMEOUT_MS, default 30s).
//
// This test uses an in-process replica of fetchWithReconnect rather than
// spawning the built CLI binary, so it doesn't require `npm run build`.
import { describe, it, expect } from "vitest";
import http from "node:http";
import { fetchWithReconnect } from "../src/mcp-server.js";
async function mockSuccessfulReconnect() {
return new Promise<void>((res) => res());
}
function startHangingServer(): Promise<{ port: number; close: () => Promise<void> }> {
return new Promise((resolve) => {
const server = http.createServer((req, res) => {
if (req.method === "GET" && req.url === "/tools") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ tools: [] }));
return;
}
// POST: accept the body then never respond.
req.on("data", () => {});
req.on("end", () => {
/* intentionally hang */
});
});
server.listen(0, "127.0.0.1", () => {
const addr = server.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
resolve({
port,
close: () =>
new Promise((r) => {
server.closeAllConnections?.();
server.close(() => r());
}),
});
});
});
}
describe("fetch timeout in fetchWithReconnect", () => {
it("throws after retries when POST never responds (does not hang)", async () => {
const fake = await startHangingServer();
const url = `http://127.0.0.1:${fake.port}`;
// GET /tools should succeed immediately.
const listRes = await fetchWithReconnect(() => `${url}/tools`, mockSuccessfulReconnect);
expect(listRes.ok).toBe(true);
// POST /tools/hang should time out and eventually throw.
let threw = false;
const t0 = Date.now();
try {
await fetchWithReconnect(() => `${url}/tools/hang`, mockSuccessfulReconnect, {
init: { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" },
expBackoffBase: 1,
fetchTimeoutMs: 20,
});
} catch {
threw = true;
}
const elapsed = Date.now() - t0;
await fake.close();
expect(threw).toBe(true);
// Same behavior as production (5 attempts × per-attempt abort + backoff),
// just with tiny knobs: 5 × 20ms + ~15ms backoff ≈ 115ms. The point is
// that it terminates fast — well under 5s (the old infinite-hang bug).
expect(elapsed).toBeLessThan(5_000);
}, 10_000);
it("succeeds immediately when the server responds within the timeout", async () => {
const server = http.createServer((_req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const addr = server.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
const res = await fetchWithReconnect(
() => `http://127.0.0.1:${port}/test`,
mockSuccessfulReconnect
);
expect(res.ok).toBe(true);
server.close();
});
it("disables the per-attempt timeout when fetchTimeoutMs is null", async () => {
const server = http.createServer((_req, res) => {
// A small server-side delay is enough to prove the request is NOT
// aborted when the per-attempt timeout is disabled.
setTimeout(() => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
}, 30);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const addr = server.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
const res = await fetchWithReconnect(
() => `http://127.0.0.1:${port}/slow`,
mockSuccessfulReconnect,
{ fetchTimeoutMs: null }
);
expect(res.ok).toBe(true);
server.close();
});
});