@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,1188 +0,0 @@
1
- /**
2
- * Tests for defineToolsDock factory.
3
- *
4
- * These tests exercise the reactive API surface directly. Svelte `setContext`
5
- * is called inside the factory, but the returned `ToolsDockInstance` is the
6
- * subject under test — we don't need a full component tree to verify the
7
- * state machine.
8
- *
9
- * `setContext` only works inside a component init scope, so we invoke the
10
- * factory inside Svelte's `mount` (via a thin host component) so that all
11
- * `$state` runes wire up correctly.
12
- */
13
- import { flushSync, mount, unmount } from 'svelte';
14
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
15
- import HostHarness from './harness.svelte';
16
- const noopTool = {};
17
- function mountDock(options) {
18
- const target = document.createElement('div');
19
- document.body.appendChild(target);
20
- const exposed = {
21
- dock: undefined,
22
- };
23
- const component = mount(HostHarness, {
24
- target,
25
- props: {
26
- options,
27
- onReady: (dock) => {
28
- exposed.dock = dock;
29
- },
30
- },
31
- });
32
- return {
33
- dock: exposed.dock,
34
- teardown: () => {
35
- unmount(component);
36
- target.remove();
37
- },
38
- };
39
- }
40
- describe('defineToolsDock', () => {
41
- let cleanup = [];
42
- beforeEach(() => {
43
- cleanup = [];
44
- if (typeof window !== 'undefined') {
45
- window.localStorage.clear();
46
- }
47
- });
48
- afterEach(() => {
49
- for (const fn of cleanup.splice(0))
50
- fn();
51
- });
52
- function track(value) {
53
- cleanup.push(value.teardown);
54
- return value;
55
- }
56
- it('creates an API with sensible defaults', () => {
57
- const { dock } = track(mountDock({
58
- tools: [
59
- { id: 'chat', label: 'Chat', component: noopTool },
60
- { id: 'jobs', label: 'Jobs', component: noopTool },
61
- ],
62
- }));
63
- expect(dock.isOpen).toBe(false);
64
- expect(dock.activeTool).toBeNull();
65
- expect(dock.layout).toBe('rail');
66
- expect(dock.storageKey).toBeNull();
67
- expect(dock.availabilityError).toBeNull();
68
- expect(dock.availableTools.map((t) => t.id)).toEqual(['chat', 'jobs']);
69
- });
70
- it('open() / close() / toggle() update isOpen and activeTool', () => {
71
- const { dock } = track(mountDock({
72
- tools: [
73
- { id: 'chat', label: 'Chat', component: noopTool },
74
- { id: 'jobs', label: 'Jobs', component: noopTool },
75
- ],
76
- }));
77
- dock.open('chat');
78
- flushSync();
79
- expect(dock.isOpen).toBe(true);
80
- expect(dock.activeTool).toBe('chat');
81
- dock.close();
82
- flushSync();
83
- expect(dock.isOpen).toBe(false);
84
- // activeTool persists so re-opening lands on the same tool.
85
- expect(dock.activeTool).toBe('chat');
86
- dock.toggle('jobs');
87
- flushSync();
88
- expect(dock.isOpen).toBe(true);
89
- expect(dock.activeTool).toBe('jobs');
90
- // toggle on the same id closes.
91
- dock.toggle('jobs');
92
- flushSync();
93
- expect(dock.isOpen).toBe(false);
94
- // toggle without id flips just isOpen.
95
- dock.toggle();
96
- flushSync();
97
- expect(dock.isOpen).toBe(true);
98
- });
99
- it('open() on unknown id is a no-op (does not crash, does not open)', () => {
100
- const { dock } = track(mountDock({
101
- tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
102
- }));
103
- dock.open('does-not-exist');
104
- flushSync();
105
- expect(dock.isOpen).toBe(false);
106
- expect(dock.activeTool).toBeNull();
107
- });
108
- it('respects initialOpen and picks first tool when toggled', () => {
109
- const { dock } = track(mountDock({
110
- tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
111
- initialOpen: true,
112
- }));
113
- expect(dock.isOpen).toBe(true);
114
- expect(dock.activeTool).toBeNull();
115
- dock.toggle();
116
- flushSync();
117
- // First toggle while no tool active selects the first available.
118
- // (Was open → now closed; activeTool now set on the next open.)
119
- expect(dock.isOpen).toBe(false);
120
- dock.toggle();
121
- flushSync();
122
- expect(dock.isOpen).toBe(true);
123
- expect(dock.activeTool).toBe('chat');
124
- });
125
- it('setContext updates context and triggers fetchAvailability', async () => {
126
- const fetchAvailability = vi
127
- .fn()
128
- .mockResolvedValueOnce([{ id: 'chat' }])
129
- .mockResolvedValueOnce([{ id: 'chat' }, { id: 'jobs' }]);
130
- const { dock } = track(mountDock({
131
- tools: [
132
- { id: 'chat', label: 'Chat', component: noopTool },
133
- { id: 'jobs', label: 'Jobs', component: noopTool },
134
- { id: 'hidden', label: 'Hidden', component: noopTool },
135
- ],
136
- fetchAvailability,
137
- }));
138
- // No call yet — fetchAvailability only fires on setContext.
139
- expect(fetchAvailability).not.toHaveBeenCalled();
140
- dock.setContext({ type: 'thing', title: 'A' });
141
- // setContext invokes fetchAvailability synchronously; the promise resolves
142
- // on the next microtask, so flushSync after a few microtasks is required
143
- // for Svelte to settle the resulting state update.
144
- expect(fetchAvailability).toHaveBeenCalledTimes(1);
145
- await new Promise((r) => setTimeout(r, 0));
146
- flushSync();
147
- expect(dock.availableTools.map((t) => t.id)).toEqual(['chat']);
148
- dock.setContext({ type: 'thing', title: 'B' });
149
- expect(fetchAvailability).toHaveBeenCalledTimes(2);
150
- await new Promise((r) => setTimeout(r, 0));
151
- flushSync();
152
- expect(dock.availableTools.map((t) => t.id)).toEqual(['chat', 'jobs']);
153
- });
154
- it('drops stale fetchAvailability results (race-safety)', async () => {
155
- let resolveFirst = null;
156
- let resolveSecond = null;
157
- const fetchAvailability = vi
158
- .fn()
159
- .mockImplementationOnce(() => new Promise((r) => {
160
- resolveFirst = r;
161
- }))
162
- .mockImplementationOnce(() => new Promise((r) => {
163
- resolveSecond = r;
164
- }));
165
- const { dock } = track(mountDock({
166
- tools: [
167
- { id: 'chat', label: 'Chat', component: noopTool },
168
- { id: 'jobs', label: 'Jobs', component: noopTool },
169
- ],
170
- fetchAvailability,
171
- }));
172
- dock.setContext({ type: 'A' });
173
- await Promise.resolve();
174
- dock.setContext({ type: 'B' });
175
- await Promise.resolve();
176
- // Resolve the *stale* (first) call after the newer one. Then resolve the
177
- // newer one. The stale value must not stomp the fresh value.
178
- resolveFirst?.([{ id: 'chat' }]);
179
- resolveSecond?.([{ id: 'jobs' }]);
180
- await Promise.resolve();
181
- await Promise.resolve();
182
- flushSync();
183
- expect(dock.availableTools.map((t) => t.id)).toEqual(['jobs']);
184
- });
185
- it('drops stale fetchAvailability failures after a newer success', async () => {
186
- let rejectFirst = null;
187
- let resolveSecond = null;
188
- const fetchAvailability = vi
189
- .fn()
190
- .mockImplementationOnce(() => new Promise((_resolve, reject) => {
191
- rejectFirst = reject;
192
- }))
193
- .mockImplementationOnce(() => new Promise((resolve) => {
194
- resolveSecond = resolve;
195
- }));
196
- const { dock } = track(mountDock({
197
- tools: [
198
- { id: 'chat', label: 'Chat', component: noopTool },
199
- { id: 'jobs', label: 'Jobs', component: noopTool },
200
- ],
201
- fetchAvailability,
202
- }));
203
- dock.setContext({ type: 'A' });
204
- dock.setContext({ type: 'B' });
205
- resolveSecond?.([{ id: 'jobs' }]);
206
- await Promise.resolve();
207
- await Promise.resolve();
208
- flushSync();
209
- rejectFirst?.(new Error('stale failure'));
210
- await Promise.resolve();
211
- await Promise.resolve();
212
- flushSync();
213
- expect(dock.availableTools.map((tool) => tool.id)).toEqual(['jobs']);
214
- expect(dock.availabilityError).toBeNull();
215
- });
216
- it('emit / on / unsubscribe', () => {
217
- const { dock } = track(mountDock({
218
- tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
219
- }));
220
- const handler = vi.fn();
221
- const off = dock.on('test', handler);
222
- dock.emit('test', { ok: true });
223
- expect(handler).toHaveBeenCalledWith({ ok: true });
224
- off();
225
- dock.emit('test', { ok: false });
226
- expect(handler).toHaveBeenCalledTimes(1);
227
- });
228
- it('emit catches handler errors without dropping later handlers', () => {
229
- const { dock } = track(mountDock({
230
- tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
231
- }));
232
- const throwing = vi.fn(() => {
233
- throw new Error('boom');
234
- });
235
- const after = vi.fn();
236
- dock.on('test', throwing);
237
- dock.on('test', after);
238
- const errSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
239
- expect(() => dock.emit('test', null)).not.toThrow();
240
- expect(throwing).toHaveBeenCalled();
241
- expect(after).toHaveBeenCalled();
242
- errSpy.mockRestore();
243
- });
244
- it('persists state to localStorage when storageKey provided', () => {
245
- const { dock } = track(mountDock({
246
- tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
247
- storageKey: 'tdtest:v1',
248
- }));
249
- dock.open('chat');
250
- flushSync();
251
- const raw = window.localStorage.getItem('tdtest:v1');
252
- expect(raw).not.toBeNull();
253
- const parsed = JSON.parse(raw);
254
- expect(parsed).toEqual({ isOpen: true, activeTool: 'chat' });
255
- });
256
- it('hydrates state from localStorage on hydrate()', () => {
257
- window.localStorage.setItem('tdtest:v2', JSON.stringify({ isOpen: true, activeTool: 'jobs' }));
258
- const { dock } = track(mountDock({
259
- tools: [
260
- { id: 'chat', label: 'Chat', component: noopTool },
261
- { id: 'jobs', label: 'Jobs', component: noopTool },
262
- ],
263
- storageKey: 'tdtest:v2',
264
- }));
265
- dock.hydrate();
266
- flushSync();
267
- expect(dock.isOpen).toBe(true);
268
- expect(dock.activeTool).toBe('jobs');
269
- });
270
- it('hydrate() ignores activeTool that no longer matches a registered tool', () => {
271
- window.localStorage.setItem('tdtest:v3', JSON.stringify({ isOpen: true, activeTool: 'gone' }));
272
- const { dock } = track(mountDock({
273
- tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
274
- storageKey: 'tdtest:v3',
275
- }));
276
- dock.hydrate();
277
- flushSync();
278
- expect(dock.isOpen).toBe(true);
279
- expect(dock.activeTool).toBeNull();
280
- });
281
- it('exposes context on the public ToolsDockApi surface', () => {
282
- const { dock } = track(mountDock({
283
- tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
284
- }));
285
- expect(dock.context).toBeNull();
286
- dock.setContext({ type: 'route', title: 'Home' });
287
- flushSync();
288
- expect(dock.context).toEqual({ type: 'route', title: 'Home' });
289
- dock.setContext(null);
290
- flushSync();
291
- expect(dock.context).toBeNull();
292
- });
293
- it('badge: null in availability clears a registered default (not falls back)', async () => {
294
- const fetchAvailability = vi
295
- .fn()
296
- .mockResolvedValueOnce([{ id: 'chat', badge: null }]);
297
- const { dock } = track(mountDock({
298
- tools: [{ id: 'chat', label: 'Chat', badge: 3, component: noopTool }],
299
- fetchAvailability,
300
- }));
301
- dock.setContext({ type: 'a' });
302
- await new Promise((r) => setTimeout(r, 0));
303
- flushSync();
304
- // `badge: null` is explicit ("clear it"), not a fallback signal.
305
- expect(dock.availableTools[0].badge).toBeNull();
306
- });
307
- it('preserves the registered fallback and exposes synchronous availability errors', async () => {
308
- const failure = new Error('sync boom');
309
- const fetchAvailability = vi.fn(() => {
310
- throw failure;
311
- });
312
- const { dock } = track(mountDock({
313
- tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
314
- fetchAvailability: fetchAvailability,
315
- }));
316
- // The sync throw must be caught by the wrapped promise chain, not
317
- // propagate out of setContext to the caller.
318
- expect(() => dock.setContext({ type: 'a' })).not.toThrow();
319
- // The failure is exposed without replacing the safe initial fallback.
320
- await new Promise((r) => setTimeout(r, 0));
321
- flushSync();
322
- expect(dock.availableTools.map((tool) => tool.id)).toEqual(['chat']);
323
- expect(dock.availabilityError).toBe(failure);
324
- dock.open('chat');
325
- flushSync();
326
- expect(dock.isOpen).toBe(true);
327
- expect(dock.activeTool).toBe('chat');
328
- });
329
- it('exposes a consumable error for a reasonless rejection', async () => {
330
- const fetchAvailability = vi.fn().mockRejectedValueOnce(undefined);
331
- const { dock } = track(mountDock({
332
- tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
333
- fetchAvailability,
334
- }));
335
- dock.setContext({ type: 'route' });
336
- await new Promise((r) => setTimeout(r, 0));
337
- flushSync();
338
- expect(dock.availableTools.map((tool) => tool.id)).toEqual(['chat']);
339
- expect(dock.availabilityError).toBeInstanceOf(Error);
340
- expect(dock.availabilityError.message).toContain('without a reason');
341
- });
342
- it('preserves last-known-good availability and badges after a rejected refresh', async () => {
343
- const failure = new Error('availability offline');
344
- const fetchAvailability = vi
345
- .fn()
346
- .mockResolvedValueOnce([
347
- { id: 'chat', badge: 2 },
348
- { id: 'jobs', badge: 7 },
349
- ])
350
- .mockRejectedValueOnce(failure);
351
- const { dock } = track(mountDock({
352
- tools: [
353
- { id: 'chat', label: 'Chat', component: noopTool },
354
- { id: 'jobs', label: 'Jobs', component: noopTool },
355
- ],
356
- fetchAvailability,
357
- }));
358
- dock.setContext({ type: 'route' });
359
- await new Promise((r) => setTimeout(r, 0));
360
- flushSync();
361
- expect(dock.availableTools).toEqual([
362
- { id: 'chat', label: 'Chat', badge: 2 },
363
- { id: 'jobs', label: 'Jobs', badge: 7 },
364
- ]);
365
- dock.open('jobs');
366
- dock.refreshAvailability();
367
- await new Promise((r) => setTimeout(r, 0));
368
- flushSync();
369
- expect(dock.availabilityError).toBe(failure);
370
- expect(dock.availableTools).toEqual([
371
- { id: 'chat', label: 'Chat', badge: 2 },
372
- { id: 'jobs', label: 'Jobs', badge: 7 },
373
- ]);
374
- expect(dock.isOpen).toBe(true);
375
- expect(dock.activeTool).toBe('jobs');
376
- dock.toggle('jobs');
377
- flushSync();
378
- expect(dock.isOpen).toBe(false);
379
- dock.toggle('jobs');
380
- flushSync();
381
- expect(dock.isOpen).toBe(true);
382
- expect(dock.activeTool).toBe('jobs');
383
- });
384
- it('preserves last-known-good availability after malformed entries', async () => {
385
- const fetchAvailability = vi
386
- .fn()
387
- .mockResolvedValueOnce([{ id: 'jobs', badge: 4 }])
388
- .mockResolvedValueOnce([
389
- { label: 'Missing id' },
390
- ]);
391
- const { dock } = track(mountDock({
392
- tools: [
393
- { id: 'chat', label: 'Chat', component: noopTool },
394
- { id: 'jobs', label: 'Jobs', component: noopTool },
395
- ],
396
- fetchAvailability,
397
- }));
398
- dock.setContext({ type: 'route' });
399
- await new Promise((r) => setTimeout(r, 0));
400
- flushSync();
401
- expect(dock.availableTools).toEqual([
402
- { id: 'jobs', label: 'Jobs', badge: 4 },
403
- ]);
404
- dock.refreshAvailability();
405
- await new Promise((r) => setTimeout(r, 0));
406
- flushSync();
407
- expect(dock.availableTools).toEqual([
408
- { id: 'jobs', label: 'Jobs', badge: 4 },
409
- ]);
410
- expect(dock.availabilityError).toBeInstanceOf(TypeError);
411
- });
412
- it('resets contextual availability before a new-context failure', async () => {
413
- const failure = new Error('tenant B unavailable');
414
- const fetchAvailability = vi
415
- .fn()
416
- .mockResolvedValueOnce([{ id: 'jobs', label: 'Tenant A jobs', badge: 9 }])
417
- .mockRejectedValueOnce(failure);
418
- const { dock } = track(mountDock({
419
- tools: [
420
- { id: 'chat', label: 'Chat', component: noopTool },
421
- { id: 'jobs', label: 'Jobs', badge: 1, component: noopTool },
422
- ],
423
- fetchAvailability,
424
- }));
425
- dock.setContext({ type: 'tenant-a' });
426
- await new Promise((r) => setTimeout(r, 0));
427
- flushSync();
428
- expect(dock.availableTools).toEqual([
429
- { id: 'jobs', label: 'Tenant A jobs', badge: 9 },
430
- ]);
431
- dock.setContext({ type: 'tenant-b' });
432
- await new Promise((r) => setTimeout(r, 0));
433
- flushSync();
434
- expect(dock.availabilityError).toBe(failure);
435
- expect(dock.availableTools).toEqual([
436
- { id: 'chat', label: 'Chat', badge: null },
437
- { id: 'jobs', label: 'Jobs', badge: 1 },
438
- ]);
439
- });
440
- it('recovers after a later successful availability refresh', async () => {
441
- const failure = new Error('temporary failure');
442
- const fetchAvailability = vi
443
- .fn()
444
- .mockRejectedValueOnce(failure)
445
- .mockResolvedValueOnce([{ id: 'jobs', badge: 3 }]);
446
- const { dock } = track(mountDock({
447
- tools: [
448
- { id: 'chat', label: 'Chat', component: noopTool },
449
- { id: 'jobs', label: 'Jobs', component: noopTool },
450
- ],
451
- fetchAvailability,
452
- }));
453
- dock.setContext({ type: 'route' });
454
- await new Promise((r) => setTimeout(r, 0));
455
- flushSync();
456
- expect(dock.availabilityError).toBe(failure);
457
- expect(dock.availableTools.map((tool) => tool.id)).toEqual([
458
- 'chat',
459
- 'jobs',
460
- ]);
461
- dock.refreshAvailability();
462
- await new Promise((r) => setTimeout(r, 0));
463
- flushSync();
464
- expect(dock.availabilityError).toBeNull();
465
- expect(dock.availableTools).toEqual([
466
- { id: 'jobs', label: 'Jobs', badge: 3 },
467
- ]);
468
- });
469
- describe('refreshAvailability()', () => {
470
- it('re-runs fetchAvailability with the current context', async () => {
471
- let availability = [{ id: 'chat' }];
472
- const fetchAvailability = vi.fn(async () => availability);
473
- const { dock } = track(mountDock({
474
- tools: [
475
- { id: 'chat', label: 'Chat', component: noopTool },
476
- { id: 'jobs', label: 'Jobs', component: noopTool },
477
- ],
478
- fetchAvailability,
479
- }));
480
- const ctx = { type: 'thing', title: 'A' };
481
- dock.setContext(ctx);
482
- await new Promise((r) => setTimeout(r, 0));
483
- flushSync();
484
- expect(fetchAvailability).toHaveBeenCalledTimes(1);
485
- expect(dock.availableTools.map((t) => t.id)).toEqual(['chat']);
486
- // Side-channel signal: availability changed without a context change.
487
- // setContext(ctx) would short-circuit; refreshAvailability() forces it.
488
- availability = [{ id: 'chat' }, { id: 'jobs' }];
489
- dock.refreshAvailability();
490
- expect(fetchAvailability).toHaveBeenCalledTimes(2);
491
- // Called with the same (unchanged) context.
492
- expect(fetchAvailability).toHaveBeenLastCalledWith(ctx);
493
- await new Promise((r) => setTimeout(r, 0));
494
- flushSync();
495
- // Second fetch's result is reflected in availableTools.
496
- expect(dock.availableTools.map((t) => t.id)).toEqual(['chat', 'jobs']);
497
- });
498
- it('resets to all registered tools when no fetchAvailability is configured', () => {
499
- const { dock } = track(mountDock({
500
- tools: [
501
- { id: 'chat', label: 'Chat', component: noopTool },
502
- { id: 'jobs', label: 'Jobs', component: noopTool },
503
- ],
504
- }));
505
- expect(dock.availableTools.map((t) => t.id)).toEqual(['chat', 'jobs']);
506
- // No-op semantically (all tools were already available), but the
507
- // method should not crash without a fetchAvailability callback.
508
- expect(() => dock.refreshAvailability()).not.toThrow();
509
- flushSync();
510
- expect(dock.availableTools.map((t) => t.id)).toEqual(['chat', 'jobs']);
511
- });
512
- it("emits 'dock:change' when refresh changes only badge values", async () => {
513
- // Regression for the badge-only refresh case: a side-channel signal
514
- // fires (e.g. a Jobs-count websocket message) and the consumer calls
515
- // `refreshAvailability()` to repaint badges. The tool ids haven't
516
- // changed and the active tool stays put, but `availableTools` now
517
- // carries a different badge — consumers mirroring this into a topbar
518
- // count must see a `'dock:change'` event to repaint without polling.
519
- let availability = [
520
- { id: 'chat', badge: 0 },
521
- { id: 'jobs', badge: 0 },
522
- ];
523
- const fetchAvailability = vi.fn(async () => availability);
524
- const { dock } = track(mountDock({
525
- tools: [
526
- { id: 'chat', label: 'Chat', component: noopTool },
527
- { id: 'jobs', label: 'Jobs', component: noopTool },
528
- ],
529
- fetchAvailability,
530
- }));
531
- dock.setContext({ type: 'thing' });
532
- await new Promise((r) => setTimeout(r, 0));
533
- flushSync();
534
- const handler = vi.fn();
535
- dock.on('dock:change', handler);
536
- // Side-channel update: same tool ids, different badge values.
537
- availability = [
538
- { id: 'chat', badge: 0 },
539
- { id: 'jobs', badge: 7 },
540
- ];
541
- dock.refreshAvailability();
542
- await new Promise((r) => setTimeout(r, 0));
543
- flushSync();
544
- // Exactly one emit reflecting the new badge.
545
- expect(handler).toHaveBeenCalledTimes(1);
546
- expect(handler).toHaveBeenLastCalledWith({
547
- isOpen: false,
548
- activeTool: null,
549
- context: { type: 'thing' },
550
- });
551
- const jobsAfter = dock.availableTools.find((t) => t.id === 'jobs');
552
- expect(jobsAfter?.badge).toBe(7);
553
- // Calling refreshAvailability again with byte-identical results must
554
- // NOT emit a second time. The discriminating shape check keeps no-op
555
- // refreshes silent so consumer mirrors don't see spurious updates.
556
- dock.refreshAvailability();
557
- await new Promise((r) => setTimeout(r, 0));
558
- flushSync();
559
- expect(handler).toHaveBeenCalledTimes(1);
560
- });
561
- it("emits 'dock:change' when refresh clears the active tool", async () => {
562
- // Direct coverage for the active-tool-clear branch of
563
- // `applyAvailability` reached via `refreshAvailability()` (rather
564
- // than via a `setContext()` → refresh chain).
565
- let availability = [{ id: 'chat' }, { id: 'jobs' }];
566
- const fetchAvailability = vi.fn(async () => availability);
567
- const { dock } = track(mountDock({
568
- tools: [
569
- { id: 'chat', label: 'Chat', component: noopTool },
570
- { id: 'jobs', label: 'Jobs', component: noopTool },
571
- ],
572
- fetchAvailability,
573
- }));
574
- dock.setContext({ type: 'thing' });
575
- await new Promise((r) => setTimeout(r, 0));
576
- flushSync();
577
- dock.open('jobs');
578
- flushSync();
579
- expect(dock.activeTool).toBe('jobs');
580
- const handler = vi.fn();
581
- dock.on('dock:change', handler);
582
- // Side-channel availability shrink — 'jobs' is no longer available.
583
- availability = [{ id: 'chat' }];
584
- dock.refreshAvailability();
585
- await new Promise((r) => setTimeout(r, 0));
586
- flushSync();
587
- expect(dock.activeTool).toBeNull();
588
- expect(handler).toHaveBeenCalledTimes(1);
589
- expect(handler).toHaveBeenLastCalledWith({
590
- isOpen: true,
591
- activeTool: null,
592
- context: { type: 'thing' },
593
- });
594
- });
595
- it('drops stale results from a prior refreshAvailability call (race-safety)', async () => {
596
- let resolveFirst = null;
597
- let resolveSecond = null;
598
- const fetchAvailability = vi
599
- .fn()
600
- .mockImplementationOnce(() => new Promise((r) => {
601
- resolveFirst = r;
602
- }))
603
- .mockImplementationOnce(() => new Promise((r) => {
604
- resolveSecond = r;
605
- }));
606
- const { dock } = track(mountDock({
607
- tools: [
608
- { id: 'chat', label: 'Chat', component: noopTool },
609
- { id: 'jobs', label: 'Jobs', component: noopTool },
610
- ],
611
- fetchAvailability,
612
- }));
613
- // First call via refreshAvailability (no prior setContext).
614
- dock.refreshAvailability();
615
- await Promise.resolve();
616
- // Second call before the first resolves.
617
- dock.refreshAvailability();
618
- await Promise.resolve();
619
- // Resolve stale first AFTER fresh second was requested.
620
- resolveFirst?.([{ id: 'chat' }]);
621
- resolveSecond?.([{ id: 'jobs' }]);
622
- await Promise.resolve();
623
- await Promise.resolve();
624
- flushSync();
625
- // Only the most recent result applies.
626
- expect(dock.availableTools.map((t) => t.id)).toEqual(['jobs']);
627
- });
628
- });
629
- it('clears activeTool when availability removes the active tool', async () => {
630
- let availability = [{ id: 'chat' }, { id: 'jobs' }];
631
- const fetchAvailability = vi.fn(async () => availability);
632
- const { dock } = track(mountDock({
633
- tools: [
634
- { id: 'chat', label: 'Chat', component: noopTool },
635
- { id: 'jobs', label: 'Jobs', component: noopTool },
636
- ],
637
- fetchAvailability,
638
- }));
639
- dock.setContext({ type: 'a' });
640
- await Promise.resolve();
641
- await Promise.resolve();
642
- flushSync();
643
- dock.open('jobs');
644
- flushSync();
645
- expect(dock.activeTool).toBe('jobs');
646
- availability = [{ id: 'chat' }];
647
- dock.setContext({ type: 'b' });
648
- await Promise.resolve();
649
- await Promise.resolve();
650
- flushSync();
651
- expect(dock.availableTools.map((t) => t.id)).toEqual(['chat']);
652
- expect(dock.activeTool).toBeNull();
653
- });
654
- describe("'dock:change' event", () => {
655
- it('does not fire during factory construction', () => {
656
- // Subscribing in a separate tick would miss any event fired during the
657
- // factory body. To assert "no construction-time emit" we count via a
658
- // listener added *before* the factory runs — but the factory's
659
- // listener bus is internal, so instead we rely on the fact that
660
- // `'dock:change'` is gated by the `ready` flag and verify
661
- // post-construction state is clean (no recursive errors etc.) here.
662
- const { dock } = track(mountDock({
663
- tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
664
- initialOpen: true,
665
- }));
666
- // Sanity: initial state reflects options without any emit having fired.
667
- expect(dock.isOpen).toBe(true);
668
- expect(dock.activeTool).toBeNull();
669
- });
670
- it('fires on open() with the post-update state', () => {
671
- const { dock } = track(mountDock({
672
- tools: [
673
- { id: 'chat', label: 'Chat', component: noopTool },
674
- { id: 'jobs', label: 'Jobs', component: noopTool },
675
- ],
676
- }));
677
- const handler = vi.fn();
678
- dock.on('dock:change', handler);
679
- dock.open('chat');
680
- flushSync();
681
- expect(handler).toHaveBeenCalledTimes(1);
682
- expect(handler).toHaveBeenLastCalledWith({
683
- isOpen: true,
684
- activeTool: 'chat',
685
- context: null,
686
- });
687
- });
688
- it('fires on close() with the post-update state', () => {
689
- const { dock } = track(mountDock({
690
- tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
691
- }));
692
- dock.open('chat');
693
- flushSync();
694
- const handler = vi.fn();
695
- dock.on('dock:change', handler);
696
- dock.close();
697
- flushSync();
698
- expect(handler).toHaveBeenCalledTimes(1);
699
- expect(handler).toHaveBeenLastCalledWith({
700
- // close() leaves activeTool intact so re-opens land on the same tool.
701
- isOpen: false,
702
- activeTool: 'chat',
703
- context: null,
704
- });
705
- });
706
- it('fires on toggle() with the post-update state', () => {
707
- const { dock } = track(mountDock({
708
- tools: [
709
- { id: 'chat', label: 'Chat', component: noopTool },
710
- { id: 'jobs', label: 'Jobs', component: noopTool },
711
- ],
712
- }));
713
- const handler = vi.fn();
714
- dock.on('dock:change', handler);
715
- dock.toggle('chat');
716
- flushSync();
717
- expect(handler).toHaveBeenCalledTimes(1);
718
- expect(handler).toHaveBeenLastCalledWith({
719
- isOpen: true,
720
- activeTool: 'chat',
721
- context: null,
722
- });
723
- dock.toggle('chat');
724
- flushSync();
725
- expect(handler).toHaveBeenCalledTimes(2);
726
- expect(handler).toHaveBeenLastCalledWith({
727
- isOpen: false,
728
- activeTool: 'chat',
729
- context: null,
730
- });
731
- dock.toggle();
732
- flushSync();
733
- expect(handler).toHaveBeenCalledTimes(3);
734
- expect(handler).toHaveBeenLastCalledWith({
735
- isOpen: true,
736
- activeTool: 'chat',
737
- context: null,
738
- });
739
- });
740
- it('fires on setContext() with the new context', () => {
741
- const { dock } = track(mountDock({
742
- tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
743
- }));
744
- const handler = vi.fn();
745
- dock.on('dock:change', handler);
746
- dock.setContext({ type: 'route', title: 'Home' });
747
- flushSync();
748
- expect(handler).toHaveBeenCalledTimes(1);
749
- expect(handler).toHaveBeenLastCalledWith({
750
- isOpen: false,
751
- activeTool: null,
752
- context: { type: 'route', title: 'Home' },
753
- });
754
- });
755
- it('fires once with cleared state when availability removes the active tool', async () => {
756
- let availability = [{ id: 'chat' }, { id: 'jobs' }];
757
- const fetchAvailability = vi.fn(async () => availability);
758
- const { dock } = track(mountDock({
759
- tools: [
760
- { id: 'chat', label: 'Chat', component: noopTool },
761
- { id: 'jobs', label: 'Jobs', component: noopTool },
762
- ],
763
- fetchAvailability,
764
- }));
765
- dock.setContext({ type: 'a' });
766
- await Promise.resolve();
767
- await Promise.resolve();
768
- flushSync();
769
- dock.open('jobs');
770
- flushSync();
771
- expect(dock.activeTool).toBe('jobs');
772
- const handler = vi.fn();
773
- dock.on('dock:change', handler);
774
- availability = [{ id: 'chat' }];
775
- dock.setContext({ type: 'b' });
776
- // setContext emits synchronously …
777
- expect(handler).toHaveBeenCalledTimes(1);
778
- // … then the async availability refresh resolves, clears the active
779
- // tool, and emits a second 'dock:change' with the cleared state.
780
- await Promise.resolve();
781
- await Promise.resolve();
782
- flushSync();
783
- expect(handler).toHaveBeenCalledTimes(2);
784
- expect(handler).toHaveBeenLastCalledWith({
785
- isOpen: true,
786
- activeTool: null,
787
- context: { type: 'b' },
788
- });
789
- });
790
- it('stops calling a handler after its unsubscribe function runs', () => {
791
- const { dock } = track(mountDock({
792
- tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
793
- }));
794
- const handler = vi.fn();
795
- const off = dock.on('dock:change', handler);
796
- dock.open('chat');
797
- flushSync();
798
- expect(handler).toHaveBeenCalledTimes(1);
799
- off();
800
- dock.close();
801
- flushSync();
802
- expect(handler).toHaveBeenCalledTimes(1);
803
- });
804
- it('payload type is inferred from ToolsDockEvents', () => {
805
- const { dock } = track(mountDock({
806
- tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
807
- }));
808
- // Compile-time check: handler arg should be the `dock:change` payload
809
- // shape without an explicit generic.
810
- dock.on('dock:change', (e) => {
811
- const _isOpen = e.isOpen;
812
- const _activeTool = e.activeTool;
813
- void _isOpen;
814
- void _activeTool;
815
- });
816
- // Runtime sanity check that the test compiles & wires.
817
- dock.open('chat');
818
- flushSync();
819
- expect(dock.isOpen).toBe(true);
820
- });
821
- it('non-"dock:*" event names use the stringly-typed overload (e.g. "change" is not reserved)', () => {
822
- // After namespacing built-ins under `'dock:*'`, the literal `'change'`
823
- // is no longer a key of ToolsDockEvents, so consumers may use it
824
- // freely with an explicit payload generic via the stringly-typed
825
- // overload.
826
- const { dock } = track(mountDock({
827
- tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
828
- }));
829
- const customHandler = vi.fn();
830
- const off = dock.on('change', customHandler);
831
- dock.emit('change', { selected: 'row-1' });
832
- expect(customHandler).toHaveBeenCalledWith({ selected: 'row-1' });
833
- // Also confirm a namespaced consumer event works.
834
- const appHandler = vi.fn();
835
- dock.on('app:custom', appHandler);
836
- dock.emit('app:custom', { id: 42 });
837
- expect(appHandler).toHaveBeenCalledWith({ id: 42 });
838
- // Built-in 'dock:change' must NOT trigger the custom 'change' listener.
839
- dock.open('chat');
840
- flushSync();
841
- expect(customHandler).toHaveBeenCalledTimes(1);
842
- off();
843
- });
844
- // ─────────────────────────────────────────────────────────────
845
- // Regression tests: no-op state changes must NOT fire 'dock:change'.
846
- //
847
- // Before this guard, idempotent calls like `dock.open(activeId)` when
848
- // already open emitted spurious events. Combined with effects that
849
- // call setContext on every prop change, that produced redundant work
850
- // (availability refetch) and could amplify into noisy loops when
851
- // consumers wired the 'dock:change' event back into upstream state.
852
- // ─────────────────────────────────────────────────────────────
853
- it('does NOT fire on open() when already at that state', () => {
854
- const { dock } = track(mountDock({
855
- tools: [
856
- { id: 'chat', label: 'Chat', component: noopTool },
857
- { id: 'jobs', label: 'Jobs', component: noopTool },
858
- ],
859
- }));
860
- dock.open('chat');
861
- flushSync();
862
- const handler = vi.fn();
863
- dock.on('dock:change', handler);
864
- // Same id, already open — no observable state change.
865
- dock.open('chat');
866
- flushSync();
867
- expect(handler).not.toHaveBeenCalled();
868
- // open() with no id, already open with an active tool — still a no-op.
869
- dock.open();
870
- flushSync();
871
- expect(handler).not.toHaveBeenCalled();
872
- });
873
- it('does NOT fire on close() when already closed', () => {
874
- const { dock } = track(mountDock({
875
- tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
876
- }));
877
- const handler = vi.fn();
878
- dock.on('dock:change', handler);
879
- // Initial state is closed; calling close again must not emit.
880
- dock.close();
881
- flushSync();
882
- expect(handler).not.toHaveBeenCalled();
883
- });
884
- it('does NOT fire on setContext() with the same reference', async () => {
885
- const fetchAvailability = vi.fn().mockResolvedValue([{ id: 'chat' }]);
886
- const { dock } = track(mountDock({
887
- tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
888
- fetchAvailability,
889
- }));
890
- const ctx = { type: 'route', title: 'Home' };
891
- dock.setContext(ctx);
892
- // Initial emit + initial fetchAvailability call.
893
- await new Promise((r) => setTimeout(r, 0));
894
- flushSync();
895
- expect(fetchAvailability).toHaveBeenCalledTimes(1);
896
- const handler = vi.fn();
897
- dock.on('dock:change', handler);
898
- // Same reference — must short-circuit (no emit, no refetch).
899
- dock.setContext(ctx);
900
- flushSync();
901
- await new Promise((r) => setTimeout(r, 0));
902
- flushSync();
903
- expect(handler).not.toHaveBeenCalled();
904
- expect(fetchAvailability).toHaveBeenCalledTimes(1);
905
- });
906
- it('does NOT fire on toggle(id) that resolves to the same state', () => {
907
- // toggle(sameId) when open+active flips to closed; toggle again
908
- // flips back to open+active. Each transition is real, so each emits.
909
- // The "no observable change" branch for toggle is hard to trigger
910
- // through the API surface alone (toggle always flips at minimum
911
- // `isOpen`), so the assertion here is the symmetric one: the emit
912
- // counts match the number of real state transitions.
913
- const { dock } = track(mountDock({
914
- tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
915
- }));
916
- const handler = vi.fn();
917
- dock.on('dock:change', handler);
918
- dock.toggle('chat'); // open+active
919
- flushSync();
920
- dock.toggle('chat'); // close
921
- flushSync();
922
- expect(handler).toHaveBeenCalledTimes(2);
923
- });
924
- it('does not retrigger context-forwarding $effect when state mutates', async () => {
925
- // Regression guard for the reactive-read leak in emitChange. Without
926
- // `untrack` around the $state reads, an $effect calling
927
- // dock.setContext would also depend on isOpen/activeTool/context
928
- // (read inside the emitChange invoked by setContext). A subsequent
929
- // dock.open() would mutate those, re-run the effect, call setContext
930
- // again, etc. Here we verify open() does NOT cause the forwarding
931
- // effect's tracked count to grow.
932
- const fetchAvailability = vi.fn().mockResolvedValue([{ id: 'chat' }]);
933
- const { dock } = track(mountDock({
934
- tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
935
- fetchAvailability,
936
- }));
937
- // Mount a child component whose $effect mirrors a `currentCtx` $state
938
- // into dock.setContext — same pattern <ToolsDock>'s context prop uses.
939
- const target = document.createElement('div');
940
- document.body.appendChild(target);
941
- cleanup.push(() => target.remove());
942
- // We use the harness pattern: a tiny inline component would require
943
- // its own file. Instead, count how many times fetchAvailability runs
944
- // after the initial setContext — open() / close() must not cause it
945
- // to re-run via a re-triggered forwarding effect.
946
- dock.setContext({ type: 'a' });
947
- await new Promise((r) => setTimeout(r, 0));
948
- flushSync();
949
- const initialFetchCount = fetchAvailability.mock.calls.length;
950
- const handler = vi.fn();
951
- dock.on('dock:change', handler);
952
- dock.open('chat');
953
- flushSync();
954
- dock.close();
955
- flushSync();
956
- // Two real state transitions → two emits, but the context-forwarding
957
- // chain stays quiet: no extra fetchAvailability calls beyond the
958
- // initial setContext.
959
- expect(handler).toHaveBeenCalledTimes(2);
960
- expect(fetchAvailability.mock.calls.length).toBe(initialFetchCount);
961
- });
962
- });
963
- // ─────────────────────────────────────────────────────────────
964
- // Granular events: 'dock:state-changed' and 'dock:context-changed'
965
- // split the legacy 'dock:change' so consumers can subscribe to just
966
- // the slice they care about. The legacy event still fires (back-compat).
967
- // ─────────────────────────────────────────────────────────────
968
- describe("'dock:state-changed' event", () => {
969
- it('fires on open() / close() / toggle() with state-only payload', () => {
970
- const { dock } = track(mountDock({
971
- tools: [
972
- { id: 'chat', label: 'Chat', component: noopTool },
973
- { id: 'jobs', label: 'Jobs', component: noopTool },
974
- ],
975
- }));
976
- const handler = vi.fn();
977
- dock.on('dock:state-changed', handler);
978
- dock.open('chat');
979
- flushSync();
980
- expect(handler).toHaveBeenCalledTimes(1);
981
- expect(handler).toHaveBeenLastCalledWith({
982
- isOpen: true,
983
- activeTool: 'chat',
984
- });
985
- dock.close();
986
- flushSync();
987
- expect(handler).toHaveBeenCalledTimes(2);
988
- expect(handler).toHaveBeenLastCalledWith({
989
- isOpen: false,
990
- activeTool: 'chat',
991
- });
992
- dock.toggle('jobs');
993
- flushSync();
994
- expect(handler).toHaveBeenCalledTimes(3);
995
- expect(handler).toHaveBeenLastCalledWith({
996
- isOpen: true,
997
- activeTool: 'jobs',
998
- });
999
- });
1000
- it('does NOT fire on setContext() (context-only mutation)', () => {
1001
- const { dock } = track(mountDock({
1002
- tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
1003
- }));
1004
- const handler = vi.fn();
1005
- dock.on('dock:state-changed', handler);
1006
- dock.setContext({ type: 'route', title: 'Home' });
1007
- flushSync();
1008
- // setContext alone does not change isOpen/activeTool — must stay silent.
1009
- expect(handler).not.toHaveBeenCalled();
1010
- });
1011
- it('fires when availability clears the active tool', async () => {
1012
- let availability = [{ id: 'chat' }, { id: 'jobs' }];
1013
- const fetchAvailability = vi.fn(async () => availability);
1014
- const { dock } = track(mountDock({
1015
- tools: [
1016
- { id: 'chat', label: 'Chat', component: noopTool },
1017
- { id: 'jobs', label: 'Jobs', component: noopTool },
1018
- ],
1019
- fetchAvailability,
1020
- }));
1021
- dock.setContext({ type: 'a' });
1022
- await new Promise((r) => setTimeout(r, 0));
1023
- flushSync();
1024
- dock.open('jobs');
1025
- flushSync();
1026
- const handler = vi.fn();
1027
- dock.on('dock:state-changed', handler);
1028
- // Availability shrink → 'jobs' is no longer available, activeTool clears.
1029
- availability = [{ id: 'chat' }];
1030
- dock.refreshAvailability();
1031
- await new Promise((r) => setTimeout(r, 0));
1032
- flushSync();
1033
- expect(handler).toHaveBeenCalledTimes(1);
1034
- expect(handler).toHaveBeenLastCalledWith({
1035
- isOpen: true,
1036
- activeTool: null,
1037
- });
1038
- });
1039
- it('does NOT fire on no-op open() / close() / toggle()', () => {
1040
- const { dock } = track(mountDock({
1041
- tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
1042
- }));
1043
- dock.open('chat');
1044
- flushSync();
1045
- const handler = vi.fn();
1046
- dock.on('dock:state-changed', handler);
1047
- dock.open('chat'); // already open + active
1048
- flushSync();
1049
- dock.close();
1050
- flushSync();
1051
- dock.close(); // already closed
1052
- flushSync();
1053
- // One observable transition (open → close), so exactly one emit.
1054
- expect(handler).toHaveBeenCalledTimes(1);
1055
- });
1056
- it('does NOT fire on badge-only availability refresh', async () => {
1057
- // Pure badge churn: no state change, no context change. The legacy
1058
- // 'dock:change' event still fires (for back-compat consumers
1059
- // mirroring availableTools), but 'dock:state-changed' stays silent.
1060
- let availability = [
1061
- { id: 'chat', badge: 0 },
1062
- ];
1063
- const fetchAvailability = vi.fn(async () => availability);
1064
- const { dock } = track(mountDock({
1065
- tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
1066
- fetchAvailability,
1067
- }));
1068
- dock.setContext({ type: 'a' });
1069
- await new Promise((r) => setTimeout(r, 0));
1070
- flushSync();
1071
- const stateHandler = vi.fn();
1072
- dock.on('dock:state-changed', stateHandler);
1073
- availability = [{ id: 'chat', badge: 7 }];
1074
- dock.refreshAvailability();
1075
- await new Promise((r) => setTimeout(r, 0));
1076
- flushSync();
1077
- expect(stateHandler).not.toHaveBeenCalled();
1078
- });
1079
- });
1080
- describe("'dock:context-changed' event", () => {
1081
- it('fires on setContext() with a different reference', () => {
1082
- const { dock } = track(mountDock({
1083
- tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
1084
- }));
1085
- const handler = vi.fn();
1086
- dock.on('dock:context-changed', handler);
1087
- const ctx = { type: 'route', title: 'Home' };
1088
- dock.setContext(ctx);
1089
- flushSync();
1090
- expect(handler).toHaveBeenCalledTimes(1);
1091
- expect(handler).toHaveBeenLastCalledWith({ context: ctx });
1092
- dock.setContext(null);
1093
- flushSync();
1094
- expect(handler).toHaveBeenCalledTimes(2);
1095
- expect(handler).toHaveBeenLastCalledWith({ context: null });
1096
- });
1097
- it('does NOT fire on open() / close() / toggle() (state-only mutations)', () => {
1098
- const { dock } = track(mountDock({
1099
- tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
1100
- }));
1101
- const handler = vi.fn();
1102
- dock.on('dock:context-changed', handler);
1103
- dock.open('chat');
1104
- flushSync();
1105
- dock.close();
1106
- flushSync();
1107
- dock.toggle('chat');
1108
- flushSync();
1109
- expect(handler).not.toHaveBeenCalled();
1110
- });
1111
- it('does NOT fire on setContext() with the same reference', () => {
1112
- const { dock } = track(mountDock({
1113
- tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
1114
- }));
1115
- const ctx = { type: 'route' };
1116
- dock.setContext(ctx);
1117
- flushSync();
1118
- const handler = vi.fn();
1119
- dock.on('dock:context-changed', handler);
1120
- dock.setContext(ctx); // same reference — no-op-guarded
1121
- flushSync();
1122
- expect(handler).not.toHaveBeenCalled();
1123
- });
1124
- it('does NOT fire on a badge-only availability refresh', async () => {
1125
- let availability = [
1126
- { id: 'chat', badge: 0 },
1127
- ];
1128
- const fetchAvailability = vi.fn(async () => availability);
1129
- const { dock } = track(mountDock({
1130
- tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
1131
- fetchAvailability,
1132
- }));
1133
- dock.setContext({ type: 'a' });
1134
- await new Promise((r) => setTimeout(r, 0));
1135
- flushSync();
1136
- const handler = vi.fn();
1137
- dock.on('dock:context-changed', handler);
1138
- availability = [{ id: 'chat', badge: 7 }];
1139
- dock.refreshAvailability();
1140
- await new Promise((r) => setTimeout(r, 0));
1141
- flushSync();
1142
- // Context didn't change — badge refresh stays silent on this event.
1143
- expect(handler).not.toHaveBeenCalled();
1144
- });
1145
- });
1146
- describe("'dock:change' event continues to fire (back-compat)", () => {
1147
- it('fires on state, context, and badge-only availability transitions', async () => {
1148
- // Use a fetchAvailability that yields the same shape the dock seeded
1149
- // from the registered tools (no badge), so the post-setContext
1150
- // availability application is a no-op and we can count emits cleanly.
1151
- const fetchAvailability = vi
1152
- .fn()
1153
- .mockResolvedValue([{ id: 'chat' }]);
1154
- const { dock } = track(mountDock({
1155
- tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
1156
- fetchAvailability,
1157
- }));
1158
- const legacyHandler = vi.fn();
1159
- dock.on('dock:change', legacyHandler);
1160
- // 1. setContext → 1 synchronous emit. The async availability fetch
1161
- // resolves to the same shape the registered tools already had
1162
- // (id='chat', no badge), so applyAvailability sees no change and
1163
- // does NOT emit a second time.
1164
- dock.setContext({ type: 'a' });
1165
- flushSync();
1166
- await new Promise((r) => setTimeout(r, 0));
1167
- flushSync();
1168
- expect(legacyHandler).toHaveBeenCalledTimes(1);
1169
- // 2. open() → 1 more emit (state change).
1170
- dock.open('chat');
1171
- flushSync();
1172
- expect(legacyHandler).toHaveBeenCalledTimes(2);
1173
- // 3. badge-only availability refresh → 1 more emit. This is the
1174
- // back-compat slice: granular 'dock:state-changed' and
1175
- // 'dock:context-changed' stay silent, but legacy mirrors of
1176
- // availableTools still see the update via 'dock:change'.
1177
- fetchAvailability.mockResolvedValueOnce([{ id: 'chat', badge: 5 }]);
1178
- dock.refreshAvailability();
1179
- await new Promise((r) => setTimeout(r, 0));
1180
- flushSync();
1181
- expect(legacyHandler).toHaveBeenCalledTimes(3);
1182
- // 4. close() → 1 more emit (state change).
1183
- dock.close();
1184
- flushSync();
1185
- expect(legacyHandler).toHaveBeenCalledTimes(4);
1186
- });
1187
- });
1188
- });