@happyvertical/smrt-svelte 0.53.1 → 0.53.3

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 (254) hide show
  1. package/package.json +15 -15
  2. package/dist/__tests__/app-state.test.js +0 -156
  3. package/dist/__tests__/data-surface-bridge.test.js +0 -351
  4. package/dist/__tests__/provider-mode-harness.svelte +0 -14
  5. package/dist/__tests__/provider-mode-harness.svelte.d.ts +0 -11
  6. package/dist/__tests__/provider-mode-harness.svelte.d.ts.map +0 -1
  7. package/dist/__tests__/provider-mode-probe.svelte +0 -13
  8. package/dist/__tests__/provider-mode-probe.svelte.d.ts +0 -8
  9. package/dist/__tests__/provider-mode-probe.svelte.d.ts.map +0 -1
  10. package/dist/__tests__/provider-webmcp-harness.svelte +0 -14
  11. package/dist/__tests__/provider-webmcp-harness.svelte.d.ts +0 -8
  12. package/dist/__tests__/provider-webmcp-harness.svelte.d.ts.map +0 -1
  13. package/dist/__tests__/success-never-text.test.js +0 -100
  14. package/dist/__tests__/warm-clients.test.js +0 -186
  15. package/dist/browser-ai/adapters/llm/bitgpu-inference-backend.test.js +0 -464
  16. package/dist/browser-ai/adapters/llm/webllm-inference-backend.test.js +0 -646
  17. package/dist/browser-ai/adapters/stt/browser-speech.test.js +0 -113
  18. package/dist/browser-ai/adapters/stt/dictation-source.test.js +0 -52
  19. package/dist/browser-ai/svelte/components/__tests__/ModelStatusControl.test.js +0 -172
  20. package/dist/components/board/__tests__/Board.test.js +0 -498
  21. package/dist/components/board/__tests__/BoardSsrHarness.svelte +0 -30
  22. package/dist/components/board/__tests__/BoardSsrHarness.svelte.d.ts +0 -19
  23. package/dist/components/board/__tests__/BoardSsrHarness.svelte.d.ts.map +0 -1
  24. package/dist/components/forms/__tests__/AddressInput.behavior.test.js +0 -216
  25. package/dist/components/forms/__tests__/CheckboxInput.test.js +0 -92
  26. package/dist/components/forms/__tests__/DateRangeInput.behavior.test.js +0 -135
  27. package/dist/components/forms/__tests__/DateTimeInput.behavior.test.js +0 -113
  28. package/dist/components/forms/__tests__/FileUpload.error-alert.test.js +0 -37
  29. package/dist/components/forms/__tests__/FileUpload.native-form.test.js +0 -249
  30. package/dist/components/forms/__tests__/FileUpload.test.js +0 -90
  31. package/dist/components/forms/__tests__/FileUploadReset.fixture.svelte +0 -24
  32. package/dist/components/forms/__tests__/FileUploadReset.fixture.svelte.d.ts +0 -10
  33. package/dist/components/forms/__tests__/FileUploadReset.fixture.svelte.d.ts.map +0 -1
  34. package/dist/components/forms/__tests__/Form.behavior.test.js +0 -137
  35. package/dist/components/forms/__tests__/Form.extraction.test.js +0 -68
  36. package/dist/components/forms/__tests__/Form.stt-error.test.js +0 -57
  37. package/dist/components/forms/__tests__/Form.test.js +0 -86
  38. package/dist/components/forms/__tests__/Form.ui-controls.test.js +0 -119
  39. package/dist/components/forms/__tests__/Form.webmcp.test.js +0 -2336
  40. package/dist/components/forms/__tests__/FormMicButton.test.js +0 -92
  41. package/dist/components/forms/__tests__/FormScope.test.js +0 -39
  42. package/dist/components/forms/__tests__/MeasurementInput.behavior.test.js +0 -129
  43. package/dist/components/forms/__tests__/MoneyInput.behavior.test.js +0 -124
  44. package/dist/components/forms/__tests__/NumberInput.behavior.test.js +0 -151
  45. package/dist/components/forms/__tests__/PhoneInput.behavior.test.js +0 -96
  46. package/dist/components/forms/__tests__/SearchInput.test.js +0 -79
  47. package/dist/components/forms/__tests__/SelectInput.behavior.test.js +0 -132
  48. package/dist/components/forms/__tests__/TextInput.behavior.test.js +0 -131
  49. package/dist/components/forms/__tests__/TextareaInput.behavior.test.js +0 -96
  50. package/dist/components/forms/__tests__/async-invalid-field.fixture.svelte +0 -30
  51. package/dist/components/forms/__tests__/async-invalid-field.fixture.svelte.d.ts +0 -7
  52. package/dist/components/forms/__tests__/async-invalid-field.fixture.svelte.d.ts.map +0 -1
  53. package/dist/components/forms/__tests__/async-validation-form.fixture.svelte +0 -19
  54. package/dist/components/forms/__tests__/async-validation-form.fixture.svelte.d.ts +0 -9
  55. package/dist/components/forms/__tests__/async-validation-form.fixture.svelte.d.ts.map +0 -1
  56. package/dist/components/forms/__tests__/async-validation-snapshot-form.fixture.svelte +0 -21
  57. package/dist/components/forms/__tests__/async-validation-snapshot-form.fixture.svelte.d.ts +0 -8
  58. package/dist/components/forms/__tests__/async-validation-snapshot-form.fixture.svelte.d.ts.map +0 -1
  59. package/dist/components/forms/__tests__/composite-inputs-a11y.test.js +0 -69
  60. package/dist/components/forms/__tests__/compound-registration-wrapper.fixture.svelte +0 -27
  61. package/dist/components/forms/__tests__/compound-registration-wrapper.fixture.svelte.d.ts +0 -9
  62. package/dist/components/forms/__tests__/compound-registration-wrapper.fixture.svelte.d.ts.map +0 -1
  63. package/dist/components/forms/__tests__/custom-registration-field.fixture.svelte +0 -48
  64. package/dist/components/forms/__tests__/custom-registration-field.fixture.svelte.d.ts +0 -10
  65. package/dist/components/forms/__tests__/custom-registration-field.fixture.svelte.d.ts.map +0 -1
  66. package/dist/components/forms/__tests__/form-extraction-values.fixture.svelte +0 -26
  67. package/dist/components/forms/__tests__/form-extraction-values.fixture.svelte.d.ts +0 -13
  68. package/dist/components/forms/__tests__/form-extraction-values.fixture.svelte.d.ts.map +0 -1
  69. package/dist/components/forms/__tests__/form-registration-lifecycle.fixture.svelte +0 -52
  70. package/dist/components/forms/__tests__/form-registration-lifecycle.fixture.svelte.d.ts +0 -16
  71. package/dist/components/forms/__tests__/form-registration-lifecycle.fixture.svelte.d.ts.map +0 -1
  72. package/dist/components/forms/__tests__/form-scope.fixture.svelte +0 -30
  73. package/dist/components/forms/__tests__/form-scope.fixture.svelte.d.ts +0 -10
  74. package/dist/components/forms/__tests__/form-scope.fixture.svelte.d.ts.map +0 -1
  75. package/dist/components/forms/__tests__/form-with-fields.fixture.svelte +0 -158
  76. package/dist/components/forms/__tests__/form-with-fields.fixture.svelte.d.ts +0 -51
  77. package/dist/components/forms/__tests__/form-with-fields.fixture.svelte.d.ts.map +0 -1
  78. package/dist/components/forms/__tests__/form-with-policy-field.fixture.svelte +0 -24
  79. package/dist/components/forms/__tests__/form-with-policy-field.fixture.svelte.d.ts +0 -11
  80. package/dist/components/forms/__tests__/form-with-policy-field.fixture.svelte.d.ts.map +0 -1
  81. package/dist/components/forms/__tests__/form-with-structured-fields.fixture.svelte +0 -105
  82. package/dist/components/forms/__tests__/form-with-structured-fields.fixture.svelte.d.ts +0 -42
  83. package/dist/components/forms/__tests__/form-with-structured-fields.fixture.svelte.d.ts.map +0 -1
  84. package/dist/components/forms/__tests__/form-with-ui-controls.fixture.svelte +0 -84
  85. package/dist/components/forms/__tests__/form-with-ui-controls.fixture.svelte.d.ts +0 -14
  86. package/dist/components/forms/__tests__/form-with-ui-controls.fixture.svelte.d.ts.map +0 -1
  87. package/dist/components/forms/__tests__/legacy-form-context.fixture.svelte +0 -44
  88. package/dist/components/forms/__tests__/legacy-form-context.fixture.svelte.d.ts +0 -9
  89. package/dist/components/forms/__tests__/legacy-form-context.fixture.svelte.d.ts.map +0 -1
  90. package/dist/components/forms/__tests__/mic-keyboard-a11y.test.js +0 -74
  91. package/dist/components/forms/__tests__/policy-field.fixture.svelte +0 -35
  92. package/dist/components/forms/__tests__/policy-field.fixture.svelte.d.ts +0 -8
  93. package/dist/components/forms/__tests__/policy-field.fixture.svelte.d.ts.map +0 -1
  94. package/dist/components/forms/__tests__/rich-inputs-a11y.test.js +0 -87
  95. package/dist/components/settings/__tests__/SettingsCatalog.test.js +0 -69
  96. package/dist/components/settings/__tests__/catalog.test.js +0 -46
  97. package/dist/components/workspace/__tests__/AdminShell.responsive.test.js +0 -597
  98. package/dist/components/workspace/__tests__/AdminShell.test.js +0 -572
  99. package/dist/components/workspace/__tests__/RoleShell.test.js +0 -772
  100. package/dist/components/workspace/__tests__/TenantNav.test.js +0 -208
  101. package/dist/components/workspace/__tests__/WorkspaceAccountMenu.test.js +0 -79
  102. package/dist/components/workspace/__tests__/WorkspaceShell.test.js +0 -630
  103. package/dist/components/workspace/__tests__/admin-shell-header-overflow.test.js +0 -25
  104. package/dist/components/workspace/__tests__/admin-shell-hotkeys.test.js +0 -34
  105. package/dist/components/workspace/__tests__/admin-shell-mobile.test.js +0 -217
  106. package/dist/components/workspace/__tests__/admin-shell-mutation-effect-harness.svelte +0 -52
  107. package/dist/components/workspace/__tests__/admin-shell-mutation-effect-harness.svelte.d.ts +0 -23
  108. package/dist/components/workspace/__tests__/admin-shell-mutation-effect-harness.svelte.d.ts.map +0 -1
  109. package/dist/components/workspace/__tests__/admin-shell-page-contracts.test.js +0 -30
  110. package/dist/components/workspace/__tests__/admin-shell-responsive-state.test.js +0 -184
  111. package/dist/components/workspace/__tests__/admin-shell-settings.test.js +0 -25
  112. package/dist/components/workspace/__tests__/admin-shell-state.test.js +0 -286
  113. package/dist/components/workspace/__tests__/admin-shell-theme-hooks.test.js +0 -36
  114. package/dist/components/workspace/__tests__/breadcrumbs-helpers.test.js +0 -141
  115. package/dist/components/workspace/__tests__/context-forwarding-harness.svelte +0 -45
  116. package/dist/components/workspace/__tests__/context-forwarding-harness.svelte.d.ts +0 -21
  117. package/dist/components/workspace/__tests__/context-forwarding-harness.svelte.d.ts.map +0 -1
  118. package/dist/components/workspace/__tests__/define-tools-dock.test.js +0 -1188
  119. package/dist/components/workspace/__tests__/harness.svelte +0 -25
  120. package/dist/components/workspace/__tests__/harness.svelte.d.ts +0 -14
  121. package/dist/components/workspace/__tests__/harness.svelte.d.ts.map +0 -1
  122. package/dist/components/workspace/__tests__/index.test.js +0 -28
  123. package/dist/components/workspace/__tests__/legacy-index.test.js +0 -9
  124. package/dist/components/workspace/__tests__/manifest-nav-helpers.test.js +0 -24
  125. package/dist/components/workspace/__tests__/manifest-nav.test.js +0 -599
  126. package/dist/components/workspace/__tests__/nav-helpers.test.js +0 -95
  127. package/dist/components/workspace/__tests__/render-harness.svelte +0 -66
  128. package/dist/components/workspace/__tests__/render-harness.svelte.d.ts +0 -32
  129. package/dist/components/workspace/__tests__/render-harness.svelte.d.ts.map +0 -1
  130. package/dist/components/workspace/__tests__/render-tools-dock.test.js +0 -260
  131. package/dist/components/workspace/__tests__/role-shell-bind-harness.svelte +0 -58
  132. package/dist/components/workspace/__tests__/role-shell-bind-harness.svelte.d.ts +0 -16
  133. package/dist/components/workspace/__tests__/role-shell-bind-harness.svelte.d.ts.map +0 -1
  134. package/dist/components/workspace/__tests__/role-shell-switch-harness.svelte +0 -41
  135. package/dist/components/workspace/__tests__/role-shell-switch-harness.svelte.d.ts +0 -13
  136. package/dist/components/workspace/__tests__/role-shell-switch-harness.svelte.d.ts.map +0 -1
  137. package/dist/components/workspace/__tests__/shell-chrome-components.test.js +0 -108
  138. package/dist/components/workspace/__tests__/system-feed.test.js +0 -327
  139. package/dist/components/workspace/__tests__/test-icon.svelte +0 -17
  140. package/dist/components/workspace/__tests__/test-icon.svelte.d.ts +0 -19
  141. package/dist/components/workspace/__tests__/test-icon.svelte.d.ts.map +0 -1
  142. package/dist/components/workspace/__tests__/typed-tool-fixture/TypedTool.svelte +0 -38
  143. package/dist/components/workspace/__tests__/typed-tool-fixture/TypedTool.svelte.d.ts +0 -22
  144. package/dist/components/workspace/__tests__/typed-tool-fixture/TypedTool.svelte.d.ts.map +0 -1
  145. package/dist/components/workspace/__tests__/typed-tool-fixture/register-typed-tool.d.ts +0 -66
  146. package/dist/components/workspace/__tests__/typed-tool-fixture/register-typed-tool.d.ts.map +0 -1
  147. package/dist/components/workspace/__tests__/typed-tool-fixture/register-typed-tool.js +0 -115
  148. package/dist/components/workspace/__tests__/typed-tool-fixture/typed-tool-types.d.ts +0 -15
  149. package/dist/components/workspace/__tests__/typed-tool-fixture/typed-tool-types.d.ts.map +0 -1
  150. package/dist/components/workspace/__tests__/typed-tool-fixture/typed-tool-types.js +0 -7
  151. package/dist/components/workspace/__tests__/typed-tool-fixture.test.js +0 -115
  152. package/dist/components/workspace/__tests__/use-harness-orphan.svelte +0 -9
  153. package/dist/components/workspace/__tests__/use-harness-orphan.svelte.d.ts +0 -19
  154. package/dist/components/workspace/__tests__/use-harness-orphan.svelte.d.ts.map +0 -1
  155. package/dist/components/workspace/__tests__/use-harness.svelte +0 -23
  156. package/dist/components/workspace/__tests__/use-harness.svelte.d.ts +0 -8
  157. package/dist/components/workspace/__tests__/use-harness.svelte.d.ts.map +0 -1
  158. package/dist/components/workspace/__tests__/use-tools-dock.test.js +0 -33
  159. package/dist/components/workspace/__tests__/workspace-shell-bind-harness.svelte +0 -43
  160. package/dist/components/workspace/__tests__/workspace-shell-bind-harness.svelte.d.ts +0 -11
  161. package/dist/components/workspace/__tests__/workspace-shell-bind-harness.svelte.d.ts.map +0 -1
  162. package/dist/components/workspace/server/__tests__/compose-availability.test.js +0 -383
  163. package/dist/components/workspace/server/__tests__/typed-context-fixture.d.ts +0 -78
  164. package/dist/components/workspace/server/__tests__/typed-context-fixture.d.ts.map +0 -1
  165. package/dist/components/workspace/server/__tests__/typed-context-fixture.js +0 -104
  166. package/dist/hooks/__tests__/stt-consumer.fixture.svelte +0 -25
  167. package/dist/hooks/__tests__/stt-consumer.fixture.svelte.d.ts +0 -15
  168. package/dist/hooks/__tests__/stt-consumer.fixture.svelte.d.ts.map +0 -1
  169. package/dist/hooks/__tests__/stt-ownership-harness.svelte +0 -42
  170. package/dist/hooks/__tests__/stt-ownership-harness.svelte.d.ts +0 -20
  171. package/dist/hooks/__tests__/stt-ownership-harness.svelte.d.ts.map +0 -1
  172. package/dist/hooks/__tests__/useSTT-ownership.test.js +0 -102
  173. package/dist/i18n/__tests__/server.spec.js +0 -57
  174. package/dist/internal/logger.test.js +0 -54
  175. package/dist/provider-mode.test.js +0 -86
  176. package/dist/provider-webmcp.test.js +0 -244
  177. package/dist/state/__tests__/app-state-ai-lifecycle.test.js +0 -240
  178. package/dist/state/__tests__/warm-clients.test.js +0 -40
  179. package/dist/web/__tests__/activity-feed-harness.svelte +0 -32
  180. package/dist/web/__tests__/activity-feed-harness.svelte.d.ts +0 -18
  181. package/dist/web/__tests__/activity-feed-harness.svelte.d.ts.map +0 -1
  182. package/dist/web/__tests__/activity-feed.integration.svelte.test.js +0 -190
  183. package/dist/web/__tests__/activity-feed.svelte.test.js +0 -235
  184. package/dist/web/__tests__/assistant-dock.integration.svelte.test.js +0 -204
  185. package/dist/web/__tests__/data-surface-conformance.integration.svelte.test.js +0 -1392
  186. package/dist/web/__tests__/harness.svelte +0 -25
  187. package/dist/web/__tests__/harness.svelte.d.ts +0 -15
  188. package/dist/web/__tests__/harness.svelte.d.ts.map +0 -1
  189. package/dist/web/__tests__/list-data-surface.fixture.svelte +0 -59
  190. package/dist/web/__tests__/list-data-surface.fixture.svelte.d.ts +0 -14
  191. package/dist/web/__tests__/list-data-surface.fixture.svelte.d.ts.map +0 -1
  192. package/dist/web/__tests__/list-data-surface.svelte.test.js +0 -106
  193. package/dist/web/__tests__/list-data-surface.test.js +0 -651
  194. package/dist/web/__tests__/list-sort-surface.fixture.svelte +0 -41
  195. package/dist/web/__tests__/list-sort-surface.fixture.svelte.d.ts +0 -15
  196. package/dist/web/__tests__/list-sort-surface.fixture.svelte.d.ts.map +0 -1
  197. package/dist/web/__tests__/list-sort-surface.svelte.test.js +0 -59
  198. package/dist/web/__tests__/live-collection.svelte.test.js +0 -185
  199. package/dist/web/__tests__/provider-ui-registry.fixture.svelte +0 -43
  200. package/dist/web/__tests__/provider-ui-registry.fixture.svelte.d.ts +0 -13
  201. package/dist/web/__tests__/provider-ui-registry.fixture.svelte.d.ts.map +0 -1
  202. package/dist/web/__tests__/provider-ui-registry.test.js +0 -235
  203. package/dist/web/__tests__/remote-query-harness.svelte +0 -27
  204. package/dist/web/__tests__/remote-query-harness.svelte.d.ts +0 -11
  205. package/dist/web/__tests__/remote-query-harness.svelte.d.ts.map +0 -1
  206. package/dist/web/__tests__/remote-query.svelte.test.js +0 -101
  207. package/dist/web/__tests__/surfaces.child.fixture.svelte +0 -69
  208. package/dist/web/__tests__/surfaces.child.fixture.svelte.d.ts +0 -16
  209. package/dist/web/__tests__/surfaces.child.fixture.svelte.d.ts.map +0 -1
  210. package/dist/web/__tests__/surfaces.fixture.svelte +0 -26
  211. package/dist/web/__tests__/surfaces.fixture.svelte.d.ts +0 -18
  212. package/dist/web/__tests__/surfaces.fixture.svelte.d.ts.map +0 -1
  213. package/dist/web/__tests__/surfaces.svelte.test.js +0 -140
  214. package/dist/web/__tests__/update-available-harness.svelte +0 -46
  215. package/dist/web/__tests__/update-available-harness.svelte.d.ts +0 -22
  216. package/dist/web/__tests__/update-available-harness.svelte.d.ts.map +0 -1
  217. package/dist/web/__tests__/update-available.svelte.test.js +0 -137
  218. package/dist/web/__tests__/view-intent-provider.fixture.svelte +0 -28
  219. package/dist/web/__tests__/view-intent-provider.fixture.svelte.d.ts +0 -13
  220. package/dist/web/__tests__/view-intent-provider.fixture.svelte.d.ts.map +0 -1
  221. package/dist/web/__tests__/view-intent.fixture.svelte +0 -33
  222. package/dist/web/__tests__/view-intent.fixture.svelte.d.ts +0 -15
  223. package/dist/web/__tests__/view-intent.fixture.svelte.d.ts.map +0 -1
  224. package/dist/web/__tests__/view-intent.intents.d.ts +0 -14
  225. package/dist/web/__tests__/view-intent.intents.d.ts.map +0 -1
  226. package/dist/web/__tests__/view-intent.intents.js +0 -38
  227. package/dist/web/__tests__/view-intent.svelte.test.js +0 -244
  228. package/dist/web/__tests__/webmcp-composed.child.fixture.svelte +0 -73
  229. package/dist/web/__tests__/webmcp-composed.child.fixture.svelte.d.ts +0 -8
  230. package/dist/web/__tests__/webmcp-composed.child.fixture.svelte.d.ts.map +0 -1
  231. package/dist/web/__tests__/webmcp-composed.fixture.svelte +0 -52
  232. package/dist/web/__tests__/webmcp-composed.fixture.svelte.d.ts +0 -12
  233. package/dist/web/__tests__/webmcp-composed.fixture.svelte.d.ts.map +0 -1
  234. package/dist/web/__tests__/webmcp-composed.integration.svelte.test.js +0 -251
  235. package/dist/web/__tests__/webmcp-effects-reactivity.child.fixture.svelte +0 -16
  236. package/dist/web/__tests__/webmcp-effects-reactivity.child.fixture.svelte.d.ts +0 -19
  237. package/dist/web/__tests__/webmcp-effects-reactivity.child.fixture.svelte.d.ts.map +0 -1
  238. package/dist/web/__tests__/webmcp-effects-reactivity.fixture.svelte +0 -11
  239. package/dist/web/__tests__/webmcp-effects-reactivity.fixture.svelte.d.ts +0 -8
  240. package/dist/web/__tests__/webmcp-effects-reactivity.fixture.svelte.d.ts.map +0 -1
  241. package/dist/web/__tests__/webmcp-harness-reactive-samename.svelte +0 -20
  242. package/dist/web/__tests__/webmcp-harness-reactive-samename.svelte.d.ts +0 -7
  243. package/dist/web/__tests__/webmcp-harness-reactive-samename.svelte.d.ts.map +0 -1
  244. package/dist/web/__tests__/webmcp-harness-unannotated.svelte +0 -14
  245. package/dist/web/__tests__/webmcp-harness-unannotated.svelte.d.ts +0 -19
  246. package/dist/web/__tests__/webmcp-harness-unannotated.svelte.d.ts.map +0 -1
  247. package/dist/web/__tests__/webmcp-harness.svelte +0 -18
  248. package/dist/web/__tests__/webmcp-harness.svelte.d.ts +0 -7
  249. package/dist/web/__tests__/webmcp-harness.svelte.d.ts.map +0 -1
  250. package/dist/web/__tests__/webmcp-ssr.fixture.svelte +0 -13
  251. package/dist/web/__tests__/webmcp-ssr.fixture.svelte.d.ts +0 -19
  252. package/dist/web/__tests__/webmcp-ssr.fixture.svelte.d.ts.map +0 -1
  253. package/dist/web/__tests__/webmcp-ui.test.js +0 -723
  254. package/dist/web/__tests__/webmcp.test.js +0 -234
