Skip to content

Commit de3e77f

Browse files
Harden waitForSettledDOM stalled request diagnostics
Co-authored-by: Shri Sukhani <shrisukhani@users.noreply.github.com>
1 parent 024228d commit de3e77f

2 files changed

Lines changed: 166 additions & 1 deletion

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { waitForSettledDOM } from "@/utils/waitForSettledDOM";
2+
import type { CDPClient, CDPSession } from "@/cdp";
3+
4+
jest.mock("@/cdp", () => ({
5+
getCDPClient: jest.fn(),
6+
getOrCreateFrameContextManager: jest.fn(),
7+
}));
8+
9+
jest.mock("@/debug/options", () => ({
10+
getDebugOptions: jest.fn(() => ({
11+
enabled: true,
12+
traceWait: true,
13+
})),
14+
}));
15+
16+
const { getCDPClient, getOrCreateFrameContextManager } = jest.requireMock(
17+
"@/cdp"
18+
) as {
19+
getCDPClient: jest.Mock;
20+
getOrCreateFrameContextManager: jest.Mock;
21+
};
22+
23+
type EventHandler = (...args: unknown[]) => void;
24+
25+
function createSessionWithEvents(): {
26+
session: CDPSession;
27+
emit: (event: string, payload: unknown) => void;
28+
} {
29+
const handlers = new Map<string, Set<EventHandler>>();
30+
const session: CDPSession = {
31+
send: async <T = unknown>(): Promise<T> => ({} as T),
32+
on: <TPayload extends unknown[]>(
33+
event: string,
34+
handler: (...payload: TPayload) => void
35+
) => {
36+
const eventHandler = handler as EventHandler;
37+
const existing = handlers.get(event);
38+
if (existing) {
39+
existing.add(eventHandler);
40+
} else {
41+
handlers.set(event, new Set([eventHandler]));
42+
}
43+
},
44+
off: <TPayload extends unknown[]>(
45+
event: string,
46+
handler: (...payload: TPayload) => void
47+
) => {
48+
handlers.get(event)?.delete(handler as EventHandler);
49+
},
50+
detach: async () => undefined,
51+
id: "session-1",
52+
};
53+
54+
const emit = (event: string, payload: unknown): void => {
55+
handlers.get(event)?.forEach((handler) => {
56+
handler(payload);
57+
});
58+
};
59+
60+
return { session, emit };
61+
}
62+
63+
describe("waitForSettledDOM diagnostics", () => {
64+
beforeEach(() => {
65+
jest.useFakeTimers();
66+
jest.clearAllMocks();
67+
});
68+
69+
afterEach(() => {
70+
jest.useRealTimers();
71+
});
72+
73+
it("sanitizes and truncates stalled-request warning diagnostics", async () => {
74+
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
75+
const logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
76+
const { session, emit } = createSessionWithEvents();
77+
const cdpClient: CDPClient = {
78+
rootSession: session,
79+
createSession: async () => session,
80+
acquireSession: async () => session,
81+
dispose: async () => undefined,
82+
};
83+
getCDPClient.mockResolvedValue(cdpClient);
84+
getOrCreateFrameContextManager.mockReturnValue({
85+
setDebug: jest.fn(),
86+
});
87+
88+
const page = {
89+
context: () => ({}),
90+
} as never;
91+
92+
try {
93+
const waitPromise = waitForSettledDOM(page, 5_000);
94+
await Promise.resolve();
95+
await Promise.resolve();
96+
97+
emit("Network.requestWillBeSent", {
98+
requestId: `req\u0000\n${"x".repeat(600)}`,
99+
type: "Document",
100+
request: {
101+
url: `https://example.com/path\u0000\n${"y".repeat(2_000)}`,
102+
},
103+
});
104+
105+
await jest.advanceTimersByTimeAsync(3_100);
106+
await waitPromise;
107+
108+
const warning = String(warnSpy.mock.calls[0]?.[0] ?? "");
109+
expect(warning).toContain("[truncated");
110+
expect(warning).not.toContain("\u0000");
111+
expect(warning).not.toContain("\n");
112+
expect(warning.length).toBeLessThan(900);
113+
} finally {
114+
warnSpy.mockRestore();
115+
logSpy.mockRestore();
116+
}
117+
});
118+
});

src/utils/waitForSettledDOM.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,53 @@ import { getDebugOptions } from "@/debug/options";
2424
const NETWORK_IDLE_THRESHOLD_MS = 500;
2525
const STALLED_REQUEST_MS = 2000;
2626
const STALLED_SWEEP_INTERVAL_MS = 500;
27+
const MAX_WAIT_DIAGNOSTIC_CHARS = 400;
28+
const MAX_WAIT_IDENTIFIER_CHARS = 200;
2729
const ENV_TRACE_WAIT =
2830
process.env.HYPERAGENT_TRACE_WAIT === "1" ||
2931
process.env.HYPERAGENT_TRACE_WAIT === "true";
3032

33+
function sanitizeWaitDiagnosticText(value: string): string {
34+
if (value.length === 0) {
35+
return value;
36+
}
37+
const withoutControlChars = Array.from(value, (char) => {
38+
const code = char.charCodeAt(0);
39+
return (code >= 0 && code < 32) || code === 127 ? " " : char;
40+
}).join("");
41+
return withoutControlChars.replace(/\s+/g, " ").trim();
42+
}
43+
44+
function truncateWaitDiagnostic(value: string, maxChars: number): string {
45+
if (value.length <= maxChars) {
46+
return value;
47+
}
48+
const omittedChars = value.length - maxChars;
49+
return `${value.slice(0, maxChars)}... [truncated ${omittedChars} chars]`;
50+
}
51+
52+
function formatWaitIdentifier(value: unknown): string {
53+
if (typeof value !== "string") {
54+
return "unknown";
55+
}
56+
const normalized = sanitizeWaitDiagnosticText(value);
57+
if (normalized.length === 0) {
58+
return "unknown";
59+
}
60+
return truncateWaitDiagnostic(normalized, MAX_WAIT_IDENTIFIER_CHARS);
61+
}
62+
63+
function formatWaitUrl(value: unknown): string {
64+
if (typeof value !== "string") {
65+
return "unknown";
66+
}
67+
const normalized = sanitizeWaitDiagnosticText(value);
68+
if (normalized.length === 0) {
69+
return "unknown";
70+
}
71+
return truncateWaitDiagnostic(normalized, MAX_WAIT_DIAGNOSTIC_CHARS);
72+
}
73+
3174
export interface LifecycleOptions {
3275
waitUntil?: Array<"domcontentloaded" | "load" | "networkidle">;
3376
timeoutMs?: number;
@@ -211,7 +254,11 @@ async function waitForNetworkIdle(
211254
stats.forcedDrops += 1;
212255
if (trace) {
213256
console.warn(
214-
`[waitForSettledDOM] Forcing completion of stalled request ${id} (age=${now - meta.start}ms url=${meta.url ?? "unknown"})`
257+
`[waitForSettledDOM] Forcing completion of stalled request ${formatWaitIdentifier(
258+
id
259+
)} (age=${now - meta.start}ms url=${formatWaitUrl(
260+
meta.url
261+
)})`
215262
);
216263
}
217264
requestMeta.delete(id);

0 commit comments

Comments
 (0)