Merge branch 'v2/main' into v2/fix/1942-tui-coverage-flake

This commit is contained in:
Cliff Hall 2026-08-16 17:40:11 -04:00 • committed by GitHub
commit 2f0aa24726
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 10868 additions and 1605 deletions

View file

@ -213,4 +213,173 @@ describe("schemaToForm", () => {
);
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",
});
});
});
});

View file

@ -3,10 +3,19 @@
*/
import type { FormStructure, FormSection, FormField } from "ink-form";
import {
isStringEnum,
normalizeNullableUnion,
} from "@inspector/core/json/nullableUnion.js";
/** Minimal JSON Schema property shape used when building tool parameter forms */
interface JsonSchemaProperty {
type?: string;
/**
* An array here is the `["string", "null"]` nullable encoding, which
* {@link normalizeNullableUnion} collapses to a single name before the
* dispatch below reads it.
*/
type?: string | string[];
title?: string;
enum?: unknown[];
/** Non-standard legacy support: titles for enum values */
@ -15,6 +24,8 @@ interface JsonSchemaProperty {
minimum?: number;
maximum?: number;
default?: unknown;
/** Present on a nullable union; see {@link normalizeNullableUnion}. */
anyOf?: readonly unknown[];
}
/**
@ -68,9 +79,16 @@ export function schemaToForm(
// `properties` values are `unknown` (the SDK schema admits anything), so
// guard before treating a value as a schema object — a malformed server
// schema with e.g. `properties: { foo: null }` must not throw on `.title`.
const property = (
typeof prop === "object" && prop !== null ? prop : {}
) as JsonSchemaProperty;
// Flatten a nullable union (`anyOf: [X, {type:"null"}]`, `type: [X,"null"]`)
// before dispatching. Every branch below reads a single `type` string, and
// for an enum the `enum` keyword sits on the union's surviving branch — so
// without this an argument declared with Zod's `.nullish()` loses its
// select and degrades to a plain text field (#2015, the TUI twin of #1928).
const property = normalizeNullableUnion(
(typeof prop === "object" && prop !== null
? prop
: {}) as JsonSchemaProperty,
);
const baseField = {
name: key,
label: property.title || key,
@ -90,12 +108,17 @@ export function schemaToForm(
...baseField,
options: toSelectOptions(property.items.enum, property.items.enumNames),
} as FormField;
} else if (property.enum) {
// Single select
} else if (isStringEnum(property.enum)) {
// Single select. Gated on the members being strings because
// `toSelectOptions` stringifies them and ink-form hands the string
// straight back: a numeric `enum: [1, 2]` would submit `"1"` and violate
// the schema. A typed non-string enum falls through to its typed field
// below, which loses the enum constraint but keeps the value's type —
// the safer of the two losses.
field = {
type: "select",
...baseField,
options: toSelectOptions(property.enum, property.enumNames),
options: toSelectOptions(property.enum ?? [], property.enumNames),
} as FormField;
} else {
// Map JSON Schema types to ink-form types