Files
WRNexusJS/packages/viewer/test/regressions.test.ts
T

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