inspector/clients/tui/__tests__/schemaToForm.test.ts
cliffhall 2cb23a8b21 fix: map the new exit code, keep add runnable, distinguish severity by shape
Copilot review round 4.

- Add `SCHEMA_UNPORTABLE` to `codeForExit`. `CliExitCodeError` lets a
  caller omit the envelope, so without the case an envelope-less throw
  reported the generic `error` — the same silent mislabelling the rename
  was meant to prevent, one layer down. Covered via the envelope-less
  construction that exposed it.

- Keep a local `type` (and the preset's `required`) alongside the remote
  `$ref` in the showcase config. The TUI's `schemaToForm` dispatches on
  `type`, so a `$ref`-only property fell through to a string field that
  `add`'s numeric handler then rejected — making the README's "every
  showcase tool is runnable" claim false there. The `remote-ref` finding
  is unaffected, since that rule reads `$ref` independently.

- Restrict `type-union` to well-formed unions: non-empty, unique, and
  all recognized JSON Schema type names. Its message asserts the
  construct is *legal* JSON Schema, which is false for `[]`, `[3]` or
  `["bananas"]`, and the `anyOf` it suggested for those would be invalid
  too. Malformed schemas stay the SDK parser's business.

- Drop `if`/`then`/`else` from the presence-only allowlist. They only
  constrain as a pair, so `{"if": {"const": 1}}` accepts every value and
  was evading `untyped-schema`; the pairing is now checked explicitly.

- Distinguish the web sidebar severities by SHAPE, not just colour — a
  circle for an error, a triangle for a warning — and name the severity
  in the tooltip. Colour alone left a colour-blind reader unable to tell
  them apart.

- Fix the stale "across 2 tools" count in the CLI README's hint sample.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 02:37:10 -04:00

411 lines
13 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { 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",
});
});
});
});