Merge branch 'v2/main' into v2/fix/1942-tui-coverage-flake
This commit is contained in:
commit
2f0aa24726
2 changed files with 10868 additions and 1605 deletions
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue