From ee6717872c9e838c4c1f5379481b72d878a34c6b Mon Sep 17 00:00:00 2001 From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Date: Wed, 22 Jul 2026 04:32:46 -0700 Subject: [PATCH] test(natives): cover ill-formed UTF-16 rejection and jsdiff fallback --- .../coding-agent/test/tools/edit-diff.test.ts | 10 ++++++++ .../test/tools/edit-renderer.test.ts | 11 +++++++++ .../test/recovery-session-chain.test.ts | 23 +++++++++++++++++++ packages/natives/test/diff-parity.test.ts | 17 ++++++++++++++ 4 files changed, 61 insertions(+) diff --git a/packages/coding-agent/test/tools/edit-diff.test.ts b/packages/coding-agent/test/tools/edit-diff.test.ts index af4807194..64e25c3b2 100644 --- a/packages/coding-agent/test/tools/edit-diff.test.ts +++ b/packages/coding-agent/test/tools/edit-diff.test.ts @@ -166,4 +166,14 @@ describe("generateDiffString", () => { expect(contextNumbers).toEqual([...contextNumbers].sort((a, b) => a - b)); expect(diffLines.filter(line => line.includes("| const keep = 2;"))).toEqual([" 3| const keep = 2;"]); }); + + it("detects changes between ill-formed UTF-16 inputs via the JS diff fallback", () => { + // The native binding rejects unpaired surrogates; the isWellFormed() + // guard must fall back to jsdiff instead of reporting no change (or + // throwing) for two distinct ill-formed lines. + const result = generateDiffString("a\ud800b", "a\ud801b", 3); + const rows = result.diff.split("\n"); + expect(rows.some(row => row.startsWith("-1|"))).toBe(true); + expect(rows.some(row => row.startsWith("+1|"))).toBe(true); + }); }); diff --git a/packages/coding-agent/test/tools/edit-renderer.test.ts b/packages/coding-agent/test/tools/edit-renderer.test.ts index 7cee7fcb6..f6f662b92 100644 --- a/packages/coding-agent/test/tools/edit-renderer.test.ts +++ b/packages/coding-agent/test/tools/edit-renderer.test.ts @@ -602,6 +602,17 @@ describe("editToolRenderer diff line wrapping", () => { for (const row of rows.slice(1, -1)) expect(row).toMatch(/^│\s*[+-]?\s*\d*│/); }); + it("renders ill-formed UTF-16 replacements through the JS word-diff fallback", async () => { + // Lone surrogates are rejected by the native diffWords binding; the + // component's isWellFormed() guard must route to jsdiff instead of + // throwing mid-render. + const rows = (await renderSingleLineReplacement("alpha \ud800 beta", "alpha \ud801 beta", 100)).map(row => + Bun.stripANSI(row), + ); + expect(rows.some(row => row.includes("-42"))).toBe(true); + expect(rows.some(row => row.includes("+"))).toBe(true); + }); + it("closes inverse video at every wrapped row end so frame padding stays uninverted", async () => { // A long contiguous rewritten phrase forces the wrap boundary to land // inside an inverse-highlighted span; the frame pads each row with spaces, diff --git a/packages/hashline/test/recovery-session-chain.test.ts b/packages/hashline/test/recovery-session-chain.test.ts index 1fbbb85e9..ca2159f79 100644 --- a/packages/hashline/test/recovery-session-chain.test.ts +++ b/packages/hashline/test/recovery-session-chain.test.ts @@ -262,3 +262,26 @@ describe("Recovery — colliding snapshot tags", () => { expect(recovered?.text).toBe(lines("shared head", "model payload", "shared tail", "drifted trailer")); }); }); + +describe("Recovery — ill-formed UTF-16 content", () => { + it("remaps line anchors through the JS diff fallback when the file contains lone surrogates", () => { + // The native diffLineRuns binding rejects unpaired surrogates; the + // isWellFormed() guard must fall back to jsdiff so recovery still + // remaps anchors instead of throwing or refusing. + const store = new InMemorySnapshotStore(); + const snapshotText = lines("head", "lone \ud800 surrogate", "target line", "tail"); + const hash = store.record(PATH, snapshotText); + // Current text gained one line above the target; the anchor must shift. + const currentText = lines("inserted above", "head", "lone \ud800 surrogate", "target line", "tail"); + + const recovered = new Recovery(store).tryRecover({ + path: PATH, + currentText, + fileHash: hash, + edits: parsePatch("SWAP 3.=3:\n+model payload").edits, + }); + + expect(recovered).not.toBeNull(); + expect(recovered?.text).toBe(lines("inserted above", "head", "lone \ud800 surrogate", "model payload", "tail")); + }); +}); diff --git a/packages/natives/test/diff-parity.test.ts b/packages/natives/test/diff-parity.test.ts index 1ce05ab3c..c0aa9b7ef 100644 --- a/packages/natives/test/diff-parity.test.ts +++ b/packages/natives/test/diff-parity.test.ts @@ -103,6 +103,23 @@ describe("native diff parity with jsdiff", () => { } }); + test("ill-formed UTF-16 is rejected, never silently normalized", () => { + // N-API UTF-8 conversion would replace unpaired surrogates with U+FFFD + // and collapse distinct inputs into "no change"; the native exports + // throw instead so callers fall back to a UTF-16-aware JS diff. + const a = "a\ud800b"; + const b = "a\ud801b"; + expect(() => diffLines(a, b)).toThrow(/ill-formed UTF-16/); + expect(() => diffLineRuns(a, b)).toThrow(/ill-formed UTF-16/); + expect(() => diffWords(a, b)).toThrow(/ill-formed UTF-16/); + expect(() => structuredPatchHunks(a, b, 3)).toThrow(/ill-formed UTF-16/); + expect(() => diffLines("well formed", b)).toThrow(/ill-formed UTF-16/); + // A legitimate embedded NUL is content, not a terminator. + expect(natChanges(diffLines("a\u0000\nb\n", "a\u0000\nc\n"))).toEqual( + jsChanges(Diff.diffLines("a\u0000\nb\n", "a\u0000\nc\n")), + ); + }); + test("seeded random word diffs", () => { // Token pool stresses jsdiff's word/whitespace boundary rules: repeated // and mixed whitespace, tabs, newlines, punctuation runs, Latin