inspector/clients/tui/__tests__/schemaToForm.test.ts
cliffhall 81f2115e8b fix: decline a union branch requiring a name it never declares (#2224)
`isOfferable` in `core/json/rootUnion.ts` checked that a merged branch carries
at least one property and that every property value is readable, but never that
each name in `required` resolves to a property a form can render. A branch like
`{ properties: { kind }, required: ["kind", "payload"] }` was therefore offered
in the picker, rendered no control for `payload`, and had the submit-time check
report it missing permanently — the exact dead end the function's own comment
already reasons through for a `false`-schema required field, one case short.

Judge the merge, so a branch requiring only names the ROOT declares —
`anyOf: [{ required: ["email"] }, …]` — stays offerable as before. `hasOwn`
rather than `in`, since an argument legally named `constructor` would otherwise
resolve through the prototype and read as declared.

Declining every branch of a union leaves the web form with neither a picker nor
a field, which is a different dead end. `SchemaForm` now seeds the raw-JSON
editor (#2151) in that one case — a root union that resolved to no branches and
no properties — so the arguments stay expressible. Seeded rather than forced, so
the switch still works, and re-seeded during render (never in an effect) because
the form is reused across tools rather than remounted. A tool that genuinely
takes no arguments is untouched. The TUI has no such editor; there the union is
simply not offered, which is already better than a section that can never be
submitted.

Adds a `record_shipment_by` preset and wires it into the
`root-union-schemas-http.json` showcase so the shape is reproducible by hand,
with docs/test-servers.md covering it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxYT4eKdnx1yRw7x4bW7Ts
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-06 12:59:52 -04:00

990 lines
32 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
decodeFormValues,
missingRequiredFields,
schemaToForm,
} from "../src/utils/schemaToForm.js";
describe("schemaToForm", () => {
it("returns an empty Parameters section when there is no schema", () => {
const form = schemaToForm(null, "noParams");
expect(form.title).toBe("Test Tool: noParams");
expect(form.sections).toEqual([{ title: "Parameters", fields: [] }]);
});
it("returns an empty Parameters section when properties are absent", () => {
const form = schemaToForm({}, "empty");
expect(form.sections[0]?.fields).toEqual([]);
});
it("treats a non-object property value as an empty schema instead of throwing", () => {
// A malformed server schema whose property value is null/primitive must not
// crash — the field degrades to a plain string input labelled by its key.
const form = schemaToForm(
{ properties: { bad: null, worse: 42 } },
"malformed",
);
const fields = form.sections[0]?.fields ?? [];
expect(fields).toHaveLength(2);
expect(
fields.map((f) => ({ name: f.name, type: f.type, label: f.label })),
).toEqual([
{ name: "bad", type: "string", label: "bad" },
{ name: "worse", type: "string", label: "worse" },
]);
});
it("maps each JSON Schema type to the matching ink-form field type", () => {
const form = schemaToForm(
{
properties: {
name: { type: "string", title: "Name" },
age: { type: "integer", minimum: 0, maximum: 120 },
ratio: { type: "number", minimum: 0, maximum: 1 },
active: { type: "boolean" },
mystery: { type: "object" },
},
required: ["name"],
},
"typed",
);
const fields = form.sections[0]!.fields;
const byName = Object.fromEntries(fields.map((f) => [f.name, f]));
expect(byName.name).toMatchObject({
type: "string",
label: "Name",
required: true,
});
expect(byName.age).toMatchObject({ type: "integer", min: 0, max: 120 });
expect(byName.ratio).toMatchObject({ type: "float", min: 0, max: 1 });
expect(byName.active).toMatchObject({ type: "boolean" });
// Unknown types fall back to string, and label falls back to the key.
expect(byName.mystery).toMatchObject({ type: "string", label: "mystery" });
});
it("builds a select field from an enum", () => {
const form = schemaToForm(
{ properties: { color: { type: "string", enum: ["red", "blue"] } } },
"enum",
);
expect(form.sections[0]!.fields[0]).toMatchObject({
type: "select",
options: [
{ label: "red", value: "red" },
{ label: "blue", value: "blue" },
],
});
});
it("builds a select field from an array-of-enum on items.enum alone", () => {
const form = schemaToForm(
{
properties: {
// Standard array-of-enums shape: options come from `items.enum` with
// NO top-level `enum`. The array branch keys on `items.enum` alone
// (matching the web guard), so this renders as a select.
tags: {
type: "array",
items: { enum: ["a", "b"] },
},
},
},
"arrayEnum",
);
expect(form.sections[0]!.fields[0]).toMatchObject({
type: "select",
options: [
{ label: "a", value: "a" },
{ label: "b", value: "b" },
],
});
});
it("still builds a select for an array-of-enum that also carries a top-level enum", () => {
const form = schemaToForm(
{
properties: {
tags: {
type: "array",
enum: ["a", "b"],
items: { enum: ["a", "b"] },
},
},
},
"arrayEnumRedundant",
);
expect(form.sections[0]!.fields[0]).toMatchObject({
type: "select",
options: [
{ label: "a", value: "a" },
{ label: "b", value: "b" },
],
});
});
it("uses enumNames as single-select labels while keeping raw values", () => {
const form = schemaToForm(
{
properties: {
pet: {
type: "string",
enum: ["pet-1", "pet-2"],
enumNames: ["Cats", "Dogs"],
},
},
},
"titledEnum",
);
expect(form.sections[0]!.fields[0]).toMatchObject({
type: "select",
options: [
{ label: "Cats", value: "pet-1" },
{ label: "Dogs", value: "pet-2" },
],
});
});
it("falls back to raw single-select labels when enumNames length mismatches", () => {
const form = schemaToForm(
{
properties: {
pet: {
type: "string",
enum: ["pet-1", "pet-2"],
// Only one name for two values — a wrong-length zip would
// mislabel, so the raw values are used as labels.
enumNames: ["Cats"],
},
},
},
"mismatchedEnum",
);
expect(form.sections[0]!.fields[0]).toMatchObject({
options: [
{ label: "pet-1", value: "pet-1" },
{ label: "pet-2", value: "pet-2" },
],
});
});
it("uses items.enumNames as array-of-enum labels while keeping raw values", () => {
const form = schemaToForm(
{
properties: {
pets: {
type: "array",
items: { enum: ["pet-1", "pet-2"], enumNames: ["Cats", "Dogs"] },
},
},
},
"titledArrayEnum",
);
expect(form.sections[0]!.fields[0]).toMatchObject({
type: "select",
options: [
{ label: "Cats", value: "pet-1" },
{ label: "Dogs", value: "pet-2" },
],
});
});
it("falls back to raw array-of-enum labels when items.enumNames length mismatches", () => {
const form = schemaToForm(
{
properties: {
pets: {
type: "array",
items: { enum: ["pet-1", "pet-2"], enumNames: ["Cats"] },
},
},
},
"mismatchedArrayEnum",
);
expect(form.sections[0]!.fields[0]).toMatchObject({
options: [
{ label: "pet-1", value: "pet-1" },
{ label: "pet-2", value: "pet-2" },
],
});
});
it("carries a JSON Schema default through as the field's initialValue", () => {
const form = schemaToForm(
{ properties: { greeting: { type: "string", default: "hi" } } },
"withDefault",
);
expect(form.sections[0]!.fields[0]).toMatchObject({ initialValue: "hi" });
});
// #2015 (the TUI twin of #1928): an argument declared with Zod's `.nullish()`
// has no top-level `type`/`enum` — they sit on the surviving `anyOf` branch —
// so before normalization every one of these degraded to a plain text field.
describe("nullable unions", () => {
it("renders a select for an anyOf string-enum|null argument", () => {
const form = schemaToForm(
{
properties: {
direction: {
anyOf: [
{ type: "string", enum: ["envio", "recebimento"] },
{ type: "null" },
],
},
},
},
"nullableEnum",
);
expect(form.sections[0]!.fields[0]).toMatchObject({
name: "direction",
type: "select",
options: [
{ label: "envio", value: "envio" },
{ label: "recebimento", value: "recebimento" },
],
});
});
it("maps the remaining nullable scalar branches to their typed fields", () => {
const form = schemaToForm(
{
properties: {
reference: { anyOf: [{ type: "string" }, { type: "null" }] },
quantity: {
anyOf: [
{ type: "integer", minimum: 1, maximum: 9 },
{ type: "null" },
],
},
ratio: { anyOf: [{ type: "number" }, { type: "null" }] },
express: { anyOf: [{ type: "boolean" }, { type: "null" }] },
},
},
"nullableScalars",
);
const fields = form.sections[0]!.fields;
expect(fields.map((f) => ({ name: f.name, type: f.type }))).toEqual([
{ name: "reference", type: "string" },
{ name: "quantity", type: "integer" },
{ name: "ratio", type: "float" },
{ name: "express", type: "boolean" },
]);
// The branch's own constraints are hoisted along with its type.
expect(fields[1]).toMatchObject({ min: 1, max: 9 });
});
it("renders a select for a nullable array-of-enum argument", () => {
const form = schemaToForm(
{
properties: {
tags: {
anyOf: [
{ type: "array", items: { enum: ["a", "b"] } },
{ type: "null" },
],
},
},
},
"nullableArrayEnum",
);
expect(form.sections[0]!.fields[0]).toMatchObject({
type: "select",
options: [
{ label: "a", value: "a" },
{ label: "b", value: "b" },
],
});
});
// With the null left in the list, the all-strings check would reject the
// enum and this would degrade to a plain text field.
it("renders a select for a type: [string, null] enum, null stripped", () => {
const form = schemaToForm(
{
properties: {
direction: {
type: ["string", "null"],
enum: ["envio", "recebimento", null],
},
},
},
"typeArrayNullableEnum",
);
expect(form.sections[0]!.fields[0]).toMatchObject({
type: "select",
options: [
{ label: "envio", value: "envio" },
{ label: "recebimento", value: "recebimento" },
],
});
});
it("collapses the type: [T, null] encoding too", () => {
const form = schemaToForm(
{
properties: {
note: { type: ["string", "null"] },
flag: { type: ["boolean", "null"] },
},
},
"typeArrayNull",
);
expect(
form.sections[0]!.fields.map((f) => ({ name: f.name, type: f.type })),
).toEqual([
{ name: "note", type: "string" },
{ name: "flag", type: "boolean" },
]);
});
// ink-form hands a select's stringified value straight back, so a numeric
// enum routed to a select would submit "1" for 1. The typed field loses the
// enum constraint but keeps the value's type — the safer loss.
it("routes a nullable numeric enum to its typed field, not a select", () => {
const form = schemaToForm(
{
properties: {
level: {
anyOf: [{ type: "integer", enum: [1, 2] }, { type: "null" }],
},
ratio: {
anyOf: [{ type: "number", enum: [0.5, 1.5] }, { type: "null" }],
},
},
},
"numericEnum",
);
expect(
form.sections[0]!.fields.map((f) => ({ name: f.name, type: f.type })),
).toEqual([
{ name: "level", type: "integer" },
{ name: "ratio", type: "float" },
]);
});
it("routes a plain numeric enum to its typed field too", () => {
const form = schemaToForm(
{ properties: { level: { type: "integer", enum: [1, 2] } } },
"plainNumericEnum",
);
expect(form.sections[0]!.fields[0]).toMatchObject({ type: "integer" });
});
it("leaves a union of two real types as a plain string field", () => {
const form = schemaToForm(
{
properties: {
mixed: { anyOf: [{ type: "string" }, { type: "number" }] },
},
},
"realUnion",
);
expect(form.sections[0]!.fields[0]).toMatchObject({
name: "mixed",
type: "string",
});
});
// #1005: the unportable-schemas showcase advertises a remote `$ref` so the
// lint has a `remote-ref` finding to report. A `$ref`-only property falls
// through to a string field here, which the tool's real (numeric) handler
// then rejects — so the fixture keeps a local `type` alongside the ref and
// the tool stays runnable. This pins the half of that contract the TUI
// owns; the wire half is in `raw-tool-schemas.test.ts`.
it("types a property that carries both a remote $ref and a local type", () => {
const form = schemaToForm(
{
properties: {
a: {
type: "number",
$ref: "https://example.com/schemas/number.json",
},
},
},
"refWithLocalType",
);
// `float` is this form builder's numeric field kind — the point is that
// it is NOT the string fallback a `$ref`-only property would get.
expect(form.sections[0]!.fields[0]).toMatchObject({
name: "a",
type: "float",
});
});
});
describe("root composition (#2123)", () => {
const UNION = {
type: "object",
properties: { note: { type: "string" } },
discriminator: { propertyName: "kind" },
oneOf: [
{
type: "object",
properties: {
kind: { type: "string", const: "email" },
address: { type: "string" },
},
required: ["kind", "address"],
},
{
type: "object",
properties: {
kind: { type: "string", const: "sms" },
count: { type: "integer" },
},
required: ["kind", "count"],
},
],
};
it("gives each branch its own section instead of rendering no fields", () => {
const form = schemaToForm(UNION, "union_tool");
expect(form.sections.map((section) => section.title)).toEqual([
"Parameters",
"email",
"sms",
]);
expect(form.sections[0]!.fields.map((field) => field.name)).toEqual([
"__variant",
"note",
]);
});
it("names branch fields uniquely, since ink-form scopes by name alone", () => {
// Both branches declare `kind`. Rendered under their real names they
// would be one field, and the later section's initial value would decide
// what the earlier section submits.
const form = schemaToForm(UNION, "union_tool");
expect(form.sections[1]!.fields.map((field) => field.name)).toEqual([
"__b0__kind",
"__b0__address",
]);
expect(form.sections[2]!.fields.map((field) => field.name)).toEqual([
"__b1__kind",
"__b1__count",
]);
});
it("labels a branch field by the name the schema declared", () => {
// The prefix is an internal field name; `buildFields` falls back to its
// map key for a label, so the user would otherwise see `__b0__address`.
const form = schemaToForm(UNION, "union_tool");
expect(form.sections[1]!.fields[1]).toMatchObject({
name: "__b0__address",
label: "address",
});
});
it("keeps a declared title ahead of the fallback", () => {
const form = schemaToForm(
{
type: "object",
anyOf: [
{
type: "object",
properties: { a: { type: "string", title: "Street address" } },
},
{ type: "object", properties: { b: { type: "string" } } },
],
},
"titled",
);
expect(form.sections[1]!.fields[0]).toMatchObject({
label: "Street address",
});
});
it("offers a variant select listing the alternatives", () => {
const form = schemaToForm(UNION, "union_tool");
expect(form.sections[0]!.fields[0]).toMatchObject({
name: "__variant",
type: "select",
initialValue: "0",
options: [
{ label: "email", value: "0" },
{ label: "sms", value: "1" },
],
});
});
it("keeps a branch's typed fields typed", () => {
const form = schemaToForm(UNION, "union_tool");
expect(form.sections[2]!.fields[1]).toMatchObject({
name: "__b1__count",
type: "integer",
});
});
it("renders branch fields optional, since only one branch applies", () => {
const form = schemaToForm(UNION, "union_tool");
for (const field of form.sections[1]!.fields) {
expect(field.required).toBe(false);
}
});
it("renders a const as a one-option select so it cannot be changed", () => {
const form = schemaToForm(UNION, "union_tool");
expect(form.sections[1]!.fields[0]).toMatchObject({
name: "__b0__kind",
type: "select",
initialValue: "email",
options: [{ label: "email", value: "email" }],
});
});
it("never marks a const control required", () => {
// Its one option may be the empty string, which ink-form's required gate
// can never accept — the call would not even reach `decodeFormValues`.
const form = schemaToForm(
{
type: "object",
properties: { kind: { type: "string", const: "" } },
required: ["kind"],
},
"empty_const",
);
expect(form.sections[0]!.fields[0]).toMatchObject({
name: "kind",
type: "select",
required: false,
});
});
it("renders a const outside a union the same way", () => {
const form = schemaToForm(
{
type: "object",
properties: { v: { type: "string", const: "a", default: "b" } },
required: ["v"],
},
"const_tool",
);
expect(form.sections[0]!.fields[0]).toMatchObject({
type: "select",
initialValue: "a",
options: [{ label: "a", value: "a" }],
});
});
it("leaves an optional const unfilled", () => {
// `const` constrains a present value; it does not require the property
// or act as a default, so an optional one must stay omittable.
const form = schemaToForm(
{
type: "object",
properties: { dryRun: { type: "boolean", const: true } },
},
"optional_const",
);
const field = form.sections[0]!.fields[0] as {
initialValue?: unknown;
options?: unknown[];
};
expect(field.initialValue).toBeUndefined();
// The single option is still offered to a user who wants it.
expect(field.options).toEqual([{ label: "true", value: "true" }]);
expect(decodeFormValues({ type: "object" }, {})).toEqual({});
});
it("renders a branch's specialization of a root property in its section", () => {
const form = schemaToForm(
{
type: "object",
properties: { count: {} },
anyOf: [
{ type: "object", properties: { count: { type: "integer" } } },
{ type: "object", properties: { other: { type: "string" } } },
],
},
"specializing",
);
// The untyped base declaration is not rendered a second time as a string.
expect(form.sections[0]!.fields.map((field) => field.name)).toEqual([
"__variant",
]);
expect(form.sections[1]!.fields[0]).toMatchObject({
name: "__b0__count",
type: "integer",
});
});
it("offers a shared base property in every branch's section", () => {
const schema = {
type: "object",
properties: { count: {} },
required: ["count"],
anyOf: [
{ type: "object", properties: { count: { type: "integer" } } },
{ type: "object", properties: { other: { type: "string" } } },
],
};
const form = schemaToForm(schema, "shared");
// Rendered only in branch A's section, branch B could never supply the
// required root argument — the chosen branch's fields are what decode.
expect(form.sections[2]!.fields.map((field) => field.name)).toEqual([
"__b1__other",
"__b1__count",
]);
expect(
decodeFormValues(schema, {
__variant: "1",
__b1__other: "x",
__b1__count: "4",
}),
).toEqual({ other: "x", count: "4" });
});
it("keeps its generated names clear of the schema's own", () => {
const form = schemaToForm(
{
type: "object",
properties: { __variant: { type: "string" } },
anyOf: [
{ type: "object", properties: { __b0__x: { type: "string" } } },
{ type: "object", properties: { y: { type: "string" } } },
],
},
"colliding",
);
const names = form.sections.flatMap((section) =>
section.fields.map((field) => field.name),
);
// The select steps aside for the declared `__variant`, and the branch
// prefix steps aside for the declared `__b0__x`.
expect(names).toContain("__variant_");
expect(names).toContain("__variant");
expect(names).toContain("__b_0____b0__x");
});
// #2224: `required` lists names, not declarations, so a member may require
// one it never declares. `buildFields` enumerates `properties` alone, so no
// control was rendered for it while `missingRequiredFields` reported it
// missing at every submit — a section the user could never complete.
it("declines a member requiring a name it never declares", () => {
const schema = {
type: "object",
anyOf: [
{
type: "object",
properties: { kind: { type: "string", const: "a" } },
required: ["kind", "payload"],
},
{
type: "object",
properties: { kind: { type: "string", const: "b" } },
required: ["kind"],
},
],
};
const form = schemaToForm(schema, "undeclared_required");
// No variant select and no per-branch section: the union is declined, so
// the root's own (here empty) properties are what render.
expect(form.sections).toEqual([{ title: "Parameters", fields: [] }]);
// And nothing is reported missing, so the call is no longer blocked on a
// field that has nowhere to be typed.
expect(
missingRequiredFields(schema, decodeFormValues(schema, {})),
).toEqual([]);
});
it("keeps offering a member requiring a name the root declares", () => {
// The regression guard for the check above: the merge is what is judged,
// and `anyOf: [{ required: ["email"] }, …]` is an ordinary union.
const form = schemaToForm(
{
type: "object",
properties: {
email: { type: "string" },
phone: { type: "string" },
},
anyOf: [
{ type: "object", required: ["email"] },
{ type: "object", required: ["phone"] },
],
},
"inherited_required",
);
expect(form.sections).toHaveLength(3);
expect(form.sections[0]!.fields[0]).toMatchObject({ name: "__variant" });
});
describe("decodeFormValues", () => {
it("submits the chosen branch's fields under their real names", () => {
expect(
decodeFormValues(UNION, {
__variant: "0",
note: "hi",
__b0__kind: "email",
__b0__address: "a@b.c",
__b1__kind: "sms",
__b1__count: 3,
}),
).toEqual({ note: "hi", kind: "email", address: "a@b.c" });
});
it("drops the branches the call is not making", () => {
expect(
decodeFormValues(UNION, {
__variant: "1",
__b0__kind: "email",
__b0__address: "a@b.c",
__b1__kind: "sms",
__b1__count: 3,
}),
).toEqual({ kind: "sms", count: 3 });
});
it("omits a branch field the user never filled", () => {
expect(
decodeFormValues(UNION, {
__variant: "0",
__b0__kind: "email",
}),
).toEqual({ kind: "email" });
});
it("falls back to the first branch on an unusable selection", () => {
expect(
decodeFormValues(UNION, {
__variant: "nonsense",
__b0__kind: "email",
}),
).toEqual({ kind: "email" });
});
it("returns the values untouched for a schema with no root union", () => {
const values = { message: "hi" };
expect(decodeFormValues({ properties: { message: {} } }, values)).toBe(
values,
);
});
it("restores a const from the schema rather than trusting the form", () => {
// ink-form has no immutable field, and a select hands back a string —
// so the pinned value is re-applied on the way out, with its own type.
expect(
decodeFormValues(
{
type: "object",
anyOf: [
{
type: "object",
properties: { n: { const: 7 }, a: { type: "string" } },
},
{ type: "object", properties: { n: { const: 8 } } },
],
},
{ __variant: "0", __b0__n: "tampered", __b0__a: "x" },
),
).toEqual({ n: 7, a: "x" });
});
it("keeps a decoded argument named __proto__", () => {
// Assigning it would invoke the legacy prototype setter, so a field
// prefixed safely in the form would vanish on the way to the call.
const schema = {
type: "object",
anyOf: [
{
type: "object",
properties: { ["__proto__"]: { type: "string" } },
},
{ type: "object", properties: { other: { type: "string" } } },
] as unknown[],
};
const decoded = decodeFormValues(schema, {
__variant: "0",
__b0____proto__: "kept",
});
expect(Object.hasOwn(decoded, "__proto__")).toBe(true);
});
it("keeps a base argument whose name looks generated", () => {
const schema = {
type: "object",
properties: { __b0__x: { type: "string" } },
anyOf: [
{ type: "object", properties: { a: { type: "string" } } },
{ type: "object", properties: { b: { type: "string" } } },
],
};
expect(
decodeFormValues(schema, {
__variant: "0",
__b0__x: "mine",
__b_0__a: "chosen",
}),
).toEqual({ __b0__x: "mine", a: "chosen" });
});
});
it("merges a root allOf into the parameters section", () => {
const form = schemaToForm(
{
type: "object",
properties: { a: { type: "string" } },
allOf: [{ type: "object", properties: { b: { type: "boolean" } } }],
},
"allof_tool",
);
expect(form.sections).toHaveLength(1);
expect(form.sections[0]!.fields.map((field) => field.name)).toEqual([
"a",
"b",
]);
});
it("still renders an empty form for a schema with no properties", () => {
const form = schemaToForm({ type: "object" }, "empty_tool");
expect(form.sections).toEqual([{ title: "Parameters", fields: [] }]);
});
});
describe("edge shapes (#2123)", () => {
it("renders a union that declares no root properties", () => {
const form = schemaToForm(
{
type: "object",
anyOf: [
{ type: "object", properties: { a: { type: "string" } } },
{ type: "object", properties: { b: { type: "string" } } },
],
},
"no_base",
);
expect(form.sections[0]!.fields.map((f) => f.name)).toEqual([
"__variant",
]);
expect(form.sections[1]!.fields.map((f) => f.name)).toEqual(["__b0__a"]);
});
it("returns the values unchanged for a schema with no properties", () => {
const values = { anything: "x" };
expect(decodeFormValues({ type: "object" }, values)).toBe(values);
});
it("declines a union carrying a malformed property declaration", () => {
// `properties` values are `unknown`; a `null` entry is not a schema, so
// the union is declined — and, the point of the test, nothing throws on
// the way out of the form any more than on the way in.
const schema = {
type: "object",
anyOf: [
{
type: "object",
properties: { broken: null, kind: { const: "a" } },
},
{ type: "object", properties: { kind: { const: "b" } } },
] as unknown[],
};
// No union, so the form values pass through as they are.
expect(
decodeFormValues(schema, { __variant: "0", __b0__kind: "tampered" }),
).toEqual({ __variant: "0", __b0__kind: "tampered" });
});
});
describe("missingRequiredFields (#2123)", () => {
const REQUIRED_UNION = {
type: "object",
oneOf: [
{
type: "object",
properties: {
kind: { type: "string", const: "email" },
address: { type: "string" },
},
required: ["kind", "address"],
},
{
type: "object",
properties: {
kind: { type: "string", const: "sms" },
phone: { type: "string" },
},
required: ["kind", "phone"],
},
],
};
it("reports what the chosen branch requires and the values omit", () => {
// A branch's fields render optional — a static form cannot demand every
// branch's — so the requirement is checked against the chosen shape here
// rather than sending a call known to violate the schema.
expect(
missingRequiredFields(
REQUIRED_UNION,
{ kind: "email" },
{ __variant: "0" },
),
).toEqual(["address"]);
});
it("reports nothing once the chosen branch is satisfied", () => {
expect(
missingRequiredFields(
REQUIRED_UNION,
{ kind: "sms", phone: "555" },
{ __variant: "1" },
),
).toEqual([]);
});
it("does not mistake an inherited property for a supplied argument", () => {
// `constructor` is a legal argument name; reading it off the prototype
// would report it as present and send the call without it.
const schema = {
type: "object",
properties: { constructor: { type: "string" } },
required: ["constructor"],
};
expect(missingRequiredFields(schema, {})).toEqual(["constructor"]);
});
it("accepts null only where the schema admits it", () => {
// Branch fields render optional, so a `default: null` on a non-nullable
// required field reaches this check — and `type: "string"` rejects it.
const strict = {
type: "object",
properties: { a: { type: "string" } },
required: ["a"],
};
expect(missingRequiredFields(strict, { a: null })).toEqual(["a"]);
const nullable = {
type: "object",
properties: { a: { type: ["string", "null"] } },
required: ["a"],
};
expect(missingRequiredFields(nullable, { a: null })).toEqual([]);
});
it("accepts an empty string a const pins the field to", () => {
// The one-option control cannot produce anything else, so reporting the
// seeded value as missing would make the branch permanently uncallable.
const pinnedEmpty = {
type: "object",
properties: { kind: { type: "string", const: "" } },
required: ["kind"],
};
expect(missingRequiredFields(pinnedEmpty, { kind: "" })).toEqual([]);
// An ordinary required string is still missing when left blank.
const ordinary = {
type: "object",
properties: { kind: { type: "string" } },
required: ["kind"],
};
expect(missingRequiredFields(ordinary, { kind: "" })).toEqual(["kind"]);
});
it("checks the root's own required fields when there is no union", () => {
const schema = {
type: "object",
properties: { message: { type: "string" } },
required: ["message"],
};
expect(missingRequiredFields(schema, {})).toEqual(["message"]);
expect(missingRequiredFields(schema, { message: "hi" })).toEqual([]);
});
});
});