feat: add toolbox contract shell and testkit
This commit is contained in:
288
packages/contract/test/contract.test.ts
Normal file
288
packages/contract/test/contract.test.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import {
|
||||
ToolboxValidationError,
|
||||
contextualizeToolboxLink,
|
||||
discoverToolboxCatalog,
|
||||
loadToolboxContext,
|
||||
parseToolboxApp,
|
||||
parseToolboxCatalog,
|
||||
type ToolboxAppManifest,
|
||||
} from "../src/index.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const app = (
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Record<string, unknown> => ({
|
||||
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", "split"],
|
||||
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",
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const catalog = (
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Record<string, unknown> => ({
|
||||
schemaVersion: 1,
|
||||
id: "de.add-ideas.toolbox",
|
||||
name: "Toolbox",
|
||||
home: "./",
|
||||
theme: { mode: "system", brand: "add·ideas" },
|
||||
apps: [{ manifest: "./pdf/toolbox-app.json", enabled: true }],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const json = (value: unknown, init: ResponseInit = {}): Response =>
|
||||
new Response(JSON.stringify(value), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
|
||||
describe("v1 runtime parsing", () => {
|
||||
it("parses the full app contract and drops unknown optional fields", () => {
|
||||
const parsed = parseToolboxApp(
|
||||
app({
|
||||
futureField: "ignored",
|
||||
privacy: {
|
||||
processing: "local",
|
||||
fileUploads: false,
|
||||
telemetry: false,
|
||||
futurePrivacyField: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(parsed).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
id: "de.add-ideas.pdf-tools",
|
||||
privacy: { processing: "local", fileUploads: false, telemetry: false },
|
||||
});
|
||||
expect(parsed).not.toHaveProperty("futureField");
|
||||
expect(parsed.privacy).not.toHaveProperty("futurePrivacyField");
|
||||
});
|
||||
|
||||
it("rejects unsupported versions and malformed known optional fields", () => {
|
||||
expect(() => parseToolboxApp(app({ schemaVersion: 2 }))).toThrow(
|
||||
ToolboxValidationError,
|
||||
);
|
||||
expect(() =>
|
||||
parseToolboxApp(
|
||||
app({
|
||||
requirements: {
|
||||
secureContext: true,
|
||||
workers: true,
|
||||
indexedDb: true,
|
||||
crossOriginIsolated: false,
|
||||
topLevelContext: "sometimes",
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrow(/topLevelContext must be a boolean/u);
|
||||
});
|
||||
|
||||
it("accepts omitted source metadata but validates it when supplied", () => {
|
||||
const withoutSource = app();
|
||||
delete withoutSource.source;
|
||||
expect(parseToolboxApp(withoutSource)).not.toHaveProperty("source");
|
||||
expect(() =>
|
||||
parseToolboxApp(
|
||||
app({ source: { repository: "../repository", license: "MIT" } }),
|
||||
),
|
||||
).toThrow(/absolute HTTP\(S\) URL/u);
|
||||
});
|
||||
|
||||
it("parses manifest references and external inline catalog entries", () => {
|
||||
const parsed = parseToolboxCatalog(
|
||||
catalog({
|
||||
futureField: 1,
|
||||
apps: [
|
||||
{ manifest: "./pdf/toolbox-app.json", enabled: true, future: true },
|
||||
{
|
||||
name: "External docs",
|
||||
entry: "https://docs.example.test/",
|
||||
launch: "new-tab",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(parsed.apps).toHaveLength(2);
|
||||
expect(parsed.apps[1]).toEqual({
|
||||
name: "External docs",
|
||||
entry: "https://docs.example.test/",
|
||||
launch: "new-tab",
|
||||
enabled: true,
|
||||
});
|
||||
expect(parsed).not.toHaveProperty("futureField");
|
||||
});
|
||||
});
|
||||
|
||||
describe("same-origin discovery", () => {
|
||||
it("prefers a same-origin query context over meta context", () => {
|
||||
const result = discoverToolboxCatalog({
|
||||
location: "https://tools.example.test/pdf/?toolbox=%2Fcatalog.json",
|
||||
document: {
|
||||
querySelector: () => ({
|
||||
getAttribute: () => "/meta-catalog.json",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("found");
|
||||
if (result.status === "found") {
|
||||
expect(result.source).toBe("query");
|
||||
expect(result.catalogUrl.href).toBe(
|
||||
"https://tools.example.test/catalog.json",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a cross-origin query without falling through to meta", () => {
|
||||
const result = discoverToolboxCatalog({
|
||||
location:
|
||||
"https://tools.example.test/pdf/?toolbox=https%3A%2F%2Fevil.test%2Fcatalog.json",
|
||||
document: {
|
||||
querySelector: () => ({ getAttribute: () => "/safe.json" }),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("error");
|
||||
if (result.status === "error")
|
||||
expect(result.error.code).toBe("cross-origin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loading and links", () => {
|
||||
it("loads a catalog, resolves manifests and URLs, and supports external apps", async () => {
|
||||
const fetch = vi.fn(
|
||||
async (input: string | URL | Request, init?: RequestInit) => {
|
||||
void init;
|
||||
const url = new URL(input instanceof Request ? input.url : input);
|
||||
if (url.pathname === "/catalog.json") {
|
||||
return json(
|
||||
catalog({
|
||||
apps: [
|
||||
{ manifest: "./pdf/toolbox-app.json", enabled: true },
|
||||
{ manifest: "./disabled/toolbox-app.json", enabled: false },
|
||||
{
|
||||
name: "Documentation",
|
||||
entry: "https://docs.example.test/",
|
||||
launch: "new-tab",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (url.pathname === "/pdf/toolbox-app.json") return json(app());
|
||||
if (url.pathname === "/disabled/toolbox-app.json") {
|
||||
throw new Error("Disabled manifests must not be fetched");
|
||||
}
|
||||
return new Response("Not found", { status: 404 });
|
||||
},
|
||||
);
|
||||
|
||||
const result = await loadToolboxContext({
|
||||
location: "https://tools.example.test/pdf/?toolbox=/catalog.json",
|
||||
fetch,
|
||||
});
|
||||
|
||||
for (const [, init] of fetch.mock.calls) {
|
||||
expect(init).toMatchObject({ redirect: "error" });
|
||||
}
|
||||
|
||||
expect(result.status).toBe("ready");
|
||||
if (result.status === "ready") {
|
||||
expect(result.context.catalog.homeUrl.href).toBe(
|
||||
"https://tools.example.test/",
|
||||
);
|
||||
expect(result.context.catalog.apps[0]?.kind).toBe("manifest");
|
||||
const first = result.context.catalog.apps[0];
|
||||
if (first?.kind === "manifest") {
|
||||
expect(first.app.entryUrl.href).toBe(
|
||||
"https://tools.example.test/pdf/index.html",
|
||||
);
|
||||
}
|
||||
expect(result.context.catalog.apps[1]).toMatchObject({
|
||||
kind: "external",
|
||||
launch: "new-tab",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a graceful error result for invalid JSON and fetch failure", async () => {
|
||||
const invalid = await loadToolboxContext({
|
||||
location: "https://tools.example.test/?toolbox=/catalog.json",
|
||||
fetch: async () => json(catalog({ schemaVersion: 2 })),
|
||||
});
|
||||
expect(invalid.status).toBe("error");
|
||||
|
||||
const unavailable = await loadToolboxContext({
|
||||
location: "https://tools.example.test/?toolbox=/catalog.json",
|
||||
fetch: async () => new Response("No", { status: 503 }),
|
||||
});
|
||||
expect(unavailable.status).toBe("error");
|
||||
if (unavailable.status === "error") {
|
||||
expect(unavailable.error.code).toBe("fetch-failed");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a fetch redirected to a cross-origin response URL", async () => {
|
||||
const response = json(catalog());
|
||||
Object.defineProperty(response, "url", {
|
||||
value: "https://evil.example/toolbox.catalog.json",
|
||||
});
|
||||
const result = await loadToolboxContext({
|
||||
location: "https://tools.example.test/?toolbox=/catalog.json",
|
||||
fetch: async () => response,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("error");
|
||||
if (result.status === "error")
|
||||
expect(result.error.code).toBe("cross-origin");
|
||||
});
|
||||
|
||||
it("adds context to same-origin links but leaves external links alone", () => {
|
||||
expect(
|
||||
new URL(
|
||||
contextualizeToolboxLink("./other/", "/catalog.json", {
|
||||
location: "https://tools.example.test/pdf/",
|
||||
}),
|
||||
).searchParams.get("toolbox"),
|
||||
).toBe("https://tools.example.test/catalog.json");
|
||||
|
||||
expect(
|
||||
contextualizeToolboxLink("https://docs.example.test/", "/catalog.json", {
|
||||
location: "https://tools.example.test/pdf/",
|
||||
}),
|
||||
).toBe("https://docs.example.test/");
|
||||
});
|
||||
});
|
||||
|
||||
export const validAppFixture = app() as unknown as ToolboxAppManifest;
|
||||
Reference in New Issue
Block a user