481 lines
15 KiB
TypeScript
481 lines
15 KiB
TypeScript
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,
|
|
ToolboxPersonalizePanel,
|
|
} 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(
|
|
<AppShell
|
|
app={currentApp}
|
|
helpAction={{ onClick: onHelp }}
|
|
appActions={<a href="/help">Help</a>}
|
|
contextOptions={{
|
|
location: "https://tools.example.test/pdf/",
|
|
fetch,
|
|
}}
|
|
>
|
|
<p>Application content</p>
|
|
</AppShell>,
|
|
);
|
|
|
|
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([
|
|
"Help",
|
|
"Source for PDF Workbench on Gitea",
|
|
"Apps",
|
|
"Personalize",
|
|
]);
|
|
expect(
|
|
Array.from(
|
|
document.querySelector(".toolbox-shell__bar")?.children ?? [],
|
|
(element) => element.className,
|
|
),
|
|
).toEqual([
|
|
"toolbox-shell__brand",
|
|
"toolbox-shell__identity",
|
|
"toolbox-shell__controls",
|
|
]);
|
|
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(
|
|
<AppShell
|
|
app={currentApp}
|
|
manifestUrl="https://tools.example.test/pdf/toolbox-app.json"
|
|
contextOptions={{
|
|
location:
|
|
"https://tools.example.test/pdf/?toolbox=/toolbox.catalog.json",
|
|
fetch,
|
|
}}
|
|
>
|
|
PDF
|
|
</AppShell>,
|
|
);
|
|
|
|
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(
|
|
<AppShell
|
|
app={currentApp}
|
|
contextOptions={{
|
|
location:
|
|
"https://tools.example.test/pdf/?toolbox=https://evil.example/catalog.json",
|
|
fetch,
|
|
}}
|
|
onContextError={onContextError}
|
|
>
|
|
PDF
|
|
</AppShell>,
|
|
);
|
|
|
|
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(
|
|
<AppShell
|
|
app={currentApp}
|
|
contextOptions={{
|
|
location:
|
|
"https://tools.example.test/pdf/?toolbox=/toolbox.catalog.json",
|
|
fetch: fetchImplementation,
|
|
}}
|
|
onContextError={onContextError}
|
|
>
|
|
Still usable
|
|
</AppShell>,
|
|
);
|
|
|
|
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(
|
|
<AppShell
|
|
app={currentApp}
|
|
contextOptions={{ location: "https://tools.example.test/pdf/" }}
|
|
>
|
|
PDF
|
|
</AppShell>,
|
|
);
|
|
|
|
const shell = document.querySelector(".toolbox-shell");
|
|
expect(shell).toHaveAttribute("data-toolbox-theme", "dark");
|
|
fireEvent.click(screen.getByRole("button", { name: "Personalize" }));
|
|
expect(
|
|
screen.getByRole("heading", { name: "Personalize your toolbox" }),
|
|
).toBeInTheDocument();
|
|
expect(screen.getByText("On this device")).toBeInTheDocument();
|
|
expect(
|
|
screen.getByRole("group", { name: "Appearance" }),
|
|
).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute(
|
|
"aria-pressed",
|
|
"true",
|
|
);
|
|
expect(screen.queryByText("Pinned")).not.toBeInTheDocument();
|
|
expect(
|
|
screen.queryByRole("button", { name: "Export JSON" }),
|
|
).not.toBeInTheDocument();
|
|
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("shares the portal-style personalization frame and extension slot", () => {
|
|
const onThemeChange = vi.fn();
|
|
render(
|
|
<ToolboxPersonalizePanel
|
|
theme="system"
|
|
onThemeChange={onThemeChange}
|
|
storageAvailable={false}
|
|
>
|
|
<p>Portal preference actions</p>
|
|
</ToolboxPersonalizePanel>,
|
|
);
|
|
|
|
expect(
|
|
screen.getByRole("heading", { name: "Personalize your toolbox" }),
|
|
).toBeInTheDocument();
|
|
expect(screen.getByRole("status")).toHaveTextContent(
|
|
"Browser storage is unavailable",
|
|
);
|
|
expect(screen.getByText("Portal preference actions")).toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole("button", { name: "Dark" }));
|
|
expect(onThemeChange).toHaveBeenCalledWith("dark");
|
|
});
|
|
|
|
it("resolves manifest-relative header links without rendering an app icon", async () => {
|
|
render(
|
|
<AppShell
|
|
app={{
|
|
...currentApp,
|
|
actions: [
|
|
{
|
|
id: "source",
|
|
label: "Source",
|
|
url: "./source/",
|
|
},
|
|
],
|
|
}}
|
|
manifestUrl="./toolbox-app.json"
|
|
contextOptions={{
|
|
location: "https://tools.example.test/nested/pdf/index.html",
|
|
}}
|
|
>
|
|
PDF
|
|
</AppShell>,
|
|
);
|
|
|
|
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(
|
|
<ToolboxHeader
|
|
title="PDF Workbench"
|
|
subtitle="Local PDF tools"
|
|
theme="system"
|
|
personalizeContent={<a href="#preferences">Full preferences</a>}
|
|
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(
|
|
<ToolboxHeader
|
|
title="PDF Workbench"
|
|
theme="system"
|
|
onPersonalize={onPersonalize}
|
|
onThemeChange={onThemeChange}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<ToolboxHeader
|
|
title="PDF Workbench"
|
|
theme="system"
|
|
onThemeChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Personalize" }));
|
|
expect(
|
|
screen.getByRole("dialog", { name: "Personalize Toolbox" }),
|
|
).toBeInTheDocument();
|
|
|
|
rerender(<ToolboxHeader title="PDF Workbench" theme="system" />);
|
|
expect(
|
|
screen.queryByRole("dialog", { name: "Personalize Toolbox" }),
|
|
).not.toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Personalize" })).toBeDisabled();
|
|
});
|
|
});
|