Release Schema Tools v0.1.0
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const ORIGIN = "http://127.0.0.1:4181";
|
||||
async function localOnly(page: Page) {
|
||||
const external: string[] = [];
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== ORIGIN) {
|
||||
external.push(url.href);
|
||||
await route.abort();
|
||||
} else await route.continue();
|
||||
});
|
||||
return external;
|
||||
}
|
||||
|
||||
test("runs from a nested path without external requests", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") errors.push(message.text());
|
||||
});
|
||||
const external = await localOnly(page);
|
||||
await page.goto("/deep/nested/schema/");
|
||||
await expect(
|
||||
page.getByRole("banner").getByRole("heading", { name: "Schema Tools" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("Five languages, deliberately different guarantees"),
|
||||
).toBeVisible();
|
||||
expect(external).toEqual([]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("validates and traces a supplied local JSON Schema reference", async ({
|
||||
page,
|
||||
}) => {
|
||||
const external = await localOnly(page);
|
||||
await page.goto("/deep/nested/schema/");
|
||||
await page.getByRole("tab", { name: "Validate" }).click();
|
||||
await page.getByRole("button", { name: "Validate instance" }).click();
|
||||
await expect(page.getByText(/Instance is valid/u)).toBeVisible();
|
||||
await page.getByRole("tab", { name: "References" }).click();
|
||||
await expect(
|
||||
page.getByText("address.schema.json#/$defs/address", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("resolved", { exact: true })).toBeVisible();
|
||||
expect(external).toEqual([]);
|
||||
});
|
||||
|
||||
test("preserves the last successful report after a parse failure", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/deep/nested/schema/");
|
||||
await expect(page.getByText("2 documents · 1 reference")).toBeVisible();
|
||||
await page.getByLabel("Schema source").fill("not: [valid");
|
||||
await page.getByRole("button", { name: "Inspect workspace" }).click();
|
||||
await expect(page.getByRole("alert")).toContainText(
|
||||
"last successful report remains",
|
||||
);
|
||||
await expect(page.getByText("2 documents · 1 reference")).toBeVisible();
|
||||
});
|
||||
|
||||
test("serves release identity and hardened headers", async ({ request }) => {
|
||||
const index = await request.get("/deep/nested/schema/");
|
||||
expect(index.ok()).toBe(true);
|
||||
expect(index.headers()["content-security-policy"]).toContain(
|
||||
"connect-src 'self'",
|
||||
);
|
||||
expect(await index.text()).not.toMatch(/\b(?:src|href)=["']\//u);
|
||||
const manifest = await request.get("/deep/nested/schema/toolbox-app.json");
|
||||
await expect(manifest.json()).resolves.toMatchObject({
|
||||
id: "de.add-ideas.schema-tools",
|
||||
version: "0.1.0",
|
||||
entry: "./",
|
||||
privacy: { processing: "local", telemetry: false },
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { App } from "../../src/App";
|
||||
|
||||
describe("Schema Tools", () => {
|
||||
it("renders capability tiers and validates the bundled local workspace", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("Not found", { status: 404 })),
|
||||
);
|
||||
render(<App />);
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "Schema Tools" }),
|
||||
).toBeVisible();
|
||||
await screen.findByText(
|
||||
"Five languages, deliberately different guarantees",
|
||||
undefined,
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
expect(screen.getByText("focused validation")).toBeVisible();
|
||||
expect(screen.getAllByText("structural only")).toHaveLength(2);
|
||||
await userEvent.click(screen.getByRole("tab", { name: "Validate" }));
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Validate instance" }),
|
||||
);
|
||||
expect(screen.getByText(/Instance is valid/u)).toBeVisible();
|
||||
expect(screen.getByText("Browser-local")).toBeVisible();
|
||||
}, 20_000);
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
compareSchemas,
|
||||
generateSample,
|
||||
inspectWorkspace,
|
||||
parseSchemaDocument,
|
||||
validateJsonInstance,
|
||||
} from "../../src/schema/model";
|
||||
|
||||
const entry = {
|
||||
name: "person.schema.json",
|
||||
language: "json-schema" as const,
|
||||
source: JSON.stringify({
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
type: "object",
|
||||
required: ["name", "address"],
|
||||
properties: {
|
||||
name: { type: "string", minLength: 1 },
|
||||
address: { $ref: "defs/address.json#/$defs/address" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
}),
|
||||
};
|
||||
const support = {
|
||||
name: "defs/address.json",
|
||||
language: "json-schema" as const,
|
||||
source: JSON.stringify({
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
$defs: {
|
||||
address: {
|
||||
type: "object",
|
||||
required: ["city"],
|
||||
properties: { city: { type: "string" } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
describe("schema workspace", () => {
|
||||
it("resolves local references, generates a sample, and validates instances", () => {
|
||||
const workspace = inspectWorkspace([entry, support], entry.name);
|
||||
expect(workspace.references).toEqual([
|
||||
expect.objectContaining({ target: support.name, status: "resolved" }),
|
||||
]);
|
||||
expect(JSON.parse(generateSample(workspace).output)).toEqual({
|
||||
name: "string",
|
||||
address: { city: "string" },
|
||||
});
|
||||
expect(
|
||||
validateJsonInstance(
|
||||
workspace,
|
||||
JSON.stringify({ name: "Ada", address: { city: "London" } }),
|
||||
).valid,
|
||||
).toBe(true);
|
||||
const invalid = validateJsonInstance(
|
||||
workspace,
|
||||
JSON.stringify({ name: "Ada" }),
|
||||
);
|
||||
expect(invalid.valid).toBe(false);
|
||||
expect(invalid.diagnostics[0]?.message).toContain("required");
|
||||
});
|
||||
|
||||
it("blocks remote and missing references without attempting resolution", () => {
|
||||
const workspace = inspectWorkspace([
|
||||
{
|
||||
name: "unsafe.json",
|
||||
source: JSON.stringify({ $ref: "https://example.test/schema.json" }),
|
||||
},
|
||||
{ name: "missing.json", source: JSON.stringify({ $ref: "other.json" }) },
|
||||
]);
|
||||
expect(workspace.references.map((item) => item.status)).toEqual([
|
||||
"blocked",
|
||||
"unresolved",
|
||||
]);
|
||||
expect(workspace.diagnostics).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
level: "error",
|
||||
message: expect.stringContaining("blocked"),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
level: "error",
|
||||
message: expect.stringContaining("not supplied"),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses executable regex keywords in the bounded validator", () => {
|
||||
const workspace = inspectWorkspace([
|
||||
{
|
||||
name: "pattern.json",
|
||||
source: JSON.stringify({ type: "string", pattern: "^(a+)+$" }),
|
||||
},
|
||||
]);
|
||||
const result = validateJsonInstance(workspace, JSON.stringify("aaaa"));
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.diagnostics[0]?.message).toMatch(/pattern.*not executed/iu);
|
||||
});
|
||||
|
||||
it("also refuses regex and dynamic-reference keywords in supplied reference documents", () => {
|
||||
const regexWorkspace = inspectWorkspace([
|
||||
{ name: "root.json", source: JSON.stringify({ $ref: "defs.json" }) },
|
||||
{
|
||||
name: "defs.json",
|
||||
source: JSON.stringify({ type: "string", pattern: "^(a+)+$" }),
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
validateJsonInstance(regexWorkspace, JSON.stringify("aaaa")).valid,
|
||||
).toBe(false);
|
||||
const dynamicWorkspace = inspectWorkspace([
|
||||
{
|
||||
name: "dynamic.json",
|
||||
source: JSON.stringify({ $dynamicRef: "#node" }),
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
validateJsonInstance(dynamicWorkspace, JSON.stringify({})).diagnostics[0]
|
||||
?.message,
|
||||
).toContain("$dynamicRef");
|
||||
});
|
||||
|
||||
it("validates the documented 2020-12 object, array, dependency, and combiner subset", () => {
|
||||
const workspace = inspectWorkspace([
|
||||
{
|
||||
name: "bounded.json",
|
||||
source: JSON.stringify({
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
type: "object",
|
||||
required: ["kind", "values"],
|
||||
properties: {
|
||||
kind: { enum: ["measurement"] },
|
||||
note: { type: "string", minLength: 2, maxLength: 8 },
|
||||
values: {
|
||||
type: "array",
|
||||
prefixItems: [{ type: "integer" }],
|
||||
items: { type: "number", minimum: 0 },
|
||||
contains: { const: 2 },
|
||||
minContains: 1,
|
||||
uniqueItems: true,
|
||||
},
|
||||
},
|
||||
dependentRequired: { note: ["kind"] },
|
||||
allOf: [{ minProperties: 2 }],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
validateJsonInstance(
|
||||
workspace,
|
||||
JSON.stringify({ kind: "measurement", note: "ok", values: [1, 2] }),
|
||||
).valid,
|
||||
).toBe(true);
|
||||
const invalid = validateJsonInstance(
|
||||
workspace,
|
||||
JSON.stringify({ kind: "measurement", values: [1, 1], extra: true }),
|
||||
);
|
||||
expect(invalid.valid).toBe(false);
|
||||
expect(invalid.diagnostics.map((item) => item.message).join("\n")).toMatch(
|
||||
/uniqueItems|contains|false schema/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("supports Draft 6 local files even when root identifiers are remote-looking", () => {
|
||||
const workspace = inspectWorkspace([
|
||||
{
|
||||
name: "root.json",
|
||||
source: JSON.stringify({
|
||||
$schema: "http://json-schema.org/draft-06/schema#",
|
||||
$id: "https://schemas.example.test/root.json",
|
||||
$ref: "defs.json#/$defs/value",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "defs.json",
|
||||
source: JSON.stringify({
|
||||
$schema: "http://json-schema.org/draft-06/schema#",
|
||||
$defs: { value: { type: "integer", minimum: 1 } },
|
||||
}),
|
||||
},
|
||||
]);
|
||||
expect(validateJsonInstance(workspace, "2").valid).toBe(true);
|
||||
expect(validateJsonInstance(workspace, "0").valid).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects active XML constructs before structural inspection", () => {
|
||||
expect(() =>
|
||||
parseSchemaDocument({
|
||||
name: "unsafe.xsd",
|
||||
source: `<!DOCTYPE schema [<!ENTITY x "value">]><schema xmlns="http://www.w3.org/2001/XMLSchema"/>`,
|
||||
}),
|
||||
).toThrow(/DTD and entity/u);
|
||||
});
|
||||
});
|
||||
|
||||
describe("schema languages", () => {
|
||||
it("inventories XSD and generates a clearly heuristic XML instance", () => {
|
||||
const workspace = inspectWorkspace([
|
||||
{
|
||||
name: "person.xsd",
|
||||
source: `<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"><xs:element name="person"><xs:complexType><xs:sequence><xs:element name="name" type="xs:string"/><xs:element name="age" type="xs:integer"/></xs:sequence></xs:complexType></xs:element></xs:schema>`,
|
||||
},
|
||||
]);
|
||||
expect(workspace.documents.get("person.xsd")?.language).toBe("xsd");
|
||||
expect(
|
||||
workspace.diagnostics.some((item) =>
|
||||
item.message.includes("not provided"),
|
||||
),
|
||||
).toBe(true);
|
||||
const sample = generateSample(workspace);
|
||||
expect(sample.output).toContain("<person>");
|
||||
expect(sample.output).toContain("<name>string</name>");
|
||||
expect(sample.notices[0]).toContain("Heuristic XSD");
|
||||
});
|
||||
|
||||
it("inventories Relax NG and Schematron without executing expressions", () => {
|
||||
const rng = inspectWorkspace([
|
||||
{
|
||||
name: "note.rng",
|
||||
source: `<grammar xmlns="http://relaxng.org/ns/structure/1.0"><start><element name="note"><element name="body"><text/></element></element></start></grammar>`,
|
||||
},
|
||||
]);
|
||||
expect(generateSample(rng).output).toContain(
|
||||
"<note><body>string</body></note>",
|
||||
);
|
||||
const sch = inspectWorkspace([
|
||||
{
|
||||
name: "rules.sch",
|
||||
source: `<schema xmlns="http://purl.oclc.org/dsdl/schematron"><pattern id="required"><rule context="person"><assert test="name">A name is required.</assert></rule></pattern></schema>`,
|
||||
},
|
||||
]);
|
||||
expect(sch.documents.get("rules.sch")?.language).toBe("schematron");
|
||||
expect(
|
||||
sch.diagnostics.some(
|
||||
(item) =>
|
||||
item.message.includes("never executed") ||
|
||||
item.message.includes("not executed"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("parses OpenAPI YAML, inventories operations, and derives component samples", () => {
|
||||
const workspace = inspectWorkspace([
|
||||
{
|
||||
name: "openapi.yaml",
|
||||
source: `openapi: 3.1.0
|
||||
info:
|
||||
title: Local API
|
||||
version: 1.0.0
|
||||
paths:
|
||||
/people:
|
||||
get:
|
||||
responses:
|
||||
"200": { description: ok }
|
||||
components:
|
||||
schemas:
|
||||
Person:
|
||||
type: object
|
||||
required: [name]
|
||||
properties:
|
||||
name: { type: string }
|
||||
`,
|
||||
},
|
||||
]);
|
||||
const document = workspace.documents.get("openapi.yaml")!;
|
||||
expect(document.language).toBe("openapi");
|
||||
expect(document.summary).toContainEqual({
|
||||
label: "Operations",
|
||||
value: "1",
|
||||
});
|
||||
expect(JSON.parse(generateSample(workspace).output)).toEqual({
|
||||
name: "string",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("conservative comparison", () => {
|
||||
it("signals newly required fields and removed enum values", () => {
|
||||
const changes = compareSchemas(
|
||||
{
|
||||
name: "before.json",
|
||||
source: JSON.stringify({
|
||||
type: "object",
|
||||
properties: { state: { enum: ["on", "off"] } },
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "after.json",
|
||||
source: JSON.stringify({
|
||||
type: "object",
|
||||
required: ["state"],
|
||||
properties: { state: { enum: ["on"] } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
expect(changes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
level: "incompatible",
|
||||
message: expect.stringContaining("required"),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
level: "incompatible",
|
||||
path: "/properties/state/enum",
|
||||
message: expect.stringContaining("removed"),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("signals removed OpenAPI operations", () => {
|
||||
const base = {
|
||||
openapi: "3.1.0",
|
||||
info: { title: "Example", version: "1" },
|
||||
};
|
||||
const changes = compareSchemas(
|
||||
{
|
||||
name: "before.json",
|
||||
source: JSON.stringify({
|
||||
...base,
|
||||
paths: {
|
||||
"/items": { get: { responses: { "200": { description: "ok" } } } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
{ name: "after.json", source: JSON.stringify({ ...base, paths: {} }) },
|
||||
);
|
||||
expect(changes).toContainEqual(
|
||||
expect.objectContaining({ level: "incompatible", path: "GET /items" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user