@@ -1,2336 +0,0 @@
1
- import { createControlInteractionRegistry, executeLocalControlBatch, executeLocalControlCommand, } from '@happyvertical/smrt-ui/forms';
2
- import { render, screen, waitFor } from '@testing-library/svelte';
3
- import userEvent from '@testing-library/user-event';
4
- import { tick } from 'svelte';
5
- import { afterEach, describe, expect, it, vi } from 'vitest';
6
- const appState = vi.hoisted(() => ({ mode: 'default' }));
7
- vi.mock('../../../hooks/useAppState.svelte.js', () => ({
8
- useAppState: () => ({ state: appState, setMode: vi.fn() }),
9
- }));
10
- vi.mock('../../../hooks/useSTT.svelte.js', () => ({
11
- useSTT: () => ({
12
- isListening: false,
13
- lastResult: '',
14
- isReady: false,
15
- adapterType: null,
16
- isInitializing: false,
17
- downloadProgress: 0,
18
- initialize: vi.fn(),
19
- start: vi.fn(),
20
- stop: vi.fn(),
21
- }),
22
- }));
23
- import AsyncValidationForm from './async-validation-form.fixture.svelte';
24
- import AsyncValidationSnapshotForm from './async-validation-snapshot-form.fixture.svelte';
25
- import FormRegistrationLifecycle from './form-registration-lifecycle.fixture.svelte';
26
- import FormWithFields from './form-with-fields.fixture.svelte';
27
- import FormWithPolicyField from './form-with-policy-field.fixture.svelte';
28
- import FormWithStructuredFields from './form-with-structured-fields.fixture.svelte';
29
- import LegacyFormContext from './legacy-form-context.fixture.svelte';
30
- afterEach(() => {
31
- delete document.modelContext;
32
- appState.mode = 'default';
33
- });
34
- function dispatchLocalGesture(execute) {
35
- const target = new EventTarget();
36
- let pending;
37
- target.addEventListener('click', (event) => {
38
- pending = execute(event);
39
- }, { once: true });
40
- target.dispatchEvent(new Event('click'));
41
- if (!pending)
42
- throw new Error('local gesture handler did not run');
43
- return pending;
44
- }
45
- describe('Form WebMCP staged-edit intent', () => {
46
- it('awaits an async false field validation before the WebMCP form submits', async () => {
47
- const registered = [];
48
- document.modelContext = {
49
- async registerTool(tool) {
50
- registered.push(tool);
51
- },
52
- };
53
- let resolveValidation = () => { };
54
- const validation = new Promise((resolve) => {
55
- resolveValidation = resolve;
56
- });
57
- const validate = vi.fn(() => validation);
58
- const onsubmit = vi.fn();
59
- render(AsyncValidationForm, {
60
- props: { onsubmit, validate, webmcp: true },
61
- });
62
- const _regLenBefore = registered.length;
63
- await tick();
64
- await tick();
65
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
66
- expect(registered).toHaveLength(1);
67
- await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
68
- await waitFor(() => expect(validate).toHaveBeenCalledTimes(1));
69
- expect(onsubmit).not.toHaveBeenCalled();
70
- resolveValidation(false);
71
- await Promise.resolve();
72
- await Promise.resolve();
73
- expect(onsubmit).not.toHaveBeenCalled();
74
- });
75
- it('awaits a cross-realm async false validation before the WebMCP form submits', async () => {
76
- const frame = document.createElement('iframe');
77
- document.body.append(frame);
78
- const ForeignPromise = frame.contentWindow?.Promise;
79
- if (!ForeignPromise)
80
- throw new Error('iframe Promise constructor unavailable');
81
- expect(ForeignPromise).not.toBe(Promise);
82
- const validation = ForeignPromise.resolve(false);
83
- expect(validation).not.toBeInstanceOf(Promise);
84
- const validate = vi.fn(() => validation);
85
- const onsubmit = vi.fn();
86
- render(AsyncValidationForm, {
87
- props: { onsubmit, validate, webmcp: true },
88
- });
89
- await tick();
90
- await tick();
91
- await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
92
- await waitFor(() => expect(validate).toHaveBeenCalledTimes(1));
93
- await Promise.resolve();
94
- await Promise.resolve();
95
- expect(onsubmit).not.toHaveBeenCalled();
96
- frame.remove();
97
- });
98
- it('submits after an async true field validation', async () => {
99
- const validate = vi.fn(async () => true);
100
- const onsubmit = vi.fn();
101
- render(AsyncValidationForm, {
102
- props: { onsubmit, validate, webmcp: true },
103
- });
104
- await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
105
- await waitFor(() => expect(validate).toHaveBeenCalledTimes(1));
106
- await waitFor(() => expect(onsubmit).toHaveBeenCalledWith({ 'async-invalid': 'proposal' }));
107
- });
108
- it('blocks submission when an async field validation rejects', async () => {
109
- const validate = vi.fn(() => Promise.reject(new Error('validation_failed')));
110
- const onsubmit = vi.fn();
111
- render(AsyncValidationForm, {
112
- props: { onsubmit, validate, webmcp: true },
113
- });
114
- await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
115
- await waitFor(() => expect(validate).toHaveBeenCalledTimes(1));
116
- await Promise.resolve();
117
- await Promise.resolve();
118
- expect(onsubmit).not.toHaveBeenCalled();
119
- });
120
- it('submits a coherent field snapshot when validation allows intervening edits', async () => {
121
- let resolveValidation = () => { };
122
- const validation = new Promise((resolve) => {
123
- resolveValidation = resolve;
124
- });
125
- const validate = vi.fn(() => validation);
126
- const onsubmit = vi.fn();
127
- render(AsyncValidationSnapshotForm, { props: { onsubmit, validate } });
128
- await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
129
- await waitFor(() => expect(validate).toHaveBeenCalledTimes(1));
130
- await userEvent.clear(screen.getByRole('textbox', { name: 'Later value' }));
131
- await userEvent.type(screen.getByRole('textbox', { name: 'Later value' }), 'after');
132
- resolveValidation(true);
133
- await waitFor(() => expect(onsubmit).toHaveBeenCalledWith({
134
- 'async-invalid': 'proposal',
135
- later: 'before',
136
- }));
137
- });
138
- it('calls onsubmit synchronously when a field validator is synchronous', async () => {
139
- const validate = vi.fn(() => true);
140
- const onsubmit = vi.fn();
141
- const { container } = render(AsyncValidationForm, {
142
- props: { onsubmit, validate, webmcp: true },
143
- });
144
- const form = container.querySelector('form');
145
- if (!form)
146
- throw new Error('Form not rendered');
147
- form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
148
- expect(validate).toHaveBeenCalledTimes(1);
149
- expect(onsubmit).toHaveBeenCalledWith({ 'async-invalid': 'proposal' });
150
- });
151
- it('registers a field-derived tool and stages without mutating or submitting', async () => {
152
- const registered = [];
153
- document.modelContext = {
154
- async registerTool(tool) {
155
- registered.push(tool);
156
- },
157
- };
158
- const onsubmit = vi.fn();
159
- render(FormWithFields, { props: { webmcp: true, onsubmit } });
160
- const _regLenBefore = registered.length;
161
- await tick();
162
- await tick();
163
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
164
- expect(registered).toHaveLength(1);
165
- expect(registered[0].inputSchema).toMatchObject({
166
- type: 'object',
167
- properties: { fullname: { type: 'string' }, age: { type: 'number' } },
168
- });
169
- expect(registered[0].name).toContain('stage_changes');
170
- const result = await registered[0].execute({
171
- fullname: 'Ada Lovelace',
172
- age: 36,
173
- });
174
- expect(result).toBe('Staged 2 changes for review');
175
- expect(onsubmit).not.toHaveBeenCalled();
176
- await waitFor(() => expect(screen.getByRole('region', { name: 'Review proposed changes' })).toBeInTheDocument());
177
- expect(screen.getByRole('textbox', { name: 'Full name' })).toHaveValue('');
178
- });
179
- it('stages canonical rich values and rejects malformed text through WebMCP', async () => {
180
- const registered = [];
181
- document.modelContext = {
182
- registerTool(tool) {
183
- registered.push(tool);
184
- },
185
- };
186
- const numberChanged = vi.fn();
187
- const checkboxChanged = vi.fn();
188
- const selectChanged = vi.fn();
189
- const registry = createControlInteractionRegistry({
190
- isLocalGesture: () => true,
191
- });
192
- render(FormWithFields, {
193
- props: {
194
- webmcp: true,
195
- interactionRegistry: registry,
196
- showClearFields: true,
197
- onnumberchange: numberChanged,
198
- oncheckboxchange: checkboxChanged,
199
- onselectchange: selectChanged,
200
- },
201
- });
202
- const _regLenBefore = registered.length;
203
- await tick();
204
- await tick();
205
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
206
- const tool = registered.at(-1);
207
- if (!tool)
208
- throw new Error('WebMCP tool was not registered');
209
- expect(await tool.execute({ fullname: { malformed: true } })).toBe('Staged 0 changes for review; 1 rejected');
210
- expect(registry.list().find((item) => item.identity.controlId === 'fullname')
211
- ?.state.staged).toBeUndefined();
212
- expect(await tool.execute({ age: '42', enabled: 'false', choice: 'First' })).toBe('Staged 3 changes for review');
213
- expect(registry.list().find((item) => item.identity.controlId === 'age')?.state
214
- .staged?.value).toBe(42);
215
- expect(registry.list().find((item) => item.identity.controlId === 'enabled')
216
- ?.state.staged?.value).toBe(false);
217
- expect(registry.list().find((item) => item.identity.controlId === 'choice')
218
- ?.state.staged?.value).toBe('first');
219
- await userEvent.click(await screen.findByRole('button', { name: 'Apply valid changes' }));
220
- expect(screen.getByRole('spinbutton', { name: 'Age' })).toHaveValue(42);
221
- expect(screen.getByRole('checkbox', { name: 'Enabled' })).not.toBeChecked();
222
- expect(screen.getByRole('combobox', { name: 'Choice' })).toHaveValue('first');
223
- expect(numberChanged).toHaveBeenLastCalledWith(42);
224
- expect(checkboxChanged).toHaveBeenLastCalledWith(false);
225
- expect(selectChanged).toHaveBeenLastCalledWith('first');
226
- });
227
- it('stages and applies a canonical null for an empty optional number', async () => {
228
- const registered = [];
229
- document.modelContext = {
230
- registerTool(tool) {
231
- registered.push(tool);
232
- },
233
- };
234
- const numberChanged = vi.fn();
235
- const registry = createControlInteractionRegistry({
236
- isLocalGesture: () => true,
237
- });
238
- render(FormWithFields, {
239
- props: {
240
- webmcp: true,
241
- numberValue: 42,
242
- onnumberchange: numberChanged,
243
- interactionRegistry: registry,
244
- },
245
- });
246
- const _regLenBefore = registered.length;
247
- await tick();
248
- await tick();
249
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
250
- expect(await registered.at(-1)?.execute({ age: '' })).toBe('Staged 1 change for review');
251
- const age = registry
252
- .list()
253
- .find((snapshot) => snapshot.identity.controlId === 'age');
254
- expect(age?.state.staged).toMatchObject({ value: null, valid: true });
255
- await userEvent.click(await screen.findByRole('button', { name: 'Apply valid changes' }));
256
- expect(screen.getByRole('spinbutton', { name: 'Age' })).toHaveValue(null);
257
- expect(numberChanged).toHaveBeenLastCalledWith(null);
258
- });
259
- it('returns focus to a rich field after applying its final proposal', async () => {
260
- const registered = [];
261
- document.modelContext = {
262
- registerTool(tool) {
263
- registered.push(tool);
264
- },
265
- };
266
- const interactionRegistry = createControlInteractionRegistry({
267
- isLocalGesture: () => true,
268
- });
269
- render(FormWithFields, {
270
- props: { webmcp: true, showAge: false, interactionRegistry },
271
- });
272
- const _regLenBefore = registered.length;
273
- await tick();
274
- await tick();
275
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
276
- await registered.at(-1)?.execute({ fullname: 'Ada Lovelace' });
277
- await userEvent.click(await screen.findByRole('button', { name: 'Apply valid changes' }));
278
- await waitFor(() => expect(screen.getByRole('textbox', { name: 'Full name' })).toHaveFocus());
279
- });
280
- it('publishes rich control identity and staged state on the native field', async () => {
281
- const registry = createControlInteractionRegistry({
282
- isLocalGesture: () => true,
283
- });
284
- render(FormWithFields, {
285
- props: {
286
- showAge: false,
287
- interactionRegistry: registry,
288
- formSubject: { type: 'person', id: 'person-1' },
289
- },
290
- });
291
- await tick();
292
- const fullname = registry
293
- .list()
294
- .find((snapshot) => snapshot.identity.controlId === 'fullname');
295
- if (!fullname)
296
- throw new Error('fullname was not registered');
297
- const input = screen.getByRole('textbox', { name: 'Full name' });
298
- expect(input).toHaveAttribute('data-smrt-control', 'fullname');
299
- expect(input).toHaveAttribute('data-smrt-form', fullname.identity.formId);
300
- expect(input).toHaveAttribute('data-smrt-subject-type', 'person');
301
- expect(input).toHaveAttribute('data-smrt-subject-id', 'person-1');
302
- await registry.execute({ action: 'stage', identity: fullname.identity, value: 'Ada' }, { source: 'agent' });
303
- await waitFor(() => expect(input).toHaveAttribute('data-smrt-staged', 'true'));
304
- await userEvent.click(screen.getByRole('button', { name: 'Discard valid changes' }));
305
- await waitFor(() => expect(input).not.toHaveAttribute('data-smrt-staged'));
306
- expect(input).toHaveFocus();
307
- });
308
- it('marks empty required rich scalar proposals invalid before application', async () => {
309
- const registry = createControlInteractionRegistry();
310
- const textChanged = vi.fn();
311
- const checkboxChanged = vi.fn();
312
- const selectChanged = vi.fn();
313
- const notesChanged = vi.fn();
314
- render(FormWithFields, {
315
- props: {
316
- showAge: false,
317
- showClearFields: true,
318
- showScalarFields: true,
319
- textValue: 'Ada',
320
- notesValue: 'Existing',
321
- textRequired: true,
322
- checkboxRequired: true,
323
- selectRequired: true,
324
- notesRequired: true,
325
- interactionRegistry: registry,
326
- ontextchange: textChanged,
327
- oncheckboxchange: checkboxChanged,
328
- onselectchange: selectChanged,
329
- onnoteschange: notesChanged,
330
- },
331
- });
332
- await tick();
333
- const candidates = new Map([
334
- ['fullname', ' '],
335
- ['enabled', false],
336
- ['choice', ''],
337
- ['notes', ' '],
338
- ]);
339
- const commands = [];
340
- for (const [controlId, value] of candidates) {
341
- const snapshot = registry
342
- .list()
343
- .find((item) => item.identity.controlId === controlId);
344
- if (!snapshot)
345
- throw new Error(`${controlId} was not registered`);
346
- await registry.execute({ action: 'stage', identity: snapshot.identity, value }, { source: 'agent' });
347
- expect(registry.get(snapshot.identity)?.state.staged).toMatchObject({
348
- valid: false,
349
- });
350
- commands.push({
351
- action: 'apply',
352
- identity: snapshot.identity,
353
- revision: registry.get(snapshot.identity)?.state.staged?.revision,
354
- });
355
- }
356
- const applied = await dispatchLocalGesture((event) => executeLocalControlBatch(registry, commands, event));
357
- expect(applied.results).toHaveLength(4);
358
- expect(applied.results.every((result) => !result.ok)).toBe(true);
359
- await userEvent.click(screen.getByRole('button', { name: 'Apply valid changes' }));
360
- expect(screen.getByRole('textbox', { name: 'Full name*' })).toHaveValue('Ada');
361
- expect(screen.getByRole('checkbox', { name: 'Enabled*' })).toBeChecked();
362
- expect(screen.getByRole('combobox', { name: 'Choice*' })).toHaveValue('second');
363
- expect(screen.getByRole('textbox', { name: 'Notes*' })).toHaveValue('Existing');
364
- expect(textChanged).not.toHaveBeenCalled();
365
- expect(checkboxChanged).not.toHaveBeenCalled();
366
- expect(selectChanged).not.toHaveBeenCalled();
367
- expect(notesChanged).not.toHaveBeenCalled();
368
- expect(commands.every((command) => registry.get(command.identity)?.state.staged !== undefined)).toBe(true);
369
- });
370
- it('preserves staged state when an unrelated sibling field mounts', async () => {
371
- const registered = [];
372
- document.modelContext = {
373
- registerTool(tool) {
374
- registered.push(tool);
375
- },
376
- };
377
- const registry = createControlInteractionRegistry({
378
- isLocalGesture: () => true,
379
- });
380
- const view = render(FormWithFields, {
381
- props: { webmcp: true, interactionRegistry: registry },
382
- });
383
- const _regLenBefore = registered.length;
384
- await tick();
385
- await tick();
386
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
387
- expect(await registered.at(-1)?.execute({ fullname: 'Ada' })).toBe('Staged 1 change for review');
388
- await view.rerender({
389
- webmcp: true,
390
- interactionRegistry: registry,
391
- showMoney: true,
392
- });
393
- await tick();
394
- expect(registry
395
- .list()
396
- .find((snapshot) => snapshot.identity.controlId === 'fullname')?.state
397
- .staged?.value).toBe('Ada');
398
- const fullname = registry
399
- .list()
400
- .find((snapshot) => snapshot.identity.controlId === 'fullname');
401
- if (!fullname)
402
- throw new Error('fullname was not registered');
403
- expect(await dispatchLocalGesture((event) => executeLocalControlBatch(registry, [
404
- {
405
- action: 'apply',
406
- identity: fullname.identity,
407
- revision: fullname.state.staged?.revision,
408
- },
409
- ], event))).toMatchObject({ ok: true });
410
- await view.rerender({
411
- webmcp: true,
412
- interactionRegistry: registry,
413
- showMoney: false,
414
- });
415
- await tick();
416
- expect(await dispatchLocalGesture((event) => executeLocalControlBatch(registry, [{ action: 'undo', identity: fullname.identity }], event))).toMatchObject({ ok: true });
417
- expect(registry.get(fullname.identity)?.state.value).toBe('');
418
- });
419
- it('preserves staged and undo state across live field label changes', async () => {
420
- const registry = createControlInteractionRegistry({
421
- isLocalGesture: () => true,
422
- });
423
- const view = render(FormWithFields, {
424
- props: {
425
- interactionRegistry: registry,
426
- ageLabel: 'Minimum age',
427
- },
428
- });
429
- await tick();
430
- const age = registry
431
- .list()
432
- .find((snapshot) => snapshot.identity.controlId === 'age');
433
- if (!age)
434
- throw new Error('age was not registered');
435
- await registry.execute({ action: 'stage', identity: age.identity, value: 42 }, { source: 'agent' });
436
- const revision = registry.get(age.identity)?.state.staged?.revision;
437
- await view.rerender({
438
- interactionRegistry: registry,
439
- ageLabel: 'Maximum age',
440
- });
441
- await waitFor(() => expect(registry.get(age.identity)?.metadata.label).toBe('Maximum age'));
442
- expect(registry.get(age.identity)?.state.staged).toMatchObject({
443
- value: 42,
444
- revision,
445
- });
446
- expect(await screen.findByRole('textbox', {
447
- name: 'Edit proposed value for Maximum age',
448
- })).toHaveValue('42');
449
- expect(await dispatchLocalGesture((event) => executeLocalControlCommand(registry, { action: 'apply', identity: age.identity, revision }, event))).toMatchObject({ ok: true });
450
- expect(registry.get(age.identity)?.state.value).toBe(42);
451
- await view.rerender({
452
- interactionRegistry: registry,
453
- ageLabel: 'Preferred age',
454
- });
455
- expect(await dispatchLocalGesture((event) => executeLocalControlCommand(registry, { action: 'undo', identity: age.identity }, event))).toMatchObject({ ok: true });
456
- expect(registry.get(age.identity)?.state.value).toBeNull();
457
- });
458
- it('preserves state for subject-label replacements but not subject moves', async () => {
459
- const registry = createControlInteractionRegistry({
460
- isLocalGesture: () => true,
461
- });
462
- const view = render(FormWithFields, {
463
- props: {
464
- interactionRegistry: registry,
465
- showAge: false,
466
- formSubject: { type: 'person', id: 'person-1', label: 'Old label' },
467
- },
468
- });
469
- await tick();
470
- const original = registry
471
- .list()
472
- .find((snapshot) => snapshot.identity.controlId === 'fullname');
473
- if (!original)
474
- throw new Error('fullname was not registered');
475
- await registry.execute({ action: 'stage', identity: original.identity, value: 'Grace' }, { source: 'agent' });
476
- const revision = registry.get(original.identity)?.state.staged?.revision;
477
- await view.rerender({
478
- interactionRegistry: registry,
479
- showAge: false,
480
- formSubject: { type: 'person', id: 'person-1', label: 'New label' },
481
- });
482
- await waitFor(() => expect(registry.get(original.identity)?.identity.subject?.label).toBe('New label'));
483
- const replacement = registry.get(original.identity);
484
- expect(replacement?.state.staged).toMatchObject({
485
- value: 'Grace',
486
- revision,
487
- });
488
- const fullname = screen.getByRole('textbox', { name: 'Full name' });
489
- expect(fullname).toHaveAttribute('data-smrt-subject-type', 'person');
490
- expect(fullname).toHaveAttribute('data-smrt-subject-id', 'person-1');
491
- expect(await dispatchLocalGesture((event) => executeLocalControlCommand(registry, {
492
- action: 'apply',
493
- identity: replacement?.identity ?? original.identity,
494
- revision,
495
- }, event))).toMatchObject({ ok: true });
496
- expect(await dispatchLocalGesture((event) => executeLocalControlCommand(registry, {
497
- action: 'undo',
498
- identity: replacement?.identity ?? original.identity,
499
- }, event))).toMatchObject({ ok: true });
500
- expect(registry.get(original.identity)?.state.value).toBe('');
501
- await view.rerender({
502
- interactionRegistry: registry,
503
- showAge: false,
504
- formSubject: { type: 'person', id: 'person-2', label: 'Moved' },
505
- });
506
- await waitFor(() => expect(registry.get(original.identity)).toBeUndefined());
507
- expect(registry.get({
508
- formId: original.identity.formId,
509
- controlId: 'fullname',
510
- subject: { type: 'person', id: 'person-2' },
511
- })?.state.staged).toBeUndefined();
512
- });
513
- it('rejects invalid rich numeric proposals before mutation callbacks run', async () => {
514
- const numberChanged = vi.fn();
515
- const measurementChanged = vi.fn();
516
- const registry = createControlInteractionRegistry({
517
- isLocalGesture: () => true,
518
- });
519
- render(FormWithFields, {
520
- props: {
521
- interactionRegistry: registry,
522
- onnumberchange: numberChanged,
523
- ageStep: 2,
524
- },
525
- });
526
- render(FormWithStructuredFields, {
527
- props: {
528
- interactionRegistry: registry,
529
- onmeasurementchange: measurementChanged,
530
- measurementStep: 2,
531
- },
532
- });
533
- await tick();
534
- const ageIdentity = registry
535
- .list()
536
- .find((snapshot) => snapshot.identity.controlId === 'age')?.identity;
537
- if (!ageIdentity)
538
- throw new Error('age control was not registered');
539
- const measurementIdentity = {
540
- formId: 'structured-fields',
541
- controlId: 'measurement',
542
- };
543
- const applyStaged = () => dispatchLocalGesture((event) => executeLocalControlBatch(registry, registry
544
- .list()
545
- .filter((snapshot) => snapshot.state.staged)
546
- .map((snapshot) => ({
547
- action: 'apply',
548
- identity: snapshot.identity,
549
- revision: snapshot.state.staged?.revision,
550
- })), event));
551
- await registry.execute({
552
- action: 'stage',
553
- identity: ageIdentity,
554
- value: Number.POSITIVE_INFINITY,
555
- }, { source: 'agent' });
556
- await registry.execute({
557
- action: 'stage',
558
- identity: measurementIdentity,
559
- value: { value: Number.POSITIVE_INFINITY, unit: 'ft' },
560
- }, { source: 'agent' });
561
- const results = await applyStaged();
562
- expect(results.results).toHaveLength(2);
563
- expect(results.results.every((result) => !result.ok)).toBe(true);
564
- await registry.execute({ action: 'stage', identity: ageIdentity, value: 3 }, { source: 'agent' });
565
- await registry.execute({
566
- action: 'stage',
567
- identity: measurementIdentity,
568
- value: { value: 3, unit: 'ft' },
569
- }, { source: 'agent' });
570
- expect((await applyStaged()).results.every((result) => !result.ok)).toBe(true);
571
- await registry.execute({
572
- action: 'stage',
573
- identity: measurementIdentity,
574
- value: { value: 2, unit: 'constructor' },
575
- }, { source: 'agent' });
576
- expect((await applyStaged()).results.every((result) => !result.ok)).toBe(true);
577
- expect(numberChanged).not.toHaveBeenCalled();
578
- expect(measurementChanged).not.toHaveBeenCalled();
579
- });
580
- it('rejects invalid scalar date and phone proposals', async () => {
581
- const registered = [];
582
- document.modelContext = {
583
- registerTool(tool) {
584
- registered.push(tool);
585
- },
586
- };
587
- const registry = createControlInteractionRegistry({
588
- isLocalGesture: () => true,
589
- });
590
- render(FormWithFields, {
591
- props: {
592
- webmcp: true,
593
- showAge: false,
594
- showScalarFields: true,
595
- interactionRegistry: registry,
596
- },
597
- });
598
- const _regLenBefore = registered.length;
599
- await tick();
600
- await tick();
601
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
602
- expect(await registered.at(-1)?.execute({
603
- appointment: '2026-02-30',
604
- phone: '21234567890',
605
- })).toBe('Staged 2 changes for review');
606
- for (const controlId of ['appointment', 'phone']) {
607
- const snapshot = registry
608
- .list()
609
- .find((item) => item.identity.controlId === controlId);
610
- expect(snapshot?.state.staged?.valid).toBe(false);
611
- expect(await dispatchLocalGesture((event) => executeLocalControlBatch(registry, [
612
- {
613
- action: 'apply',
614
- identity: snapshot?.identity ?? { formId: '', controlId: '' },
615
- revision: snapshot?.state.staged?.revision,
616
- },
617
- ], event))).toMatchObject({ ok: false });
618
- }
619
- });
620
- it('applies an untouched canonical append-mode value from the mounted review', async () => {
621
- const registered = [];
622
- document.modelContext = {
623
- registerTool(tool) {
624
- registered.push(tool);
625
- },
626
- };
627
- const registry = createControlInteractionRegistry({
628
- isLocalGesture: () => true,
629
- });
630
- render(FormWithFields, {
631
- props: {
632
- webmcp: true,
633
- showAge: false,
634
- showScalarFields: true,
635
- notesValue: 'Existing',
636
- notesAppendMode: true,
637
- interactionRegistry: registry,
638
- },
639
- });
640
- const _regLenBefore = registered.length;
641
- await tick();
642
- await tick();
643
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
644
- expect(await registered.at(-1)?.execute({ notes: 'Proposed' })).toBe('Staged 1 change for review');
645
- const notes = registry
646
- .list()
647
- .find((item) => item.identity.controlId === 'notes');
648
- expect(notes?.state.staged?.value).toBe('Existing\nProposed');
649
- expect(screen.getByRole('textbox', { name: 'Edit proposed value for Notes' })).toHaveValue('Existing\nProposed');
650
- await userEvent.click(screen.getByRole('button', { name: 'Apply Notes' }));
651
- expect(registry.get(notes?.identity ?? { formId: '', controlId: '' })?.state
652
- .value).toBe('Existing\nProposed');
653
- });
654
- it('applies an edited canonical append-mode value from the mounted review', async () => {
655
- const registered = [];
656
- document.modelContext = {
657
- registerTool(tool) {
658
- registered.push(tool);
659
- },
660
- };
661
- const registry = createControlInteractionRegistry({
662
- isLocalGesture: () => true,
663
- });
664
- render(FormWithFields, {
665
- props: {
666
- webmcp: true,
667
- showAge: false,
668
- showScalarFields: true,
669
- notesValue: 'Existing',
670
- notesAppendMode: true,
671
- interactionRegistry: registry,
672
- },
673
- });
674
- const _regLenBefore = registered.length;
675
- await tick();
676
- await tick();
677
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
678
- expect(await registered.at(-1)?.execute({ notes: 'Proposed' })).toBe('Staged 1 change for review');
679
- const notes = registry
680
- .list()
681
- .find((item) => item.identity.controlId === 'notes');
682
- expect(notes?.state.staged?.value).toBe('Existing\nProposed');
683
- const reviewEditor = screen.getByRole('textbox', {
684
- name: 'Edit proposed value for Notes',
685
- });
686
- expect(reviewEditor).toHaveValue('Existing\nProposed');
687
- await userEvent.clear(reviewEditor);
688
- await userEvent.type(reviewEditor, 'Existing{enter}Edited');
689
- expect(reviewEditor).toHaveValue('Existing\nEdited');
690
- await userEvent.click(screen.getByRole('button', { name: 'Apply Notes' }));
691
- expect(registry.get(notes?.identity ?? { formId: '', controlId: '' })?.state
692
- .value).toBe('Existing\nEdited');
693
- });
694
- it('canonically applies a reverse-order measurement proposal from empty', async () => {
695
- const registered = [];
696
- document.modelContext = {
697
- registerTool(tool) {
698
- registered.push(tool);
699
- },
700
- };
701
- const measurementChanged = vi.fn();
702
- const registry = createControlInteractionRegistry({
703
- isLocalGesture: () => true,
704
- });
705
- render(FormWithStructuredFields, {
706
- props: {
707
- webmcp: true,
708
- structuredRequired: false,
709
- interactionRegistry: registry,
710
- onmeasurementchange: measurementChanged,
711
- },
712
- });
713
- const _regLenBefore = registered.length;
714
- await tick();
715
- await tick();
716
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
717
- expect(await registered.at(-1)?.execute({
718
- measurement: { unit: 'ft', value: 12 },
719
- })).toBe('Staged 1 change for review');
720
- const staged = registry.get({
721
- formId: 'structured-fields',
722
- controlId: 'measurement',
723
- })?.state.staged?.value;
724
- expect(JSON.stringify(staged)).toBe('{"value":12,"unit":"ft"}');
725
- await userEvent.click(await screen.findByRole('button', { name: 'Apply valid changes' }));
726
- await waitFor(() => expect(measurementChanged).toHaveBeenLastCalledWith({
727
- value: 12,
728
- unit: 'ft',
729
- }));
730
- expect(registry.get({
731
- formId: 'structured-fields',
732
- controlId: 'measurement',
733
- })?.state.staged).toBeUndefined();
734
- });
735
- it('canonically applies a spoken phone proposal', async () => {
736
- const registered = [];
737
- document.modelContext = {
738
- registerTool(tool) {
739
- registered.push(tool);
740
- },
741
- };
742
- const registry = createControlInteractionRegistry({
743
- isLocalGesture: () => true,
744
- });
745
- render(FormWithFields, {
746
- props: {
747
- webmcp: true,
748
- showAge: false,
749
- showScalarFields: true,
750
- interactionRegistry: registry,
751
- },
752
- });
753
- const _regLenBefore = registered.length;
754
- await tick();
755
- await tick();
756
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
757
- expect(await registered.at(-1)?.execute({
758
- phone: 'five five five one two three four five six seven',
759
- })).toBe('Staged 1 change for review');
760
- const phone = registry
761
- .list()
762
- .find((item) => item.identity.controlId === 'phone');
763
- if (!phone)
764
- throw new Error('Phone control was not registered');
765
- expect(registry.get(phone.identity)?.state.staged?.value).toBe('(555) 123-4567');
766
- await userEvent.click(await screen.findByRole('button', { name: 'Apply valid changes' }));
767
- await waitFor(() => expect(screen.getByRole('textbox', { name: 'Phone' })).toHaveValue('(555) 123-4567'));
768
- expect(registry.get(phone.identity)?.state.staged).toBeUndefined();
769
- });
770
- it('prepares appended textarea proposals after an async policy wait', async () => {
771
- const registered = [];
772
- document.modelContext = {
773
- registerTool(tool) {
774
- registered.push(tool);
775
- },
776
- };
777
- let releasePolicy;
778
- let policyStarted;
779
- const policyBlocked = new Promise((resolve) => {
780
- releasePolicy = resolve;
781
- });
782
- const policyStartedPromise = new Promise((resolve) => {
783
- policyStarted = resolve;
784
- });
785
- const policyValues = [];
786
- const registry = createControlInteractionRegistry({
787
- isLocalGesture: () => true,
788
- policy: async (command) => {
789
- if (command.action === 'stage') {
790
- policyValues.push(command.value);
791
- if (policyValues.length === 1) {
792
- policyStarted?.();
793
- await policyBlocked;
794
- }
795
- if (String(command.value).includes('Forbidden')) {
796
- return { allowed: false, reason: 'forbidden_content' };
797
- }
798
- }
799
- return { allowed: true };
800
- },
801
- });
802
- render(FormWithFields, {
803
- props: {
804
- webmcp: true,
805
- showAge: false,
806
- showScalarFields: true,
807
- notesValue: 'Existing',
808
- notesAppendMode: true,
809
- interactionRegistry: registry,
810
- },
811
- });
812
- const _regLenBefore = registered.length;
813
- await tick();
814
- await tick();
815
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
816
- const staging = registered.at(-1)?.execute({ notes: 'Proposed' });
817
- await policyStartedPromise;
818
- await userEvent.type(screen.getByRole('textbox', { name: 'Notes' }), ' human');
819
- releasePolicy?.();
820
- expect(await staging).toBe('Staged 1 change for review');
821
- const notes = registry
822
- .list()
823
- .find((item) => item.identity.controlId === 'notes');
824
- expect(notes?.state.staged).toMatchObject({
825
- value: 'Existing human\nProposed',
826
- stale: false,
827
- });
828
- expect(policyValues).toEqual([
829
- 'Existing\nProposed',
830
- 'Existing human\nProposed',
831
- ]);
832
- expect(await registered.at(-1)?.execute({ notes: 'Forbidden' })).toBe('Staged 0 changes for review; 1 rejected');
833
- expect(policyValues.at(-1)).toBe('Existing human\nForbidden');
834
- expect(registry.list().find((item) => item.identity.controlId === 'notes')?.state
835
- .staged?.value).toBe('Existing human\nProposed');
836
- });
837
- it('retries partial structured merges against an intervening human edit', async () => {
838
- const registered = [];
839
- document.modelContext = {
840
- registerTool(tool) {
841
- registered.push(tool);
842
- },
843
- };
844
- let releasePolicy;
845
- let policyStarted;
846
- const policyBlocked = new Promise((resolve) => {
847
- releasePolicy = resolve;
848
- });
849
- const policyStartedPromise = new Promise((resolve) => {
850
- policyStarted = resolve;
851
- });
852
- const policyValues = [];
853
- const registry = createControlInteractionRegistry({
854
- policy: async (command) => {
855
- if (command.action === 'stage') {
856
- policyValues.push(command.value);
857
- if (policyValues.length === 1) {
858
- policyStarted?.();
859
- await policyBlocked;
860
- }
861
- }
862
- return { allowed: true };
863
- },
864
- });
865
- render(FormWithStructuredFields, {
866
- props: {
867
- webmcp: true,
868
- structuredRequired: false,
869
- interactionRegistry: registry,
870
- },
871
- });
872
- const _regLenBefore = registered.length;
873
- await tick();
874
- await tick();
875
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
876
- const staging = registered.at(-1)?.execute({
877
- address: { street: '123 Main Street' },
878
- });
879
- await policyStartedPromise;
880
- await userEvent.type(screen.getByRole('textbox', { name: 'City' }), 'Calgary');
881
- releasePolicy?.();
882
- expect(await staging).toBe('Staged 1 change for review');
883
- const stagedAddress = registry.get({
884
- formId: 'structured-fields',
885
- controlId: 'address',
886
- })?.state.staged?.value;
887
- expect(stagedAddress).toMatchObject({
888
- street: '123 Main Street',
889
- city: 'Calgary',
890
- });
891
- expect(policyValues).toHaveLength(2);
892
- expect(policyValues[0]).toMatchObject({ city: '' });
893
- expect(policyValues[1]).toMatchObject({ city: 'Calgary' });
894
- });
895
- it('rejects a non-object date-range proposal without delayed mutation', async () => {
896
- const datesChanged = vi.fn();
897
- const registry = createControlInteractionRegistry({
898
- isLocalGesture: () => true,
899
- });
900
- render(FormWithStructuredFields, {
901
- props: {
902
- interactionRegistry: registry,
903
- ondateschange: datesChanged,
904
- },
905
- });
906
- await tick();
907
- const identity = { formId: 'structured-fields', controlId: 'dates' };
908
- await registry.execute({ action: 'stage', identity, value: 'next week' }, { source: 'agent' });
909
- const revision = registry.get(identity)?.state.staged?.revision;
910
- expect(await dispatchLocalGesture((event) => executeLocalControlBatch(registry, [{ action: 'apply', identity, revision }], event))).toMatchObject({ ok: false });
911
- await Promise.resolve();
912
- expect(datesChanged).not.toHaveBeenCalled();
913
- });
914
- it('affirms idempotent clears for empty composite fields', async () => {
915
- const registry = createControlInteractionRegistry({
916
- isLocalGesture: () => true,
917
- });
918
- render(FormWithStructuredFields, {
919
- props: {
920
- interactionRegistry: registry,
921
- structuredRequired: false,
922
- },
923
- });
924
- await tick();
925
- expect(await dispatchLocalGesture((event) => executeLocalControlBatch(registry, [
926
- {
927
- action: 'clear',
928
- identity: { formId: 'structured-fields', controlId: 'dates' },
929
- },
930
- {
931
- action: 'clear',
932
- identity: {
933
- formId: 'structured-fields',
934
- controlId: 'measurement',
935
- },
936
- },
937
- ], event))).toMatchObject({
938
- ok: true,
939
- results: [{ ok: true }, { ok: true }],
940
- });
941
- });
942
- it('clears rich checkbox and select fields to their explicit empty values', async () => {
943
- const registry = createControlInteractionRegistry({
944
- isLocalGesture: () => true,
945
- });
946
- render(FormWithFields, {
947
- props: {
948
- interactionRegistry: registry,
949
- showAge: false,
950
- showClearFields: true,
951
- checkboxValue: true,
952
- selectValue: 'second',
953
- },
954
- });
955
- await tick();
956
- const enabled = registry
957
- .list()
958
- .find((snapshot) => snapshot.identity.controlId === 'enabled');
959
- const choice = registry
960
- .list()
961
- .find((snapshot) => snapshot.identity.controlId === 'choice');
962
- if (!enabled || !choice)
963
- throw new Error('clear fields were not registered');
964
- expect(await dispatchLocalGesture((event) => executeLocalControlBatch(registry, [
965
- {
966
- action: 'clear',
967
- identity: enabled.identity,
968
- },
969
- {
970
- action: 'clear',
971
- identity: choice.identity,
972
- },
973
- ], event))).toMatchObject({
974
- ok: true,
975
- results: [{ ok: true }, { ok: true }],
976
- });
977
- expect(registry.get(enabled.identity)?.state.value).toBe(false);
978
- expect(registry.get(choice.identity)?.state.value).toBe('');
979
- });
980
- it('rejects invalid structured proposals before mutation callbacks run', async () => {
981
- const addressChanged = vi.fn();
982
- const datesChanged = vi.fn();
983
- const measurementChanged = vi.fn();
984
- const registry = createControlInteractionRegistry({
985
- isLocalGesture: () => true,
986
- });
987
- render(FormWithStructuredFields, {
988
- props: {
989
- interactionRegistry: registry,
990
- structuredRequired: false,
991
- addressFields: ['city'],
992
- onaddresschange: addressChanged,
993
- ondateschange: datesChanged,
994
- onmeasurementchange: measurementChanged,
995
- measurementUnits: ['m'],
996
- minDate: '2026-08-01',
997
- maxDate: '2026-08-31',
998
- },
999
- });
1000
- await tick();
1001
- const apply = async (controlId, value) => {
1002
- const identity = { formId: 'structured-fields', controlId };
1003
- await registry.execute({ action: 'stage', identity, value }, { source: 'agent' });
1004
- return dispatchLocalGesture((event) => executeLocalControlBatch(registry, [
1005
- {
1006
- action: 'apply',
1007
- identity,
1008
- revision: registry.get(identity)?.state.staged?.revision,
1009
- },
1010
- ], event));
1011
- };
1012
- expect((await apply('address', { city: { nested: true } })).results[0].ok).toBe(false);
1013
- expect((await apply('address', { city: 'Edmonton', constructor: 'payload' }))
1014
- .results[0].ok).toBe(false);
1015
- expect((await apply('dates', {
1016
- startDate: '2026-08-30',
1017
- endDate: '2026-08-20',
1018
- })).results[0].ok).toBe(false);
1019
- expect((await apply('dates', {
1020
- startDate: '2026-02-30',
1021
- endDate: '2026-08-20',
1022
- })).results[0].ok).toBe(false);
1023
- expect((await apply('dates', {
1024
- startDate: '2026-07-31',
1025
- endDate: '2026-08-20',
1026
- })).results[0].ok).toBe(false);
1027
- expect((await apply('dates', {
1028
- startDate: '2026-08-10',
1029
- endDate: '2026-08-20',
1030
- constructor: 'payload',
1031
- })).results[0].ok).toBe(false);
1032
- expect((await apply('measurement', { value: 2, unit: 'ft' })).results[0].ok).toBe(false);
1033
- expect((await apply('measurement', {
1034
- value: 2,
1035
- unit: 'm',
1036
- constructor: 'payload',
1037
- })).results[0].ok).toBe(false);
1038
- expect(addressChanged).not.toHaveBeenCalled();
1039
- expect(datesChanged).not.toHaveBeenCalled();
1040
- expect(measurementChanged).not.toHaveBeenCalled();
1041
- });
1042
- it('returns focus to smrt-mode DateRangeInput after final discard', async () => {
1043
- appState.mode = 'smrt';
1044
- const registry = createControlInteractionRegistry({
1045
- isLocalGesture: () => true,
1046
- });
1047
- render(FormWithStructuredFields, {
1048
- props: { interactionRegistry: registry },
1049
- });
1050
- await tick();
1051
- await registry.execute({
1052
- action: 'stage',
1053
- identity: { formId: 'structured-fields', controlId: 'dates' },
1054
- value: { startDate: '2026-08-26', endDate: '2026-08-27' },
1055
- }, { source: 'agent' });
1056
- await userEvent.click(await screen.findByRole('button', { name: 'Discard valid changes' }));
1057
- await waitFor(() => expect(screen.getByRole('button', { name: /date range/i })).toHaveFocus());
1058
- });
1059
- it('falls back to an enabled form control after discarding a disabled field', async () => {
1060
- appState.mode = 'smrt';
1061
- const registry = createControlInteractionRegistry({
1062
- isLocalGesture: () => true,
1063
- });
1064
- const view = render(FormWithStructuredFields, {
1065
- props: { interactionRegistry: registry },
1066
- });
1067
- await tick();
1068
- await registry.execute({
1069
- action: 'stage',
1070
- identity: { formId: 'structured-fields', controlId: 'dates' },
1071
- value: { startDate: '2026-08-26', endDate: '2026-08-27' },
1072
- }, { source: 'agent' });
1073
- await view.rerender({
1074
- interactionRegistry: registry,
1075
- dateDisabled: true,
1076
- });
1077
- await tick();
1078
- await userEvent.click(await screen.findByRole('button', { name: 'Discard valid changes' }));
1079
- await waitFor(() => expect(screen.getByRole('button', { name: 'Submit' })).toHaveFocus());
1080
- });
1081
- it('publishes constraints and leaves validation outcomes in the review workflow', async () => {
1082
- const registered = [];
1083
- document.modelContext = {
1084
- async registerTool(tool) {
1085
- registered.push(tool);
1086
- },
1087
- };
1088
- const onsubmit = vi.fn();
1089
- render(FormWithFields, {
1090
- props: {
1091
- webmcp: true,
1092
- textRequired: true,
1093
- ageRequired: true,
1094
- ageMin: 18,
1095
- ageMax: 65,
1096
- onsubmit,
1097
- },
1098
- });
1099
- const _regLenBefore = registered.length;
1100
- await tick();
1101
- await tick();
1102
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
1103
- expect(registered).toHaveLength(1);
1104
- const schema = registered[0].inputSchema;
1105
- expect(schema.required).toEqual(['fullname', 'age']);
1106
- expect(schema.properties.age).toMatchObject({
1107
- type: 'number',
1108
- minimum: 18,
1109
- maximum: 65,
1110
- });
1111
- expect(await registered[0].execute({ age: 17 })).toBe('Staged 1 change for review');
1112
- expect(onsubmit).not.toHaveBeenCalled();
1113
- expect(await registered[0].execute({ fullname: 'Ada', age: 36 })).toBe('Staged 2 changes for review');
1114
- expect(onsubmit).not.toHaveBeenCalled();
1115
- });
1116
- it('does not expose or stage disabled rich fields', async () => {
1117
- const registered = [];
1118
- document.modelContext = {
1119
- registerTool(tool) {
1120
- registered.push(tool);
1121
- },
1122
- };
1123
- const view = render(FormWithFields, {
1124
- props: { webmcp: true, textDisabled: false, showAge: false },
1125
- });
1126
- let _regLenBefore = registered.length;
1127
- await tick();
1128
- await tick();
1129
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
1130
- expect(registered.at(-1)?.inputSchema).toMatchObject({
1131
- properties: { fullname: { type: 'string' } },
1132
- });
1133
- await view.rerender({
1134
- webmcp: true,
1135
- textDisabled: true,
1136
- showAge: false,
1137
- });
1138
- _regLenBefore = registered.length;
1139
- await tick();
1140
- await tick();
1141
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
1142
- const tool = registered.at(-1);
1143
- expect(tool?.inputSchema).toMatchObject({ properties: {} });
1144
- expect(await tool?.execute({ fullname: 'Ada Lovelace' })).toBe('No reviewable changes provided');
1145
- expect(screen.getByRole('textbox', { name: 'Full name' })).toBeDisabled();
1146
- });
1147
- it('refreshes the WebMCP schema after imperative editability changes', async () => {
1148
- const registered = [];
1149
- document.modelContext = {
1150
- registerTool(tool) {
1151
- registered.push(tool);
1152
- },
1153
- };
1154
- render(FormWithFields, {
1155
- props: { webmcp: true, showAge: false },
1156
- });
1157
- const _regLenBefore = registered.length;
1158
- await tick();
1159
- await tick();
1160
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
1161
- const input = screen.getByRole('textbox', { name: 'Full name' });
1162
- expect(registered.at(-1)?.inputSchema).toMatchObject({
1163
- properties: { fullname: expect.any(Object) },
1164
- });
1165
- let registrationCount = registered.length;
1166
- input.setAttribute('disabled', '');
1167
- await waitFor(() => expect(registered.length).toBeGreaterThan(registrationCount));
1168
- expect(registered.at(-1)?.inputSchema).toMatchObject({ properties: {} });
1169
- registrationCount = registered.length;
1170
- input.removeAttribute('disabled');
1171
- await waitFor(() => expect(registered.length).toBeGreaterThan(registrationCount));
1172
- expect(registered.at(-1)?.inputSchema).toMatchObject({
1173
- properties: { fullname: expect.any(Object) },
1174
- });
1175
- registrationCount = registered.length;
1176
- input.setAttribute('readonly', '');
1177
- await waitFor(() => expect(registered.length).toBeGreaterThan(registrationCount));
1178
- expect(registered.at(-1)?.inputSchema).toMatchObject({ properties: {} });
1179
- });
1180
- it('does not expose fields disabled by an ancestor fieldset', async () => {
1181
- const registered = [];
1182
- document.modelContext = {
1183
- registerTool(tool) {
1184
- registered.push(tool);
1185
- },
1186
- };
1187
- render(FormWithFields, {
1188
- props: {
1189
- webmcp: true,
1190
- fieldsetDisabled: true,
1191
- showAge: false,
1192
- },
1193
- });
1194
- const _regLenBefore = registered.length;
1195
- await tick();
1196
- await tick();
1197
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
1198
- const tool = registered.at(-1);
1199
- expect(tool?.inputSchema).toMatchObject({ properties: {} });
1200
- expect(await tool?.execute({ fullname: 'Ada Lovelace' })).toBe('No reviewable changes provided');
1201
- expect(screen.getByRole('textbox', { name: 'Full name' })).toBeDisabled();
1202
- });
1203
- it('prevents apply and restores it when DOM ancestry disables the field', async () => {
1204
- const interactionRegistry = createControlInteractionRegistry();
1205
- const view = render(FormWithFields, {
1206
- props: {
1207
- interactionRegistry,
1208
- showAge: false,
1209
- },
1210
- });
1211
- await tick();
1212
- await tick();
1213
- const fullname = interactionRegistry.list().at(0);
1214
- if (!fullname)
1215
- throw new Error('Expected registered full-name control');
1216
- await interactionRegistry.execute({
1217
- action: 'stage',
1218
- identity: fullname.identity,
1219
- value: 'Ada Lovelace',
1220
- }, { source: 'agent' });
1221
- await waitFor(() => expect(screen.getByRole('region', { name: 'Review proposed changes' })).toBeInTheDocument());
1222
- const fieldset = view.container.querySelector('fieldset');
1223
- if (!(fieldset instanceof HTMLFieldSetElement)) {
1224
- throw new Error('Expected fieldset');
1225
- }
1226
- fieldset.disabled = true;
1227
- await waitFor(() => expect(screen.getByRole('button', { name: 'Apply Full name' })).toBeDisabled());
1228
- expect(screen.getByRole('button', { name: 'Discard Full name' })).not.toBeDisabled();
1229
- fieldset.disabled = false;
1230
- await waitFor(() => expect(screen.getByRole('button', { name: 'Apply Full name' })).not.toBeDisabled());
1231
- });
1232
- it('uses the form subject for rich-field registration and staging', async () => {
1233
- const registered = [];
1234
- document.modelContext = {
1235
- registerTool(tool) {
1236
- registered.push(tool);
1237
- },
1238
- };
1239
- const view = render(FormWithFields, {
1240
- props: {
1241
- webmcp: true,
1242
- showAge: false,
1243
- formSubject: { type: 'person', id: 'person-1' },
1244
- },
1245
- });
1246
- let _regLenBefore = registered.length;
1247
- await tick();
1248
- await tick();
1249
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
1250
- expect(await registered.at(-1)?.execute({ fullname: 'Ada' })).toBe('Staged 1 change for review');
1251
- const fullname = screen.getByRole('textbox', { name: 'Full name' });
1252
- expect(fullname).toHaveAttribute('data-smrt-subject-type', 'person');
1253
- expect(fullname).toHaveAttribute('data-smrt-subject-id', 'person-1');
1254
- // The review names the field, not the registry subject (plain labels).
1255
- expect(await screen.findByRole('button', { name: 'Apply Full name' })).toBeInTheDocument();
1256
- expect(screen.queryByText(/person:person-1/)).not.toBeInTheDocument();
1257
- await view.rerender({
1258
- webmcp: true,
1259
- showAge: false,
1260
- formSubject: { type: 'person', id: 'person-2' },
1261
- });
1262
- _regLenBefore = registered.length;
1263
- await tick();
1264
- await tick();
1265
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
1266
- expect(await registered.at(-1)?.execute({ fullname: 'Grace' })).toBe('Staged 1 change for review');
1267
- expect(fullname).toHaveAttribute('data-smrt-subject-id', 'person-2');
1268
- expect(await screen.findByRole('button', { name: 'Apply Full name' })).toBeInTheDocument();
1269
- await userEvent.click(screen.getByRole('button', { name: 'Mutate subject' }));
1270
- await tick();
1271
- expect(await registered.at(-1)?.execute({ fullname: 'Katherine' })).toBe('Staged 1 change for review');
1272
- expect(fullname).toHaveAttribute('data-smrt-subject-id', 'person-mutated');
1273
- expect(await screen.findByRole('button', { name: 'Apply Full name' })).toBeInTheDocument();
1274
- });
1275
- it('publishes money in integer minor units and applies cents without rescaling', async () => {
1276
- const registered = [];
1277
- document.modelContext = {
1278
- registerTool(tool) {
1279
- registered.push(tool);
1280
- },
1281
- };
1282
- const registry = createControlInteractionRegistry({
1283
- isLocalGesture: () => true,
1284
- });
1285
- render(FormWithFields, {
1286
- props: {
1287
- webmcp: true,
1288
- showAge: false,
1289
- showMoney: true,
1290
- moneyMin: 100,
1291
- moneyMax: 5000,
1292
- interactionRegistry: registry,
1293
- },
1294
- });
1295
- const _regLenBefore = registered.length;
1296
- await tick();
1297
- await tick();
1298
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
1299
- expect(registered.at(-1)?.inputSchema).toMatchObject({
1300
- properties: {
1301
- budget: {
1302
- type: 'integer',
1303
- description: 'A monetary amount in dollars (between $1.00 and $50.00). WebMCP values use integer minor units (cents).',
1304
- minimum: 100,
1305
- maximum: 5000,
1306
- },
1307
- },
1308
- });
1309
- expect(await registered.at(-1)?.execute({ budget: 123.5 })).toBe('Staged 1 change for review');
1310
- expect(registry
1311
- .list()
1312
- .find((snapshot) => snapshot.identity.controlId === 'budget')?.state
1313
- .staged).toMatchObject({ value: 123.5, valid: false });
1314
- expect(await registered.at(-1)?.execute({ budget: 1234 })).toBe('Staged 1 change for review');
1315
- const budget = registry
1316
- .list()
1317
- .find((snapshot) => snapshot.identity.controlId === 'budget');
1318
- expect(budget?.state.staged?.value).toBe(1234);
1319
- expect(await dispatchLocalGesture((event) => executeLocalControlBatch(registry, [
1320
- {
1321
- action: 'apply',
1322
- identity: budget?.identity ?? { formId: '', controlId: '' },
1323
- revision: budget?.state.staged?.revision,
1324
- },
1325
- ], event))).toMatchObject({ ok: true });
1326
- expect(registry
1327
- .list()
1328
- .find((snapshot) => snapshot.identity.controlId === 'budget')?.state
1329
- .value).toBe(1234);
1330
- });
1331
- it('keeps the browser path a no-op without WebMCP', async () => {
1332
- const onsubmit = vi.fn();
1333
- const view = render(FormWithFields, { props: { onsubmit } });
1334
- await userEvent.click(view.getByRole('button', { name: 'Submit' }));
1335
- expect(onsubmit).toHaveBeenCalledTimes(1);
1336
- });
1337
- it('publishes and accepts structured measurement, date range, and address fields', async () => {
1338
- const registered = [];
1339
- document.modelContext = {
1340
- async registerTool(tool) {
1341
- registered.push(tool);
1342
- },
1343
- };
1344
- const onsubmit = vi.fn();
1345
- render(FormWithStructuredFields, { props: { webmcp: true, onsubmit } });
1346
- const _regLenBefore = registered.length;
1347
- await tick();
1348
- await tick();
1349
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
1350
- expect(registered).toHaveLength(1);
1351
- const schema = registered[0].inputSchema;
1352
- expect(schema.required).toEqual(['measurement', 'dates', 'address']);
1353
- expect(schema.properties.measurement).toMatchObject({
1354
- type: 'object',
1355
- properties: {
1356
- value: { type: 'number' },
1357
- unit: { type: 'string' },
1358
- },
1359
- required: ['value', 'unit'],
1360
- });
1361
- expect(schema.properties.dates).toMatchObject({
1362
- type: 'object',
1363
- properties: {
1364
- startDate: { type: 'string' },
1365
- endDate: { type: 'string' },
1366
- },
1367
- required: ['startDate', 'endDate'],
1368
- });
1369
- expect(schema.properties.address).toMatchObject({
1370
- type: 'object',
1371
- additionalProperties: false,
1372
- properties: {
1373
- street: { type: 'string' },
1374
- city: { type: 'string' },
1375
- province: { type: 'string' },
1376
- postalCode: { type: 'string' },
1377
- country: { type: 'string' },
1378
- },
1379
- required: ['street', 'city', 'province', 'postalCode', 'country'],
1380
- });
1381
- expect(schema.properties.address.properties.country.enum).toEqual(['CA', 'US']);
1382
- expect(schema.properties.address.properties.province.enum).toEqual(expect.arrayContaining(['AB', 'BC', 'ON', 'QC', 'US-CA', 'US-NY']));
1383
- const values = {
1384
- measurement: { value: 1.5, unit: 'm' },
1385
- dates: { startDate: '2026-01-01', endDate: '2026-01-02' },
1386
- address: {
1387
- street: '1 Main St',
1388
- city: 'Edmonton',
1389
- province: 'AB',
1390
- postalCode: 'T1A 1A1',
1391
- country: 'CA',
1392
- },
1393
- };
1394
- expect(await registered[0].execute(values)).toBe('Staged 3 changes for review');
1395
- expect(onsubmit).not.toHaveBeenCalled();
1396
- });
1397
- it('keeps address option schemas and validation live across rerenders', async () => {
1398
- const registered = [];
1399
- document.modelContext = {
1400
- registerTool(tool) {
1401
- registered.push(tool);
1402
- },
1403
- };
1404
- const addressChanged = vi.fn();
1405
- const registry = createControlInteractionRegistry({
1406
- isLocalGesture: () => true,
1407
- });
1408
- const view = render(FormWithStructuredFields, {
1409
- props: {
1410
- webmcp: true,
1411
- structuredRequired: false,
1412
- addressFields: ['province', 'country'],
1413
- addressCountries: [{ value: 'CA', label: 'Canada' }],
1414
- addressProvinces: [{ value: 'AB', label: 'Alberta' }],
1415
- interactionRegistry: registry,
1416
- onaddresschange: addressChanged,
1417
- },
1418
- });
1419
- const _regLenBefore = registered.length;
1420
- await tick();
1421
- await tick();
1422
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
1423
- const addressProperties = () => {
1424
- const tool = registered.at(-1);
1425
- if (!tool)
1426
- throw new Error('WebMCP tool was not registered');
1427
- return tool.inputSchema.properties.address.properties;
1428
- };
1429
- // `registered.at(-1)` is a snapshot: a registry-driven side effect from
1430
- // an earlier step in this test (e.g. an "apply" attempt) can retrigger
1431
- // the reactive tool spec and replace that snapshot before `.execute()`
1432
- // runs against it. Retry against whatever is current rather than
1433
- // asserting the specific instance stays live for the whole test.
1434
- const executeLive = async (args) => {
1435
- for (let attempt = 0;; attempt += 1) {
1436
- const tool = registered.at(-1);
1437
- if (!tool)
1438
- throw new Error('WebMCP tool was not registered');
1439
- try {
1440
- return await tool.execute(args);
1441
- }
1442
- catch (error) {
1443
- const stale = error instanceof Error &&
1444
- error.message.includes('is no longer registered');
1445
- if (!stale || attempt >= 9)
1446
- throw error;
1447
- await tick();
1448
- }
1449
- }
1450
- };
1451
- expect(addressProperties()).toEqual({
1452
- province: { type: 'string', enum: ['', 'AB'] },
1453
- country: { type: 'string', enum: ['CA'] },
1454
- });
1455
- await executeLive({
1456
- address: { province: 'AB', country: 'CA' },
1457
- });
1458
- const identity = { formId: 'structured-fields', controlId: 'address' };
1459
- expect(registry.get(identity)?.state.staged).toMatchObject({ valid: true });
1460
- await view.rerender({
1461
- webmcp: true,
1462
- structuredRequired: false,
1463
- addressFields: ['province', 'country'],
1464
- addressCountries: [{ value: 'FR', label: 'France' }],
1465
- addressProvinces: [{ value: 'IDF', label: 'Île-de-France' }],
1466
- interactionRegistry: registry,
1467
- onaddresschange: addressChanged,
1468
- });
1469
- await tick();
1470
- await tick();
1471
- // A prop change here can drive the reactive tool spec through more than
1472
- // one intermediate registration before it settles (address option
1473
- // registries can update in a separate tick from the rerender itself), so
1474
- // waiting only for `registered.length` to grow can observe a
1475
- // soon-to-be-superseded entry. Poll the actual settled schema instead.
1476
- await vi.waitFor(() => {
1477
- expect(addressProperties()).toEqual({
1478
- province: { type: 'string', enum: ['', 'IDF'] },
1479
- country: { type: 'string', enum: ['FR'] },
1480
- });
1481
- });
1482
- expect(await dispatchLocalGesture((event) => executeLocalControlCommand(registry, {
1483
- action: 'apply',
1484
- identity,
1485
- revision: registry.get(identity)?.state.staged?.revision,
1486
- }, event))).toMatchObject({ ok: false });
1487
- expect(addressChanged).not.toHaveBeenCalled();
1488
- await executeLive({
1489
- address: { province: 'IDF', country: 'FR' },
1490
- });
1491
- expect(registry.get(identity)?.state.staged).toMatchObject({ valid: true });
1492
- expect(await dispatchLocalGesture((event) => executeLocalControlCommand(registry, {
1493
- action: 'apply',
1494
- identity,
1495
- revision: registry.get(identity)?.state.staged?.revision,
1496
- }, event))).toMatchObject({ ok: true });
1497
- expect(addressChanged).toHaveBeenLastCalledWith({
1498
- province: 'IDF',
1499
- country: 'FR',
1500
- });
1501
- await executeLive({
1502
- address: { province: 'XX', country: 'FR' },
1503
- });
1504
- expect(registry.get(identity)?.state.staged).toMatchObject({
1505
- valid: false,
1506
- });
1507
- });
1508
- it('omits zero-based multipleOf when measurement steps use a minimum offset', async () => {
1509
- const registered = [];
1510
- const registry = createControlInteractionRegistry();
1511
- document.modelContext = {
1512
- registerTool(tool) {
1513
- registered.push(tool);
1514
- },
1515
- };
1516
- const view = render(FormWithStructuredFields, {
1517
- props: {
1518
- webmcp: true,
1519
- structuredRequired: false,
1520
- measurementMin: 1,
1521
- measurementStep: 2,
1522
- interactionRegistry: registry,
1523
- },
1524
- });
1525
- let _regLenBefore = registered.length;
1526
- await tick();
1527
- await tick();
1528
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
1529
- const valueSchema = () => {
1530
- const tool = registered.at(-1);
1531
- if (!tool)
1532
- throw new Error('WebMCP tool was not registered');
1533
- return tool.inputSchema.properties.measurement.properties.value;
1534
- };
1535
- expect(valueSchema()).toMatchObject({ type: 'number', minimum: 1 });
1536
- expect(valueSchema()).not.toHaveProperty('multipleOf');
1537
- expect(await registered.at(-1)?.execute({
1538
- measurement: { value: 3, unit: 'ft' },
1539
- })).toBe('Staged 1 change for review');
1540
- const measurement = registry.get({
1541
- formId: 'structured-fields',
1542
- controlId: 'measurement',
1543
- });
1544
- expect(measurement?.state.staged).toMatchObject({ valid: true });
1545
- await registered.at(-1)?.execute({
1546
- measurement: { value: 2, unit: 'ft' },
1547
- });
1548
- expect(registry.get({
1549
- formId: 'structured-fields',
1550
- controlId: 'measurement',
1551
- })?.state.staged).toMatchObject({ valid: false });
1552
- await view.rerender({
1553
- webmcp: true,
1554
- structuredRequired: false,
1555
- measurementMin: 2,
1556
- measurementStep: 2,
1557
- interactionRegistry: registry,
1558
- });
1559
- _regLenBefore = registered.length;
1560
- await tick();
1561
- await tick();
1562
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
1563
- expect(valueSchema()).toMatchObject({
1564
- type: 'number',
1565
- minimum: 2,
1566
- multipleOf: 2,
1567
- });
1568
- });
1569
- it('stages through an additive legacy registry without executeBatch', async () => {
1570
- const registered = [];
1571
- document.modelContext = {
1572
- registerTool(tool) {
1573
- registered.push(tool);
1574
- },
1575
- };
1576
- const baseRegistry = createControlInteractionRegistry({
1577
- isLocalGesture: () => true,
1578
- });
1579
- const { executeBatch: _executeBatch, ...legacyRegistry } = baseRegistry;
1580
- render(FormWithFields, {
1581
- props: {
1582
- webmcp: true,
1583
- showAge: false,
1584
- interactionRegistry: legacyRegistry,
1585
- },
1586
- });
1587
- const _regLenBefore = registered.length;
1588
- await tick();
1589
- await tick();
1590
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
1591
- expect(await registered.at(-1)?.execute({ fullname: 'Ada' })).toBe('Staged 1 change for review');
1592
- expect(legacyRegistry.list()[0]?.state.staged?.value).toBe('Ada');
1593
- await userEvent.click(screen.getByRole('button', { name: 'Apply Full name' }));
1594
- await waitFor(() => expect(screen.getByRole('textbox', { name: 'Full name' })).toHaveValue('Ada'));
1595
- expect(legacyRegistry.list()[0]?.state.staged).toBeUndefined();
1596
- });
1597
- it('limits the address schema and payload to configured fields', async () => {
1598
- const registered = [];
1599
- document.modelContext = {
1600
- async registerTool(tool) {
1601
- registered.push(tool);
1602
- },
1603
- };
1604
- const onsubmit = vi.fn();
1605
- const registry = createControlInteractionRegistry();
1606
- render(FormWithStructuredFields, {
1607
- props: {
1608
- webmcp: true,
1609
- addressFields: ['city', 'country'],
1610
- onsubmit,
1611
- interactionRegistry: registry,
1612
- },
1613
- });
1614
- const _regLenBefore = registered.length;
1615
- await tick();
1616
- await tick();
1617
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
1618
- expect(registered).toHaveLength(1);
1619
- const schema = registered[0].inputSchema;
1620
- expect(schema.properties.address).toMatchObject({
1621
- type: 'object',
1622
- properties: {
1623
- city: { type: 'string' },
1624
- country: { type: 'string' },
1625
- },
1626
- required: ['city', 'country'],
1627
- });
1628
- expect(schema.properties.address.properties).not.toHaveProperty('street');
1629
- expect(schema.properties.address.properties.country.enum).toEqual(['CA', 'US']);
1630
- const values = {
1631
- measurement: { value: 1.5, unit: 'm' },
1632
- dates: { startDate: '2026-01-01', endDate: '2026-01-02' },
1633
- address: { city: 'Edmonton', country: 'CA', street: 'hidden' },
1634
- };
1635
- expect(await registered[0].execute(values)).toBe('Staged 3 changes for review');
1636
- expect(onsubmit).not.toHaveBeenCalled();
1637
- expect(registry
1638
- .list('structured-fields')
1639
- .find((snapshot) => snapshot.identity.controlId === 'address')?.state
1640
- .staged?.value).toEqual({ city: 'Edmonton', country: 'CA' });
1641
- });
1642
- it('updates configured rich-field schemas after prop changes', async () => {
1643
- const registered = [];
1644
- const registry = createControlInteractionRegistry();
1645
- document.modelContext = {
1646
- registerTool(tool) {
1647
- registered.push(tool);
1648
- },
1649
- };
1650
- const view = render(FormWithStructuredFields, {
1651
- props: {
1652
- webmcp: true,
1653
- addressFields: ['city'],
1654
- measurementUnits: ['m'],
1655
- measurementLabel: 'Height',
1656
- measurementMin: 0,
1657
- measurementMax: 10,
1658
- interactionRegistry: registry,
1659
- },
1660
- });
1661
- let _regLenBefore = registered.length;
1662
- await tick();
1663
- await tick();
1664
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
1665
- await view.rerender({
1666
- webmcp: true,
1667
- addressFields: ['country'],
1668
- measurementUnits: ['ft'],
1669
- measurementLabel: 'Weight',
1670
- measurementMin: 100,
1671
- measurementMax: 200,
1672
- interactionRegistry: registry,
1673
- });
1674
- _regLenBefore = registered.length;
1675
- await tick();
1676
- await tick();
1677
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
1678
- const schema = registered.at(-1)?.inputSchema;
1679
- expect(schema.properties.address.properties).toEqual({
1680
- country: { type: 'string', enum: ['CA', 'US'] },
1681
- });
1682
- expect(schema.properties.measurement.properties.unit.enum).toEqual(['ft']);
1683
- expect(schema.properties.measurement).toMatchObject({
1684
- title: 'Weight',
1685
- description: expect.stringContaining('between 100 and 200'),
1686
- });
1687
- expect(schema.properties.measurement.properties.value).toMatchObject({
1688
- minimum: 100,
1689
- maximum: 200,
1690
- });
1691
- expect(registry
1692
- .list('structured-fields')
1693
- .find((snapshot) => snapshot.identity.controlId === 'measurement')
1694
- ?.metadata).toMatchObject({
1695
- label: 'Weight',
1696
- description: expect.stringContaining('between 100 and 200'),
1697
- });
1698
- });
1699
- it('updates ordinary rich-field schemas after prop changes', async () => {
1700
- const registered = [];
1701
- document.modelContext = {
1702
- registerTool(tool) {
1703
- registered.push(tool);
1704
- },
1705
- };
1706
- const view = render(FormWithFields, {
1707
- props: {
1708
- webmcp: true,
1709
- ageLabel: 'Minimum age',
1710
- ageMax: 65,
1711
- textRequired: false,
1712
- },
1713
- });
1714
- let _regLenBefore = registered.length;
1715
- await tick();
1716
- await tick();
1717
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
1718
- await view.rerender({
1719
- webmcp: true,
1720
- ageLabel: 'Maximum age',
1721
- ageMax: 100,
1722
- textRequired: true,
1723
- });
1724
- _regLenBefore = registered.length;
1725
- await tick();
1726
- await tick();
1727
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
1728
- const schema = registered.at(-1)?.inputSchema;
1729
- expect(schema.required).toContain('fullname');
1730
- expect(schema.properties.age).toMatchObject({
1731
- title: 'Maximum age',
1732
- maximum: 100,
1733
- description: expect.stringContaining('maximum 100'),
1734
- });
1735
- });
1736
- it('tightens live field policy without re-registering the field', async () => {
1737
- const registered = [];
1738
- document.modelContext = {
1739
- registerTool(tool) {
1740
- registered.push(tool);
1741
- },
1742
- };
1743
- const registry = createControlInteractionRegistry({
1744
- isLocalGesture: () => true,
1745
- });
1746
- const view = render(FormWithPolicyField, {
1747
- props: { webmcp: true, interactionRegistry: registry },
1748
- });
1749
- let _regLenBefore = registered.length;
1750
- await tick();
1751
- await tick();
1752
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
1753
- expect(await registered.at(-1)?.execute({ policy: 'Grace' })).toBe('Staged 1 change for review');
1754
- const revision = registry.get({
1755
- formId: 'policy-form',
1756
- controlId: 'policy',
1757
- })?.state.staged?.revision;
1758
- expect(revision).toBeDefined();
1759
- await view.rerender({
1760
- webmcp: true,
1761
- interactionRegistry: registry,
1762
- sensitivity: 'secret',
1763
- writable: false,
1764
- });
1765
- _regLenBefore = registered.length;
1766
- await tick();
1767
- await tick();
1768
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
1769
- expect(registered.at(-1)?.inputSchema).toMatchObject({ properties: {} });
1770
- const snapshot = registry.get({
1771
- formId: 'policy-form',
1772
- controlId: 'policy',
1773
- });
1774
- expect(snapshot?.metadata).toMatchObject({
1775
- sensitivity: 'secret',
1776
- writable: false,
1777
- });
1778
- expect(snapshot?.state).toMatchObject({
1779
- value: undefined,
1780
- valueRedacted: true,
1781
- });
1782
- expect(snapshot?.state).not.toHaveProperty('staged');
1783
- expect(snapshot?.state).not.toHaveProperty('stagedValue');
1784
- expect(screen.queryByRole('region', { name: 'Review proposed changes' })).not.toBeInTheDocument();
1785
- await expect(dispatchLocalGesture((event) => executeLocalControlCommand(registry, {
1786
- action: 'discard',
1787
- identity: { formId: 'policy-form', controlId: 'policy' },
1788
- revision,
1789
- }, event))).resolves.toMatchObject({ ok: true, action: 'discard' });
1790
- await view.rerender({
1791
- webmcp: true,
1792
- interactionRegistry: registry,
1793
- sensitivity: 'public',
1794
- writable: true,
1795
- });
1796
- _regLenBefore = registered.length;
1797
- await tick();
1798
- await tick();
1799
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
1800
- expect(registry.get({ formId: 'policy-form', controlId: 'policy' })?.state).not.toHaveProperty('staged');
1801
- });
1802
- it('allows partial payloads for optional structured fields', async () => {
1803
- const registered = [];
1804
- document.modelContext = {
1805
- async registerTool(tool) {
1806
- registered.push(tool);
1807
- },
1808
- };
1809
- const onsubmit = vi.fn();
1810
- const registry = createControlInteractionRegistry({
1811
- isLocalGesture: () => true,
1812
- });
1813
- render(FormWithStructuredFields, {
1814
- props: {
1815
- webmcp: true,
1816
- structuredRequired: false,
1817
- onsubmit,
1818
- interactionRegistry: registry,
1819
- },
1820
- });
1821
- const _regLenBefore = registered.length;
1822
- await tick();
1823
- await tick();
1824
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
1825
- expect(registered).toHaveLength(1);
1826
- const schema = registered[0].inputSchema;
1827
- expect(schema.properties.measurement).not.toHaveProperty('required');
1828
- expect(schema.properties.dates).not.toHaveProperty('required');
1829
- expect(schema.properties.address).not.toHaveProperty('required');
1830
- const partialValues = {
1831
- measurement: { value: 1.5 },
1832
- dates: { startDate: '2026-01-01' },
1833
- address: { city: 'Edmonton' },
1834
- };
1835
- expect(await registered[0].execute(partialValues)).toBe('Staged 3 changes for review');
1836
- expect(onsubmit).not.toHaveBeenCalled();
1837
- const staged = registry
1838
- .list('structured-fields')
1839
- .filter((snapshot) => snapshot.state.staged)
1840
- .map((snapshot) => ({
1841
- action: 'apply',
1842
- identity: snapshot.identity,
1843
- revision: snapshot.state.staged?.revision,
1844
- }));
1845
- const applied = await dispatchLocalGesture((event) => executeLocalControlBatch(registry, staged, event));
1846
- expect(applied.results).toEqual(staged.map((command) => expect.objectContaining({
1847
- ok: true,
1848
- identity: command.identity,
1849
- })));
1850
- expect(registry.list('structured-fields')).toEqual(expect.arrayContaining([
1851
- expect.objectContaining({
1852
- identity: expect.objectContaining({ controlId: 'dates' }),
1853
- state: expect.objectContaining({
1854
- value: { startDate: '2026-01-01', endDate: '' },
1855
- }),
1856
- }),
1857
- expect.objectContaining({
1858
- identity: expect.objectContaining({ controlId: 'address' }),
1859
- state: expect.objectContaining({
1860
- value: expect.objectContaining({
1861
- city: 'Edmonton',
1862
- country: 'CA',
1863
- }),
1864
- }),
1865
- }),
1866
- ]));
1867
- });
1868
- it('does not expose structured fields disabled by an ancestor fieldset', async () => {
1869
- appState.mode = 'smrt';
1870
- const registered = [];
1871
- document.modelContext = {
1872
- registerTool(tool) {
1873
- registered.push(tool);
1874
- },
1875
- };
1876
- render(FormWithStructuredFields, {
1877
- props: { webmcp: true, fieldsetDisabled: true },
1878
- });
1879
- const _regLenBefore = registered.length;
1880
- await tick();
1881
- await tick();
1882
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
1883
- const tool = registered.at(-1);
1884
- expect(tool?.inputSchema).toMatchObject({ properties: {} });
1885
- expect(await tool?.execute({ address: { city: 'Edmonton' } })).toBe('No reviewable changes provided');
1886
- });
1887
- it('prevents applying a staged address when any matched child control becomes disabled', async () => {
1888
- const interactionRegistry = createControlInteractionRegistry({
1889
- isLocalGesture: () => true,
1890
- });
1891
- render(FormWithStructuredFields, {
1892
- props: { interactionRegistry },
1893
- });
1894
- await tick();
1895
- await tick();
1896
- const address = interactionRegistry
1897
- .list('structured-fields')
1898
- .find((snapshot) => snapshot.identity.controlId === 'address');
1899
- if (!address)
1900
- throw new Error('Expected registered address control');
1901
- await interactionRegistry.execute({
1902
- action: 'stage',
1903
- identity: address.identity,
1904
- value: { city: 'Edmonton' },
1905
- }, { source: 'agent' });
1906
- await waitFor(() => expect(screen.getByRole('region', { name: 'Review proposed changes' })).toBeInTheDocument());
1907
- const city = screen.getByRole('textbox', { name: 'City' });
1908
- city.setAttribute('disabled', '');
1909
- await waitFor(() => {
1910
- expect(interactionRegistry.get(address.identity)?.state.disabled).toBe(true);
1911
- expect(screen.getByRole('button', { name: 'Apply Address' })).toBeDisabled();
1912
- });
1913
- expect(screen.getByRole('button', { name: 'Discard Address' })).not.toBeDisabled();
1914
- });
1915
- it('does not let a colliding sibling name mask a disabled rich field', async () => {
1916
- const registered = [];
1917
- document.modelContext = {
1918
- registerTool(tool) {
1919
- registered.push(tool);
1920
- },
1921
- };
1922
- render(FormWithStructuredFields, {
1923
- props: {
1924
- webmcp: true,
1925
- fieldsetDisabled: true,
1926
- measurementName: 'user',
1927
- showCollidingSibling: true,
1928
- },
1929
- });
1930
- const _regLenBefore = registered.length;
1931
- await tick();
1932
- await tick();
1933
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
1934
- expect(registered.at(-1)?.inputSchema).toMatchObject({ properties: {} });
1935
- expect(await registered.at(-1)?.execute({ user: { value: 2, unit: 'm' } })).toBe('No reviewable changes provided');
1936
- });
1937
- it('gives exact registered names precedence over composite ownership', async () => {
1938
- const registered = [];
1939
- const registry = createControlInteractionRegistry();
1940
- document.modelContext = {
1941
- registerTool(tool) {
1942
- registered.push(tool);
1943
- },
1944
- };
1945
- render(FormWithStructuredFields, {
1946
- props: {
1947
- webmcp: true,
1948
- structuredRequired: false,
1949
- fieldsetDisabled: true,
1950
- measurementName: 'weight',
1951
- showExactNameCollisions: true,
1952
- interactionRegistry: registry,
1953
- },
1954
- });
1955
- const _regLenBefore = registered.length;
1956
- await tick();
1957
- await tick();
1958
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
1959
- const tool = registered.at(-1);
1960
- if (!tool)
1961
- throw new Error('WebMCP tool was not registered');
1962
- const properties = tool.inputSchema.properties;
1963
- expect(properties).toHaveProperty('address[city]');
1964
- expect(properties).toHaveProperty('weight_unit');
1965
- expect(properties).not.toHaveProperty('address');
1966
- expect(properties).not.toHaveProperty('weight');
1967
- const exactAddress = screen.getByRole('textbox', {
1968
- name: 'Exact address city',
1969
- });
1970
- const exactUnit = screen.getByRole('textbox', {
1971
- name: 'Exact measurement unit',
1972
- });
1973
- expect(exactAddress).toHaveAttribute('data-smrt-control', 'address[city]');
1974
- expect(exactUnit).toHaveAttribute('data-smrt-control', 'weight_unit');
1975
- expect(screen.getByRole('textbox', { name: 'City' })).toHaveAttribute('data-smrt-control', 'address');
1976
- expect(registry.get({
1977
- formId: 'structured-fields',
1978
- controlId: 'address[city]',
1979
- })?.state.disabled).toBe(false);
1980
- expect(registry.get({
1981
- formId: 'structured-fields',
1982
- controlId: 'weight_unit',
1983
- })?.state.disabled).toBe(false);
1984
- });
1985
- it('keeps a base control with a composite-looking name outside rich ownership', async () => {
1986
- const registry = createControlInteractionRegistry();
1987
- render(FormWithStructuredFields, {
1988
- props: {
1989
- interactionRegistry: registry,
1990
- fieldsetDisabled: true,
1991
- showBaseControlCollision: true,
1992
- },
1993
- });
1994
- await tick();
1995
- await tick();
1996
- expect(registry.get({ formId: 'structured-fields', controlId: 'address' })?.state
1997
- .disabled).toBe(true);
1998
- expect(registry.get({
1999
- formId: 'structured-fields',
2000
- controlId: 'address[city]',
2001
- })?.state.disabled).toBe(false);
2002
- expect(screen.getByRole('textbox', { name: 'City' })).toHaveAttribute('data-smrt-control', 'address');
2003
- expect(screen.getByTestId('base-address-city')).toHaveAttribute('data-smrt-control', 'address[city]');
2004
- });
2005
- it('moves a built-in registration when its name changes', async () => {
2006
- const registry = createControlInteractionRegistry();
2007
- const view = render(FormRegistrationLifecycle, {
2008
- props: { interactionRegistry: registry, firstName: 'first' },
2009
- });
2010
- await tick();
2011
- await tick();
2012
- expect(registry.get({ formId: 'registration-lifecycle', controlId: 'first' })).toBeDefined();
2013
- await view.rerender({
2014
- interactionRegistry: registry,
2015
- firstName: 'renamed',
2016
- });
2017
- await tick();
2018
- await tick();
2019
- expect(registry.get({ formId: 'registration-lifecycle', controlId: 'first' })).toBeUndefined();
2020
- expect(registry.get({ formId: 'registration-lifecycle', controlId: 'renamed' })).toBeDefined();
2021
- });
2022
- it('does not let stale cleanup remove a same-name replacement', async () => {
2023
- const registry = createControlInteractionRegistry();
2024
- const view = render(FormRegistrationLifecycle, {
2025
- props: {
2026
- interactionRegistry: registry,
2027
- firstName: 'shared',
2028
- showFirst: true,
2029
- showReplacement: false,
2030
- },
2031
- });
2032
- await tick();
2033
- await tick();
2034
- await view.rerender({
2035
- interactionRegistry: registry,
2036
- firstName: 'shared',
2037
- showFirst: true,
2038
- showReplacement: true,
2039
- });
2040
- await tick();
2041
- await tick();
2042
- expect(registry.get({ formId: 'registration-lifecycle', controlId: 'shared' })
2043
- ?.metadata.label).toBe('Replacement field');
2044
- await view.rerender({
2045
- interactionRegistry: registry,
2046
- firstName: 'shared',
2047
- showFirst: false,
2048
- showReplacement: true,
2049
- });
2050
- await tick();
2051
- await tick();
2052
- expect(registry.get({ formId: 'registration-lifecycle', controlId: 'shared' })
2053
- ?.metadata.label).toBe('Replacement field');
2054
- });
2055
- it('does not let stale custom cleanup remove its replacement', async () => {
2056
- const registry = createControlInteractionRegistry();
2057
- const view = render(FormRegistrationLifecycle, {
2058
- props: {
2059
- interactionRegistry: registry,
2060
- showFirst: false,
2061
- showCustomFirst: true,
2062
- showCustomReplacement: false,
2063
- },
2064
- });
2065
- await tick();
2066
- await tick();
2067
- await view.rerender({
2068
- interactionRegistry: registry,
2069
- showFirst: false,
2070
- showCustomFirst: true,
2071
- showCustomReplacement: true,
2072
- });
2073
- await tick();
2074
- await tick();
2075
- expect(registry.get({
2076
- formId: 'registration-lifecycle',
2077
- controlId: 'custom-shared',
2078
- })?.metadata.label).toBe('Custom replacement');
2079
- await view.rerender({
2080
- interactionRegistry: registry,
2081
- showFirst: false,
2082
- showCustomFirst: false,
2083
- showCustomReplacement: true,
2084
- });
2085
- await tick();
2086
- await tick();
2087
- expect(registry.get({
2088
- formId: 'registration-lifecycle',
2089
- controlId: 'custom-shared',
2090
- })?.metadata.label).toBe('Custom replacement');
2091
- });
2092
- it('supports ordinary legacy register and unregister cleanup', async () => {
2093
- const registry = createControlInteractionRegistry();
2094
- const view = render(FormRegistrationLifecycle, {
2095
- props: {
2096
- interactionRegistry: registry,
2097
- showFirst: false,
2098
- showCustomFirst: true,
2099
- legacyCustomCleanup: true,
2100
- },
2101
- });
2102
- await tick();
2103
- await tick();
2104
- expect(registry.get({
2105
- formId: 'registration-lifecycle',
2106
- controlId: 'custom-shared',
2107
- })).toBeDefined();
2108
- await view.rerender({
2109
- interactionRegistry: registry,
2110
- showFirst: false,
2111
- showCustomFirst: false,
2112
- legacyCustomCleanup: true,
2113
- });
2114
- await tick();
2115
- await tick();
2116
- expect(registry.get({
2117
- formId: 'registration-lifecycle',
2118
- controlId: 'custom-shared',
2119
- })).toBeUndefined();
2120
- });
2121
- it('does not let stale legacy unregister remove its replacement', async () => {
2122
- const registry = createControlInteractionRegistry();
2123
- const view = render(FormRegistrationLifecycle, {
2124
- props: {
2125
- interactionRegistry: registry,
2126
- showFirst: false,
2127
- showCustomFirst: true,
2128
- showCustomReplacement: false,
2129
- legacyCustomCleanup: true,
2130
- },
2131
- });
2132
- await tick();
2133
- await tick();
2134
- await view.rerender({
2135
- interactionRegistry: registry,
2136
- showFirst: false,
2137
- showCustomFirst: true,
2138
- showCustomReplacement: true,
2139
- legacyCustomCleanup: true,
2140
- });
2141
- await tick();
2142
- await tick();
2143
- await view.rerender({
2144
- interactionRegistry: registry,
2145
- showFirst: false,
2146
- showCustomFirst: false,
2147
- showCustomReplacement: true,
2148
- legacyCustomCleanup: true,
2149
- });
2150
- await tick();
2151
- await tick();
2152
- expect(registry.get({
2153
- formId: 'registration-lifecycle',
2154
- controlId: 'custom-shared',
2155
- })?.metadata.label).toBe('Custom replacement');
2156
- await view.rerender({
2157
- interactionRegistry: registry,
2158
- showFirst: false,
2159
- showCustomFirst: false,
2160
- showCustomReplacement: false,
2161
- legacyCustomCleanup: true,
2162
- });
2163
- await tick();
2164
- await tick();
2165
- expect(registry.get({
2166
- formId: 'registration-lifecycle',
2167
- controlId: 'custom-shared',
2168
- })).toBeUndefined();
2169
- });
2170
- it('restores the older legacy registration when its replacement unmounts first', async () => {
2171
- const registry = createControlInteractionRegistry();
2172
- const view = render(FormRegistrationLifecycle, {
2173
- props: {
2174
- interactionRegistry: registry,
2175
- showFirst: false,
2176
- showCustomFirst: true,
2177
- showCustomReplacement: true,
2178
- legacyCustomCleanup: true,
2179
- },
2180
- });
2181
- await tick();
2182
- await tick();
2183
- expect(registry.get({
2184
- formId: 'registration-lifecycle',
2185
- controlId: 'custom-shared',
2186
- })?.metadata.label).toBe('Custom replacement');
2187
- await view.rerender({
2188
- interactionRegistry: registry,
2189
- showFirst: false,
2190
- showCustomFirst: true,
2191
- showCustomReplacement: false,
2192
- legacyCustomCleanup: true,
2193
- });
2194
- await tick();
2195
- await tick();
2196
- expect(registry.get({
2197
- formId: 'registration-lifecycle',
2198
- controlId: 'custom-shared',
2199
- })?.metadata.label).toBe('Custom first');
2200
- await view.rerender({
2201
- interactionRegistry: registry,
2202
- showFirst: false,
2203
- showCustomFirst: false,
2204
- showCustomReplacement: false,
2205
- legacyCustomCleanup: true,
2206
- });
2207
- await tick();
2208
- await tick();
2209
- expect(registry.get({
2210
- formId: 'registration-lifecycle',
2211
- controlId: 'custom-shared',
2212
- })).toBeUndefined();
2213
- });
2214
- it('binds repeated context accessor cleanup to the same legacy registration', async () => {
2215
- const registry = createControlInteractionRegistry();
2216
- const view = render(FormRegistrationLifecycle, {
2217
- props: {
2218
- interactionRegistry: registry,
2219
- showFirst: false,
2220
- showCustomFirst: true,
2221
- legacyCustomCleanup: true,
2222
- separateLegacyCleanupContext: true,
2223
- },
2224
- });
2225
- await tick();
2226
- await tick();
2227
- expect(registry.get({
2228
- formId: 'registration-lifecycle',
2229
- controlId: 'custom-shared',
2230
- })?.metadata.label).toBe('Custom first');
2231
- await view.rerender({
2232
- interactionRegistry: registry,
2233
- showFirst: false,
2234
- showCustomFirst: false,
2235
- legacyCustomCleanup: true,
2236
- separateLegacyCleanupContext: true,
2237
- });
2238
- await tick();
2239
- await tick();
2240
- expect(registry.get({
2241
- formId: 'registration-lifecycle',
2242
- controlId: 'custom-shared',
2243
- })).toBeUndefined();
2244
- });
2245
- it('keeps descendant legacy cleanup isolated after a compound field reads context', async () => {
2246
- const registry = createControlInteractionRegistry();
2247
- const view = render(FormRegistrationLifecycle, {
2248
- props: {
2249
- interactionRegistry: registry,
2250
- showFirst: false,
2251
- showCustomFirst: true,
2252
- showCustomReplacement: true,
2253
- legacyCustomCleanup: true,
2254
- compoundLegacyCustom: true,
2255
- },
2256
- });
2257
- await tick();
2258
- await tick();
2259
- expect(registry.get({
2260
- formId: 'registration-lifecycle',
2261
- controlId: 'custom-shared',
2262
- })?.metadata.label).toBe('Custom replacement');
2263
- await view.rerender({
2264
- interactionRegistry: registry,
2265
- showFirst: false,
2266
- showCustomFirst: true,
2267
- showCustomReplacement: false,
2268
- legacyCustomCleanup: true,
2269
- compoundLegacyCustom: true,
2270
- });
2271
- await tick();
2272
- await tick();
2273
- expect(registry.get({
2274
- formId: 'registration-lifecycle',
2275
- controlId: 'custom-shared',
2276
- })?.metadata.label).toBe('Custom first');
2277
- await view.rerender({
2278
- interactionRegistry: registry,
2279
- showFirst: false,
2280
- showCustomFirst: false,
2281
- showCustomReplacement: false,
2282
- legacyCustomCleanup: true,
2283
- compoundLegacyCustom: true,
2284
- });
2285
- await tick();
2286
- await tick();
2287
- expect(registry.get({
2288
- formId: 'registration-lifecycle',
2289
- controlId: 'custom-shared',
2290
- })).toBeUndefined();
2291
- });
2292
- it('cleans up built-ins against a legacy void-returning context', async () => {
2293
- const registered = vi.fn();
2294
- const unregistered = vi.fn();
2295
- const view = render(LegacyFormContext, {
2296
- props: { onregister: registered, onunregister: unregistered },
2297
- });
2298
- await tick();
2299
- expect(registered).toHaveBeenCalledWith('legacy-field');
2300
- await view.rerender({
2301
- showField: false,
2302
- onregister: registered,
2303
- onunregister: unregistered,
2304
- });
2305
- await tick();
2306
- expect(unregistered).toHaveBeenCalledWith('legacy-field');
2307
- });
2308
- it('removes a smrt-mode date range when its fieldset becomes disabled', async () => {
2309
- appState.mode = 'smrt';
2310
- const registered = [];
2311
- document.modelContext = {
2312
- registerTool(tool) {
2313
- registered.push(tool);
2314
- },
2315
- };
2316
- const view = render(FormWithStructuredFields, {
2317
- props: { webmcp: true },
2318
- });
2319
- let _regLenBefore = registered.length;
2320
- await tick();
2321
- await tick();
2322
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
2323
- expect(registered.at(-1)?.inputSchema).toMatchObject({
2324
- properties: { dates: expect.any(Object) },
2325
- });
2326
- await view.rerender({ webmcp: true, fieldsetDisabled: true });
2327
- _regLenBefore = registered.length;
2328
- await tick();
2329
- await tick();
2330
- await vi.waitFor(() => expect(registered.length).toBeGreaterThan(_regLenBefore));
2331
- expect(registered.at(-1)?.inputSchema).toMatchObject({ properties: {} });
2332
- expect(await registered.at(-1)?.execute({
2333
- dates: { startDate: '2026-08-26', endDate: '2026-08-27' },
2334
- })).toBe('No reviewable changes provided');
2335
- });
2336
- });