import { afterEach, describe, expect, test } from "bun:test"; import { Window } from "happy-dom"; import { clearViewerActions, registerViewerAction } from "../src/actions.ts"; import { mountViewer } from "../src/runtime.ts"; import { iconFor } from "../src/renderers/icons.ts"; async function settled(ticks = 30) { 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 }; } const csv = () => ({ name: "data.csv", blob: new Blob(["a,b\n1,2\n"]) }); const labels = (host: HTMLElement) => [...host.querySelectorAll(".wrn-viewer-action")].map((node) => node.getAttribute("aria-label")); afterEach(() => clearViewerActions()); describe("icon buttons", () => { test("every control is an icon button with an accessible name and a tooltip", async () => { const { window, host } = fixture(); const dispose = mountViewer(host, csv()); await settled(); const buttons = [...host.querySelectorAll(".wrn-viewer-action")]; expect(buttons.length).toBeGreaterThan(0); for (const node of buttons) { const label = node.getAttribute("aria-label") ?? ""; expect(label).toBeTruthy(); // The tooltip shows the same name the assistive label reads out. expect(node.getAttribute("data-tooltip")).toBe(label); expect(node.querySelector("svg")).not.toBeNull(); // The name stays in the DOM for screen readers, visually hidden. expect(node.querySelector(".wrn-viewer-sr")!.textContent).toBe(label); } dispose(); await window.happyDOM.close(); }); test("toggling a label swaps the icon instead of deleting it", async () => { const { window, host } = fixture(); const dispose = mountViewer(host, csv()); await settled(); const toggle = [...host.querySelectorAll(".wrn-viewer-action")].find( (node) => node.getAttribute("aria-label") === "Raw", )!; toggle.click(); expect(toggle.getAttribute("aria-label")).toBe("Table"); expect(toggle.getAttribute("data-tooltip")).toBe("Table"); expect(toggle.querySelector("svg")).not.toBeNull(); dispose(); await window.happyDOM.close(); }); test("an unknown label still gets an icon rather than nothing", () => { expect(iconFor("Zoom in")).toBe("zoom-in"); expect(iconFor("Something bespoke")).toBe("file"); }); }); describe("custom actions", () => { test("a globally registered action appears and runs with the render context", async () => { const { window, host } = fixture(); const seen: string[] = []; registerViewerAction({ id: "open-in-tab", label: "Open in new tab", run: ({ source, renderer }) => { seen.push(`${source.name}:${renderer?.component}`); }, }); const dispose = mountViewer(host, csv()); await settled(); const action = host.querySelector('[data-viewer-action="open-in-tab"]')!; expect(action).not.toBeNull(); expect(action.getAttribute("aria-label")).toBe("Open in new tab"); action.click(); expect(seen).toEqual(["data.csv:TableViewer"]); dispose(); await window.happyDOM.close(); }); test("`when` decides, and per-viewer actions are offered too", async () => { const { window, host } = fixture(); registerViewerAction({ id: "images-only", label: "Crop", when: ({ kind }) => kind === "image", run: () => {}, }); const dispose = mountViewer(host, csv(), { actions: [{ id: "local", label: "Send", placement: "controls", run: () => {} }], }); await settled(); expect(host.querySelector('[data-viewer-action="images-only"]')).toBeNull(); expect(host.querySelector('[data-viewer-action="local"]')).not.toBeNull(); dispose(); await window.happyDOM.close(); }); test("actions survive a re-render without piling up", async () => { const { window, host } = fixture(); registerViewerAction({ id: "once", label: "Once", run: () => {} }); const dispose = mountViewer(host, csv()); await settled(); const toggle = [...host.querySelectorAll(".wrn-viewer-action")].find( (node) => node.getAttribute("aria-label") === "Raw", )!; toggle.click(); expect(host.querySelectorAll('[data-viewer-action="once"]')).toHaveLength(1); dispose(); await window.happyDOM.close(); }); test("an app action is still offered for a file nothing can preview", async () => { const { window, host } = fixture(); registerViewerAction({ id: "anywhere", label: "Report", run: () => {} }); const dispose = mountViewer(host, { name: "thing.bin", blob: new Blob([new Uint8Array([0, 1, 2])]), }); await settled(); expect(host.querySelector('[data-viewer-action="anywhere"]')).not.toBeNull(); // Printing a download card would print nothing, so it is withheld. expect(host.querySelector('[data-viewer-action="print"]')).toBeNull(); dispose(); await window.happyDOM.close(); }); }); describe("print", () => { test("a rendered preview offers Print; it is opt-out", async () => { const { window, host } = fixture(); const dispose = mountViewer(host, csv()); await settled(); expect(labels(host)).toContain("Print"); dispose(); const second = fixture(); const disposeSecond = mountViewer(second.host, csv(), { print: false }); await settled(); expect(labels(second.host)).not.toContain("Print"); disposeSecond(); await second.window.happyDOM.close(); await window.happyDOM.close(); }); test("printing isolates a repaired clone of the preview", async () => { const { window, host } = fixture(); let printed = 0; (window as unknown as { print: () => void }).print = () => { printed++; const doc = window.document; // At the moment of printing, a clone of the preview stands alone in the // body and the page is marked so everything else drops out of the flow. expect(doc.documentElement.hasAttribute("data-viewer-printing-active")).toBe(true); const root = doc.querySelector("[data-viewer-print-root]")!; expect(root).not.toBeNull(); expect(root.textContent).toContain("data.csv"); expect(root.querySelector("table")).not.toBeNull(); // Spacer rows stand in for rows that were never in the DOM. expect(root.querySelectorAll("[data-viewer-spacer]")).toHaveLength(0); }; const dispose = mountViewer(host, csv()); await settled(); host.querySelector('[data-viewer-action="print"]')!.click(); expect(printed).toBe(1); // Cleanup waits for the browser to finish, then leaves the page as found. window.dispatchEvent(new window.Event("afterprint") as never); expect(window.document.querySelector("[data-viewer-print-root]")).toBeNull(); expect(window.document.documentElement.hasAttribute("data-viewer-printing-active")).toBe(false); dispose(); await window.happyDOM.close(); }); }); describe("audio transport", () => { test("one set of controls drives the playhead, not two", async () => { const { window, host } = fixture(); const wav = new Uint8Array(64); wav.set(new TextEncoder().encode("RIFF")); wav.set(new TextEncoder().encode("WAVE"), 8); const dispose = mountViewer(host, { name: "clip.wav", blob: new Blob([wav as never]) }); await settled(); // The native controls are gone: they were a second play/seek for the same // element, sitting above the package's own transport. expect(host.querySelector("audio")!.hasAttribute("controls")).toBe(false); const player = host.querySelector(".wrn-viewer-player")!; expect(player).not.toBeNull(); expect( [...player.querySelectorAll(".wrn-viewer-action")].map((n) => n.getAttribute("aria-label")), ).toEqual(["Play", "Mute"]); // Exactly one seek control, and it is labelled. const seeks = host.querySelectorAll('input[type="range"]'); expect(seeks).toHaveLength(1); expect(seeks[0]!.getAttribute("aria-label")).toContain("clip.wav"); const play = player.querySelector(".wrn-viewer-play")!; expect(play.querySelector("svg")).not.toBeNull(); dispose(); await window.happyDOM.close(); }); });