Files
WRNexusJS/packages/viewer/test/tier2.test.ts
T

262 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});
});