@assistant-ui/react-generative-ui 0.0.20 → 0.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/LICENSE +1 -1
  2. package/dist/JSONGenerativeUI.client.d.ts.map +1 -1
  3. package/dist/JSONGenerativeUI.client.js +30 -13
  4. package/dist/JSONGenerativeUI.client.js.map +1 -1
  5. package/dist/RadioGroupScope.d.ts +6 -0
  6. package/dist/RadioGroupScope.d.ts.map +1 -0
  7. package/dist/RadioGroupScope.js +25 -0
  8. package/dist/RadioGroupScope.js.map +1 -0
  9. package/dist/a2ui/BindingContext.d.ts +11 -0
  10. package/dist/a2ui/BindingContext.d.ts.map +1 -0
  11. package/dist/a2ui/BindingContext.js +12 -0
  12. package/dist/a2ui/BindingContext.js.map +1 -0
  13. package/dist/a2ui/PresentRenderer.d.ts +11 -0
  14. package/dist/a2ui/PresentRenderer.d.ts.map +1 -0
  15. package/dist/a2ui/PresentRenderer.js +39 -0
  16. package/dist/a2ui/PresentRenderer.js.map +1 -0
  17. package/dist/a2ui/convert.d.ts +6 -0
  18. package/dist/a2ui/convert.d.ts.map +1 -1
  19. package/dist/a2ui/convert.js +369 -87
  20. package/dist/a2ui/convert.js.map +1 -1
  21. package/dist/a2ui/dataModel.d.ts +9 -0
  22. package/dist/a2ui/dataModel.d.ts.map +1 -0
  23. package/dist/a2ui/dataModel.js +44 -0
  24. package/dist/a2ui/dataModel.js.map +1 -0
  25. package/dist/a2ui/reducer.d.ts +5 -0
  26. package/dist/a2ui/reducer.d.ts.map +1 -1
  27. package/dist/a2ui/reducer.js +3 -3
  28. package/dist/a2ui/reducer.js.map +1 -1
  29. package/dist/a2ui/types.d.ts +8 -2
  30. package/dist/a2ui/types.d.ts.map +1 -1
  31. package/dist/a2ui/types.js.map +1 -1
  32. package/dist/a2ui/valueFunctions.d.ts +16 -0
  33. package/dist/a2ui/valueFunctions.d.ts.map +1 -0
  34. package/dist/a2ui/valueFunctions.js +332 -0
  35. package/dist/a2ui/valueFunctions.js.map +1 -0
  36. package/dist/actionRegistry.d.ts +1 -1
  37. package/dist/actionRegistry.js.map +1 -1
  38. package/dist/answeredValues.d.ts +16 -0
  39. package/dist/answeredValues.d.ts.map +1 -0
  40. package/dist/answeredValues.js +37 -0
  41. package/dist/answeredValues.js.map +1 -0
  42. package/dist/constants.d.ts +2 -0
  43. package/dist/constants.d.ts.map +1 -1
  44. package/dist/constants.js +3 -1
  45. package/dist/constants.js.map +1 -1
  46. package/dist/fieldReferences.d.ts +9 -0
  47. package/dist/fieldReferences.d.ts.map +1 -0
  48. package/dist/fieldReferences.js +53 -0
  49. package/dist/fieldReferences.js.map +1 -0
  50. package/dist/generativeUIToJSX.js +3 -3
  51. package/dist/generativeUIToJSX.js.map +1 -1
  52. package/dist/index.js +2 -2
  53. package/dist/index.server.js +2 -2
  54. package/dist/index.shared.js +2 -2
  55. package/dist/ir.d.ts +1 -1
  56. package/dist/ir.js.map +1 -1
  57. package/dist/renderGenerativeUI.d.ts.map +1 -1
  58. package/dist/renderGenerativeUI.js +12 -1
  59. package/dist/renderGenerativeUI.js.map +1 -1
  60. package/dist/slack/constants.d.ts +2 -0
  61. package/dist/slack/constants.d.ts.map +1 -1
  62. package/dist/slack/constants.js +3 -1
  63. package/dist/slack/constants.js.map +1 -1
  64. package/dist/slack/decodeBlockAction.d.ts +3 -1
  65. package/dist/slack/decodeBlockAction.d.ts.map +1 -1
  66. package/dist/slack/decodeBlockAction.js +58 -7
  67. package/dist/slack/decodeBlockAction.js.map +1 -1
  68. package/dist/slack/fromSlackBlocks.d.ts.map +1 -1
  69. package/dist/slack/fromSlackBlocks.js +20 -3
  70. package/dist/slack/fromSlackBlocks.js.map +1 -1
  71. package/dist/slack/toSlackBlocks.d.ts.map +1 -1
  72. package/dist/slack/toSlackBlocks.js +187 -19
  73. package/dist/slack/toSlackBlocks.js.map +1 -1
  74. package/dist/slack/types.d.ts +4 -0
  75. package/dist/slack/types.d.ts.map +1 -1
  76. package/dist/teams/constants.d.ts +1 -1
  77. package/dist/teams/constants.d.ts.map +1 -1
  78. package/dist/teams/constants.js +1 -1
  79. package/dist/teams/constants.js.map +1 -1
  80. package/dist/teams/decodeSubmitData.d.ts +4 -0
  81. package/dist/teams/decodeSubmitData.d.ts.map +1 -1
  82. package/dist/teams/decodeSubmitData.js +6 -1
  83. package/dist/teams/decodeSubmitData.js.map +1 -1
  84. package/dist/teams/toAdaptiveCard.d.ts +5 -4
  85. package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
  86. package/dist/teams/toAdaptiveCard.js +56 -17
  87. package/dist/teams/toAdaptiveCard.js.map +1 -1
  88. package/dist/teams/types.d.ts +4 -1
  89. package/dist/teams/types.d.ts.map +1 -1
  90. package/dist/vocabulary/alert.d.ts +9 -2
  91. package/dist/vocabulary/alert.d.ts.map +1 -1
  92. package/dist/vocabulary/alert.js +116 -19
  93. package/dist/vocabulary/alert.js.map +1 -1
  94. package/dist/vocabulary/collectFormValues.d.ts +2 -1
  95. package/dist/vocabulary/collectFormValues.d.ts.map +1 -1
  96. package/dist/vocabulary/collectFormValues.js +10 -3
  97. package/dist/vocabulary/collectFormValues.js.map +1 -1
  98. package/dist/vocabulary/data.d.ts +22 -5
  99. package/dist/vocabulary/data.d.ts.map +1 -1
  100. package/dist/vocabulary/data.js +278 -83
  101. package/dist/vocabulary/data.js.map +1 -1
  102. package/dist/vocabulary/dispatch.d.ts +3 -2
  103. package/dist/vocabulary/dispatch.d.ts.map +1 -1
  104. package/dist/vocabulary/dispatch.js +33 -4
  105. package/dist/vocabulary/dispatch.js.map +1 -1
  106. package/dist/vocabulary/fact.d.ts +8 -1
  107. package/dist/vocabulary/fact.d.ts.map +1 -1
  108. package/dist/vocabulary/fact.js +37 -11
  109. package/dist/vocabulary/fact.js.map +1 -1
  110. package/dist/vocabulary/form.d.ts.map +1 -1
  111. package/dist/vocabulary/form.js +3 -3
  112. package/dist/vocabulary/form.js.map +1 -1
  113. package/dist/vocabulary/formatValue.d.ts +7 -0
  114. package/dist/vocabulary/formatValue.d.ts.map +1 -0
  115. package/dist/vocabulary/formatValue.js +76 -0
  116. package/dist/vocabulary/formatValue.js.map +1 -0
  117. package/dist/vocabulary/icon.d.ts +1 -1
  118. package/dist/vocabulary/interactive.d.ts +137 -8
  119. package/dist/vocabulary/interactive.d.ts.map +1 -1
  120. package/dist/vocabulary/interactive.js +404 -103
  121. package/dist/vocabulary/interactive.js.map +1 -1
  122. package/dist/vocabulary/layout.d.ts.map +1 -1
  123. package/dist/vocabulary/layout.js +4 -4
  124. package/dist/vocabulary/layout.js.map +1 -1
  125. package/dist/vocabulary/list.js +2 -2
  126. package/dist/vocabulary/list.js.map +1 -1
  127. package/dist/vocabulary/text.d.ts +1 -1
  128. package/package.json +6 -3
  129. package/src/JSONGenerativeUI.client.tsx +84 -40
  130. package/src/JSONGenerativeUI.test.tsx +964 -68
  131. package/src/RadioGroupScope.server.test.tsx +24 -0
  132. package/src/RadioGroupScope.tsx +34 -0
  133. package/src/a2ui/BindingContext.ts +26 -0
  134. package/src/a2ui/PresentRenderer.tsx +83 -0
  135. package/src/a2ui/convert.test.ts +1982 -145
  136. package/src/a2ui/convert.ts +677 -137
  137. package/src/a2ui/dataModel.ts +69 -0
  138. package/src/a2ui/reducer.test.ts +35 -0
  139. package/src/a2ui/reducer.ts +5 -4
  140. package/src/a2ui/types.ts +9 -2
  141. package/src/a2ui/valueFunctions.test.ts +271 -0
  142. package/src/a2ui/valueFunctions.ts +523 -0
  143. package/src/actionRegistry.ts +1 -1
  144. package/src/answeredValues.test.ts +33 -0
  145. package/src/answeredValues.tsx +70 -0
  146. package/src/constants.ts +4 -0
  147. package/src/fieldReferences.test.ts +66 -0
  148. package/src/fieldReferences.ts +83 -0
  149. package/src/generativeUIToJSX.test.ts +18 -0
  150. package/src/generativeUIToJSX.ts +4 -6
  151. package/src/index.server.test.ts +106 -0
  152. package/src/ir.ts +1 -1
  153. package/src/renderGenerativeUI.test.tsx +14 -5
  154. package/src/renderGenerativeUI.tsx +30 -1
  155. package/src/slack/a2uiSlack.test.ts +198 -0
  156. package/src/slack/constants.ts +3 -0
  157. package/src/slack/decodeBlockAction.test.ts +98 -0
  158. package/src/slack/decodeBlockAction.ts +110 -17
  159. package/src/slack/fromSlackBlocks.test.ts +222 -3
  160. package/src/slack/fromSlackBlocks.ts +53 -2
  161. package/src/slack/toSlackBlocks.test.ts +682 -3
  162. package/src/slack/toSlackBlocks.ts +374 -23
  163. package/src/slack/types.ts +4 -0
  164. package/src/teams/constants.ts +1 -1
  165. package/src/teams/decodeSubmitData.test.ts +33 -0
  166. package/src/teams/decodeSubmitData.ts +9 -1
  167. package/src/teams/toAdaptiveCard.test.ts +219 -0
  168. package/src/teams/toAdaptiveCard.ts +93 -19
  169. package/src/teams/types.ts +4 -1
  170. package/src/vocabulary/alert.test.tsx +288 -7
  171. package/src/vocabulary/alert.tsx +168 -26
  172. package/src/vocabulary/collectFormValues.test.ts +28 -1
  173. package/src/vocabulary/collectFormValues.ts +25 -5
  174. package/src/vocabulary/data.test.tsx +248 -6
  175. package/src/vocabulary/data.tsx +398 -100
  176. package/src/vocabulary/dispatch.ts +63 -2
  177. package/src/vocabulary/fact.test.tsx +67 -0
  178. package/src/vocabulary/fact.tsx +40 -9
  179. package/src/vocabulary/form.tsx +7 -2
  180. package/src/vocabulary/formatValue.test.ts +72 -0
  181. package/src/vocabulary/formatValue.ts +127 -0
  182. package/src/vocabulary/index.test.ts +2 -0
  183. package/src/vocabulary/interactive.dispatch.test.tsx +167 -79
  184. package/src/vocabulary/interactive.dom.test.tsx +1699 -0
  185. package/src/vocabulary/interactive.test.tsx +60 -4
  186. package/src/vocabulary/interactive.tsx +770 -151
  187. package/src/vocabulary/layout.test.tsx +6 -4
  188. package/src/vocabulary/layout.tsx +13 -2
  189. package/src/vocabulary/list.tsx +2 -2
