schema.test.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890
  1. import { describe, it, expect } from "vitest";
  2. import { z } from "zod";
  3. import { defineSchema, defineCatalog } from "./schema";
  4. // =============================================================================
  5. // Shared test schema (mirrors the React schema shape)
  6. // =============================================================================
  7. const testSchema = defineSchema((s) => ({
  8. spec: s.object({
  9. root: s.string(),
  10. elements: s.record(
  11. s.object({
  12. type: s.ref("catalog.components"),
  13. props: s.propsOf("catalog.components"),
  14. children: s.array(s.string()),
  15. visible: s.any(),
  16. }),
  17. ),
  18. }),
  19. catalog: s.object({
  20. components: s.map({
  21. props: s.zod(),
  22. slots: s.array(s.string()),
  23. description: s.string(),
  24. example: s.any(),
  25. }),
  26. actions: s.map({
  27. description: s.string(),
  28. }),
  29. }),
  30. }));
  31. // =============================================================================
  32. // defineSchema
  33. // =============================================================================
  34. describe("defineSchema", () => {
  35. it("creates a schema with spec and catalog definition", () => {
  36. const schema = defineSchema((s) => ({
  37. spec: s.object({ root: s.string() }),
  38. catalog: s.object({ components: s.map({ props: s.zod() }) }),
  39. }));
  40. expect(schema.definition).toBeDefined();
  41. expect(schema.definition.spec.kind).toBe("object");
  42. expect(schema.definition.catalog.kind).toBe("object");
  43. });
  44. it("accepts promptTemplate option", () => {
  45. const template = () => "custom prompt";
  46. const schema = defineSchema(
  47. (s) => ({
  48. spec: s.object({ root: s.string() }),
  49. catalog: s.object({ components: s.map({ props: s.zod() }) }),
  50. }),
  51. { promptTemplate: template },
  52. );
  53. expect(schema.promptTemplate).toBe(template);
  54. });
  55. it("accepts defaultRules option", () => {
  56. const schema = defineSchema(
  57. (s) => ({
  58. spec: s.object({ root: s.string() }),
  59. catalog: s.object({ components: s.map({ props: s.zod() }) }),
  60. }),
  61. { defaultRules: ["Rule A", "Rule B"] },
  62. );
  63. expect(schema.defaultRules).toEqual(["Rule A", "Rule B"]);
  64. });
  65. it("exposes createCatalog method", () => {
  66. const schema = defineSchema((s) => ({
  67. spec: s.object({ root: s.string() }),
  68. catalog: s.object({ components: s.map({ props: s.zod() }) }),
  69. }));
  70. expect(typeof schema.createCatalog).toBe("function");
  71. });
  72. });
  73. // =============================================================================
  74. // defineCatalog / createCatalog
  75. // =============================================================================
  76. describe("defineCatalog", () => {
  77. it("creates catalog with componentNames and actionNames", () => {
  78. const catalog = defineCatalog(testSchema, {
  79. components: {
  80. Text: {
  81. props: z.object({ content: z.string() }),
  82. description: "Display text",
  83. slots: [],
  84. },
  85. Button: {
  86. props: z.object({ label: z.string() }),
  87. description: "A clickable button",
  88. slots: [],
  89. },
  90. },
  91. actions: {
  92. navigate: { description: "Navigate to URL" },
  93. submit: { description: "Submit form" },
  94. },
  95. });
  96. expect(catalog.componentNames).toEqual(["Text", "Button"]);
  97. expect(catalog.actionNames).toEqual(["navigate", "submit"]);
  98. });
  99. it("handles empty components and actions", () => {
  100. const catalog = defineCatalog(testSchema, {
  101. components: {},
  102. actions: {},
  103. });
  104. expect(catalog.componentNames).toEqual([]);
  105. expect(catalog.actionNames).toEqual([]);
  106. });
  107. it("is equivalent to schema.createCatalog", () => {
  108. const catalogData = {
  109. components: {
  110. Card: {
  111. props: z.object({ title: z.string() }),
  112. description: "A card",
  113. slots: ["default"],
  114. },
  115. },
  116. actions: {},
  117. };
  118. const a = defineCatalog(testSchema, catalogData);
  119. const b = testSchema.createCatalog(catalogData);
  120. expect(a.componentNames).toEqual(b.componentNames);
  121. expect(a.actionNames).toEqual(b.actionNames);
  122. expect(a.data).toBe(b.data);
  123. });
  124. it("exposes the schema on the catalog", () => {
  125. const catalog = defineCatalog(testSchema, {
  126. components: {},
  127. actions: {},
  128. });
  129. expect(catalog.schema).toBe(testSchema);
  130. });
  131. it("exposes catalog data", () => {
  132. const data = {
  133. components: {
  134. Text: {
  135. props: z.object({ content: z.string() }),
  136. description: "",
  137. slots: [],
  138. },
  139. },
  140. actions: {},
  141. };
  142. const catalog = defineCatalog(testSchema, data);
  143. expect(catalog.data).toBe(data);
  144. });
  145. });
  146. // =============================================================================
  147. // catalog.prompt()
  148. // =============================================================================
  149. describe("catalog.prompt", () => {
  150. it("includes AVAILABLE COMPONENTS section", () => {
  151. const catalog = defineCatalog(testSchema, {
  152. components: {
  153. Card: {
  154. props: z.object({
  155. title: z.string(),
  156. names: z.array(z.string()),
  157. users: z.array(z.object({ name: z.string(), age: z.number() })),
  158. }),
  159. description: "A card container",
  160. slots: ["default"],
  161. },
  162. },
  163. actions: {},
  164. });
  165. const prompt = catalog.prompt();
  166. expect(prompt).toContain("AVAILABLE COMPONENTS");
  167. expect(prompt).toContain("Card");
  168. expect(prompt).toContain("A card container");
  169. expect(prompt).toContain("title: string");
  170. expect(prompt).toContain("names: Array<string>");
  171. expect(prompt).toContain("users: Array<{ name: string, age: number }>");
  172. });
  173. it("formats z.literal() as quoted value", () => {
  174. const catalog = defineCatalog(testSchema, {
  175. components: {
  176. Config: {
  177. props: z.object({
  178. version: z.literal("3.0"),
  179. count: z.literal(42),
  180. }),
  181. description: "",
  182. slots: [],
  183. },
  184. },
  185. actions: {},
  186. });
  187. const prompt = catalog.prompt();
  188. expect(prompt).toContain('version: "3.0"');
  189. expect(prompt).toContain("count: 42");
  190. });
  191. it("formats z.default() by unwrapping to inner type", () => {
  192. const catalog = defineCatalog(testSchema, {
  193. components: {
  194. Form: {
  195. props: z.object({
  196. enabled: z.boolean().default(false),
  197. count: z.number().default(0),
  198. tags: z.array(z.string()).default([]),
  199. }),
  200. description: "",
  201. slots: [],
  202. },
  203. },
  204. actions: {},
  205. });
  206. const prompt = catalog.prompt();
  207. expect(prompt).toContain("enabled: boolean");
  208. expect(prompt).toContain("count: number");
  209. expect(prompt).toContain("tags: Array<string>");
  210. });
  211. it("formats z.record() as Record<K, V>", () => {
  212. const catalog = defineCatalog(testSchema, {
  213. components: {
  214. Store: {
  215. props: z.object({
  216. simple: z.record(z.string(), z.number()),
  217. nested: z.record(
  218. z.string(),
  219. z.object({ id: z.string(), score: z.number() }),
  220. ),
  221. }),
  222. description: "",
  223. slots: [],
  224. },
  225. },
  226. actions: {},
  227. });
  228. const prompt = catalog.prompt();
  229. expect(prompt).toContain("simple: Record<string, number>");
  230. expect(prompt).toContain(
  231. "nested: Record<string, { id: string, score: number }>",
  232. );
  233. });
  234. it("includes AVAILABLE ACTIONS when present", () => {
  235. const catalog = defineCatalog(testSchema, {
  236. components: {
  237. Text: {
  238. props: z.object({ content: z.string() }),
  239. description: "",
  240. slots: [],
  241. },
  242. },
  243. actions: {
  244. navigate: { description: "Navigate to URL" },
  245. },
  246. });
  247. const prompt = catalog.prompt();
  248. expect(prompt).toContain("AVAILABLE ACTIONS");
  249. expect(prompt).toContain("navigate");
  250. expect(prompt).toContain("Navigate to URL");
  251. });
  252. it("omits AVAILABLE ACTIONS when there are none", () => {
  253. const catalog = defineCatalog(testSchema, {
  254. components: {
  255. Text: {
  256. props: z.object({ content: z.string() }),
  257. description: "",
  258. slots: [],
  259. },
  260. },
  261. actions: {},
  262. });
  263. const prompt = catalog.prompt();
  264. expect(prompt).not.toContain("AVAILABLE ACTIONS");
  265. });
  266. it("uses custom system message when provided", () => {
  267. const catalog = defineCatalog(testSchema, {
  268. components: {
  269. Text: {
  270. props: z.object({}),
  271. description: "",
  272. slots: [],
  273. },
  274. },
  275. actions: {},
  276. });
  277. const prompt = catalog.prompt({ system: "You are a dashboard builder." });
  278. expect(prompt).toContain("You are a dashboard builder.");
  279. expect(prompt).not.toContain("You are a UI generator that outputs JSON.");
  280. });
  281. it("appends customRules to prompt", () => {
  282. const catalog = defineCatalog(testSchema, {
  283. components: {
  284. Text: {
  285. props: z.object({}),
  286. description: "",
  287. slots: [],
  288. },
  289. },
  290. actions: {},
  291. });
  292. const prompt = catalog.prompt({
  293. customRules: ["Always use Card as root", "Keep UIs simple"],
  294. });
  295. expect(prompt).toContain("Always use Card as root");
  296. expect(prompt).toContain("Keep UIs simple");
  297. });
  298. it("generates inline mode prompt when mode is inline", () => {
  299. const catalog = defineCatalog(testSchema, {
  300. components: {
  301. Text: {
  302. props: z.object({}),
  303. description: "",
  304. slots: [],
  305. },
  306. },
  307. actions: {},
  308. });
  309. const prompt = catalog.prompt({ mode: "inline" });
  310. expect(prompt).toContain("```spec");
  311. expect(prompt).toContain("conversationally");
  312. expect(prompt).toContain("text + JSONL");
  313. });
  314. it("generates standalone mode prompt by default", () => {
  315. const catalog = defineCatalog(testSchema, {
  316. components: {
  317. Text: {
  318. props: z.object({}),
  319. description: "",
  320. slots: [],
  321. },
  322. },
  323. actions: {},
  324. });
  325. const prompt = catalog.prompt();
  326. expect(prompt).toContain("Output ONLY JSONL patches");
  327. expect(prompt).not.toContain("conversationally");
  328. });
  329. it("accepts deprecated 'chat' as alias for 'inline'", () => {
  330. const catalog = defineCatalog(testSchema, {
  331. components: {
  332. Text: {
  333. props: z.object({}),
  334. description: "",
  335. slots: [],
  336. },
  337. },
  338. actions: {},
  339. });
  340. const inlinePrompt = catalog.prompt({ mode: "inline" });
  341. const chatPrompt = catalog.prompt({ mode: "chat" });
  342. expect(chatPrompt).toEqual(inlinePrompt);
  343. });
  344. it("accepts deprecated 'generate' as alias for 'standalone'", () => {
  345. const catalog = defineCatalog(testSchema, {
  346. components: {
  347. Text: {
  348. props: z.object({}),
  349. description: "",
  350. slots: [],
  351. },
  352. },
  353. actions: {},
  354. });
  355. const standalonePrompt = catalog.prompt({ mode: "standalone" });
  356. const generatePrompt = catalog.prompt({ mode: "generate" });
  357. expect(generatePrompt).toEqual(standalonePrompt);
  358. });
  359. it("uses actual catalog component names in examples", () => {
  360. const catalog = defineCatalog(testSchema, {
  361. components: {
  362. MyBox: {
  363. props: z.object({ padding: z.number() }),
  364. description: "A box",
  365. slots: ["default"],
  366. },
  367. MyLabel: {
  368. props: z.object({ text: z.string() }),
  369. description: "A label",
  370. slots: [],
  371. },
  372. },
  373. actions: {},
  374. });
  375. const prompt = catalog.prompt();
  376. expect(prompt).toContain('"type":"MyBox"');
  377. expect(prompt).toContain('"type":"MyLabel"');
  378. });
  379. it("does not include hardcoded component names not in catalog", () => {
  380. const catalog = defineCatalog(testSchema, {
  381. components: {
  382. Text: {
  383. props: z.object({ content: z.string() }),
  384. description: "",
  385. slots: [],
  386. },
  387. },
  388. actions: {},
  389. });
  390. const prompt = catalog.prompt();
  391. const hardcoded = ["Stack", "Grid", "Heading", "Column", "Pressable"];
  392. for (const comp of hardcoded) {
  393. expect(prompt).not.toContain(`"type":"${comp}"`);
  394. }
  395. });
  396. it("generates example props from Zod schemas", () => {
  397. const catalog = defineCatalog(testSchema, {
  398. components: {
  399. Widget: {
  400. props: z.object({
  401. title: z.string(),
  402. count: z.number(),
  403. active: z.boolean(),
  404. variant: z.enum(["primary", "secondary"]),
  405. }),
  406. description: "",
  407. slots: [],
  408. },
  409. },
  410. actions: {},
  411. });
  412. const prompt = catalog.prompt();
  413. expect(prompt).toContain('"title":"example"');
  414. expect(prompt).toContain('"count":0');
  415. expect(prompt).toContain('"active":true');
  416. expect(prompt).toContain('"variant":"primary"');
  417. });
  418. it("uses explicit example over Zod-generated values", () => {
  419. const catalog = defineCatalog(testSchema, {
  420. components: {
  421. Heading: {
  422. props: z.object({
  423. text: z.string(),
  424. level: z.enum(["h1", "h2", "h3"]),
  425. }),
  426. description: "A heading",
  427. slots: [],
  428. example: { text: "Welcome", level: "h1" },
  429. },
  430. },
  431. actions: {},
  432. });
  433. const prompt = catalog.prompt();
  434. expect(prompt).toContain('"text":"Welcome"');
  435. expect(prompt).toContain('"level":"h1"');
  436. });
  437. it("uses custom promptTemplate when schema has one", () => {
  438. const customSchema = defineSchema(
  439. (s) => ({
  440. spec: s.object({ root: s.string() }),
  441. catalog: s.object({
  442. components: s.map({ props: s.zod(), description: s.string() }),
  443. }),
  444. }),
  445. {
  446. promptTemplate: (ctx) =>
  447. `Custom prompt with ${ctx.componentNames.length} components: ${ctx.componentNames.join(", ")}`,
  448. },
  449. );
  450. const catalog = customSchema.createCatalog({
  451. components: {
  452. Alpha: { props: z.object({}), description: "A" },
  453. Beta: { props: z.object({}), description: "B" },
  454. },
  455. });
  456. const prompt = catalog.prompt();
  457. expect(prompt).toBe("Custom prompt with 2 components: Alpha, Beta");
  458. });
  459. it("includes defaultRules from schema in the RULES section", () => {
  460. const schemaWithRules = defineSchema(
  461. (s) => ({
  462. spec: s.object({
  463. root: s.string(),
  464. elements: s.record(
  465. s.object({
  466. type: s.ref("catalog.components"),
  467. props: s.any(),
  468. children: s.array(s.string()),
  469. }),
  470. ),
  471. }),
  472. catalog: s.object({
  473. components: s.map({ props: s.zod(), description: s.string() }),
  474. }),
  475. }),
  476. { defaultRules: ["Schema default rule one", "Schema default rule two"] },
  477. );
  478. const catalog = schemaWithRules.createCatalog({
  479. components: {
  480. Text: { props: z.object({}), description: "" },
  481. },
  482. });
  483. const prompt = catalog.prompt();
  484. expect(prompt).toContain("Schema default rule one");
  485. expect(prompt).toContain("Schema default rule two");
  486. });
  487. it("contains sections for state, repeat, actions, visibility, and dynamic props", () => {
  488. const catalog = defineCatalog(testSchema, {
  489. components: {
  490. Text: {
  491. props: z.object({ content: z.string() }),
  492. description: "",
  493. slots: [],
  494. },
  495. },
  496. actions: {},
  497. });
  498. const prompt = catalog.prompt();
  499. expect(prompt).toContain("INITIAL STATE:");
  500. expect(prompt).toContain("DYNAMIC LISTS (repeat field):");
  501. expect(prompt).toContain("EVENTS (the `on` field):");
  502. expect(prompt).toContain("VISIBILITY CONDITIONS:");
  503. expect(prompt).toContain("DYNAMIC PROPS:");
  504. expect(prompt).toContain("RULES:");
  505. });
  506. });
  507. // =============================================================================
  508. // catalog.validate()
  509. // =============================================================================
  510. describe("catalog.validate", () => {
  511. const catalog = defineCatalog(testSchema, {
  512. components: {
  513. Text: {
  514. props: z.object({ content: z.string() }),
  515. description: "",
  516. slots: [],
  517. },
  518. Card: {
  519. props: z.object({ title: z.string() }),
  520. description: "",
  521. slots: ["default"],
  522. },
  523. },
  524. actions: {},
  525. });
  526. it("validates a valid spec", () => {
  527. const spec = {
  528. root: "card-1",
  529. elements: {
  530. "card-1": {
  531. type: "Card",
  532. props: { title: "Hello" },
  533. children: ["text-1"],
  534. },
  535. "text-1": {
  536. type: "Text",
  537. props: { content: "World" },
  538. children: [],
  539. },
  540. },
  541. };
  542. const result = catalog.validate(spec);
  543. expect(result.success).toBe(true);
  544. expect(result.data).toEqual(spec);
  545. });
  546. it("rejects spec with wrong root type", () => {
  547. const result = catalog.validate({ root: 123, elements: {} });
  548. expect(result.success).toBe(false);
  549. expect(result.error).toBeDefined();
  550. });
  551. it("rejects spec with missing root", () => {
  552. const result = catalog.validate({ elements: {} });
  553. expect(result.success).toBe(false);
  554. expect(result.error).toBeDefined();
  555. });
  556. it("rejects spec with invalid component type", () => {
  557. const result = catalog.validate({
  558. root: "x",
  559. elements: {
  560. x: { type: "NonExistent", props: {}, children: [] },
  561. },
  562. });
  563. expect(result.success).toBe(false);
  564. });
  565. it("returns data on success", () => {
  566. const spec = {
  567. root: "t",
  568. elements: {
  569. t: { type: "Text", props: { content: "hi" }, children: [] },
  570. },
  571. };
  572. const result = catalog.validate(spec);
  573. expect(result.success).toBe(true);
  574. expect(result.data).toBeDefined();
  575. expect(result.data!.root).toBe("t");
  576. });
  577. });
  578. // =============================================================================
  579. // catalog.jsonSchema()
  580. // =============================================================================
  581. describe("catalog.jsonSchema", () => {
  582. it("returns a JSON Schema object", () => {
  583. const catalog = defineCatalog(testSchema, {
  584. components: {
  585. Text: {
  586. props: z.object({ content: z.string() }),
  587. description: "",
  588. slots: [],
  589. },
  590. },
  591. actions: {},
  592. });
  593. const jsonSchema = catalog.jsonSchema();
  594. expect(jsonSchema).toBeDefined();
  595. expect(typeof jsonSchema).toBe("object");
  596. });
  597. it("returns a non-empty object for a catalog with components", () => {
  598. const catalog = defineCatalog(testSchema, {
  599. components: {
  600. Text: {
  601. props: z.object({ content: z.string() }),
  602. description: "",
  603. slots: [],
  604. },
  605. },
  606. actions: {},
  607. });
  608. const jsonSchema = catalog.jsonSchema();
  609. expect(jsonSchema).toBeDefined();
  610. expect(jsonSchema).not.toBeNull();
  611. expect(typeof jsonSchema).toBe("object");
  612. });
  613. describe("strict mode (LLM structured output compatible)", () => {
  614. function hasNoPropertyNames(obj: unknown): boolean {
  615. if (typeof obj !== "object" || obj === null) return true;
  616. if ("propertyNames" in obj) return false;
  617. return Object.values(obj).every(hasNoPropertyNames);
  618. }
  619. function allObjectsHaveAdditionalPropertiesFalse(obj: unknown): boolean {
  620. if (typeof obj !== "object" || obj === null) return true;
  621. const record = obj as Record<string, unknown>;
  622. if (record.type === "object") {
  623. if (record.additionalProperties !== false) return false;
  624. }
  625. return Object.values(record).every(
  626. allObjectsHaveAdditionalPropertiesFalse,
  627. );
  628. }
  629. function allObjectPropertiesRequired(obj: unknown): boolean {
  630. if (typeof obj !== "object" || obj === null) return true;
  631. const record = obj as Record<string, unknown>;
  632. if (
  633. record.type === "object" &&
  634. record.properties &&
  635. typeof record.properties === "object"
  636. ) {
  637. const propKeys = Object.keys(record.properties);
  638. const required = (record.required as string[]) ?? [];
  639. if (!propKeys.every((k) => required.includes(k))) return false;
  640. }
  641. return Object.values(record).every(allObjectPropertiesRequired);
  642. }
  643. it("sets additionalProperties: false on all nested objects", () => {
  644. const catalog = defineCatalog(testSchema, {
  645. components: {
  646. Card: {
  647. props: z.object({
  648. title: z.string(),
  649. subtitle: z.string().optional(),
  650. }),
  651. description: "",
  652. slots: [],
  653. },
  654. },
  655. actions: {},
  656. });
  657. const schema = catalog.jsonSchema({ strict: true });
  658. expect(allObjectsHaveAdditionalPropertiesFalse(schema)).toBe(true);
  659. });
  660. it("does not emit propertyNames", () => {
  661. const catalog = defineCatalog(testSchema, {
  662. components: {
  663. Text: {
  664. props: z.object({ content: z.string() }),
  665. description: "",
  666. slots: [],
  667. },
  668. },
  669. actions: {},
  670. });
  671. const schema = catalog.jsonSchema({ strict: true });
  672. expect(hasNoPropertyNames(schema)).toBe(true);
  673. });
  674. it("lists all properties in required (optional uses nullable)", () => {
  675. const catalog = defineCatalog(testSchema, {
  676. components: {
  677. Card: {
  678. props: z.object({
  679. title: z.string(),
  680. subtitle: z.string().optional(),
  681. }),
  682. description: "",
  683. slots: [],
  684. },
  685. },
  686. actions: {},
  687. });
  688. const schema = catalog.jsonSchema({ strict: true });
  689. expect(allObjectPropertiesRequired(schema)).toBe(true);
  690. });
  691. it("converts record types without additionalProperties schema value", () => {
  692. const catalog = defineCatalog(testSchema, {
  693. components: {
  694. Text: {
  695. props: z.object({ content: z.string() }),
  696. description: "",
  697. slots: [],
  698. },
  699. },
  700. actions: {},
  701. });
  702. const schema = catalog.jsonSchema({
  703. strict: true,
  704. }) as Record<string, unknown>;
  705. // Walk the schema and ensure no additionalProperties is set to a non-false value
  706. function noAdditionalPropertiesSchema(obj: unknown): boolean {
  707. if (typeof obj !== "object" || obj === null) return true;
  708. const rec = obj as Record<string, unknown>;
  709. if (
  710. "additionalProperties" in rec &&
  711. rec.additionalProperties !== false
  712. ) {
  713. return false;
  714. }
  715. return Object.values(rec).every(noAdditionalPropertiesSchema);
  716. }
  717. expect(noAdditionalPropertiesSchema(schema)).toBe(true);
  718. });
  719. it("wraps optional properties with anyOf nullable", () => {
  720. // Use a schema where propsOf resolves to a single component's props
  721. // (no record wrapper around the props) so the optional anyOf handling
  722. // is directly visible in the JSON Schema output.
  723. const flatSchema = defineSchema((s) => ({
  724. spec: s.object({
  725. component: s.object({
  726. type: s.ref("catalog.components"),
  727. props: s.propsOf("catalog.components"),
  728. }),
  729. }),
  730. catalog: s.object({
  731. components: s.map({
  732. props: s.zod(),
  733. description: s.string(),
  734. }),
  735. }),
  736. }));
  737. const catalog = defineCatalog(flatSchema, {
  738. components: {
  739. Card: {
  740. props: z.object({
  741. heading: z.string(),
  742. caption: z.string().optional(),
  743. }),
  744. description: "",
  745. },
  746. },
  747. });
  748. const schema = catalog.jsonSchema({ strict: true }) as {
  749. properties: {
  750. component: {
  751. properties: { props: Record<string, unknown> };
  752. };
  753. };
  754. };
  755. const propsSchema = schema.properties.component.properties.props;
  756. // caption is optional – in strict mode it must be in `required`
  757. // and wrapped in anyOf with null
  758. const captionSchema = (
  759. propsSchema as {
  760. properties: { caption: Record<string, unknown> };
  761. }
  762. ).properties.caption;
  763. expect(captionSchema).toEqual({
  764. anyOf: [{ type: "string" }, { type: "null" }],
  765. });
  766. const propsRequired = (propsSchema as { required: string[] }).required;
  767. expect(propsRequired).toContain("heading");
  768. expect(propsRequired).toContain("caption");
  769. });
  770. it("does not affect default (non-strict) output", () => {
  771. const catalog = defineCatalog(testSchema, {
  772. components: {
  773. Text: {
  774. props: z.object({ content: z.string() }),
  775. description: "",
  776. slots: [],
  777. },
  778. },
  779. actions: {},
  780. });
  781. const defaultSchema = catalog.jsonSchema();
  782. const defaultSchema2 = catalog.jsonSchema({ strict: false });
  783. expect(defaultSchema).toEqual(defaultSchema2);
  784. });
  785. });
  786. });
  787. // =============================================================================
  788. // catalog.zodSchema()
  789. // =============================================================================
  790. describe("catalog.zodSchema", () => {
  791. it("returns a Zod schema that validates valid specs", () => {
  792. const catalog = defineCatalog(testSchema, {
  793. components: {
  794. Text: {
  795. props: z.object({ content: z.string() }),
  796. description: "",
  797. slots: [],
  798. },
  799. },
  800. actions: {},
  801. });
  802. const zodSchema = catalog.zodSchema();
  803. const result = zodSchema.safeParse({
  804. root: "t",
  805. elements: {
  806. t: { type: "Text", props: { content: "hi" }, children: [] },
  807. },
  808. });
  809. expect(result.success).toBe(true);
  810. });
  811. it("returns a Zod schema that rejects invalid specs", () => {
  812. const catalog = defineCatalog(testSchema, {
  813. components: {
  814. Text: {
  815. props: z.object({}),
  816. description: "",
  817. slots: [],
  818. },
  819. },
  820. actions: {},
  821. });
  822. const zodSchema = catalog.zodSchema();
  823. const result = zodSchema.safeParse({ root: 42 });
  824. expect(result.success).toBe(false);
  825. });
  826. });