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