shadow-claw 1.23.3 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (286) hide show
  1. package/README.md +42 -5
  2. package/bin/assert-version-bump.mjs +60 -38
  3. package/bin/assert-version-bump.test.mjs +74 -11
  4. package/bin/cli.mjs +166 -0
  5. package/bin/cli.test.mjs +60 -0
  6. package/bin/commands/backup.mjs +70 -0
  7. package/bin/commands/clients.mjs +41 -0
  8. package/bin/commands/peer-id.mjs +76 -0
  9. package/bin/commands/peer-id.test.mjs +104 -0
  10. package/bin/commands/send.mjs +51 -0
  11. package/bin/commands/tasks.mjs +59 -0
  12. package/bin/commands/webrtc-listen.mjs +254 -0
  13. package/bin/prerender-dsd-shell/prerender-dsd-shell.test.mjs +46 -0
  14. package/bin/rolldown.config.test.mjs +401 -8
  15. package/bin/utils/control-client.mjs +243 -0
  16. package/bin/utils/control-client.test.mjs +83 -0
  17. package/bin/utils/ulid.mjs +45 -0
  18. package/bin/utils/webrtc-control-client.mjs +699 -0
  19. package/bin/utils/webrtc-control-client.test.mjs +318 -0
  20. package/dist/electron/main.cjs +1 -1
  21. package/dist/public/AGENTS.md +5 -3
  22. package/dist/public/README.md +42 -5
  23. package/dist/public/agent.worker.js +6 -6
  24. package/dist/public/{app-routes-B0ePT49v.js → app-routes-Bs-riIvr.js} +1 -1
  25. package/dist/public/backup-controller-C_s9in2t.js +1 -0
  26. package/dist/public/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.css +1 -0
  27. package/dist/public/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.html +1 -0
  28. package/dist/public/components/settings/shadow-claw-storage/shadow-claw-storage.html +1 -1
  29. package/dist/public/components/settings/shadow-claw-task-server/shadow-claw-task-server.css +1 -1
  30. package/dist/public/components/settings/shadow-claw-task-server/shadow-claw-task-server.html +1 -1
  31. package/dist/public/components/shadow-claw-files/shadow-claw-files.css +1 -1
  32. package/dist/public/components/shadow-claw-files/shadow-claw-files.html +1 -1
  33. package/dist/public/components/shadow-claw-settings/shadow-claw-settings.html +1 -1
  34. package/dist/public/config-CrHchneq.js +1 -0
  35. package/dist/public/{configurePeerJs-B7kNU8AQ.js → configurePeerJs-CxpZeDJJ.js} +1 -1
  36. package/dist/public/{connections-xd_66YVU.js → connections-DvbgdkaB.js} +1 -1
  37. package/dist/public/{custom-element-security-CZ92anB8.js → custom-element-security-NCqQ5lmC.js} +1 -1
  38. package/dist/public/docs/README.md +3 -1
  39. package/dist/public/docs/architecture/overview.md +12 -12
  40. package/dist/public/docs/architecture/storage.md +5 -2
  41. package/dist/public/docs/decisions/bundled-typescript-architecture.md +1 -1
  42. package/dist/public/docs/decisions/indexeddb-and-opfs-storage.md +6 -3
  43. package/dist/public/docs/decisions/native-web-components-and-signals.md +1 -1
  44. package/dist/public/docs/example/article/index.html +2 -2
  45. package/dist/public/docs/guides/configuring-messaging-channels.md +41 -6
  46. package/dist/public/docs/guides/server-development-configuration.md +24 -0
  47. package/dist/public/docs/publishing/index.html +19 -76
  48. package/dist/public/docs/skill-creator/index.html +2 -2
  49. package/dist/public/docs/subsystems/backup.md +56 -0
  50. package/dist/public/docs/subsystems/cli.md +124 -0
  51. package/dist/public/docs/subsystems/control-plane.md +158 -0
  52. package/dist/public/docs/subsystems/notifications.md +54 -2
  53. package/dist/public/docs/subsystems/reactive-ui.md +1 -0
  54. package/dist/public/downloadGroupFile-By0GR6eV.js +1 -0
  55. package/dist/public/e2e/README.md +5 -1
  56. package/dist/public/{e2e-bridge-DkS5xFGX.js → e2e-bridge-IGWsRl4i.js} +1 -1
  57. package/dist/public/file-viewer-D0eflgt6.js +1 -0
  58. package/dist/public/files/main/~/docs/publishing-to-github-pages.md +17 -82
  59. package/dist/public/getAllTasks-vpBlgGsc.js +1 -0
  60. package/dist/public/getGroupDir-P1h9wl6S.js +1 -0
  61. package/dist/public/{git-BkjrsRXX.js → git-7Vq_SJSp.js} +2 -2
  62. package/dist/public/{iframe-sanitizer-L6Jyu__Z.js → iframe-sanitizer-y2Z1pPwY.js} +1 -1
  63. package/dist/public/index.html +2 -2
  64. package/dist/public/index.js +1 -1
  65. package/dist/public/{initChatSplitResize-BslPXAXS.js → initChatSplitResize-CzV2E7Ul.js} +1 -1
  66. package/dist/public/initControlPlane-C9jpnWev.js +1 -0
  67. package/dist/public/llms.txt +1 -1
  68. package/dist/public/main/index.html +2 -2
  69. package/dist/public/main/memory/index.html +2 -2
  70. package/dist/public/{markdown-6MBrIo-S.js → markdown-BnpKxSxl.js} +1 -1
  71. package/dist/public/mcp-reconnect-9emsqGRe.js +1 -0
  72. package/dist/public/{memoryStorage-CjGseKI1.js → memoryStorage-C0KvLNUp.js} +1 -1
  73. package/dist/public/{orchestrator-B9KmIj8Q.js → orchestrator-3h9xxHUm.js} +676 -676
  74. package/dist/public/orchestrator-nJj59RqD.js +15 -0
  75. package/dist/public/pages/main/~/docs/publishing-to-github-pages.md +17 -82
  76. package/dist/public/{peerjs-Dv8JNm7v.js → peerjs-BP4laMXj.js} +1 -1
  77. package/dist/public/{push-client-Dqq06wbB.js → push-client-CrjEXK3U.js} +1 -1
  78. package/dist/public/service-worker/fetch-proxy.js +1 -1
  79. package/dist/public/service-worker/push-handler.js +1 -1
  80. package/dist/public/service-worker.js +1 -1
  81. package/dist/public/{shadow-claw-Bd1rfuVC.js → shadow-claw-CbwETBWf.js} +2 -2
  82. package/dist/public/{shadow-claw-accounts-DL2l3oM0.js → shadow-claw-accounts-B4sBTQEj.js} +3 -3
  83. package/dist/public/{shadow-claw-channels-BGXDsQ38.js → shadow-claw-channels-BDWG5Qa3.js} +1 -1
  84. package/dist/public/{shadow-claw-chat-NXg6zz_l.js → shadow-claw-chat-Crv0DTCY.js} +6 -6
  85. package/dist/public/shadow-claw-control-plane-CX1IGbh9.js +199 -0
  86. package/dist/public/{shadow-claw-conversations-C8eqGpP9.js → shadow-claw-conversations-_9qAaRXk.js} +1 -1
  87. package/dist/public/shadow-claw-dialog-BrOwAOdk.js +1 -0
  88. package/dist/public/shadow-claw-file-viewer-Blf6yGVD.js +577 -0
  89. package/dist/public/shadow-claw-files-CYySBDCl.js +840 -0
  90. package/dist/public/{shadow-claw-git-C0ntz7aX.js → shadow-claw-git-v8S0JjuR.js} +3 -3
  91. package/dist/public/{shadow-claw-integrations-DCXV2r69.js → shadow-claw-integrations-D7ufpwhX.js} +1 -1
  92. package/dist/public/{shadow-claw-llm-CudgFQsS.js → shadow-claw-llm-GWgoVfhI.js} +2 -2
  93. package/dist/public/{shadow-claw-mcp-remote-B8RkpX28.js → shadow-claw-mcp-remote-kY4LMFNN.js} +1 -1
  94. package/dist/public/{shadow-claw-networking-BLiRsrsK.js → shadow-claw-networking-qHbP95z9.js} +1 -1
  95. package/dist/public/{shadow-claw-notifications-CQhQcjtL.js → shadow-claw-notifications-YyHOK2Fr.js} +1 -1
  96. package/dist/public/{shadow-claw-page-header-NeRD0tKU.js → shadow-claw-page-header-DMKbkv3s.js} +1 -1
  97. package/dist/public/{shadow-claw-pages-DCHnFh6r.js → shadow-claw-pages-DFZHhzFZ.js} +3 -3
  98. package/dist/public/{shadow-claw-settings-VAfjKFQg.js → shadow-claw-settings-DhxrKIfA.js} +10 -3
  99. package/dist/public/shadow-claw-storage-BFynbP0j.js +219 -0
  100. package/dist/public/{shadow-claw-task-server-Dk33N_1G.js → shadow-claw-task-server-3BIeb5oZ.js} +25 -4
  101. package/dist/public/{shadow-claw-tasks-xA1VfmWN.js → shadow-claw-tasks-Caxc9atL.js} +4 -4
  102. package/dist/public/{shadow-claw-terminal-Du2r0ciq.js → shadow-claw-terminal-Beb5qF-U.js} +1 -1
  103. package/dist/public/{shadow-claw-tools-CZj4rq7l.js → shadow-claw-tools-BfJx3A9W.js} +2 -2
  104. package/dist/public/{shadow-claw-webvm-JYUkWICG.js → shadow-claw-webvm-BCS8ykeP.js} +1 -1
  105. package/dist/public/static-main/~/docs/publishing-to-github-pages.md +17 -82
  106. package/dist/public/static-main-manifest.json +1 -1
  107. package/dist/public/{syncWebMcpRegistration-DfpvASnh.js → syncWebMcpRegistration-B_M2fCjH.js} +1 -1
  108. package/dist/server.js +286 -189
  109. package/package.json +2 -1
  110. package/src/__mocks__/jszip.cjs +15 -5
  111. package/src/components/settings/shadow-claw-accounts/shadow-claw-accounts.test.ts +35 -0
  112. package/src/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.css +117 -0
  113. package/src/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.html +81 -0
  114. package/src/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.test.ts +127 -0
  115. package/src/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.ts +292 -0
  116. package/src/components/settings/shadow-claw-git/shadow-claw-git.test.ts +72 -0
  117. package/src/components/settings/shadow-claw-integrations/connection-test-auth.test.ts +20 -30
  118. package/src/components/settings/shadow-claw-integrations/shadow-claw-integrations.test.ts +116 -0
  119. package/src/components/settings/shadow-claw-llm/model-ranking.test.ts +143 -51
  120. package/src/components/settings/shadow-claw-llm/shadow-claw-llm.test.ts +307 -4
  121. package/src/components/settings/shadow-claw-llm/shadow-claw-llm.ts +8 -8
  122. package/src/components/settings/shadow-claw-mcp-remote/shadow-claw-mcp-remote.test.ts +31 -0
  123. package/src/components/settings/shadow-claw-peerjs/shadow-claw-peerjs.test.ts +186 -0
  124. package/src/components/settings/shadow-claw-storage/shadow-claw-storage.html +17 -0
  125. package/src/components/settings/shadow-claw-storage/shadow-claw-storage.test.ts +120 -0
  126. package/src/components/settings/shadow-claw-storage/shadow-claw-storage.ts +56 -0
  127. package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.css +23 -2
  128. package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.html +1 -1
  129. package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.test.ts +19 -0
  130. package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.ts +2 -1
  131. package/src/components/shadow-claw/utils/initChatSplitResize.test.ts +180 -13
  132. package/src/components/shadow-claw/utils/setupEffects.test.ts +39 -0
  133. package/src/components/shadow-claw-a2ui/catalog/basic/shared.test.ts +117 -0
  134. package/src/components/shadow-claw-a2ui/shadow-claw-a2ui.test.ts +1159 -1
  135. package/src/components/shadow-claw-chat/shadow-claw-chat.test.ts +1523 -19
  136. package/src/components/shadow-claw-chat/utils/escapeHtml.test.ts +13 -0
  137. package/src/components/shadow-claw-chat/utils/getPeerChatDisplayStatus.test.ts +20 -0
  138. package/src/components/shadow-claw-conversations/shadow-claw-conversations.test.ts +494 -0
  139. package/src/components/shadow-claw-dialog/shadow-claw-dialog.ts +13 -2
  140. package/src/components/shadow-claw-file-viewer/shadow-claw-file-viewer.test.ts +378 -0
  141. package/src/components/shadow-claw-file-viewer/shadow-claw-file-viewer.ts +11 -9
  142. package/src/components/shadow-claw-files/shadow-claw-files.css +41 -17
  143. package/src/components/shadow-claw-files/shadow-claw-files.html +35 -0
  144. package/src/components/shadow-claw-files/shadow-claw-files.test.ts +627 -1
  145. package/src/components/shadow-claw-files/shadow-claw-files.ts +170 -6
  146. package/src/components/shadow-claw-pages/utils/resolveWorkspaceLinkPath.test.ts +31 -0
  147. package/src/components/shadow-claw-pages/utils/resolveWorkspaceLinkPath.ts +2 -1
  148. package/src/components/shadow-claw-pages/utils/rewriteWorkspacePreviewHtml.test.ts +15 -0
  149. package/src/components/shadow-claw-pdf-viewer/utils/installGetOrInsertComputedPolyfill.test.ts +65 -0
  150. package/src/components/shadow-claw-settings/shadow-claw-settings.html +7 -0
  151. package/src/components/shadow-claw-settings/shadow-claw-settings.test.ts +221 -0
  152. package/src/components/shadow-claw-settings/shadow-claw-settings.ts +2 -0
  153. package/src/components/shadow-claw-tasks/shadow-claw-tasks.test.ts +407 -0
  154. package/src/components/shadow-claw-terminal/utils/consumeEscapeSequence.test.ts +58 -0
  155. package/src/components/shadow-claw-tools/shadow-claw-tools.test.ts +80 -0
  156. package/src/config/config.test.ts +9 -0
  157. package/src/config/config.ts +15 -0
  158. package/src/core/app-routes.test.ts +56 -0
  159. package/src/core/app-routes.ts +8 -1
  160. package/src/core/backup-controller.test.ts +101 -0
  161. package/src/core/backup-controller.ts +137 -0
  162. package/src/core/control-plane-client.test.ts +408 -0
  163. package/src/core/control-plane-client.ts +425 -0
  164. package/src/core/orchestrator/orchestrator.ts +4 -0
  165. package/src/core/orchestrator/utils/compactContext.test.ts +78 -0
  166. package/src/core/orchestrator/utils/operations/channel.test.ts +254 -9
  167. package/src/core/orchestrator/utils/operations/provider.test.ts +326 -34
  168. package/src/core/orchestrator/utils/operations/vm.test.ts +114 -2
  169. package/src/core/orchestrator/utils/syncProxyConfigToServiceWorker.test.ts +60 -0
  170. package/src/core/utils/initControlPlane.test.ts +276 -0
  171. package/src/core/utils/initControlPlane.ts +437 -0
  172. package/src/core/utils/initializeApp.ts +18 -0
  173. package/src/db/getSession.test.ts +13 -1
  174. package/src/db/openDatabase.test.ts +10 -1
  175. package/src/db/openDatabase.ts +3 -1
  176. package/src/server/app.test.ts +12 -0
  177. package/src/server/app.ts +18 -8
  178. package/src/server/client-registry.test.ts +250 -0
  179. package/src/server/client-registry.ts +477 -0
  180. package/src/server/config.ts +15 -0
  181. package/src/server/control-plane-types.ts +120 -0
  182. package/src/server/control-plane.test.ts +472 -0
  183. package/src/server/control-plane.ts +581 -0
  184. package/src/server/routes/backup.test.ts +263 -0
  185. package/src/server/routes/backup.ts +262 -0
  186. package/src/server/routes/integrations-email.test.ts +532 -0
  187. package/src/server/routes/llamafile.test.ts +172 -48
  188. package/src/server/routes/mesh-llm.test.ts +192 -0
  189. package/src/server/routes/ollama.test.ts +49 -0
  190. package/src/server/server-peer.test.ts +86 -0
  191. package/src/server/server-peer.ts +330 -0
  192. package/src/server/server.ts +35 -0
  193. package/src/server/services/llamafile-manager.test.ts +230 -6
  194. package/src/server/services/transformers-runtime.test.ts +186 -18
  195. package/src/server/webrtc.test.ts +21 -0
  196. package/src/server/webrtc.ts +72 -0
  197. package/src/service-worker/fetch-proxy.test.ts +64 -1
  198. package/src/service-worker/push-handler.ts +39 -0
  199. package/src/shell/vm.test.ts +744 -12
  200. package/src/storage/ensureMainGroupIndex.test.ts +153 -0
  201. package/src/storage/getGroupFile.test.ts +66 -0
  202. package/src/storage/memoryStorage.test.ts +82 -2
  203. package/src/storage/migrateLegacyOpfs.test.ts +327 -0
  204. package/src/storage/migrateLegacyOpfs.ts +117 -0
  205. package/src/storage/writeGroupFileBytes.test.ts +143 -0
  206. package/src/stores/chat-ui.test.ts +20 -0
  207. package/src/stores/orchestrator.test.ts +134 -0
  208. package/src/stores/toast.test.ts +50 -0
  209. package/src/stores/tools.test.ts +147 -10
  210. package/src/subsystems/channels/imessage.test.ts +59 -0
  211. package/src/subsystems/channels/peerjs.test.ts +500 -62
  212. package/src/subsystems/channels/peerjs.ts +71 -4
  213. package/src/subsystems/channels/telegram.test.ts +177 -0
  214. package/src/subsystems/email/catalog.test.ts +1 -0
  215. package/src/subsystems/email/connections.test.ts +39 -1
  216. package/src/subsystems/git/credentials.test.ts +160 -825
  217. package/src/subsystems/git/git.test.ts +463 -0
  218. package/src/subsystems/notifications/__mocks__/node-sqlite.cjs +12 -1
  219. package/src/subsystems/notifications/push-routes.test.ts +33 -0
  220. package/src/subsystems/notifications/push-routes.ts +23 -0
  221. package/src/subsystems/notifications/push-store.ts +1 -1
  222. package/src/subsystems/providers/builtin-ai-tasks.test.ts +74 -0
  223. package/src/subsystems/providers/litert-lm-provider.test.ts +212 -51
  224. package/src/subsystems/providers/model-registry.test.ts +115 -0
  225. package/src/subsystems/providers/providers.test.ts +94 -1
  226. package/src/subsystems/providers/transformers-js-provider.test.ts +279 -0
  227. package/src/subsystems/providers/utils/createModelCacheFetch.test.ts +2 -2
  228. package/src/subsystems/providers/utils/downloadModelToCache.test.ts +2 -2
  229. package/src/subsystems/providers/utils/loadModelStream.test.ts +2 -2
  230. package/src/subsystems/skills/activateSkill.test.ts +101 -0
  231. package/src/subsystems/skills/discoverSkills.test.ts +251 -0
  232. package/src/subsystems/tools/cron.test.ts +64 -0
  233. package/src/subsystems/tools/declarative.test.ts +255 -89
  234. package/src/testing/assets/v86.9pfs/libv86.mjs +63 -0
  235. package/src/testing/assets/v86.ext2/libv86.mjs +3 -0
  236. package/src/testing/assets_missing_v86/assets/v86.9pfs/libv86.mjs +1 -0
  237. package/src/testing/jest-setup.ts +328 -40
  238. package/src/testing/mock-v86.mjs +49 -0
  239. package/src/ui/a2ui/types.ts +4 -3
  240. package/src/ui/a2ui/utils/normaliseComponentsToMap.test.ts +102 -0
  241. package/src/ui/a2ui/utils/resolveChildIds.test.ts +63 -0
  242. package/src/ui/a2ui/utils/resolveDynamicNumber.test.ts +13 -1
  243. package/src/worker/tools/bash/utils/waitForVMReady.test.ts +40 -0
  244. package/src/worker/tools/email/email.test.ts +193 -8
  245. package/src/worker/tools/email/utils/asEmailAttachmentInputs.test.ts +47 -0
  246. package/src/worker/tools/git/git.test.ts +214 -3
  247. package/src/worker/tools/rooms/invite-to-room.test.ts +54 -0
  248. package/src/worker/tools/rooms/leave-room.test.ts +40 -0
  249. package/src/worker/tools/rooms/list-room-members.test.ts +75 -0
  250. package/src/worker/tools/tasks/tasks-utils.test.ts +22 -0
  251. package/src/worker/tools/ui/ask-user.test.ts +50 -0
  252. package/src/worker/tools/ui/clear-chat.test.ts +19 -0
  253. package/src/worker/tools/ui/get-current-time.test.ts +19 -0
  254. package/src/worker/tools/ui/javascript.test.ts +116 -0
  255. package/src/worker/tools/ui/list-components.test.ts +10 -0
  256. package/src/worker/tools/ui/list-tool-profiles.test.ts +42 -0
  257. package/src/worker/tools/ui/manage-tools.test.ts +34 -0
  258. package/src/worker/tools/ui/render-component.test.ts +346 -0
  259. package/src/worker/tools/ui/send-notification.test.ts +40 -0
  260. package/src/worker/tools/ui/utils/getAllowFullInternetAccess.test.ts +31 -0
  261. package/src/worker/tools/ui/utils/parseBooleanConfig.test.ts +25 -0
  262. package/src/worker/tools/workspace/attach-file.test.ts +84 -0
  263. package/src/worker/tools/workspace/copy-file.test.ts +69 -0
  264. package/src/worker/tools/workspace/create-directory.test.ts +40 -0
  265. package/src/worker/tools/workspace/delete-file.test.ts +73 -0
  266. package/src/worker/tools/workspace/diff-files.test.ts +94 -0
  267. package/src/worker/tools/workspace/list-files.test.ts +36 -0
  268. package/src/worker/tools/workspace/move-file.test.ts +66 -0
  269. package/src/worker/tools/workspace/open-file.test.ts +57 -0
  270. package/src/worker/tools/workspace/patch-file.test.ts +81 -0
  271. package/src/worker/tools/workspace/read-file.test.ts +116 -0
  272. package/src/worker/tools/workspace/send-file.test.ts +82 -0
  273. package/src/worker/tools/workspace/update-memory.test.ts +29 -0
  274. package/src/worker/tools/workspace/write-file.test.ts +28 -0
  275. package/src/worker/utils/rate-limit.test.ts +199 -0
  276. package/src/worker/utils/tool-state.test.ts +29 -0
  277. package/src/worker/utils/transformers-js-utils.test.ts +59 -0
  278. package/dist/public/config-AL77QAx4.js +0 -1
  279. package/dist/public/downloadGroupFile-BABv_1mJ.js +0 -1
  280. package/dist/public/file-viewer-CCSABMsE.js +0 -1
  281. package/dist/public/mcp-reconnect-DoicazQd.js +0 -1
  282. package/dist/public/orchestrator-4su7iyWj.js +0 -15
  283. package/dist/public/shadow-claw-dialog-ByyHgxM9.js +0 -1
  284. package/dist/public/shadow-claw-file-viewer-9Qkwc30T.js +0 -577
  285. package/dist/public/shadow-claw-files-aDyq6Hv7.js +0 -781
  286. package/dist/public/shadow-claw-storage-Cf31np81.js +0 -202
