From 78c9aef3c9af42f157fc59f052b8674baedbb961 Mon Sep 17 00:00:00 2001 From: "jin.2" Date: Thu, 12 Mar 2026 10:48:26 +0900 Subject: [PATCH] fix: sanitize lone Unicode surrogates using toWellFormed() (#720) * fix: sanitize lone Unicode surrogates in snapshot and response serialization (#635) Pages with emoji/special characters can contain lone surrogates (e.g. \uD800 without a matching \uDC00-\uDFFF), causing serde_json to fail with "unexpected end of hex escape" when parsing the JSON response. - Add sanitizeSurrogates() to replace lone surrogates with U+FFFD in ariaSnapshot output - Add sanitizeJsonSurrogates() safety net in serializeResponse for other response fields (page.title, page.content, etc.) - Add tests for both sanitization paths Co-Authored-By: Claude Opus 4.6 * chore: remove trivial "no surrogates unchanged" test Co-Authored-By: Claude Opus 4.6 * ci: retry flaky Rust test * refactor: remove unnecessary sanitizeSurrogates from snapshot.ts Chromium's ariaSnapshot() converts lone surrogates to literal text (e.g. the 6-char string "\ud800"), not actual surrogate code points. The real fix is sanitizeJsonSurrogates() in protocol.ts which handles eval and other response paths where actual surrogates appear. Co-Authored-By: Claude Opus 4.6 * refactor: use toWellFormed() instead of regex for lone surrogate sanitization Upgrade tsconfig target/lib from ES2022 to ES2024 and replace the manual regex-based surrogate sanitization with String.prototype.toWellFormed(). This is simpler, more readable, and relies on the standard API. Co-Authored-By: Claude Opus 4.6 * chore: remove trivial no-surrogate test Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: hyunjinee Co-authored-by: Claude Opus 4.6 --- src/protocol.test.ts | 24 +++++++++++++++++++++++- src/protocol.ts | 8 ++++++-- tsconfig.json | 4 ++-- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/protocol.test.ts b/src/protocol.test.ts index 06c2673..7735cb9 100644 --- a/src/protocol.test.ts +++ b/src/protocol.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { parseCommand } from './protocol.js'; +import { parseCommand, serializeResponse, successResponse } from './protocol.js'; // Helper to create command JSON string const cmd = (obj: object) => JSON.stringify(obj); @@ -1412,6 +1412,28 @@ describe('parseCommand', () => { }); }); + describe('serializeResponse - lone surrogate handling', () => { + it('should serialize response with lone surrogate to valid JSON', () => { + const response = successResponse('1', { + snapshot: 'button "test \uD800 click"', + }); + const json = serializeResponse(response); + expect(() => JSON.parse(json)).not.toThrow(); + const parsed = JSON.parse(json); + expect(parsed.data.snapshot).toContain('\uFFFD'); + expect(parsed.data.snapshot).not.toMatch(/[\uD800-\uDFFF]/); + }); + + it('should preserve valid emoji characters', () => { + const response = successResponse('1', { + snapshot: 'button "😀 Click"', + }); + const json = serializeResponse(response); + const parsed = JSON.parse(json); + expect(parsed.data.snapshot).toContain('😀'); + }); + }); + describe('invalid commands', () => { it('should reject unknown action', () => { const result = parseCommand(cmd({ id: '1', action: 'unknown' })); diff --git a/src/protocol.ts b/src/protocol.ts index 4b4200a..785d576 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -1149,8 +1149,12 @@ export function errorResponse(id: string, error: string): Response { } /** - * Serialize a response to JSON string + * Serialize a response to JSON string. + * Replaces lone Unicode surrogates with U+FFFD to prevent + * serde_json parsing errors on the Rust side. */ export function serializeResponse(response: Response): string { - return JSON.stringify(response); + return JSON.stringify(response, (_key, value) => + typeof value === 'string' && !value.isWellFormed() ? value.toWellFormed() : value + ); } diff --git a/tsconfig.json b/tsconfig.json index 93aa062..ff14e31 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,10 @@ { "compilerOptions": { - "target": "ES2022", + "target": "ES2024", "module": "NodeNext", "moduleResolution": "NodeNext", "lib": [ - "ES2022" + "ES2024" ], "outDir": "./dist", "rootDir": "./src",