import { fireEvent, render, screen, waitFor, within, } from "@testing-library/react"; import { TOOLBOX_PREFERENCES_KEY, type ToolboxAppManifest, } from "@add-ideas/toolbox-contract"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { AppShell, ToolboxHeader } from "../src/index.js"; const currentApp: ToolboxAppManifest = { schemaVersion: 1, id: "de.add-ideas.pdf-tools", name: "PDF Workbench", version: "1.2.3", description: "Local PDF tools", entry: "./index.html", icon: "./icon.svg", categories: ["documents", "pdf"], tags: ["merge"], integration: { contextVersion: 1, launchModes: ["navigate", "new-tab"], embedding: "unsupported", }, requirements: { secureContext: true, workers: true, indexedDb: true, crossOriginIsolated: false, }, privacy: { processing: "local", fileUploads: false, telemetry: false, }, source: { repository: "https://git.example.test/pdf-tools", license: "AGPL-3.0-only", }, }; const otherApp: ToolboxAppManifest = { ...currentApp, id: "de.add-ideas.xslt-tools", name: "XSLT Workbench", entry: "./index.html", source: { repository: "https://git.example.test/xslt-tools", license: "AGPL-3.0-only", }, }; const catalog = { schemaVersion: 1, id: "de.add-ideas.toolbox", name: "Toolbox", home: "./", theme: { mode: "system", brand: "add·ideas" }, apps: [ { manifest: "./pdf/toolbox-app.json", enabled: true }, { manifest: "./xslt/toolbox-app.json", enabled: true }, ], }; const json = (value: unknown, status = 200): Response => new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" }, }); function catalogFetch() { return vi.fn(async (input: string | URL | Request) => { const url = new URL(input instanceof Request ? input.url : input); if (url.pathname === "/toolbox.catalog.json") return json(catalog); if (url.pathname === "/pdf/toolbox-app.json") return json(currentApp); if (url.pathname === "/xslt/toolbox-app.json") return json(otherApp); return json({}, 404); }); } describe("AppShell", () => { beforeEach(() => localStorage.clear()); it("renders identity, privacy, version, actions, and content standalone", async () => { const onHelp = vi.fn(); const fetch = vi.fn(); render( Help} contextOptions={{ location: "https://tools.example.test/pdf/", fetch, }} >

Application content

