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