import { describe, expect, test } from "bun:test"; import { Window } from "happy-dom"; import { detectDelimiter, parseDelimited } from "../src/parse/delimited.ts"; import { gzipName, isTar, parseTar } from "../src/parse/archive.ts"; import { sanitizeSvg } from "../src/parse/svg.ts"; import { resolve } from "../src/resolve.ts"; import { mountViewer } from "../src/runtime.ts"; const file = (name: string, bytes: string | Uint8Array, mime = "") => ({ name, blob: new Blob([bytes as string], { type: mime }), }); async function settled() { for (let i = 0; i < 15; i++) await new Promise((done) => setTimeout(done, 5)); } function fixture() { const window = new Window(); const host = window.document.createElement("section"); window.document.body.append(host); return { window, host: host as unknown as HTMLElement }; } /** Minimal USTAR archive: one header block plus padded content. */ function tar(entries: { name: string; body: string }[]): Uint8Array { const blocks: Uint8Array[] = []; const encoder = new TextEncoder(); for (const entry of entries) { const header = new Uint8Array(512); header.set(encoder.encode(entry.name), 0); header.set(encoder.encode(entry.body.length.toString(8).padStart(11, "0")), 124); header[156] = "0".charCodeAt(0); header.set(encoder.encode("ustar"), 257); blocks.push(header); const content = new Uint8Array(Math.ceil(entry.body.length / 512) * 512); content.set(encoder.encode(entry.body)); blocks.push(content); } blocks.push(new Uint8Array(1024)); const total = blocks.reduce((sum, block) => sum + block.length, 0); const bytes = new Uint8Array(total); let offset = 0; for (const block of blocks) { bytes.set(block, offset); offset += block.length; } return bytes; } describe("delimited parsing", () => { test("quotes may hold the delimiter, newlines and escaped quotes", () => { const table = parseDelimited('a,b\r\n"x,1","says ""hi""\nagain"\r\n'); expect(table.header).toEqual(["a", "b"]); expect(table.rows).toEqual([["x,1", 'says "hi"\nagain']]); expect(table.delimiter).toBe(","); }); test("tabs and semicolons are detected, quoted ones ignored", () => { expect(detectDelimiter("a\tb\tc")).toBe("\t"); expect(detectDelimiter("a;b;c")).toBe(";"); expect(detectDelimiter('"a,b,c,d";e')).toBe(";"); }); test("a trailing newline does not add a row and caps are honoured", () => { expect(parseDelimited("a,b\n1,2\n").rows).toHaveLength(1); const capped = parseDelimited("a,b\n1,2\n3,4\n5,6\n", { maxRows: 2 }); expect(capped.truncated).toBe(true); expect(capped.rows).toHaveLength(1); }); }); describe("SVG sanitising", () => { const window = new Window(); const view = window as unknown as { DOMParser: new () => DOMParser; XMLSerializer: new () => XMLSerializer; }; const clean = (source: string) => sanitizeSvg(source, view) ?? ""; test("scripts, handlers and foreign content are removed", () => { const output = clean( '' + "" + "x" + '' + "", ); expect(output).not.toContain("script"); expect(output).not.toContain("foreignObject"); expect(output).not.toContain("onload"); expect(output).not.toContain("onclick"); expect(output).toContain("rect"); }); test("only same-document references survive", () => { const output = clean( '' + '' + '' + 'go' + "", ); expect(output).not.toContain("evil.test"); expect(output).not.toContain("javascript:"); expect(output).toContain("#local"); }); test("stylesheets may not import or fetch", () => { expect( clean(''), ).not.toContain("@import"); expect( clean( '', ), ).not.toContain("evil.test"); }); test("non-SVG input is rejected outright", () => { expect(sanitizeSvg("hi", view)).toBeUndefined(); expect(sanitizeSvg("not markup at all", view)).toBeUndefined(); }); }); describe("archive listing", () => { test("USTAR entries are listed with sizes", () => { const bytes = tar([ { name: "readme.txt", body: "hello" }, { name: "src/index.ts", body: "export {};" }, ]); expect(isTar(bytes)).toBe(true); expect(parseTar(bytes).map((entry) => entry.name)).toEqual(["readme.txt", "src/index.ts"]); expect(parseTar(bytes)[0]!.size).toBe(5); }); test("gzip member names are read when present", () => { const header = new Uint8Array([0x1f, 0x8b, 8, 0x08, 0, 0, 0, 0, 0, 3]); const name = new TextEncoder().encode("payload.tar\0"); const bytes = new Uint8Array(header.length + name.length); bytes.set(header); bytes.set(name, header.length); expect(gzipName(bytes)).toBe("payload.tar"); expect(gzipName(new Uint8Array([0x1f, 0x8b, 8, 0, 0, 0, 0, 0, 0, 3]))).toBeUndefined(); }); }); describe("tier 2 routing", () => { const cases: [string, string, string][] = [ ["data.csv", "a,b\n1,2\n", "TableViewer"], ["data.tsv", "a\tb\n1\t2\n", "TableViewer"], ["data.json", '{"a":1}', "JsonViewer"], ["data.xml", "", "XmlViewer"], ["logo.svg", '', "SvgViewer"], ]; for (const [name, body, component] of cases) test(`routes ${name}`, async () => expect((await resolve(file(name, body))).renderer.component).toBe(component)); test("a tar archive is listed rather than downloaded", async () => { const bytes = tar([{ name: "a.txt", body: "x" }]); const plan = await resolve({ name: "bundle.tar", blob: new Blob([bytes]) }); expect(plan.kind).toBe("archive"); expect(plan.renderer.component).toBe("ArchiveViewer"); }); test("tier 2 renderers stay behind the source view for their kind", async () => { const plan = await resolve(file("notes.json", '{"a":1}')); expect(plan.fallback.map((renderer) => renderer.component)).toContain("CodeViewer"); }); }); describe("tier 2 rendering", () => { test("a delimited file becomes a spreadsheet grid", async () => { const { window, host } = fixture(); const dispose = mountViewer(host, file("data.csv", "name,size\nalpha,10\nbeta,20\n")); await settled(); expect(host.querySelectorAll("table")).toHaveLength(1); // The first line of the file is row 1 of the sheet, not a promoted header, // so all three rows are counted and the columns are lettered. expect(host.textContent).toContain("3 rows × 2 columns"); expect([...host.querySelectorAll("thead th")].map((cell) => cell.textContent)).toEqual([ "", "A", "B", ]); expect( [...host.querySelectorAll("tbody [data-viewer-index]")].map((cell) => cell.textContent), ).toEqual(["1", "2", "3"]); expect(host.textContent).toContain("alpha"); // A column of numbers is right-aligned; one of words is not. expect(host.querySelectorAll("tbody [data-viewer-numeric]")).toHaveLength(3); dispose(); await window.happyDOM.close(); }); test("malformed JSON falls through to the source view", async () => { const { window, host } = fixture(); const dispose = mountViewer(host, file("broken.json", "{ not json")); await settled(); expect(host.querySelector(".wrn-viewer-tree")).toBeNull(); expect(host.textContent).toContain("{ not json"); dispose(); await window.happyDOM.close(); }); test("JSON renders a lazy tree with nested values", async () => { const { window, host } = fixture(); const dispose = mountViewer(host, file("data.json", '{"tag":"alpha","list":[1,2]}')); await settled(); const tree = host.querySelector(".wrn-viewer-tree")!; expect(tree).not.toBeNull(); expect(tree.textContent).toContain("tag"); // The nested array is a closed branch until it is opened. const nested = host.querySelector(".wrn-viewer-branch details")!; expect(nested.textContent).toContain("list"); expect(nested.open).toBe(false); nested.open = true; nested.dispatchEvent(new window.Event("toggle") as unknown as Event); expect(nested.textContent).toContain("2"); dispose(); await window.happyDOM.close(); }); test("a JSON file carrying markup is shown as inert source, not a tree", async () => { const { window, host } = fixture(); const dispose = mountViewer(host, file("data.json", '{"tag":""}')); await settled(); expect(host.querySelector("script")).toBeNull(); expect(host.querySelector(".wrn-viewer-tree")).toBeNull(); expect(host.textContent).toContain(""); dispose(); await window.happyDOM.close(); }); test("SVG renders sandboxed, with the sanitised document inside the frame", async () => { const { window, host } = fixture(); const dispose = mountViewer( host, file( "logo.svg", '', ), ); await settled(); const frame = host.querySelector("iframe")!; expect(frame).not.toBeNull(); expect(frame.getAttribute("sandbox")).toBe(""); const srcdoc = frame.getAttribute("srcdoc")!; expect(srcdoc).toContain("default-src 'none'"); expect(srcdoc).not.toContain("onload"); expect(srcdoc).not.toContain(" { const { window, host } = fixture(); const dispose = mountViewer(host, { name: "bundle.tar", blob: new Blob([tar([{ name: "docs/readme.md", body: "hello" }])]), }); await settled(); expect(host.textContent).toContain("docs/readme.md"); expect(host.textContent).toContain("1 file"); dispose(); await window.happyDOM.close(); }); });