@@ -0,0 +1,1699 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, useState } from "react";
4
+ import { createRoot, hydrateRoot, type Root } from "react-dom/client";
5
+ import { renderToString } from "react-dom/server";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+ import { convertSurfaceToUISpec } from "../a2ui/convert";
8
+ import { A2uiBindingContext } from "../a2ui/BindingContext";
9
+ import { applyA2uiOperations } from "../a2ui/reducer";
10
+ import { A2uiPresentRenderer } from "../a2ui/PresentRenderer";
11
+ import { createActionRegistry, type ActionHandler } from "../actionRegistry";
12
+ import { AnsweredValuesProvider } from "../answeredValues";
13
+ import type { GenerativeUIDispatch, GenerativeUILibrary } from "../types";
14
+ import { renderGenerativeUI } from "../renderGenerativeUI";
15
+ import { defaultGenerativeUILibrary } from "./index";
16
+
17
+ (
18
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
19
+ ).IS_REACT_ACT_ENVIRONMENT = true;
20
+
21
+ const toppings = [
22
+ { label: "Basil", value: "basil" },
23
+ { label: "Olives", value: "olives" },
24
+ { label: "Onion", value: "onion" },
25
+ ];
26
+
27
+ let root: Root | undefined;
28
+
29
+ afterEach(async () => {
30
+ await act(async () => root?.unmount());
31
+ root = undefined;
32
+ document.body.innerHTML = "";
33
+ vi.useRealTimers();
34
+ });
35
+
36
+ const view = (node: unknown, dispatch?: GenerativeUIDispatch) => (
37
+ <div data-aui="root">
38
+ {renderGenerativeUI(node, defaultGenerativeUILibrary, {
39
+ status: "done",
40
+ ...(dispatch ? { dispatch } : {}),
41
+ })}
42
+ </div>
43
+ );
44
+
45
+ const mount = async (
46
+ node: unknown,
47
+ handlers: Readonly<Record<string, ActionHandler>>,
48
+ parent: HTMLElement = document.body,
49
+ ) => {
50
+ const container = document.createElement("div");
51
+ parent.append(container);
52
+ const registry = createActionRegistry(handlers);
53
+ root = createRoot(container);
54
+ await act(async () => {
55
+ root!.render(view(node, registry.dispatch));
56
+ });
57
+ return container;
58
+ };
59
+
60
+ describe("Input", () => {
61
+ it("keeps password and number single-line when multiline is set", async () => {
62
+ const container = await mount(
63
+ [
64
+ {
65
+ $type: "Input",
66
+ name: "password",
67
+ inputType: "password",
68
+ multiline: true,
69
+ },
70
+ {
71
+ $type: "Input",
72
+ name: "number",
73
+ inputType: "number",
74
+ multiline: true,
75
+ },
76
+ { $type: "Input", name: "text", inputType: "text", multiline: true },
77
+ { $type: "Input", name: "default", multiline: true },
78
+ ],
79
+ {},
80
+ );
81
+
82
+ expect(
83
+ Array.from(container.querySelectorAll("input, textarea")).map(
84
+ (control) =>
85
+ control instanceof HTMLInputElement
86
+ ? control.type
87
+ : control.tagName.toLowerCase(),
88
+ ),
89
+ ).toEqual(["password", "number", "textarea", "textarea"]);
90
+ });
91
+ });
92
+
93
+ describe("RadioGroup", () => {
94
+ it("keeps repeated logical fields exclusive within one root without a form", async () => {
95
+ const save = vi.fn();
96
+ const container = await mount(
97
+ {
98
+ $type: "Col",
99
+ children: [
100
+ { $type: "RadioGroup", name: "choice", options: toppings },
101
+ { $type: "RadioGroup", name: "choice", options: toppings },
102
+ {
103
+ $type: "Button",
104
+ label: "Save",
105
+ $action: { type: "save", choice: { $field: "choice" } },
106
+ },
107
+ ],
108
+ },
109
+ { save },
110
+ );
111
+ const inputs = container.querySelectorAll("input");
112
+ await act(async () => inputs[4]!.click());
113
+ await act(async () => inputs[0]!.click());
114
+ await act(async () => container.querySelector("button")!.click());
115
+
116
+ expect(inputs[4]!.checked).toBe(false);
117
+ expect(save).toHaveBeenCalledWith({
118
+ payload: { type: "save", choice: "basil" },
119
+ });
120
+ });
121
+
122
+ it.each(["Form", "Card"])(
123
+ "keeps repeated logical names mutually exclusive inside a %s form",
124
+ async ($type) => {
125
+ const save = vi.fn();
126
+ const container = await mount(
127
+ {
128
+ $type,
129
+ ...($type === "Form"
130
+ ? { $action: { type: "save" } }
131
+ : {
132
+ asForm: true,
133
+ confirm: { label: "Save", $action: { type: "save" } },
134
+ }),
135
+ children: [
136
+ { $type: "RadioGroup", name: "choice", options: toppings },
137
+ {
138
+ $type: "Card",
139
+ children: {
140
+ $type: "RadioGroup",
141
+ name: "choice",
142
+ options: toppings,
143
+ },
144
+ },
145
+ ...($type === "Form"
146
+ ? [{ $type: "Button", label: "Save", submit: true }]
147
+ : []),
148
+ ],
149
+ },
150
+ { save },
151
+ );
152
+ const inputs = container.querySelectorAll("input");
153
+ await act(async () => inputs[4]!.click());
154
+ await act(async () => inputs[0]!.click());
155
+ await act(async () => container.querySelector("button")!.click());
156
+
157
+ expect(inputs[4]!.checked).toBe(false);
158
+ expect(save).toHaveBeenCalledWith({
159
+ payload: { type: "save", $input: { choice: "basil" } },
160
+ });
161
+ },
162
+ );
163
+
164
+ it("preserves independent selections across separately rendered and hydrated roots", async () => {
165
+ const save = vi.fn();
166
+ const registry = createActionRegistry({ save });
167
+ const tree = view(
168
+ {
169
+ $type: "Col",
170
+ children: [
171
+ { $type: "RadioGroup", name: "choice", options: toppings },
172
+ {
173
+ $type: "Button",
174
+ label: "Save",
175
+ $action: { type: "save", choice: { $field: "choice" } },
176
+ },
177
+ ],
178
+ },
179
+ registry.dispatch,
180
+ );
181
+ const containers = ["first-", "second-"].map((identifierPrefix) => {
182
+ const container = document.createElement("div");
183
+ container.innerHTML = renderToString(tree, { identifierPrefix });
184
+ document.body.append(container);
185
+ return { container, identifierPrefix };
186
+ });
187
+ const hydratedRoots: Root[] = [];
188
+ const onRecoverableError = vi.fn();
189
+ try {
190
+ await act(async () => {
191
+ for (const { container, identifierPrefix } of containers) {
192
+ hydratedRoots.push(
193
+ hydrateRoot(container, tree, {
194
+ identifierPrefix,
195
+ onRecoverableError,
196
+ }),
197
+ );
198
+ }
199
+ });
200
+ for (const [index, { container }] of containers.entries()) {
201
+ await act(async () =>
202
+ container.querySelectorAll("input")[index]!.click(),
203
+ );
204
+ }
205
+ for (const { container } of containers) {
206
+ await act(async () => container.querySelector("button")!.click());
207
+ }
208
+
209
+ expect(save.mock.calls.map(([{ payload }]) => payload)).toEqual([
210
+ { type: "save", choice: "basil" },
211
+ { type: "save", choice: "olives" },
212
+ ]);
213
+ expect(onRecoverableError).not.toHaveBeenCalled();
214
+ } finally {
215
+ await act(async () => hydratedRoots.forEach((item) => item.unmount()));
216
+ }
217
+ });
218
+
219
+ it("keeps same-named groups in separate roots independent and resolves their field values", async () => {
220
+ const save = vi.fn();
221
+ const registry = createActionRegistry({ save });
222
+ const container = document.createElement("div");
223
+ document.body.append(container);
224
+ root = createRoot(container);
225
+ const card = (id: string) => ({
226
+ $type: "Col",
227
+ children: [
228
+ { $type: "RadioGroup", name: "choice", options: toppings },
229
+ {
230
+ $type: "Button",
231
+ label: "Save",
232
+ $action: { type: "save", id, choice: { $field: "choice" } },
233
+ },
234
+ ],
235
+ });
236
+ await act(async () => {
237
+ root!.render(
238
+ <>
239
+ {view(card("first"), registry.dispatch)}
240
+ {view(card("second"), registry.dispatch)}
241
+ </>,
242
+ );
243
+ });
244
+ const inputs = container.querySelectorAll("input");
245
+ const [firstSave, secondSave] = container.querySelectorAll("button");
246
+
247
+ await act(async () => inputs[0]!.click());
248
+ await act(async () => inputs[4]!.click());
249
+ await act(async () => firstSave!.click());
250
+ await act(async () => secondSave!.click());
251
+
252
+ expect(save.mock.calls.map(([{ payload }]) => payload)).toEqual([
253
+ { type: "save", id: "first", choice: "basil" },
254
+ { type: "save", id: "second", choice: "olives" },
255
+ ]);
256
+ expect(inputs[0]!.checked).toBe(true);
257
+ expect(inputs[4]!.checked).toBe(true);
258
+
259
+ await act(async () => inputs[2]!.click());
260
+ expect(inputs[0]!.checked).toBe(false);
261
+ expect(inputs[2]!.checked).toBe(true);
262
+ expect(inputs[4]!.checked).toBe(true);
263
+ });
264
+
265
+ it("submits logical field names and omits unnamed groups", async () => {
266
+ const save = vi.fn();
267
+ const container = await mount(
268
+ {
269
+ $type: "Form",
270
+ $action: { type: "save" },
271
+ children: [
272
+ { $type: "RadioGroup", name: "choice", options: toppings },
273
+ { $type: "RadioGroup", options: toppings },
274
+ { $type: "Button", label: "Save", submit: true },
275
+ ],
276
+ },
277
+ { save },
278
+ );
279
+ const inputs = container.querySelectorAll("input");
280
+ await act(async () => inputs[0]!.click());
281
+ await act(async () => inputs[4]!.click());
282
+ await act(async () => container.querySelector("button")!.click());
283
+
284
+ expect(save).toHaveBeenCalledWith({
285
+ payload: { type: "save", $input: { choice: "basil" } },
286
+ });
287
+ });
288
+ });
289
+
290
+ describe("option identity", () => {
291
+ it.each(["CheckboxGroup", "RadioGroup", "Select"])(
292
+ "%s preserves the submitted choice when options move or their labels change",
293
+ async (type) => {
294
+ const save = vi.fn();
295
+ const registry = createActionRegistry({ save });
296
+ const form = (options: typeof toppings) => ({
297
+ $type: "Form",
298
+ $action: { type: "save" },
299
+ children: [
300
+ { $type: type, name: "choice", options },
301
+ { $type: "Button", label: "Save", submit: true },
302
+ ],
303
+ });
304
+ const container = await mount(form(toppings), { save });
305
+ if (type === "Select") {
306
+ container.querySelector("select")!.value = "olives";
307
+ } else {
308
+ await act(async () => container.querySelectorAll("input")[1]!.click());
309
+ }
310
+ await act(async () => container.querySelector("button")!.click());
311
+
312
+ await act(async () => {
313
+ root!.render(
314
+ view(
315
+ form([
316
+ { label: "Pepper", value: "pepper" },
317
+ ...[...toppings].reverse().map((option) => ({
318
+ ...option,
319
+ label: option.label.toUpperCase(),
320
+ })),
321
+ ]),
322
+ registry.dispatch,
323
+ ),
324
+ );
325
+ });
326
+ await act(async () => container.querySelector("button")!.click());
327
+
328
+ const payload = {
329
+ type: "save",
330
+ $input: { choice: type === "CheckboxGroup" ? ["olives"] : "olives" },
331
+ };
332
+ expect(save.mock.calls.map(([call]) => call.payload)).toEqual([
333
+ payload,
334
+ payload,
335
+ ]);
336
+ },
337
+ );
338
+
339
+ it.each(["CheckboxGroup", "RadioGroup", "Select"])(
340
+ "%s retains duplicate-valued options without duplicate React keys",
341
+ async (type) => {
342
+ const errors = vi.spyOn(console, "error").mockImplementation(() => {});
343
+ try {
344
+ const container = await mount(
345
+ {
346
+ $type: type,
347
+ options: [
348
+ { label: "First", value: "same" },
349
+ { label: "Second", value: "same" },
350
+ { label: "Third", value: "same:1" },
351
+ ],
352
+ },
353
+ {},
354
+ );
355
+ expect(container.querySelectorAll("input, option")).toHaveLength(3);
356
+ expect(errors).not.toHaveBeenCalled();
357
+ } finally {
358
+ errors.mockRestore();
359
+ }
360
+ },
361
+ );
362
+ });
363
+
364
+ describe("CheckboxGroup", () => {
365
+ it("dispatches its checked option values in option order on every change", async () => {
366
+ const pick = vi.fn();
367
+ const container = await mount(
368
+ {
369
+ $type: "CheckboxGroup",
370
+ options: toppings,
371
+ defaultValue: ["onion"],
372
+ $action: { type: "pick" },
373
+ children: { $type: "Checkbox", label: "Extra cheese" },
374
+ },
375
+ { pick },
376
+ );
377
+ const [basil, , onion, cheese] = container.querySelectorAll("input");
378
+
379
+ await act(async () => basil!.click());
380
+ await act(async () => cheese!.click());
381
+ await act(async () => onion!.click());
382
+
383
+ expect(pick.mock.calls.map(([{ payload }]) => payload)).toEqual([
384
+ { type: "pick", $input: ["basil", "onion"] },
385
+ { type: "pick", $input: ["basil"] },
386
+ ]);
387
+ });
388
+
389
+ it("renders a converted A2UI multipleSelection ChoicePicker with its bound values checked", async () => {
390
+ const { state } = applyA2uiOperations(new Map(), [
391
+ { version: "v0.9", createSurface: { surfaceId: "s" } },
392
+ {
393
+ version: "v0.9",
394
+ updateComponents: {
395
+ surfaceId: "s",
396
+ components: [
397
+ {
398
+ id: "root",
399
+ component: "ChoicePicker",
400
+ variant: "multipleSelection",
401
+ options: toppings,
402
+ value: { path: "/picked" },
403
+ },
404
+ ],
405
+ },
406
+ },
407
+ {
408
+ version: "v0.9",
409
+ updateDataModel: {
410
+ surfaceId: "s",
411
+ path: "/",
412
+ value: { picked: ["basil", "onion"] },
413
+ },
414
+ },
415
+ ]);
416
+ const container = await mount(
417
+ convertSurfaceToUISpec(state.get("s")!).spec,
418
+ {},
419
+ );
420
+
421
+ expect(
422
+ [...container.querySelectorAll("input")].map((input) => input.checked),
423
+ ).toEqual([true, false, true]);
424
+ });
425
+
426
+ it("submits each group in a Form as its checked values keyed by name", async () => {
427
+ const submit = vi.fn();
428
+ const container = await mount(
429
+ {
430
+ $type: "Form",
431
+ $action: { type: "submit" },
432
+ children: [
433
+ {
434
+ $type: "CheckboxGroup",
435
+ name: "toppings",
436
+ options: toppings,
437
+ defaultValue: ["olives"],
438
+ },
439
+ {
440
+ $type: "CheckboxGroup",
441
+ name: "sides",
442
+ options: [{ label: "Fries", value: "fries" }],
443
+ },
444
+ { $type: "Button", label: "Order", submit: true },
445
+ ],
446
+ },
447
+ { submit },
448
+ );
449
+
450
+ await act(async () => container.querySelector("input")!.click());
451
+ await act(async () => container.querySelector("button")!.click());
452
+
453
+ expect(submit).toHaveBeenCalledWith({
454
+ payload: {
455
+ type: "submit",
456
+ $input: { toppings: ["basil", "olives"], sides: [] },
457
+ },
458
+ });
459
+ });
460
+ });
461
+
462
+ describe("Button undo window", () => {
463
+ it("keeps counting down while a bound input changes and dispatches its latest value", async () => {
464
+ vi.useFakeTimers();
465
+ const save = vi.fn();
466
+ const container = document.createElement("div");
467
+ document.body.append(container);
468
+ const registry = createActionRegistry({ save });
469
+ const BindingContext = A2uiBindingContext!;
470
+ const Surface = () => {
471
+ const [name, setName] = useState("Initial");
472
+ return (
473
+ <BindingContext.Provider
474
+ value={{
475
+ fields: new Map([["/name", { value: name, arrayValue: false }]]),
476
+ update: (_path, value) => setName(String(value)),
477
+ }}
478
+ >
479
+ <div data-aui="root">
480
+ {renderGenerativeUI(
481
+ [
482
+ { $type: "Input", name: "/name", value: name },
483
+ {
484
+ $type: "Button",
485
+ label: "Save",
486
+ undoable: true,
487
+ $action: { type: "save", name: { $field: "/name" } },
488
+ },
489
+ ],
490
+ defaultGenerativeUILibrary,
491
+ { status: "done", dispatch: registry.dispatch },
492
+ )}
493
+ </div>
494
+ </BindingContext.Provider>
495
+ );
496
+ };
497
+ root = createRoot(container);
498
+ await act(async () => root!.render(<Surface />));
499
+ const button = container.querySelector<HTMLButtonElement>("button")!;
500
+ const input = container.querySelector<HTMLInputElement>("input")!;
501
+ await act(async () => button.click());
502
+ await act(async () => {
503
+ await vi.advanceTimersByTimeAsync(500);
504
+ });
505
+ await act(async () => {
506
+ Object.getOwnPropertyDescriptor(
507
+ HTMLInputElement.prototype,
508
+ "value",
509
+ )!.set!.call(input, "Edited");
510
+ input.dispatchEvent(new Event("input", { bubbles: true }));
511
+ });
512
+ await act(async () => {
513
+ await vi.advanceTimersByTimeAsync(500);
514
+ });
515
+ expect(button.textContent).toContain("Undo 4");
516
+ for (let seconds = 0; seconds < 4; seconds++) {
517
+ await act(async () => {
518
+ await vi.advanceTimersByTimeAsync(1_000);
519
+ });
520
+ }
521
+ expect(save).toHaveBeenCalledWith({
522
+ payload: { type: "save", name: "Edited" },
523
+ });
524
+ });
525
+
526
+ it("counts down for five seconds before firing once", async () => {
527
+ vi.useFakeTimers();
528
+ const purchase = vi.fn();
529
+ const container = await mount(
530
+ {
531
+ $type: "Button",
532
+ label: "Purchase",
533
+ undoable: true,
534
+ $action: { type: "purchase" },
535
+ },
536
+ { purchase },
537
+ );
538
+ const button = container.querySelector<HTMLButtonElement>("button")!;
539
+
540
+ await act(async () => button.click());
541
+
542
+ expect(button.dataset.auiState).toBe("pending");
543
+ expect(button.textContent).toContain("Undo 5");
544
+ expect(button.getAttribute("aria-label")).toBe("Undo Purchase");
545
+ expect(container.querySelector('[role="status"]')?.textContent).toBe(
546
+ "Purchase in 5 seconds",
547
+ );
548
+ expect(purchase).not.toHaveBeenCalled();
549
+
550
+ for (const seconds of [4, 3, 2, 1]) {
551
+ await act(async () => {
552
+ await vi.advanceTimersByTimeAsync(1_000);
553
+ });
554
+ expect(button.textContent).toContain(`Undo ${seconds}`);
555
+ expect(purchase).not.toHaveBeenCalled();
556
+ }
557
+
558
+ await act(async () => {
559
+ await vi.advanceTimersByTimeAsync(1_000);
560
+ });
561
+
562
+ expect(purchase).toHaveBeenCalledTimes(1);
563
+ expect(purchase).toHaveBeenCalledWith({ payload: { type: "purchase" } });
564
+ expect(button.dataset.auiState).toBeUndefined();
565
+ expect(button.textContent).toBe("Purchase");
566
+ });
567
+
568
+ it("keeps the generic undo label when its text label is empty", async () => {
569
+ vi.useFakeTimers();
570
+ const remove = vi.fn();
571
+ const container = await mount(
572
+ {
573
+ $type: "Button",
574
+ label: "",
575
+ undoable: true,
576
+ $action: { type: "remove" },
577
+ },
578
+ { remove },
579
+ );
580
+ const button = container.querySelector<HTMLButtonElement>("button")!;
581
+
582
+ await act(async () => button.click());
583
+
584
+ expect(button.getAttribute("aria-label")).toBe("Undo");
585
+ });
586
+
587
+ it("cancels when clicked again", async () => {
588
+ vi.useFakeTimers();
589
+ const send = vi.fn();
590
+ const container = await mount(
591
+ {
592
+ $type: "Button",
593
+ label: "Send",
594
+ undoable: true,
595
+ $action: { type: "send" },
596
+ },
597
+ { send },
598
+ );
599
+ const button = container.querySelector<HTMLButtonElement>("button")!;
600
+
601
+ await act(async () => button.click());
602
+ await act(async () => button.click());
603
+ await act(async () => {
604
+ await vi.advanceTimersByTimeAsync(5_000);
605
+ });
606
+
607
+ expect(send).not.toHaveBeenCalled();
608
+ expect(button.dataset.auiState).toBeUndefined();
609
+ expect(button.textContent).toBe("Send");
610
+ });
611
+
612
+ it("cancels when Escape is pressed while focused", async () => {
613
+ vi.useFakeTimers();
614
+ const remove = vi.fn();
615
+ const container = await mount(
616
+ {
617
+ $type: "Button",
618
+ label: "Delete",
619
+ undoable: true,
620
+ $action: { type: "remove" },
621
+ },
622
+ { remove },
623
+ );
624
+ const button = container.querySelector<HTMLButtonElement>("button")!;
625
+
626
+ await act(async () => button.click());
627
+ button.focus();
628
+ await act(async () => {
629
+ button.dispatchEvent(
630
+ new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }),
631
+ );
632
+ });
633
+ await act(async () => {
634
+ await vi.advanceTimersByTimeAsync(5_000);
635
+ });
636
+
637
+ expect(remove).not.toHaveBeenCalled();
638
+ expect(button.dataset.auiState).toBeUndefined();
639
+ expect(button.textContent).toBe("Delete");
640
+ });
641
+
642
+ it("clears the timer on unmount", async () => {
643
+ vi.useFakeTimers();
644
+ const purchase = vi.fn();
645
+ const container = await mount(
646
+ {
647
+ $type: "Button",
648
+ label: "Purchase",
649
+ undoable: true,
650
+ $action: { type: "purchase" },
651
+ },
652
+ { purchase },
653
+ );
654
+
655
+ await act(async () => container.querySelector("button")!.click());
656
+ await act(async () => root!.unmount());
657
+ root = undefined;
658
+ await act(async () => {
659
+ await vi.advanceTimersByTimeAsync(5_000);
660
+ });
661
+
662
+ expect(purchase).not.toHaveBeenCalled();
663
+ });
664
+
665
+ it("leaves an undoable button without a dispatcher unchanged", async () => {
666
+ vi.useFakeTimers();
667
+ const container = document.createElement("div");
668
+ document.body.append(container);
669
+ root = createRoot(container);
670
+ await act(async () => {
671
+ root!.render(
672
+ view({
673
+ $type: "Button",
674
+ label: "Purchase",
675
+ undoable: true,
676
+ $action: { type: "purchase" },
677
+ }),
678
+ );
679
+ });
680
+ const button = container.querySelector<HTMLButtonElement>("button")!;
681
+
682
+ await act(async () => button.click());
683
+
684
+ expect(button.dataset.auiState).toBeUndefined();
685
+ expect(button.textContent).toBe("Purchase");
686
+ });
687
+ });
688
+
689
+ describe("Slider", () => {
690
+ it("renders a labelled range with its current value and unit", async () => {
691
+ const container = await mount(
692
+ {
693
+ $type: "Slider",
694
+ name: "volume",
695
+ label: "Volume",
696
+ min: 0,
697
+ max: 10,
698
+ defaultValue: 4,
699
+ unit: "%",
700
+ },
701
+ {},
702
+ );
703
+ const slider = container.querySelector<HTMLInputElement>(
704
+ '[data-aui="slider"]',
705
+ )!;
706
+
707
+ expect(slider.type).toBe("range");
708
+ expect(slider.getAttribute("aria-valuetext")).toBe("4 %");
709
+ expect(
710
+ container.querySelector('[data-aui="slider-value"]')?.textContent,
711
+ ).toBe("4 %");
712
+ });
713
+
714
+ it("normalizes malformed range props before rendering", () => {
715
+ function Slider() {
716
+ return defaultGenerativeUILibrary.Slider!.render({
717
+ $status: "done",
718
+ min: Number.NaN,
719
+ max: Number.POSITIVE_INFINITY,
720
+ step: Number.NEGATIVE_INFINITY,
721
+ defaultValue: Number.NaN,
722
+ });
723
+ }
724
+
725
+ const markup = renderToString(<Slider />);
726
+
727
+ expect(markup).toContain('min="0"');
728
+ expect(markup).toContain('max="100"');
729
+ expect(markup).toContain('step="1"');
730
+ expect(markup).toContain('value="0"');
731
+ });
732
+
733
+ it("fires once with a number for a committed adjustment", async () => {
734
+ const setVolume = vi.fn();
735
+ const container = await mount(
736
+ {
737
+ $type: "Slider",
738
+ name: "volume",
739
+ min: 0,
740
+ max: 10,
741
+ $action: { type: "set_volume" },
742
+ },
743
+ { set_volume: setVolume },
744
+ );
745
+ const slider = container.querySelector<HTMLInputElement>(
746
+ '[data-aui="slider"]',
747
+ )!;
748
+
749
+ await act(async () => {
750
+ slider.dispatchEvent(new Event("pointerdown", { bubbles: true }));
751
+ slider.value = "7";
752
+ slider.dispatchEvent(new Event("input", { bubbles: true }));
753
+ slider.dispatchEvent(new Event("change", { bubbles: true }));
754
+ slider.dispatchEvent(new Event("pointerup", { bubbles: true }));
755
+ slider.dispatchEvent(new Event("change", { bubbles: true }));
756
+ });
757
+
758
+ expect(setVolume).toHaveBeenCalledTimes(1);
759
+ expect(setVolume).toHaveBeenCalledWith({
760
+ payload: { type: "set_volume", $input: 7 },
761
+ });
762
+ });
763
+
764
+ it("does not dispatch when a pointer interaction keeps the initial value", async () => {
765
+ const setVolume = vi.fn();
766
+ const container = await mount(
767
+ {
768
+ $type: "Slider",
769
+ min: 0,
770
+ max: 10,
771
+ defaultValue: 4,
772
+ $action: { type: "set_volume" },
773
+ },
774
+ { set_volume: setVolume },
775
+ );
776
+ const slider = container.querySelector<HTMLInputElement>(
777
+ '[data-aui="slider"]',
778
+ )!;
779
+
780
+ await act(async () => {
781
+ slider.dispatchEvent(new Event("pointerdown", { bubbles: true }));
782
+ slider.dispatchEvent(new Event("pointerup", { bubbles: true }));
783
+ });
784
+
785
+ expect(setVolume).not.toHaveBeenCalled();
786
+ });
787
+
788
+ it("collects as a number in a Form", async () => {
789
+ const submit = vi.fn();
790
+ const container = await mount(
791
+ {
792
+ $type: "Form",
793
+ $action: { type: "submit" },
794
+ children: [
795
+ {
796
+ $type: "Slider",
797
+ name: "volume",
798
+ min: 0,
799
+ max: 10,
800
+ defaultValue: 3,
801
+ },
802
+ { $type: "Button", label: "Save", submit: true },
803
+ ],
804
+ },
805
+ { submit },
806
+ );
807
+
808
+ await act(async () => container.querySelector("button")!.click());
809
+
810
+ expect(submit).toHaveBeenCalledWith({
811
+ payload: { type: "submit", $input: { volume: 3 } },
812
+ });
813
+ });
814
+
815
+ it("restores an answered numeric value", () => {
816
+ function Slider() {
817
+ return defaultGenerativeUILibrary.Slider!.render({
818
+ $status: "done",
819
+ name: "volume",
820
+ min: 0,
821
+ max: 10,
822
+ defaultValue: 3,
823
+ });
824
+ }
825
+
826
+ const markup = renderToString(
827
+ <AnsweredValuesProvider values={{ volume: 7 }}>
828
+ <Slider />
829
+ </AnsweredValuesProvider>,
830
+ );
831
+
832
+ expect(markup).toContain('value="7"');
833
+ });
834
+ });
835
+
836
+ describe("Checkbox and option descriptions", () => {
837
+ it("renders a switch role and preserves its boolean form value", async () => {
838
+ const submit = vi.fn();
839
+ const container = await mount(
840
+ {
841
+ $type: "Form",
842
+ $action: { type: "submit" },
843
+ children: [
844
+ {
845
+ $type: "Checkbox",
846
+ name: "enabled",
847
+ label: "Enabled",
848
+ variant: "switch",
849
+ defaultChecked: true,
850
+ },
851
+ { $type: "Button", label: "Save", submit: true },
852
+ ],
853
+ },
854
+ { submit },
855
+ );
856
+
857
+ expect(container.querySelector('input[role="switch"]')).not.toBeNull();
858
+ await act(async () => container.querySelector("button")!.click());
859
+ expect(submit).toHaveBeenCalledWith({
860
+ payload: { type: "submit", $input: { enabled: true } },
861
+ });
862
+ });
863
+
864
+ it.each(["RadioGroup", "CheckboxGroup"])(
865
+ "renders an option description for %s",
866
+ async ($type) => {
867
+ const container = await mount(
868
+ {
869
+ $type,
870
+ options: [
871
+ {
872
+ label: "Free",
873
+ description: "For personal projects",
874
+ value: "free",
875
+ },
876
+ ],
877
+ },
878
+ {},
879
+ );
880
+
881
+ expect(
882
+ container.querySelector('[data-aui="option-description"]')?.textContent,
883
+ ).toBe("For personal projects");
884
+ },
885
+ );
886
+ });
887
+
888
+ describe("$field references", () => {
889
+ it("resolve from the enclosing form, or else the generative UI root, at the moment the action fires", async () => {
890
+ const save = vi.fn();
891
+ const container = await mount(
892
+ {
893
+ $type: "Col",
894
+ children: [
895
+ { $type: "Input", name: "title", defaultValue: "Draft" },
896
+ {
897
+ $type: "Select",
898
+ name: "size",
899
+ options: [
900
+ { label: "Medium", value: "m" },
901
+ { label: "Large", value: "l" },
902
+ ],
903
+ defaultValue: "l",
904
+ },
905
+ {
906
+ $type: "Form",
907
+ $action: { type: "submit" },
908
+ children: [
909
+ { $type: "Input", name: "note" },
910
+ {
911
+ $type: "Button",
912
+ label: "Save note",
913
+ $action: {
914
+ type: "save",
915
+ note: { $field: "note" },
916
+ title: { $field: "title" },
917
+ },
918
+ },
919
+ ],
920
+ },
921
+ {
922
+ $type: "Button",
923
+ label: "Save",
924
+ $action: {
925
+ type: "save",
926
+ fields: [{ $field: "title" }, { $field: "missing" }],
927
+ size: { $field: "size" },
928
+ note: { $field: "note" },
929
+ missing: { $field: "missing" },
930
+ },
931
+ },
932
+ ],
933
+ },
934
+ { save },
935
+ );
936
+ const [title, note] = container.querySelectorAll("input");
937
+ const [saveNote, saveAll] = container.querySelectorAll("button");
938
+
939
+ expect(title!.value).toBe("Draft");
940
+ expect(container.querySelector("select")!.value).toBe("l");
941
+ title!.value = "Final";
942
+ note!.value = "Ship it";
943
+ await act(async () => saveNote!.click());
944
+ await act(async () => saveAll!.click());
945
+
946
+ expect(save.mock.calls.map(([{ payload }]) => payload)).toEqual([
947
+ { type: "save", note: "Ship it" },
948
+ { type: "save", fields: ["Final"], size: "l", note: "Ship it" },
949
+ ]);
950
+ });
951
+ it("never read a control inside a nested generative UI root", async () => {
952
+ const save = vi.fn();
953
+ const container = await mount(
954
+ {
955
+ $type: "Col",
956
+ children: [
957
+ { $type: "Input", name: "note", defaultValue: "outer" },
958
+ {
959
+ $type: "Button",
960
+ label: "Save",
961
+ $action: { type: "save", note: { $field: "note" } },
962
+ },
963
+ ],
964
+ },
965
+ { save },
966
+ );
967
+ const nested = document.createElement("div");
968
+ nested.setAttribute("data-aui", "root");
969
+ nested.innerHTML = '<input name="note" value="inner">';
970
+ container.querySelector('[data-aui="root"]')!.append(nested);
971
+
972
+ await act(async () => container.querySelector("button")!.click());
973
+
974
+ expect(save).toHaveBeenCalledWith({
975
+ payload: { type: "save", note: "outer" },
976
+ });
977
+ });
978
+
979
+ it("never read a host form around the generative UI root", async () => {
980
+ const save = vi.fn();
981
+ const hostForm = document.createElement("form");
982
+ hostForm.innerHTML = '<input name="secret" value="host">';
983
+ document.body.append(hostForm);
984
+ const container = await mount(
985
+ {
986
+ $type: "Button",
987
+ label: "Save",
988
+ $action: { type: "save", secret: { $field: "secret" } },
989
+ },
990
+ { save },
991
+ hostForm,
992
+ );
993
+
994
+ await act(async () => container.querySelector("button")!.click());
995
+
996
+ expect(save).toHaveBeenCalledWith({ payload: { type: "save" } });
997
+ });
998
+ it("read nothing outside a vocabulary form when the tree has no generative UI root", async () => {
999
+ const save = vi.fn();
1000
+ const registry = createActionRegistry({ save });
1001
+ const hostForm = document.createElement("form");
1002
+ hostForm.innerHTML = '<input name="secret" value="host">';
1003
+ const inHost = document.createElement("div");
1004
+ const standalone = document.createElement("div");
1005
+ hostForm.append(inHost);
1006
+ document.body.append(hostForm, standalone);
1007
+ const roots = [createRoot(inHost), createRoot(standalone)];
1008
+ await act(async () => {
1009
+ roots[0]!.render(
1010
+ renderGenerativeUI(
1011
+ {
1012
+ $type: "Button",
1013
+ label: "Save",
1014
+ $action: { type: "save", secret: { $field: "secret" } },
1015
+ },
1016
+ defaultGenerativeUILibrary,
1017
+ { status: "done", dispatch: registry.dispatch },
1018
+ ),
1019
+ );
1020
+ roots[1]!.render(
1021
+ renderGenerativeUI(
1022
+ {
1023
+ $type: "Form",
1024
+ children: [
1025
+ { $type: "Input", name: "note", defaultValue: "kept" },
1026
+ {
1027
+ $type: "Button",
1028
+ label: "Save note",
1029
+ $action: { type: "save", note: { $field: "note" } },
1030
+ },
1031
+ ],
1032
+ },
1033
+ defaultGenerativeUILibrary,
1034
+ { status: "done", dispatch: registry.dispatch },
1035
+ ),
1036
+ );
1037
+ });
1038
+
1039
+ await act(async () => inHost.querySelector("button")!.click());
1040
+ await act(async () => standalone.querySelector("button")!.click());
1041
+ await act(async () => roots.forEach((r) => r.unmount()));
1042
+
1043
+ expect(save.mock.calls.map(([{ payload }]) => payload)).toEqual([
1044
+ { type: "save" },
1045
+ { type: "save", note: "kept" },
1046
+ ]);
1047
+ });
1048
+ });
1049
+
1050
+ describe("A2UI two-way binding", () => {
1051
+ it("renders bound input values and sends the values the user entered as the action context", async () => {
1052
+ const { state } = applyA2uiOperations(new Map(), [
1053
+ { version: "v0.9", createSurface: { surfaceId: "s" } },
1054
+ {
1055
+ version: "v0.9",
1056
+ updateComponents: {
1057
+ surfaceId: "s",
1058
+ components: [
1059
+ {
1060
+ id: "root",
1061
+ component: "Column",
1062
+ children: ["name", "plan", "send"],
1063
+ },
1064
+ {
1065
+ id: "name",
1066
+ component: "TextField",
1067
+ label: "Name",
1068
+ value: { path: "/form/name" },
1069
+ },
1070
+ {
1071
+ id: "plan",
1072
+ component: "ChoicePicker",
1073
+ options: [
1074
+ { label: "Free", value: "free" },
1075
+ { label: "Pro", value: "pro" },
1076
+ ],
1077
+ value: { path: "/form/plan" },
1078
+ },
1079
+ {
1080
+ id: "send",
1081
+ component: "Button",
1082
+ label: "Send",
1083
+ action: {
1084
+ event: {
1085
+ name: "send",
1086
+ context: {
1087
+ name: { path: "/form/name" },
1088
+ form: { path: "/form" },
1089
+ },
1090
+ },
1091
+ },
1092
+ },
1093
+ ],
1094
+ },
1095
+ },
1096
+ {
1097
+ version: "v0.9",
1098
+ updateDataModel: {
1099
+ surfaceId: "s",
1100
+ path: "/",
1101
+ value: { form: { name: "Ada", plan: ["free"], id: 7 } },
1102
+ },
1103
+ },
1104
+ ]);
1105
+ const send = vi.fn();
1106
+ const container = await mount(
1107
+ convertSurfaceToUISpec(state.get("s")!).spec,
1108
+ { "a2ui:action": send },
1109
+ );
1110
+ const name = container.querySelector<HTMLInputElement>(
1111
+ 'input[data-aui="input"]',
1112
+ )!;
1113
+ const [free, pro] = container.querySelectorAll<HTMLInputElement>(
1114
+ 'input[type="radio"]',
1115
+ );
1116
+
1117
+ expect(name.value).toBe("Ada");
1118
+ expect(free!.checked).toBe(true);
1119
+ name.value = "Grace";
1120
+ await act(async () => pro!.click());
1121
+ await act(async () => container.querySelector("button")!.click());
1122
+
1123
+ expect(send).toHaveBeenCalledWith({
1124
+ payload: {
1125
+ type: "a2ui:action",
1126
+ name: "send",
1127
+ surfaceId: "s",
1128
+ sourceComponentId: "send",
1129
+ context: {
1130
+ name: "Grace",
1131
+ form: { name: "Grace", plan: ["pro"], id: 7 },
1132
+ },
1133
+ },
1134
+ });
1135
+ });
1136
+
1137
+ it("sends a userMessage bound to an input as the text the user entered", async () => {
1138
+ const { state } = applyA2uiOperations(new Map(), [
1139
+ {
1140
+ version: "v1.0",
1141
+ createSurface: {
1142
+ surfaceId: "s",
1143
+ dataModel: { note: "Draft" },
1144
+ components: [
1145
+ { id: "root", component: "Column", children: ["note", "send"] },
1146
+ { id: "note", component: "TextField", value: { path: "/note" } },
1147
+ {
1148
+ id: "send",
1149
+ component: "Button",
1150
+ label: "Send",
1151
+ action: {
1152
+ event: { name: "send", userMessage: { path: "/note" } },
1153
+ },
1154
+ },
1155
+ ],
1156
+ },
1157
+ },
1158
+ ]);
1159
+ const send = vi.fn();
1160
+ const container = await mount(
1161
+ convertSurfaceToUISpec(state.get("s")!).spec,
1162
+ { "a2ui:action": send },
1163
+ );
1164
+
1165
+ container.querySelector<HTMLInputElement>(
1166
+ 'input[data-aui="input"]',
1167
+ )!.value = "Ship it";
1168
+ await act(async () => container.querySelector("button")!.click());
1169
+
1170
+ expect(send).toHaveBeenCalledWith({
1171
+ payload: {
1172
+ type: "a2ui:action",
1173
+ name: "send",
1174
+ surfaceId: "s",
1175
+ sourceComponentId: "send",
1176
+ userMessage: "Ship it",
1177
+ },
1178
+ });
1179
+ });
1180
+
1181
+ it("sends the agent's values when an overridden Button dispatches $action itself", async () => {
1182
+ const { state } = applyA2uiOperations(new Map(), [
1183
+ { version: "v0.9", createSurface: { surfaceId: "s" } },
1184
+ {
1185
+ version: "v0.9",
1186
+ updateComponents: {
1187
+ surfaceId: "s",
1188
+ components: [
1189
+ { id: "root", component: "Column", children: ["email", "send"] },
1190
+ {
1191
+ id: "email",
1192
+ component: "TextField",
1193
+ value: { path: "/form/email" },
1194
+ },
1195
+ {
1196
+ id: "send",
1197
+ component: "Button",
1198
+ label: "Send",
1199
+ action: {
1200
+ event: {
1201
+ name: "send",
1202
+ context: {
1203
+ email: { path: "/form/email" },
1204
+ form: { path: "/form" },
1205
+ },
1206
+ },
1207
+ },
1208
+ },
1209
+ ],
1210
+ },
1211
+ },
1212
+ {
1213
+ version: "v0.9",
1214
+ updateDataModel: {
1215
+ surfaceId: "s",
1216
+ path: "/",
1217
+ value: { form: { email: "old@example.com" } },
1218
+ },
1219
+ },
1220
+ ]);
1221
+ const button = defaultGenerativeUILibrary.Button!;
1222
+ const library: GenerativeUILibrary = {
1223
+ ...defaultGenerativeUILibrary,
1224
+ Button: {
1225
+ properties: button.properties,
1226
+ description: button.description,
1227
+ render: ({ label, $action, $dispatch }) => (
1228
+ <button
1229
+ type="button"
1230
+ onClick={() => ($action ? $dispatch?.($action) : undefined)}
1231
+ >
1232
+ {label}
1233
+ </button>
1234
+ ),
1235
+ },
1236
+ };
1237
+ const send = vi.fn();
1238
+ const registry = createActionRegistry({ "a2ui:action": send });
1239
+ const container = document.createElement("div");
1240
+ document.body.append(container);
1241
+ root = createRoot(container);
1242
+ await act(async () => {
1243
+ root!.render(
1244
+ <div data-aui="root">
1245
+ {renderGenerativeUI(
1246
+ convertSurfaceToUISpec(state.get("s")!).spec,
1247
+ library,
1248
+ { status: "done", dispatch: registry.dispatch },
1249
+ )}
1250
+ </div>,
1251
+ );
1252
+ });
1253
+
1254
+ container.querySelector<HTMLInputElement>(
1255
+ 'input[data-aui="input"]',
1256
+ )!.value = "new@example.com";
1257
+ await act(async () => container.querySelector("button")!.click());
1258
+
1259
+ expect(send).toHaveBeenCalledWith({
1260
+ payload: {
1261
+ type: "a2ui:action",
1262
+ name: "send",
1263
+ surfaceId: "s",
1264
+ sourceComponentId: "send",
1265
+ context: {
1266
+ email: "old@example.com",
1267
+ form: { email: "old@example.com" },
1268
+ },
1269
+ },
1270
+ });
1271
+ });
1272
+
1273
+ it("keeps what the user entered until the agent updates the bound value", async () => {
1274
+ const options = [
1275
+ { label: "Free", value: "free" },
1276
+ { label: "Pro", value: "pro" },
1277
+ { label: "Team", value: "team" },
1278
+ ];
1279
+ const surface = (data: Record<string, unknown>) =>
1280
+ convertSurfaceToUISpec(
1281
+ applyA2uiOperations(new Map(), [
1282
+ { version: "v0.9", createSurface: { surfaceId: "s" } },
1283
+ {
1284
+ version: "v0.9",
1285
+ updateComponents: {
1286
+ surfaceId: "s",
1287
+ components: [
1288
+ {
1289
+ id: "root",
1290
+ component: "Column",
1291
+ children: ["name", "agree", "plan", "size", "extras", "day"],
1292
+ },
1293
+ {
1294
+ id: "name",
1295
+ component: "TextField",
1296
+ value: { path: "/name" },
1297
+ },
1298
+ {
1299
+ id: "agree",
1300
+ component: "CheckBox",
1301
+ value: { path: "/agree" },
1302
+ },
1303
+ {
1304
+ id: "plan",
1305
+ component: "ChoicePicker",
1306
+ options,
1307
+ value: { path: "/plan" },
1308
+ },
1309
+ {
1310
+ id: "size",
1311
+ component: "ChoicePicker",
1312
+ displayStyle: "chips",
1313
+ options,
1314
+ value: { path: "/size" },
1315
+ },
1316
+ {
1317
+ id: "extras",
1318
+ component: "ChoicePicker",
1319
+ variant: "multipleSelection",
1320
+ options,
1321
+ value: { path: "/extras" },
1322
+ },
1323
+ {
1324
+ id: "day",
1325
+ component: "DateTimeInput",
1326
+ value: { path: "/day" },
1327
+ },
1328
+ ],
1329
+ },
1330
+ },
1331
+ {
1332
+ version: "v0.9",
1333
+ updateDataModel: { surfaceId: "s", path: "/", value: data },
1334
+ },
1335
+ ]).state.get("s")!,
1336
+ ).spec;
1337
+ const initial = {
1338
+ name: "Ada",
1339
+ agree: false,
1340
+ plan: ["free"],
1341
+ size: ["free"],
1342
+ extras: [],
1343
+ day: "2026-01-02",
1344
+ };
1345
+ const container = await mount(surface(initial), {});
1346
+ const read = () => ({
1347
+ name: container.querySelector<HTMLInputElement>(
1348
+ 'input[data-aui="input"]',
1349
+ )!.value,
1350
+ agree: container.querySelector<HTMLInputElement>(
1351
+ 'label[data-aui="checkbox"] input',
1352
+ )!.checked,
1353
+ plan: container.querySelector<HTMLInputElement>(
1354
+ 'input[type="radio"]:checked',
1355
+ )?.value,
1356
+ size: container.querySelector<HTMLInputElement>(
1357
+ 'input[data-aui-field-name="/size"]:checked',
1358
+ )?.value,
1359
+ extras: [
1360
+ ...container.querySelectorAll<HTMLInputElement>(
1361
+ 'fieldset[data-aui="checkboxgroup"] input:checked',
1362
+ ),
1363
+ ].map((input) => input.value),
1364
+ day: container.querySelector<HTMLInputElement>('input[type="date"]')!
1365
+ .value,
1366
+ });
1367
+ const edited = {
1368
+ name: "Grace",
1369
+ agree: false,
1370
+ plan: "pro",
1371
+ size: "pro",
1372
+ extras: ["pro"],
1373
+ day: "2026-03-04",
1374
+ };
1375
+ container.querySelector<HTMLInputElement>(
1376
+ 'input[data-aui="input"]',
1377
+ )!.value = edited.name;
1378
+ container.querySelectorAll<HTMLInputElement>(
1379
+ 'input[type="radio"]',
1380
+ )[1]!.checked = true;
1381
+ container.querySelector<HTMLInputElement>(
1382
+ 'input[data-aui-field-name="/size"][value="pro"]',
1383
+ )!.checked = true;
1384
+ container.querySelectorAll<HTMLInputElement>(
1385
+ 'fieldset[data-aui="checkboxgroup"] input',
1386
+ )[1]!.checked = true;
1387
+ container.querySelector<HTMLInputElement>('input[type="date"]')!.value =
1388
+ edited.day;
1389
+
1390
+ await act(async () => root!.render(view(surface(initial))));
1391
+ expect(read()).toEqual(edited);
1392
+
1393
+ await act(async () =>
1394
+ root!.render(
1395
+ view(
1396
+ surface({
1397
+ name: "Hopper",
1398
+ agree: true,
1399
+ plan: ["team"],
1400
+ size: ["team"],
1401
+ extras: ["team"],
1402
+ day: "2026-05-06",
1403
+ }),
1404
+ ),
1405
+ ),
1406
+ );
1407
+ expect(read()).toEqual({
1408
+ name: "Hopper",
1409
+ agree: true,
1410
+ plan: "team",
1411
+ size: "team",
1412
+ extras: ["team"],
1413
+ day: "2026-05-06",
1414
+ });
1415
+ });
1416
+ });
1417
+
1418
+ describe.each([
1419
+ { version: "v0.9", live: false },
1420
+ { version: "v0.9", live: true },
1421
+ { version: "v0.9.1", live: false },
1422
+ { version: "v0.9.1", live: true },
1423
+ ])("A2UI input contracts ($version, live: $live)", ({ version, live }) => {
1424
+ const mountSurface = async (
1425
+ field: Record<string, unknown>,
1426
+ initial: unknown,
1427
+ previewPath = "/form/value",
1428
+ ) => {
1429
+ const send = vi.fn();
1430
+ const registry = createActionRegistry({ "a2ui:action": send });
1431
+ const container = document.createElement("div");
1432
+ document.body.append(container);
1433
+ root = createRoot(container);
1434
+ const render = async (value: unknown) => {
1435
+ const operations = [
1436
+ { version, createSurface: { surfaceId: "s" } },
1437
+ {
1438
+ version,
1439
+ updateComponents: {
1440
+ surfaceId: "s",
1441
+ components: [
1442
+ {
1443
+ id: "root",
1444
+ component: "Column",
1445
+ children: ["field", "preview", "send"],
1446
+ },
1447
+ {
1448
+ id: "field",
1449
+ label: "Value",
1450
+ ...field,
1451
+ value: { path: "/form/value" },
1452
+ },
1453
+ { id: "preview", component: "Text", text: { path: previewPath } },
1454
+ {
1455
+ id: "send",
1456
+ component: "Button",
1457
+ label: "Send",
1458
+ action: {
1459
+ event: {
1460
+ name: "send",
1461
+ context: {
1462
+ value: { path: "/form/value" },
1463
+ form: { path: "/form" },
1464
+ },
1465
+ },
1466
+ },
1467
+ },
1468
+ ],
1469
+ },
1470
+ },
1471
+ {
1472
+ version,
1473
+ updateDataModel: {
1474
+ surfaceId: "s",
1475
+ path: "/",
1476
+ value: { form: value === undefined ? {} : { value } },
1477
+ },
1478
+ },
1479
+ ];
1480
+ const surface = applyA2uiOperations(new Map(), operations).state.get(
1481
+ "s",
1482
+ )!;
1483
+ const converted = convertSurfaceToUISpec(surface);
1484
+ expect(converted.warnings).toEqual([]);
1485
+ await act(async () =>
1486
+ root!.render(
1487
+ live ? (
1488
+ <div data-aui="root">
1489
+ <A2uiPresentRenderer
1490
+ surfaceId="s"
1491
+ operations={operations}
1492
+ fallback={converted.spec}
1493
+ library={defaultGenerativeUILibrary}
1494
+ status="done"
1495
+ dispatch={registry.dispatch}
1496
+ />
1497
+ </div>
1498
+ ) : (
1499
+ view(converted.spec, registry.dispatch)
1500
+ ),
1501
+ ),
1502
+ );
1503
+ };
1504
+ await render(initial);
1505
+ const submit = async (value: unknown) => {
1506
+ await act(async () => container.querySelector("button")!.click());
1507
+ expect(send).toHaveBeenLastCalledWith({
1508
+ payload: {
1509
+ type: "a2ui:action",
1510
+ name: "send",
1511
+ surfaceId: "s",
1512
+ sourceComponentId: "send",
1513
+ context: { value, form: { value } },
1514
+ },
1515
+ });
1516
+ };
1517
+ return { container, render, submit };
1518
+ };
1519
+
1520
+ it.each([
1521
+ {
1522
+ title: "obscured text",
1523
+ field: { component: "TextField", variant: "obscured" },
1524
+ initial: "s3cret",
1525
+ edited: " new secret ",
1526
+ type: "password",
1527
+ },
1528
+ {
1529
+ title: "number text",
1530
+ field: { component: "TextField", variant: "number" },
1531
+ initial: "3",
1532
+ edited: "12.5",
1533
+ type: "number",
1534
+ },
1535
+ {
1536
+ title: "date and time",
1537
+ field: { component: "DateTimeInput", enableDate: true, enableTime: true },
1538
+ initial: "2025-12-15T17:00:00Z",
1539
+ edited: "2025-12-16T08:30:45+02:00",
1540
+ type: "text",
1541
+ },
1542
+ {
1543
+ title: "timestamp in date mode",
1544
+ field: { component: "DateTimeInput", enableDate: true },
1545
+ initial: "2025-12-15T17:00:00Z",
1546
+ edited: "2025-12-16T08:30:00Z",
1547
+ type: "text",
1548
+ },
1549
+ {
1550
+ title: "time only",
1551
+ field: { component: "DateTimeInput", enableTime: true },
1552
+ initial: "17:00:00",
1553
+ edited: "08:30:45",
1554
+ type: "text",
1555
+ },
1556
+ {
1557
+ title: "omitted temporal flags",
1558
+ field: { component: "DateTimeInput" },
1559
+ initial: "2025-12-15",
1560
+ edited: "2025-12-16",
1561
+ type: "date",
1562
+ },
1563
+ {
1564
+ title: "disabled temporal flags",
1565
+ field: {
1566
+ component: "DateTimeInput",
1567
+ enableDate: false,
1568
+ enableTime: false,
1569
+ },
1570
+ initial: "",
1571
+ edited: "2025-12-16",
1572
+ type: "date",
1573
+ },
1574
+ {
1575
+ title: "date only",
1576
+ field: { component: "DateTimeInput", enableDate: true },
1577
+ initial: "2025-12-15",
1578
+ edited: "2025-12-16",
1579
+ type: "date",
1580
+ },
1581
+ {
1582
+ title: "unresolved obscured text",
1583
+ field: { component: "TextField", variant: "obscured" },
1584
+ initial: undefined,
1585
+ edited: "s3cret",
1586
+ type: "password",
1587
+ },
1588
+ {
1589
+ title: "unresolved number text",
1590
+ field: { component: "TextField", variant: "number" },
1591
+ initial: undefined,
1592
+ edited: "12.5",
1593
+ type: "number",
1594
+ },
1595
+ {
1596
+ title: "unresolved date and time",
1597
+ field: { component: "DateTimeInput", enableDate: true, enableTime: true },
1598
+ initial: undefined,
1599
+ edited: "2025-12-15T17:00:00Z",
1600
+ type: "text",
1601
+ },
1602
+ ])(
1603
+ "preserves untouched, edited, and cleared $title values",
1604
+ async ({ field, initial, edited, type }) => {
1605
+ const { container, render, submit } = await mountSurface(field, initial);
1606
+ const input = () =>
1607
+ container.querySelector<HTMLInputElement>('input[name="/form/value"]')!;
1608
+ const preview = () =>
1609
+ container.querySelector('[data-aui="markdown"]')?.textContent ?? "";
1610
+ const edit = async (value: string) => {
1611
+ await act(async () => {
1612
+ const control = input();
1613
+ Object.getOwnPropertyDescriptor(
1614
+ HTMLInputElement.prototype,
1615
+ "value",
1616
+ )!.set!.call(control, value);
1617
+ control.dispatchEvent(new Event("input", { bubbles: true }));
1618
+ });
1619
+ };
1620
+
1621
+ expect(input().type).toBe(type);
1622
+ expect(input().getAttribute("aria-label")).toBe("Value");
1623
+ expect(input().value).toBe(initial ?? "");
1624
+ if (type === "number") expect(input().step).toBe("any");
1625
+ await submit(initial ?? "");
1626
+
1627
+ await edit(edited);
1628
+ expect(input().value).toBe(edited);
1629
+ if (type === "number") expect(input().validity.stepMismatch).toBe(false);
1630
+ if (live) expect(preview()).toBe(edited);
1631
+ await submit(edited);
1632
+
1633
+ await edit("");
1634
+ expect(input().type).toBe(type);
1635
+ expect(input().value).toBe("");
1636
+ if (live) expect(preview()).toBe("");
1637
+ await submit("");
1638
+
1639
+ await render(initial);
1640
+ expect(input().value).toBe("");
1641
+ await submit("");
1642
+ await render(edited);
1643
+ expect(input().value).toBe(edited);
1644
+ expect(preview()).toBe(edited);
1645
+ await submit(edited);
1646
+ },
1647
+ );
1648
+
1649
+ it.each([undefined, "Pick one"])(
1650
+ "keeps chips empty until selected with placeholder %s",
1651
+ async (placeholder) => {
1652
+ const { container, render, submit } = await mountSurface(
1653
+ {
1654
+ component: "ChoicePicker",
1655
+ displayStyle: "chips",
1656
+ placeholder,
1657
+ options: [
1658
+ { label: "A", value: "a" },
1659
+ { label: "B", value: "b" },
1660
+ ],
1661
+ },
1662
+ [],
1663
+ "/form/value/0",
1664
+ );
1665
+ const radios = () => [
1666
+ ...container.querySelectorAll<HTMLInputElement>('input[type="radio"]'),
1667
+ ];
1668
+ const selected = () =>
1669
+ radios()
1670
+ .filter((input) => input.checked)
1671
+ .map((input) => input.value);
1672
+
1673
+ await submit([]);
1674
+ expect(container.querySelector("select")).toBeNull();
1675
+ expect(radios()).toHaveLength(2);
1676
+ expect(
1677
+ container.querySelector("fieldset")?.getAttribute("aria-label"),
1678
+ ).toBe("Value");
1679
+ expect(selected()).toEqual([]);
1680
+
1681
+ await act(async () => radios()[1]!.click());
1682
+ expect(selected()).toEqual(["b"]);
1683
+ if (live)
1684
+ expect(
1685
+ container.querySelector('[data-aui="markdown"]')?.textContent,
1686
+ ).toBe("b");
1687
+ await submit(["b"]);
1688
+ await render([]);
1689
+ expect(selected()).toEqual(["b"]);
1690
+ await submit(["b"]);
1691
+ await render(["a"]);
1692
+ expect(selected()).toEqual(["a"]);
1693
+ await submit(["a"]);
1694
+ await render([]);
1695
+ expect(selected()).toEqual([]);
1696
+ await submit([]);
1697
+ },
1698
+ );
1699
+ });