Ship file viewer, editor improvements, and private package releases
This commit is contained in:
1 parent
645914955c
commit
a4a31032b9
158 files changed
+10523
-137
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
Reference in new issue
Block a user