1031 lines
35 KiB
TypeScript
1031 lines
35 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
SCHEMA_LIMITS,
|
|
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" } },
|
|
},
|
|
},
|
|
}),
|
|
};
|
|
|
|
function nestedJsonSchema(levels: number): object {
|
|
let schema: object = { const: "leaf" };
|
|
for (let level = 1; level < levels; level += 1)
|
|
schema = {
|
|
type: "object",
|
|
required: ["child"],
|
|
properties: { child: schema },
|
|
};
|
|
return schema;
|
|
}
|
|
|
|
function jsonValueDepth(value: unknown): number {
|
|
if (!value || typeof value !== "object") return 1;
|
|
const children = Object.values(value);
|
|
return 1 + (children.length ? Math.max(...children.map(jsonValueDepth)) : 0);
|
|
}
|
|
|
|
function xsdChain(levels: number): string {
|
|
const types = Array.from({ length: levels - 1 }, (_, index) => {
|
|
const number = index + 1;
|
|
const childType = number === levels - 1 ? "xs:string" : `Type${number + 1}`;
|
|
return `<xs:complexType name="Type${number}"><xs:sequence><xs:element name="level${number + 1}" type="${childType}"/></xs:sequence></xs:complexType>`;
|
|
}).join("");
|
|
return `<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">${types}<xs:element name="level1" type="Type1"/></xs:schema>`;
|
|
}
|
|
|
|
function relaxNgChain(levels: number): string {
|
|
let pattern = "<text/>";
|
|
for (let level = levels; level >= 1; level -= 1)
|
|
pattern = `<element name="level${level}"><group>${pattern}</group></element>`;
|
|
return `<grammar xmlns="http://relaxng.org/ns/structure/1.0"><start>${pattern}</start></grammar>`;
|
|
}
|
|
|
|
function hasUnpairedSurrogate(value: string): boolean {
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
const code = value.charCodeAt(index);
|
|
if (code >= 0xd800 && code <= 0xdbff) {
|
|
const next = value.charCodeAt(index + 1);
|
|
if (next < 0xdc00 || next > 0xdfff) return true;
|
|
index += 1;
|
|
} else if (code >= 0xdc00 && code <= 0xdfff) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const amplifiedChoiceSupport = {
|
|
name: "choices.json",
|
|
source: JSON.stringify({
|
|
$defs: {
|
|
impossible: {
|
|
anyOf: Array.from({ length: 600 }, () => false),
|
|
},
|
|
amplified: {
|
|
anyOf: Array.from({ length: 100 }, () => ({
|
|
$ref: "#/$defs/impossible",
|
|
})),
|
|
},
|
|
},
|
|
}),
|
|
};
|
|
|
|
function captureError(run: () => unknown): Error {
|
|
try {
|
|
run();
|
|
} catch (error) {
|
|
if (error instanceof Error) return error;
|
|
throw error;
|
|
}
|
|
throw new Error("Expected operation to throw.");
|
|
}
|
|
|
|
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("bounds repeated large literal samples reached through local references", () => {
|
|
const properties = Object.fromEntries(
|
|
Array.from({ length: 100 }, (_, index) => [
|
|
`value${index}`,
|
|
{ $ref: "literal.json#/$defs/large" },
|
|
]),
|
|
);
|
|
const workspace = inspectWorkspace([
|
|
{
|
|
name: "root.json",
|
|
source: JSON.stringify({
|
|
type: "object",
|
|
required: Object.keys(properties),
|
|
properties,
|
|
}),
|
|
},
|
|
{
|
|
name: "literal.json",
|
|
source: JSON.stringify({
|
|
$defs: { large: { default: "x".repeat(100_000) } },
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const sample = generateSample(workspace);
|
|
const values = Object.values(JSON.parse(sample.output) as object);
|
|
expect(sample.output.length).toBeLessThanOrEqual(
|
|
SCHEMA_LIMITS.sampleOutputChars,
|
|
);
|
|
expect(values).toHaveLength(100);
|
|
expect(
|
|
values.every(
|
|
(value) => String(value).length <= SCHEMA_LIMITS.sampleValueChars,
|
|
),
|
|
).toBe(true);
|
|
expect(sample.notices.join("\n")).toMatch(/truncated.*safety bound/iu);
|
|
});
|
|
|
|
it("applies the exact JSON node budget to copied literal collections", () => {
|
|
const workspace = inspectWorkspace([
|
|
{
|
|
name: "literal-array.json",
|
|
source: JSON.stringify({
|
|
default: Array.from({ length: 100 }, () =>
|
|
Array.from({ length: 20 }, () => null),
|
|
),
|
|
}),
|
|
},
|
|
]);
|
|
const sample = generateSample(workspace);
|
|
const value = JSON.parse(sample.output) as unknown;
|
|
const countNodes = (item: unknown): number =>
|
|
item && typeof item === "object"
|
|
? 1 +
|
|
Object.values(item).reduce((sum, child) => sum + countNodes(child), 0)
|
|
: 1;
|
|
expect(countNodes(value)).toBe(SCHEMA_LIMITS.sampleNodes);
|
|
expect(sample.notices.join("\n")).toMatch(/node.*safety bound/iu);
|
|
});
|
|
|
|
it("monotonically bounds amplified JSON choice attempts through reused refs", () => {
|
|
const properties = Object.fromEntries(
|
|
Array.from({ length: 100 }, (_, index) => [
|
|
`value${index}`,
|
|
{ $ref: "choices.json#/$defs/value" },
|
|
]),
|
|
);
|
|
const workspace = inspectWorkspace([
|
|
{
|
|
name: "root.json",
|
|
source: JSON.stringify({
|
|
type: "object",
|
|
required: Object.keys(properties),
|
|
properties,
|
|
}),
|
|
},
|
|
{
|
|
name: "choices.json",
|
|
source: JSON.stringify({
|
|
$defs: {
|
|
value: {
|
|
anyOf: [
|
|
...Array.from({ length: 600 }, () => false),
|
|
{ const: "viable" },
|
|
],
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const started = performance.now();
|
|
const error = captureError(() => generateSample(workspace));
|
|
const elapsed = performance.now() - started;
|
|
expect(error.message).toMatch(/sample budget was exhausted/iu);
|
|
expect(error.message).not.toMatch(/first viable/iu);
|
|
expect(elapsed).toBeLessThan(2_000);
|
|
});
|
|
|
|
it("never treats exhausted all-impossible anyOf or oneOf branches as viable", () => {
|
|
for (const keyword of ["anyOf", "oneOf"] as const) {
|
|
const workspace = inspectWorkspace([
|
|
{
|
|
name: `${keyword}.json`,
|
|
source: JSON.stringify({
|
|
[keyword]: Array.from({ length: 100 }, () => ({
|
|
$ref: "choices.json#/$defs/impossible",
|
|
})),
|
|
}),
|
|
},
|
|
amplifiedChoiceSupport,
|
|
]);
|
|
|
|
const error = captureError(() => generateSample(workspace));
|
|
expect(error.message).toMatch(/sample budget was exhausted/iu);
|
|
expect(error.message).not.toMatch(/first viable/iu);
|
|
}
|
|
});
|
|
|
|
it("refuses a partial mandatory allOf when prior work exhausts the budget", () => {
|
|
const properties = Object.fromEntries(
|
|
Array.from({ length: 100 }, (_, index) => [
|
|
`value${index}`,
|
|
{ $ref: "choices.json#/$defs/amplified" },
|
|
]),
|
|
);
|
|
const workspace = inspectWorkspace([
|
|
{
|
|
name: "all-of.json",
|
|
source: JSON.stringify({
|
|
allOf: [
|
|
{
|
|
type: "object",
|
|
required: Object.keys(properties),
|
|
properties,
|
|
},
|
|
false,
|
|
],
|
|
}),
|
|
},
|
|
amplifiedChoiceSupport,
|
|
]);
|
|
|
|
expect(() => generateSample(workspace)).toThrow(
|
|
/sample budget was exhausted/iu,
|
|
);
|
|
});
|
|
|
|
it("propagates optional-property exhaustion before later required constraints", () => {
|
|
for (const requiredSchema of [{ const: "required" }, false] as const) {
|
|
const workspace = inspectWorkspace([
|
|
{
|
|
name: "optional-before-required.json",
|
|
source: JSON.stringify({
|
|
type: "object",
|
|
required: ["required"],
|
|
properties: {
|
|
optional: { $ref: "choices.json#/$defs/amplified" },
|
|
required: requiredSchema,
|
|
},
|
|
}),
|
|
},
|
|
amplifiedChoiceSupport,
|
|
]);
|
|
|
|
expect(() => generateSample(workspace)).toThrow(
|
|
/sample budget was exhausted/iu,
|
|
);
|
|
}
|
|
});
|
|
|
|
it("propagates work exhaustion from a required array item", () => {
|
|
const workspace = inspectWorkspace([
|
|
{
|
|
name: "required-item.json",
|
|
source: JSON.stringify({
|
|
type: "array",
|
|
minItems: 1,
|
|
items: { $ref: "choices.json#/$defs/amplified" },
|
|
}),
|
|
},
|
|
amplifiedChoiceSupport,
|
|
]);
|
|
|
|
expect(() => generateSample(workspace)).toThrow(
|
|
/sample budget was exhausted/iu,
|
|
);
|
|
});
|
|
|
|
it("charges cached wide JSON property visits on every reused schema", () => {
|
|
const wideProperties = Object.fromEntries(
|
|
Array.from({ length: 2_000 }, (_, index) => [
|
|
`optional${index}`,
|
|
{ type: "string" },
|
|
]),
|
|
);
|
|
const workspace = inspectWorkspace([
|
|
{
|
|
name: "wide-references.json",
|
|
source: JSON.stringify({
|
|
allOf: Array.from({ length: 2_000 }, () => ({
|
|
$ref: "wide.json#/$defs/wide",
|
|
})),
|
|
}),
|
|
},
|
|
{
|
|
name: "wide.json",
|
|
source: JSON.stringify({
|
|
$defs: {
|
|
wide: { type: "object", properties: wideProperties },
|
|
},
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const started = performance.now();
|
|
const error = captureError(() => generateSample(workspace));
|
|
expect(error.message).toMatch(/sample budget was exhausted/iu);
|
|
expect(performance.now() - started).toBeLessThan(2_000);
|
|
});
|
|
|
|
it("admits exactly 20 generated JSON levels and omits level 21", () => {
|
|
const exact = inspectWorkspace([
|
|
{
|
|
name: "depth-20.json",
|
|
source: JSON.stringify(nestedJsonSchema(20)),
|
|
},
|
|
]);
|
|
const exactSample = generateSample(exact);
|
|
expect(jsonValueDepth(JSON.parse(exactSample.output))).toBe(20);
|
|
expect(exactSample.notices.join("\n")).not.toMatch(/level safety bound/iu);
|
|
|
|
const excessive = inspectWorkspace([
|
|
{
|
|
name: "depth-21.json",
|
|
source: JSON.stringify(nestedJsonSchema(21)),
|
|
},
|
|
]);
|
|
const boundedSample = generateSample(excessive);
|
|
expect(jsonValueDepth(JSON.parse(boundedSample.output))).toBe(20);
|
|
expect(boundedSample.notices.join("\n")).toMatch(/level safety bound/iu);
|
|
});
|
|
|
|
it("truncates JSON literals without splitting surrogate pairs", () => {
|
|
const value = `a${"😀".repeat(600)}`;
|
|
const workspace = inspectWorkspace([
|
|
{
|
|
name: "unicode.json",
|
|
source: JSON.stringify({ default: value }),
|
|
},
|
|
]);
|
|
const generated = JSON.parse(generateSample(workspace).output) as string;
|
|
expect(generated.length).toBeLessThanOrEqual(
|
|
SCHEMA_LIMITS.sampleValueChars,
|
|
);
|
|
expect(hasUnpairedSurrogate(generated)).toBe(false);
|
|
});
|
|
|
|
it("refuses to claim a sample for the always-invalid false schema", () => {
|
|
const workspace = inspectWorkspace([
|
|
{ name: "impossible.json", source: "false" },
|
|
]);
|
|
expect(() => generateSample(workspace)).toThrow(/no valid sample/iu);
|
|
});
|
|
|
|
it("refuses false schemas reached through references and required combiners", () => {
|
|
const referenced = inspectWorkspace([
|
|
{ name: "root.json", source: JSON.stringify({ $ref: "defs.json" }) },
|
|
{ name: "defs.json", source: "false" },
|
|
]);
|
|
expect(() => generateSample(referenced)).toThrow(/no valid sample/iu);
|
|
|
|
const combined = inspectWorkspace([
|
|
{
|
|
name: "combined.json",
|
|
source: JSON.stringify({ allOf: [{ type: "object" }, false] }),
|
|
},
|
|
]);
|
|
expect(() => generateSample(combined)).toThrow(/no valid sample/iu);
|
|
});
|
|
|
|
it("skips impossible alternatives when a viable anyOf sample exists", () => {
|
|
const workspace = inspectWorkspace([
|
|
{
|
|
name: "choice.json",
|
|
source: JSON.stringify({
|
|
anyOf: [false, { type: "string", const: "viable" }],
|
|
}),
|
|
},
|
|
]);
|
|
expect(JSON.parse(generateSample(workspace).output)).toBe("viable");
|
|
});
|
|
|
|
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("applies the aggregate text budget to repeated XSD type literals", () => {
|
|
const branches = Array.from(
|
|
{ length: 50 },
|
|
(_, index) => `<xs:element name="branch${index}" type="Branch"/>`,
|
|
).join("");
|
|
const leaves = Array.from(
|
|
{ length: 50 },
|
|
(_, index) => `<xs:element name="item${index}" type="LargeText"/>`,
|
|
).join("");
|
|
const workspace = inspectWorkspace([
|
|
{
|
|
name: "amplified.xsd",
|
|
source: `<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"><xs:simpleType name="LargeText"><xs:restriction base="xs:string"><xs:enumeration value="${"x".repeat(SCHEMA_LIMITS.sampleValueChars)}"/></xs:restriction></xs:simpleType><xs:complexType name="Branch"><xs:sequence>${leaves}</xs:sequence></xs:complexType><xs:element name="root"><xs:complexType><xs:sequence>${branches}</xs:sequence></xs:complexType></xs:element></xs:schema>`,
|
|
},
|
|
]);
|
|
|
|
const sample = generateSample(workspace);
|
|
const parsed = new DOMParser().parseFromString(sample.output, "text/xml");
|
|
expect(parsed.querySelector("parsererror")).toBeNull();
|
|
expect(parsed.documentElement.textContent?.length ?? 0).toBeLessThanOrEqual(
|
|
SCHEMA_LIMITS.sampleTextChars,
|
|
);
|
|
expect(parsed.querySelectorAll("*").length).toBeLessThanOrEqual(
|
|
SCHEMA_LIMITS.sampleNodes,
|
|
);
|
|
expect(sample.output.length).toBeLessThanOrEqual(
|
|
SCHEMA_LIMITS.sampleOutputChars,
|
|
);
|
|
expect(sample.notices.join("\n")).toMatch(/text safety bound/iu);
|
|
});
|
|
|
|
it("linearly inspects and preindexes a large reused XSD type", () => {
|
|
const leaves = Array.from(
|
|
{ length: 20_000 },
|
|
() => `<xs:element name="leaf" type="xs:string"/>`,
|
|
).join("");
|
|
const branches = Array.from(
|
|
{ length: 50 },
|
|
(_, index) => `<xs:element name="branch${index}" type="Heavy"/>`,
|
|
).join("");
|
|
const source = `<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"><xs:complexType name="Heavy"><xs:sequence>${leaves}</xs:sequence></xs:complexType><xs:complexType name="Root"><xs:sequence>${branches}</xs:sequence></xs:complexType><xs:element name="root" type="Root"/></xs:schema>`;
|
|
|
|
const inspectStarted = performance.now();
|
|
const workspace = inspectWorkspace([{ name: "large-flat.xsd", source }]);
|
|
const inspectElapsed = performance.now() - inspectStarted;
|
|
const generateStarted = performance.now();
|
|
const sample = generateSample(workspace);
|
|
const generateElapsed = performance.now() - generateStarted;
|
|
const parsed = new DOMParser().parseFromString(sample.output, "text/xml");
|
|
|
|
expect(parsed.querySelector("parsererror")).toBeNull();
|
|
expect(parsed.querySelectorAll("*").length).toBeLessThanOrEqual(
|
|
SCHEMA_LIMITS.sampleNodes,
|
|
);
|
|
expect(inspectElapsed).toBeLessThan(2_000);
|
|
expect(generateElapsed).toBeLessThan(2_000);
|
|
});
|
|
|
|
it("linearly inspects a wide XSD schema container", () => {
|
|
const annotations = Array.from(
|
|
{ length: 20_000 },
|
|
() => "<xs:annotation/>",
|
|
).join("");
|
|
const source = `<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"><xs:element name="root"/>${annotations}</xs:schema>`;
|
|
|
|
const started = performance.now();
|
|
const workspace = inspectWorkspace([{ name: "wide-root.xsd", source }]);
|
|
const elapsed = performance.now() - started;
|
|
|
|
expect(workspace.documents.get(workspace.entry)?.language).toBe("xsd");
|
|
expect(elapsed).toBeLessThan(2_000);
|
|
});
|
|
|
|
it("admits exactly 20 generated XSD levels and omits level 21", () => {
|
|
for (const [levels, noticeExpected] of [
|
|
[20, false],
|
|
[21, true],
|
|
] as const) {
|
|
const workspace = inspectWorkspace([
|
|
{ name: `depth-${levels}.xsd`, source: xsdChain(levels) },
|
|
]);
|
|
const sample = generateSample(workspace);
|
|
const parsed = new DOMParser().parseFromString(sample.output, "text/xml");
|
|
expect(parsed.querySelector("parsererror")).toBeNull();
|
|
expect(parsed.querySelectorAll("*")).toHaveLength(20);
|
|
expect(/level safety bound/iu.test(sample.notices.join("\n"))).toBe(
|
|
noticeExpected,
|
|
);
|
|
}
|
|
});
|
|
|
|
it("keeps truncated XSD astral literals well formed", () => {
|
|
const value = `a${"😀".repeat(600)}`;
|
|
const workspace = inspectWorkspace([
|
|
{
|
|
name: "unicode.xsd",
|
|
source: `<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"><xs:simpleType name="Unicode"><xs:restriction base="xs:string"><xs:enumeration value="${value}"/></xs:restriction></xs:simpleType><xs:element name="root" type="Unicode"/></xs:schema>`,
|
|
},
|
|
]);
|
|
const sample = generateSample(workspace);
|
|
const parsed = new DOMParser().parseFromString(sample.output, "text/xml");
|
|
expect(parsed.querySelector("parsererror")).toBeNull();
|
|
expect(hasUnpairedSurrogate(parsed.documentElement.textContent ?? "")).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it("omits duplicate and fallback-colliding XSD attributes", () => {
|
|
const workspace = inspectWorkspace([
|
|
{
|
|
name: "attributes.xsd",
|
|
source: `<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"><xs:complexType name="WithAttributes"><xs:attribute name="same"/><xs:attribute name="same"/><xs:attribute name="1bad"/><xs:attribute name="2bad"/></xs:complexType><xs:element name="root" type="WithAttributes"/></xs:schema>`,
|
|
},
|
|
]);
|
|
|
|
const sample = generateSample(workspace);
|
|
const parsed = new DOMParser().parseFromString(sample.output, "text/xml");
|
|
expect(parsed.querySelector("parsererror")).toBeNull();
|
|
expect(parsed.documentElement.attributes).toHaveLength(2);
|
|
expect(parsed.documentElement.hasAttribute("same")).toBe(true);
|
|
expect(parsed.documentElement.hasAttribute("attribute")).toBe(true);
|
|
expect(sample.notices.join("\n")).toMatch(/duplicate.*attribute/iu);
|
|
});
|
|
|
|
it("caches a wide inline XSD type across repeated rendering", () => {
|
|
const annotations = Array.from(
|
|
{ length: 20_000 },
|
|
() => "<xs:annotation/>",
|
|
).join("");
|
|
const branches = Array.from(
|
|
{ length: 50 },
|
|
(_, index) => `<xs:element name="branch${index}" type="Branch"/>`,
|
|
).join("");
|
|
const workspace = inspectWorkspace([
|
|
{
|
|
name: "wide-inline.xsd",
|
|
source: `<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"><xs:complexType name="Branch"><xs:sequence><xs:element name="leaf">${annotations}<xs:simpleType><xs:restriction base="xs:string"/></xs:simpleType></xs:element></xs:sequence></xs:complexType><xs:complexType name="Root"><xs:sequence>${branches}</xs:sequence></xs:complexType><xs:element name="root" type="Root"/></xs:schema>`,
|
|
},
|
|
]);
|
|
|
|
const started = performance.now();
|
|
const sample = generateSample(workspace);
|
|
const elapsed = performance.now() - started;
|
|
const parsed = new DOMParser().parseFromString(sample.output, "text/xml");
|
|
expect(parsed.querySelector("parsererror")).toBeNull();
|
|
expect(elapsed).toBeLessThan(2_000);
|
|
});
|
|
|
|
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("retains a direct-root Relax NG element in the generated sample", () => {
|
|
const workspace = inspectWorkspace([
|
|
{
|
|
name: "direct.rng",
|
|
source: `<element xmlns="http://relaxng.org/ns/structure/1.0" name="root"><text/></element>`,
|
|
},
|
|
]);
|
|
const sample = generateSample(workspace);
|
|
const parsed = new DOMParser().parseFromString(sample.output, "text/xml");
|
|
expect(parsed.querySelector("parsererror")).toBeNull();
|
|
expect(parsed.documentElement.localName).toBe("root");
|
|
expect(parsed.documentElement.textContent).toBe("string");
|
|
});
|
|
|
|
it("never emits more than the exact Relax NG sample node budget", () => {
|
|
const repeatedElements = Array.from(
|
|
{ length: SCHEMA_LIMITS.sampleNodes },
|
|
() => `<element name="node"><text/></element>`,
|
|
).join("");
|
|
const workspace = inspectWorkspace([
|
|
{
|
|
name: "bounded.rng",
|
|
source: `<grammar xmlns="http://relaxng.org/ns/structure/1.0"><start><element name="root"><group>${repeatedElements}</group></element></start></grammar>`,
|
|
},
|
|
]);
|
|
|
|
const sample = generateSample(workspace);
|
|
const parsed = new DOMParser().parseFromString(sample.output, "text/xml");
|
|
expect(parsed.querySelector("parsererror")).toBeNull();
|
|
expect(parsed.querySelectorAll("*")).toHaveLength(
|
|
SCHEMA_LIMITS.sampleNodes,
|
|
);
|
|
});
|
|
|
|
it("applies the aggregate text budget through repeated Relax NG refs", () => {
|
|
const references = Array.from(
|
|
{ length: SCHEMA_LIMITS.sampleNodes },
|
|
() => `<ref name="large"/>`,
|
|
).join("");
|
|
const workspace = inspectWorkspace([
|
|
{
|
|
name: "text-budget.rng",
|
|
source: `<grammar xmlns="http://relaxng.org/ns/structure/1.0"><define name="large"><value>${"x".repeat(SCHEMA_LIMITS.sampleValueChars)}</value></define><start><element name="root"><group>${references}</group></element></start></grammar>`,
|
|
},
|
|
]);
|
|
|
|
const sample = generateSample(workspace);
|
|
const parsed = new DOMParser().parseFromString(sample.output, "text/xml");
|
|
expect(parsed.querySelector("parsererror")).toBeNull();
|
|
expect(parsed.documentElement.textContent?.length ?? 0).toBeLessThanOrEqual(
|
|
SCHEMA_LIMITS.sampleTextChars,
|
|
);
|
|
expect(sample.notices.join("\n")).toMatch(/text safety bound/iu);
|
|
});
|
|
|
|
it("bounds amplified Relax NG pattern visits independently of output", () => {
|
|
const width = 300;
|
|
const emptyPatterns = Array.from({ length: width }, () => "<empty/>").join(
|
|
"",
|
|
);
|
|
const references = Array.from(
|
|
{ length: width },
|
|
() => `<ref name="fanout"/>`,
|
|
).join("");
|
|
const workspace = inspectWorkspace([
|
|
{
|
|
name: "amplified-work.rng",
|
|
source: `<grammar xmlns="http://relaxng.org/ns/structure/1.0"><define name="fanout"><group>${emptyPatterns}</group></define><start><element name="root"><group>${references}</group></element></start></grammar>`,
|
|
},
|
|
]);
|
|
|
|
const started = performance.now();
|
|
const error = captureError(() => generateSample(workspace));
|
|
const elapsed = performance.now() - started;
|
|
expect(error.message).toMatch(/sample budget was exhausted/iu);
|
|
expect(elapsed).toBeLessThan(2_000);
|
|
});
|
|
|
|
it("caches wide reused Relax NG pattern containers", () => {
|
|
const ignored = Array.from({ length: 19_000 }, () => "<empty/>").join("");
|
|
const references = Array.from(
|
|
{ length: 1_000 },
|
|
() => '<ref name="wide"/>',
|
|
).join("");
|
|
const workspace = inspectWorkspace([
|
|
{
|
|
name: "wide-reused.rng",
|
|
source: `<grammar xmlns="http://relaxng.org/ns/structure/1.0"><define name="wide"><choice><empty/></choice>${ignored}</define><start><element name="root"><group>${references}</group></element></start></grammar>`,
|
|
},
|
|
]);
|
|
|
|
const started = performance.now();
|
|
const sample = generateSample(workspace);
|
|
const elapsed = performance.now() - started;
|
|
const parsed = new DOMParser().parseFromString(sample.output, "text/xml");
|
|
expect(parsed.querySelector("parsererror")).toBeNull();
|
|
expect(parsed.documentElement.localName).toBe("root");
|
|
expect(elapsed).toBeLessThan(2_000);
|
|
});
|
|
|
|
it("counts generated Relax NG levels rather than wrapper patterns", () => {
|
|
for (const [levels, noticeExpected] of [
|
|
[20, false],
|
|
[21, true],
|
|
] as const) {
|
|
const workspace = inspectWorkspace([
|
|
{ name: `depth-${levels}.rng`, source: relaxNgChain(levels) },
|
|
]);
|
|
const sample = generateSample(workspace);
|
|
const parsed = new DOMParser().parseFromString(sample.output, "text/xml");
|
|
expect(parsed.querySelector("parsererror")).toBeNull();
|
|
expect(parsed.querySelectorAll("*")).toHaveLength(20);
|
|
expect(/level safety bound/iu.test(sample.notices.join("\n"))).toBe(
|
|
noticeExpected,
|
|
);
|
|
}
|
|
});
|
|
|
|
it("keeps truncated Relax NG astral literals well formed", () => {
|
|
const value = `a${"😀".repeat(600)}`;
|
|
const workspace = inspectWorkspace([
|
|
{
|
|
name: "unicode.rng",
|
|
source: `<element xmlns="http://relaxng.org/ns/structure/1.0" name="root"><value>${value}</value></element>`,
|
|
},
|
|
]);
|
|
const sample = generateSample(workspace);
|
|
const parsed = new DOMParser().parseFromString(sample.output, "text/xml");
|
|
expect(parsed.querySelector("parsererror")).toBeNull();
|
|
expect(hasUnpairedSurrogate(parsed.documentElement.textContent ?? "")).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it("omits duplicate and fallback-colliding Relax NG attributes", () => {
|
|
const workspace = inspectWorkspace([
|
|
{
|
|
name: "attributes.rng",
|
|
source: `<element xmlns="http://relaxng.org/ns/structure/1.0" name="root"><attribute name="same"/><attribute name="same"/><attribute name="1bad"/><attribute name="2bad"/><text/></element>`,
|
|
},
|
|
]);
|
|
|
|
const sample = generateSample(workspace);
|
|
const parsed = new DOMParser().parseFromString(sample.output, "text/xml");
|
|
expect(parsed.querySelector("parsererror")).toBeNull();
|
|
expect(parsed.documentElement.attributes).toHaveLength(2);
|
|
expect(parsed.documentElement.hasAttribute("same")).toBe(true);
|
|
expect(parsed.documentElement.hasAttribute("attribute")).toBe(true);
|
|
expect(sample.notices.join("\n")).toMatch(/duplicate.*attribute/iu);
|
|
});
|
|
|
|
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",
|
|
});
|
|
});
|
|
|
|
it("refuses an impossible OpenAPI component sample", () => {
|
|
const workspace = inspectWorkspace([
|
|
{
|
|
name: "impossible-openapi.json",
|
|
source: JSON.stringify({
|
|
openapi: "3.1.0",
|
|
info: { title: "Impossible", version: "1" },
|
|
paths: {},
|
|
components: { schemas: { Impossible: false } },
|
|
}),
|
|
},
|
|
]);
|
|
expect(() => generateSample(workspace)).toThrow(/no valid sample/iu);
|
|
});
|
|
|
|
it("applies the same 20-level depth ceiling to OpenAPI samples", () => {
|
|
const workspace = inspectWorkspace([
|
|
{
|
|
name: "deep-openapi.json",
|
|
source: JSON.stringify({
|
|
openapi: "3.1.0",
|
|
info: { title: "Deep", version: "1" },
|
|
paths: {},
|
|
components: { schemas: { Deep: nestedJsonSchema(21) } },
|
|
}),
|
|
},
|
|
]);
|
|
const sample = generateSample(workspace);
|
|
expect(jsonValueDepth(JSON.parse(sample.output))).toBe(20);
|
|
expect(sample.notices.join("\n")).toMatch(/level safety bound/iu);
|
|
});
|
|
});
|
|
|
|
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" }),
|
|
);
|
|
});
|
|
});
|