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,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");
|
||||
});
|
||||
Reference in new issue
Block a user