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(); }); });