@assistant-ui/react-generative-ui 0.0.23 → 0.1.0

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 (338) hide show
  1. package/README.md +2 -0
  2. package/dist/a2ui.d.ts +1 -4
  3. package/dist/a2ui.d.ts.map +1 -1
  4. package/dist/a2ui.js +1 -4
  5. package/dist/index.d.ts +1 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -10
  8. package/dist/index.server.d.ts +1 -2
  9. package/dist/index.server.d.ts.map +1 -1
  10. package/dist/index.server.js +1 -10
  11. package/dist/ir.d.ts +1 -111
  12. package/dist/ir.d.ts.map +1 -1
  13. package/dist/ir.js +1 -175
  14. package/dist/slack.d.ts +1 -4
  15. package/dist/slack.d.ts.map +1 -1
  16. package/dist/slack.js +1 -4
  17. package/dist/teams.d.ts +1 -4
  18. package/dist/teams.d.ts.map +1 -1
  19. package/dist/teams.js +1 -4
  20. package/package.json +5 -5
  21. package/src/a2ui.ts +1 -22
  22. package/src/index.server.ts +1 -2
  23. package/src/index.test.ts +75 -0
  24. package/src/index.ts +1 -2
  25. package/src/ir.ts +1 -279
  26. package/src/slack.ts +1 -39
  27. package/src/teams.ts +1 -35
  28. package/dist/JSONGenerativeUI.client.d.ts +0 -28
  29. package/dist/JSONGenerativeUI.client.d.ts.map +0 -1
  30. package/dist/JSONGenerativeUI.client.js +0 -101
  31. package/dist/JSONGenerativeUI.client.js.map +0 -1
  32. package/dist/JSONGenerativeUI.server.d.ts +0 -21
  33. package/dist/JSONGenerativeUI.server.d.ts.map +0 -1
  34. package/dist/JSONGenerativeUI.server.js +0 -32
  35. package/dist/JSONGenerativeUI.server.js.map +0 -1
  36. package/dist/JSONGenerativeUI.shared.d.ts +0 -63
  37. package/dist/JSONGenerativeUI.shared.d.ts.map +0 -1
  38. package/dist/JSONGenerativeUI.shared.js +0 -31
  39. package/dist/JSONGenerativeUI.shared.js.map +0 -1
  40. package/dist/RadioGroupScope.d.ts +0 -6
  41. package/dist/RadioGroupScope.d.ts.map +0 -1
  42. package/dist/RadioGroupScope.js +0 -25
  43. package/dist/RadioGroupScope.js.map +0 -1
  44. package/dist/a2ui/BindingContext.d.ts +0 -11
  45. package/dist/a2ui/BindingContext.d.ts.map +0 -1
  46. package/dist/a2ui/BindingContext.js +0 -12
  47. package/dist/a2ui/BindingContext.js.map +0 -1
  48. package/dist/a2ui/PresentRenderer.d.ts +0 -11
  49. package/dist/a2ui/PresentRenderer.d.ts.map +0 -1
  50. package/dist/a2ui/PresentRenderer.js +0 -39
  51. package/dist/a2ui/PresentRenderer.js.map +0 -1
  52. package/dist/a2ui/convert.d.ts +0 -15
  53. package/dist/a2ui/convert.d.ts.map +0 -1
  54. package/dist/a2ui/convert.js +0 -728
  55. package/dist/a2ui/convert.js.map +0 -1
  56. package/dist/a2ui/dataModel.d.ts +0 -9
  57. package/dist/a2ui/dataModel.d.ts.map +0 -1
  58. package/dist/a2ui/dataModel.js +0 -44
  59. package/dist/a2ui/dataModel.js.map +0 -1
  60. package/dist/a2ui/reducer.d.ts +0 -8
  61. package/dist/a2ui/reducer.d.ts.map +0 -1
  62. package/dist/a2ui/reducer.js +0 -242
  63. package/dist/a2ui/reducer.js.map +0 -1
  64. package/dist/a2ui/snapshot.d.ts +0 -3
  65. package/dist/a2ui/snapshot.d.ts.map +0 -1
  66. package/dist/a2ui/snapshot.js +0 -33
  67. package/dist/a2ui/snapshot.js.map +0 -1
  68. package/dist/a2ui/types.d.ts +0 -88
  69. package/dist/a2ui/types.d.ts.map +0 -1
  70. package/dist/a2ui/types.js +0 -6
  71. package/dist/a2ui/types.js.map +0 -1
  72. package/dist/a2ui/valueFunctions.d.ts +0 -16
  73. package/dist/a2ui/valueFunctions.d.ts.map +0 -1
  74. package/dist/a2ui/valueFunctions.js +0 -332
  75. package/dist/a2ui/valueFunctions.js.map +0 -1
  76. package/dist/actionRegistry.d.ts +0 -29
  77. package/dist/actionRegistry.d.ts.map +0 -1
  78. package/dist/actionRegistry.js +0 -41
  79. package/dist/actionRegistry.js.map +0 -1
  80. package/dist/answeredValues.d.ts +0 -16
  81. package/dist/answeredValues.d.ts.map +0 -1
  82. package/dist/answeredValues.js +0 -37
  83. package/dist/answeredValues.js.map +0 -1
  84. package/dist/buildPresentParameters.d.ts +0 -21
  85. package/dist/buildPresentParameters.d.ts.map +0 -1
  86. package/dist/buildPresentParameters.js +0 -95
  87. package/dist/buildPresentParameters.js.map +0 -1
  88. package/dist/constants.d.ts +0 -15
  89. package/dist/constants.d.ts.map +0 -1
  90. package/dist/constants.js +0 -19
  91. package/dist/constants.js.map +0 -1
  92. package/dist/convert/boundSpec.d.ts +0 -48
  93. package/dist/convert/boundSpec.d.ts.map +0 -1
  94. package/dist/convert/boundSpec.js +0 -108
  95. package/dist/convert/boundSpec.js.map +0 -1
  96. package/dist/convert/copyBounded.d.ts +0 -25
  97. package/dist/convert/copyBounded.d.ts.map +0 -1
  98. package/dist/convert/copyBounded.js +0 -38
  99. package/dist/convert/copyBounded.js.map +0 -1
  100. package/dist/convert/isElement.d.ts +0 -3
  101. package/dist/convert/isElement.d.ts.map +0 -1
  102. package/dist/convert/isElement.js +0 -6
  103. package/dist/convert/isElement.js.map +0 -1
  104. package/dist/convert/takeRun.d.ts +0 -6
  105. package/dist/convert/takeRun.d.ts.map +0 -1
  106. package/dist/convert/takeRun.js +0 -20
  107. package/dist/convert/takeRun.js.map +0 -1
  108. package/dist/defineGenerativeComponents.d.ts +0 -29
  109. package/dist/defineGenerativeComponents.d.ts.map +0 -1
  110. package/dist/defineGenerativeComponents.js +0 -34
  111. package/dist/defineGenerativeComponents.js.map +0 -1
  112. package/dist/fieldReferences.d.ts +0 -9
  113. package/dist/fieldReferences.d.ts.map +0 -1
  114. package/dist/fieldReferences.js +0 -53
  115. package/dist/fieldReferences.js.map +0 -1
  116. package/dist/generativeUIToJSX.d.ts +0 -18
  117. package/dist/generativeUIToJSX.d.ts.map +0 -1
  118. package/dist/generativeUIToJSX.js +0 -76
  119. package/dist/generativeUIToJSX.js.map +0 -1
  120. package/dist/index.shared.d.ts +0 -15
  121. package/dist/index.shared.d.ts.map +0 -1
  122. package/dist/index.shared.js +0 -9
  123. package/dist/ir.js.map +0 -1
  124. package/dist/renderGenerativeUI.d.ts +0 -14
  125. package/dist/renderGenerativeUI.d.ts.map +0 -1
  126. package/dist/renderGenerativeUI.js +0 -83
  127. package/dist/renderGenerativeUI.js.map +0 -1
  128. package/dist/scopeSchema.d.ts +0 -6
  129. package/dist/scopeSchema.d.ts.map +0 -1
  130. package/dist/scopeSchema.js +0 -56
  131. package/dist/scopeSchema.js.map +0 -1
  132. package/dist/slack/constants.d.ts +0 -93
  133. package/dist/slack/constants.d.ts.map +0 -1
  134. package/dist/slack/constants.js +0 -146
  135. package/dist/slack/constants.js.map +0 -1
  136. package/dist/slack/decodeBlockAction.d.ts +0 -10
  137. package/dist/slack/decodeBlockAction.d.ts.map +0 -1
  138. package/dist/slack/decodeBlockAction.js +0 -129
  139. package/dist/slack/decodeBlockAction.js.map +0 -1
  140. package/dist/slack/fromSlackBlocks.d.ts +0 -27
  141. package/dist/slack/fromSlackBlocks.d.ts.map +0 -1
  142. package/dist/slack/fromSlackBlocks.js +0 -483
  143. package/dist/slack/fromSlackBlocks.js.map +0 -1
  144. package/dist/slack/toSlackBlocks.d.ts +0 -4
  145. package/dist/slack/toSlackBlocks.d.ts.map +0 -1
  146. package/dist/slack/toSlackBlocks.js +0 -901
  147. package/dist/slack/toSlackBlocks.js.map +0 -1
  148. package/dist/slack/types.d.ts +0 -204
  149. package/dist/slack/types.d.ts.map +0 -1
  150. package/dist/slack/types.js +0 -0
  151. package/dist/teams/constants.d.ts +0 -34
  152. package/dist/teams/constants.d.ts.map +0 -1
  153. package/dist/teams/constants.js +0 -68
  154. package/dist/teams/constants.js.map +0 -1
  155. package/dist/teams/decodeSubmitData.d.ts +0 -26
  156. package/dist/teams/decodeSubmitData.d.ts.map +0 -1
  157. package/dist/teams/decodeSubmitData.js +0 -70
  158. package/dist/teams/decodeSubmitData.js.map +0 -1
  159. package/dist/teams/temporalId.d.ts +0 -5
  160. package/dist/teams/temporalId.d.ts.map +0 -1
  161. package/dist/teams/temporalId.js +0 -28
  162. package/dist/teams/temporalId.js.map +0 -1
  163. package/dist/teams/toAdaptiveCard.d.ts +0 -45
  164. package/dist/teams/toAdaptiveCard.d.ts.map +0 -1
  165. package/dist/teams/toAdaptiveCard.js +0 -642
  166. package/dist/teams/toAdaptiveCard.js.map +0 -1
  167. package/dist/teams/toTeamsAttachments.d.ts +0 -10
  168. package/dist/teams/toTeamsAttachments.d.ts.map +0 -1
  169. package/dist/teams/toTeamsAttachments.js +0 -99
  170. package/dist/teams/toTeamsAttachments.js.map +0 -1
  171. package/dist/teams/types.d.ts +0 -224
  172. package/dist/teams/types.d.ts.map +0 -1
  173. package/dist/teams/types.js +0 -0
  174. package/dist/temporal.d.ts +0 -33
  175. package/dist/temporal.d.ts.map +0 -1
  176. package/dist/temporal.js +0 -152
  177. package/dist/temporal.js.map +0 -1
  178. package/dist/types.d.ts +0 -99
  179. package/dist/types.d.ts.map +0 -1
  180. package/dist/types.js +0 -0
  181. package/dist/vocabulary/alert.d.ts +0 -32
  182. package/dist/vocabulary/alert.d.ts.map +0 -1
  183. package/dist/vocabulary/alert.js +0 -160
  184. package/dist/vocabulary/alert.js.map +0 -1
  185. package/dist/vocabulary/collectFormValues.d.ts +0 -23
  186. package/dist/vocabulary/collectFormValues.d.ts.map +0 -1
  187. package/dist/vocabulary/collectFormValues.js +0 -44
  188. package/dist/vocabulary/collectFormValues.js.map +0 -1
  189. package/dist/vocabulary/data.d.ts +0 -66
  190. package/dist/vocabulary/data.d.ts.map +0 -1
  191. package/dist/vocabulary/data.js +0 -503
  192. package/dist/vocabulary/data.js.map +0 -1
  193. package/dist/vocabulary/dispatch.d.ts +0 -9
  194. package/dist/vocabulary/dispatch.d.ts.map +0 -1
  195. package/dist/vocabulary/dispatch.js +0 -50
  196. package/dist/vocabulary/dispatch.js.map +0 -1
  197. package/dist/vocabulary/fact.d.ts +0 -19
  198. package/dist/vocabulary/fact.d.ts.map +0 -1
  199. package/dist/vocabulary/fact.js +0 -51
  200. package/dist/vocabulary/fact.js.map +0 -1
  201. package/dist/vocabulary/form.d.ts +0 -11
  202. package/dist/vocabulary/form.d.ts.map +0 -1
  203. package/dist/vocabulary/form.js +0 -23
  204. package/dist/vocabulary/form.js.map +0 -1
  205. package/dist/vocabulary/formatValue.d.ts +0 -7
  206. package/dist/vocabulary/formatValue.d.ts.map +0 -1
  207. package/dist/vocabulary/formatValue.js +0 -76
  208. package/dist/vocabulary/formatValue.js.map +0 -1
  209. package/dist/vocabulary/icon.d.ts +0 -41
  210. package/dist/vocabulary/icon.d.ts.map +0 -1
  211. package/dist/vocabulary/icon.js +0 -272
  212. package/dist/vocabulary/icon.js.map +0 -1
  213. package/dist/vocabulary/index.d.ts +0 -12
  214. package/dist/vocabulary/index.d.ts.map +0 -1
  215. package/dist/vocabulary/index.js +0 -36
  216. package/dist/vocabulary/index.js.map +0 -1
  217. package/dist/vocabulary/interactive.d.ts +0 -230
  218. package/dist/vocabulary/interactive.d.ts.map +0 -1
  219. package/dist/vocabulary/interactive.js +0 -530
  220. package/dist/vocabulary/interactive.js.map +0 -1
  221. package/dist/vocabulary/layout.d.ts +0 -79
  222. package/dist/vocabulary/layout.d.ts.map +0 -1
  223. package/dist/vocabulary/layout.js +0 -139
  224. package/dist/vocabulary/layout.js.map +0 -1
  225. package/dist/vocabulary/list.d.ts +0 -14
  226. package/dist/vocabulary/list.d.ts.map +0 -1
  227. package/dist/vocabulary/list.js +0 -50
  228. package/dist/vocabulary/list.js.map +0 -1
  229. package/dist/vocabulary/media.d.ts +0 -25
  230. package/dist/vocabulary/media.d.ts.map +0 -1
  231. package/dist/vocabulary/media.js +0 -42
  232. package/dist/vocabulary/media.js.map +0 -1
  233. package/dist/vocabulary/text.d.ts +0 -57
  234. package/dist/vocabulary/text.d.ts.map +0 -1
  235. package/dist/vocabulary/text.js +0 -49
  236. package/dist/vocabulary/text.js.map +0 -1
  237. package/dist/vocabulary/toTextContent.d.ts +0 -2
  238. package/dist/vocabulary/toTextContent.d.ts.map +0 -1
  239. package/dist/vocabulary/toTextContent.js +0 -6
  240. package/dist/vocabulary/toTextContent.js.map +0 -1
  241. package/src/JSONGenerativeUI.client.tsx +0 -168
  242. package/src/JSONGenerativeUI.server.tsx +0 -39
  243. package/src/JSONGenerativeUI.shared.ts +0 -90
  244. package/src/JSONGenerativeUI.test.tsx +0 -1298
  245. package/src/RadioGroupScope.server.test.tsx +0 -24
  246. package/src/RadioGroupScope.tsx +0 -34
  247. package/src/a2ui/BindingContext.ts +0 -26
  248. package/src/a2ui/PresentRenderer.tsx +0 -83
  249. package/src/a2ui/convert.test.ts +0 -2923
  250. package/src/a2ui/convert.ts +0 -1207
  251. package/src/a2ui/dataModel.ts +0 -69
  252. package/src/a2ui/reducer.test.ts +0 -422
  253. package/src/a2ui/reducer.ts +0 -363
  254. package/src/a2ui/snapshot.test.ts +0 -52
  255. package/src/a2ui/snapshot.ts +0 -52
  256. package/src/a2ui/types.ts +0 -114
  257. package/src/a2ui/valueFunctions.test.ts +0 -271
  258. package/src/a2ui/valueFunctions.ts +0 -523
  259. package/src/actionRegistry.test.ts +0 -90
  260. package/src/actionRegistry.ts +0 -88
  261. package/src/answeredValues.test.ts +0 -33
  262. package/src/answeredValues.tsx +0 -70
  263. package/src/buildPresentParameters.test.ts +0 -246
  264. package/src/buildPresentParameters.ts +0 -130
  265. package/src/constants.ts +0 -17
  266. package/src/convert/boundSpec.test.ts +0 -192
  267. package/src/convert/boundSpec.ts +0 -134
  268. package/src/convert/copyBounded.test.ts +0 -131
  269. package/src/convert/copyBounded.ts +0 -38
  270. package/src/convert/isElement.ts +0 -6
  271. package/src/convert/takeRun.test.ts +0 -64
  272. package/src/convert/takeRun.ts +0 -19
  273. package/src/defineGenerativeComponents.ts +0 -38
  274. package/src/fieldReferences.test.ts +0 -66
  275. package/src/fieldReferences.ts +0 -83
  276. package/src/generativeUIToJSX.test.ts +0 -268
  277. package/src/generativeUIToJSX.ts +0 -152
  278. package/src/index.server.test.ts +0 -106
  279. package/src/index.shared.ts +0 -61
  280. package/src/ir.test.ts +0 -203
  281. package/src/renderGenerativeUI.test.tsx +0 -373
  282. package/src/renderGenerativeUI.tsx +0 -165
  283. package/src/scopeSchema.test.ts +0 -72
  284. package/src/scopeSchema.ts +0 -71
  285. package/src/slack/a2uiSlack.test.ts +0 -198
  286. package/src/slack/constants.ts +0 -194
  287. package/src/slack/datePicker.test.ts +0 -612
  288. package/src/slack/decodeBlockAction.test.ts +0 -233
  289. package/src/slack/decodeBlockAction.ts +0 -232
  290. package/src/slack/fromSlackBlocks.test.ts +0 -1465
  291. package/src/slack/fromSlackBlocks.ts +0 -789
  292. package/src/slack/toSlackBlocks.test.ts +0 -3065
  293. package/src/slack/toSlackBlocks.ts +0 -1818
  294. package/src/slack/types.ts +0 -264
  295. package/src/teams/constants.ts +0 -93
  296. package/src/teams/decodeSubmitData.test.ts +0 -397
  297. package/src/teams/decodeSubmitData.ts +0 -110
  298. package/src/teams/temporal.test.ts +0 -766
  299. package/src/teams/temporalId.ts +0 -56
  300. package/src/teams/toAdaptiveCard.test.ts +0 -1897
  301. package/src/teams/toAdaptiveCard.ts +0 -1057
  302. package/src/teams/toTeamsAttachments.test.ts +0 -74
  303. package/src/teams/toTeamsAttachments.ts +0 -133
  304. package/src/teams/types.ts +0 -266
  305. package/src/temporal.test.ts +0 -319
  306. package/src/temporal.ts +0 -259
  307. package/src/types.ts +0 -119
  308. package/src/vocabulary/alert.test.tsx +0 -427
  309. package/src/vocabulary/alert.tsx +0 -217
  310. package/src/vocabulary/collectFormValues.test.ts +0 -389
  311. package/src/vocabulary/collectFormValues.ts +0 -116
  312. package/src/vocabulary/data.test.tsx +0 -858
  313. package/src/vocabulary/data.tsx +0 -819
  314. package/src/vocabulary/datepicker.dom.test.tsx +0 -1148
  315. package/src/vocabulary/dispatch.ts +0 -83
  316. package/src/vocabulary/fact.test.tsx +0 -94
  317. package/src/vocabulary/fact.tsx +0 -54
  318. package/src/vocabulary/form.test.tsx +0 -113
  319. package/src/vocabulary/form.tsx +0 -39
  320. package/src/vocabulary/formatValue.test.ts +0 -72
  321. package/src/vocabulary/formatValue.ts +0 -127
  322. package/src/vocabulary/icon.test.tsx +0 -64
  323. package/src/vocabulary/icon.tsx +0 -174
  324. package/src/vocabulary/index.test.ts +0 -65
  325. package/src/vocabulary/index.ts +0 -33
  326. package/src/vocabulary/interactive.dispatch.test.tsx +0 -454
  327. package/src/vocabulary/interactive.dom.test.tsx +0 -1747
  328. package/src/vocabulary/interactive.test.tsx +0 -377
  329. package/src/vocabulary/interactive.tsx +0 -1048
  330. package/src/vocabulary/layout.test.tsx +0 -323
  331. package/src/vocabulary/layout.tsx +0 -247
  332. package/src/vocabulary/list.test.tsx +0 -222
  333. package/src/vocabulary/list.tsx +0 -50
  334. package/src/vocabulary/media.test.tsx +0 -85
  335. package/src/vocabulary/media.tsx +0 -56
  336. package/src/vocabulary/text.test.tsx +0 -102
  337. package/src/vocabulary/text.tsx +0 -60
  338. package/src/vocabulary/toTextContent.ts +0 -2
