Files
software-mansion__argent/packages/tool-server/test/debugger/status-source-map-await.test.ts
Ignacy Łątka c0a63875df test(debugger): pin the source-map wait debugger-status promises (#706)
Found while reviewing #610 and set aside as out of scope: the code and
the prose it comes from are byte-identical at that PR's merge base, and
the same mutant survives there too.

## The gap

`debugger-status` returns `sourceMapReady: true` as a **hardcoded
literal** (`debugger-status.ts:57`). The only thing that makes the
literal true is the line above it:

```ts
await api.sourceMaps.waitForPending();
```

and the description sells exactly that: *"sourceMapReady (always true —
**waits for pending source maps before returning**; no-op on
Chromium)"*.

Nothing pins it. Deleting the await:

| | existing suite (`test/debugger test/metro test/flows test/utils`) |
|---|---|
| unmutated | 856 passed |
| await deleted | **856 passed** |

So a status that reports readiness it never established would ship
green.

## Scope, stated honestly

This is a test-only change; the shipped behaviour is correct. The impact
of a regression is narrow but real: `js-runtime-debugger.ts:214` already
drains the registry once while the service is being created, so the
await in the tool is what covers maps registered **after** that point -
a Fast Refresh, or a lazily loaded bundle chunk. In that window a
non-awaiting status would return `sourceMapReady: true` while
`SourceMapsRegistry.pendingRegistrations` is non-empty, and a
`debugger-inspect-element` issued straight after can miss the file:line
it would otherwise resolve.

I did not manufacture a user-visible failure end to end, and I am not
claiming one - the finding is that the single promised behaviour of this
field has no test.

## The tests

Both assert the **ordering**, not the literal - a test that only checked
`sourceMapReady === true` would pass on the mutant, since the literal is
unconditional.

1. `does not resolve until the pending source-map registration settles`
- hands the tool a `waitForPending` that returns a deferred promise,
drains several macrotask turns, asserts the tool has not returned, then
releases it.
2. `reports sourceMapReady only after the wait` - records
`["maps-settled", "status-returned"]` and asserts that exact order.

| | new tests |
|---|---|
| on `main` as shipped | 2 passed |
| with the await deleted | **2 failed** |

eslint `--max-warnings 0`, `tsc --noEmit -p tsconfig.test.json` and
prettier all clean.
2026-08-20 15:24:28 +02:00

113 lines
3.8 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import type { Registry } from "@argent/registry";
import type { JsRuntimeDebuggerApi } from "../../src/blueprints/js-runtime-debugger";
import { createDebuggerStatusTool } from "../../src/tools/debugger/debugger-status";
// The tool resolves its api through the registry, so the fake is handed over
// there rather than injected as a service.
const resolved = vi.hoisted(() => ({ api: undefined as JsRuntimeDebuggerApi | undefined }));
vi.mock("../../src/tools/debugger/not-connected", async (importOriginal) => ({
...(await importOriginal<typeof import("../../src/tools/debugger/not-connected")>()),
resolveDebuggerService: () => Promise.resolve(resolved.api),
}));
/**
* `debugger-status` promises in its own description that `sourceMapReady` is
* "always true — waits for pending source maps before returning". The wait is the
* whole content of that promise: `sourceMapReady` is a hardcoded literal, so a
* status that returns *before* pending maps settle reports readiness it never
* established. These tests pin the ordering, not the literal.
*/
interface Deferred {
promise: Promise<void>;
resolve: () => void;
settled: boolean;
}
function deferred(): Deferred {
const d = { settled: false } as Deferred;
d.promise = new Promise<void>((res) => {
d.resolve = () => {
d.settled = true;
res();
};
});
return d;
}
function fakeApi(waitForPending: () => Promise<void>): JsRuntimeDebuggerApi {
return {
port: 8081,
projectRoot: "/mock/project",
deviceName: "MockDevice",
appName: "MockApp",
logicalDeviceId: "logical-1",
isNewDebugger: true,
cdp: {
isConnected: () => true,
getLoadedScripts: () => new Map([["1", {}]]),
getEnabledDomains: () => new Set(["Runtime", "Debugger"]),
},
sourceMaps: { waitForPending },
} as unknown as JsRuntimeDebuggerApi;
}
const PARAMS = { port: 8081, device_id: "mock-device" };
type StatusResult = Awaited<ReturnType<typeof statusTool.execute>>;
function assertConnected(
result: StatusResult
): asserts result is Extract<StatusResult, { status: "connected" }> {
expect(result.status).toBe("connected");
}
// Only the stale-connection branch reaches the registry, and `isConnected` is
// true throughout, so no member of it is ever read here.
const statusTool = createDebuggerStatusTool({} as Registry);
describe("debugger-status waits for pending source maps", () => {
it("does not resolve until the pending source-map registration settles", async () => {
const pending = deferred();
resolved.api = fakeApi(() => pending.promise);
let returned = false;
const call = statusTool.execute({}, PARAMS, undefined).then((r) => {
returned = true;
return r;
});
// Ample opportunity for a non-awaiting implementation to have returned:
// several macrotask turns, far more than the microtask drain a bare
// `return {...}` (or an `await Promise.resolve()`) would need.
for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 10));
expect(pending.settled).toBe(false);
expect(returned).toBe(false);
pending.resolve();
const result = await call;
expect(pending.settled).toBe(true);
expect(returned).toBe(true);
assertConnected(result);
expect(result.sourceMapReady).toBe(true);
});
it("reports sourceMapReady only after the wait, so the flag is never ahead of the registry", async () => {
const order: string[] = [];
resolved.api = fakeApi(async () => {
await new Promise((r) => setTimeout(r, 20));
order.push("maps-settled");
});
const result = await statusTool.execute({}, PARAMS, undefined);
order.push("status-returned");
expect(order).toEqual(["maps-settled", "status-returned"]);
assertConnected(result);
expect(result.sourceMapReady).toBe(true);
});
});