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

+208
View File
@@ -0,0 +1,208 @@
import { afterEach, describe, expect, test } from "bun:test";
import { Window } from "happy-dom";
import { clearViewerActions, registerViewerAction } from "../src/actions.ts";
import { mountViewer } from "../src/runtime.ts";
import { iconFor } from "../src/renderers/icons.ts";
async function settled(ticks = 30) {
for (let i = 0; i < ticks; i++) await new Promise((done) => setTimeout(done, 5));
}
function fixture() {
const window = new Window({ url: "https://app.test/" });
const host = window.document.createElement("section") as unknown as HTMLElement;
window.document.body.append(host as never);
return { window, host };
}
const csv = () => ({ name: "data.csv", blob: new Blob(["a,b\n1,2\n"]) });
const labels = (host: HTMLElement) =>
[...host.querySelectorAll(".wrn-viewer-action")].map((node) => node.getAttribute("aria-label"));
afterEach(() => clearViewerActions());
describe("icon buttons", () => {
test("every control is an icon button with an accessible name and a tooltip", async () => {
const { window, host } = fixture();
const dispose = mountViewer(host, csv());
await settled();
const buttons = [...host.querySelectorAll(".wrn-viewer-action")];
expect(buttons.length).toBeGreaterThan(0);
for (const node of buttons) {
const label = node.getAttribute("aria-label") ?? "";
expect(label).toBeTruthy();
// The tooltip shows the same name the assistive label reads out.
expect(node.getAttribute("data-tooltip")).toBe(label);
expect(node.querySelector("svg")).not.toBeNull();
// The name stays in the DOM for screen readers, visually hidden.
expect(node.querySelector(".wrn-viewer-sr")!.textContent).toBe(label);
}
dispose();
await window.happyDOM.close();
});
test("toggling a label swaps the icon instead of deleting it", async () => {
const { window, host } = fixture();
const dispose = mountViewer(host, csv());
await settled();
const toggle = [...host.querySelectorAll<HTMLButtonElement>(".wrn-viewer-action")].find(
(node) => node.getAttribute("aria-label") === "Raw",
)!;
toggle.click();
expect(toggle.getAttribute("aria-label")).toBe("Table");
expect(toggle.getAttribute("data-tooltip")).toBe("Table");
expect(toggle.querySelector("svg")).not.toBeNull();
dispose();
await window.happyDOM.close();
});
test("an unknown label still gets an icon rather than nothing", () => {
expect(iconFor("Zoom in")).toBe("zoom-in");
expect(iconFor("Something bespoke")).toBe("file");
});
});
describe("custom actions", () => {
test("a globally registered action appears and runs with the render context", async () => {
const { window, host } = fixture();
const seen: string[] = [];
registerViewerAction({
id: "open-in-tab",
label: "Open in new tab",
run: ({ source, renderer }) => {
seen.push(`${source.name}:${renderer?.component}`);
},
});
const dispose = mountViewer(host, csv());
await settled();
const action = host.querySelector<HTMLButtonElement>('[data-viewer-action="open-in-tab"]')!;
expect(action).not.toBeNull();
expect(action.getAttribute("aria-label")).toBe("Open in new tab");
action.click();
expect(seen).toEqual(["data.csv:TableViewer"]);
dispose();
await window.happyDOM.close();
});
test("`when` decides, and per-viewer actions are offered too", async () => {
const { window, host } = fixture();
registerViewerAction({
id: "images-only",
label: "Crop",
when: ({ kind }) => kind === "image",
run: () => {},
});
const dispose = mountViewer(host, csv(), {
actions: [{ id: "local", label: "Send", placement: "controls", run: () => {} }],
});
await settled();
expect(host.querySelector('[data-viewer-action="images-only"]')).toBeNull();
expect(host.querySelector('[data-viewer-action="local"]')).not.toBeNull();
dispose();
await window.happyDOM.close();
});
test("actions survive a re-render without piling up", async () => {
const { window, host } = fixture();
registerViewerAction({ id: "once", label: "Once", run: () => {} });
const dispose = mountViewer(host, csv());
await settled();
const toggle = [...host.querySelectorAll<HTMLButtonElement>(".wrn-viewer-action")].find(
(node) => node.getAttribute("aria-label") === "Raw",
)!;
toggle.click();
expect(host.querySelectorAll('[data-viewer-action="once"]')).toHaveLength(1);
dispose();
await window.happyDOM.close();
});
test("an app action is still offered for a file nothing can preview", async () => {
const { window, host } = fixture();
registerViewerAction({ id: "anywhere", label: "Report", run: () => {} });
const dispose = mountViewer(host, {
name: "thing.bin",
blob: new Blob([new Uint8Array([0, 1, 2])]),
});
await settled();
expect(host.querySelector('[data-viewer-action="anywhere"]')).not.toBeNull();
// Printing a download card would print nothing, so it is withheld.
expect(host.querySelector('[data-viewer-action="print"]')).toBeNull();
dispose();
await window.happyDOM.close();
});
});
describe("print", () => {
test("a rendered preview offers Print; it is opt-out", async () => {
const { window, host } = fixture();
const dispose = mountViewer(host, csv());
await settled();
expect(labels(host)).toContain("Print");
dispose();
const second = fixture();
const disposeSecond = mountViewer(second.host, csv(), { print: false });
await settled();
expect(labels(second.host)).not.toContain("Print");
disposeSecond();
await second.window.happyDOM.close();
await window.happyDOM.close();
});
test("printing isolates a repaired clone of the preview", async () => {
const { window, host } = fixture();
let printed = 0;
(window as unknown as { print: () => void }).print = () => {
printed++;
const doc = window.document;
// At the moment of printing, a clone of the preview stands alone in the
// body and the page is marked so everything else drops out of the flow.
expect(doc.documentElement.hasAttribute("data-viewer-printing-active")).toBe(true);
const root = doc.querySelector("[data-viewer-print-root]")!;
expect(root).not.toBeNull();
expect(root.textContent).toContain("data.csv");
expect(root.querySelector("table")).not.toBeNull();
// Spacer rows stand in for rows that were never in the DOM.
expect(root.querySelectorAll("[data-viewer-spacer]")).toHaveLength(0);
};
const dispose = mountViewer(host, csv());
await settled();
host.querySelector<HTMLButtonElement>('[data-viewer-action="print"]')!.click();
expect(printed).toBe(1);
// Cleanup waits for the browser to finish, then leaves the page as found.
window.dispatchEvent(new window.Event("afterprint") as never);
expect(window.document.querySelector("[data-viewer-print-root]")).toBeNull();
expect(window.document.documentElement.hasAttribute("data-viewer-printing-active")).toBe(false);
dispose();
await window.happyDOM.close();
});
});
describe("audio transport", () => {
test("one set of controls drives the playhead, not two", async () => {
const { window, host } = fixture();
const wav = new Uint8Array(64);
wav.set(new TextEncoder().encode("RIFF"));
wav.set(new TextEncoder().encode("WAVE"), 8);
const dispose = mountViewer(host, { name: "clip.wav", blob: new Blob([wav as never]) });
await settled();
// The native controls are gone: they were a second play/seek for the same
// element, sitting above the package's own transport.
expect(host.querySelector("audio")!.hasAttribute("controls")).toBe(false);
const player = host.querySelector(".wrn-viewer-player")!;
expect(player).not.toBeNull();
expect(
[...player.querySelectorAll(".wrn-viewer-action")].map((n) => n.getAttribute("aria-label")),
).toEqual(["Play", "Mute"]);
// Exactly one seek control, and it is labelled.
const seeks = host.querySelectorAll('input[type="range"]');
expect(seeks).toHaveLength(1);
expect(seeks[0]!.getAttribute("aria-label")).toContain("clip.wav");
const play = player.querySelector<HTMLButtonElement>(".wrn-viewer-play")!;
expect(play.querySelector("svg")).not.toBeNull();
dispose();
await window.happyDOM.close();
});
});
+122
View File
@@ -0,0 +1,122 @@
import { expect, test } from "bun:test";
import { readFileSync, readdirSync } from "node:fs";
import { createRequire } from "node:module";
import { renderComponent } from "../../test/src/index.ts";
import { viewerPlugin } from "../src/plugin.ts";
import { compileWrnFile } from "../../compiler/src/index.ts";
import {
mergePluginAssets,
readPluginAsset,
runtimeScriptsForMarkup,
} from "../../dev-server/src/plugin-assets.ts";
import type { ClientRuntimeDefinition } from "@wrnexus/plugin";
for (const filename of readdirSync(new URL("../components", import.meta.url))) {
test(`${filename} renders its presentation contract`, async () => {
const source = readFileSync(new URL(`../components/${filename}`, import.meta.url), "utf8");
const html = await renderComponent(source, { class: "custom-presentation" });
expect(html).toContain("custom-presentation");
expect(html).toContain(filename === "Viewer.wrn" ? "data-wrnexus-runtime" : "data-viewer-");
});
}
test("Viewer renders escaped source attributes and runtime marker", async () => {
const source = readFileSync(new URL("../components/Viewer.wrn", import.meta.url), "utf8");
const html = await renderComponent(source, {
name: "<script>alert(1)</script>",
src: '/file?x="',
});
expect(html).toContain('data-wrnexus-runtime="viewer"');
expect(html).not.toContain("<script>alert(1)</script>");
expect(html).toContain('data-component="ViewerShell"');
});
test("framework bundles editor separately and injects only the viewer runtime", async () => {
const runtimes = viewerPlugin().clientRuntimes as ClientRuntimeDefinition[];
const assets = mergePluginAssets(runtimes, []);
const viewer = assets.find((asset) => asset.id === "runtime:viewer")!;
const editor = assets.find((asset) => asset.id === "runtime:viewer-code")!;
const decode = (body: string | ArrayBuffer) =>
typeof body === "string" ? body : new TextDecoder().decode(body);
const main = decode(await readPluginAsset(viewer));
expect(main).not.toContain("class EditorView");
expect(main).toContain("/__wrnexus/assets/viewer-code.js");
expect(decode(await readPluginAsset(editor))).toContain("mountBuilderSourceEditor");
expect(
runtimeScriptsForMarkup('<section data-wrnexus-runtime="viewer"></section>', runtimes),
).toHaveLength(1);
});
test("example page compiles with explicit Viewer import", () => {
const path = new URL("../../../examples/basic-app/app/pages/file-viewer.wrn", import.meta.url);
expect(compileWrnFile(readFileSync(path, "utf8"), path.pathname)).toContain("Viewer");
});
test("plugin contributes existing browser assets and mounts no conversion routes", () => {
const plugin = viewerPlugin();
expect(plugin.routeEntries).toBeUndefined();
expect(Array.isArray(plugin.componentDirs)).toBe(true);
for (const file of [
new URL("../src/runtime.ts", import.meta.url),
new URL("../viewer.css", import.meta.url),
])
expect(readFileSync(file, "utf8").length).toBeGreaterThan(0);
// The two core runtimes, plus one per installed optional parser (and the
// pdf.js worker). Only the core viewer runtime is ever injected into a page.
const ids = (plugin.clientRuntimes as ClientRuntimeDefinition[]).map((runtime) => runtime.id);
expect(ids.slice(0, 2)).toEqual(["viewer-code", "viewer"]);
for (const id of ids.slice(2)) expect(id.startsWith("viewer-")).toBe(true);
expect(plugin.styleSources).toHaveLength(1);
});
test("optional parsers are contributed only when their package resolves", () => {
const runtimes = viewerPlugin().clientRuntimes as ClientRuntimeDefinition[];
const ids = new Set(runtimes.map((runtime) => runtime.id));
const resolvable = (specifier: string) => {
try {
createRequire(new URL("../src/plugin.ts", import.meta.url)).resolve(specifier);
return true;
} catch {
return false;
}
};
for (const [id, specifier] of [
["viewer-sheet", "xlsx"],
["viewer-docx", "mammoth"],
["viewer-zip", "fflate"],
["viewer-pdfjs", "pdfjs-dist"],
] as const)
expect(ids.has(id)).toBe(resolvable(specifier));
// pdf.js is useless without its worker asset.
expect(ids.has("viewer-pdfjs-worker")).toBe(ids.has("viewer-pdfjs"));
});
test("optional runtimes are served but never injected into a page", () => {
const runtimes = viewerPlugin().clientRuntimes as ClientRuntimeDefinition[];
const markup = '<section data-wrnexus-runtime="viewer"></section>';
expect(runtimeScriptsForMarkup(markup, runtimes)).toHaveLength(1);
const assets = mergePluginAssets(runtimes, []);
for (const id of ["viewer-sheet", "viewer-zip", "viewer-docx", "viewer-pdfjs"]) {
if (!runtimes.some((runtime) => runtime.id === id)) continue;
const asset = assets.find((item) => item.id === `runtime:${id}`)!;
expect(asset.publicPath).toBe(`/__wrnexus/assets/${id}.js`);
}
});
test("each optional runtime bundles for the browser and exports its parser", async () => {
const runtimes = viewerPlugin().clientRuntimes as ClientRuntimeDefinition[];
const assets = mergePluginAssets(runtimes, []);
const decode = (body: string | ArrayBuffer) =>
typeof body === "string" ? body : new TextDecoder().decode(body);
for (const [id, exported] of [
["viewer-sheet", "readWorkbook"],
["viewer-docx", "docxToHtml"],
["viewer-zip", "listZip"],
["viewer-pdfjs", "renderPdf"],
] as const) {
const asset = assets.find((item) => item.id === `runtime:${id}`);
if (!asset) continue;
const code = decode(await readPluginAsset(asset));
expect(code.length).toBeGreaterThan(0);
expect(code).toContain(exported);
}
}, 120_000);
+203
View File
@@ -0,0 +1,203 @@
import { afterEach, describe, expect, test } from "bun:test";
import { configureConversion } from "../src/convert/config.ts";
import { handleCapabilities, handleConvert } from "../src/convert/service.ts";
import { localBinaryConverter } from "../src/convert/local.ts";
import { resetConverterProbe } from "../src/convert/client.ts";
import { viewerPlugin } from "../src/plugin.ts";
import { resolve } from "../src/resolve.ts";
import type { ViewerConverter } from "../src/convert/types.ts";
const encoder = new TextEncoder();
function fakeConverter(overrides: Partial<ViewerConverter> = {}): ViewerConverter {
return {
name: "fake",
version: "1",
supports: (_kind, ext) => ext === ".docx",
outputType: (ext) => (ext === ".docx" ? "application/pdf" : undefined),
toPreview: async () => ({ mime: "application/pdf", bytes: encoder.encode("%PDF-1.7 fake") }),
...overrides,
};
}
const request = (path: string) => new Request(`https://app.test${path}`);
const convertRequest = (src: string, name: string) =>
request(`/api/viewer/preview?src=${encodeURIComponent(src)}&name=${encodeURIComponent(name)}`);
afterEach(() => {
configureConversion(undefined);
resetConverterProbe();
});
describe("conversion routes", () => {
test("everything 404s until conversion is configured", async () => {
expect(handleCapabilities().status).toBe(404);
expect((await handleConvert(convertRequest("/a.docx", "a.docx"), "preview")).status).toBe(404);
});
test("capabilities advertise only what a converter claims", async () => {
configureConversion({ converters: [fakeConverter()] });
const body = (await handleCapabilities().json()) as {
converters: string[];
formats: Record<string, string>;
};
expect(body.converters).toEqual(["fake"]);
expect(body.formats[".docx"]).toBe("application/pdf");
expect(body.formats[".psd"]).toBeUndefined();
});
test("unknown extensions and unsupported formats are refused, not attempted", async () => {
configureConversion({ converters: [fakeConverter()] });
expect((await handleConvert(convertRequest("/a.foo", "a.foo"), "preview")).status).toBe(415);
expect((await handleConvert(convertRequest("/a.psd", "a.psd"), "preview")).status).toBe(415);
expect((await handleConvert(request("/api/viewer/preview"), "preview")).status).toBe(400);
});
test("the default source policy refuses cross-origin URLs without fetching", async () => {
let fetched = false;
const original = globalThis.fetch;
globalThis.fetch = (async () => {
fetched = true;
return new Response("x");
}) as unknown as typeof fetch;
try {
configureConversion({ converters: [fakeConverter()] });
const response = await handleConvert(
convertRequest("https://evil.test/a.docx", "a.docx"),
"preview",
);
expect(response.status).toBe(403);
expect(fetched).toBe(false);
} finally {
globalThis.fetch = original;
}
});
test("a converted result is returned, then served from cache", async () => {
let conversions = 0;
configureConversion({
converters: [
fakeConverter({
toPreview: async () => {
conversions++;
return { mime: "application/pdf", bytes: encoder.encode("%PDF-1.7 fake") };
},
}),
],
resolveSource: async () => ({
body: new Response(encoder.encode("docx bytes")).body!,
size: 10,
}),
});
const first = await handleConvert(convertRequest("/files/a.docx", "a.docx"), "preview");
expect(first.status).toBe(200);
expect(first.headers.get("content-type")).toBe("application/pdf");
expect(first.headers.get("x-content-type-options")).toBe("nosniff");
expect(first.headers.get("x-viewer-cache")).toBe("miss");
expect(await first.text()).toContain("%PDF");
const second = await handleConvert(convertRequest("/files/a.docx", "a.docx"), "preview");
expect(second.headers.get("x-viewer-cache")).toBe("hit");
expect(conversions).toBe(1);
});
test("oversized sources are refused before the converter runs", async () => {
let ran = false;
configureConversion({
maxInputBytes: 8,
converters: [
fakeConverter({
toPreview: async () => {
ran = true;
return { mime: "application/pdf", bytes: new Uint8Array() };
},
}),
],
resolveSource: async () => ({ body: new Response("x").body!, size: 999 }),
});
expect((await handleConvert(convertRequest("/a.docx", "a.docx"), "preview")).status).toBe(413);
expect(ran).toBe(false);
});
test("a converter failure reports a generic error, never its own message", async () => {
configureConversion({
converters: [
fakeConverter({
toPreview: async () => {
throw new Error("soffice crashed at C:/secret/path");
},
}),
],
resolveSource: async () => ({ body: new Response("x").body! }),
});
const response = await handleConvert(convertRequest("/a.docx", "a.docx"), "preview");
expect(response.status).toBe(422);
expect(await response.text()).not.toContain("secret");
});
});
describe("plugin wiring", () => {
test("routes appear only when conversion is configured", () => {
expect(viewerPlugin().routeEntries).toBeUndefined();
const routes = viewerPlugin({ convert: { converters: [fakeConverter()] } }).routeEntries as {
path: string;
}[];
expect(routes.map((route) => route.path)).toEqual([
"/api/viewer/capabilities",
"/api/viewer/preview",
"/api/viewer/thumb",
"/api/viewer/text",
]);
// Configuring the plugin is what arms the handlers.
expect(handleCapabilities().status).toBe(200);
});
});
describe("local binary converter", () => {
test("a disabled binary makes its formats unsupported", () => {
const converter = localBinaryConverter({
binaries: { soffice: false, magick: false, ffmpeg: false, "7z": false },
});
expect(converter.supports("doc", ".docx")).toBe(false);
expect(converter.outputType(".psd")).toBeUndefined();
});
test("an unknown extension is never routed to a binary", () => {
expect(localBinaryConverter().supports("binary", ".xyz")).toBe(false);
});
test("conversion of an unsupported format rejects rather than spawning", async () => {
const converter = localBinaryConverter({ binaries: { soffice: false } });
await expect(
converter.toPreview(new Response("x").body!, {
name: "a.docx",
ext: ".docx",
kind: "doc",
signal: new AbortController().signal,
}),
).rejects.toThrow("No local converter");
});
});
describe("tier 3 planning", () => {
test("office and design files keep their extension kind despite generic bytes", async () => {
// A .docx is a ZIP and a .psd is opaque binary; neither signature identifies
// the content, so the extension must still choose the converter.
const docx = await resolve({
name: "report.docx",
blob: new Blob([new Uint8Array([0x50, 0x4b, 3, 4, 0, 0])]),
});
expect(docx.kind).toBe("doc");
const psd = await resolve({
name: "art.psd",
blob: new Blob([new Uint8Array([0x38, 0x42, 0x50, 0x53, 0, 1])]),
});
expect(psd.kind).toBe("binary");
});
test("without a reachable converter they fall back and ask for no conversion", async () => {
const plan = await resolve({
name: "report.docx",
blob: new Blob([new Uint8Array([0x50, 0x4b, 3, 4, 0, 0])]),
});
expect(plan.renderer.component).toBe("DownloadCard");
expect(plan.needsConvert).toBe(false);
});
});
+259
View File
@@ -0,0 +1,259 @@
import { afterEach, expect, test } from "bun:test";
import { Window } from "happy-dom";
import { firstOf, sameOriginSource } from "../src/convert/sources.ts";
import { configureConversion } from "../src/convert/config.ts";
import { handleConvert } from "../src/convert/service.ts";
import { converterFormats, convertUrl, resetConverterProbe } from "../src/convert/client.ts";
import { loadOptional } from "../src/optional/load.ts";
import { sanitizeHtml } from "../src/parse/html.ts";
import { waveformPeaks } from "../src/renderers/waveform.ts";
import { mountViewer } from "../src/runtime.ts";
import { registerRenderer } from "../src/registry.ts";
import { extractText } from "../src/text.ts";
afterEach(() => {
configureConversion(undefined);
resetConverterProbe();
});
const tick = () => new Promise((resolve) => setTimeout(resolve, 40));
test("runtime preserves overridden chrome and mounts inside the renderer presentation", async () => {
const window = new Window();
const host = window.document.createElement("section");
window.document.body.append(host);
// Trusted test fixture representing SSR output from an overridden component.
host.innerHTML =
'<header class="custom-toolbar" data-viewer-toolbar><span data-viewer-title></span><span data-viewer-controls></span><span data-viewer-actions></span></header><main class="custom-content" data-viewer-content></main><template data-viewer-template="ImageViewer"><article class="custom-image"><div data-viewer-mount></div></article></template>';
const before = host.innerHTML;
const dispose = mountViewer(host as unknown as HTMLElement, {
name: "photo.png",
blob: new Blob([new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])]),
});
await tick();
expect(host.querySelector(".custom-toolbar [data-viewer-title]")?.textContent).toBe("photo.png");
expect(
host.querySelector(".custom-content .custom-image [data-viewer-mount] img"),
).not.toBeNull();
dispose();
expect(host.innerHTML).toBe(before);
await window.happyDOM.close();
});
test("filmstrip consumes the thumbnail endpoint and releases its object URLs", async () => {
const window = new Window({ url: "https://thumb.test/demo" });
const host = window.document.createElement("section");
window.document.body.append(host);
const saved = globalThis.fetch;
const requests: string[] = [];
globalThis.fetch = (async (input, init) => {
const url = new URL(String(input));
requests.push(url.pathname);
if (url.pathname.endsWith("/capabilities"))
return Response.json({ formats: { ".psd": "image/png" } });
if (url.pathname.endsWith("/thumb"))
return new Response(new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), {
headers: { "content-type": "image/png" },
});
if (init?.method === "HEAD") return new Response(null, { headers: { "content-length": "8" } });
return new Response(new Uint8Array([56, 66, 80, 83, 0, 0, 0, 0]));
}) as typeof fetch;
let dispose: (() => void) | undefined;
try {
dispose = mountViewer(host as unknown as HTMLElement, [
{ name: "art.psd", src: "https://thumb.test/art.psd" },
]);
await tick();
expect(requests).toContain("/api/viewer/thumb");
const image = host.querySelector(".wrn-viewer-file img") as unknown as HTMLImageElement;
expect(image).not.toBeNull();
const url = image.src;
dispose();
dispose = undefined;
await expect(saved(url)).rejects.toThrow();
} finally {
dispose?.();
globalThis.fetch = saved;
await window.happyDOM.close();
}
});
test("rotating links hit one stable cache entry, but denied access never reads it", async () => {
let allowed = true;
let conversions = 0;
let cancellations = 0;
configureConversion({
converters: [
{
name: "stable-test",
supports: () => true,
outputType: () => "application/pdf",
toPreview: async () => {
conversions++;
return { mime: "application/pdf", bytes: new TextEncoder().encode("%PDF") };
},
},
],
resolveSource: async () =>
allowed
? {
identity: "reports:key:revision1",
body: new ReadableStream({
cancel() {
cancellations++;
},
}),
}
: undefined,
});
const request = (token: string) =>
new Request(
`https://app.test/api/viewer/preview?name=a.docx&src=${encodeURIComponent("/api/viewer/file?token=" + token)}`,
);
expect((await handleConvert(request("first"), "preview")).headers.get("x-viewer-cache")).toBe(
"miss",
);
expect((await handleConvert(request("rotated"), "preview")).headers.get("x-viewer-cache")).toBe(
"hit",
);
expect(conversions).toBe(1);
expect(cancellations).toBeGreaterThanOrEqual(1);
allowed = false;
expect((await handleConvert(request("rotated"), "preview")).status).toBe(403);
});
test("source composition falls through on undefined and stops on errors", async () => {
const seen: string[] = [];
const result = await firstOf(
async () => {
seen.push("upload");
return undefined;
},
async () => {
seen.push("static");
return { body: new Response("static").body!, identity: "static" };
},
)(new Request("https://app.test"), { src: "/a", name: "a" });
expect(result?.identity).toBe("static");
expect(seen).toEqual(["upload", "static"]);
await expect(
firstOf(
async () => {
throw new Error("denied");
},
async () => {
throw new Error("must not run");
},
)(new Request("https://app.test"), { src: "/a", name: "a" }),
).rejects.toThrow("denied");
});
test("same-origin policy refuses viewer API links instead of bypassing private policy", async () => {
const source = sameOriginSource();
for (const src of [
"/api/viewer/file?token=bad",
"https://other.test/a",
"https://user:pass@app.test/a",
])
expect(await source(new Request("https://app.test"), { src, name: "a.docx" })).toBeUndefined();
});
test("client helpers use the supplied document origin without global document", async () => {
const fetched: string[] = [];
const request = (async (url: string) => {
fetched.push(String(url));
return Response.json({ formats: { ".docx": "application/pdf" } });
}) as unknown as typeof fetch;
expect(await converterFormats({ baseURI: "https://one.test/page", fetch: request })).toEqual({
".docx": "application/pdf",
});
expect(fetched).toEqual(["https://one.test/api/viewer/capabilities"]);
expect(convertUrl({ src: "/file", name: "a.docx" }, "text", "https://two.test/page")).toContain(
"https://two.test/api/viewer/text",
);
expect(
await loadOptional<{ ok: boolean }>("viewer-sheet", "https://three.test/page", async (url) => {
fetched.push(url);
return { ok: true };
}),
).toEqual({ ok: true });
expect(fetched[1]).toBe("https://three.test/__wrnexus/assets/viewer-sheet.js");
});
test("HTML sanitizer preserves text and layout while removing active and external content", async () => {
const window = new Window();
const html = sanitizeHtml(
'<base href="https://evil.test"><script>alert(1)</script><h1 onclick="x()">Hello</h1><iframe src="//evil"></iframe><a href="javascript:x()">link</a><img src="https://evil" onerror="x()"><p style="background:url(https://evil)">safe</p>',
window as unknown as { DOMParser: new () => DOMParser },
);
expect(html).toContain("<h1>Hello</h1>");
for (const bad of [
"script",
"onclick",
"iframe",
"javascript:",
"https://evil",
"onerror",
"<base",
])
expect(html).not.toContain(bad);
await window.happyDOM.close();
});
test("waveform envelope includes stereo channels and clamps amplitudes", () => {
expect(
waveformPeaks([new Float32Array([0, 0.5, 0, 0]), new Float32Array([0, 0, -2, 0])], 2),
).toEqual([0.5, 1]);
expect(waveformPeaks([new Float32Array(4)], 2)).toEqual([0, 0]);
});
test("filmstrip switching disposes each active renderer and supports keyboard selection", async () => {
const window = new Window();
const host = window.document.createElement("section");
window.document.body.append(host);
const clean: string[] = [];
const unregister = registerRenderer({
kind: "text",
exts: [".txt"],
tier: 2,
component: "TextTest",
mount: ({ parent, source }) => {
parent.textContent = source.name;
return () => {
clean.push(source.name);
};
},
});
const dispose = mountViewer(host as unknown as HTMLElement, [
{ name: "one.txt", blob: new Blob(["one"]) },
{ name: "two.txt", blob: new Blob(["two"]) },
]);
await tick();
const buttons = host.querySelectorAll(".wrn-viewer-file");
(buttons[1] as unknown as HTMLButtonElement).click();
await tick();
expect(clean).toEqual(["one.txt"]);
expect(buttons[1]!.getAttribute("aria-pressed")).toBe("true");
buttons[1]!.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Home", bubbles: true }));
await tick();
expect(clean).toEqual(["one.txt", "two.txt"]);
expect(buttons[0]!.getAttribute("aria-pressed")).toBe("true");
dispose();
expect(clean).toEqual(["one.txt", "two.txt", "one.txt"]);
expect(host.children.length).toBe(0);
unregister();
await window.happyDOM.close();
});
test("text extraction rejects non-text and enforces bounded output", async () => {
const source = { src: "/a.docx", name: "a.docx" };
expect(
await extractText(source, {
baseURI: "https://app.test",
fetch: (async () =>
new Response("search words", {
headers: { "content-type": "text/plain" },
})) as unknown as typeof fetch,
}),
).toBe("search words");
await expect(
extractText(source, {
baseURI: "https://app.test",
fetch: (async () =>
new Response("%PDF", {
headers: { "content-type": "application/pdf" },
})) as unknown as typeof fetch,
}),
).rejects.toThrow("unavailable");
});
+203
View File
@@ -0,0 +1,203 @@
import { describe, expect, test } from "bun:test";
import { Window } from "happy-dom";
import { zipSync, strToU8 } from "fflate";
import { readOdf, readPptx } from "../src/optional/office.ts";
import { isRtf, rtfToText } from "../src/parse/rtf.ts";
import { resolve } from "../src/resolve.ts";
import { RendererRegistry } from "../src/registry.ts";
import { kindOfExtension } from "../src/kinds.ts";
const view = new Window() as unknown as { DOMParser: new () => DOMParser };
const A = "http://schemas.openxmlformats.org/drawingml/2006/main";
const P = "http://schemas.openxmlformats.org/presentationml/2006/main";
const OFFICE = "urn:oasis:names:tc:opendocument:xmlns:office:1.0";
const TEXT = "urn:oasis:names:tc:opendocument:xmlns:text:1.0";
const TABLE = "urn:oasis:names:tc:opendocument:xmlns:table:1.0";
const DRAW = "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0";
const file = (name: string, bytes: Uint8Array, mime = "") => ({
name,
blob: new Blob([bytes as unknown as BlobPart], { type: mime }),
});
/** A slide with one title shape and one body shape, as PowerPoint writes them. */
const slideXml = (title: string, bullets: string[]) =>
`<?xml version="1.0"?><p:sld xmlns:p="${P}" xmlns:a="${A}"><p:cSld><p:spTree>` +
`<p:sp><p:txBody><a:p><a:r><a:t>${title}</a:t></a:r></a:p></p:txBody></p:sp>` +
`<p:sp><p:txBody>${bullets
.map((line) => `<a:p><a:r><a:t>${line}</a:t></a:r></a:p>`)
.join("")}</p:txBody></p:sp>` +
"</p:spTree></p:cSld></p:sld>";
const pptx = zipSync({
// Written out of order on purpose: slide 10 must not sort before slide 2.
"ppt/slides/slide10.xml": strToU8(slideXml("Last slide", ["The end"])),
"ppt/slides/slide1.xml": strToU8(slideXml("Quarterly review", ["Revenue up", "Costs flat"])),
"ppt/slides/slide2.xml": strToU8(slideXml("Regions", ["North", "South"])),
"ppt/notesSlides/notesSlide1.xml": strToU8(
`<?xml version="1.0"?><p:notes xmlns:p="${P}" xmlns:a="${A}"><a:p><a:r><a:t>Say hello first</a:t></a:r></a:p></p:notes>`,
),
// Media must never be inflated for a listing-style read.
"ppt/media/image1.png": new Uint8Array(2048),
});
const odf = (mimetype: string, body: string) =>
zipSync({
mimetype: strToU8(mimetype),
"content.xml": strToU8(
`<?xml version="1.0"?><office:document-content xmlns:office="${OFFICE}" ` +
`xmlns:text="${TEXT}" xmlns:table="${TABLE}" xmlns:draw="${DRAW}">${body}` +
"</office:document-content>",
),
});
describe("PowerPoint", () => {
test("slides come back in numeric order with titles and bullets", async () => {
const deck = await readPptx(pptx, view);
expect(deck.type).toBe("slides");
expect(deck.slides.map((slide) => slide.title)).toEqual([
"Quarterly review",
"Regions",
"Last slide",
]);
expect(deck.slides[0]!.blocks[1]).toEqual(["Revenue up", "Costs flat"]);
expect(deck.slides[0]!.notes).toBe("Say hello first");
expect(deck.slides[1]!.notes).toBeUndefined();
});
test("a file with no slides rejects rather than showing an empty deck", async () => {
await expect(readPptx(zipSync({ "docProps/app.xml": strToU8("<x/>") }), view)).rejects.toThrow(
"no slides",
);
});
});
describe("OpenDocument", () => {
test("text keeps heading levels and list membership", async () => {
const bytes = odf(
"application/vnd.oasis.opendocument.text",
"<office:body><office:text>" +
'<text:h text:outline-level="1">Annual report</text:h>' +
"<text:p>Opening paragraph.</text:p>" +
'<text:h text:outline-level="2">Detail</text:h>' +
"<text:list><text:list-item><text:p>First point</text:p></text:list-item>" +
"<text:list-item><text:p>Second point</text:p></text:list-item></text:list>" +
"</office:text></office:body>",
);
const document = await readOdf(bytes, view);
expect(document.type).toBe("text");
if (document.type !== "text") throw new Error("unreachable");
expect(document.blocks[0]).toEqual({ level: 1, text: "Annual report" });
expect(document.blocks[1]).toEqual({ level: 0, text: "Opening paragraph." });
expect(document.blocks[2]!.level).toBe(2);
expect(document.blocks[3]).toEqual({ level: 0, text: "First point", list: true });
});
test("spreadsheets expand repeated cells but not trailing padding", async () => {
const bytes = odf(
"application/vnd.oasis.opendocument.spreadsheet",
"<office:body><office:spreadsheet>" +
'<table:table table:name="Revenue">' +
"<table:table-row><table:table-cell><text:p>Region</text:p></table:table-cell>" +
"<table:table-cell><text:p>Q1</text:p></table:table-cell></table:table-row>" +
"<table:table-row><table:table-cell><text:p>North</text:p></table:table-cell>" +
'<table:table-cell table:number-columns-repeated="2"><text:p>10</text:p></table:table-cell>' +
'<table:table-cell table:number-columns-repeated="16384"/></table:table-row>' +
"</table:table></office:spreadsheet></office:body>",
);
const document = await readOdf(bytes, view);
if (document.type !== "sheets") throw new Error("expected sheets");
expect(document.sheets[0]!.name).toBe("Revenue");
expect(document.sheets[0]!.header).toEqual(["Region", "Q1"]);
// The repeat of 2 is real data; the 16384 empty tail is dropped.
expect(document.sheets[0]!.rows).toEqual([["North", "10", "10"]]);
});
test("presentations become slides", async () => {
const bytes = odf(
"application/vnd.oasis.opendocument.presentation",
"<office:body><office:presentation>" +
'<draw:page draw:name="page1"><draw:frame><text:p>Kickoff</text:p></draw:frame>' +
"<draw:frame><text:p>Agenda</text:p><text:p>Timeline</text:p></draw:frame></draw:page>" +
"</office:presentation></office:body>",
);
const document = await readOdf(bytes, view);
if (document.type !== "slides") throw new Error("expected slides");
expect(document.slides[0]!.title).toBe("Kickoff");
expect(document.slides[0]!.blocks[1]).toEqual(["Agenda", "Timeline"]);
});
test("a zip that is not an OpenDocument file rejects", async () => {
await expect(readOdf(zipSync({ "a.txt": strToU8("hi") }), view)).rejects.toThrow(
"Not an OpenDocument",
);
});
});
describe("RTF", () => {
test("control words, escapes and unicode become text", () => {
const source =
"{\\rtf1\\ansi{\\fonttbl{\\f0 Times;}}{\\colortbl;\\red0\\green0\\blue0;}" +
"\\f0\\fs24 Hello\\tab world\\par Second line\\par " +
"Caf\\'e9 and \\u8212? dashes\\par}";
const text = rtfToText(source);
expect(text).toContain("Hello\tworld");
expect(text).toContain("Second line");
// Font and colour tables must not leak into the output.
expect(text).not.toContain("Times");
expect(text).not.toContain("red0");
expect(text).toContain("Café");
expect(text).toContain("—");
});
test("optional destinations are skipped whole", () => {
const text = rtfToText(
"{\\rtf1{\\*\\generator Word 16;}Visible text{\\*\\bkmkstart hidden}\\par}",
);
expect(text).toContain("Visible text");
expect(text).not.toContain("Word 16");
expect(text).not.toContain("hidden");
});
test("non-RTF input is rejected", () => {
expect(isRtf("hello")).toBe(false);
expect(() => rtfToText("hello")).toThrow("Not an RTF");
});
});
describe("routing", () => {
// Optional modules cannot load outside a browser, so `resolve` would skip
// every renderer that needs one. Registration and precedence are the units
// under test here; the mounted result is verified in the example app.
const registry = new RendererRegistry();
const components = (ext: string) =>
registry.candidates(kindOfExtension(ext), ext).map((renderer) => renderer.component);
test("presentations and documents prefer the converter, with a local reader behind it", () => {
expect(components(".pptx")).toEqual(["ConvertedViewer", "SlidesViewer"]);
expect(components(".odt")).toEqual(["ConvertedViewer", "OpenDocumentViewer"]);
expect(components(".odp")).toEqual(["ConvertedViewer", "OpenDocumentViewer"]);
expect(components(".docx")).toEqual(["ConvertedViewer", "DocxViewer"]);
});
test("spreadsheets prefer the local reader: the data matters, not the layout", () => {
expect(components(".ods")[0]).toBe("SheetViewer");
expect(components(".xlsx")[0]).toBe("SheetViewer");
// A workbook SheetJS cannot open still has the OpenDocument reader behind it.
expect(components(".ods")).toContain("OpenDocumentViewer");
});
test("legacy binary formats stay converter-only", () => {
expect(components(".doc")).toEqual(["ConvertedViewer"]);
expect(components(".ppt")).toEqual(["ConvertedViewer"]);
});
test("RTF needs no optional module and no converter", async () => {
expect(components(".rtf")[0]).toBe("ConvertedViewer");
const source = String.raw`{\rtf1\ansi Hello\par}`;
expect(isRtf(source)).toBe(true);
const plan = await resolve(file("letter.rtf", strToU8(source)));
expect(plan.renderer.component).toBe("RtfViewer");
expect(rtfToText(source)).toBe("Hello");
});
});
+135
View File
@@ -0,0 +1,135 @@
import { describe, expect, test } from "bun:test";
import { docxToHtml } from "../src/optional/docx.ts";
import { readWorkbook } from "../src/optional/sheet.ts";
import { listZip } from "../src/optional/zip.ts";
import { zipSync, strToU8 } from "fflate";
/**
* These exercise the parsers themselves. The browser wiring — one runtime asset
* per installed package, fetched on first use — is covered in component.test.ts.
*/
describe("zip listing", () => {
const archive = zipSync({
"docs/readme.md": strToU8("hello world\n".repeat(20)),
"src/index.ts": strToU8("export const value = 1;\n"),
});
test("entries are listed with their uncompressed sizes", async () => {
const entries = await listZip(archive);
expect(entries.map((entry) => entry.name).sort()).toEqual(["docs/readme.md", "src/index.ts"]);
expect(entries.find((entry) => entry.name === "docs/readme.md")!.size).toBe(240);
});
test("a corrupt archive rejects rather than resolving empty", async () => {
await expect(listZip(new Uint8Array([0x50, 0x4b, 3, 4, 9, 9, 9]))).rejects.toThrow();
});
});
describe("workbook reading", () => {
// Built with the same shape as examples/basic-app/public/viewer/sample.xlsx.
const NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
const R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
const sheet = (rows: (string | number)[][]) =>
`<?xml version="1.0"?><worksheet xmlns="${NS}"><sheetData>${rows
.map(
(row, rowIndex) =>
`<row r="${rowIndex + 1}">${row
.map((value, index) => {
const ref = `${String.fromCharCode(65 + index)}${rowIndex + 1}`;
return typeof value === "number"
? `<c r="${ref}"><v>${value}</v></c>`
: `<c r="${ref}" t="inlineStr"><is><t>${value}</t></is></c>`;
})
.join("")}</row>`,
)
.join("")}</sheetData></worksheet>`;
const workbook = zipSync({
"[Content_Types].xml": strToU8(
'<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
'<Default Extension="xml" ContentType="application/xml"/>' +
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' +
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' +
'<Override PartName="/xl/worksheets/sheet2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' +
"</Types>",
),
"_rels/.rels": strToU8(
`<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="${R}/officeDocument" Target="xl/workbook.xml"/></Relationships>`,
),
"xl/workbook.xml": strToU8(
`<?xml version="1.0"?><workbook xmlns="${NS}" xmlns:r="${R}"><sheets>` +
'<sheet name="Revenue" sheetId="1" r:id="rId1"/>' +
'<sheet name="Headcount" sheetId="2" r:id="rId2"/>' +
"</sheets></workbook>",
),
"xl/_rels/workbook.xml.rels": strToU8(
`<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
`<Relationship Id="rId1" Type="${R}/worksheet" Target="worksheets/sheet1.xml"/>` +
`<Relationship Id="rId2" Type="${R}/worksheet" Target="worksheets/sheet2.xml"/>` +
"</Relationships>",
),
"xl/worksheets/sheet1.xml": strToU8(
sheet([
["Region", "Q1"],
["North", 15400],
["South", 11200],
]),
),
"xl/worksheets/sheet2.xml": strToU8(
sheet([
["Team", "People"],
["Engineering", 42],
]),
),
});
test("every sheet comes back named, with a header and string cells", () => {
const sheets = readWorkbook(workbook);
expect(sheets.map((item) => item.name)).toEqual(["Revenue", "Headcount"]);
expect(sheets[0]!.header).toEqual(["Region", "Q1"]);
expect(sheets[0]!.rows).toEqual([
["North", "15400"],
["South", "11200"],
]);
expect(sheets[1]!.rows).toEqual([["Engineering", "42"]]);
});
});
describe("document conversion", () => {
const W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
const R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
const paragraph = (text: string, style?: string) =>
`<w:p>${style ? `<w:pPr><w:pStyle w:val="${style}"/></w:pPr>` : ""}` +
`<w:r><w:t xml:space="preserve">${text}</w:t></w:r></w:p>`;
const document = zipSync({
"[Content_Types].xml": strToU8(
'<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
'<Default Extension="xml" ContentType="application/xml"/>' +
'<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>' +
"</Types>",
),
"_rels/.rels": strToU8(
`<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="${R}/officeDocument" Target="word/document.xml"/></Relationships>`,
),
"word/document.xml": strToU8(
`<?xml version="1.0"?><w:document xmlns:w="${W}"><w:body>` +
paragraph("Quarterly summary", "Heading1") +
paragraph("Bodies of text survive; page layout does not.") +
"</w:body></w:document>",
),
});
test("a docx becomes semantic HTML", async () => {
const html = await docxToHtml(document);
expect(html).toContain("<h1>Quarterly summary</h1>");
expect(html).toContain("Bodies of text survive");
// Semantic output only: no document chrome, and nothing executable.
expect(html).not.toContain("<script");
});
test("a file that is not a docx rejects", async () => {
await expect(docxToHtml(strToU8("not a document"))).rejects.toThrow();
});
});
+182
View File
@@ -0,0 +1,182 @@
import { describe, expect, test } from "bun:test";
import { Window } from "happy-dom";
import { openViewer, sourceFromTrigger, startViewerTriggers } from "../src/overlay.ts";
import { mountViewer, startViewerRuntime } from "../src/runtime.ts";
async function settled(ticks = 30) {
for (let i = 0; i < ticks; i++) await new Promise((done) => setTimeout(done, 5));
}
function page() {
const window = new Window({ url: "https://app.test/" });
return { window, doc: window.document as unknown as Document };
}
const csv = { name: "data.csv", blob: new Blob(["a,b\n1,2\n"]) };
describe("opening a preview over the page", () => {
test("the overlay mounts a viewer and offers a way out", async () => {
const { window, doc } = page();
const opened = openViewer(csv, { document: doc });
await settled();
const dialog = doc.querySelector(".wrn-viewer-dialog")!;
expect(dialog).not.toBeNull();
expect(dialog.querySelector(".wrn-viewer-content")!.getAttribute("data-viewer-state")).toBe(
"ready",
);
expect(dialog.querySelector('[data-viewer-action="close"]')).not.toBeNull();
opened.close();
expect(doc.querySelector(".wrn-viewer-dialog")).toBeNull();
await window.happyDOM.close();
});
test("closing disposes the viewer rather than leaving it behind the overlay", async () => {
const { window, doc } = page();
const opened = openViewer(csv, { document: doc });
await settled();
const host = doc.querySelector(".wrn-viewer-dialog .wrn-viewer")!;
expect(host.querySelector("table")).not.toBeNull();
opened.close();
// The host went with the dialog, so nothing keeps running off-screen.
expect(host.isConnected).toBe(false);
await window.happyDOM.close();
});
test("closing twice, or from the dialog's own close event, is safe", async () => {
const { window, doc } = page();
let closes = 0;
const opened = openViewer(csv, { document: doc, onClose: () => closes++ });
await settled();
doc.querySelector(".wrn-viewer-dialog")!.dispatchEvent(new window.Event("close") as never);
opened.close();
expect(closes).toBe(1);
await window.happyDOM.close();
});
test("a click on the dialog itself is a backdrop click", async () => {
const { window, doc } = page();
openViewer(csv, { document: doc });
await settled();
const dialog = doc.querySelector(".wrn-viewer-dialog")!;
// A click inside the viewer must not close it.
dialog
.querySelector(".wrn-viewer")!
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }) as never);
expect(doc.querySelector(".wrn-viewer-dialog")).not.toBeNull();
dialog.dispatchEvent(new window.MouseEvent("click", { bubbles: true }) as never);
expect(doc.querySelector(".wrn-viewer-dialog")).toBeNull();
await window.happyDOM.close();
});
});
describe("triggers", () => {
test("a data attribute is read into a source", () => {
const { window, doc } = page();
const button = doc.createElement("button");
button.dataset.viewerOpen = "";
button.dataset.src = "/files/a.pdf";
button.dataset.name = "a.pdf";
button.dataset.size = "120";
expect(sourceFromTrigger(button)).toEqual({
src: "/files/a.pdf",
key: undefined,
store: undefined,
name: "a.pdf",
mime: undefined,
size: 120,
});
// Nothing to open is not an error; the click is simply ignored.
expect(sourceFromTrigger(doc.createElement("button"))).toBeUndefined();
void window.happyDOM.close();
});
test("clicking an element with data-viewer-open opens the preview", async () => {
const { window, doc } = page();
const stop = startViewerTriggers(doc);
const button = doc.createElement("button");
button.dataset.viewerOpen = "";
button.dataset.src = "https://app.test/notes.csv";
button.dataset.name = "notes.csv";
doc.body.append(button);
const original = globalThis.fetch;
globalThis.fetch = (async () =>
new Response("a,b\n1,2\n", { headers: { "content-type": "text/csv" } })) as never;
try {
button.dispatchEvent(new window.MouseEvent("click", { bubbles: true }) as never);
await settled();
expect(doc.querySelector(".wrn-viewer-dialog")).not.toBeNull();
expect(doc.querySelector(".wrn-viewer-dialog table")).not.toBeNull();
} finally {
globalThis.fetch = original;
stop();
}
await window.happyDOM.close();
});
});
describe("layout modes", () => {
test("fullScreen mounts covering the page, and Close returns it inline", async () => {
const { window, doc } = page();
const host = doc.createElement("section");
doc.body.append(host);
const dispose = mountViewer(host as never, csv, { fullScreen: true });
await settled();
expect(host.classList.contains("wrn-viewer-expanded")).toBe(true);
const close = host.querySelector('[data-viewer-action="close"]') as HTMLButtonElement;
expect(close).not.toBeNull();
close.click();
expect(host.classList.contains("wrn-viewer-expanded")).toBe(false);
// The control goes with the state it exits.
expect(host.querySelector('[data-viewer-action="close"]')).toBeNull();
dispose();
await window.happyDOM.close();
});
test("an inline viewer offers no Close, because there is nothing to close", async () => {
const { window, doc } = page();
const host = doc.createElement("section");
doc.body.append(host);
const dispose = mountViewer(host as never, csv);
await settled();
expect(host.querySelector('[data-viewer-action="close"]')).toBeNull();
dispose();
await window.happyDOM.close();
});
test("layout=page drops the card chrome", async () => {
const { window, doc } = page();
const host = doc.createElement("section");
doc.body.append(host);
const dispose = mountViewer(host as never, csv, { layout: "page" });
await settled();
expect(host.classList.contains("wrn-viewer-as-page")).toBe(true);
dispose();
await window.happyDOM.close();
});
test("the runtime re-mounts when the layout attributes change", async () => {
const { window, doc } = page();
const host = doc.createElement("section");
host.dataset.wrnexusRuntime = "viewer";
host.dataset.name = "data.csv";
host.dataset.src = "https://app.test/data.csv";
host.dataset.fullScreen = "false";
doc.body.append(host);
const original = globalThis.fetch;
globalThis.fetch = (async () =>
new Response("a,b\n1,2\n", { headers: { "content-type": "text/csv" } })) as never;
const stop = startViewerRuntime(doc);
try {
await settled();
expect(host.classList.contains("wrn-viewer-expanded")).toBe(false);
// Without this in the remount signature the attribute change is ignored.
host.dataset.fullScreen = "true";
await settled();
expect(host.classList.contains("wrn-viewer-expanded")).toBe(true);
} finally {
globalThis.fetch = original;
stop();
}
await window.happyDOM.close();
});
});
+167
View File
@@ -0,0 +1,167 @@
import { describe, expect, test } from "bun:test";
import { Window } from "happy-dom";
import { rtfToText } from "../src/parse/rtf.ts";
import { renderSlides } from "../src/renderers/slides.ts";
import { mountViewer } from "../src/runtime.ts";
import { registerRenderer } from "../src/registry.ts";
import { resetConverterProbe } from "../src/convert/client.ts";
import { mountPdf } from "../src/renderers/pdf.ts";
async function settled(ticks = 40) {
for (let i = 0; i < ticks; i++) await new Promise((done) => setTimeout(done, 5));
}
function fixture() {
const window = new Window({ url: "https://app.test/" });
const host = window.document.createElement("section") as unknown as HTMLElement;
window.document.body.append(host as never);
return { window, host };
}
describe("RTF unicode substitution", () => {
// \ucN says how many ANSI characters stand in for each \uN. Assuming 1 ate a
// real character after \uc0, and left a stray "?" behind after \uc2.
test("uc0 means the escape stands alone", () => {
expect(rtfToText(String.raw`{\rtf1\ansi\uc0舒 Hello world\par}`)).toBe("—Hello world");
});
test("uc2 skips exactly two substitutes", () => {
expect(rtfToText(String.raw`{\rtf1\ansi\uc2舒?? Hello\par}`)).toBe("— Hello");
});
test("the count is group-scoped and restored on close", () => {
expect(rtfToText(String.raw`{\rtf1\ansi{\uc0舒}舒? tail\par}`)).toBe("—— tail");
});
});
describe("slide navigation", () => {
// The counter used to be refreshed from a click listener, so arrow keys moved
// the deck without moving the label.
test("every move notifies, keyboard included", () => {
const { window } = fixture();
const doc = window.document as unknown as Document;
const seen: number[] = [];
const deck = renderSlides(
doc,
[
{ title: "One", blocks: [["One"]] },
{ title: "Two", blocks: [["Two"]] },
{ title: "Three", blocks: [["Three"]] },
],
(index) => seen.push(index),
);
doc.body.append(deck.node as never);
deck.next(1);
deck.node.dispatchEvent(
new window.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }) as never,
);
expect(seen).toEqual([0, 1, 2]);
expect(deck.status()).toBe("3 of 3");
});
});
describe("streaming renderers and the body", () => {
function stubFetch(requests: string[], body: Uint8Array) {
const original = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input);
const range = new Headers(init?.headers).get("range");
requests.push(`${init?.method ?? "GET"}${range ? " ranged" : ""} ${new URL(url).pathname}`);
if (url.includes("/api/viewer/capabilities"))
return Response.json({ converters: ["fake"], formats: { ".psd": "image/png" } });
return new Response(body as unknown as BodyInit, {
headers: {
"content-length": String(body.byteLength),
"content-type": "image/vnd.adobe.photoshop",
},
});
}) as unknown as typeof fetch;
return () => {
globalThis.fetch = original;
};
}
test("a converted preview never downloads the original body", async () => {
resetConverterProbe();
const { window, host } = fixture();
const requests: string[] = [];
// 40 MB of design file: reading it into a blob only to discard it was the bug.
const body = new Uint8Array(40 * 1024 * 1024);
body.set([0x38, 0x42, 0x50, 0x53]);
const restore = stubFetch(requests, body);
try {
const dispose = mountViewer(host, { src: "https://app.test/art.psd", name: "art.psd" });
await settled();
expect(requests.some((entry) => entry.includes("/api/viewer/capabilities"))).toBe(true);
expect(
requests.filter((entry) => entry === "GET /art.psd"),
// Only the bounded ranged sniff may touch the source.
).toEqual([]);
dispose();
} finally {
restore();
resetConverterProbe();
}
await window.happyDOM.close();
});
test("a streaming renderer that fails still falls through to a buffered one", async () => {
const { window, host } = fixture();
// Registered first so it sits *behind* the streaming renderer in the ladder.
const unregisterBuffered = registerRenderer({
kind: "text",
exts: ["probe"],
tier: 2,
component: "BufferedProbe",
async mount({ parent, file }) {
parent.textContent = `buffered:${await file!.text()}`;
},
});
const unregisterStreaming = registerRenderer({
kind: "text",
exts: ["probe"],
tier: 2,
component: "StreamingProbe",
streaming: true,
mount() {
throw new Error("streaming renderer failed");
},
});
const requests: string[] = [];
const restore = stubFetch(requests, new TextEncoder().encode("hello"));
try {
const dispose = mountViewer(host, { src: "https://app.test/a.probe", name: "a.probe" });
await settled();
expect(host.textContent).toContain("buffered:hello");
dispose();
} finally {
restore();
unregisterStreaming();
unregisterBuffered();
}
await window.happyDOM.close();
});
});
describe("PDF framing", () => {
// Reaching the native frame as a fallback means a body was already read. The
// object URL for it cannot be framed under a `default-src 'self'` policy, so
// the original URL has to win whenever there is one.
test("the frame uses the original URL even when a body is in hand", async () => {
const { window, host } = fixture();
const blob = new Blob(["%PDF-1.7"], { type: "application/pdf" });
await mountPdf({
parent: host,
file: blob,
source: { src: "https://app.test/report.pdf", name: "report.pdf" },
sourceUrl: async () => "blob:https://app.test/should-not-be-used",
kind: "pdf",
button: () => window.document.createElement("button") as never,
objectUrl: () => "blob:https://app.test/should-not-be-used",
degrade: () => {},
signal: new AbortController().signal,
});
expect(host.querySelector("iframe")!.getAttribute("src")).toBe("https://app.test/report.pdf");
await window.happyDOM.close();
});
});
+200
View File
@@ -0,0 +1,200 @@
import { describe, expect, test } from "bun:test";
import { extensions, kindOfExtension } from "../src/kinds.ts";
import { RendererRegistry } from "../src/registry.ts";
import { resolve } from "../src/resolve.ts";
import { readBounded } from "../src/source.ts";
import { sniff } from "../src/sniff.ts";
const file = (name: string, bytes: string | number[], mime = "") => ({
name,
blob: new Blob([typeof bytes === "string" ? bytes : new Uint8Array(bytes)], { type: mime }),
});
describe("classification and signatures", () => {
for (const [kind, exts] of Object.entries(extensions))
for (const ext of exts) {
test(`classifies .${ext}`, () =>
expect(String(kindOfExtension(`.${ext.toUpperCase()}`))).toBe(kind));
}
const signatures: [string, string | number[], string][] = [
["png", [137, 80, 78, 71, 13, 10, 26, 10], "ImageViewer"],
["jpeg", [255, 216, 255], "ImageViewer"],
["gif", "GIF89a", "ImageViewer"],
["webp", "RIFF0000WEBP", "ImageViewer"],
["bmp", "BM00", "ImageViewer"],
["ico", [0, 0, 1, 0], "ImageViewer"],
["avif", "0000ftypavif", "ImageViewer"],
["pdf", "%PDF-1.7", "PdfViewer"],
["wav", "RIFF0000WAVE", "MediaViewer"],
["mp3", "ID3", "MediaViewer"],
["aac", [255, 241, 80, 128], "MediaViewer"],
["flac", "fLaC", "MediaViewer"],
["ogg", "OggS", "MediaViewer"],
["mp4", "0000ftypisom", "MediaViewer"],
["mov", "0000ftypqt ", "MediaViewer"],
["webm", [0x1a, 0x45, 0xdf, 0xa3], "MediaViewer"],
["txt", "hello", "CodeViewer"],
["js", "const value = 1;", "CodeViewer"],
["html", "<html><script>alert(1)</script></html>", "HtmlViewer"],
];
for (const [ext, bytes, component] of signatures)
test(`resolves ${ext}`, async () => {
expect((await resolve(file(`test.${ext}`, bytes))).renderer.component).toBe(component);
});
test("HTML masquerading as PNG is shown as inert source", async () => {
const plan = await resolve(
file("fake.png", "<!doctype html><script>alert(1)</script>", "image/png"),
);
expect(plan.kind).toBe("markup");
expect(plan.renderer.component).toBe("CodeViewer");
});
test("SVG masquerading as image never reaches native image rendering", async () => {
const plan = await resolve(file("fake.png", '<svg onload="alert(1)"></svg>', "image/png"));
expect(plan.kind).toBe("vector");
expect(plan.sandboxed).toBe(true);
expect(plan.renderer.component).toBe("SvgViewer");
});
test("binary or executables cannot become text through MIME", async () => {
expect((await resolve(file("fake.txt", [0, 1, 2], "text/plain"))).renderer.component).toBe(
"DownloadCard",
);
expect((await resolve(file("fake.png", "MZ123", "image/png"))).renderer.component).toBe(
"DownloadCard",
);
expect((await resolve(file("fake.exe", "hello"))).renderer.component).toBe("DownloadCard");
});
test("UTF-8 truncated at sniff boundary is still text", () => {
expect(sniff(new Uint8Array([97, 0xe2, 0x82])).kind).toBe("text");
});
});
describe("degradation and network limits", () => {
test("missing optional dependencies fall through to native renderer", async () => {
const registry = new RendererRegistry();
registry.register({
kind: "pdf",
exts: [".pdf"],
tier: 2,
component: "RichPdf",
load: async () => {
throw new Error("missing");
},
});
expect((await resolve(file("test.pdf", "%PDF-1.7"), { registry })).renderer.component).toBe(
"PdfViewer",
);
});
test("overrides can be removed and conflicting signatures bypass them", async () => {
const registry = new RendererRegistry();
const unregister = registry.register({
kind: "text",
exts: ["txt"],
tier: 2,
component: "Custom",
});
expect((await resolve(file("test.txt", "hi"), { registry })).renderer.component).toBe("Custom");
expect((await resolve(file("test.txt", "%PDF-1.7"), { registry })).renderer.component).toBe(
"PdfViewer",
);
unregister();
expect((await resolve(file("test.txt", "hi"), { registry })).renderer.component).toBe(
"CodeViewer",
);
});
test("unavailable converters degrade", async () => {
const registry = new RendererRegistry();
registry.register({ kind: "binary", exts: [".psd"], tier: 3, component: "Convert" });
expect((await resolve(file("image.psd", [0, 0, 0]), { registry })).renderer.component).toBe(
"DownloadCard",
);
});
test("oversized HEAD prevents body fetch", async () => {
const methods: string[] = [];
const request = (async (_url, init) => {
methods.push(init?.method ?? "GET");
return new Response(null, { headers: { "content-length": "400000000" } });
}) as typeof fetch;
const plan = await resolve(
{ name: "large.mp4", src: "https://example.test/file" },
{ fetch: request },
);
expect(plan.renderer.component).toBe("DownloadCard");
expect(methods).toEqual(["HEAD"]);
});
test("unknown-length or ignored Range stream is cancelled at prefix cap", async () => {
let cancelled = false;
const bytes = await readBounded(
new Response(
new ReadableStream({
start(c) {
c.enqueue(new Uint8Array(8192));
},
cancel() {
cancelled = true;
},
}),
),
4096,
true,
);
expect(bytes.length).toBe(4096);
expect(cancelled).toBe(true);
});
test("full reads enforce actual bytes despite dishonest metadata", async () => {
await expect(
readBounded(new Response("123456", { headers: { "content-length": "1" } }), 3),
).rejects.toThrow("limit");
});
test("HEAD without support still performs bounded sniff", async () => {
const request = (async (_url, init) =>
init?.method === "HEAD"
? new Response(null, { status: 405 })
: new Response("hello")) as typeof fetch;
expect(
(await resolve({ name: "text.txt", src: "/file" }, { fetch: request })).renderer.component,
).toBe("CodeViewer");
});
test("invalid URLs, rejected fetches and key-only sources degrade", async () => {
expect((await resolve({ name: "a.txt", src: "javascript:alert(1)" })).renderer.component).toBe(
"DownloadCard",
);
expect((await resolve({ name: "a.txt", key: "private" })).renderer.component).toBe(
"DownloadCard",
);
const request = (async () => {
throw new Error("offline");
}) as unknown as typeof fetch;
expect(
(await resolve({ name: "a.txt", src: "/file" }, { fetch: request })).renderer.component,
).toBe("DownloadCard");
});
});
describe("fallback reasons and unrenderable signatures", () => {
test("a kind with no renderer reports why, not a size limit", async () => {
const request = (async () =>
new Response("PK", {
headers: { "content-length": "512", "content-type": "application/zip" },
})) as unknown as typeof fetch;
const plan = await resolve(
{ name: "bundle.zip", src: "https://example.test/bundle.zip" },
{ fetch: request },
);
expect(plan.kind).toBe("archive");
expect(plan.reason).toBe("Preview unavailable for this file");
});
test("design formats with ASCII-clean headers are not mistaken for text", async () => {
const ascii = (signature: number[]) => {
const bytes = new Uint8Array(4096).fill(0x41);
bytes.set(signature);
return bytes;
};
expect(sniff(ascii([0x38, 0x42, 0x50, 0x53])).kind).toBe("binary");
expect(sniff(ascii([0x49, 0x49, 0x2a, 0x00])).kind).toBe("binary");
expect(sniff(ascii([0x4d, 0x4d, 0x00, 0x2a])).kind).toBe("binary");
expect(
(await resolve({ name: "art.psd", blob: new Blob([ascii([0x38, 0x42, 0x50, 0x53])]) }))
.renderer.component,
).toBe("DownloadCard");
});
});
+147
View File
@@ -0,0 +1,147 @@
import { expect, test } from "bun:test";
import { Window } from "happy-dom";
import { mountViewer } from "../src/runtime.ts";
import { registerRenderer } from "../src/registry.ts";
async function settled() {
for (let i = 0; i < 15; i++) await new Promise((resolve) => setTimeout(resolve, 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 };
}
test("image controls, failure fallback, and teardown", async () => {
const { window, host } = fixture();
const dispose = mountViewer(host, {
name: "<script>bad</script>.png",
blob: new Blob([new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])]),
});
await settled();
expect(host.querySelector("script")).toBeNull();
const image = host.querySelector("img")!;
expect(image).not.toBeNull();
const zoom = [...host.querySelectorAll("button")].find(
(button) => button.textContent === "Zoom in",
)!;
zoom.click();
expect(image.style.width).toBe("125%");
const url = image.src;
image.dispatchEvent(new window.Event("error") as unknown as Event);
expect(host.textContent).toContain("Image could not be displayed");
dispose();
expect(host.children.length).toBe(0);
await expect(fetch(url)).rejects.toThrow();
await window.happyDOM.close();
});
test("unsafe links are omitted and unavailable files retain metadata", async () => {
const { window, host } = fixture();
const dispose = mountViewer(host, { name: "file", src: "javascript:alert(1)", size: 123 });
await settled();
expect(host.querySelector("a")).toBeNull();
// The fallback card keeps the name and a human-readable size.
expect(host.textContent).toContain("file");
expect(host.textContent).toContain("123 B");
dispose();
await window.happyDOM.close();
});
test("markup source never creates executable DOM", async () => {
const { window, host } = fixture();
const dispose = mountViewer(host, {
name: "hostile.html",
blob: new Blob(['<html><script>alert(1)</script><img onerror="alert(1)"></html>']),
});
await settled();
expect(host.querySelector("script, img")).toBeNull();
const frame = host.querySelector("iframe")!;
expect(frame.getAttribute("sandbox")).toBe("");
expect(frame.srcdoc).not.toContain("<script>");
expect(frame.srcdoc).not.toContain("onerror");
[...host.querySelectorAll("button")].find((button) => button.textContent === "Source")!.click();
expect(host.textContent).toContain("<script>alert(1)</script>");
dispose();
await window.happyDOM.close();
});
test("disposing a pending custom renderer runs its eventual cleanup", async () => {
const { window, host } = fixture();
let finish!: () => void;
let cleaned = false;
const ready = new Promise<void>((resolve) => {
finish = resolve;
});
const unregister = registerRenderer({
kind: "text",
exts: ["txt"],
tier: 2,
component: "Custom",
mount: async () => {
await ready;
return () => {
cleaned = true;
};
},
});
const dispose = mountViewer(host, { name: "a.txt", blob: new Blob(["hello"]) });
await settled();
dispose();
finish();
await settled();
expect(cleaned).toBe(true);
expect(host.children.length).toBe(0);
unregister();
await window.happyDOM.close();
});
test("a renderer without a mount adapter never blocks the preview", async () => {
const { window, host } = fixture();
const unregister = registerRenderer({
kind: "text",
exts: ["txt"],
tier: 2,
component: "NoMount",
});
const dispose = mountViewer(host, { name: "a.txt", blob: new Blob(["plain body"]) });
await settled();
expect(host.textContent).toContain("plain body");
dispose();
unregister();
await window.happyDOM.close();
});
test("an http source streams from its own URL instead of a blob copy", async () => {
const { window, host } = fixture();
let bodyRequests = 0;
const original = globalThis.fetch;
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
const method = init?.method ?? "GET";
if (method === "HEAD") return new Response(null, { headers: { "content-type": "video/mp4" } });
bodyRequests++;
return new Response("\0\0\0\0ftypisom");
}) as unknown as typeof fetch;
try {
const dispose = mountViewer(host, { name: "clip.mp4", src: "https://cdn.test/clip.mp4" });
await settled();
const video = host.querySelector("video")!;
expect(video).not.toBeNull();
// The element points at the original URL: no blob copy, no full download.
expect(video.getAttribute("src")).toBe("https://cdn.test/clip.mp4");
expect(bodyRequests).toBe(1); // the bounded sniff only
dispose();
} finally {
globalThis.fetch = original;
}
await window.happyDOM.close();
});
test("a local file still renders from an object URL under its sniffed type", async () => {
const { window, host } = fixture();
const dispose = mountViewer(host, {
name: "shot.png",
blob: new Blob([new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])]),
});
await settled();
expect(host.querySelector("img")!.src.startsWith("blob:")).toBe(true);
dispose();
await window.happyDOM.close();
});
+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();
});
});
+250
View File
@@ -0,0 +1,250 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { configureStorage } from "@wrnexus/uploader";
import { configureUploads } from "../src/uploads/config.ts";
import { handleFile, handleSource, uploadedConversionSource } from "../src/uploads/service.ts";
import { viewerPlugin } from "../src/plugin.ts";
import { Window } from "happy-dom";
import { mountViewer } from "../src/runtime.ts";
const SECRET = "test-secret-value-32-chars-minimum!!";
const root = mkdtempSync(join(tmpdir(), "wrn-viewer-uploads-"));
function storage() {
for (const dir of ["public", "private"])
mkdirSync(join(root, "uploads", dir), { recursive: true });
writeFileSync(join(root, "uploads", "public", "logo.png"), Buffer.from([137, 80, 78, 71]));
writeFileSync(join(root, "uploads", "private", "report.pdf"), Buffer.from("%PDF-1.7 private"));
configureStorage(
{
default: "public",
stores: {
public: { driver: "local", access: "public", dir: "uploads/public" },
private: { driver: "local", access: "private", dir: "uploads/private" },
secret: { driver: "local", access: "private", dir: "uploads/private" },
},
},
root,
);
}
storage();
const ask = (query: string) => new Request(`https://app.test/api/viewer/source?${query}`);
const body = async (response: Response) =>
(await response.json()) as { src: string; name: string; mime?: string };
afterEach(() => configureUploads(undefined));
describe("resolving a storage key", () => {
test("nothing resolves until uploads are enabled", async () => {
expect((await handleSource(ask("key=logo.png"))).status).toBe(404);
});
test("a public object answers with its own URL, no token", async () => {
configureUploads({ stores: ["public"] });
const response = await handleSource(ask("key=logo.png&store=public"));
expect(response.status).toBe(200);
const descriptor = await body(response);
expect(descriptor.src).toContain("logo.png");
expect(descriptor.src).not.toContain("token=");
expect(descriptor.name).toBe("logo.png");
// The MIME comes from the uploader's table, not a second copy of it.
expect(descriptor.mime).toBe("image/png");
});
test("stores outside the allow-list are refused", async () => {
configureUploads({ stores: ["public"], secret: SECRET, authorize: () => true });
expect((await handleSource(ask("key=report.pdf&store=private"))).status).toBe(403);
});
test("keys that try to escape the store are refused", async () => {
configureUploads({ stores: ["public"] });
for (const key of ["../secrets.env", "/etc/passwd", "a/../../b"])
expect((await handleSource(ask(`key=${encodeURIComponent(key)}&store=public`))).status).toBe(
400,
);
});
test("a private object needs both a secret and an authorize hook", async () => {
configureUploads({ stores: ["private"] });
expect((await handleSource(ask("key=report.pdf&store=private"))).status).toBe(403);
configureUploads({ stores: ["private"], secret: SECRET });
expect((await handleSource(ask("key=report.pdf&store=private"))).status).toBe(403);
});
test("authorize decides, and a refusal mints nothing", async () => {
const seen: { store: string; key: string }[] = [];
configureUploads({
stores: ["private"],
secret: SECRET,
authorize: (_request, params) => {
seen.push(params);
return false;
},
});
expect((await handleSource(ask("key=report.pdf&store=private"))).status).toBe(403);
expect(seen).toEqual([{ store: "private", key: "report.pdf" }]);
});
});
describe("serving a signed link", () => {
const signedFor = async (key: string, store = "private") => {
configureUploads({ stores: [store], secret: SECRET, authorize: () => true });
const descriptor = await body(await handleSource(ask(`key=${key}&store=${store}`)));
return new URL(descriptor.src, "https://app.test").searchParams.get("token")!;
};
const fetchFile = (token: string, init: RequestInit = {}) =>
handleFile(
new Request(`https://app.test/api/viewer/file?token=${encodeURIComponent(token)}`, init),
);
test("a valid link streams the object, uncacheable and inline", async () => {
const response = await fetchFile(await signedFor("report.pdf"));
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("private, no-store");
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
expect(response.headers.get("content-disposition")).toBe("inline");
expect(await response.text()).toContain("%PDF");
});
test("HEAD answers with the same headers and no body", async () => {
const response = await fetchFile(await signedFor("report.pdf"), { method: "HEAD" });
expect(response.status).toBe(200);
expect(response.headers.get("content-length")).toBe("16");
expect(await response.text()).toBe("");
});
test("ranges are served so media can seek", async () => {
const response = await fetchFile(await signedFor("report.pdf"), {
headers: { range: "bytes=0-3" },
});
expect(response.status).toBe(206);
expect(response.headers.get("content-range")).toBe("bytes 0-3/16");
expect(await response.text()).toBe("%PDF");
});
test("a tampered, unsigned or expired link is indistinguishable from a miss", async () => {
const token = await signedFor("report.pdf");
expect((await fetchFile(token.slice(0, -2) + "xx")).status).toBe(404);
expect((await fetchFile("not-a-token")).status).toBe(404);
expect((await handleFile(new Request("https://app.test/api/viewer/file"))).status).toBe(400);
// A link signed with a different secret must not open under this one.
configureUploads({
stores: ["private"],
secret: "another-secret-entirely-x",
authorize: () => true,
});
expect((await fetchFile(token)).status).toBe(404);
});
test("a link for one store cannot be replayed after that store is withdrawn", async () => {
const token = await signedFor("report.pdf");
configureUploads({ stores: ["public"], secret: SECRET, authorize: () => true });
expect((await fetchFile(token)).status).toBe(404);
});
});
describe("conversion inherits the same check", () => {
test("only a valid signed link opens an object for a converter", async () => {
configureUploads({ stores: ["private"], secret: SECRET, authorize: () => true });
const descriptor = await body(await handleSource(ask("key=report.pdf&store=private")));
const open = uploadedConversionSource();
const request = new Request("https://app.test/api/viewer/preview");
const allowed = await open(request, { src: descriptor.src, name: "report.pdf" });
expect(allowed).toBeDefined();
expect(allowed!.size).toBe(16);
// A bare key, a foreign URL, or a forged token opens nothing.
expect(await open(request, { src: "report.pdf", name: "report.pdf" })).toBeUndefined();
expect(
await open(request, { src: "https://evil.test/x?token=abc", name: "report.pdf" }),
).toBeUndefined();
});
});
describe("plugin wiring", () => {
test("uploader routes appear only when uploads are configured", () => {
const paths = (plugin: ReturnType<typeof viewerPlugin>) =>
((plugin.routeEntries ?? []) as { path: string }[]).map((route) => route.path);
expect(paths(viewerPlugin())).toEqual([]);
expect(paths(viewerPlugin({ uploads: { stores: ["public"] } }))).toEqual([
"/api/viewer/source",
"/api/viewer/file",
]);
// Enabling one feature must not publish the other's endpoints.
expect(paths(viewerPlugin({ uploads: { stores: ["public"] } }))).not.toContain(
"/api/viewer/preview",
);
});
});
describe("the browser side of a key", () => {
async function mountKeyed(descriptor: unknown, body: string, componentName = "") {
const window = new Window({ url: "https://app.test/" });
const host = window.document.createElement("section") as unknown as HTMLElement;
window.document.body.append(host as never);
const asked: string[] = [];
const original = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request) => {
const url = String(input);
asked.push(url);
if (url.includes("/api/viewer/source"))
return descriptor ? Response.json(descriptor) : new Response("no", { status: 403 });
return new Response(body, { headers: { "content-type": "text/csv" } });
}) as unknown as typeof fetch;
try {
const dispose = mountViewer(host, {
key: "regional-figures.csv",
store: "reports",
name: componentName,
});
for (let i = 0; i < 20; i++) await new Promise((done) => setTimeout(done, 5));
return { host, asked, dispose, window };
} finally {
globalThis.fetch = original;
}
}
test("a key is exchanged for a link, and the store travels with it", async () => {
const { host, asked, dispose, window } = await mountKeyed(
{ src: "/api/viewer/file?token=abc", name: "regional-figures.csv", mime: "text/csv" },
"region,units\nNorth,120\n",
);
const source = asked.find((url) => url.includes("/api/viewer/source"))!;
expect(source).toContain("key=regional-figures.csv");
expect(source).toContain("store=reports");
// The descriptor's filename must reach classification: without the extension
// the renderer for this kind cannot be found at all.
expect(host.querySelector("table")).not.toBeNull();
expect(host.querySelector(".wrn-viewer-toolbar span")!.textContent).toBe(
"regional-figures.csv",
);
dispose();
await window.happyDOM.close();
});
test("an explicit name on the component still wins", async () => {
const { host, dispose, window } = await mountKeyed(
{ src: "/api/viewer/file?token=abc", name: "regional-figures.csv", mime: "text/csv" },
"region,units\nNorth,120\n",
"Q3 figures.csv",
);
expect(host.querySelector(".wrn-viewer-toolbar span")!.textContent).toBe("Q3 figures.csv");
dispose();
await window.happyDOM.close();
});
test("a refused key shows the fallback, and no file is fetched", async () => {
const { host, asked, dispose, window } = await mountKeyed(undefined, "");
expect(host.textContent).toContain("This file is not available to preview");
expect(asked.every((url) => url.includes("/api/viewer/source"))).toBe(true);
dispose();
await window.happyDOM.close();
});
});
process.on("exit", () => rmSync(root, { recursive: true, force: true }));