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 <noreply@anthropic.com>

* chore: remove trivial "no surrogates unchanged" test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* chore: remove trivial no-surrogate test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: hyunjinee <leehj0110@kakao.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jin.2
2026-03-11 20:48:26 -05:00
committed by GitHub
co-authored by Claude Opus 4.6 hyunjinee
parent 417428463b
commit 78c9aef3c9
3 changed files with 31 additions and 5 deletions
+23 -1
View File
@@ -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' }));
+6 -2
View File
@@ -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
);
}
+2 -2
View File
@@ -1,10 +1,10 @@
{
"compilerOptions": {
"target": "ES2022",
"target": "ES2024",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": [
"ES2022"
"ES2024"
],
"outDir": "./dist",
"rootDir": "./src",