import { expect, test } from "@playwright/test"; import { snapshotXml } from "../payload"; import type { Layer, Snapshot } from "../types"; function sample(): Snapshot { return { id: 'snapshot<&"', repoRoot: "/unused", source: { kind: "base", ref: "branch<&\t\r\n" }, base: "before", head: "after", skipped: [{ path: 'art/<&"\t\n.bin', reason: "binary\r" }], hunks: [ { id: "first", path: 'src/é\t\r\n"<&.ts', oldPath: "old<&.ts", header: "@@ -2,2 +2,2 @@ context<&", lines: [ { kind: "meta", text: "old mode 100644" }, { kind: "meta", text: "new mode 100755" }, { kind: "context", text: '\r', oldLine: 2, newLine: 2, }, { kind: "delete", text: "old ]]> text", oldLine: 3 }, { kind: "add", text: '😀', newLine: 3 }, { kind: "meta", text: "\\ No newline at end of file" }, ], }, { id: "second", path: 'src/é\t\r\n"<&.ts', oldPath: "old<&.ts", header: "@@ -9,0 +10 @@", lines: [{ kind: "add", text: "", newLine: 10 }], }, { id: "rename", path: "after.txt", oldPath: "before.txt", header: "metadata", lines: [ { kind: "meta", text: "rename from before.txt" }, { kind: "meta", text: "rename to after.txt" }, ], }, ], }; } const layer: Layer = { id: "layer", title: "Layer<&", summary: 'Check "quoted" changes.', hunks: [{ id: "first", summary: "Summary" }], flow: ["Before", "After"], }; test("native XML parser recovers every snapshot value and line anchor", async ({ page, }) => { const ordinary = sample(); const special = sample(); const forbidden = Array.from({ length: 32 }, (_, i) => i) .filter((i) => ![9, 10, 13].includes(i)) .map((i) => String.fromCharCode(i)) .join("") + "\ud800X\udfff\ufffe\uffff"; special.hunks[0].lines[3].text += forbidden; special.id += forbidden; special.source.ref += forbidden; special.hunks[0].path += forbidden; special.hunks[0].oldPath += forbidden; special.skipped[0].reason += forbidden; const empty = { ...sample(), source: { kind: "working" as const }, hunks: [], skipped: [], }; const question = 'Explain &"\r\n\tchanges.'; const cases = [ { snapshot: ordinary, layer, question }, { snapshot: special, layer: { ...layer, summary: forbidden }, question: question + forbidden, }, { snapshot: sample(), layer, question: forbidden }, { snapshot: sample(), layer: { ...layer, title: "\uffff" }, question }, { snapshot: empty, layer: undefined, question: undefined }, ]; for (const input of cases) { const before = structuredClone(input); const xml = snapshotXml(input.snapshot, input.layer, input.question); expect(input).toEqual(before); const result = await page.evaluate((xml) => { const doc = new DOMParser().parseFromString(xml, "application/xml"); if (doc.querySelector("parsererror")) throw new Error("Invalid XML payload"); const root = doc.documentElement; const json = root.getAttribute("strings") === "json"; const decode = (value: string) => json ? (JSON.parse(value) as string) : value; const attr = (el: Element, name: string) => decode(el.getAttribute(name) ?? ""); const source: { kind: string; ref?: string } = { kind: attr(root, "kind"), }; if (root.hasAttribute("ref")) source.ref = attr(root, "ref"); const hunks: Array<{ id: string; path: string; oldPath?: string; header: string; lines: Array<{ kind: string; text: string; oldLine?: number; newLine?: number; }>; }> = []; for (const file of [...root.children].filter( (el) => el.tagName === "file", )) { for (const node of [...file.children]) { const header = attr(node, "header"); const range = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(header); let oldLine = Number(range?.[1] ?? 0), newLine = Number(range?.[2] ?? 0); const body = decode(node.textContent ?? ""); if (body && !body.endsWith("\n")) throw new Error("Missing line delimiter"); const lines = (body ? body.slice(0, -1).split("\n") : []).map( (line) => { if (range && line.startsWith(" ")) return { kind: "context", text: line.slice(1), oldLine: oldLine++, newLine: newLine++, }; if (range && line.startsWith("-")) return { kind: "delete", text: line.slice(1), oldLine: oldLine++, }; if (range && line.startsWith("+")) return { kind: "add", text: line.slice(1), newLine: newLine++ }; return { kind: "meta", text: line }; }, ); hunks.push({ id: attr(node, "id"), path: attr(file, "path"), ...(file.hasAttribute("oldPath") ? { oldPath: attr(file, "oldPath") } : {}), header, lines, }); } } const layerNode = root.querySelector(":scope > layer"); const questionNode = root.querySelector(":scope > question"); return { snapshot: { id: attr(root, "id"), source, base: attr(root, "base"), head: attr(root, "head"), skipped: [...root.querySelectorAll(":scope > skipped > file")].map( (el) => ({ path: attr(el, "path"), reason: attr(el, "reason") }), ), hunks, }, layer: layerNode ? JSON.parse(decode(layerNode.textContent ?? "")) : undefined, question: questionNode ? decode(questionNode.textContent ?? "") : undefined, fileCount: [...root.children].filter((el) => el.tagName === "file") .length, json, }; }, xml); const { repoRoot: _repoRoot, ...expected } = input.snapshot; expect(result.snapshot).toEqual(expected); expect(result.layer).toEqual(input.layer); expect(result.question).toEqual(input.question); if (input.snapshot === ordinary) { expect(result.fileCount).toBe(2); expect(result.json).toBe(false); } if (input.snapshot === special) expect(result.json).toBe(true); } });