262 lines
11 KiB
TypeScript
262 lines
11 KiB
TypeScript
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<ArrayBuffer> {
|
||
const blocks: Uint8Array<ArrayBuffer>[] = [];
|
||
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(
|
||
'<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)">' +
|
||
"<script>alert(1)</script>" +
|
||
"<foreignObject><body>x</body></foreignObject>" +
|
||
'<rect width="10" height="10" onclick="alert(1)"/>' +
|
||
"</svg>",
|
||
);
|
||
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(
|
||
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">' +
|
||
'<use xlink:href="https://evil.test/x.svg#a"/>' +
|
||
'<use href="#local"/>' +
|
||
'<a href="javascript:alert(1)"><text>go</text></a>' +
|
||
"</svg>",
|
||
);
|
||
expect(output).not.toContain("evil.test");
|
||
expect(output).not.toContain("javascript:");
|
||
expect(output).toContain("#local");
|
||
});
|
||
test("stylesheets may not import or fetch", () => {
|
||
expect(
|
||
clean('<svg xmlns="http://www.w3.org/2000/svg"><style>@import url(x)</style></svg>'),
|
||
).not.toContain("@import");
|
||
expect(
|
||
clean(
|
||
'<svg xmlns="http://www.w3.org/2000/svg"><rect style="fill:url(https://evil.test/a)"/></svg>',
|
||
),
|
||
).not.toContain("evil.test");
|
||
});
|
||
test("non-SVG input is rejected outright", () => {
|
||
expect(sanitizeSvg("<html><body>hi</body></html>", 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", "<root><a/></root>", "XmlViewer"],
|
||
["logo.svg", '<svg xmlns="http://www.w3.org/2000/svg"></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<HTMLDetailsElement>(".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":"<script>alert(1)</script>"}'));
|
||
await settled();
|
||
expect(host.querySelector("script")).toBeNull();
|
||
expect(host.querySelector(".wrn-viewer-tree")).toBeNull();
|
||
expect(host.textContent).toContain("<script>alert(1)</script>");
|
||
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",
|
||
'<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><script>alert(1)</script><rect width="4" height="4"/></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("<script");
|
||
expect(srcdoc).toContain("rect");
|
||
// The frame is its own document: theme tokens and currentColor cannot reach
|
||
// it, and its base background is opaque, so both are carried across.
|
||
expect(srcdoc).toMatch(/html,body\{[^}]*color:[^;}]+/);
|
||
expect(srcdoc).toMatch(/html,body\{[^}]*background:[^;}]+/);
|
||
dispose();
|
||
await window.happyDOM.close();
|
||
});
|
||
test("an archive lists its entries", async () => {
|
||
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();
|
||
});
|
||
});
|