168 lines
6.2 KiB
TypeScript
168 lines
6.2 KiB
TypeScript
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();
|
|
});
|
|
});
|