, ); expect( screen.getByRole("heading", { level: 1, name: "PDF Workbench" }), ).toBeInTheDocument(); expect(screen.getByLabelText("Version 1.2.3")).toBeInTheDocument(); expect(screen.getByText(/Local processing/u)).toBeInTheDocument(); expect(screen.getByRole("link", { name: "Help" })).toHaveAttribute( "href", "/help", ); expect(screen.getByText("Application content")).toBeInTheDocument(); await waitFor(() => expect(fetch).not.toHaveBeenCalled()); expect( document.querySelector("[data-toolbox-context='standalone']"), ).toBeInTheDocument(); const controls = screen.getByLabelText("Toolbox controls"); expect( Array.from(controls.children, (element) => element.getAttribute("aria-label"), ), ).toEqual([ "Personalize", "Apps", "Source for PDF Workbench on Gitea", "Help", ]); fireEvent.click(screen.getByRole("button", { name: "Apps" })); expect( screen.getByRole("navigation", { name: "Toolbox applications" }), ).toHaveTextContent("Open this app from Toolbox"); expect( screen.getByRole("link", { name: /Source for PDF Workbench/u }), ).toHaveAttribute("href", "https://git.example.test/pdf-tools"); fireEvent.click(screen.getByRole("button", { name: "Help" })); expect(onHelp).toHaveBeenCalledOnce(); expect( screen.queryByRole("navigation", { name: "Toolbox applications" }), ).not.toBeInTheDocument(); expect( screen.getByRole("button", { name: "Personalize" }), ).toHaveTextContent("Personalize"); expect(screen.getByRole("button", { name: "Apps" })).toHaveTextContent( "Apps", ); expect(document.querySelector(".toolbox-shell__icon")).toBeNull(); expect(screen.getByText("add·ideas Toolbox").closest("a")).toBeNull(); expect(document.querySelector(".toolbox-shell__footer")).toContainElement( screen.getByLabelText("Version 1.2.3"), ); }); it("loads same-origin context and creates full-page contextual switch links", async () => { const fetch = catalogFetch(); render( PDF , ); await waitFor(() => expect( document.querySelector("[data-toolbox-context='connected']"), ).toBeInTheDocument(), ); fireEvent.click(screen.getByRole("button", { name: "Apps" })); const navigation = screen.getByRole("navigation", { name: "Toolbox applications", }); expect(navigation).toBeInTheDocument(); const current = screen.getByRole("link", { name: "PDF Workbench" }); expect(current).toHaveAttribute("aria-current", "page"); const other = screen.getByRole("link", { name: "XSLT Workbench" }); const destination = new URL(other.getAttribute("href") as string); expect(destination.pathname).toBe("/xslt/index.html"); expect(destination.searchParams.get("toolbox")).toBe( "https://tools.example.test/toolbox.catalog.json", ); expect( screen.getByRole("link", { name: "add·ideas Toolbox" }), ).toHaveAttribute("href", "https://tools.example.test/"); expect( screen .getByRole("link", { name: "add·ideas Toolbox" }) .querySelector("img"), ).toHaveAttribute("src", "https://tools.example.test/favicon.svg"); expect( document.querySelector("[data-toolbox-context='connected']"), ).toBeInTheDocument(); }); it("rejects cross-origin context and remains standalone", async () => { const fetch = vi.fn(); const onContextError = vi.fn(); render( PDF , ); await waitFor(() => expect(onContextError).toHaveBeenCalledOnce()); expect(onContextError.mock.calls[0]?.[0]).toMatchObject({ code: "cross-origin", }); expect(fetch).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "Apps" })); expect( screen.getByRole("navigation", { name: "Toolbox applications" }), ).toHaveTextContent("Open this app from Toolbox"); }); it.each([ ["invalid catalog", async () => json({ schemaVersion: 2 })], ["fetch failure", async () => json({}, 503)], ])("survives %s in standalone mode", async (_label, fetchImplementation) => { const onContextError = vi.fn(); render( Still usable , ); await waitFor(() => expect(onContextError).toHaveBeenCalledOnce()); expect(screen.getByText("Still usable")).toBeInTheDocument(); expect( document.querySelector("[data-toolbox-context='standalone']"), ).toBeInTheDocument(); }); it("uses and updates the shared light, dark, and system preference", async () => { localStorage.setItem( TOOLBOX_PREFERENCES_KEY, JSON.stringify({ version: 1, pinned: [], order: [], hidden: [], theme: "dark", }), ); render( PDF , ); const shell = document.querySelector(".toolbox-shell"); expect(shell).toHaveAttribute("data-toolbox-theme", "dark"); fireEvent.click(screen.getByRole("button", { name: "Personalize" })); fireEvent.click(screen.getByRole("button", { name: "Light" })); expect(shell).toHaveAttribute("data-toolbox-theme", "light"); expect( JSON.parse(localStorage.getItem(TOOLBOX_PREFERENCES_KEY) ?? "{}"), ).toMatchObject({ theme: "light" }); }); it("resolves manifest-relative header links without rendering an app icon", async () => { render( PDF , ); expect( screen.getByRole("link", { name: "Source for PDF Workbench on Gitea" }), ).toHaveAttribute("href", "https://tools.example.test/nested/pdf/source/"); expect(document.querySelector(".toolbox-shell__icon")).toBeNull(); }); it("uses one accessible controlled popover for Personalize and Apps", () => { const onHelp = vi.fn(); render( Full preferences} helpAction={{ onClick: onHelp }} sourceHref="https://git.example.test/pdf-tools" apps={[ { id: "xslt", name: "XSLT Workbench", href: "#xslt", }, ]} />, ); const personalize = screen.getByRole("button", { name: "Personalize" }); const apps = screen.getByRole("button", { name: "Apps" }); expect( screen.getByRole("group", { name: "Toolbox controls" }), ).toBeInTheDocument(); expect(personalize).toHaveAttribute("aria-label", "Personalize"); expect(apps).toHaveAttribute("aria-label", "Apps"); expect(apps).not.toHaveAttribute("aria-haspopup"); fireEvent.click(personalize); expect( screen.getByRole("dialog", { name: "Personalize Toolbox" }), ).toHaveTextContent("Full preferences"); expect(personalize).toHaveAttribute("aria-expanded", "true"); expect( screen.getByRole("link", { name: "Full preferences" }), ).toHaveFocus(); fireEvent.click(apps); expect( screen.queryByRole("dialog", { name: "Personalize Toolbox" }), ).not.toBeInTheDocument(); const navigation = screen.getByRole("navigation", { name: "Toolbox applications", }); expect(navigation).toBeInTheDocument(); expect( within(navigation).getByRole("link", { name: "XSLT Workbench" }), ).toHaveFocus(); fireEvent.keyDown(document, { key: "Escape" }); expect( screen.queryByRole("navigation", { name: "Toolbox applications" }), ).not.toBeInTheDocument(); expect(apps).toHaveFocus(); fireEvent.click(personalize); fireEvent.pointerDown(document.body); expect( screen.queryByRole("dialog", { name: "Personalize Toolbox" }), ).not.toBeInTheDocument(); fireEvent.click(apps); fireEvent.click( within( screen.getByRole("navigation", { name: "Toolbox applications" }), ).getByRole("link", { name: "XSLT Workbench" }), ); expect( screen.queryByRole("navigation", { name: "Toolbox applications" }), ).not.toBeInTheDocument(); }); it("keeps the legacy onPersonalize callback when no popover content exists", () => { const onPersonalize = vi.fn(); const onThemeChange = vi.fn(); render( , ); fireEvent.click(screen.getByRole("button", { name: "Personalize" })); expect(onPersonalize).toHaveBeenCalledOnce(); expect( screen.queryByRole("dialog", { name: "Personalize Toolbox" }), ).not.toBeInTheDocument(); }); it("closes Personalize if its content becomes unavailable", () => { const { rerender } = render( , ); fireEvent.click(screen.getByRole("button", { name: "Personalize" })); expect( screen.getByRole("dialog", { name: "Personalize Toolbox" }), ).toBeInTheDocument(); rerender(); expect( screen.queryByRole("dialog", { name: "Personalize Toolbox" }), ).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "Personalize" })).toBeDisabled(); }); });