@@ -1,2923 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import type { UIElement } from "../ir";
3
- import { resolveFieldReferences } from "../fieldReferences";
4
- import { convertSurfaceToUISpec, createLiveSurfaceConverter } from "./convert";
5
- import { applyA2uiOperations } from "./reducer";
6
- import { surfaceToOperations } from "./snapshot";
7
- import type { A2uiSurfaceState } from "./types";
8
-
9
- const surfaceFrom = (
10
- components: readonly Record<string, unknown>[],
11
- dataModel: unknown = {},
12
- ): A2uiSurfaceState => ({
13
- components: new Map(
14
- components.map((component) => [
15
- component["id"] as string,
16
- { ...component },
17
- ]),
18
- ),
19
- dataModel,
20
- });
21
-
22
- describe("convertSurfaceToUISpec", () => {
23
- it("keeps absolute bindings rooted while resolving relative template bindings locally", () => {
24
- const surface = surfaceFrom(
25
- [
26
- {
27
- id: "root",
28
- component: "List",
29
- children: { componentId: "row", path: "/items" },
30
- },
31
- {
32
- id: "row",
33
- component: "Row",
34
- children: ["absolute", "relative", "send", "nested"],
35
- },
36
- {
37
- id: "absolute",
38
- component: "TextField",
39
- value: { path: "/currency" },
40
- },
41
- { id: "relative", component: "TextField", value: { path: "currency" } },
42
- {
43
- id: "send",
44
- component: "Button",
45
- label: "Send",
46
- action: {
47
- event: {
48
- name: "send",
49
- context: {
50
- absolute: { path: "/currency" },
51
- relative: { path: "currency" },
52
- },
53
- },
54
- },
55
- },
56
- {
57
- id: "nested",
58
- component: "List",
59
- children: { componentId: "label", path: "/labels" },
60
- },
61
- { id: "label", component: "Text", text: { path: "name" } },
62
- ],
63
- {
64
- currency: "USD",
65
- labels: [{ name: "Root label" }],
66
- items: [{ currency: "EUR", labels: [{ name: "Wrong label" }] }],
67
- },
68
- );
69
-
70
- const result = convertSurfaceToUISpec(surface);
71
- expect(result.warnings).toEqual([]);
72
- expect(result.spec).toMatchObject({
73
- children: [
74
- {
75
- children: {
76
- children: [
77
- { $type: "Input", name: "/currency", defaultValue: "USD" },
78
- {
79
- $type: "Input",
80
- name: "/items/0/currency",
81
- defaultValue: "EUR",
82
- },
83
- {
84
- $action: {
85
- context: {
86
- absolute: { $field: "/currency", fallback: "USD" },
87
- relative: { $field: "/items/0/currency", fallback: "EUR" },
88
- },
89
- },
90
- },
91
- { children: [{ children: { value: "Root label" } }] },
92
- ],
93
- },
94
- },
95
- ],
96
- });
97
- });
98
-
99
- it("replays a surface with bindings, templates, and custom components", () => {
100
- const initial = applyA2uiOperations(new Map(), [
101
- {
102
- version: "v0.9",
103
- createSurface: { surfaceId: "replay", catalogId: "basic" },
104
- },
105
- {
106
- version: "v0.9",
107
- updateComponents: {
108
- surfaceId: "replay",
109
- components: [
110
- {
111
- id: "root",
112
- component: "Column",
113
- children: ["items", "custom"],
114
- },
115
- {
116
- id: "items",
117
- component: "List",
118
- children: { componentId: "item", path: "/items" },
119
- },
120
- {
121
- id: "item",
122
- component: "Text",
123
- text: { path: "name" },
124
- },
125
- {
126
- id: "custom",
127
- component: "StatusPill",
128
- label: { path: "/status" },
129
- },
130
- ],
131
- },
132
- },
133
- {
134
- version: "v0.9",
135
- updateDataModel: {
136
- surfaceId: "replay",
137
- contents: {
138
- status: "ready",
139
- items: [{ name: "one" }, { name: "two" }],
140
- },
141
- },
142
- },
143
- ]);
144
- const surface = initial.state.get("replay");
145
- expect(surface).toBeDefined();
146
-
147
- const operations = surfaceToOperations(surface!);
148
- const replayed = applyA2uiOperations(
149
- new Map(),
150
- JSON.parse(JSON.stringify(operations)),
151
- );
152
- const replayedSurface = replayed.state.get("replay");
153
-
154
- expect(operations).toEqual([
155
- {
156
- version: "v0.9",
157
- createSurface: { surfaceId: "replay", catalogId: "basic" },
158
- },
159
- expect.objectContaining({
160
- version: "v0.9",
161
- updateComponents: expect.objectContaining({ surfaceId: "replay" }),
162
- }),
163
- {
164
- version: "v0.9",
165
- updateDataModel: expect.objectContaining({
166
- surfaceId: "replay",
167
- path: "/",
168
- }),
169
- },
170
- ]);
171
- expect(replayed.warnings).toEqual([]);
172
- expect(replayedSurface).toMatchObject({
173
- catalogId: "basic",
174
- dataModel: surface!.dataModel,
175
- });
176
- expect([...replayedSurface!.components.entries()]).toEqual([
177
- ...surface!.components.entries(),
178
- ]);
179
- expect(convertSurfaceToUISpec(replayedSurface!)).toEqual(
180
- convertSurfaceToUISpec(surface!),
181
- );
182
- });
183
-
184
- it("converts an operation-built A2UI surface to the exact canonical UISpec", () => {
185
- const result = applyA2uiOperations(new Map(), [
186
- {
187
- version: "v0.9",
188
- createSurface: { surfaceId: "main" },
189
- },
190
- {
191
- version: "v0.9",
192
- updateComponents: {
193
- surfaceId: "main",
194
- components: [
195
- {
196
- id: "root",
197
- component: "Column",
198
- gap: 2,
199
- children: [
200
- "heading",
201
- "body",
202
- "caption",
203
- "image",
204
- "row",
205
- "card",
206
- "divider",
207
- "button",
208
- "field",
209
- "checkbox",
210
- ],
211
- },
212
- {
213
- id: "heading",
214
- component: "Text",
215
- variant: "h1",
216
- text: { path: "/title" },
217
- },
218
- {
219
- id: "body",
220
- component: "Text",
221
- text: { path: "/body" },
222
- },
223
- {
224
- id: "caption",
225
- component: "Text",
226
- variant: "caption",
227
- text: "Fine print",
228
- },
229
- {
230
- id: "image",
231
- component: "Image",
232
- url: { path: "/imageUrl" },
233
- altText: "Preview",
234
- },
235
- {
236
- id: "row",
237
- component: "Row",
238
- gap: 1,
239
- align: "center",
240
- justify: "between",
241
- children: ["rowText"],
242
- },
243
- {
244
- id: "rowText",
245
- component: "Text",
246
- text: "Inside row",
247
- },
248
- {
249
- id: "card",
250
- component: "Card",
251
- title: "Details",
252
- padding: 3,
253
- },
254
- { id: "divider", component: "Divider", flush: true },
255
- {
256
- id: "button",
257
- component: "Button",
258
- label: "Open",
259
- action: {
260
- name: "open",
261
- context: {
262
- ticketId: { path: "/ticketId" },
263
- literal: "kept",
264
- missing: { path: "/missing" },
265
- },
266
- },
267
- },
268
- {
269
- id: "field",
270
- component: "TextField",
271
- label: "Email",
272
- placeholder: "you@example.com",
273
- text: { path: "/form/email" },
274
- },
275
- {
276
- id: "checkbox",
277
- component: "CheckBox",
278
- label: "Accepted",
279
- value: { path: "/form/accepted" },
280
- },
281
- ],
282
- },
283
- },
284
- {
285
- version: "v0.9",
286
- updateDataModel: {
287
- surfaceId: "main",
288
- data: {
289
- title: "Welcome",
290
- body: "**Hello**",
291
- imageUrl: "https://example.com/image.png",
292
- ticketId: "ticket-1",
293
- form: { email: "ada@example.com", accepted: true },
294
- },
295
- },
296
- },
297
- ]);
298
- const surface = result.state.get("main");
299
- expect(surface).toBeDefined();
300
-
301
- const converted = convertSurfaceToUISpec(surface!);
302
-
303
- expect(converted.warnings).toEqual([]);
304
- expect(converted.spec).toEqual({
305
- $type: "Col",
306
- gap: 2,
307
- children: [
308
- { $type: "Header", text: "Welcome" },
309
- { $type: "Markdown", value: "**Hello**" },
310
- { $type: "Caption", value: "Fine print" },
311
- {
312
- $type: "Image",
313
- src: "https://example.com/image.png",
314
- alt: "Preview",
315
- },
316
- {
317
- $type: "Row",
318
- gap: 1,
319
- align: "center",
320
- justify: "between",
321
- children: [{ $type: "Markdown", value: "Inside row" }],
322
- },
323
- { $type: "Card", title: "Details", padding: 3 },
324
- { $type: "Divider", flush: true },
325
- {
326
- $type: "Button",
327
- label: "Open",
328
- $action: {
329
- type: "a2ui:action",
330
- name: "open",
331
- surfaceId: "main",
332
- sourceComponentId: "button",
333
- context: { ticketId: "ticket-1", literal: "kept" },
334
- },
335
- },
336
- {
337
- $type: "Input",
338
- label: "Email",
339
- placeholder: "you@example.com",
340
- name: "/form/email",
341
- defaultValue: "ada@example.com",
342
- },
343
- {
344
- $type: "Checkbox",
345
- label: "Accepted",
346
- name: "/form/accepted",
347
- defaultChecked: true,
348
- },
349
- ],
350
- });
351
- });
352
-
353
- it("maps basic catalog icons, lists, choice pickers, and date inputs", () => {
354
- const surface = surfaceFrom(
355
- [
356
- {
357
- id: "root",
358
- component: "Column",
359
- children: ["icon", "list", "horizontal", "select", "radio", "date"],
360
- },
361
- { id: "icon", component: "Icon", name: "calendar", size: "lg" },
362
- {
363
- id: "list",
364
- component: "List",
365
- children: ["list-item"],
366
- },
367
- { id: "list-item", component: "Text", text: "Vertical item" },
368
- {
369
- id: "horizontal",
370
- component: "List",
371
- direction: "horizontal",
372
- align: "center",
373
- children: ["horizontal-item"],
374
- },
375
- {
376
- id: "horizontal-item",
377
- component: "Text",
378
- text: "Horizontal item",
379
- },
380
- {
381
- id: "select",
382
- component: "ChoicePicker",
383
- label: "Delivery",
384
- variant: "multipleSelection",
385
- value: { path: "/form/delivery" },
386
- options: [
387
- { label: { path: "/labels/express" }, value: "express" },
388
- { label: "Standard", value: "standard" },
389
- ],
390
- },
391
- {
392
- id: "radio",
393
- component: "ChoicePicker",
394
- label: "Plan",
395
- variant: "mutuallyExclusive",
396
- value: { path: "/form/plan" },
397
- options: [
398
- { label: "Free", value: "free" },
399
- { label: "Pro", value: "pro" },
400
- ],
401
- },
402
- {
403
- id: "date",
404
- component: "DateTimeInput",
405
- label: "Start date",
406
- value: { path: "/form/startDate" },
407
- min: "2026-01-01",
408
- max: "2026-12-31",
409
- },
410
- ],
411
- {
412
- labels: { express: "Express" },
413
- form: {
414
- delivery: ["express"],
415
- plan: "pro",
416
- startDate: "2026-06-01",
417
- },
418
- },
419
- );
420
-
421
- expect(convertSurfaceToUISpec(surface)).toEqual({
422
- spec: {
423
- $type: "Col",
424
- children: [
425
- { $type: "Icon", name: "calendar", size: "lg" },
426
- {
427
- $type: "ListView",
428
- children: [
429
- {
430
- $type: "ListViewItem",
431
- children: { $type: "Markdown", value: "Vertical item" },
432
- },
433
- ],
434
- },
435
- {
436
- $type: "Row",
437
- align: "center",
438
- children: [{ $type: "Markdown", value: "Horizontal item" }],
439
- },
440
- {
441
- $type: "CheckboxGroup",
442
- options: [
443
- { label: "Express", value: "express" },
444
- { label: "Standard", value: "standard" },
445
- ],
446
- label: "Delivery",
447
- name: "/form/delivery",
448
- defaultValue: ["express"],
449
- },
450
- {
451
- $type: "RadioGroup",
452
- options: [
453
- { label: "Free", value: "free" },
454
- { label: "Pro", value: "pro" },
455
- ],
456
- label: "Plan",
457
- name: "/form/plan",
458
- defaultValue: "pro",
459
- },
460
- {
461
- $type: "DatePicker",
462
- inputType: "date",
463
- value: "2026-06-01",
464
- min: "2026-01-01",
465
- max: "2026-12-31",
466
- label: "Start date",
467
- name: "/form/startDate",
468
- },
469
- ],
470
- },
471
- warnings: [],
472
- });
473
- });
474
-
475
- it("maps an omitted ChoicePicker variant as mutuallyExclusive and multiple selection chips as a CheckboxGroup", () => {
476
- const surface = surfaceFrom(
477
- [
478
- { id: "root", component: "Column", children: ["plan", "toppings"] },
479
- {
480
- id: "plan",
481
- component: "ChoicePicker",
482
- options: [
483
- { label: "Free", value: "free" },
484
- { label: "Pro", value: "pro" },
485
- ],
486
- value: { path: "/plan" },
487
- },
488
- {
489
- id: "toppings",
490
- component: "ChoicePicker",
491
- variant: "multipleSelection",
492
- displayStyle: "chips",
493
- options: [
494
- { label: "Basil", value: "basil" },
495
- { label: "Olives", value: "olives" },
496
- { label: "Onion", value: "onion" },
497
- ],
498
- value: { path: "/toppings" },
499
- },
500
- ],
501
- { plan: ["pro"], toppings: ["basil", "onion"] },
502
- );
503
-
504
- expect(convertSurfaceToUISpec(surface)).toEqual({
505
- spec: {
506
- $type: "Col",
507
- children: [
508
- {
509
- $type: "RadioGroup",
510
- options: [
511
- { label: "Free", value: "free" },
512
- { label: "Pro", value: "pro" },
513
- ],
514
- name: "/plan",
515
- defaultValue: "pro",
516
- },
517
- {
518
- $type: "CheckboxGroup",
519
- options: [
520
- { label: "Basil", value: "basil" },
521
- { label: "Olives", value: "olives" },
522
- { label: "Onion", value: "onion" },
523
- ],
524
- name: "/toppings",
525
- defaultValue: ["basil", "onion"],
526
- },
527
- ],
528
- },
529
- warnings: [],
530
- });
531
- });
532
-
533
- it("passes a chips ChoicePicker value to RadioGroup as its initial value", () => {
534
- const result = convertSurfaceToUISpec(
535
- surfaceFrom([
536
- {
537
- id: "root",
538
- component: "ChoicePicker",
539
- displayStyle: "chips",
540
- value: ["express"],
541
- options: [{ label: "Express", value: "express" }],
542
- },
543
- ]),
544
- );
545
-
546
- expect(result).toEqual({
547
- spec: {
548
- $type: "RadioGroup",
549
- options: [{ label: "Express", value: "express" }],
550
- defaultValue: "express",
551
- },
552
- warnings: [],
553
- });
554
- });
555
-
556
- it.each([
557
- ["obscured", "password", "s3cret"],
558
- ["obscured", "password", ""],
559
- ["number", "number", "12.5"],
560
- ["number", "number", ""],
561
- ])(
562
- "maps a %s TextField to a %s Input holding %j",
563
- (variant, inputType, value) => {
564
- const result = convertSurfaceToUISpec(
565
- surfaceFrom(
566
- [
567
- { id: "root", component: "Column", children: ["field", "send"] },
568
- {
569
- id: "field",
570
- component: "TextField",
571
- variant,
572
- label: "Value",
573
- value: { path: "/form/value" },
574
- },
575
- {
576
- id: "send",
577
- component: "Button",
578
- action: {
579
- event: { name: "send", context: { form: { path: "/form" } } },
580
- },
581
- },
582
- ],
583
- { form: { value } },
584
- ),
585
- );
586
-
587
- expect(result.warnings).toEqual([]);
588
- expect(result.spec?.children).toEqual([
589
- {
590
- $type: "Input",
591
- inputType,
592
- label: "Value",
593
- name: "/form/value",
594
- defaultValue: value,
595
- },
596
- {
597
- $type: "Button",
598
- $action: {
599
- type: "a2ui:action",
600
- name: "send",
601
- surfaceId: "",
602
- sourceComponentId: "send",
603
- context: {
604
- form: { value: { $field: "/form/value", fallback: value } },
605
- },
606
- },
607
- },
608
- ]);
609
- },
610
- );
611
-
612
- it.each([
613
- { mode: {}, value: "2025-12-15", type: "DatePicker", inputType: "date" },
614
- {
615
- mode: { enableDate: false, enableTime: false },
616
- value: "2025-12-15",
617
- type: "DatePicker",
618
- inputType: "date",
619
- },
620
- {
621
- mode: { enableDate: true },
622
- value: "2025-12-15",
623
- type: "DatePicker",
624
- inputType: "date",
625
- },
626
- {
627
- mode: { enableDate: true, enableTime: false },
628
- value: "2025-12-15",
629
- type: "DatePicker",
630
- inputType: "date",
631
- },
632
- {
633
- mode: { enableDate: true },
634
- value: "",
635
- type: "DatePicker",
636
- inputType: "date",
637
- },
638
- {
639
- mode: { enableDate: true },
640
- value: undefined,
641
- type: "DatePicker",
642
- inputType: "date",
643
- },
644
- { mode: {}, value: undefined, type: "DatePicker", inputType: "date" },
645
- {
646
- mode: {},
647
- value: "2025-12-15T17:00:00Z",
648
- type: "DatePicker",
649
- inputType: "datetime",
650
- },
651
- {
652
- mode: { enableDate: true },
653
- value: "2025-12-15T17:00:00Z",
654
- type: "Input",
655
- },
656
- {
657
- mode: { enableDate: true, enableTime: true },
658
- value: "2025-12-15T17:00:00Z",
659
- type: "DatePicker",
660
- inputType: "datetime",
661
- },
662
- {
663
- mode: { enableDate: true, enableTime: true },
664
- value: "2025-12-15",
665
- type: "Input",
666
- },
667
- {
668
- mode: { enableTime: true },
669
- value: "17:00:00",
670
- type: "DatePicker",
671
- inputType: "time",
672
- },
673
- {
674
- mode: { enableDate: false, enableTime: true },
675
- value: "17:00:00",
676
- type: "DatePicker",
677
- inputType: "time",
678
- },
679
- {
680
- mode: { enableDate: true },
681
- value: "2025-12-15T17:00:00+02:00",
682
- type: "Input",
683
- },
684
- ])(
685
- "maps DateTimeInput $mode holding $value to $type with live field references",
686
- ({ mode, value, type, inputType }) => {
687
- const reference = {
688
- $field: "/form/due",
689
- ...(value !== undefined ? { fallback: value } : {}),
690
- };
691
- const result = convertSurfaceToUISpec(
692
- surfaceFrom(
693
- [
694
- {
695
- id: "root",
696
- component: "Column",
697
- children: ["due", "send", "call"],
698
- },
699
- {
700
- id: "due",
701
- component: "DateTimeInput",
702
- ...mode,
703
- label: "Due",
704
- value: { path: "/form/due" },
705
- min: "2025-01-01",
706
- max: "2025-12-31",
707
- },
708
- {
709
- id: "send",
710
- component: "Button",
711
- action: {
712
- event: {
713
- name: "send",
714
- context: {
715
- due: { path: "/form/due" },
716
- form: { path: "/form" },
717
- },
718
- userMessage: { path: "/form/due" },
719
- },
720
- },
721
- },
722
- {
723
- id: "call",
724
- component: "Button",
725
- action: {
726
- functionCall: {
727
- call: "save",
728
- args: { due: { path: "/form/due" } },
729
- },
730
- },
731
- },
732
- ],
733
- { form: value === undefined ? {} : { due: value } },
734
- ),
735
- );
736
-
737
- const bounds =
738
- inputType === "date"
739
- ? { min: "2025-01-01", max: "2025-12-31" }
740
- : inputType === "datetime"
741
- ? { min: "2025-01-01T00:00", max: "2025-12-31T23:59:59.999" }
742
- : {};
743
- expect(result.warnings).toEqual(
744
- inputType === "time"
745
- ? [
746
- 'A2UI DateTimeInput "min" of "2025-01-01" is not a time value and was dropped.',
747
- 'A2UI DateTimeInput "max" of "2025-12-31" is not a time value and was dropped.',
748
- ]
749
- : [],
750
- );
751
- expect(result.spec?.children).toEqual([
752
- {
753
- $type: type,
754
- ...(inputType !== undefined ? { inputType } : {}),
755
- label: "Due",
756
- name: "/form/due",
757
- ...(value !== undefined
758
- ? { [type === "DatePicker" ? "value" : "defaultValue"]: value }
759
- : {}),
760
- ...bounds,
761
- },
762
- {
763
- $type: "Button",
764
- $action: {
765
- type: "a2ui:action",
766
- name: "send",
767
- surfaceId: "",
768
- sourceComponentId: "send",
769
- context: { due: reference, form: { due: reference } },
770
- userMessage: reference,
771
- },
772
- },
773
- {
774
- $type: "Button",
775
- $action: {
776
- type: "a2ui:functionCall",
777
- call: "save",
778
- surfaceId: "",
779
- sourceComponentId: "call",
780
- args: { due: reference },
781
- },
782
- },
783
- ]);
784
- },
785
- );
786
-
787
- const convertedDateTimeInput = (
788
- value: string | undefined,
789
- mode: Record<string, unknown> = {},
790
- bounds: Record<string, unknown> = {},
791
- warnings: readonly string[] = [],
792
- ) => {
793
- const surface = surfaceFrom(
794
- [
795
- {
796
- id: "root",
797
- component: "DateTimeInput",
798
- ...mode,
799
- ...bounds,
800
- value: { path: "/due" },
801
- },
802
- ],
803
- value === undefined ? {} : { due: value },
804
- );
805
- const result = convertSurfaceToUISpec(surface);
806
- expect(result.warnings).toEqual(warnings);
807
- return result.spec;
808
- };
809
-
810
- it.each([
811
- [{ enableDate: true, enableTime: true }, "2025-12-15T17:00Z", "datetime"],
812
- [{ enableTime: true }, "17:00", "time"],
813
- [{ enableDate: true }, "2025-12-15", "date"],
814
- [{ enableDate: false, enableTime: false }, "2025-12-15", "date"],
815
- [{ enableDate: true, enableTime: true }, "", "datetime"],
816
- ] as const)(
817
- "follows DateTimeInput flags %j for %j",
818
- (mode, value, inputType) => {
819
- expect(convertedDateTimeInput(value, mode)).toEqual({
820
- $type: "DatePicker",
821
- inputType,
822
- value,
823
- name: "/due",
824
- });
825
- },
826
- );
827
-
828
- it.each([
829
- ["2025-12-15", "date"],
830
- ["2025-12-15T17:00", "datetime"],
831
- ["2025-12-15T17:00:30.125+02:00", "datetime"],
832
- ["17:00", "time"],
833
- ["17:00:30", "time"],
834
- ["", "date"],
835
- [undefined, "date"],
836
- ] as const)("infers DateTimeInput type %s from %j", (value, inputType) => {
837
- expect(convertedDateTimeInput(value)).toEqual({
838
- $type: "DatePicker",
839
- inputType,
840
- ...(value !== undefined ? { value } : {}),
841
- name: "/due",
842
- });
843
- });
844
-
845
- it.each([
846
- ["2025-12-15T17:00", { enableDate: true }],
847
- ["17:00", { enableDate: true }],
848
- ["2025-12-15", { enableDate: true, enableTime: true }],
849
- ["2025-12-15", { enableTime: true }],
850
- ["17:00Z", { enableTime: true }],
851
- ["not a date", {}],
852
- ["2025-02-30", {}],
853
- ] as const)(
854
- "keeps incompatible DateTimeInput value %j in a text Input",
855
- (value, mode) => {
856
- expect(convertedDateTimeInput(value, mode)).toEqual({
857
- $type: "Input",
858
- defaultValue: value,
859
- name: "/due",
860
- });
861
- },
862
- );
863
-
864
- it.each([
865
- {
866
- value: "2025-12-15",
867
- mode: { enableDate: true },
868
- bounds: { min: "2025-01-01", max: "17:00" },
869
- inputType: "date",
870
- expected: { min: "2025-01-01" },
871
- warnings: [
872
- 'A2UI DateTimeInput "max" of "17:00" is not a date value and was dropped.',
873
- ],
874
- },
875
- {
876
- value: "17:00",
877
- mode: { enableTime: true },
878
- bounds: { min: "16:00", max: "2025-12-31" },
879
- inputType: "time",
880
- expected: { min: "16:00" },
881
- warnings: [
882
- 'A2UI DateTimeInput "max" of "2025-12-31" is not a time value and was dropped.',
883
- ],
884
- },
885
- {
886
- value: "2025-12-15T17:00Z",
887
- mode: { enableDate: true, enableTime: true },
888
- bounds: { min: "2025-01-01T08:00+02:00", max: "17:00" },
889
- inputType: "datetime",
890
- expected: { min: "2025-01-01T08:00+02:00" },
891
- warnings: [
892
- 'A2UI DateTimeInput "max" of "17:00" is not a datetime value and was dropped.',
893
- ],
894
- },
895
- {
896
- value: "17:00",
897
- mode: { enableTime: true },
898
- bounds: { min: "2025-12-31", max: "18:00" },
899
- inputType: "time",
900
- expected: { max: "18:00" },
901
- warnings: [
902
- 'A2UI DateTimeInput "min" of "2025-12-31" is not a time value and was dropped.',
903
- ],
904
- },
905
- {
906
- value: "2025-12-15T17:00Z",
907
- mode: { enableDate: true, enableTime: true },
908
- bounds: { min: "2025-01-01", max: "2025-12-31" },
909
- inputType: "datetime",
910
- expected: { min: "2025-01-01T00:00", max: "2025-12-31T23:59:59.999" },
911
- warnings: [],
912
- },
913
- ] as const)(
914
- "resolves DateTimeInput bounds $bounds for a $inputType value",
915
- ({ value, mode, bounds, inputType, expected, warnings }) => {
916
- expect(convertedDateTimeInput(value, mode, bounds, warnings)).toEqual({
917
- $type: "DatePicker",
918
- inputType,
919
- value,
920
- ...expected,
921
- name: "/due",
922
- });
923
- },
924
- );
925
-
926
- it("keeps a DateTimeInput control shape tied to the agent model during local edits", () => {
927
- const surface = surfaceFrom(
928
- [{ id: "root", component: "DateTimeInput", value: { path: "/due" } }],
929
- { due: "2025-12-15T17:00Z" },
930
- );
931
- const convert = createLiveSurfaceConverter(surface);
932
- expect(convert(surface.dataModel).spec).toMatchObject({
933
- $type: "DatePicker",
934
- inputType: "datetime",
935
- });
936
- expect(convert({ due: "2025-12-15" }).spec).toMatchObject({
937
- $type: "DatePicker",
938
- inputType: "datetime",
939
- });
940
- });
941
-
942
- it.each([undefined, "mutuallyExclusive"])(
943
- "keeps chips with variant %s list-valued when empty and selected",
944
- (variant) => {
945
- for (const value of [[], ["b"]]) {
946
- const options = [
947
- { label: "A", value: "a" },
948
- { label: "B", value: "b" },
949
- ];
950
- const result = convertSurfaceToUISpec(
951
- surfaceFrom(
952
- [
953
- { id: "root", component: "Column", children: ["choice", "send"] },
954
- {
955
- id: "choice",
956
- component: "ChoicePicker",
957
- variant,
958
- displayStyle: "chips",
959
- options,
960
- value: { path: "/form/choice" },
961
- },
962
- {
963
- id: "send",
964
- component: "Button",
965
- action: {
966
- name: "send",
967
- context: { choice: { path: "/form/choice" } },
968
- },
969
- },
970
- ],
971
- { form: { choice: value } },
972
- ),
973
- );
974
- const reference = {
975
- $field: "/form/choice",
976
- ...(value[0] !== undefined ? { fallback: value[0] } : {}),
977
- };
978
- expect(result.warnings).toEqual([]);
979
- expect(result.spec?.children).toEqual([
980
- {
981
- $type: "RadioGroup",
982
- name: "/form/choice",
983
- options,
984
- ...(value[0] !== undefined ? { defaultValue: value[0] } : {}),
985
- },
986
- {
987
- $type: "Button",
988
- $action: {
989
- type: "a2ui:action",
990
- name: "send",
991
- surfaceId: "",
992
- sourceComponentId: "send",
993
- context: { choice: [reference] },
994
- },
995
- },
996
- ]);
997
- expect(resolveFieldReferences([reference], {})).toEqual(value);
998
- expect(
999
- resolveFieldReferences([reference], { "/form/choice": "b" }),
1000
- ).toEqual(["b"]);
1001
- }
1002
- },
1003
- );
1004
-
1005
- it("drops an unresolvable bound prop without throwing", () => {
1006
- const surface = surfaceFrom([
1007
- {
1008
- id: "root",
1009
- component: "Text",
1010
- text: { path: "/missing" },
1011
- },
1012
- ]);
1013
-
1014
- expect(convertSurfaceToUISpec(surface)).toEqual({
1015
- spec: { $type: "Markdown" },
1016
- warnings: [],
1017
- });
1018
- });
1019
-
1020
- it("evaluates v1.0 formatString values against the data model", () => {
1021
- const { state } = applyA2uiOperations(new Map(), [
1022
- {
1023
- version: "v1.0",
1024
- createSurface: {
1025
- surfaceId: "functions",
1026
- components: [
1027
- {
1028
- id: "root",
1029
- component: "Text",
1030
- text: {
1031
- call: "formatString",
1032
- args: { value: "Hello ${/name}" },
1033
- },
1034
- },
1035
- ],
1036
- dataModel: { name: "Ada" },
1037
- },
1038
- },
1039
- ]);
1040
-
1041
- expect(convertSurfaceToUISpec(state.get("functions")!)).toEqual({
1042
- spec: { $type: "Markdown", value: "Hello Ada" },
1043
- warnings: [],
1044
- });
1045
- });
1046
-
1047
- it("evaluates nested value calls and warns when a value function is unknown", () => {
1048
- const surface = surfaceFrom(
1049
- [
1050
- {
1051
- id: "root",
1052
- component: "Column",
1053
- children: ["message", "enabled", "unknown"],
1054
- },
1055
- {
1056
- id: "message",
1057
- component: "Text",
1058
- text: {
1059
- call: "formatString",
1060
- args: {
1061
- value:
1062
- "${formatNumber(value:${/count}, decimals:0, grouping:false)} items",
1063
- },
1064
- },
1065
- },
1066
- {
1067
- id: "enabled",
1068
- component: "CheckBox",
1069
- label: "Enabled",
1070
- value: { call: "not", args: { value: { path: "/disabled" } } },
1071
- },
1072
- {
1073
- id: "unknown",
1074
- component: "Text",
1075
- text: { call: "customValue", args: { value: "ignored" } },
1076
- },
1077
- ],
1078
- { count: 12, disabled: false },
1079
- );
1080
-
1081
- expect(convertSurfaceToUISpec(surface)).toEqual({
1082
- spec: {
1083
- $type: "Col",
1084
- children: [
1085
- { $type: "Markdown", value: "12 items" },
1086
- {
1087
- $type: "Checkbox",
1088
- label: "Enabled",
1089
- defaultChecked: true,
1090
- },
1091
- { $type: "Markdown" },
1092
- ],
1093
- },
1094
- warnings: [
1095
- 'A2UI function "customValue" is not supported and was skipped.',
1096
- ],
1097
- });
1098
- });
1099
-
1100
- it("maps a bound Slider with the spec's min default and a continuous step", () => {
1101
- const surface = surfaceFrom(
1102
- [
1103
- {
1104
- id: "root",
1105
- component: "Slider",
1106
- label: "Volume",
1107
- max: 1,
1108
- value: { path: "/volume" },
1109
- },
1110
- ],
1111
- { volume: 0.35 },
1112
- );
1113
-
1114
- expect(convertSurfaceToUISpec(surface)).toEqual({
1115
- spec: {
1116
- $type: "Slider",
1117
- min: 0,
1118
- max: 1,
1119
- step: 0.01,
1120
- defaultValue: 0.35,
1121
- label: "Volume",
1122
- name: "/volume",
1123
- },
1124
- warnings: [],
1125
- });
1126
- });
1127
-
1128
- it("divides a Slider range by its steps and keeps integer values exact", () => {
1129
- const stepped = surfaceFrom([
1130
- { id: "root", component: "Slider", max: 100, steps: 4, value: 50 },
1131
- ]);
1132
- const wide = surfaceFrom([
1133
- { id: "root", component: "Slider", min: 0, max: 1000, value: 537 },
1134
- ]);
1135
-
1136
- expect(convertSurfaceToUISpec(stepped).spec).toEqual({
1137
- $type: "Slider",
1138
- min: 0,
1139
- max: 100,
1140
- step: 25,
1141
- defaultValue: 50,
1142
- });
1143
- expect(convertSurfaceToUISpec(wide).spec).toEqual({
1144
- $type: "Slider",
1145
- min: 0,
1146
- max: 1000,
1147
- step: 1,
1148
- defaultValue: 537,
1149
- });
1150
- });
1151
-
1152
- it("sends a Slider's live value to an action bound to its path", () => {
1153
- const surface = surfaceFrom(
1154
- [
1155
- { id: "root", component: "Column", children: ["volume", "save"] },
1156
- {
1157
- id: "volume",
1158
- component: "Slider",
1159
- max: 10,
1160
- value: { path: "/volume" },
1161
- },
1162
- {
1163
- id: "save",
1164
- component: "Button",
1165
- label: "Save",
1166
- action: {
1167
- event: { name: "save", context: { volume: { path: "/volume" } } },
1168
- },
1169
- },
1170
- ],
1171
- { volume: 4 },
1172
- );
1173
-
1174
- const result = convertSurfaceToUISpec(surface);
1175
- expect(result.warnings).toEqual([]);
1176
- expect(result.spec).toMatchObject({
1177
- children: [
1178
- { $type: "Slider", name: "/volume", defaultValue: 4 },
1179
- {
1180
- $action: {
1181
- context: { volume: { $field: "/volume", fallback: 4 } },
1182
- },
1183
- },
1184
- ],
1185
- });
1186
- });
1187
-
1188
- it("skips a Slider without a numeric max", () => {
1189
- const surface = surfaceFrom([
1190
- { id: "root", component: "Slider", value: 3 },
1191
- ]);
1192
-
1193
- expect(convertSurfaceToUISpec(surface).warnings).toEqual([
1194
- 'A2UI component "Slider" could not be mapped and was skipped.',
1195
- ]);
1196
- });
1197
-
1198
- it("warns and omits a malformed formatString template", () => {
1199
- const surface = surfaceFrom([
1200
- {
1201
- id: "root",
1202
- component: "Text",
1203
- text: { call: "formatString", args: { value: "Hi ${name" } },
1204
- },
1205
- ]);
1206
-
1207
- expect(convertSurfaceToUISpec(surface)).toEqual({
1208
- spec: { $type: "Markdown" },
1209
- warnings: [
1210
- "A2UI formatString template is malformed: an interpolation is not closed.",
1211
- ],
1212
- });
1213
- });
1214
-
1215
- it("resolves relative paths in bindings and function arguments against the current item", () => {
1216
- const usd = (value: number) =>
1217
- new Intl.NumberFormat(undefined, {
1218
- style: "currency",
1219
- currency: "USD",
1220
- }).format(value);
1221
- const surface = surfaceFrom(
1222
- [
1223
- { id: "root", component: "Column", children: ["total", "rows"] },
1224
- {
1225
- id: "total",
1226
- component: "Text",
1227
- text: {
1228
- call: "formatCurrency",
1229
- args: { value: { path: "total" }, currency: "USD" },
1230
- },
1231
- },
1232
- {
1233
- id: "rows",
1234
- component: "Column",
1235
- children: { componentId: "row", path: "/items" },
1236
- },
1237
- {
1238
- id: "row",
1239
- component: "Text",
1240
- text: {
1241
- call: "formatString",
1242
- args: {
1243
- value:
1244
- "${name}: ${formatCurrency(value: ${price}, currency: 'USD')}",
1245
- },
1246
- },
1247
- },
1248
- ],
1249
- { total: 12, items: [{ name: "Tea", price: 3 }] },
1250
- );
1251
-
1252
- expect(convertSurfaceToUISpec(surface)).toEqual({
1253
- spec: {
1254
- $type: "Col",
1255
- children: [
1256
- { $type: "Markdown", value: usd(12) },
1257
- {
1258
- $type: "Col",
1259
- children: [{ $type: "Markdown", value: `Tea: ${usd(3)}` }],
1260
- },
1261
- ],
1262
- },
1263
- warnings: [],
1264
- });
1265
- });
1266
-
1267
- it("evaluates values inside actions but leaves a functionCall for the host to run", () => {
1268
- const surface = surfaceFrom(
1269
- [
1270
- { id: "root", component: "Row", children: ["save", "docs"] },
1271
- {
1272
- id: "save",
1273
- component: "Button",
1274
- label: "Save",
1275
- action: {
1276
- event: {
1277
- name: "save",
1278
- context: {
1279
- summary: {
1280
- call: "formatString",
1281
- args: { value: "${/count} items" },
1282
- },
1283
- },
1284
- },
1285
- },
1286
- },
1287
- {
1288
- id: "docs",
1289
- component: "Button",
1290
- label: "Docs",
1291
- action: {
1292
- functionCall: {
1293
- call: "openUrl",
1294
- args: {
1295
- url: {
1296
- call: "formatString",
1297
- args: { value: "https://example.com/${/slug}" },
1298
- },
1299
- },
1300
- },
1301
- },
1302
- },
1303
- ],
1304
- { count: 2, slug: "a2ui" },
1305
- );
1306
-
1307
- expect(convertSurfaceToUISpec(surface)).toEqual({
1308
- spec: {
1309
- $type: "Row",
1310
- children: [
1311
- {
1312
- $type: "Button",
1313
- label: "Save",
1314
- $action: {
1315
- type: "a2ui:action",
1316
- name: "save",
1317
- surfaceId: "",
1318
- sourceComponentId: "save",
1319
- context: { summary: "2 items" },
1320
- },
1321
- },
1322
- {
1323
- $type: "Button",
1324
- label: "Docs",
1325
- $action: {
1326
- type: "a2ui:functionCall",
1327
- call: "openUrl",
1328
- surfaceId: "",
1329
- sourceComponentId: "docs",
1330
- args: { url: "https://example.com/a2ui" },
1331
- },
1332
- },
1333
- ],
1334
- },
1335
- warnings: [],
1336
- });
1337
- });
1338
-
1339
- it("reads a function's arguments entry by entry, even when they look like a binding or a call", () => {
1340
- const surface = surfaceFrom([
1341
- { id: "root", component: "Column", children: ["bound", "nested", "ok"] },
1342
- {
1343
- id: "bound",
1344
- component: "Text",
1345
- text: { call: "formatNumber", args: { path: "/missing" } },
1346
- },
1347
- {
1348
- id: "nested",
1349
- component: "Text",
1350
- text: {
1351
- call: "formatString",
1352
- args: { value: "${formatNumber(call: 'x')}" },
1353
- },
1354
- },
1355
- { id: "ok", component: "Text", text: "still here" },
1356
- ]);
1357
-
1358
- expect(convertSurfaceToUISpec(surface)).toEqual({
1359
- spec: {
1360
- $type: "Col",
1361
- children: [
1362
- { $type: "Markdown", value: "" },
1363
- { $type: "Markdown", value: "" },
1364
- { $type: "Markdown", value: "still here" },
1365
- ],
1366
- },
1367
- warnings: [],
1368
- });
1369
- });
1370
-
1371
- it("keeps a missing operand in and and or instead of dropping it", () => {
1372
- const operands = [{ path: "/agreed" }, { path: "/verified" }];
1373
- const surface = surfaceFrom(
1374
- [
1375
- { id: "root", component: "Column", children: ["all", "any"] },
1376
- {
1377
- id: "all",
1378
- component: "CheckBox",
1379
- label: "All",
1380
- value: { call: "and", args: { values: operands } },
1381
- },
1382
- {
1383
- id: "any",
1384
- component: "CheckBox",
1385
- label: "Any",
1386
- value: { call: "or", args: { values: operands } },
1387
- },
1388
- ],
1389
- { verified: true },
1390
- );
1391
-
1392
- expect(convertSurfaceToUISpec(surface)).toEqual({
1393
- spec: {
1394
- $type: "Col",
1395
- children: [
1396
- { $type: "Checkbox", label: "All", defaultChecked: false },
1397
- { $type: "Checkbox", label: "Any", defaultChecked: true },
1398
- ],
1399
- },
1400
- warnings: [],
1401
- });
1402
- });
1403
-
1404
- it("stops evaluating functions once the evaluation budget is spent", () => {
1405
- const surface = surfaceFrom(
1406
- [
1407
- {
1408
- id: "root",
1409
- component: "Text",
1410
- text: { call: "formatString", args: { value: { path: "/t" } } },
1411
- },
1412
- ],
1413
- { t: "${formatString(value: /t)}".repeat(380) },
1414
- );
1415
-
1416
- const result = convertSurfaceToUISpec(surface);
1417
-
1418
- expect(result.spec).toEqual({ $type: "Markdown", value: "" });
1419
- expect(result.warnings).toEqual([
1420
- "A2UI function nesting cap of 32 was reached.",
1421
- "A2UI function evaluation budget of 20000 was reached.",
1422
- ]);
1423
- });
1424
-
1425
- it("keeps an object with a call key and other fields as data", () => {
1426
- const surface = surfaceFrom([
1427
- {
1428
- id: "root",
1429
- component: "ContactCard",
1430
- phone: { call: "+1 555 0100", label: "Office" },
1431
- },
1432
- ]);
1433
-
1434
- expect(
1435
- convertSurfaceToUISpec(surface, { keepUnknownComponents: true }),
1436
- ).toEqual({
1437
- spec: {
1438
- $type: "ContactCard",
1439
- phone: { call: "+1 555 0100", label: "Office" },
1440
- },
1441
- warnings: [],
1442
- });
1443
- });
1444
-
1445
- it("leaves validation check calls outside value evaluation", () => {
1446
- const surface = surfaceFrom([
1447
- {
1448
- id: "root",
1449
- component: "TextField",
1450
- label: "Name",
1451
- checks: [{ call: "required", args: { value: { path: "/name" } } }],
1452
- },
1453
- ]);
1454
-
1455
- expect(convertSurfaceToUISpec(surface)).toEqual({
1456
- spec: { $type: "Input", label: "Name" },
1457
- warnings: [],
1458
- });
1459
- });
1460
-
1461
- it("preserves prototype-named action context fields", () => {
1462
- const context = JSON.parse(
1463
- '{"__proto__":{"admin":true},"literal":"kept"}',
1464
- ) as Record<string, unknown>;
1465
- const surface = surfaceFrom([
1466
- {
1467
- id: "root",
1468
- component: "Button",
1469
- label: "Run",
1470
- action: { name: "run", context },
1471
- },
1472
- ]);
1473
-
1474
- const result = convertSurfaceToUISpec(surface);
1475
- const convertedContext = (result.spec as UIElement).$action
1476
- ?.context as Record<string, unknown>;
1477
-
1478
- expect(Object.getPrototypeOf(convertedContext)).toBe(Object.prototype);
1479
- expect(Object.hasOwn(convertedContext, "__proto__")).toBe(true);
1480
- expect(convertedContext["__proto__"]).toEqual({ admin: true });
1481
- expect(JSON.stringify(convertedContext)).toBe(
1482
- '{"__proto__":{"admin":true},"literal":"kept"}',
1483
- );
1484
- });
1485
-
1486
- it("does not read component fields through a prototype-named prop", () => {
1487
- const component = JSON.parse(
1488
- '{"id":"root","component":"Text","__proto__":{"text":"injected"}}',
1489
- ) as Record<string, unknown>;
1490
-
1491
- expect(convertSurfaceToUISpec(surfaceFrom([component]))).toEqual({
1492
- spec: { $type: "Markdown" },
1493
- warnings: [],
1494
- });
1495
- });
1496
-
1497
- it("expands template children relative to each bound item", () => {
1498
- const surface = surfaceFrom(
1499
- [
1500
- {
1501
- id: "root",
1502
- component: "List",
1503
- children: { componentId: "item", path: "/items" },
1504
- },
1505
- {
1506
- id: "item",
1507
- component: "Text",
1508
- text: { path: "name" },
1509
- },
1510
- ],
1511
- { items: [{ name: "one" }, { name: "two" }] },
1512
- );
1513
-
1514
- expect(convertSurfaceToUISpec(surface)).toEqual({
1515
- spec: {
1516
- $type: "ListView",
1517
- children: [
1518
- {
1519
- $type: "ListViewItem",
1520
- children: { $type: "Markdown", value: "one" },
1521
- },
1522
- {
1523
- $type: "ListViewItem",
1524
- children: { $type: "Markdown", value: "two" },
1525
- },
1526
- ],
1527
- },
1528
- warnings: [],
1529
- });
1530
- });
1531
-
1532
- it("keeps a Row or Column template in its own container", () => {
1533
- const surface = surfaceFrom(
1534
- [
1535
- { id: "root", component: "Column", children: ["days", "hours"] },
1536
- {
1537
- id: "days",
1538
- component: "Row",
1539
- justify: "spaceBetween",
1540
- children: { componentId: "day", path: "/days" },
1541
- },
1542
- {
1543
- id: "hours",
1544
- component: "Column",
1545
- align: "center",
1546
- children: { componentId: "day", path: "/hours" },
1547
- },
1548
- { id: "day", component: "Text", text: { path: "label" } },
1549
- ],
1550
- {
1551
- days: [{ label: "Tue" }, { label: "Wed" }],
1552
- hours: [{ label: "9am" }],
1553
- },
1554
- );
1555
-
1556
- expect(convertSurfaceToUISpec(surface)).toEqual({
1557
- spec: {
1558
- $type: "Col",
1559
- children: [
1560
- {
1561
- $type: "Row",
1562
- justify: "spaceBetween",
1563
- children: [
1564
- { $type: "Markdown", value: "Tue" },
1565
- { $type: "Markdown", value: "Wed" },
1566
- ],
1567
- },
1568
- {
1569
- $type: "Col",
1570
- align: "center",
1571
- children: [{ $type: "Markdown", value: "9am" }],
1572
- },
1573
- ],
1574
- },
1575
- warnings: [],
1576
- });
1577
- });
1578
-
1579
- it("expands a template on a component without a child list into a ListView", () => {
1580
- const surface = surfaceFrom(
1581
- [
1582
- {
1583
- id: "root",
1584
- component: "Divider",
1585
- children: { componentId: "item", path: "/items" },
1586
- },
1587
- { id: "item", component: "Text", text: { path: "name" } },
1588
- ],
1589
- { items: [{ name: "one" }] },
1590
- );
1591
-
1592
- expect(convertSurfaceToUISpec(surface)).toEqual({
1593
- spec: {
1594
- $type: "ListView",
1595
- children: [
1596
- {
1597
- $type: "ListViewItem",
1598
- children: { $type: "Markdown", value: "one" },
1599
- },
1600
- ],
1601
- },
1602
- warnings: [],
1603
- });
1604
- });
1605
-
1606
- it("reads template children wrapped in a template key", () => {
1607
- const surface = surfaceFrom(
1608
- [
1609
- {
1610
- id: "root",
1611
- component: "List",
1612
- children: { template: { componentId: "item", path: "/items" } },
1613
- },
1614
- { id: "item", component: "Text", text: { path: "name" } },
1615
- ],
1616
- { items: [{ name: "one" }] },
1617
- );
1618
-
1619
- expect(convertSurfaceToUISpec(surface)).toEqual({
1620
- spec: {
1621
- $type: "ListView",
1622
- children: [
1623
- {
1624
- $type: "ListViewItem",
1625
- children: { $type: "Markdown", value: "one" },
1626
- },
1627
- ],
1628
- },
1629
- warnings: [],
1630
- });
1631
- });
1632
-
1633
- it("preserves a horizontal list container when expanding templates", () => {
1634
- const surface = surfaceFrom(
1635
- [
1636
- {
1637
- id: "root",
1638
- component: "List",
1639
- direction: "horizontal",
1640
- align: "center",
1641
- children: { componentId: "item", path: "/items" },
1642
- },
1643
- { id: "item", component: "Text", text: { path: "label" } },
1644
- ],
1645
- { items: [{ label: "One" }, { label: "Two" }] },
1646
- );
1647
-
1648
- expect(convertSurfaceToUISpec(surface)).toEqual({
1649
- spec: {
1650
- $type: "Row",
1651
- align: "center",
1652
- children: [
1653
- { $type: "Markdown", value: "One" },
1654
- { $type: "Markdown", value: "Two" },
1655
- ],
1656
- },
1657
- warnings: [],
1658
- });
1659
- });
1660
-
1661
- it("counts only emitted template nodes", () => {
1662
- const components = (root: Record<string, unknown>) => [
1663
- root,
1664
- {
1665
- id: "item",
1666
- component: "Row",
1667
- children: Array.from({ length: 49 }, (_, index) => `divider-${index}`),
1668
- },
1669
- ...Array.from({ length: 49 }, (_, index) => ({
1670
- id: `divider-${index}`,
1671
- component: "Divider",
1672
- })),
1673
- ];
1674
- const dataModel = { items: Array.from({ length: 100 }, () => ({})) };
1675
- const results = [
1676
- convertSurfaceToUISpec(
1677
- surfaceFrom(
1678
- components({
1679
- id: "root",
1680
- component: "List",
1681
- direction: "horizontal",
1682
- children: { componentId: "item", path: "/items" },
1683
- }),
1684
- dataModel,
1685
- ),
1686
- ),
1687
- convertSurfaceToUISpec(
1688
- surfaceFrom(
1689
- components({
1690
- id: "root",
1691
- component: "CustomList",
1692
- children: { componentId: "item", path: "/items" },
1693
- }),
1694
- dataModel,
1695
- ),
1696
- { keepUnknownComponents: true },
1697
- ),
1698
- ];
1699
-
1700
- for (const result of results) {
1701
- const children = (result.spec as UIElement).children as UIElement[];
1702
- expect(children).toHaveLength(100);
1703
- expect(children.at(-1)?.children as UIElement[]).toHaveLength(48);
1704
- expect(result.warnings).toEqual([
1705
- "A2UI node budget of 5000 was reached.",
1706
- ]);
1707
- }
1708
- });
1709
-
1710
- it("caps template expansion at 100 items", () => {
1711
- const surface = surfaceFrom(
1712
- [
1713
- {
1714
- id: "root",
1715
- component: "Column",
1716
- children: { componentId: "item", path: "/items" },
1717
- },
1718
- {
1719
- id: "item",
1720
- component: "Text",
1721
- text: { path: "/" },
1722
- },
1723
- ],
1724
- { items: Array.from({ length: 101 }, (_, index) => `item-${index}`) },
1725
- );
1726
-
1727
- const result = convertSurfaceToUISpec(surface);
1728
- expect((result.spec as UIElement).children).toHaveLength(100);
1729
- expect(result.warnings).toContain(
1730
- "A2UI template expansion was capped at 100 items.",
1731
- );
1732
- });
1733
-
1734
- it("follows single child references and event actions", () => {
1735
- const surface = surfaceFrom(
1736
- [
1737
- { id: "root", component: "Card", child: "column" },
1738
- {
1739
- id: "column",
1740
- component: "Column",
1741
- children: ["submit", "close"],
1742
- },
1743
- {
1744
- id: "submit",
1745
- component: "Button",
1746
- child: "submit-text",
1747
- action: {
1748
- event: {
1749
- name: "submit",
1750
- context: { carrier: { path: "/carrier" } },
1751
- },
1752
- },
1753
- },
1754
- { id: "submit-text", component: "Text", text: { path: "/label" } },
1755
- {
1756
- id: "close",
1757
- component: "Button",
1758
- child: "close-icon",
1759
- action: { event: { name: "close" } },
1760
- },
1761
- { id: "close-icon", component: "Icon", name: "x" },
1762
- ],
1763
- { carrier: ["dhl"], label: "Create label" },
1764
- );
1765
-
1766
- expect(convertSurfaceToUISpec(surface)).toEqual({
1767
- spec: {
1768
- $type: "Card",
1769
- children: [
1770
- {
1771
- $type: "Col",
1772
- children: [
1773
- {
1774
- $type: "Button",
1775
- label: "Create label",
1776
- $action: {
1777
- type: "a2ui:action",
1778
- name: "submit",
1779
- surfaceId: "",
1780
- sourceComponentId: "submit",
1781
- context: { carrier: ["dhl"] },
1782
- },
1783
- },
1784
- {
1785
- $type: "Button",
1786
- $action: {
1787
- type: "a2ui:action",
1788
- name: "close",
1789
- surfaceId: "",
1790
- sourceComponentId: "close",
1791
- },
1792
- children: [{ $type: "Icon", name: "x" }],
1793
- },
1794
- ],
1795
- },
1796
- ],
1797
- },
1798
- warnings: [],
1799
- });
1800
- });
1801
-
1802
- it("carries an event's userMessage when it resolves to a string", () => {
1803
- const surface = surfaceFrom(
1804
- [
1805
- { id: "root", component: "Row", children: ["greet", "count"] },
1806
- {
1807
- id: "greet",
1808
- component: "Button",
1809
- label: "Greet",
1810
- action: {
1811
- event: {
1812
- name: "greet",
1813
- userMessage: {
1814
- call: "formatString",
1815
- args: { value: "Greet ${/name}" },
1816
- },
1817
- },
1818
- },
1819
- },
1820
- {
1821
- id: "count",
1822
- component: "Button",
1823
- label: "Count",
1824
- action: { event: { name: "count", userMessage: { path: "/count" } } },
1825
- },
1826
- ],
1827
- { name: "Ada", count: 3 },
1828
- );
1829
-
1830
- expect(convertSurfaceToUISpec(surface)).toEqual({
1831
- spec: {
1832
- $type: "Row",
1833
- children: [
1834
- {
1835
- $type: "Button",
1836
- label: "Greet",
1837
- $action: {
1838
- type: "a2ui:action",
1839
- name: "greet",
1840
- surfaceId: "",
1841
- sourceComponentId: "greet",
1842
- userMessage: "Greet Ada",
1843
- },
1844
- },
1845
- {
1846
- $type: "Button",
1847
- label: "Count",
1848
- $action: {
1849
- type: "a2ui:action",
1850
- name: "count",
1851
- surfaceId: "",
1852
- sourceComponentId: "count",
1853
- },
1854
- },
1855
- ],
1856
- },
1857
- warnings: [],
1858
- });
1859
- });
1860
-
1861
- it("points a userMessage only at a lone text input bound to its exact path", () => {
1862
- const surface = surfaceFrom(
1863
- [
1864
- {
1865
- id: "root",
1866
- component: "Column",
1867
- children: [
1868
- "email",
1869
- "volume",
1870
- "note",
1871
- "qty",
1872
- "qty-level",
1873
- "form",
1874
- "level",
1875
- "draft",
1876
- "copy",
1877
- ],
1878
- },
1879
- { id: "email", component: "TextField", value: { path: "/form/email" } },
1880
- {
1881
- id: "volume",
1882
- component: "Slider",
1883
- max: 10,
1884
- value: { path: "/volume" },
1885
- },
1886
- { id: "note", component: "TextField", value: { path: "/note" } },
1887
- { id: "qty", component: "TextField", value: { path: "/qty" } },
1888
- {
1889
- id: "qty-level",
1890
- component: "Slider",
1891
- max: 10,
1892
- value: { path: "/qty" },
1893
- },
1894
- {
1895
- id: "form",
1896
- component: "Button",
1897
- label: "Form",
1898
- action: { event: { name: "form", userMessage: { path: "/form" } } },
1899
- },
1900
- {
1901
- id: "level",
1902
- component: "Button",
1903
- label: "Level",
1904
- action: {
1905
- event: { name: "level", userMessage: { path: "/volume" } },
1906
- },
1907
- },
1908
- {
1909
- id: "draft",
1910
- component: "Button",
1911
- label: "Draft",
1912
- action: { event: { name: "draft", userMessage: { path: "/note" } } },
1913
- },
1914
- {
1915
- id: "copy",
1916
- component: "Button",
1917
- label: "Copy",
1918
- action: { event: { name: "copy", userMessage: { path: "/qty" } } },
1919
- },
1920
- ],
1921
- { form: { email: "ada@example.com" }, volume: 4, qty: "2" },
1922
- );
1923
-
1924
- const { spec, warnings } = convertSurfaceToUISpec(surface);
1925
-
1926
- expect(warnings).toEqual([]);
1927
- expect(spec?.["children"]).toContainEqual({
1928
- $type: "Button",
1929
- label: "Form",
1930
- $action: {
1931
- type: "a2ui:action",
1932
- name: "form",
1933
- surfaceId: "",
1934
- sourceComponentId: "form",
1935
- },
1936
- });
1937
- expect(spec?.["children"]).toContainEqual({
1938
- $type: "Button",
1939
- label: "Level",
1940
- $action: {
1941
- type: "a2ui:action",
1942
- name: "level",
1943
- surfaceId: "",
1944
- sourceComponentId: "level",
1945
- },
1946
- });
1947
- expect(spec?.["children"]).toContainEqual({
1948
- $type: "Button",
1949
- label: "Draft",
1950
- $action: {
1951
- type: "a2ui:action",
1952
- name: "draft",
1953
- surfaceId: "",
1954
- sourceComponentId: "draft",
1955
- userMessage: { $field: "/note" },
1956
- },
1957
- });
1958
- expect(spec?.["children"]).toContainEqual({
1959
- $type: "Button",
1960
- label: "Copy",
1961
- $action: {
1962
- type: "a2ui:action",
1963
- name: "copy",
1964
- surfaceId: "",
1965
- sourceComponentId: "copy",
1966
- userMessage: "2",
1967
- },
1968
- });
1969
- });
1970
-
1971
- it("takes a button label only from a Text child and keeps action data as bound", () => {
1972
- const surface = surfaceFrom(
1973
- [
1974
- {
1975
- id: "root",
1976
- component: "Column",
1977
- children: ["custom-label", "bound-context"],
1978
- },
1979
- { id: "custom-label", component: "Button", child: "note" },
1980
- { id: "note", component: "Markdown", value: "Read me", tone: "loud" },
1981
- {
1982
- id: "bound-context",
1983
- component: "Button",
1984
- label: "Go",
1985
- action: {
1986
- event: { name: "go", context: { target: { path: "/target" } } },
1987
- },
1988
- },
1989
- ],
1990
- { target: { path: "/secret" }, secret: "hidden" },
1991
- );
1992
-
1993
- expect(
1994
- convertSurfaceToUISpec(surface, { keepUnknownComponents: true }),
1995
- ).toEqual({
1996
- spec: {
1997
- $type: "Col",
1998
- children: [
1999
- {
2000
- $type: "Button",
2001
- children: [{ $type: "Markdown", value: "Read me", tone: "loud" }],
2002
- },
2003
- {
2004
- $type: "Button",
2005
- label: "Go",
2006
- $action: {
2007
- type: "a2ui:action",
2008
- name: "go",
2009
- surfaceId: "",
2010
- sourceComponentId: "bound-context",
2011
- context: { target: { path: "/secret" } },
2012
- },
2013
- },
2014
- ],
2015
- },
2016
- warnings: [],
2017
- });
2018
- });
2019
-
2020
- it("points action context bound to an input at that input, so it resolves when the action fires", () => {
2021
- const surface = surfaceFrom(
2022
- [
2023
- {
2024
- id: "root",
2025
- component: "Column",
2026
- children: ["name", "plan", "size", "agree", "start", "due", "send"],
2027
- },
2028
- {
2029
- id: "name",
2030
- component: "TextField",
2031
- label: "Name",
2032
- value: { path: "/form/name" },
2033
- },
2034
- {
2035
- id: "plan",
2036
- component: "ChoicePicker",
2037
- options: [{ label: "Pro", value: "pro" }],
2038
- value: { path: "/form/plan" },
2039
- },
2040
- {
2041
- id: "size",
2042
- component: "ChoicePicker",
2043
- displayStyle: "chips",
2044
- options: [{ label: "Medium", value: "m" }],
2045
- value: { path: "/form/size" },
2046
- },
2047
- {
2048
- id: "agree",
2049
- component: "CheckBox",
2050
- label: "Agree",
2051
- value: { path: "/form/agree" },
2052
- },
2053
- {
2054
- id: "start",
2055
- component: "DateTimeInput",
2056
- value: { path: "/form/start" },
2057
- },
2058
- {
2059
- id: "due",
2060
- component: "DateTimeInput",
2061
- value: { path: "/form/due" },
2062
- },
2063
- {
2064
- id: "send",
2065
- component: "Button",
2066
- label: "Send",
2067
- action: {
2068
- event: {
2069
- name: "send",
2070
- context: {
2071
- name: { path: "/form/name" },
2072
- form: { path: "/form" },
2073
- owner: { path: "/owner" },
2074
- literal: "kept",
2075
- },
2076
- },
2077
- },
2078
- },
2079
- ],
2080
- {
2081
- form: {
2082
- name: "Ada",
2083
- plan: ["pro"],
2084
- size: "m",
2085
- agree: false,
2086
- start: "2026-01-02",
2087
- due: "2025-12-15T17:00:00Z",
2088
- id: 7,
2089
- },
2090
- owner: "u1",
2091
- },
2092
- );
2093
-
2094
- const { spec, warnings } = convertSurfaceToUISpec(surface);
2095
-
2096
- expect(warnings).toEqual([]);
2097
- expect(spec?.["children"]).toContainEqual({
2098
- $type: "DatePicker",
2099
- inputType: "date",
2100
- value: "2026-01-02",
2101
- name: "/form/start",
2102
- });
2103
- expect(spec?.["children"]).toContainEqual({
2104
- $type: "DatePicker",
2105
- inputType: "datetime",
2106
- value: "2025-12-15T17:00:00Z",
2107
- name: "/form/due",
2108
- });
2109
- expect(spec?.["children"]).toContainEqual({
2110
- $type: "Button",
2111
- label: "Send",
2112
- $action: {
2113
- type: "a2ui:action",
2114
- name: "send",
2115
- surfaceId: "",
2116
- sourceComponentId: "send",
2117
- context: {
2118
- name: { $field: "/form/name", fallback: "Ada" },
2119
- form: {
2120
- name: { $field: "/form/name", fallback: "Ada" },
2121
- plan: [{ $field: "/form/plan", fallback: "pro" }],
2122
- size: { $field: "/form/size", fallback: "m" },
2123
- agree: { $field: "/form/agree", fallback: false },
2124
- start: { $field: "/form/start", fallback: "2026-01-02" },
2125
- due: { $field: "/form/due", fallback: "2025-12-15T17:00:00Z" },
2126
- id: 7,
2127
- },
2128
- owner: "u1",
2129
- literal: "kept",
2130
- },
2131
- },
2132
- });
2133
- expect(surface.dataModel).toEqual({
2134
- form: {
2135
- name: "Ada",
2136
- plan: ["pro"],
2137
- size: "m",
2138
- agree: false,
2139
- start: "2026-01-02",
2140
- due: "2025-12-15T17:00:00Z",
2141
- id: 7,
2142
- },
2143
- owner: "u1",
2144
- });
2145
- });
2146
-
2147
- it("names an input inside a template item by the item's path", () => {
2148
- const surface = surfaceFrom(
2149
- [
2150
- { id: "root", component: "Column", children: ["lines", "order"] },
2151
- {
2152
- id: "lines",
2153
- component: "List",
2154
- children: { componentId: "line", path: "/items" },
2155
- },
2156
- { id: "line", component: "Row", children: ["qty", "remove"] },
2157
- {
2158
- id: "qty",
2159
- component: "TextField",
2160
- label: "Quantity",
2161
- value: { path: "qty" },
2162
- },
2163
- {
2164
- id: "remove",
2165
- component: "Button",
2166
- label: "Remove",
2167
- action: {
2168
- event: {
2169
- name: "remove",
2170
- context: { qty: { path: "qty" }, sku: { path: "sku" } },
2171
- },
2172
- },
2173
- },
2174
- {
2175
- id: "order",
2176
- component: "Button",
2177
- label: "Order",
2178
- action: {
2179
- event: { name: "order", context: { items: { path: "/items" } } },
2180
- },
2181
- },
2182
- ],
2183
- {
2184
- items: [
2185
- { sku: "a", qty: 1 },
2186
- { sku: "b", qty: 2 },
2187
- ],
2188
- },
2189
- );
2190
-
2191
- const line = (index: number, sku: string, qty: string) => ({
2192
- $type: "ListViewItem",
2193
- children: {
2194
- $type: "Row",
2195
- children: [
2196
- {
2197
- $type: "Input",
2198
- label: "Quantity",
2199
- name: `/items/${index}/qty`,
2200
- defaultValue: qty,
2201
- },
2202
- {
2203
- $type: "Button",
2204
- label: "Remove",
2205
- $action: {
2206
- type: "a2ui:action",
2207
- name: "remove",
2208
- surfaceId: "",
2209
- sourceComponentId: "remove",
2210
- context: {
2211
- qty: { $field: `/items/${index}/qty`, fallback: Number(qty) },
2212
- sku,
2213
- },
2214
- },
2215
- },
2216
- ],
2217
- },
2218
- });
2219
- expect(convertSurfaceToUISpec(surface)).toEqual({
2220
- spec: {
2221
- $type: "Col",
2222
- children: [
2223
- {
2224
- $type: "ListView",
2225
- children: [line(0, "a", "1"), line(1, "b", "2")],
2226
- },
2227
- {
2228
- $type: "Button",
2229
- label: "Order",
2230
- $action: {
2231
- type: "a2ui:action",
2232
- name: "order",
2233
- surfaceId: "",
2234
- sourceComponentId: "order",
2235
- context: {
2236
- items: [
2237
- { sku: "a", qty: { $field: "/items/0/qty", fallback: 1 } },
2238
- { sku: "b", qty: { $field: "/items/1/qty", fallback: 2 } },
2239
- ],
2240
- },
2241
- },
2242
- },
2243
- ],
2244
- },
2245
- warnings: [],
2246
- });
2247
- });
2248
-
2249
- it("grows a bound list for a field reference the way the data model grows", () => {
2250
- const surface = surfaceFrom(
2251
- [
2252
- {
2253
- id: "root",
2254
- component: "Column",
2255
- children: ["far", "next", "row", "cleared", "send"],
2256
- },
2257
- {
2258
- id: "far",
2259
- component: "TextField",
2260
- value: { path: "/items/999999999/name" },
2261
- },
2262
- {
2263
- id: "next",
2264
- component: "TextField",
2265
- value: { path: "/items/0/name" },
2266
- },
2267
- { id: "row", component: "TextField", value: { path: "/rows/0/name" } },
2268
- {
2269
- id: "cleared",
2270
- component: "TextField",
2271
- value: { path: "/cleared/0/name" },
2272
- },
2273
- {
2274
- id: "send",
2275
- component: "Button",
2276
- label: "Send",
2277
- action: {
2278
- event: {
2279
- name: "send",
2280
- context: {
2281
- items: { path: "/items" },
2282
- rows: { path: "/rows" },
2283
- cleared: { path: "/cleared" },
2284
- },
2285
- },
2286
- },
2287
- },
2288
- ],
2289
- { items: [], cleared: null },
2290
- );
2291
-
2292
- expect(convertSurfaceToUISpec(surface).spec?.["children"]).toContainEqual({
2293
- $type: "Button",
2294
- label: "Send",
2295
- $action: {
2296
- type: "a2ui:action",
2297
- name: "send",
2298
- surfaceId: "",
2299
- sourceComponentId: "send",
2300
- context: {
2301
- items: [{ name: { $field: "/items/0/name" } }],
2302
- rows: [{ name: { $field: "/rows/0/name" } }],
2303
- cleared: [{ name: { $field: "/cleared/0/name" } }],
2304
- },
2305
- },
2306
- });
2307
- });
2308
-
2309
- it("points function call arguments and paths the data model does not hold yet at their inputs", () => {
2310
- const surface = surfaceFrom([
2311
- { id: "root", component: "Column", children: ["link", "open", "save"] },
2312
- {
2313
- id: "link",
2314
- component: "TextField",
2315
- label: "Link",
2316
- value: { path: "/draft/link" },
2317
- },
2318
- {
2319
- id: "open",
2320
- component: "Button",
2321
- label: "Open",
2322
- action: {
2323
- functionCall: {
2324
- call: "openUrl",
2325
- args: { url: { path: "/draft/link" } },
2326
- },
2327
- },
2328
- },
2329
- {
2330
- id: "save",
2331
- component: "Button",
2332
- label: "Save",
2333
- action: {
2334
- event: { name: "save", context: { draft: { path: "/draft" } } },
2335
- },
2336
- },
2337
- ]);
2338
-
2339
- expect(convertSurfaceToUISpec(surface)).toEqual({
2340
- spec: {
2341
- $type: "Col",
2342
- children: [
2343
- { $type: "Input", label: "Link", name: "/draft/link" },
2344
- {
2345
- $type: "Button",
2346
- label: "Open",
2347
- $action: {
2348
- type: "a2ui:functionCall",
2349
- call: "openUrl",
2350
- surfaceId: "",
2351
- sourceComponentId: "open",
2352
- args: { url: { $field: "/draft/link" } },
2353
- },
2354
- },
2355
- {
2356
- $type: "Button",
2357
- label: "Save",
2358
- $action: {
2359
- type: "a2ui:action",
2360
- name: "save",
2361
- surfaceId: "",
2362
- sourceComponentId: "save",
2363
- context: { draft: { link: { $field: "/draft/link" } } },
2364
- },
2365
- },
2366
- ],
2367
- },
2368
- warnings: [],
2369
- });
2370
- });
2371
-
2372
- it("maps button and text field variants", () => {
2373
- const surface = surfaceFrom(
2374
- [
2375
- {
2376
- id: "root",
2377
- component: "Column",
2378
- children: ["save", "skip", "plain", "styled", "notes", "code"],
2379
- },
2380
- { id: "save", component: "Button", label: "Save", variant: "primary" },
2381
- {
2382
- id: "skip",
2383
- component: "Button",
2384
- label: "Skip",
2385
- variant: "borderless",
2386
- },
2387
- { id: "plain", component: "Button", label: "Back", variant: "default" },
2388
- {
2389
- id: "styled",
2390
- component: "Button",
2391
- label: "Delete",
2392
- variant: "primary",
2393
- buttonStyle: "danger",
2394
- },
2395
- {
2396
- id: "notes",
2397
- component: "TextField",
2398
- label: "Notes",
2399
- variant: "longText",
2400
- value: { path: "/notes" },
2401
- },
2402
- {
2403
- id: "code",
2404
- component: "TextField",
2405
- label: "Code",
2406
- variant: "shortText",
2407
- value: { path: "/code" },
2408
- },
2409
- ],
2410
- { notes: "", code: "" },
2411
- );
2412
-
2413
- expect(convertSurfaceToUISpec(surface)).toEqual({
2414
- spec: {
2415
- $type: "Col",
2416
- children: [
2417
- { $type: "Button", label: "Save", buttonStyle: "primary" },
2418
- { $type: "Button", label: "Skip", buttonStyle: "ghost" },
2419
- { $type: "Button", label: "Back" },
2420
- { $type: "Button", label: "Delete", buttonStyle: "danger" },
2421
- {
2422
- $type: "Input",
2423
- multiline: true,
2424
- label: "Notes",
2425
- name: "/notes",
2426
- defaultValue: "",
2427
- },
2428
- { $type: "Input", label: "Code", name: "/code", defaultValue: "" },
2429
- ],
2430
- },
2431
- warnings: [],
2432
- });
2433
- });
2434
-
2435
- it("maps function call actions and warns about an action it cannot read", () => {
2436
- const surface = surfaceFrom(
2437
- [
2438
- { id: "root", component: "Column", children: ["docs", "broken"] },
2439
- {
2440
- id: "docs",
2441
- component: "Button",
2442
- child: "docs-text",
2443
- action: {
2444
- functionCall: {
2445
- call: "openUrl",
2446
- args: { url: { path: "/docsUrl" } },
2447
- },
2448
- },
2449
- },
2450
- { id: "docs-text", component: "Text", text: "Docs" },
2451
- {
2452
- id: "broken",
2453
- component: "Button",
2454
- label: "Broken",
2455
- action: { functionCall: { args: { url: "https://a2ui.org" } } },
2456
- },
2457
- ],
2458
- { docsUrl: "https://a2ui.org" },
2459
- );
2460
-
2461
- expect(convertSurfaceToUISpec(surface)).toEqual({
2462
- spec: {
2463
- $type: "Col",
2464
- children: [
2465
- {
2466
- $type: "Button",
2467
- label: "Docs",
2468
- $action: {
2469
- type: "a2ui:functionCall",
2470
- call: "openUrl",
2471
- surfaceId: "",
2472
- sourceComponentId: "docs",
2473
- args: { url: "https://a2ui.org" },
2474
- },
2475
- },
2476
- { $type: "Button", label: "Broken" },
2477
- ],
2478
- },
2479
- warnings: ['Component "broken" has a malformed action.'],
2480
- });
2481
- });
2482
-
2483
- it("warns about a child reference it cannot follow", () => {
2484
- const surface = surfaceFrom([
2485
- { id: "root", component: "Column", children: ["missing", "malformed"] },
2486
- { id: "missing", component: "Card", child: "gone" },
2487
- { id: "malformed", component: "Card", child: 7 },
2488
- ]);
2489
-
2490
- expect(convertSurfaceToUISpec(surface)).toEqual({
2491
- spec: {
2492
- $type: "Col",
2493
- children: [{ $type: "Card" }, { $type: "Card" }],
2494
- },
2495
- warnings: [
2496
- 'A2UI component "gone" was not found.',
2497
- 'Component "malformed" has a malformed child reference.',
2498
- ],
2499
- });
2500
- });
2501
-
2502
- it("keeps the content of kept Modal, Tabs, and single child components", () => {
2503
- const tabs = [
2504
- { title: "Pickup", child: "pickup" },
2505
- { title: "Drop off", child: "drop-off" },
2506
- ];
2507
- const surface = surfaceFrom([
2508
- {
2509
- id: "root",
2510
- component: "Column",
2511
- children: ["modal", "tabs", "panel"],
2512
- },
2513
- { id: "modal", component: "Modal", trigger: "open", content: "details" },
2514
- {
2515
- id: "open",
2516
- component: "Button",
2517
- child: "open-text",
2518
- action: { event: { name: "open" } },
2519
- },
2520
- { id: "open-text", component: "Text", text: "Details" },
2521
- { id: "details", component: "Text", text: "Ships in two days." },
2522
- { id: "tabs", component: "Tabs", tabs },
2523
- { id: "pickup", component: "Text", text: "A courier collects it." },
2524
- { id: "drop-off", component: "Text", text: "Any service point." },
2525
- { id: "panel", component: "ReturnPanel", child: "note" },
2526
- { id: "note", component: "Text", text: "Returns are free." },
2527
- ]);
2528
-
2529
- expect(
2530
- convertSurfaceToUISpec(surface, { keepUnknownComponents: true }),
2531
- ).toEqual({
2532
- spec: {
2533
- $type: "Col",
2534
- children: [
2535
- {
2536
- $type: "Modal",
2537
- trigger: "open",
2538
- content: "details",
2539
- children: [
2540
- {
2541
- $type: "Button",
2542
- label: "Details",
2543
- $action: {
2544
- type: "a2ui:action",
2545
- name: "open",
2546
- surfaceId: "",
2547
- sourceComponentId: "open",
2548
- },
2549
- },
2550
- { $type: "Markdown", value: "Ships in two days." },
2551
- ],
2552
- },
2553
- {
2554
- $type: "Tabs",
2555
- tabs,
2556
- children: [
2557
- { $type: "Markdown", value: "A courier collects it." },
2558
- { $type: "Markdown", value: "Any service point." },
2559
- ],
2560
- },
2561
- {
2562
- $type: "ReturnPanel",
2563
- children: [{ $type: "Markdown", value: "Returns are free." }],
2564
- },
2565
- ],
2566
- },
2567
- warnings: [],
2568
- });
2569
- });
2570
-
2571
- it("truncates a component cycle", () => {
2572
- const surface = surfaceFrom([
2573
- {
2574
- id: "root",
2575
- component: "Row",
2576
- children: ["root"],
2577
- },
2578
- ]);
2579
-
2580
- expect(convertSurfaceToUISpec(surface)).toEqual({
2581
- spec: { $type: "Row" },
2582
- warnings: ['A2UI component cycle detected at "root".'],
2583
- });
2584
- });
2585
-
2586
- it("truncates component resolution at depth 32", () => {
2587
- const components = Array.from({ length: 34 }, (_, index) => ({
2588
- id: index === 0 ? "root" : `node-${index}`,
2589
- component: "Row",
2590
- ...(index < 33
2591
- ? { children: [index === 0 ? "node-1" : `node-${index + 1}`] }
2592
- : {}),
2593
- }));
2594
- const result = convertSurfaceToUISpec(surfaceFrom(components));
2595
- let node = result.spec as UIElement;
2596
- let count = 1;
2597
- while (Array.isArray(node.children) && node.children.length > 0) {
2598
- node = node.children[0] as UIElement;
2599
- count++;
2600
- }
2601
-
2602
- expect(count).toBe(32);
2603
- expect(result.warnings).toContain("A2UI depth cap of 32 was reached.");
2604
- });
2605
-
2606
- it("applies the depth cap to kept unknown components", () => {
2607
- const components = Array.from({ length: 34 }, (_, index) => ({
2608
- id: index === 0 ? "root" : `node-${index}`,
2609
- component: "CustomContainer",
2610
- ...(index < 33
2611
- ? { children: [index === 0 ? "node-1" : `node-${index + 1}`] }
2612
- : {}),
2613
- }));
2614
- const result = convertSurfaceToUISpec(surfaceFrom(components), {
2615
- keepUnknownComponents: true,
2616
- });
2617
- let node = result.spec as UIElement;
2618
- let count = 1;
2619
- while (Array.isArray(node.children) && node.children.length > 0) {
2620
- node = node.children[0] as UIElement;
2621
- count++;
2622
- }
2623
-
2624
- expect(count).toBe(32);
2625
- expect(result.warnings).toContain("A2UI depth cap of 32 was reached.");
2626
- });
2627
-
2628
- it("enforces the total emitted node budget", () => {
2629
- const childCount = 5001;
2630
- const components = [
2631
- {
2632
- id: "root",
2633
- component: "Row",
2634
- children: Array.from(
2635
- { length: childCount },
2636
- (_, index) => `child-${index}`,
2637
- ),
2638
- },
2639
- ...Array.from({ length: childCount }, (_, index) => ({
2640
- id: `child-${index}`,
2641
- component: "Divider",
2642
- })),
2643
- ];
2644
- const result = convertSurfaceToUISpec(surfaceFrom(components));
2645
-
2646
- expect((result.spec as UIElement).children).toHaveLength(4999);
2647
- expect(result.warnings).toContain("A2UI node budget of 5000 was reached.");
2648
- });
2649
-
2650
- it("converts a children list longer than the call argument limit", () => {
2651
- const result = convertSurfaceToUISpec(
2652
- surfaceFrom([
2653
- {
2654
- id: "root",
2655
- component: "Row",
2656
- children: Array.from({ length: 500_000 }, () => "leaf"),
2657
- },
2658
- { id: "leaf", component: "Divider" },
2659
- ]),
2660
- );
2661
-
2662
- expect((result.spec as UIElement).children).toHaveLength(4999);
2663
- expect(result.warnings).toEqual(["A2UI node budget of 5000 was reached."]);
2664
- });
2665
-
2666
- it("skips an unknown component with a warning naming it", () => {
2667
- const result = convertSurfaceToUISpec(
2668
- surfaceFrom([{ id: "root", component: "VideoPlayer" }]),
2669
- );
2670
-
2671
- expect(result.spec).toBeNull();
2672
- expect(result.warnings).toEqual([
2673
- 'Unknown A2UI component "VideoPlayer" was skipped.',
2674
- ]);
2675
- });
2676
-
2677
- it("skips a supported component that cannot be mapped", () => {
2678
- const surface = surfaceFrom([
2679
- { id: "root", component: "Icon", name: "not-in-the-vocabulary" },
2680
- ]);
2681
- const expected = {
2682
- spec: null,
2683
- warnings: ['A2UI component "Icon" could not be mapped and was skipped.'],
2684
- };
2685
-
2686
- expect(convertSurfaceToUISpec(surface)).toEqual(expected);
2687
- expect(
2688
- convertSurfaceToUISpec(surface, { keepUnknownComponents: true }),
2689
- ).toEqual(expected);
2690
- });
2691
-
2692
- it("optionally keeps unknown components with resolved props and children", () => {
2693
- const surface = surfaceFrom(
2694
- [
2695
- {
2696
- id: "root",
2697
- component: "StatusPill",
2698
- title: { path: "/status/title" },
2699
- details: { level: { path: "/status/level" } },
2700
- children: ["text"],
2701
- },
2702
- { id: "text", component: "Text", text: { path: "/status/body" } },
2703
- ],
2704
- {
2705
- status: {
2706
- title: "Deployment",
2707
- level: "success",
2708
- body: "Ready",
2709
- },
2710
- },
2711
- );
2712
-
2713
- expect(convertSurfaceToUISpec(surface)).toEqual({
2714
- spec: null,
2715
- warnings: ['Unknown A2UI component "StatusPill" was skipped.'],
2716
- });
2717
- expect(
2718
- convertSurfaceToUISpec(surface, { keepUnknownComponents: true }),
2719
- ).toEqual({
2720
- spec: {
2721
- $type: "StatusPill",
2722
- title: "Deployment",
2723
- details: { level: "success" },
2724
- children: [{ $type: "Markdown", value: "Ready" }],
2725
- },
2726
- warnings: [],
2727
- });
2728
- });
2729
-
2730
- it("drops framework props from kept unknown components", () => {
2731
- const staticSurface = surfaceFrom([
2732
- {
2733
- id: "root",
2734
- component: "StatusPill",
2735
- label: "Ready",
2736
- $action: { type: "injected" },
2737
- $key: "injected",
2738
- $status: "injected",
2739
- },
2740
- ]);
2741
- const templateSurface = surfaceFrom(
2742
- [
2743
- {
2744
- id: "root",
2745
- component: "CustomList",
2746
- title: "Tasks",
2747
- $action: { type: "injected" },
2748
- $key: "injected",
2749
- $status: "injected",
2750
- children: { componentId: "item", path: "/items" },
2751
- },
2752
- { id: "item", component: "Text", text: { path: "label" } },
2753
- ],
2754
- { items: [{ label: "One" }] },
2755
- );
2756
-
2757
- expect(
2758
- convertSurfaceToUISpec(staticSurface, { keepUnknownComponents: true }),
2759
- ).toEqual({
2760
- spec: { $type: "StatusPill", label: "Ready" },
2761
- warnings: [],
2762
- });
2763
- expect(
2764
- convertSurfaceToUISpec(templateSurface, { keepUnknownComponents: true }),
2765
- ).toEqual({
2766
- spec: {
2767
- $type: "CustomList",
2768
- title: "Tasks",
2769
- children: [{ $type: "Markdown", value: "One" }],
2770
- },
2771
- warnings: [],
2772
- });
2773
- });
2774
-
2775
- it("expands an unknown component with template children into a list by default", () => {
2776
- const surface = surfaceFrom(
2777
- [
2778
- {
2779
- id: "root",
2780
- component: "CustomList",
2781
- children: { componentId: "item", path: "/items" },
2782
- },
2783
- { id: "item", component: "Text", text: { path: "label" } },
2784
- ],
2785
- { items: [{ label: "One" }] },
2786
- );
2787
-
2788
- expect(convertSurfaceToUISpec(surface)).toEqual({
2789
- spec: {
2790
- $type: "ListView",
2791
- children: [
2792
- {
2793
- $type: "ListViewItem",
2794
- children: { $type: "Markdown", value: "One" },
2795
- },
2796
- ],
2797
- },
2798
- warnings: [],
2799
- });
2800
- });
2801
-
2802
- it("keeps unknown template components as resolved nodes", () => {
2803
- const surface = surfaceFrom(
2804
- [
2805
- {
2806
- id: "root",
2807
- component: "CustomList",
2808
- title: { path: "/title" },
2809
- children: { componentId: "item", path: "/items" },
2810
- },
2811
- { id: "item", component: "Text", text: { path: "label" } },
2812
- ],
2813
- { title: "Tasks", items: [{ label: "One" }, { label: "Two" }] },
2814
- );
2815
-
2816
- expect(
2817
- convertSurfaceToUISpec(surface, { keepUnknownComponents: true }),
2818
- ).toEqual({
2819
- spec: {
2820
- $type: "CustomList",
2821
- title: "Tasks",
2822
- children: [
2823
- { $type: "Markdown", value: "One" },
2824
- { $type: "Markdown", value: "Two" },
2825
- ],
2826
- },
2827
- warnings: [],
2828
- });
2829
- });
2830
-
2831
- it("returns null with a warning when root is missing", () => {
2832
- expect(
2833
- convertSurfaceToUISpec(
2834
- surfaceFrom([{ id: "other", component: "Divider" }]),
2835
- ),
2836
- ).toEqual({
2837
- spec: null,
2838
- warnings: ['A2UI root component "root" was not found.'],
2839
- });
2840
- });
2841
- });
2842
-
2843
- it("keeps live controls internal and action-free while plain conversion stays uncontrolled", () => {
2844
- const surface = applyA2uiOperations(new Map(), [
2845
- {
2846
- version: "v1.0",
2847
- createSurface: {
2848
- surfaceId: "main",
2849
- components: [
2850
- {
2851
- id: "root",
2852
- component: "Column",
2853
- children: ["field", "pick", "slider"],
2854
- },
2855
- { id: "field", component: "TextField", text: { path: "/name" } },
2856
- {
2857
- id: "pick",
2858
- component: "ChoicePicker",
2859
- value: { path: "/choices" },
2860
- options: [{ label: "A", value: "a" }],
2861
- },
2862
- {
2863
- id: "slider",
2864
- component: "Slider",
2865
- value: { path: "/count" },
2866
- min: 0,
2867
- max: 10,
2868
- },
2869
- ],
2870
- dataModel: { name: "Ada", choices: ["a"], count: 2 },
2871
- },
2872
- },
2873
- ]).state.get("main")!;
2874
- expect(convertSurfaceToUISpec(surface).spec).toMatchObject({
2875
- children: [
2876
- { $type: "Input", defaultValue: "Ada" },
2877
- { $type: "RadioGroup", defaultValue: "a" },
2878
- { $type: "Slider", defaultValue: 2 },
2879
- ],
2880
- });
2881
- const convert = createLiveSurfaceConverter(surface);
2882
- const live = convert(surface.dataModel);
2883
- expect(live.spec).toEqual({
2884
- $type: "Col",
2885
- children: [
2886
- {
2887
- $type: "Input",
2888
- value: "Ada",
2889
- name: "/name",
2890
- },
2891
- {
2892
- $type: "RadioGroup",
2893
- value: "a",
2894
- name: "/choices",
2895
- options: [{ label: "A", value: "a" }],
2896
- },
2897
- {
2898
- $type: "Slider",
2899
- value: 2,
2900
- name: "/count",
2901
- min: 0,
2902
- max: 10,
2903
- step: 0.1,
2904
- },
2905
- ],
2906
- });
2907
- expect(live.bindings).toEqual(
2908
- new Map([
2909
- ["/name", { value: "Ada", arrayValue: false }],
2910
- ["/choices", { value: "a", arrayValue: true }],
2911
- ["/count", { value: 2, arrayValue: false }],
2912
- ]),
2913
- );
2914
- const updated = convert({ name: "Grace", choices: ["a"], count: 2 });
2915
- expect((updated.spec!.children as UIElement[])[0]).toEqual({
2916
- $type: "Input",
2917
- name: "/name",
2918
- value: "Grace",
2919
- });
2920
- expect((updated.spec!.children as UIElement[])[2]).toBe(
2921
- (live.spec!.children as UIElement[])[2],
2922
- );
2923
- });