@@ -1,5 +1,6 @@
1
1
  import { jest } from "@jest/globals";
2
2
 
3
+ import { setDB } from "../../db/db.js";
3
4
  import { registerBasicFunctions } from "../../ui/a2ui/registries/basicFunctions.js";
4
5
  import { A2UI_BASIC_CATALOG_ID } from "../../ui/a2ui/types.js";
5
6
  import { applyDataModelPatches } from "../../ui/a2ui/utils/applyDataModelUpdate.js";
@@ -9,6 +10,7 @@ import { resolveJsonPointer } from "../../ui/a2ui/utils/resolveJsonPointer.js";
9
10
  import type { A2UICreateSurface } from "../../ui/a2ui/types.js";
10
11
 
11
12
  beforeAll(() => {
13
+ setDB({} as any);
12
14
  registerBasicFunctions();
13
15
  });
14
16
 
@@ -131,6 +133,25 @@ describe("applyDataModelPatches", () => {
131
133
  // getSurfaceId() is a pure accessor we use as observable output.
132
134
  // ---------------------------------------------------------------------------
133
135
 
136
+ // Mock readGroupFileBytes for workspace media resolution
137
+ jest.mock("../../storage/readGroupFileBytes.js", () => ({
138
+ readGroupFileBytes: jest.fn(
139
+ async (_db: any, _groupId: string, filePath: string) => {
140
+ if (filePath.includes("error")) {
141
+ throw new Error("File read error");
142
+ }
143
+ return new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
144
+ },
145
+ ),
146
+ }));
147
+
148
+ global.URL.createObjectURL = jest.fn(() => "http://localhost/mock-blob");
149
+ global.URL.revokeObjectURL = jest.fn();
150
+ if (typeof window !== "undefined") {
151
+ window.URL.createObjectURL = global.URL.createObjectURL;
152
+ window.URL.revokeObjectURL = global.URL.revokeObjectURL;
153
+ }
154
+
134
155
  // Mock ShadowClawElement so we can instantiate without full browser lifecycle
135
156
  jest.mock("../shadow-claw-element.js", () => {
136
157
  class MockBase extends HTMLElement {
@@ -140,9 +161,12 @@ jest.mock("../shadow-claw-element.js", () => {
140
161
  constructor() {
141
162
  super();
142
163
  this.attachShadow({ mode: "open" });
164
+ const surface = document.createElement("div");
165
+ surface.className = "a2ui__surface";
143
166
  const root = document.createElement("div");
144
167
  root.className = "a2ui__root";
145
- this.shadowRoot?.replaceChildren(root);
168
+ surface.appendChild(root);
169
+ this.shadowRoot?.replaceChildren(surface);
146
170
  }
147
171
 
148
172
  connectedCallback() {}
@@ -633,4 +657,1138 @@ describe("ShadowClawA2UI action dispatch", () => {
633
657
  expect((texts[2] as HTMLElement).textContent?.trim()).toBe("2025-12-17");
634
658
  expect((texts[3] as HTMLElement).textContent?.trim()).toBe("⛅");
635
659
  });
660
+
661
+ it("handles AudioPlayer, Video, and Image rendering and action handlers", async () => {
662
+ const el = document.createElement("shadow-claw-a2ui") as any;
663
+ el.groupId = "peer:01ktq6kh3bb9nf37a2wpeeb64v";
664
+ document.body.appendChild(el);
665
+
666
+ await (el as any).onTemplateReady;
667
+
668
+ el.applyEnvelope({
669
+ version: "v1.0" as const,
670
+ type: "createSurface",
671
+ surfaceId: "media-surface",
672
+ catalogId: A2UI_BASIC_CATALOG_ID,
673
+ components: [
674
+ {
675
+ id: "root",
676
+ component: "Column",
677
+ children: ["img-1", "vid-1", "btn-play", "btn-pause"],
678
+ },
679
+ {
680
+ id: "img-1",
681
+ component: "Image",
682
+ url: "photo.png",
683
+ alt: "A photo",
684
+ },
685
+ {
686
+ id: "vid-1",
687
+ component: "Video",
688
+ url: "clip.mp4",
689
+ },
690
+ {
691
+ id: "btn-play",
692
+ component: "Button",
693
+ label: "Play",
694
+ action: "playTrack",
695
+ },
696
+ {
697
+ id: "btn-pause",
698
+ component: "Button",
699
+ label: "Pause",
700
+ action: "pauseTrack",
701
+ },
702
+ ] as any,
703
+ });
704
+
705
+ const root = el.shadowRoot;
706
+ const img = root?.querySelector("img");
707
+ const video = root?.querySelector("video");
708
+ const buttons = root?.querySelectorAll("button") || [];
709
+
710
+ expect(img).not.toBeNull();
711
+ expect(video).not.toBeNull();
712
+ expect(buttons.length).toBeGreaterThan(0);
713
+
714
+ // Test deleteSurface
715
+ el.applyEnvelope({
716
+ version: "v1.0" as const,
717
+ type: "deleteSurface",
718
+ surfaceId: "media-surface",
719
+ });
720
+ expect(root?.querySelector(".a2ui__root")?.children.length).toBe(0);
721
+
722
+ document.body.removeChild(el);
723
+ });
724
+
725
+ it("handles actionResponse and callFunction envelopes", async () => {
726
+ const el = document.createElement("shadow-claw-a2ui") as any;
727
+ el.groupId = "peer:test-group";
728
+ document.body.appendChild(el);
729
+
730
+ await (el as any).onTemplateReady;
731
+
732
+ el.applyEnvelope({
733
+ version: "v1.0" as const,
734
+ type: "createSurface",
735
+ surfaceId: "interactive-surface",
736
+ catalogId: A2UI_BASIC_CATALOG_ID,
737
+ components: [
738
+ {
739
+ id: "root",
740
+ component: "Text",
741
+ text: {
742
+ path: "/user/name",
743
+ },
744
+ },
745
+ ] as any,
746
+ dataModel: {
747
+ user: { name: "Alice" },
748
+ },
749
+ });
750
+
751
+ const root = el.shadowRoot;
752
+ expect(root?.querySelector(".a2ui__text")?.textContent?.trim()).toBe(
753
+ "Alice",
754
+ );
755
+
756
+ // Apply actionResponse to update data model
757
+ el.applyEnvelope({
758
+ version: "v1.0" as const,
759
+ type: "actionResponse",
760
+ surfaceId: "interactive-surface",
761
+ responsePath: "/user/name",
762
+ value: "Bob",
763
+ });
764
+
765
+ expect(root?.querySelector(".a2ui__text")?.textContent?.trim()).toBe("Bob");
766
+
767
+ // Call function
768
+ let functionResponseDetail: any = null;
769
+ el.addEventListener("shadow-claw-a2ui-function-response", (evt: any) => {
770
+ functionResponseDetail = evt.detail;
771
+ });
772
+
773
+ el.applyEnvelope({
774
+ version: "v1.0" as const,
775
+ type: "callFunction",
776
+ surfaceId: "interactive-surface",
777
+ callId: "call-1",
778
+ call: {
779
+ call: "capitalize",
780
+ args: {
781
+ value: "world",
782
+ },
783
+ },
784
+ });
785
+
786
+ expect(functionResponseDetail).not.toBeNull();
787
+ expect(functionResponseDetail.response.value).toBe("World");
788
+
789
+ // Call function with error
790
+ let functionErrorDetail: any = null;
791
+ el.addEventListener("shadow-claw-a2ui-function-response", (evt: any) => {
792
+ functionErrorDetail = evt.detail;
793
+ });
794
+
795
+ el.applyEnvelope({
796
+ version: "v1.0" as const,
797
+ type: "callFunction",
798
+ surfaceId: "interactive-surface",
799
+ callId: "call-2",
800
+ call: {
801
+ call: "nonExistentFunction",
802
+ args: {},
803
+ },
804
+ });
805
+
806
+ expect(functionErrorDetail).not.toBeNull();
807
+ expect(functionErrorDetail.response.error).toContain(
808
+ "INVALID_FUNCTION_CALL",
809
+ );
810
+
811
+ document.body.removeChild(el);
812
+ });
813
+
814
+ it("handles play, playTrack, pause, pauseTrack, and closeModal actions locally", async () => {
815
+ const el = document.createElement("shadow-claw-a2ui") as any;
816
+ el.groupId = "peer:test-media";
817
+ document.body.appendChild(el);
818
+
819
+ await (el as any).onTemplateReady;
820
+
821
+ el.applyEnvelope({
822
+ version: "v1.0" as const,
823
+ type: "createSurface",
824
+ surfaceId: "actions-surface",
825
+ catalogId: A2UI_BASIC_CATALOG_ID,
826
+ components: [
827
+ {
828
+ id: "root",
829
+ component: "Column",
830
+ children: ["aud", "vid", "mod", "btn-play", "btn-pause", "btn-close"],
831
+ },
832
+ { id: "aud", component: "AudioPlayer", url: "track.mp3" },
833
+ { id: "vid", component: "Video", url: "clip.mp4" },
834
+ {
835
+ id: "mod",
836
+ component: "Modal",
837
+ trigger: "mod-btn",
838
+ content: "mod-txt",
839
+ },
840
+ { id: "mod-btn", component: "Text", text: "Open Modal" },
841
+ { id: "mod-txt", component: "Text", text: "Modal Body" },
842
+ {
843
+ id: "btn-play",
844
+ component: "Button",
845
+ text: "Play",
846
+ action: { event: { name: "playTrack" } },
847
+ },
848
+ {
849
+ id: "btn-pause",
850
+ component: "Button",
851
+ text: "Pause",
852
+ action: { id: "pauseTrack" },
853
+ },
854
+ {
855
+ id: "btn-close",
856
+ component: "Button",
857
+ text: "Close",
858
+ action: { event: { name: "closeModal" } },
859
+ },
860
+ ] as any,
861
+ });
862
+
863
+ const root = el.shadowRoot;
864
+ const audio = root.querySelector("audio");
865
+ const video = root.querySelector("video");
866
+ const modalOverlay = root.querySelector(".a2ui__modal-overlay");
867
+
868
+ // Mock play/pause on audio and video
869
+ audio.play = jest.fn<() => Promise<void>>().mockResolvedValue(undefined);
870
+ audio.pause = jest.fn();
871
+ video.play = jest.fn<() => Promise<void>>().mockResolvedValue(undefined);
872
+ video.pause = jest.fn();
873
+
874
+ // Find and click Play button
875
+ const buttons = Array.from(
876
+ root.querySelectorAll("button"),
877
+ ) as HTMLButtonElement[];
878
+ const playBtn = buttons.find((b) => b.textContent?.includes("Play"));
879
+ playBtn?.click();
880
+ expect(audio.play).toHaveBeenCalled();
881
+ expect(video.play).toHaveBeenCalled();
882
+
883
+ // Find and click Pause button
884
+ const pauseBtn = buttons.find((b) => b.textContent?.includes("Pause"));
885
+ pauseBtn?.click();
886
+ expect(audio.pause).toHaveBeenCalled();
887
+ expect(video.pause).toHaveBeenCalled();
888
+
889
+ // Open modal then test closeModal button
890
+ if (modalOverlay) {
891
+ modalOverlay.style.display = "block";
892
+ }
893
+ const closeBtn = buttons.find((b) => b.textContent?.includes("Close"));
894
+ closeBtn?.click();
895
+ if (modalOverlay) {
896
+ expect(modalOverlay.style.display).toBe("none");
897
+ }
898
+
899
+ document.body.removeChild(el);
900
+ });
901
+
902
+ it("resolves deferred workspace media to blob URLs for various extensions", async () => {
903
+ const el = document.createElement("shadow-claw-a2ui") as any;
904
+ el.groupId = "group:workspace-123";
905
+ document.body.appendChild(el);
906
+
907
+ await (el as any).onTemplateReady;
908
+
909
+ const extensions = [
910
+ "file.jpg",
911
+ "file.jpeg",
912
+ "file.png",
913
+ "file.gif",
914
+ "file.webp",
915
+ "file.svg",
916
+ "file.mp4",
917
+ "file.m4v",
918
+ "file.webm",
919
+ "file.mkv",
920
+ "file.mov",
921
+ "file.mp3",
922
+ "file.wav",
923
+ "file.flac",
924
+ "file.aac",
925
+ "file.m4a",
926
+ ];
927
+
928
+ const components: any[] = [
929
+ {
930
+ id: "root",
931
+ component: "Column",
932
+ children: extensions.map((_, i) => `media-${i}`),
933
+ },
934
+ ];
935
+
936
+ extensions.forEach((ext, i) => {
937
+ if (
938
+ ext.endsWith(".mp4") ||
939
+ ext.endsWith(".webm") ||
940
+ ext.endsWith(".mov") ||
941
+ ext.endsWith(".mkv") ||
942
+ ext.endsWith(".m4v")
943
+ ) {
944
+ components.push({
945
+ id: `media-${i}`,
946
+ component: "Video",
947
+ url: ext,
948
+ posterUrl: "poster.png",
949
+ });
950
+ } else if (
951
+ ext.endsWith(".mp3") ||
952
+ ext.endsWith(".wav") ||
953
+ ext.endsWith(".flac") ||
954
+ ext.endsWith(".aac") ||
955
+ ext.endsWith(".m4a")
956
+ ) {
957
+ components.push({
958
+ id: `media-${i}`,
959
+ component: "AudioPlayer",
960
+ url: ext,
961
+ description: `Audio ${ext}`,
962
+ });
963
+ } else {
964
+ components.push({
965
+ id: `media-${i}`,
966
+ component: "Image",
967
+ url: ext,
968
+ alt: `Image ${ext}`,
969
+ });
970
+ }
971
+ });
972
+
973
+ el.applyEnvelope({
974
+ version: "v1.0" as const,
975
+ type: "createSurface",
976
+ surfaceId: "all-media-surface",
977
+ catalogId: A2UI_BASIC_CATALOG_ID,
978
+ components,
979
+ dataModel: {},
980
+ });
981
+
982
+ // Also call render() override to test line coverage
983
+ await el.render();
984
+
985
+ // Allow promise resolution for blob conversion
986
+ await new Promise((r) => setTimeout(r, 50));
987
+
988
+ const images = el.shadowRoot.querySelectorAll("img");
989
+ expect(images.length).toBeGreaterThan(0);
990
+
991
+ // Test with error file
992
+ el.applyEnvelope({
993
+ version: "v1.0" as const,
994
+ type: "createSurface",
995
+ surfaceId: "error-media-surface",
996
+ catalogId: A2UI_BASIC_CATALOG_ID,
997
+ components: [
998
+ { id: "root", component: "Image", url: "error-image.jpg" },
999
+ ] as any,
1000
+ dataModel: {},
1001
+ });
1002
+ await new Promise((r) => setTimeout(r, 50));
1003
+
1004
+ document.body.removeChild(el);
1005
+ });
1006
+
1007
+ it("handles unknown component IDs and unknown component types gracefully", async () => {
1008
+ const el = document.createElement("shadow-claw-a2ui") as any;
1009
+ el.groupId = "peer:test";
1010
+ document.body.appendChild(el);
1011
+
1012
+ await (el as any).onTemplateReady;
1013
+
1014
+ const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
1015
+
1016
+ el.applyEnvelope({
1017
+ version: "v1.0" as const,
1018
+ type: "createSurface",
1019
+ surfaceId: "unknown-surface",
1020
+ catalogId: A2UI_BASIC_CATALOG_ID,
1021
+ components: [
1022
+ {
1023
+ id: "root",
1024
+ component: "Column",
1025
+ children: ["missing-child", "unknown-type-child"],
1026
+ },
1027
+ { id: "unknown-type-child", component: "NonExistentComponentType" },
1028
+ ] as any,
1029
+ dataModel: {},
1030
+ });
1031
+
1032
+ expect(warnSpy).toHaveBeenCalled();
1033
+ warnSpy.mockRestore();
1034
+
1035
+ document.body.removeChild(el);
1036
+ });
1037
+ });
1038
+
1039
+ describe("Basic catalog components unit tests", () => {
1040
+ const mockSurface = {
1041
+ surfaceId: "s1",
1042
+ components: {},
1043
+ dataModel: {
1044
+ isDone: true,
1045
+ fruit: "apple",
1046
+ fruits: ["apple", "banana"],
1047
+ count: 25,
1048
+ dateVal: "2025-05-15T10:30",
1049
+ notes: "Some initial notes",
1050
+ item: { name: "Scoped item" },
1051
+ },
1052
+ rootComponentId: "root",
1053
+ };
1054
+
1055
+ it("CheckBox handles path, $dataModel, boolean, change events, and actions", async () => {
1056
+ const updatePointer = jest.fn();
1057
+ const dispatchAction = jest.fn();
1058
+
1059
+ // 1. Spec canonical path
1060
+ const cb1 = (await import("./catalog/basic/checkbox.js")).renderCheckBox(
1061
+ {
1062
+ id: "cb1",
1063
+ component: "CheckBox",
1064
+ label: "Mark Done",
1065
+ value: { path: "/isDone" },
1066
+ action: { event: { name: "checkbox-toggle" } },
1067
+ },
1068
+ mockSurface,
1069
+ { updateDataModelPointer: updatePointer, dispatchAction },
1070
+ );
1071
+ const input1 = cb1.querySelector("input")!;
1072
+ expect(input1.checked).toBe(true);
1073
+ input1.checked = false;
1074
+ input1.dispatchEvent(new Event("change"));
1075
+ expect(updatePointer).toHaveBeenCalledWith("/isDone", false);
1076
+ expect(dispatchAction).toHaveBeenCalledWith("checkbox-toggle");
1077
+
1078
+ // 2. Deprecated $dataModel
1079
+ const cb2 = (await import("./catalog/basic/checkbox.js")).renderCheckBox(
1080
+ {
1081
+ id: "cb2",
1082
+ component: "CheckBox",
1083
+ label: "Mark Done 2",
1084
+ value: { $dataModel: "/isDone" },
1085
+ action: { id: "legacy-action" } as any,
1086
+ },
1087
+ mockSurface,
1088
+ { updateDataModelPointer: updatePointer, dispatchAction },
1089
+ );
1090
+ const input2 = cb2.querySelector("input")!;
1091
+ input2.checked = true;
1092
+ input2.dispatchEvent(new Event("change"));
1093
+ expect(updatePointer).toHaveBeenCalledWith("/isDone", true);
1094
+ expect(dispatchAction).toHaveBeenCalledWith("legacy-action");
1095
+
1096
+ // 3. Boolean static value
1097
+ const cb3 = (await import("./catalog/basic/checkbox.js")).renderCheckBox(
1098
+ {
1099
+ id: "cb3",
1100
+ component: "CheckBox",
1101
+ label: "Static",
1102
+ value: false,
1103
+ },
1104
+ mockSurface,
1105
+ { updateDataModelPointer: updatePointer, dispatchAction },
1106
+ );
1107
+ expect(cb3.querySelector("input")!.checked).toBe(false);
1108
+ });
1109
+
1110
+ it("ChoicePicker handles single and multiple selection with primitive and object options", async () => {
1111
+ const updatePointer = jest.fn();
1112
+
1113
+ // 1. Single selection with path
1114
+ const cp1 = (
1115
+ await import("./catalog/basic/choice-picker.js")
1116
+ ).renderChoicePicker(
1117
+ {
1118
+ id: "cp1",
1119
+ component: "ChoicePicker",
1120
+ options: [
1121
+ { value: "apple", label: "Apple" },
1122
+ { value: "orange", label: "Orange" },
1123
+ ],
1124
+ value: { path: "/fruit" },
1125
+ },
1126
+ mockSurface,
1127
+ { updateDataModelPointer: updatePointer },
1128
+ );
1129
+ const radios = cp1.querySelectorAll<HTMLInputElement>(
1130
+ "input[type='radio']",
1131
+ );
1132
+ expect(radios[0].checked).toBe(true);
1133
+ expect(radios[1].checked).toBe(false);
1134
+
1135
+ radios[1].checked = true;
1136
+ radios[1].dispatchEvent(new Event("change"));
1137
+ expect(updatePointer).toHaveBeenCalledWith("/fruit", "orange");
1138
+
1139
+ // 2. Multiple selection with $dataModel and primitive options
1140
+ const cp2 = (
1141
+ await import("./catalog/basic/choice-picker.js")
1142
+ ).renderChoicePicker(
1143
+ {
1144
+ id: "cp2",
1145
+ component: "ChoicePicker",
1146
+ variant: "multipleSelection",
1147
+ options: ["apple", "banana", "grape"] as any,
1148
+ value: { $dataModel: "/fruits" },
1149
+ },
1150
+ mockSurface,
1151
+ { updateDataModelPointer: updatePointer },
1152
+ );
1153
+ const checkboxes = cp2.querySelectorAll<HTMLInputElement>(
1154
+ "input[type='checkbox']",
1155
+ );
1156
+ expect(checkboxes.length).toBe(3);
1157
+ expect(checkboxes[0].checked).toBe(true);
1158
+ expect(checkboxes[1].checked).toBe(true);
1159
+ expect(checkboxes[2].checked).toBe(false);
1160
+
1161
+ checkboxes[2].checked = true;
1162
+ checkboxes[2].dispatchEvent(new Event("change"));
1163
+ expect(updatePointer).toHaveBeenCalledWith("/fruits", [
1164
+ "apple",
1165
+ "banana",
1166
+ "grape",
1167
+ ]);
1168
+ });
1169
+
1170
+ it("DateTimeInput handles date, time, datetime-local, and text formats", async () => {
1171
+ const updatePointer = jest.fn();
1172
+
1173
+ // 1. datetime-local
1174
+ const dt1 = (
1175
+ await import("./catalog/basic/date-time-input.js")
1176
+ ).renderDateTimeInput(
1177
+ {
1178
+ id: "dt1",
1179
+ component: "DateTimeInput",
1180
+ label: "Event Time",
1181
+ enableDate: true,
1182
+ enableTime: true,
1183
+ value: { path: "/dateVal" },
1184
+ min: "2025-01-01T00:00",
1185
+ max: "2025-12-31T23:59",
1186
+ },
1187
+ mockSurface,
1188
+ { updateDataModelPointer: updatePointer },
1189
+ );
1190
+ const input1 = dt1.querySelector("input")!;
1191
+ expect(input1.type).toBe("datetime-local");
1192
+ expect(input1.value).toBe("2025-05-15T10:30");
1193
+ input1.value = "2025-06-01T12:00";
1194
+ input1.dispatchEvent(new Event("input"));
1195
+ expect(updatePointer).toHaveBeenCalledWith("/dateVal", "2025-06-01T12:00");
1196
+
1197
+ // 2. Date only with $dataModel
1198
+ const dt2 = (
1199
+ await import("./catalog/basic/date-time-input.js")
1200
+ ).renderDateTimeInput(
1201
+ {
1202
+ id: "dt2",
1203
+ component: "DateTimeInput",
1204
+ enableDate: true,
1205
+ value: { $dataModel: "/dateVal" },
1206
+ },
1207
+ mockSurface,
1208
+ { updateDataModelPointer: updatePointer },
1209
+ );
1210
+ expect(dt2.querySelector("input")!.type).toBe("date");
1211
+
1212
+ // 3. Time only
1213
+ const dt3 = (
1214
+ await import("./catalog/basic/date-time-input.js")
1215
+ ).renderDateTimeInput(
1216
+ {
1217
+ id: "dt3",
1218
+ component: "DateTimeInput",
1219
+ enableTime: true,
1220
+ value: "14:30",
1221
+ },
1222
+ mockSurface,
1223
+ { updateDataModelPointer: updatePointer },
1224
+ );
1225
+ expect(dt3.querySelector("input")!.type).toBe("time");
1226
+ expect(dt3.querySelector("input")!.value).toBe("14:30");
1227
+
1228
+ // 4. Fallback text
1229
+ const dt4 = (
1230
+ await import("./catalog/basic/date-time-input.js")
1231
+ ).renderDateTimeInput(
1232
+ {
1233
+ id: "dt4",
1234
+ component: "DateTimeInput",
1235
+ },
1236
+ mockSurface,
1237
+ { updateDataModelPointer: updatePointer },
1238
+ );
1239
+ expect(dt4.querySelector("input")!.type).toBe("text");
1240
+ });
1241
+
1242
+ it("Slider handles number and dynamic string values, steps, min, max", async () => {
1243
+ const updatePointer = jest.fn();
1244
+
1245
+ // 1. Path binding with steps
1246
+ const s1 = (await import("./catalog/basic/slider.js")).renderSlider(
1247
+ {
1248
+ id: "s1",
1249
+ component: "Slider",
1250
+ label: "Volume",
1251
+ min: 0,
1252
+ max: 100,
1253
+ steps: 5,
1254
+ value: { path: "/count" },
1255
+ weight: 1,
1256
+ },
1257
+ mockSurface,
1258
+ { updateDataModelPointer: updatePointer },
1259
+ );
1260
+ const input1 = s1.querySelector("input")!;
1261
+ expect(input1.min).toBe("0");
1262
+ expect(input1.max).toBe("100");
1263
+ expect(input1.step).toBe("5");
1264
+ expect(input1.value).toBe("25");
1265
+
1266
+ input1.value = "30";
1267
+ input1.dispatchEvent(new Event("input"));
1268
+ expect(updatePointer).toHaveBeenCalledWith("/count", 30);
1269
+
1270
+ // 2. $dataModel binding
1271
+ const s2 = (await import("./catalog/basic/slider.js")).renderSlider(
1272
+ {
1273
+ id: "s2",
1274
+ component: "Slider",
1275
+ value: { $dataModel: "/count" },
1276
+ },
1277
+ mockSurface,
1278
+ { updateDataModelPointer: updatePointer },
1279
+ );
1280
+ const input2 = s2.querySelector("input")!;
1281
+ input2.value = "40";
1282
+ input2.dispatchEvent(new Event("input"));
1283
+ expect(updatePointer).toHaveBeenCalledWith("/count", 40);
1284
+
1285
+ // 3. Static number value
1286
+ const s3 = (await import("./catalog/basic/slider.js")).renderSlider(
1287
+ {
1288
+ id: "s3",
1289
+ component: "Slider",
1290
+ value: 75,
1291
+ },
1292
+ mockSurface,
1293
+ { updateDataModelPointer: updatePointer },
1294
+ );
1295
+ expect(s3.querySelector("input")!.value).toBe("75");
1296
+ });
1297
+
1298
+ it("TextField handles longText, number, obscured, patterns, and inputs", async () => {
1299
+ const updateKey = jest.fn();
1300
+
1301
+ // 1. Long text (textarea)
1302
+ const tf1 = (await import("./catalog/basic/text-field.js")).renderTextField(
1303
+ {
1304
+ id: "tf1",
1305
+ component: "TextField",
1306
+ label: "Notes",
1307
+ variant: "longText",
1308
+ value: { path: "/notes" },
1309
+ },
1310
+ mockSurface,
1311
+ { updateDataModelKey: updateKey },
1312
+ );
1313
+ const textarea = tf1.querySelector("textarea")!;
1314
+ expect(textarea).toBeTruthy();
1315
+ expect(textarea.value).toBe("Some initial notes");
1316
+ textarea.value = "Updated notes";
1317
+ textarea.dispatchEvent(new Event("input"));
1318
+ expect(updateKey).toHaveBeenCalled();
1319
+
1320
+ // 2. Obscured (password) with regex
1321
+ const tf2 = (await import("./catalog/basic/text-field.js")).renderTextField(
1322
+ {
1323
+ id: "tf2",
1324
+ component: "TextField",
1325
+ label: "Password",
1326
+ variant: "obscured",
1327
+ validationRegexp: "^[0-9]{4}$",
1328
+ },
1329
+ mockSurface,
1330
+ { updateDataModelKey: updateKey },
1331
+ );
1332
+ const input2 = tf2.querySelector("input")!;
1333
+ expect(input2.type).toBe("password");
1334
+ expect(input2.pattern).toBe("^[0-9]{4}$");
1335
+
1336
+ // 3. Number variant
1337
+ const tf3 = (await import("./catalog/basic/text-field.js")).renderTextField(
1338
+ {
1339
+ id: "tf3",
1340
+ component: "TextField",
1341
+ label: "Age",
1342
+ variant: "number",
1343
+ value: "42",
1344
+ },
1345
+ mockSurface,
1346
+ { updateDataModelKey: updateKey },
1347
+ );
1348
+ const input3 = tf3.querySelector("input")!;
1349
+ expect(input3.type).toBe("number");
1350
+ expect(input3.value).toBe("42");
1351
+ input3.dispatchEvent(new Event("input"));
1352
+ expect(updateKey).toHaveBeenCalled();
1353
+ });
1354
+
1355
+ it("Button handles variants, checked state, child labels, and actions", async () => {
1356
+ const dispatchAction = jest.fn();
1357
+ const renderComponent = jest.fn((id: string) => {
1358
+ const el = document.createElement("span");
1359
+ el.textContent = `Child ${id}`;
1360
+ return el;
1361
+ });
1362
+
1363
+ // 1. Variant, checked, child component
1364
+ const surfaceWithChild = {
1365
+ ...mockSurface,
1366
+ components: {
1367
+ "btn-child": { id: "btn-child", component: "Text", text: "Submit" },
1368
+ },
1369
+ };
1370
+ const b1 = (await import("./catalog/basic/button.js")).renderButton(
1371
+ {
1372
+ id: "b1",
1373
+ component: "Button",
1374
+ variant: "secondary",
1375
+ checked: true,
1376
+ child: "btn-child",
1377
+ action: { event: { name: "submit-action" } },
1378
+ },
1379
+ surfaceWithChild as any,
1380
+ { renderComponent, dispatchAction },
1381
+ );
1382
+ expect(b1.className).toContain("a2ui__button--secondary");
1383
+ expect(b1.className).toContain("a2ui__button--checked");
1384
+ expect(renderComponent).toHaveBeenCalledWith("btn-child");
1385
+ b1.click();
1386
+ expect(dispatchAction).toHaveBeenCalledWith("submit-action");
1387
+
1388
+ // 2. Fallback text, action with name property
1389
+ const b2 = (await import("./catalog/basic/button.js")).renderButton(
1390
+ {
1391
+ id: "b2",
1392
+ component: "Button",
1393
+ text: "Fallback Text",
1394
+ action: { name: "named-action" } as any,
1395
+ } as any,
1396
+ mockSurface,
1397
+ { renderComponent, dispatchAction },
1398
+ );
1399
+ expect(b2.textContent).toBe("Fallback Text");
1400
+ b2.click();
1401
+ expect(dispatchAction).toHaveBeenCalledWith("named-action");
1402
+ });
1403
+
1404
+ it("Modal handles trigger clicks, overlay backdrop click, and Escape key", async () => {
1405
+ const attachModalOverlay = jest.fn();
1406
+ const renderComponent = jest.fn((id: string) => {
1407
+ const el = document.createElement("div");
1408
+ el.className = id;
1409
+ el.textContent = `Rendered ${id}`;
1410
+ return el;
1411
+ });
1412
+
1413
+ const m = (await import("./catalog/basic/modal.js")).renderModal(
1414
+ {
1415
+ id: "m1",
1416
+ component: "Modal",
1417
+ trigger: "trig-id",
1418
+ content: "cont-id",
1419
+ weight: 1,
1420
+ },
1421
+ mockSurface,
1422
+ { renderComponent, attachModalOverlay },
1423
+ );
1424
+
1425
+ expect(attachModalOverlay).toHaveBeenCalled();
1426
+ const overlay = attachModalOverlay.mock.calls[0][0] as HTMLElement;
1427
+ expect(overlay.style.display).toBe("none");
1428
+
1429
+ // Click trigger to open
1430
+ const trigger = m.querySelector("div.trig-id") || m.children[0];
1431
+ trigger.dispatchEvent(new MouseEvent("click", { bubbles: true }));
1432
+ expect(overlay.style.display).toBe("block");
1433
+ expect(overlay.querySelector(".cont-id")).toBeTruthy();
1434
+
1435
+ // Click backdrop to close
1436
+ overlay.dispatchEvent(new MouseEvent("click", { bubbles: true }));
1437
+ expect(overlay.style.display).toBe("none");
1438
+
1439
+ // Open again and press Escape to close
1440
+ trigger.dispatchEvent(new MouseEvent("click", { bubbles: true }));
1441
+ expect(overlay.style.display).toBe("block");
1442
+ overlay.dispatchEvent(
1443
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
1444
+ );
1445
+ expect(overlay.style.display).toBe("none");
1446
+
1447
+ // Fallback trigger button
1448
+ const mFallback = (await import("./catalog/basic/modal.js")).renderModal(
1449
+ {
1450
+ id: "m2",
1451
+ component: "Modal",
1452
+ trigger: "missing-trig",
1453
+ content: "cont-id",
1454
+ },
1455
+ mockSurface,
1456
+ { renderComponent: () => null, attachModalOverlay: () => {} },
1457
+ );
1458
+ expect(mFallback.querySelector("button")?.textContent).toBe("Open");
1459
+ });
1460
+
1461
+ it("Tabs handles tab click switching and warns on missing tabs", async () => {
1462
+ const renderComponent = jest.fn((id: string) => {
1463
+ const el = document.createElement("div");
1464
+ el.className = `content-${id}`;
1465
+ return el;
1466
+ });
1467
+
1468
+ const tabsComponent = (await import("./catalog/basic/tabs.js")).renderTabs(
1469
+ {
1470
+ id: "t1",
1471
+ component: "Tabs",
1472
+ tabs: [
1473
+ { title: "Tab 1", child: "c1" },
1474
+ { title: "Tab 2", child: "c2" },
1475
+ ],
1476
+ weight: 2,
1477
+ },
1478
+ mockSurface,
1479
+ { renderComponent },
1480
+ );
1481
+
1482
+ const headers =
1483
+ tabsComponent.querySelectorAll<HTMLButtonElement>(".a2ui__tab-header");
1484
+ expect(headers.length).toBe(2);
1485
+ expect(headers[0].classList.contains("active")).toBe(true);
1486
+
1487
+ // Click tab 2
1488
+ headers[1].click();
1489
+ expect(headers[1].classList.contains("active")).toBe(true);
1490
+ expect(headers[0].classList.contains("active")).toBe(false);
1491
+ expect(tabsComponent.querySelector(".content-c2")).toBeTruthy();
1492
+ });
1493
+
1494
+ it("Icon renders string name and path object", async () => {
1495
+ // String name
1496
+ const i1 = (await import("./catalog/basic/icon.js")).renderIcon(
1497
+ {
1498
+ id: "i1",
1499
+ component: "Icon",
1500
+ name: "star",
1501
+ weight: 1,
1502
+ },
1503
+ mockSurface,
1504
+ );
1505
+ expect(i1.dataset.iconName).toBe("star");
1506
+ expect(i1.textContent).toBe("star");
1507
+
1508
+ // Object path
1509
+ const i2 = (await import("./catalog/basic/icon.js")).renderIcon(
1510
+ {
1511
+ id: "i2",
1512
+ component: "Icon",
1513
+ name: { path: "custom-icon.png" } as any,
1514
+ },
1515
+ mockSurface,
1516
+ );
1517
+ const img = i2.querySelector("img");
1518
+ expect(img).toBeTruthy();
1519
+ expect(img?.getAttribute("src")).toBe("custom-icon.png");
1520
+ });
1521
+
1522
+ it("shared helpers: variantToTag and renderChildrenList", async () => {
1523
+ const { variantToTag, renderChildrenList, applyWeight } =
1524
+ await import("./catalog/basic/shared.js");
1525
+
1526
+ // variantToTag
1527
+ expect(variantToTag("h1")).toBe("h1");
1528
+ expect(variantToTag("h2")).toBe("h2");
1529
+ expect(variantToTag("h3")).toBe("h3");
1530
+ expect(variantToTag("h4")).toBe("h4");
1531
+ expect(variantToTag("h5")).toBe("h5");
1532
+ expect(variantToTag("body" as any)).toBe("span");
1533
+
1534
+ // applyWeight
1535
+ const el = document.createElement("div");
1536
+ applyWeight(el, 3);
1537
+ expect(el.style.flexGrow).toBe("3");
1538
+
1539
+ // renderChildrenList with array of ids
1540
+ const appended: HTMLElement[] = [];
1541
+ const mockCtx = {
1542
+ renderComponent: (id: string) => {
1543
+ const d = document.createElement("div");
1544
+ d.id = id;
1545
+ return d;
1546
+ },
1547
+ };
1548
+ renderChildrenList(["item-1", "item-2"], mockSurface, mockCtx, (c) =>
1549
+ appended.push(c),
1550
+ );
1551
+ expect(appended.length).toBe(2);
1552
+
1553
+ // renderChildrenList with array of { id }
1554
+ const appendedObj: HTMLElement[] = [];
1555
+ renderChildrenList([{ id: "item-3" }] as any, mockSurface, mockCtx, (c) =>
1556
+ appendedObj.push(c),
1557
+ );
1558
+ expect(appendedObj.length).toBe(1);
1559
+
1560
+ // renderChildrenList with template
1561
+ const appendedTmpl: HTMLElement[] = [];
1562
+ renderChildrenList(
1563
+ { path: "/fruits", componentId: "fruit-item" },
1564
+ mockSurface,
1565
+ mockCtx,
1566
+ (c) => appendedTmpl.push(c),
1567
+ );
1568
+ expect(appendedTmpl.length).toBe(2);
1569
+ });
1570
+
1571
+ it("handles scoped /@item updates for nested form controls", async () => {
1572
+ const el = document.createElement("shadow-claw-a2ui") as any;
1573
+ el.groupId = "peer:scoped-test";
1574
+ document.body.appendChild(el);
1575
+
1576
+ await (el as any).onTemplateReady;
1577
+
1578
+ el.applyEnvelope({
1579
+ version: "v1.0" as const,
1580
+ type: "createSurface",
1581
+ surfaceId: "scoped-surface",
1582
+ catalogId: A2UI_BASIC_CATALOG_ID,
1583
+ components: [
1584
+ {
1585
+ id: "root",
1586
+ component: "List",
1587
+ children: {
1588
+ path: "/todos",
1589
+ componentId: "todo-item",
1590
+ },
1591
+ },
1592
+ {
1593
+ id: "todo-item",
1594
+ component: "Column",
1595
+ children: ["todo-tf", "todo-cb"],
1596
+ },
1597
+ {
1598
+ id: "todo-tf",
1599
+ component: "TextField",
1600
+ label: "Title",
1601
+ value: { path: "/@item/title" },
1602
+ },
1603
+ {
1604
+ id: "todo-cb",
1605
+ component: "CheckBox",
1606
+ label: "Completed",
1607
+ value: { path: "/@item/done" },
1608
+ },
1609
+ ] as any,
1610
+ dataModel: {
1611
+ todos: [
1612
+ { title: "Buy groceries", done: false },
1613
+ { title: "Walk dog", done: true },
1614
+ ],
1615
+ },
1616
+ });
1617
+
1618
+ const root = el.shadowRoot;
1619
+ const inputs = root.querySelectorAll(
1620
+ "input[type='text'], input:not([type])",
1621
+ );
1622
+ const checkboxes = root.querySelectorAll("input[type='checkbox']");
1623
+
1624
+ expect(inputs.length).toBe(2);
1625
+ expect(checkboxes.length).toBe(2);
1626
+
1627
+ // Edit first textfield
1628
+ const tf0 = inputs[0] as HTMLInputElement;
1629
+ tf0.value = "Buy organic groceries";
1630
+ tf0.dispatchEvent(new Event("input"));
1631
+
1632
+ // Check first checkbox
1633
+ const cb0 = checkboxes[0] as HTMLInputElement;
1634
+ cb0.checked = true;
1635
+ cb0.dispatchEvent(new Event("change"));
1636
+
1637
+ // Trigger action to verify dataModel has updated scoped item values
1638
+ let receivedAction: any = null;
1639
+ el.addEventListener("shadow-claw-a2ui-action", (e: any) => {
1640
+ receivedAction = e.detail;
1641
+ });
1642
+
1643
+ // Apply createSurface with sendDataModel: true
1644
+ el.applyEnvelope({
1645
+ version: "v1.0" as const,
1646
+ type: "createSurface",
1647
+ surfaceId: "send-dm-surface",
1648
+ catalogId: A2UI_BASIC_CATALOG_ID,
1649
+ sendDataModel: true,
1650
+ components: [
1651
+ {
1652
+ id: "root",
1653
+ component: "Button",
1654
+ text: "Submit All",
1655
+ action: { id: "submit-all" },
1656
+ },
1657
+ ] as any,
1658
+ dataModel: { test: "val" },
1659
+ });
1660
+
1661
+ const subBtn = el.shadowRoot.querySelector("button");
1662
+ subBtn?.click();
1663
+ expect(receivedAction).not.toBeNull();
1664
+ expect(receivedAction.action.dataModel).toEqual({ test: "val" });
1665
+
1666
+ document.body.removeChild(el);
1667
+ });
1668
+
1669
+ it("handles resolveMediaUrl and poster format edge cases", async () => {
1670
+ const el = document.createElement("shadow-claw-a2ui") as any;
1671
+ el.groupId = "group:test:colons";
1672
+ document.body.appendChild(el);
1673
+
1674
+ await (el as any).onTemplateReady;
1675
+
1676
+ // Test multiple poster formats
1677
+ el.applyEnvelope({
1678
+ version: "v1.0" as const,
1679
+ type: "createSurface",
1680
+ surfaceId: "poster-surface",
1681
+ catalogId: A2UI_BASIC_CATALOG_ID,
1682
+ components: [
1683
+ {
1684
+ id: "root",
1685
+ component: "Column",
1686
+ children: [
1687
+ "v1",
1688
+ "v2",
1689
+ "v3",
1690
+ "img-external",
1691
+ "img-data",
1692
+ "img-file",
1693
+ "img-already-files",
1694
+ "img-empty",
1695
+ ],
1696
+ },
1697
+ {
1698
+ id: "v1",
1699
+ component: "Video",
1700
+ url: "v1.mp4",
1701
+ posterUrl: "poster.gif",
1702
+ },
1703
+ {
1704
+ id: "v2",
1705
+ component: "Video",
1706
+ url: "v2.mp4",
1707
+ posterUrl: "poster.webp",
1708
+ },
1709
+ {
1710
+ id: "v3",
1711
+ component: "Video",
1712
+ url: "v3.mp4",
1713
+ posterUrl: "poster.jpeg",
1714
+ },
1715
+ {
1716
+ id: "img-external",
1717
+ component: "Image",
1718
+ url: "https://example.com/ext.jpg",
1719
+ },
1720
+ {
1721
+ id: "img-data",
1722
+ component: "Image",
1723
+ url: "data:image/png;base64,123",
1724
+ },
1725
+ { id: "img-file", component: "Image", url: "file:///local/image.png" },
1726
+ {
1727
+ id: "img-already-files",
1728
+ component: "Image",
1729
+ url: "/files/group-abc/image.png",
1730
+ },
1731
+ { id: "img-empty", component: "Image", url: "" },
1732
+ ] as any,
1733
+ dataModel: {},
1734
+ });
1735
+
1736
+ await el.render();
1737
+ await new Promise((r) => setTimeout(r, 50));
1738
+
1739
+ // Test with missing groupId
1740
+ const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
1741
+ el.groupId = "";
1742
+ el.applyEnvelope({
1743
+ version: "v1.0" as const,
1744
+ type: "createSurface",
1745
+ surfaceId: "no-group-surface",
1746
+ catalogId: A2UI_BASIC_CATALOG_ID,
1747
+ components: [{ id: "root", component: "Image", url: "image.png" }] as any,
1748
+ dataModel: {},
1749
+ });
1750
+ expect(warnSpy).toHaveBeenCalled();
1751
+ warnSpy.mockRestore();
1752
+
1753
+ // Test updateDataModel without value (delete/unset)
1754
+ el.applyEnvelope({
1755
+ version: "v1.0" as const,
1756
+ type: "updateDataModel",
1757
+ surfaceId: "no-group-surface",
1758
+ path: "/someKey",
1759
+ });
1760
+
1761
+ // Test actionResponse without responsePath
1762
+ el.applyEnvelope({
1763
+ version: "v1.0" as const,
1764
+ type: "actionResponse",
1765
+ surfaceId: "no-group-surface",
1766
+ value: "ignored",
1767
+ });
1768
+
1769
+ // Test envelopes with mismatched surfaceId
1770
+ el.applyEnvelope({
1771
+ version: "v1.0" as const,
1772
+ type: "updateDataModel",
1773
+ surfaceId: "wrong-id",
1774
+ path: "/key",
1775
+ value: 123,
1776
+ });
1777
+ el.applyEnvelope({
1778
+ version: "v1.0" as const,
1779
+ type: "actionResponse",
1780
+ surfaceId: "wrong-id",
1781
+ responsePath: "/key",
1782
+ value: 123,
1783
+ });
1784
+ el.applyEnvelope({
1785
+ version: "v1.0" as const,
1786
+ type: "callFunction",
1787
+ surfaceId: "wrong-id",
1788
+ callId: "call-x",
1789
+ call: { call: "capitalize", args: {} },
1790
+ });
1791
+
1792
+ document.body.removeChild(el);
1793
+ });
636
1794
  });