import { fireEvent, render, screen, waitFor } 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 } 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();
expect(
screen.queryByRole("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.getByText("Personalize")).toBeInTheDocument();
expect(screen.getByText("Apps")).toBeInTheDocument();
});
it("loads same-origin context and creates full-page contextual switch links", async () => {
const fetch = catalogFetch();
render(
PDF
,
);
const navigation = await screen.findByRole("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" })).toHaveAttribute(
"href",
"https://tools.example.test/",
);
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();
expect(
screen.queryByRole("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.getByText("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 a relative explicit manifest URL against the deployed page", async () => {
render(
PDF
,
);
expect(document.querySelector(".toolbox-shell__icon")).toHaveAttribute(
"src",
"https://tools.example.test/nested/pdf/icon.svg",
);
});
});