import { describe, expect, expectTypeOf, it } from "vitest"; import { getSchema, j, type Infer } from "./schema-builder.js"; import { events } from "./events.generated.js"; import { compileWorkflowInputSchema } from "./workflow-schema.js"; import type { WorkflowEventForTrigger } from "./workflow.js"; const releaseSchema = j.object({ releases: j.array(j.object({ version: j.string(), dryRun: j.boolean().nullable() })), }); describe("manual input schema builders", () => { it("infers nested nullable fields without optional keys or readonly output", () => { const trigger = events.manual({ inputSchema: releaseSchema }); type Input = WorkflowEventForTrigger["input"]; expectTypeOf().toEqualTypeOf<{ releases: { version: string; dryRun: boolean | null }[]; }>(); expectTypeOf().toEqualTypeOf>(); }); it("serializes a builder as standard JSON Schema with all keys required", () => { const trigger = events.manual({ inputSchema: releaseSchema }); expect(JSON.parse(JSON.stringify(trigger))).toEqual({ type: "workflow.manual", inputJsonSchema: { type: "object", required: ["releases"], additionalProperties: false, properties: { releases: { type: "array", items: { type: "object", required: ["version", "dryRun"], additionalProperties: false, properties: { version: { type: "string" }, dryRun: { anyOf: [{ type: "boolean" }, { type: "null" }] }, }, }, }, }, }, }); }); it("accepts explicit null and rejects missing, invalid, and extra fields without coercion", () => { const { validate } = compileWorkflowInputSchema(releaseSchema); for (const dryRun of [true, false, null]) { expect(() => validate({ releases: [{ version: "1", dryRun }] })).not.toThrow(); } for (const input of [ {}, { releases: [{}] }, { releases: [{ version: "1" }] }, { releases: [{ version: "1", dryRun: "false" }] }, { releases: null }, { releases: [], extra: true }, ]) { const original = structuredClone(input); expect(() => validate(input)).toThrow("Invalid manual workflow input"); expect(input).toEqual(original); } }); it("supports primitive, enum, nullable object, and nullable array inference and validation", () => { const inputSchema = j.object({ count: j.integer(), ratio: j.number(), stage: j.enum("draft", "live"), config: j.object({ label: j.string() }).nullable(), tags: j.array(j.string(), { minItems: 1 }).nullable(), }); const trigger = events.manual({ inputSchema }); type Input = WorkflowEventForTrigger["input"]; expectTypeOf().toEqualTypeOf<{ count: number; ratio: number; stage: "draft" | "live"; config: { label: string } | null; tags: string[] | null; }>(); const { validate } = compileWorkflowInputSchema(inputSchema); const input = { count: 1, ratio: 0.5, stage: "draft", config: null, tags: null }; expect(() => validate(input)).not.toThrow(); for (const change of [{ count: 0.5 }, { stage: "unknown" }, { config: {} }, { tags: [] }]) { expect(() => validate({ ...input, ...change })).toThrow(); } }); it("keeps metadata outside nullable unions and builders immutable", () => { const name = j.string().label("Name").describe("A name"); const nullable = name.nullable(); expect(getSchema(nullable)).toEqual({ title: "Name", description: "A name", anyOf: [{ type: "string" }, { type: "null" }], }); const copy = getSchema(name); copy.description = "Changed"; expect(getSchema(name)).toEqual({ type: "string", title: "Name", description: "A name" }); expect(getSchema(j.object({ z: name, a: name }).describe("Root"))).toMatchObject({ required: ["z", "a"], description: "Root", }); }); it("rejects plain JSON schemas and non-object roots from untyped callers", () => { for (const inputSchema of [ JSON.parse('{"type":"object","properties":{}}'), JSON.parse("null"), JSON.parse('"string"'), ]) { expect(() => events.manual({ inputSchema })).toThrow(); } }); }); describe("formatted manual inputs", () => { it.each([ { builder: j.datetime, format: "date-time", valid: "2026-09-24T10:30:00Z", invalid: "2026-09-24", }, { builder: j.date, format: "date", valid: "2024-02-29", invalid: "2025-02-29" }, { builder: j.time, format: "time", valid: "10:30:00Z", invalid: "25:00:00Z" }, { builder: j.duration, format: "duration", valid: "P1DT2H", invalid: "tomorrow" }, { builder: j.email, format: "email", valid: "user@example.com", invalid: "not-an-email" }, { builder: j.hostname, format: "hostname", valid: "example.com", invalid: "bad host" }, { builder: j.ipv4, format: "ipv4", valid: "192.0.2.1", invalid: "256.0.0.1" }, { builder: j.ipv6, format: "ipv6", valid: "2001:db8::1", invalid: "2001:db8::xyz" }, { builder: j.uuid, format: "uuid", valid: "74877cbb-93f9-41fb-9143-c1a11bf88f6b", invalid: "not-a-uuid", }, ])( "serializes and validates $format, including nested nullable inputs", ({ builder, format, valid, invalid }) => { const field = builder().label("Input").describe("Help").nullable(); expectTypeOf>().toEqualTypeOf(); expect(getSchema(field)).toEqual({ title: "Input", description: "Help", anyOf: [{ type: "string", format }, { type: "null" }], }); const schema = j.object({ values: j.array(j.object({ value: field })) }); const { validate } = compileWorkflowInputSchema(schema); for (const value of [valid, null]) expect(() => validate({ values: [{ value }] })).not.toThrow(); expect(() => validate({ values: [{ value: invalid }] })).toThrow( "Invalid manual workflow input", ); expect(() => validate({ values: [{}] })).toThrow(); }, ); });