mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
d4b9818d64
Fixes two confirmed defects in the JS-runtime debugger subsystem.
## Defect A — `debugger-component-tree` hangs ~15s and throws on every
error condition
**File:**
`packages/tool-server/src/utils/debugger/scripts/component-tree.ts`
The injected fiber-walk script delivers its success result through the
`__argent_callback` CDP binding, but every error path instead did
`return JSON.stringify({ error: ... })` from the async IIFE — `'No
DevTools hook'`, `'No fiber roots'`, `'Could not find UIManager'`.
The consumer (`evaluateWithBinding`, `cdp-client.ts`) runs
`Runtime.evaluate` with `returnByValue:false`/`awaitPromise:false`, so
the IIFE's return value is discarded. The promise only settles when the
binding fires or the 15s timeout elapses. So on a release/production
build, before the first render, or on a non-RN runtime,
`debugger-component-tree` blocked for ~15s and then threw a generic
`DEBUGGER_CDP_BINDING_TIMEOUT` instead of returning the intended
diagnostic immediately.
**Observed:** callback never invoked (0 calls) on the error paths; the
tool hangs to the binding timeout.
**Expected:** the error is delivered through the binding right away and
surfaces as `Error: <message>` via the consumer's
`JSON.parse(response.result).error` path.
**Fix:** mirror the sibling `inspect-at-point.ts`. Added an
`__argent_fail(msg)` helper that routes the error through
`__argent_callback` in the exact same wire shape as the success path (`{
requestId, result: JSON.stringify({ error: msg }) }`), and converted all
three error paths to call it and `return`.
## Defect B — `LogFileWriter` truncates console levels longer than 5
chars
**File:** `packages/tool-server/src/utils/debugger/log-file-writer.ts`
The flat-line level was rendered as `LEVEL_DISPLAY[entry.level] ??
entry.level.toUpperCase().padEnd(5).slice(0, 5)`. The `.slice(0,5)`
truncated any level not in the 5-entry `LEVEL_DISPLAY` map. CDP's
`Runtime.consoleAPICalled.type` includes `"warning"` (`console.warn`)
and `"assert"` (`console.assert`) — neither is in the map — so
`readAll()`/`readFiltered()` returned `"warni"`/`"asser"`, and
`readFiltered({ level: "warning" })` matched nothing.
**Observed:** writing level `"warning"` → `readAll()[0].level ===
"warni"`; `readFiltered({ level: "assert" }).total === 0`.
**Expected:** the canonical level round-trips exactly for levels of any
length.
**Fix:** drop `.slice(0,5)` — keep `padEnd(5)` for column alignment but
never cut. `LINE_RE` captures the level as `\S+`, so a non-truncated,
whitespace-free level round-trips through `parseFlatLine` unchanged.
Short levels (log/info/warn/error/debug) are unaffected.
## Tests
- `packages/tool-server/test/debugger/comptree-error-channel.test.ts` —
asserts `__argent_callback` is invoked on the error paths and the error
is encoded as `parsed.error`.
- `packages/tool-server/test/debugger/log-file-writer-level.test.ts` —
asserts `"warning"`/`"assert"` round-trip and filter correctly, and
short levels still work.
Both new files fail before the fix and pass after. Full `tool-server`
suite green (1627 tests); repo-root `tsc --build` clean; eslint clean on
changed files.
241 lines
9.9 KiB
TypeScript
241 lines
9.9 KiB
TypeScript
import { afterEach, describe, expect, it } from "vitest";
|
|
import { makeComponentTreeScript } from "../../src/utils/debugger/scripts/component-tree";
|
|
|
|
/**
|
|
* Regression for the component-tree error channel: every error path must deliver
|
|
* its result through the `__argent_callback` binding, exactly like the success
|
|
* path. The consumer (`evaluateWithBinding`) runs Runtime.evaluate WITHOUT
|
|
* returnByValue/awaitPromise, so the IIFE's return value is discarded — an error
|
|
* that is merely `return`ed never settles the promise and the tool hangs until
|
|
* the 15s binding timeout. This test asserts the binding IS invoked on error.
|
|
*/
|
|
function run(opts: { hook: unknown }): string[] {
|
|
const g = globalThis as Record<string, unknown>;
|
|
const saved = {
|
|
window: g.window,
|
|
hook: g.__REACT_DEVTOOLS_GLOBAL_HOOK__,
|
|
cb: g.__argent_callback,
|
|
r: g.__r,
|
|
};
|
|
const payloads: string[] = [];
|
|
g.window = g;
|
|
g.__REACT_DEVTOOLS_GLOBAL_HOOK__ = opts.hook;
|
|
g.__r = function () {};
|
|
g.__argent_callback = (p: string) => {
|
|
payloads.push(p);
|
|
};
|
|
try {
|
|
void (0, eval)(makeComponentTreeScript({ requestId: "t" }));
|
|
return payloads;
|
|
} finally {
|
|
g.window = saved.window;
|
|
g.__REACT_DEVTOOLS_GLOBAL_HOOK__ = saved.hook;
|
|
g.__argent_callback = saved.cb;
|
|
g.__r = saved.r;
|
|
}
|
|
}
|
|
|
|
// The SUCCESS path fires __argent_callback ASYNCHRONOUSLY (after `await
|
|
// Promise.all(...)` on the host measurements), so the binding must stay installed
|
|
// across the await — the synchronous run() helper restores it in its finally
|
|
// before the callback lands and would drop it. Also injects a real __r so the
|
|
// Paper branch can resolve UIManager + Dimensions.
|
|
async function runToSuccess(opts: { hook: unknown; r: unknown }): Promise<string[]> {
|
|
const g = globalThis as Record<string, unknown>;
|
|
const saved = {
|
|
window: g.window,
|
|
hook: g.__REACT_DEVTOOLS_GLOBAL_HOOK__,
|
|
cb: g.__argent_callback,
|
|
r: g.__r,
|
|
};
|
|
const payloads: string[] = [];
|
|
g.window = g;
|
|
g.__REACT_DEVTOOLS_GLOBAL_HOOK__ = opts.hook;
|
|
g.__r = opts.r;
|
|
const landed = new Promise<void>((resolve) => {
|
|
g.__argent_callback = (p: string) => {
|
|
payloads.push(p);
|
|
resolve();
|
|
};
|
|
});
|
|
try {
|
|
void (0, eval)(makeComponentTreeScript({ requestId: "t" }));
|
|
await Promise.race([landed, new Promise((r) => setTimeout(r, 2000))]);
|
|
return payloads;
|
|
} finally {
|
|
g.window = saved.window;
|
|
g.__REACT_DEVTOOLS_GLOBAL_HOOK__ = saved.hook;
|
|
g.__argent_callback = saved.cb;
|
|
g.__r = saved.r;
|
|
}
|
|
}
|
|
|
|
afterEach(() => {
|
|
const g = globalThis as Record<string, unknown>;
|
|
delete g.window;
|
|
delete g.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
delete g.__argent_callback;
|
|
delete g.__r;
|
|
});
|
|
|
|
describe("component-tree delivers errors via the binding channel", () => {
|
|
it("invokes __argent_callback on the 'No DevTools hook' error path", async () => {
|
|
const payloads = run({ hook: undefined });
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
expect(payloads.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("encodes the error so the tool surfaces it as parsed.error", async () => {
|
|
const payloads = run({ hook: undefined });
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
const outer = JSON.parse(payloads[0]!) as { requestId: string; result: string };
|
|
expect(outer.requestId).toBe("t");
|
|
const inner = JSON.parse(outer.result) as { error?: string };
|
|
expect(inner.error).toBe("No DevTools hook");
|
|
});
|
|
|
|
it("invokes __argent_callback on the 'No fiber roots' error path", async () => {
|
|
// Hook present but every renderer yields zero roots → 'No fiber roots'.
|
|
const hook = { renderers: new Map(), getFiberRoots: () => new Set() };
|
|
const payloads = run({ hook });
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
expect(payloads.length).toBeGreaterThan(0);
|
|
const inner = JSON.parse((JSON.parse(payloads[0]!) as { result: string }).result) as {
|
|
error?: string;
|
|
};
|
|
expect(inner.error).toBe("No fiber roots");
|
|
});
|
|
|
|
it("invokes __argent_callback on the 'Could not find UIManager' error path", async () => {
|
|
// Fiber roots present (passes the 'No fiber roots' guard), but the stub __r
|
|
// (function(){} → undefined) never yields a module with .UIManager and Fabric
|
|
// is absent → 'Could not find UIManager'. Fires synchronously, before the
|
|
// measurement await, so the synchronous run() helper captures it.
|
|
const hook = {
|
|
renderers: new Map([[1, {}]]),
|
|
getFiberRoots: () => new Set([{ current: {} }]),
|
|
};
|
|
const payloads = run({ hook });
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
expect(payloads.length).toBeGreaterThan(0);
|
|
const inner = JSON.parse((JSON.parse(payloads[0]!) as { result: string }).result) as {
|
|
error?: string;
|
|
};
|
|
expect(inner.error).toBe("Could not find UIManager");
|
|
});
|
|
|
|
it("delivers the SUCCESS result through the binding (parsed.error is undefined)", async () => {
|
|
// Guards against a regression where the top-level try/catch redirects or
|
|
// suppresses the happy-path callback. Minimal Paper fiber tree: one named
|
|
// component whose host child carries a nativeTag that measureInWindow resolves.
|
|
const hostFiber = {
|
|
type: "RCTView",
|
|
stateNode: { _nativeTag: 42 },
|
|
child: null,
|
|
sibling: null,
|
|
};
|
|
const buttonFiber = {
|
|
type: { displayName: "MyButton" },
|
|
memoizedProps: { testID: "btn" },
|
|
child: hostFiber,
|
|
sibling: null,
|
|
};
|
|
const root = { current: { child: buttonFiber, sibling: null } };
|
|
const hook = { renderers: new Map([[1, {}]]), getFiberRoots: () => new Set([root]) };
|
|
// __r(0) exposes UIManager (measure) + Dimensions (screen size); Fabric absent → Paper path.
|
|
const moduleObj = {
|
|
UIManager: {
|
|
measureInWindow: (_tag: number, cb: (x: number, y: number, w: number, h: number) => void) =>
|
|
cb(10, 20, 100, 50),
|
|
},
|
|
Dimensions: { get: () => ({ width: 390, height: 844 }) },
|
|
};
|
|
const r = (i: number) => (i === 0 ? moduleObj : undefined);
|
|
|
|
const payloads = await runToSuccess({ hook, r });
|
|
expect(payloads.length).toBeGreaterThan(0);
|
|
const outer = JSON.parse(payloads[0]!) as { requestId: string; result: string };
|
|
expect(outer.requestId).toBe("t");
|
|
const inner = JSON.parse(outer.result) as {
|
|
error?: string;
|
|
screenW: number;
|
|
screenH: number;
|
|
components: Array<{ name: string; testID?: string; rect: unknown }>;
|
|
};
|
|
expect(inner.error).toBeUndefined();
|
|
expect(inner.screenW).toBe(390);
|
|
expect(inner.screenH).toBe(844);
|
|
expect(inner.components.length).toBeGreaterThan(0);
|
|
expect(inner.components[0]!.name).toBe("MyButton");
|
|
expect(inner.components[0]!.testID).toBe("btn");
|
|
expect(inner.components[0]!.rect).toEqual({ x: 10, y: 20, w: 100, h: 50 });
|
|
});
|
|
|
|
it("routes an UNEXPECTED throw (outside the named guards) through the binding", async () => {
|
|
// getFiberRoots throws on the legacy-roots path, which runs outside any inner
|
|
// try/catch. Without the top-level try/catch the async IIFE would reject and
|
|
// __argent_callback would never fire — the tool would hang to the 15s binding
|
|
// timeout instead of surfacing the crash.
|
|
const hook = {
|
|
getFiberRoots: () => {
|
|
throw new Error("boom");
|
|
},
|
|
};
|
|
const payloads = run({ hook });
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
expect(payloads.length).toBeGreaterThan(0);
|
|
const inner = JSON.parse((JSON.parse(payloads[0]!) as { result: string }).result) as {
|
|
error?: string;
|
|
};
|
|
expect(inner.error).toContain("Component-tree script crashed");
|
|
expect(inner.error).toContain("boom");
|
|
});
|
|
|
|
it("delivers a crash that happens AFTER the measurement await through the still-installed binding", async () => {
|
|
// Distinct from the synchronous crash above (getFiberRoots throws before any
|
|
// await): here every guard passes and the throw happens in the post-await
|
|
// continuation, after `await Promise.all(...)` on the host measurements. That
|
|
// is the path closest to the production hang this change fixes — the failure
|
|
// has to travel through the binding that is still installed across the await,
|
|
// not be `return`ed. The fiber/module setup is the known-good success case,
|
|
// except the kept component carries a BigInt accessibilityLabel (read without
|
|
// a typeof guard), which the post-await `JSON.stringify(result)` cannot
|
|
// serialize, forcing a genuine async throw into the top-level catch.
|
|
//
|
|
// Mutation-checked: ending the try before the await, or dropping the async
|
|
// delivery, leaves the async IIFE to reject with the binding never firing —
|
|
// `runToSuccess` then times out with zero payloads and this test fails, while
|
|
// the synchronous crash test above (captured by the sync `run()` helper) stays
|
|
// green. That is exactly the ~15s binding-timeout regression this guards.
|
|
const hostFiber = {
|
|
type: "RCTView",
|
|
stateNode: { _nativeTag: 42 },
|
|
child: null,
|
|
sibling: null,
|
|
};
|
|
const buttonFiber = {
|
|
type: { displayName: "MyButton" },
|
|
memoizedProps: { testID: "btn", accessibilityLabel: BigInt(1) },
|
|
child: hostFiber,
|
|
sibling: null,
|
|
};
|
|
const root = { current: { child: buttonFiber, sibling: null } };
|
|
const hook = { renderers: new Map([[1, {}]]), getFiberRoots: () => new Set([root]) };
|
|
const moduleObj = {
|
|
UIManager: {
|
|
measureInWindow: (_tag: number, cb: (x: number, y: number, w: number, h: number) => void) =>
|
|
cb(10, 20, 100, 50),
|
|
},
|
|
Dimensions: { get: () => ({ width: 390, height: 844 }) },
|
|
};
|
|
const r = (i: number) => (i === 0 ? moduleObj : undefined);
|
|
|
|
const payloads = await runToSuccess({ hook, r });
|
|
expect(payloads.length).toBeGreaterThan(0);
|
|
const outer = JSON.parse(payloads[0]!) as { requestId: string; result: string };
|
|
expect(outer.requestId).toBe("t");
|
|
const inner = JSON.parse(outer.result) as { error?: string };
|
|
expect(inner.error).toContain("Component-tree script crashed");
|
|
});
|
|
});
|