Skip to content

Commit 8be5172

Browse files
Harden dom-capture callback diagnostics
Co-authored-by: Shri Sukhani <shrisukhani@users.noreply.github.com>
1 parent cc91c5c commit 8be5172

2 files changed

Lines changed: 72 additions & 3 deletions

File tree

src/agent/shared/dom-capture.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,4 +115,44 @@ describe("captureDOMState", () => {
115115
logSpy.mockRestore();
116116
}
117117
});
118+
119+
it("sanitizes and truncates onFrameChunk callback diagnostics", async () => {
120+
getA11yDOM.mockImplementation(
121+
async (
122+
_page: Page,
123+
_debug: boolean,
124+
_enableVisualMode: boolean,
125+
_debugStepDir: string | undefined,
126+
options?: { onFrameChunk?: (chunk: { order: number; simplified: string }) => void }
127+
) => {
128+
options?.onFrameChunk?.({ order: 0, simplified: " streamed chunk " });
129+
return createDomState({
130+
elements: new Map([["0-1", { name: "button" }]]),
131+
domState: "fallback",
132+
});
133+
}
134+
);
135+
136+
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
137+
const logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
138+
try {
139+
const result = await captureDOMState(createPage(), {
140+
enableStreaming: true,
141+
onFrameChunk: () => {
142+
throw new Error(`stream\u0000\n${"x".repeat(10_000)}`);
143+
},
144+
debug: true,
145+
});
146+
147+
expect(result.domState).toBe("streamed chunk");
148+
const diagnostic = String(warnSpy.mock.calls[0]?.[0] ?? "");
149+
expect(diagnostic).toContain("[truncated");
150+
expect(diagnostic).not.toContain("\u0000");
151+
expect(diagnostic).not.toContain("\n");
152+
expect(diagnostic.length).toBeLessThan(700);
153+
} finally {
154+
warnSpy.mockRestore();
155+
logSpy.mockRestore();
156+
}
157+
});
118158
});

src/agent/shared/dom-capture.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type A11yDOMState,
77
} from "@/context-providers/a11y-dom";
88
import type { FrameChunkEvent } from "@/context-providers/a11y-dom/types";
9+
import { formatUnknownError } from "@/utils";
910
import { waitForSettledDOM } from "@/utils/waitForSettledDOM";
1011

1112
const DOM_CAPTURE_MAX_ATTEMPTS = 3;
@@ -26,6 +27,34 @@ export interface CaptureDOMOptions {
2627
}
2728

2829
const MAX_DOM_CAPTURE_RETRIES = 10;
30+
const MAX_DOM_CAPTURE_DIAGNOSTIC_CHARS = 400;
31+
32+
function sanitizeDomCaptureText(value: string): string {
33+
if (value.length === 0) {
34+
return value;
35+
}
36+
const withoutControlChars = Array.from(value, (char) => {
37+
const code = char.charCodeAt(0);
38+
return (code >= 0 && code < 32) || code === 127 ? " " : char;
39+
}).join("");
40+
return withoutControlChars.replace(/\s+/g, " ").trim();
41+
}
42+
43+
function truncateDomCaptureDiagnostic(value: string): string {
44+
if (value.length <= MAX_DOM_CAPTURE_DIAGNOSTIC_CHARS) {
45+
return value;
46+
}
47+
const omittedChars = value.length - MAX_DOM_CAPTURE_DIAGNOSTIC_CHARS;
48+
return `${value.slice(0, MAX_DOM_CAPTURE_DIAGNOSTIC_CHARS)}... [truncated ${omittedChars} chars]`;
49+
}
50+
51+
function formatDomCaptureDiagnostic(value: unknown): string {
52+
const normalized = sanitizeDomCaptureText(formatUnknownError(value));
53+
if (normalized.length === 0) {
54+
return "unknown callback error";
55+
}
56+
return truncateDomCaptureDiagnostic(normalized);
57+
}
2958

3059
class DomChunkAggregator {
3160
private parts: string[] = [];
@@ -139,9 +168,9 @@ export async function captureDOMState(
139168
} catch (error) {
140169
if (debug) {
141170
console.warn(
142-
`[DOM] onFrameChunk callback failed: ${
143-
error instanceof Error ? error.message : "unknown callback error"
144-
}`
171+
`[DOM] onFrameChunk callback failed: ${formatDomCaptureDiagnostic(
172+
error
173+
)}`
145174
);
146175
}
147176
}

0 commit comments

Comments
 (0)