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;
|
||||
261
packages/contract/test/schema-parity.test.ts
Normal file
261
packages/contract/test/schema-parity.test.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import Ajv2020, { type ValidateFunction } from "ajv/dist/2020.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseToolboxApp, parseToolboxCatalog } from "../src/index.js";
|
||||
|
||||
const appSchema = JSON.parse(
|
||||
readFileSync(
|
||||
new URL("../../../schemas/toolbox-app.v1.schema.json", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
) as object;
|
||||
const catalogSchema = JSON.parse(
|
||||
readFileSync(
|
||||
new URL("../../../schemas/toolbox-catalog.v1.schema.json", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
) as object;
|
||||
|
||||
const ajv = new Ajv2020({
|
||||
allErrors: true,
|
||||
strict: true,
|
||||
// `not: { required: [...] }` intentionally names the opposite union arm.
|
||||
strictRequired: false,
|
||||
});
|
||||
const validateApp = ajv.compile(appSchema);
|
||||
const validateCatalog = ajv.compile(catalogSchema);
|
||||
|
||||
type Parser = (value: unknown) => unknown;
|
||||
|
||||
function runtimeAccepts(parser: Parser, value: unknown): boolean {
|
||||
try {
|
||||
parser(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function expectParity(
|
||||
validate: ValidateFunction,
|
||||
parser: Parser,
|
||||
value: unknown,
|
||||
expected: boolean,
|
||||
): void {
|
||||
const schemaAccepted = validate(value);
|
||||
expect(
|
||||
schemaAccepted,
|
||||
ajv.errorsText(validate.errors, { separator: "\n" }),
|
||||
).toBe(expected);
|
||||
expect(runtimeAccepts(parser, value)).toBe(expected);
|
||||
}
|
||||
|
||||
const validApp = (): Record<string, unknown> => ({
|
||||
schemaVersion: 1,
|
||||
id: "de.add-ideas.example",
|
||||
name: "Example",
|
||||
version: "1.2.3",
|
||||
description: "Example application",
|
||||
entry: "./",
|
||||
icon: "./icon.svg",
|
||||
categories: ["documents"],
|
||||
tags: ["example"],
|
||||
integration: {
|
||||
contextVersion: 1,
|
||||
launchModes: ["navigate"],
|
||||
embedding: "unsupported",
|
||||
},
|
||||
requirements: {
|
||||
secureContext: false,
|
||||
workers: false,
|
||||
indexedDb: false,
|
||||
crossOriginIsolated: false,
|
||||
},
|
||||
privacy: {
|
||||
processing: "local",
|
||||
fileUploads: false,
|
||||
telemetry: false,
|
||||
},
|
||||
source: {
|
||||
repository: "https://example.test/source",
|
||||
license: "MIT",
|
||||
},
|
||||
});
|
||||
|
||||
const validCatalog = (): Record<string, unknown> => ({
|
||||
schemaVersion: 1,
|
||||
id: "de.add-ideas.toolbox",
|
||||
name: "Example Toolbox",
|
||||
home: "./",
|
||||
theme: { mode: "system", brand: "Example" },
|
||||
apps: [{ manifest: "./apps/example/toolbox-app.json", enabled: true }],
|
||||
});
|
||||
|
||||
describe("canonical schema and runtime parser parity", () => {
|
||||
const acceptedApps: Array<[string, () => Record<string, unknown>]> = [
|
||||
["the baseline app", validApp],
|
||||
[
|
||||
"relative references containing spaces and path @ signs",
|
||||
() => ({
|
||||
...validApp(),
|
||||
entry: " ./nested app/?mode=1#result ",
|
||||
actions: [{ id: "docs", label: "Docs", url: "./people/@me" }],
|
||||
}),
|
||||
],
|
||||
[
|
||||
"case-insensitive and compact absolute HTTP URLs",
|
||||
() => ({
|
||||
...validApp(),
|
||||
source: { repository: "HTTPS:example.test", license: "MIT" },
|
||||
}),
|
||||
],
|
||||
];
|
||||
|
||||
it.each(acceptedApps)("accepts %s", (_label, fixture) => {
|
||||
expectParity(validateApp, parseToolboxApp, fixture(), true);
|
||||
});
|
||||
|
||||
const rejectedApps: Array<[string, () => Record<string, unknown>]> = [
|
||||
["a whitespace-only name", () => ({ ...validApp(), name: " \t " })],
|
||||
[
|
||||
"a whitespace-only list item",
|
||||
() => ({ ...validApp(), categories: ["documents", " \n "] }),
|
||||
],
|
||||
[
|
||||
"a non-web entry scheme",
|
||||
() => ({ ...validApp(), entry: "javascript:alert(1)" }),
|
||||
],
|
||||
["a backslash path", () => ({ ...validApp(), icon: ".\\icon.svg" })],
|
||||
["an empty HTTP authority", () => ({ ...validApp(), entry: "https://" })],
|
||||
[
|
||||
"a credential-bearing privacy URL",
|
||||
() => ({
|
||||
...validApp(),
|
||||
privacy: {
|
||||
processing: "local",
|
||||
fileUploads: false,
|
||||
telemetry: false,
|
||||
url: "https://user:secret@example.test/privacy",
|
||||
},
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a relative source repository",
|
||||
() => ({
|
||||
...validApp(),
|
||||
source: { repository: "../source", license: "MIT" },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a compact credential-bearing source repository",
|
||||
() => ({
|
||||
...validApp(),
|
||||
source: { repository: "https:user@example.test", license: "MIT" },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a whitespace-only action label",
|
||||
() => ({
|
||||
...validApp(),
|
||||
actions: [{ id: "docs", label: " ", url: "./docs" }],
|
||||
}),
|
||||
],
|
||||
];
|
||||
|
||||
it.each(rejectedApps)("rejects %s", (_label, fixture) => {
|
||||
expectParity(validateApp, parseToolboxApp, fixture(), false);
|
||||
});
|
||||
|
||||
const acceptedCatalogs: Array<[string, () => Record<string, unknown>]> = [
|
||||
["the baseline catalog", validCatalog],
|
||||
[
|
||||
"an uppercase HTTPS manifest URL",
|
||||
() => ({
|
||||
...validCatalog(),
|
||||
apps: [
|
||||
{
|
||||
manifest: "HTTPS://example.test/apps/example/toolbox-app.json",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
];
|
||||
|
||||
it.each(acceptedCatalogs)("accepts %s", (_label, fixture) => {
|
||||
expectParity(validateCatalog, parseToolboxCatalog, fixture(), true);
|
||||
});
|
||||
|
||||
const rejectedCatalogs: Array<[string, () => Record<string, unknown>]> = [
|
||||
[
|
||||
"a whitespace-only catalog name",
|
||||
() => ({ ...validCatalog(), name: " " }),
|
||||
],
|
||||
[
|
||||
"a whitespace-only brand",
|
||||
() => ({
|
||||
...validCatalog(),
|
||||
theme: { mode: "system", brand: "\t" },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a non-web home URL",
|
||||
() => ({ ...validCatalog(), home: "file:///tmp/toolbox" }),
|
||||
],
|
||||
[
|
||||
"a backslash manifest path",
|
||||
() => ({
|
||||
...validCatalog(),
|
||||
apps: [{ manifest: ".\\apps\\toolbox-app.json", enabled: true }],
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a credential-bearing manifest URL",
|
||||
() => ({
|
||||
...validCatalog(),
|
||||
apps: [
|
||||
{
|
||||
manifest: "https://user:secret@example.test/toolbox-app.json",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a non-web external entry",
|
||||
() => ({
|
||||
...validCatalog(),
|
||||
apps: [
|
||||
{
|
||||
name: "External",
|
||||
entry: "data:text/html,hello",
|
||||
launch: "new-tab",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a whitespace-only external name",
|
||||
() => ({
|
||||
...validCatalog(),
|
||||
apps: [
|
||||
{
|
||||
name: " ",
|
||||
entry: "https://example.test/",
|
||||
launch: "new-tab",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
];
|
||||
|
||||
it.each(rejectedCatalogs)("rejects %s", (_label, fixture) => {
|
||||
expectParity(validateCatalog, parseToolboxCatalog, fixture(), false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user