Ship file viewer, editor improvements, and private package releases

This commit is contained in:
Clintchiz committed 2026-09-09 11:20:11 +05:30
1 parent 645914955c
commit a4a31032b9
158 files changed
+10523 -137

No files matched your search

+261
View File
@@ -0,0 +1,261 @@
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();
});
});