Skip to content

Commit 5bdea31

Browse files
authored
fix(browser): keep Playwright truncation UTF-16 safe (#101761)
* fix(browser): keep Playwright truncation UTF-16 safe * fix(browser): cover Chrome MCP snapshot truncation
1 parent 7498c4d commit 5bdea31

6 files changed

Lines changed: 68 additions & 3 deletions

extensions/browser/src/browser/chrome-mcp.snapshot.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,18 @@ describe("chrome MCP snapshot conversion", () => {
6666
});
6767
expect(result.stats.refs).toBe(2);
6868
});
69+
70+
it("does not split a surrogate pair when truncating AI snapshots", () => {
71+
const prefix = `- button "${"A".repeat(18)}`;
72+
const result = buildAiSnapshotFromChromeMcpSnapshot({
73+
root: {
74+
role: "button",
75+
name: `${"A".repeat(18)}🙂`,
76+
},
77+
maxChars: prefix.length + 1,
78+
});
79+
80+
expect(result.snapshot).toBe(`${prefix}\n\n[...TRUNCATED - page too large]`);
81+
expect(result.truncated).toBe(true);
82+
});
6983
});

extensions/browser/src/browser/chrome-mcp.snapshot.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* and compact AI snapshots with stable refs and duplicate tracking.
66
*/
77
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
8+
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
89
import { normalizeString } from "../record-shared.js";
910
import type { SnapshotAriaNode } from "./client.types.js";
1011
import {
@@ -184,7 +185,7 @@ export function buildAiSnapshotFromChromeMcpSnapshot(params: {
184185
? Math.floor(params.maxChars)
185186
: undefined;
186187
if (maxChars && snapshot.length > maxChars) {
187-
snapshot = `${snapshot.slice(0, maxChars)}\n\n[...TRUNCATED - page too large]`;
188+
snapshot = `${truncateUtf16Safe(snapshot, maxChars)}\n\n[...TRUNCATED - page too large]`;
188189
truncated = true;
189190
}
190191

extensions/browser/src/browser/pw-tools-core.responses.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* Response-body retrieval for Playwright-backed browser tools.
33
*/
44
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
5+
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
56
import { ensurePageState, getPageForTargetId } from "./pw-session.js";
67
import { normalizeTimeoutMs } from "./pw-tools-core.shared.js";
78
import { matchBrowserUrlPattern } from "./url-pattern.js";
@@ -103,7 +104,7 @@ export async function responseBodyViaPlaywright(opts: {
103104
throw new Error(`Failed to read response body for "${url}": ${String(err)}`, { cause: err });
104105
}
105106

106-
const trimmed = bodyText.length > maxChars ? bodyText.slice(0, maxChars) : bodyText;
107+
const trimmed = bodyText.length > maxChars ? truncateUtf16Safe(bodyText, maxChars) : bodyText;
107108
return {
108109
url,
109110
status,

extensions/browser/src/browser/pw-tools-core.snapshot.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,23 @@ describe("pw-tools-core aria snapshot storage", () => {
194194
expect(ariaSnapshotMock).toHaveBeenCalledWith({ mode: "ai", timeout: 5000 });
195195
});
196196

197+
it("does not split a surrogate pair when truncating ai snapshots", async () => {
198+
const prefix = `- button "${"A".repeat(18)}`;
199+
const ariaSnapshotMock = vi.fn().mockResolvedValue(`${prefix}🙂"`);
200+
const page = { ariaSnapshot: ariaSnapshotMock };
201+
getPageForTargetId.mockResolvedValue(page);
202+
203+
const mod = await import("./pw-tools-core.snapshot.js");
204+
const result = await mod.snapshotAiViaPlaywright({
205+
cdpUrl: "http://127.0.0.1:9222",
206+
targetId: "tab-1",
207+
maxChars: prefix.length + 1,
208+
});
209+
210+
expect(result.snapshot).toBe(`${prefix}\n\n[...TRUNCATED - page too large]`);
211+
expect(result.truncated).toBe(true);
212+
});
213+
197214
it("uses the default navigation timeout for non-finite timeouts", async () => {
198215
const page = { url: vi.fn(() => "http://127.0.0.1:31337/after") };
199216
getPageForTargetId.mockResolvedValue(page);

extensions/browser/src/browser/pw-tools-core.snapshot.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ export async function snapshotAiViaPlaywright(opts: {
281281
: undefined;
282282
let truncated = false;
283283
if (limit && snapshot.length > limit) {
284-
snapshot = `${snapshot.slice(0, limit)}\n\n[...TRUNCATED - page too large]`;
284+
snapshot = `${truncateUtf16Safe(snapshot, limit)}\n\n[...TRUNCATED - page too large]`;
285285
truncated = true;
286286
}
287287

extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,38 @@ describe("pw-tools-core", () => {
532532
expect(res.truncated).toBe(true);
533533
});
534534

535+
it("does not split a surrogate pair when truncating response body text", async () => {
536+
let responseHandler: ((resp: unknown) => void) | undefined;
537+
const on = vi.fn((event: string, handler: (resp: unknown) => void) => {
538+
if (event === "response") {
539+
responseHandler = handler;
540+
}
541+
});
542+
const off = vi.fn();
543+
setPwToolsCoreCurrentPage({ on, off });
544+
545+
const p = mod.responseBodyViaPlaywright({
546+
cdpUrl: "http://127.0.0.1:18792",
547+
targetId: "T1",
548+
url: "**/emoji",
549+
timeoutMs: 1000,
550+
maxChars: 1,
551+
});
552+
553+
await Promise.resolve();
554+
if (!responseHandler) {
555+
throw new Error("expected Playwright response handler");
556+
}
557+
responseHandler({
558+
url: () => "https://example.com/emoji",
559+
status: () => 200,
560+
headers: () => ({ "content-type": "text/plain" }),
561+
body: async () => Buffer.from("🙂B"),
562+
});
563+
564+
await expect(p).resolves.toMatchObject({ body: "", truncated: true });
565+
});
566+
535567
it("preserves the prefix while bounding decode for a large response", async () => {
536568
let responseHandler: ((resp: unknown) => void) | undefined;
537569
const on = vi.fn((event: string, handler: (resp: unknown) => void) => {

0 commit comments

Comments
 (0)