260 lines
9.8 KiB
TypeScript
260 lines
9.8 KiB
TypeScript
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");
|
|
});
|