state-store-e2e.test.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. import { describe, it, expect } from "vitest";
  2. import { streamText } from "ai";
  3. import { z } from "zod";
  4. import {
  5. defineCatalog,
  6. buildUserPrompt,
  7. createStateStore,
  8. createSpecStreamCompiler,
  9. type Spec,
  10. type StateStore,
  11. } from "@json-render/core";
  12. import { schema } from "@json-render/react/schema";
  13. import { reduxStateStore } from "@json-render/redux";
  14. import { zustandStateStore } from "@json-render/zustand";
  15. import { jotaiStateStore } from "@json-render/jotai";
  16. import { configureStore, createSlice } from "@reduxjs/toolkit";
  17. import { createStore as createZustandStore } from "zustand/vanilla";
  18. import { atom } from "jotai";
  19. import { createStore as createJotaiStore } from "jotai/vanilla";
  20. const HAS_API_KEY = !!process.env.AI_GATEWAY_API_KEY;
  21. const catalog = defineCatalog(schema, {
  22. components: {
  23. Text: {
  24. props: z.object({ content: z.string() }),
  25. description: "Text content",
  26. },
  27. Input: {
  28. props: z.object({
  29. label: z.string().nullable(),
  30. value: z.string().nullable(),
  31. placeholder: z.string().nullable(),
  32. }),
  33. description:
  34. "Text input. Use value with $bindState for two-way state binding.",
  35. example: {
  36. label: "Name",
  37. value: { $bindState: "/form/name" },
  38. placeholder: "Enter name",
  39. },
  40. },
  41. Button: {
  42. props: z.object({
  43. label: z.string(),
  44. action: z.string().nullable(),
  45. }),
  46. description: "Clickable button",
  47. },
  48. Stack: {
  49. props: z.object({
  50. direction: z.enum(["horizontal", "vertical"]).nullable(),
  51. }),
  52. slots: ["default"],
  53. description: "Layout container",
  54. },
  55. },
  56. actions: {},
  57. });
  58. async function generateSpec(): Promise<Spec> {
  59. const prompt = buildUserPrompt({
  60. prompt:
  61. "Create a simple form with two text inputs bound to state: one for /form/name and one for /form/email. Include initial state with name set to 'Alice' and email set to 'alice@example.com'. Use a vertical Stack as the container.",
  62. });
  63. const result = streamText({
  64. model: "anthropic/claude-haiku-4.5",
  65. system: catalog.prompt(),
  66. prompt,
  67. temperature: 0,
  68. });
  69. const compiler = createSpecStreamCompiler<Spec>();
  70. for await (const chunk of result.textStream) {
  71. compiler.push(chunk);
  72. }
  73. return compiler.getResult();
  74. }
  75. function extractStatePaths(spec: Spec): string[] {
  76. const paths: string[] = [];
  77. function walk(obj: unknown) {
  78. if (obj === null || obj === undefined) return;
  79. if (typeof obj !== "object") return;
  80. if (Array.isArray(obj)) {
  81. for (const item of obj) walk(item);
  82. return;
  83. }
  84. const record = obj as Record<string, unknown>;
  85. if ("$bindState" in record && typeof record.$bindState === "string") {
  86. paths.push(record.$bindState);
  87. }
  88. if ("$state" in record && typeof record.$state === "string") {
  89. paths.push(record.$state);
  90. }
  91. for (const value of Object.values(record)) {
  92. walk(value);
  93. }
  94. }
  95. if (spec.elements) walk(spec.elements);
  96. return [...new Set(paths)];
  97. }
  98. function flattenState(
  99. obj: Record<string, unknown>,
  100. prefix = "",
  101. ): Record<string, unknown> {
  102. const result: Record<string, unknown> = {};
  103. for (const [key, value] of Object.entries(obj)) {
  104. const pointer = `${prefix}/${key}`;
  105. if (
  106. value !== null &&
  107. typeof value === "object" &&
  108. !Array.isArray(value) &&
  109. Object.getPrototypeOf(value) === Object.prototype
  110. ) {
  111. Object.assign(
  112. result,
  113. flattenState(value as Record<string, unknown>, pointer),
  114. );
  115. } else {
  116. result[pointer] = value;
  117. }
  118. }
  119. return result;
  120. }
  121. function verifyStoreRoundTrip(store: StateStore, spec: Spec) {
  122. if (spec.state) {
  123. const flat = flattenState(spec.state);
  124. store.update(flat);
  125. }
  126. const paths = extractStatePaths(spec);
  127. expect(paths.length).toBeGreaterThan(0);
  128. for (const path of paths) {
  129. const original = store.get(path);
  130. const testValue = `test-${Date.now()}`;
  131. store.set(path, testValue);
  132. expect(store.get(path)).toBe(testValue);
  133. store.set(path, original);
  134. expect(store.get(path)).toBe(original);
  135. }
  136. const batchUpdate: Record<string, unknown> = {};
  137. for (const path of paths) {
  138. batchUpdate[path] = `batch-${Date.now()}`;
  139. }
  140. store.update(batchUpdate);
  141. for (const path of paths) {
  142. expect(store.get(path)).toBe(batchUpdate[path]);
  143. }
  144. }
  145. describe.skipIf(!HAS_API_KEY)(
  146. "e2e: Claude Haiku 4.5 + StateStore adapters",
  147. () => {
  148. let spec: Spec;
  149. it("generates a valid spec with state bindings from Claude Haiku 4.5", async () => {
  150. spec = await generateSpec();
  151. expect(spec.root).toBeTruthy();
  152. expect(spec.elements).toBeTruthy();
  153. expect(Object.keys(spec.elements).length).toBeGreaterThan(0);
  154. const paths = extractStatePaths(spec);
  155. expect(paths.length).toBeGreaterThan(0);
  156. });
  157. it("works with createStateStore (built-in)", () => {
  158. const store = createStateStore(spec.state ?? {});
  159. verifyStoreRoundTrip(store, spec);
  160. });
  161. it("works with reduxStateStore (Redux Toolkit)", () => {
  162. const uiSlice = createSlice({
  163. name: "ui",
  164. initialState: (spec.state ?? {}) as Record<string, unknown>,
  165. reducers: {
  166. replace: (_state, action) => action.payload,
  167. },
  168. });
  169. const reduxStore = configureStore({
  170. reducer: { ui: uiSlice.reducer },
  171. });
  172. const store = reduxStateStore({
  173. store: reduxStore,
  174. selector: (state) => state.ui as Record<string, unknown>,
  175. dispatch: (next, s) => s.dispatch(uiSlice.actions.replace(next)),
  176. });
  177. verifyStoreRoundTrip(store, spec);
  178. });
  179. it("works with zustandStateStore (Zustand)", () => {
  180. const zStore = createZustandStore<Record<string, unknown>>()(
  181. () => spec.state ?? {},
  182. );
  183. const store = zustandStateStore({ store: zStore });
  184. verifyStoreRoundTrip(store, spec);
  185. });
  186. it("works with jotaiStateStore (Jotai)", () => {
  187. const stateAtom = atom<Record<string, unknown>>(spec.state ?? {});
  188. const jStore = createJotaiStore();
  189. const store = jotaiStateStore({ atom: stateAtom, store: jStore });
  190. verifyStoreRoundTrip(store, spec);
  191. });
  192. },
  193. );