pixelweaver 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (392) hide show
  1. package/.env.development +1 -0
  2. package/.github/workflows/ci.yml +22 -0
  3. package/.github/workflows/publish.yml +18 -0
  4. package/.prettierignore +5 -0
  5. package/.prettierrc +16 -0
  6. package/.python-version +1 -0
  7. package/.rlsbl/bases/.github/workflows/ci.yml +21 -0
  8. package/.rlsbl/bases/.github/workflows/publish.yml +18 -0
  9. package/.rlsbl/bases/.rlsbl/changes/unreleased.jsonl +0 -0
  10. package/.rlsbl/bases/.rlsbl/hooks/post-release.sh +8 -0
  11. package/.rlsbl/bases/.rlsbl/hooks/pre-checks.sh +5 -0
  12. package/.rlsbl/bases/.rlsbl/hooks/pre-release.sh +8 -0
  13. package/.rlsbl/bases/.rlsbl/lint/go.toml +17 -0
  14. package/.rlsbl/bases/.rlsbl/lint/npm.toml +19 -0
  15. package/.rlsbl/bases/.rlsbl/lint/python.toml +25 -0
  16. package/.rlsbl/bases/CHANGELOG.md +5 -0
  17. package/.rlsbl/bases/LICENSE +21 -0
  18. package/.rlsbl/changes/.validated +1 -0
  19. package/.rlsbl/changes/0.1.0.jsonl +85 -0
  20. package/.rlsbl/changes/0.1.0.md +13 -0
  21. package/.rlsbl/changes/unreleased.jsonl +0 -0
  22. package/.rlsbl/config.json +6 -0
  23. package/.rlsbl/hashes.json +14 -0
  24. package/.rlsbl/hooks/post-release.sh +8 -0
  25. package/.rlsbl/hooks/pre-checks.sh +5 -0
  26. package/.rlsbl/hooks/pre-release.sh +8 -0
  27. package/.rlsbl/lint/go.toml +17 -0
  28. package/.rlsbl/lint/npm.toml +19 -0
  29. package/.rlsbl/lint/python.toml +25 -0
  30. package/.rlsbl/releases/unreleased.toml +0 -0
  31. package/.rlsbl/releases/v0.1.0.toml +3 -0
  32. package/.rlsbl/version +1 -0
  33. package/.selfdoc/hashes/hashes.json +146 -0
  34. package/.strictcli/schema.json +227 -0
  35. package/CHANGELOG.md +17 -0
  36. package/CLAUDE.md +100 -0
  37. package/LICENSE +21 -0
  38. package/README.md +116 -0
  39. package/assets/icon.png +0 -0
  40. package/docs/_README.md +117 -0
  41. package/docs/cli-config.md +35 -0
  42. package/docs/cli-dev.md +21 -0
  43. package/docs/cli-diagnose.md +12 -0
  44. package/docs/cli-index.md +30 -0
  45. package/docs/cli-list.md +18 -0
  46. package/docs/cli-mcp.md +18 -0
  47. package/docs/cli-new.md +26 -0
  48. package/docs/cli-open.md +21 -0
  49. package/docs/cli-serve.md +21 -0
  50. package/docs/cli-stop.md +12 -0
  51. package/docs/gen-index.md +36 -0
  52. package/docs/index.md +13 -0
  53. package/docs/server-src-pixelweaver-__main__.md +12 -0
  54. package/docs/server-src-pixelweaver-autosave.md +12 -0
  55. package/docs/server-src-pixelweaver-bridge.md +12 -0
  56. package/docs/server-src-pixelweaver-cli.md +12 -0
  57. package/docs/server-src-pixelweaver-config.md +12 -0
  58. package/docs/server-src-pixelweaver-connections.md +12 -0
  59. package/docs/server-src-pixelweaver-main.md +12 -0
  60. package/docs/server-src-pixelweaver-mcp_bridge.md +12 -0
  61. package/docs/server-src-pixelweaver-mcp_drawing_tools.md +12 -0
  62. package/docs/server-src-pixelweaver-mcp_export_tools.md +12 -0
  63. package/docs/server-src-pixelweaver-mcp_frame_tools.md +12 -0
  64. package/docs/server-src-pixelweaver-mcp_history_tools.md +12 -0
  65. package/docs/server-src-pixelweaver-mcp_layer_tools.md +12 -0
  66. package/docs/server-src-pixelweaver-mcp_lock.md +12 -0
  67. package/docs/server-src-pixelweaver-mcp_project_tools.md +12 -0
  68. package/docs/server-src-pixelweaver-mcp_read_tools.md +12 -0
  69. package/docs/server-src-pixelweaver-mcp_registry.md +12 -0
  70. package/docs/server-src-pixelweaver-mcp_resources.md +12 -0
  71. package/docs/server-src-pixelweaver-mcp_server.md +12 -0
  72. package/docs/server-src-pixelweaver-protocol.md +12 -0
  73. package/docs/server-src-pixelweaver-state.md +12 -0
  74. package/docs/server-src-pixelweaver-storage.md +12 -0
  75. package/docs/server-src-pixelweaver-websocket.md +12 -0
  76. package/docs/server-src-pixelweaver.md +12 -0
  77. package/e2e/app-launch.test.ts +35 -0
  78. package/e2e/menus.test.ts +26 -0
  79. package/e2e/tools.test.ts +27 -0
  80. package/e2e/undo-redo.test.ts +11 -0
  81. package/eslint.config.js +62 -0
  82. package/index.html +13 -0
  83. package/package.json +48 -0
  84. package/playwright.config.ts +19 -0
  85. package/plugins/builtin/.gitkeep +0 -0
  86. package/plugins/builtin/advanced-fill-tool.ts +146 -0
  87. package/plugins/builtin/circle-tool.ts +186 -0
  88. package/plugins/builtin/diamond-tool.ts +182 -0
  89. package/plugins/builtin/dither-tool.ts +186 -0
  90. package/plugins/builtin/drawing-primitives-plugin.ts +362 -0
  91. package/plugins/builtin/drawing-utils.test.ts +495 -0
  92. package/plugins/builtin/drawing-utils.ts +431 -0
  93. package/plugins/builtin/effects/blur.ts +97 -0
  94. package/plugins/builtin/effects/color-effects.ts +278 -0
  95. package/plugins/builtin/effects/flip.ts +83 -0
  96. package/plugins/builtin/effects/glow.ts +118 -0
  97. package/plugins/builtin/effects/outline.ts +110 -0
  98. package/plugins/builtin/effects/rotate.ts +357 -0
  99. package/plugins/builtin/effects/scale.ts +258 -0
  100. package/plugins/builtin/effects/shadow.ts +111 -0
  101. package/plugins/builtin/effects/sharpen.ts +102 -0
  102. package/plugins/builtin/effects.test.ts +715 -0
  103. package/plugins/builtin/eraser-tool.ts +23 -0
  104. package/plugins/builtin/eyedropper-tool.ts +83 -0
  105. package/plugins/builtin/fill-tool.ts +93 -0
  106. package/plugins/builtin/gradient-tool.ts +204 -0
  107. package/plugins/builtin/gradient.test.ts +142 -0
  108. package/plugins/builtin/importers/aseprite-importer-plugin.ts +174 -0
  109. package/plugins/builtin/importers/aseprite-parser.ts +497 -0
  110. package/plugins/builtin/importers/piskel-importer-plugin.ts +222 -0
  111. package/plugins/builtin/importers/sky-spec-plugin.ts +409 -0
  112. package/plugins/builtin/line-tool.ts +267 -0
  113. package/plugins/builtin/make-stroke-tool.ts +271 -0
  114. package/plugins/builtin/noise-dither.test.ts +151 -0
  115. package/plugins/builtin/noise-tool.ts +131 -0
  116. package/plugins/builtin/pattern-stamp-tool.ts +162 -0
  117. package/plugins/builtin/pencil-tool.ts +25 -0
  118. package/plugins/builtin/rect-tool.ts +179 -0
  119. package/plugins/builtin/selection-tool.ts +388 -0
  120. package/plugins/builtin/selection.test.ts +195 -0
  121. package/plugins/builtin/tool-plugins.test.ts +529 -0
  122. package/public/favicon.svg +7 -0
  123. package/public/sw.js +91 -0
  124. package/pyproject.toml +49 -0
  125. package/scripts/eslint-wave-a-list.sh +24 -0
  126. package/scripts/eslint-wave-a-status.sh +25 -0
  127. package/scripts/fix-index-signature-access.py +127 -0
  128. package/scripts/fix-unchecked-index.py +128 -0
  129. package/scripts/fix-unchecked-vars.py +127 -0
  130. package/scripts/fix-wave-a-bangs.py +36 -0
  131. package/scripts/fix-wave-a-templates.py +108 -0
  132. package/scripts/fix-wave-b-void-expr.py +167 -0
  133. package/scripts/generate-command-params.py +540 -0
  134. package/scripts/migrate-single-frame-to-multi.py +171 -0
  135. package/scripts/smoke-test.sh +77 -0
  136. package/selfdoc.json +10 -0
  137. package/server/README.md +0 -0
  138. package/server/src/pixelweaver/__init__.py +1 -0
  139. package/server/src/pixelweaver/__main__.py +4 -0
  140. package/server/src/pixelweaver/autosave.py +114 -0
  141. package/server/src/pixelweaver/bridge.py +127 -0
  142. package/server/src/pixelweaver/cli.py +199 -0
  143. package/server/src/pixelweaver/config.py +24 -0
  144. package/server/src/pixelweaver/connections.py +54 -0
  145. package/server/src/pixelweaver/main.py +271 -0
  146. package/server/src/pixelweaver/mcp_bridge.py +189 -0
  147. package/server/src/pixelweaver/mcp_drawing_tools.py +178 -0
  148. package/server/src/pixelweaver/mcp_export_tools.py +291 -0
  149. package/server/src/pixelweaver/mcp_frame_tools.py +423 -0
  150. package/server/src/pixelweaver/mcp_history_tools.py +106 -0
  151. package/server/src/pixelweaver/mcp_layer_tools.py +64 -0
  152. package/server/src/pixelweaver/mcp_lock.py +37 -0
  153. package/server/src/pixelweaver/mcp_project_tools.py +302 -0
  154. package/server/src/pixelweaver/mcp_read_tools.py +163 -0
  155. package/server/src/pixelweaver/mcp_registry.py +247 -0
  156. package/server/src/pixelweaver/mcp_resources.py +312 -0
  157. package/server/src/pixelweaver/mcp_server.py +234 -0
  158. package/server/src/pixelweaver/protocol.py +219 -0
  159. package/server/src/pixelweaver/state.py +267 -0
  160. package/server/src/pixelweaver/storage.py +293 -0
  161. package/server/src/pixelweaver/websocket.py +282 -0
  162. package/server/tests/__init__.py +0 -0
  163. package/server/tests/conftest.py +17 -0
  164. package/server/tests/test_api.py +96 -0
  165. package/server/tests/test_bridge.py +161 -0
  166. package/server/tests/test_health.py +9 -0
  167. package/server/tests/test_integration.py +86 -0
  168. package/server/tests/test_mcp_bridge.py +293 -0
  169. package/server/tests/test_mcp_lock.py +34 -0
  170. package/server/tests/test_mcp_registry.py +679 -0
  171. package/server/tests/test_mcp_resources.py +648 -0
  172. package/server/tests/test_protocol.py +125 -0
  173. package/server/tests/test_state.py +87 -0
  174. package/server/tests/test_storage.py +306 -0
  175. package/server/tests/test_websocket.py +275 -0
  176. package/src/App.svelte +107 -0
  177. package/src/app.css +215 -0
  178. package/src/lib/animation/AnimationPreviewPanel.svelte +667 -0
  179. package/src/lib/animation/animation-commands.test.ts +228 -0
  180. package/src/lib/animation/animation-commands.ts +540 -0
  181. package/src/lib/animation/animation-preview-panel-plugin.ts +25 -0
  182. package/src/lib/animation/animation-preview.svelte.ts +151 -0
  183. package/src/lib/animation/clipboard.ts +134 -0
  184. package/src/lib/animation/frame-model.svelte.ts +437 -0
  185. package/src/lib/animation/frame-model.test.ts +314 -0
  186. package/src/lib/animation/frame-selection.svelte.ts +77 -0
  187. package/src/lib/animation/frame-tags.svelte.ts +238 -0
  188. package/src/lib/animation/frame-tags.test.ts +136 -0
  189. package/src/lib/animation/import.test.ts +141 -0
  190. package/src/lib/animation/import.ts +112 -0
  191. package/src/lib/animation/spritesheet-export.test.ts +239 -0
  192. package/src/lib/animation/spritesheet-export.ts +314 -0
  193. package/src/lib/canvas/CanvasViewport.svelte +216 -0
  194. package/src/lib/canvas/canvas-init-plugin.ts +178 -0
  195. package/src/lib/canvas/canvas-renderer.ts +408 -0
  196. package/src/lib/canvas/canvas-state.svelte.ts +232 -0
  197. package/src/lib/canvas/canvas-state.test.ts +139 -0
  198. package/src/lib/canvas/input-handler.ts +221 -0
  199. package/src/lib/canvas/input-plugin.ts +150 -0
  200. package/src/lib/canvas/onion-skin.ts +94 -0
  201. package/src/lib/canvas/pixel-buffer.test.ts +249 -0
  202. package/src/lib/canvas/pixel-buffer.ts +151 -0
  203. package/src/lib/canvas/render-state.svelte.ts +18 -0
  204. package/src/lib/canvas/shape-preview-state.svelte.ts +36 -0
  205. package/src/lib/canvas/tile-mode.test.ts +53 -0
  206. package/src/lib/canvas/tile-mode.ts +92 -0
  207. package/src/lib/canvas/viewport-utils.ts +24 -0
  208. package/src/lib/canvas/zoom-utils.ts +31 -0
  209. package/src/lib/color/.gitkeep +0 -0
  210. package/src/lib/color/color-commands.ts +87 -0
  211. package/src/lib/color/color-state.svelte.ts +98 -0
  212. package/src/lib/color/color-state.test.ts +91 -0
  213. package/src/lib/color/color-utils.test.ts +220 -0
  214. package/src/lib/color/color-utils.ts +243 -0
  215. package/src/lib/color/palette-state.svelte.ts +127 -0
  216. package/src/lib/color/palette-state.test.ts +154 -0
  217. package/src/lib/color/palette.ts +79 -0
  218. package/src/lib/core/bootstrap.ts +66 -0
  219. package/src/lib/core/command-params.generated.ts +1549 -0
  220. package/src/lib/core/command-params.ts +20 -0
  221. package/src/lib/core/command-runner.ts +79 -0
  222. package/src/lib/core/commands.ts +134 -0
  223. package/src/lib/core/dispatcher.test.ts +548 -0
  224. package/src/lib/core/dispatcher.ts +361 -0
  225. package/src/lib/core/index.test.ts +7 -0
  226. package/src/lib/core/notification-state.svelte.ts +119 -0
  227. package/src/lib/core/plugin-api.ts +210 -0
  228. package/src/lib/core/plugin-discovery.test.ts +53 -0
  229. package/src/lib/core/plugin-discovery.ts +65 -0
  230. package/src/lib/core/plugin-loader.test.ts +159 -0
  231. package/src/lib/core/plugin-loader.ts +240 -0
  232. package/src/lib/core/plugin-types.ts +286 -0
  233. package/src/lib/core/registries.svelte.ts +74 -0
  234. package/src/lib/core/tool-options-state.svelte.ts +61 -0
  235. package/src/lib/dock/DockLayout.svelte +375 -0
  236. package/src/lib/dock/TabAddMenu.svelte +90 -0
  237. package/src/lib/dock/dock-persistence.ts +46 -0
  238. package/src/lib/dock/dock-plugin.ts +49 -0
  239. package/src/lib/dock/dock-presets.ts +156 -0
  240. package/src/lib/dock/dock-state.svelte.ts +77 -0
  241. package/src/lib/dock/dock-types.ts +2 -0
  242. package/src/lib/dock/dockview-theme.css +226 -0
  243. package/src/lib/dock/dockview-theme.ts +17 -0
  244. package/src/lib/dock/header-action-renderer.ts +77 -0
  245. package/src/lib/edit/clipboard-state.ts +34 -0
  246. package/src/lib/export/download.ts +201 -0
  247. package/src/lib/export/png-metadata.ts +181 -0
  248. package/src/lib/history/ActionLogPanel.svelte +418 -0
  249. package/src/lib/history/action-log-panel-plugin.ts +24 -0
  250. package/src/lib/history/action-log.svelte.ts +172 -0
  251. package/src/lib/history/action-log.test.ts +168 -0
  252. package/src/lib/history/history-commands.ts +403 -0
  253. package/src/lib/history/macros.svelte.ts +320 -0
  254. package/src/lib/history/macros.test.ts +224 -0
  255. package/src/lib/history/replay-engine.test.ts +241 -0
  256. package/src/lib/history/replay-engine.ts +149 -0
  257. package/src/lib/history/spec-format.test.ts +250 -0
  258. package/src/lib/history/spec-format.ts +210 -0
  259. package/src/lib/iso/SeamCheckerPanel.svelte +251 -0
  260. package/src/lib/iso/iso-math.test.ts +77 -0
  261. package/src/lib/iso/iso-math.ts +77 -0
  262. package/src/lib/iso/seam-checker-panel-plugin.ts +25 -0
  263. package/src/lib/iso/seam-checker.test.ts +126 -0
  264. package/src/lib/iso/seam-checker.ts +93 -0
  265. package/src/lib/iso/tessellation.ts +67 -0
  266. package/src/lib/layers/compositor.test.ts +193 -0
  267. package/src/lib/layers/compositor.ts +175 -0
  268. package/src/lib/layers/layer-commands.test.ts +263 -0
  269. package/src/lib/layers/layer-commands.ts +429 -0
  270. package/src/lib/layers/layer-tree.svelte.ts +516 -0
  271. package/src/lib/layers/layer-tree.test.ts +383 -0
  272. package/src/lib/layers/layer-types.ts +56 -0
  273. package/src/lib/leveleditor/LevelEditorViewport.svelte +1808 -0
  274. package/src/lib/leveleditor/MapPropertiesPanel.svelte +266 -0
  275. package/src/lib/leveleditor/TilePickerPanel.svelte +324 -0
  276. package/src/lib/leveleditor/depth-sort.test.ts +70 -0
  277. package/src/lib/leveleditor/depth-sort.ts +39 -0
  278. package/src/lib/leveleditor/level-editor-commands.ts +353 -0
  279. package/src/lib/leveleditor/level-editor-viewport-plugin.ts +25 -0
  280. package/src/lib/leveleditor/map-properties-panel-plugin.ts +25 -0
  281. package/src/lib/leveleditor/map-state.svelte.ts +372 -0
  282. package/src/lib/leveleditor/map-state.test.ts +243 -0
  283. package/src/lib/leveleditor/tile-picker-panel-plugin.ts +25 -0
  284. package/src/lib/leveleditor/tile-source-registry.svelte.ts +91 -0
  285. package/src/lib/leveleditor/tiled-export.test.ts +144 -0
  286. package/src/lib/leveleditor/tiled-export.ts +374 -0
  287. package/src/lib/pywebview.d.ts +15 -0
  288. package/src/lib/recovery/.gitkeep +0 -0
  289. package/src/lib/recovery/idb-store.ts +118 -0
  290. package/src/lib/recovery/recovery-manager.test.ts +321 -0
  291. package/src/lib/recovery/recovery-manager.ts +115 -0
  292. package/src/lib/recovery/recovery-plugin.ts +55 -0
  293. package/src/lib/recovery/recovery-state.svelte.ts +21 -0
  294. package/src/lib/recovery/sw-plugin.ts +18 -0
  295. package/src/lib/recovery/sw-register.ts +17 -0
  296. package/src/lib/save/directory-format.ts +42 -0
  297. package/src/lib/save/project-snapshot.ts +139 -0
  298. package/src/lib/save/recent-projects.ts +56 -0
  299. package/src/lib/save/save-state.svelte.ts +35 -0
  300. package/src/lib/save/storage.ts +167 -0
  301. package/src/lib/save/zip-format.ts +45 -0
  302. package/src/lib/shortcuts/.gitkeep +0 -0
  303. package/src/lib/shortcuts/ShortcutEditorPanel.svelte +690 -0
  304. package/src/lib/shortcuts/default-bindings.ts +61 -0
  305. package/src/lib/shortcuts/shortcut-editor-panel-plugin.ts +25 -0
  306. package/src/lib/shortcuts/shortcut-init.ts +380 -0
  307. package/src/lib/shortcuts/shortcut-manager.test.ts +466 -0
  308. package/src/lib/shortcuts/shortcut-manager.ts +281 -0
  309. package/src/lib/shortcuts/shortcut-state.svelte.ts +78 -0
  310. package/src/lib/shortcuts/shortcuts-plugin.ts +17 -0
  311. package/src/lib/sync/patch-applicator.ts +300 -0
  312. package/src/lib/sync/patch-types.ts +65 -0
  313. package/src/lib/sync/project-manager.ts +108 -0
  314. package/src/lib/sync/sync-init.ts +152 -0
  315. package/src/lib/sync/sync-plugin.ts +19 -0
  316. package/src/lib/sync/sync-state.svelte.ts +56 -0
  317. package/src/lib/sync/ws-client.test.ts +604 -0
  318. package/src/lib/sync/ws-client.ts +574 -0
  319. package/src/lib/tools/.gitkeep +0 -0
  320. package/src/lib/ui/.gitkeep +0 -0
  321. package/src/lib/ui/AboutDialog.svelte +113 -0
  322. package/src/lib/ui/ColorPicker.svelte +761 -0
  323. package/src/lib/ui/ContextMenu.svelte +216 -0
  324. package/src/lib/ui/ExportDialog.svelte +747 -0
  325. package/src/lib/ui/FrameStrip.svelte +854 -0
  326. package/src/lib/ui/LayerPanel.svelte +810 -0
  327. package/src/lib/ui/MenuBar.svelte +590 -0
  328. package/src/lib/ui/NewProjectDialog.svelte +803 -0
  329. package/src/lib/ui/PluginManagerPanel.svelte +475 -0
  330. package/src/lib/ui/PromptDialog.svelte +252 -0
  331. package/src/lib/ui/RecoveryDialog.svelte +295 -0
  332. package/src/lib/ui/ResizeDialog.svelte +416 -0
  333. package/src/lib/ui/StatusBar.svelte +145 -0
  334. package/src/lib/ui/ToolbarPanel.svelte +488 -0
  335. package/src/lib/ui/animation-menu-commands.ts +194 -0
  336. package/src/lib/ui/command-palette/CommandPalette.svelte +232 -0
  337. package/src/lib/ui/command-palette/command-palette-plugin.ts +30 -0
  338. package/src/lib/ui/command-palette/command-palette-state.svelte.ts +190 -0
  339. package/src/lib/ui/command-palette/command-palette.test.ts +129 -0
  340. package/src/lib/ui/dialog-state.svelte.ts +70 -0
  341. package/src/lib/ui/edit-commands.ts +271 -0
  342. package/src/lib/ui/file-commands.ts +275 -0
  343. package/src/lib/ui/file-open.ts +99 -0
  344. package/src/lib/ui/help-commands.ts +93 -0
  345. package/src/lib/ui/image-commands.ts +181 -0
  346. package/src/lib/ui/layer-menu-commands.ts +420 -0
  347. package/src/lib/ui/menu-builder.ts +224 -0
  348. package/src/lib/ui/notifications/NotificationBanner.svelte +137 -0
  349. package/src/lib/ui/notifications/notification-plugin.ts +29 -0
  350. package/src/lib/ui/notifications/notification-state.svelte.ts +9 -0
  351. package/src/lib/ui/plugin-manager-panel-plugin.ts +26 -0
  352. package/src/lib/ui/plugin-state.svelte.ts +62 -0
  353. package/src/lib/ui/select-commands.ts +75 -0
  354. package/src/lib/ui/theme-plugin.ts +18 -0
  355. package/src/lib/ui/theme.svelte.ts +45 -0
  356. package/src/lib/ui/theme.test.ts +51 -0
  357. package/src/lib/ui/toolbar-config.ts +90 -0
  358. package/src/lib/ui/toolbar-plugin.ts +39 -0
  359. package/src/lib/ui/view-commands.ts +629 -0
  360. package/src/lib/variants/BisectionExportDialog.svelte +500 -0
  361. package/src/lib/variants/VariantPanel.svelte +822 -0
  362. package/src/lib/variants/bisection-export.test.ts +113 -0
  363. package/src/lib/variants/bisection-export.ts +148 -0
  364. package/src/lib/variants/palette-extraction.test.ts +111 -0
  365. package/src/lib/variants/palette-extraction.ts +84 -0
  366. package/src/lib/variants/palette-interpolation.test.ts +113 -0
  367. package/src/lib/variants/palette-interpolation.ts +87 -0
  368. package/src/lib/variants/palette-swap.test.ts +101 -0
  369. package/src/lib/variants/palette-swap.ts +114 -0
  370. package/src/lib/variants/variant-commands.ts +594 -0
  371. package/src/lib/variants/variant-panel-plugin.ts +27 -0
  372. package/src/lib/variants/variant-randomizer.ts +101 -0
  373. package/src/lib/variants/variant-state.svelte.ts +166 -0
  374. package/src/lib/variants/variant-state.test.ts +138 -0
  375. package/src/main.ts +14 -0
  376. package/src/vite-env.d.ts +3 -0
  377. package/svelte.config.js +2 -0
  378. package/todo/.done/audit-design-decisions.md +812 -0
  379. package/todo/.done/audit-implementation-plan.md +1235 -0
  380. package/todo/.done/happy-path-polish.md +177 -0
  381. package/todo/.done/pixelweaver-full-build.md +937 -0
  382. package/todo/.done/server-multi-frame-design.md +405 -0
  383. package/todo/.done/typed-dispatcher-design.md +435 -0
  384. package/todo/.done/unified-toolbar-and-action-system.md +323 -0
  385. package/todo/.obsolete/comprehensive-audit-obsolete-items.md +33 -0
  386. package/todo/.obsolete/tauri-desktop-bundle.md +424 -0
  387. package/todo/comprehensive-audit.md +1085 -0
  388. package/tsconfig.app.json +26 -0
  389. package/tsconfig.json +7 -0
  390. package/tsconfig.node.json +20 -0
  391. package/uv.lock +1167 -0
  392. package/vite.config.ts +32 -0
@@ -0,0 +1,604 @@
1
+ /**
2
+ * Unit tests for the WebSocket client.
3
+ *
4
+ * These test the unit-testable logic (ID generation, backoff calculation,
5
+ * message serialization, pending timeouts) without requiring an actual
6
+ * WebSocket server. Full integration tests are deferred to E2E.
7
+ */
8
+
9
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
10
+ import { generateMessageId, calculateBackoff, WebSocketClient } from './ws-client.js';
11
+
12
+ /**
13
+ * Structural shape for the messages produced by the WebSocket client.
14
+ * Used only to narrow JSON.parse results in these tests.
15
+ */
16
+ interface ParsedCommand {
17
+ type: string;
18
+ plugin?: string;
19
+ params?: Record<string, unknown>;
20
+ id?: string;
21
+ timestamp?: number;
22
+ }
23
+ interface ParsedMessage {
24
+ type: string;
25
+ id: string;
26
+ command?: ParsedCommand;
27
+ error?: string;
28
+ }
29
+ function parseMessage(raw: string): ParsedMessage {
30
+ return JSON.parse(raw) as ParsedMessage;
31
+ }
32
+
33
+ // --- generateMessageId ---
34
+
35
+ describe('generateMessageId', () => {
36
+ it('should return a non-empty string', () => {
37
+ const id = generateMessageId();
38
+ expect(typeof id).toBe('string');
39
+ expect(id.length).toBeGreaterThan(0);
40
+ });
41
+
42
+ it('should return unique IDs on successive calls', () => {
43
+ const ids = new Set(Array.from({ length: 100 }, () => generateMessageId()));
44
+ expect(ids.size).toBe(100);
45
+ });
46
+
47
+ it('should return valid UUID format', () => {
48
+ const id = generateMessageId();
49
+ // UUID v4 pattern: 8-4-4-4-12 hex digits
50
+ expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
51
+ });
52
+ });
53
+
54
+ // --- calculateBackoff ---
55
+
56
+ describe('calculateBackoff', () => {
57
+ it('should return 1000ms for attempt 0', () => {
58
+ expect(calculateBackoff(0)).toBe(1000);
59
+ });
60
+
61
+ it('should return 2000ms for attempt 1', () => {
62
+ expect(calculateBackoff(1)).toBe(2000);
63
+ });
64
+
65
+ it('should return 4000ms for attempt 2', () => {
66
+ expect(calculateBackoff(2)).toBe(4000);
67
+ });
68
+
69
+ it('should return 8000ms for attempt 3', () => {
70
+ expect(calculateBackoff(3)).toBe(8000);
71
+ });
72
+
73
+ it('should return 16000ms for attempt 4', () => {
74
+ expect(calculateBackoff(4)).toBe(16000);
75
+ });
76
+
77
+ it('should cap at 30000ms (MAX_BACKOFF)', () => {
78
+ expect(calculateBackoff(5)).toBe(30000); // 1000 * 2^5 = 32000 -> capped at 30000
79
+ expect(calculateBackoff(10)).toBe(30000);
80
+ expect(calculateBackoff(100)).toBe(30000);
81
+ });
82
+
83
+ it('should produce strictly non-decreasing values', () => {
84
+ let prev = 0;
85
+ for (let i = 0; i < 20; i++) {
86
+ const current = calculateBackoff(i);
87
+ expect(current).toBeGreaterThanOrEqual(prev);
88
+ prev = current;
89
+ }
90
+ });
91
+ });
92
+
93
+ // --- Message serialization ---
94
+
95
+ describe('message serialization', () => {
96
+ it('should produce correct JSON for a command message', () => {
97
+ // Simulate what sendCommand builds internally
98
+ const command = {
99
+ type: 'draw_pixel',
100
+ plugin: 'builtin/pencil',
101
+ version: '1.0.0',
102
+ params: { x: 10, y: 20, color: '#ff0000' },
103
+ id: 'cmd-id-123',
104
+ timestamp: 1700000000000,
105
+ };
106
+
107
+ const message = {
108
+ type: 'command' as const,
109
+ id: 'msg-id-456',
110
+ command,
111
+ };
112
+
113
+ const json = parseMessage(JSON.stringify(message));
114
+
115
+ expect(json.type).toBe('command');
116
+ expect(json.id).toBe('msg-id-456');
117
+ expect(json.command?.type).toBe('draw_pixel');
118
+ expect(json.command?.plugin).toBe('builtin/pencil');
119
+ expect(json.command?.params?.["x"]).toBe(10);
120
+ expect(json.command?.id).toBe('cmd-id-123');
121
+ expect(json.command?.timestamp).toBe(1700000000000);
122
+ });
123
+
124
+ it('should produce correct JSON for a sync_request message', () => {
125
+ const message = {
126
+ type: 'sync_request' as const,
127
+ id: 'msg-sync-1',
128
+ };
129
+
130
+ const json = parseMessage(JSON.stringify(message));
131
+ expect(json.type).toBe('sync_request');
132
+ expect(json.id).toBe('msg-sync-1');
133
+ expect(Object.keys(json)).toHaveLength(2);
134
+ });
135
+
136
+ it('should produce correct JSON for an undo message', () => {
137
+ const message = {
138
+ type: 'undo' as const,
139
+ id: 'msg-undo-1',
140
+ };
141
+
142
+ const json = parseMessage(JSON.stringify(message));
143
+ expect(json.type).toBe('undo');
144
+ expect(json.id).toBe('msg-undo-1');
145
+ expect(Object.keys(json)).toHaveLength(2);
146
+ });
147
+
148
+ it('should produce correct JSON for a redo message', () => {
149
+ const message = {
150
+ type: 'redo' as const,
151
+ id: 'msg-redo-1',
152
+ };
153
+
154
+ const json = parseMessage(JSON.stringify(message));
155
+ expect(json.type).toBe('redo');
156
+ expect(json.id).toBe('msg-redo-1');
157
+ expect(Object.keys(json)).toHaveLength(2);
158
+ });
159
+ });
160
+
161
+ // --- Pending message timeout ---
162
+
163
+ describe('pending message timeout', () => {
164
+ beforeEach(() => {
165
+ vi.useFakeTimers();
166
+ });
167
+
168
+ afterEach(() => {
169
+ vi.useRealTimers();
170
+ });
171
+
172
+ it('should reject with timeout error after 10 seconds', async () => {
173
+ // Create a mock WebSocket that connects but never responds to messages
174
+ let mockOnOpen: ((ev: Event) => void) | null = null;
175
+ const mockWs = {
176
+ readyState: 1, // WebSocket.OPEN
177
+ send: vi.fn(),
178
+ close: vi.fn(),
179
+ set onopen(fn: ((ev: Event) => void) | null) { mockOnOpen = fn; },
180
+ get onopen() { return mockOnOpen; },
181
+ onclose: null as ((ev: CloseEvent) => void) | null,
182
+ onerror: null as ((ev: Event) => void) | null,
183
+ onmessage: null as ((ev: MessageEvent) => void) | null,
184
+ };
185
+
186
+ const OriginalWebSocket = globalThis.WebSocket;
187
+ // Replace global WebSocket with a constructor that returns our mock
188
+ globalThis.WebSocket = vi.fn().mockImplementation(() => mockWs) as unknown as typeof WebSocket;
189
+ // Preserve the OPEN constant for isConnected() check
190
+ (globalThis.WebSocket as unknown as Record<string, number>)["OPEN"] = 1;
191
+
192
+ try {
193
+ const client = new WebSocketClient('ws://localhost:9999/ws');
194
+
195
+ // Start connect and immediately fire onopen (synchronous mock)
196
+ const connectPromise = client.connect();
197
+ (mockOnOpen as ((ev: Event) => void) | null)?.(new Event('open'));
198
+ await connectPromise;
199
+
200
+ // Start an undo request -- server will never respond
201
+ const undoPromise = client.sendUndo();
202
+
203
+ // Advance time by 10 seconds to trigger the pending timeout
204
+ vi.advanceTimersByTime(10_000);
205
+
206
+ await expect(undoPromise).rejects.toThrow('timed out');
207
+ } finally {
208
+ globalThis.WebSocket = OriginalWebSocket;
209
+ }
210
+ });
211
+ });
212
+
213
+ // --- WebSocketClient construction ---
214
+
215
+ describe('WebSocketClient', () => {
216
+ it('should report not connected before connect() is called', () => {
217
+ const client = new WebSocketClient('ws://localhost:9999/ws');
218
+ expect(client.isConnected()).toBe(false);
219
+ });
220
+
221
+ it('should allow setting URL via setUrl', () => {
222
+ const client = new WebSocketClient('ws://localhost:9999/ws');
223
+ // setUrl doesn't throw and the client can be used afterward
224
+ client.setUrl('ws://localhost:8888/ws');
225
+ expect(client.isConnected()).toBe(false);
226
+ });
227
+
228
+ it('should support subscribing and unsubscribing from events', () => {
229
+ const client = new WebSocketClient('ws://localhost:9999/ws');
230
+
231
+ const cb = vi.fn();
232
+ const unsub = client.onError(cb);
233
+ expect(typeof unsub).toBe('function');
234
+
235
+ // Unsubscribe should not throw
236
+ unsub();
237
+ });
238
+
239
+ it('should reject sendCommand when not connected', async () => {
240
+ const client = new WebSocketClient('ws://localhost:9999/ws');
241
+
242
+ await expect(
243
+ client.sendCommand({
244
+ type: 'draw_pixel',
245
+ plugin: 'builtin/pencil',
246
+ version: '1.0.0',
247
+ params: {},
248
+ }),
249
+ ).rejects.toThrow('Not connected');
250
+ });
251
+
252
+ it('should reject requestSync when not connected', async () => {
253
+ const client = new WebSocketClient('ws://localhost:9999/ws');
254
+ await expect(client.requestSync()).rejects.toThrow('Not connected');
255
+ });
256
+
257
+ it('should reject sendUndo when not connected', async () => {
258
+ const client = new WebSocketClient('ws://localhost:9999/ws');
259
+ await expect(client.sendUndo()).rejects.toThrow('Not connected');
260
+ });
261
+
262
+ it('should reject sendRedo when not connected', async () => {
263
+ const client = new WebSocketClient('ws://localhost:9999/ws');
264
+ await expect(client.sendRedo()).rejects.toThrow('Not connected');
265
+ });
266
+ });
267
+
268
+ // --- Forward-reject callback ---
269
+
270
+ describe('forwardCommand reject handling', () => {
271
+ it('should invoke onForwardReject callback when server rejects a forwarded command', async () => {
272
+ let mockOnOpen: ((ev: Event) => void) | null = null;
273
+ let mockOnMessage: ((ev: MessageEvent) => void) | null = null;
274
+ const sentMessages: string[] = [];
275
+
276
+ const mockWs = {
277
+ readyState: 1,
278
+ send: vi.fn((data: string) => { sentMessages.push(data); }),
279
+ close: vi.fn(),
280
+ set onopen(fn: ((ev: Event) => void) | null) { mockOnOpen = fn; },
281
+ get onopen() { return mockOnOpen; },
282
+ onclose: null as ((ev: CloseEvent) => void) | null,
283
+ onerror: null as ((ev: Event) => void) | null,
284
+ set onmessage(fn: ((ev: MessageEvent) => void) | null) { mockOnMessage = fn; },
285
+ get onmessage() { return mockOnMessage; },
286
+ };
287
+
288
+ const OriginalWebSocket = globalThis.WebSocket;
289
+ globalThis.WebSocket = vi.fn().mockImplementation(() => mockWs) as unknown as typeof WebSocket;
290
+ (globalThis.WebSocket as unknown as Record<string, number>)["OPEN"] = 1;
291
+
292
+ try {
293
+ const client = new WebSocketClient('ws://localhost:9999/ws');
294
+ const connectPromise = client.connect();
295
+ (mockOnOpen as ((ev: Event) => void) | null)?.(new Event('open'));
296
+ await connectPromise;
297
+
298
+ const rejectCb = vi.fn();
299
+ client.setOnForwardReject(rejectCb);
300
+
301
+ // Forward a command
302
+ client.forwardCommand({
303
+ type: 'draw_pixel',
304
+ plugin: 'builtin/pencil',
305
+ version: '1.0.0',
306
+ params: { x: 1, y: 2 },
307
+ id: 'cmd-1',
308
+ timestamp: Date.now(),
309
+ });
310
+
311
+ // Extract the message ID that was sent
312
+ expect(sentMessages).toHaveLength(1);
313
+ const sentMsg = parseMessage(sentMessages[0] ?? "");
314
+ const msgId = sentMsg.id;
315
+
316
+ // Simulate server sending command_reject for that message ID
317
+ (mockOnMessage as ((ev: MessageEvent) => void) | null)?.(new MessageEvent('message', {
318
+ data: JSON.stringify({
319
+ type: 'command_reject',
320
+ id: msgId,
321
+ error: 'permission denied',
322
+ }),
323
+ }));
324
+
325
+ expect(rejectCb).toHaveBeenCalledOnce();
326
+ expect(rejectCb).toHaveBeenCalledWith(msgId, 'permission denied');
327
+ } finally {
328
+ globalThis.WebSocket = OriginalWebSocket;
329
+ }
330
+ });
331
+
332
+ it('should NOT invoke onForwardReject for acked forwarded commands', async () => {
333
+ let mockOnOpen: ((ev: Event) => void) | null = null;
334
+ let mockOnMessage: ((ev: MessageEvent) => void) | null = null;
335
+ const sentMessages: string[] = [];
336
+
337
+ const mockWs = {
338
+ readyState: 1,
339
+ send: vi.fn((data: string) => { sentMessages.push(data); }),
340
+ close: vi.fn(),
341
+ set onopen(fn: ((ev: Event) => void) | null) { mockOnOpen = fn; },
342
+ get onopen() { return mockOnOpen; },
343
+ onclose: null as ((ev: CloseEvent) => void) | null,
344
+ onerror: null as ((ev: Event) => void) | null,
345
+ set onmessage(fn: ((ev: MessageEvent) => void) | null) { mockOnMessage = fn; },
346
+ get onmessage() { return mockOnMessage; },
347
+ };
348
+
349
+ const OriginalWebSocket = globalThis.WebSocket;
350
+ globalThis.WebSocket = vi.fn().mockImplementation(() => mockWs) as unknown as typeof WebSocket;
351
+ (globalThis.WebSocket as unknown as Record<string, number>)["OPEN"] = 1;
352
+
353
+ try {
354
+ const client = new WebSocketClient('ws://localhost:9999/ws');
355
+ const connectPromise = client.connect();
356
+ (mockOnOpen as ((ev: Event) => void) | null)?.(new Event('open'));
357
+ await connectPromise;
358
+
359
+ const rejectCb = vi.fn();
360
+ client.setOnForwardReject(rejectCb);
361
+
362
+ // Forward a command
363
+ client.forwardCommand({
364
+ type: 'draw_pixel',
365
+ plugin: 'builtin/pencil',
366
+ version: '1.0.0',
367
+ params: {},
368
+ id: 'cmd-2',
369
+ timestamp: Date.now(),
370
+ });
371
+
372
+ const sentMsg = parseMessage(sentMessages[0] ?? "");
373
+ const msgId = sentMsg.id;
374
+
375
+ // Simulate server sending command_ack
376
+ (mockOnMessage as ((ev: MessageEvent) => void) | null)?.(new MessageEvent('message', {
377
+ data: JSON.stringify({ type: 'command_ack', id: msgId }),
378
+ }));
379
+
380
+ // Now simulate a reject for the same ID (should not trigger callback
381
+ // because the ID was already removed on ack)
382
+ (mockOnMessage as ((ev: MessageEvent) => void) | null)?.(new MessageEvent('message', {
383
+ data: JSON.stringify({
384
+ type: 'command_reject',
385
+ id: msgId,
386
+ error: 'too late',
387
+ }),
388
+ }));
389
+
390
+ expect(rejectCb).not.toHaveBeenCalled();
391
+ } finally {
392
+ globalThis.WebSocket = OriginalWebSocket;
393
+ }
394
+ });
395
+
396
+ it('should not invoke callback when not connected (forwardCommand is a no-op)', () => {
397
+ const client = new WebSocketClient('ws://localhost:9999/ws');
398
+ const rejectCb = vi.fn();
399
+ client.setOnForwardReject(rejectCb);
400
+
401
+ // forwardCommand on a disconnected client silently returns
402
+ client.forwardCommand({
403
+ type: 'draw_pixel',
404
+ plugin: 'builtin/pencil',
405
+ version: '1.0.0',
406
+ params: {},
407
+ id: 'cmd-3',
408
+ timestamp: Date.now(),
409
+ });
410
+
411
+ expect(rejectCb).not.toHaveBeenCalled();
412
+ });
413
+ });
414
+
415
+ // --- Post-double-socket hardening regression tests ---
416
+
417
+ /**
418
+ * Shared mock WebSocket factory used by the hardening tests. Returns a
419
+ * mutable handle plus an installer that swaps globalThis.WebSocket with a
420
+ * constructor yielding the handle. Each test is responsible for calling
421
+ * the restore function in a finally block.
422
+ */
423
+ interface MockHandle {
424
+ onopen: ((ev: Event) => void) | null;
425
+ onclose: ((ev: CloseEvent) => void) | null;
426
+ onerror: ((ev: Event) => void) | null;
427
+ onmessage: ((ev: MessageEvent) => void) | null;
428
+ close: ReturnType<typeof vi.fn>;
429
+ send: ReturnType<typeof vi.fn>;
430
+ readyState: number;
431
+ }
432
+
433
+ function installMockWebSocket(opts?: { throwOnConstruct?: Error }): {
434
+ handle: MockHandle;
435
+ restore: () => void;
436
+ } {
437
+ const handle: MockHandle = {
438
+ onopen: null,
439
+ onclose: null,
440
+ onerror: null,
441
+ onmessage: null,
442
+ close: vi.fn(),
443
+ send: vi.fn(),
444
+ readyState: 0, // CONNECTING initially
445
+ };
446
+
447
+ const OriginalWebSocket = globalThis.WebSocket;
448
+ globalThis.WebSocket = vi.fn().mockImplementation(() => {
449
+ if (opts?.throwOnConstruct) throw opts.throwOnConstruct;
450
+ return handle;
451
+ }) as unknown as typeof WebSocket;
452
+ (globalThis.WebSocket as unknown as Record<string, number>)["OPEN"] = 1;
453
+ (globalThis.WebSocket as unknown as Record<string, number>)["CONNECTING"] = 0;
454
+
455
+ return {
456
+ handle,
457
+ restore: () => {
458
+ globalThis.WebSocket = OriginalWebSocket;
459
+ },
460
+ };
461
+ }
462
+
463
+ describe('disconnect() symmetric handler cleanup', () => {
464
+ it('should null all four handlers so stale events from the closing socket are ignored', async () => {
465
+ const { handle, restore } = installMockWebSocket();
466
+ try {
467
+ const client = new WebSocketClient('ws://localhost:9999/ws');
468
+ const errorCb = vi.fn();
469
+ client.onError(errorCb);
470
+
471
+ const connectPromise = client.connect();
472
+ handle.readyState = 1;
473
+ handle.onopen?.(new Event('open'));
474
+ await connectPromise;
475
+
476
+ // Grab the live handlers before disconnect so we can assert they're
477
+ // cleared (not just that the reference on the handle is null, but
478
+ // that the client actually stripped them off).
479
+ const openBefore = handle.onopen;
480
+ const messageBefore = handle.onmessage;
481
+ const errorBefore = handle.onerror;
482
+ const closeBefore = handle.onclose;
483
+ expect(openBefore).not.toBeNull();
484
+ expect(messageBefore).not.toBeNull();
485
+ expect(errorBefore).not.toBeNull();
486
+ expect(closeBefore).not.toBeNull();
487
+
488
+ client.disconnect();
489
+
490
+ // All four handlers must be nulled so stale events from the closing
491
+ // socket can't invoke handleMessage/notifyError post-disconnect.
492
+ expect(handle.onopen).toBeNull();
493
+ expect(handle.onmessage).toBeNull();
494
+ expect(handle.onerror).toBeNull();
495
+ expect(handle.onclose).toBeNull();
496
+
497
+ // Sanity: if we imagine the closing socket firing a stale error,
498
+ // the captured handler was nulled so there's nothing to call. The
499
+ // errorCb subscriber must not have received any post-disconnect
500
+ // notifications.
501
+ expect(errorCb).not.toHaveBeenCalled();
502
+ } finally {
503
+ restore();
504
+ }
505
+ });
506
+ });
507
+
508
+ describe('connect() promise is guarded by settled flag', () => {
509
+ it('should only reject once and notify error once when onerror multi-fires before open', async () => {
510
+ const { handle, restore } = installMockWebSocket();
511
+ try {
512
+ const client = new WebSocketClient('ws://localhost:9999/ws');
513
+ const errorCb = vi.fn();
514
+ client.onError(errorCb);
515
+
516
+ // Start connect and fire onerror multiple times before open.
517
+ const connectPromise = client.connect();
518
+ handle.readyState = 3; // CLOSED
519
+ handle.onerror?.(new Event('error'));
520
+ handle.onerror?.(new Event('error'));
521
+ handle.onerror?.(new Event('error'));
522
+
523
+ await expect(connectPromise).rejects.toThrow('WebSocket error');
524
+
525
+ // Exactly one notifyError despite three onerror events.
526
+ expect(errorCb).toHaveBeenCalledTimes(1);
527
+ } finally {
528
+ restore();
529
+ }
530
+ });
531
+
532
+ it('should swallow post-settle onerror events once open has resolved', async () => {
533
+ const { handle, restore } = installMockWebSocket();
534
+ try {
535
+ const client = new WebSocketClient('ws://localhost:9999/ws');
536
+ const errorCb = vi.fn();
537
+ client.onError(errorCb);
538
+
539
+ const connectPromise = client.connect();
540
+ handle.readyState = 1; // OPEN
541
+ handle.onopen?.(new Event('open'));
542
+ await connectPromise;
543
+
544
+ // Post-settle onerror events should NOT re-notify subscribers. The
545
+ // following onclose (which the server or network will send) still
546
+ // fires notifyConnectionChange(false) so subscribers still learn
547
+ // the socket died -- we're only deduping the error channel.
548
+ handle.onerror?.(new Event('error'));
549
+ handle.onerror?.(new Event('error'));
550
+ expect(errorCb).not.toHaveBeenCalled();
551
+ } finally {
552
+ restore();
553
+ }
554
+ });
555
+ });
556
+
557
+ describe('scheduleReconnect() survives synchronous WebSocket constructor throws', () => {
558
+ beforeEach(() => {
559
+ vi.useFakeTimers();
560
+ });
561
+
562
+ afterEach(() => {
563
+ vi.useRealTimers();
564
+ });
565
+
566
+ it('should schedule the next reconnect when new WebSocket() throws synchronously', async () => {
567
+ // Install a constructor that always throws. This simulates the case
568
+ // where the WebSocket URL is malformed or a security-policy denies
569
+ // construction -- no socket is created, no onclose will ever fire,
570
+ // so connect() must explicitly schedule the next reconnect.
571
+ const { restore } = installMockWebSocket({
572
+ throwOnConstruct: new Error('invalid URL'),
573
+ });
574
+ try {
575
+ const client = new WebSocketClient('ws://localhost:9999/ws');
576
+ const errorCb = vi.fn();
577
+ client.onError(errorCb);
578
+
579
+ // First connect attempt: throws synchronously. The promise rejects,
580
+ // but because we're not intentionalDisconnect and reconnectAttempts
581
+ // is still within limits, a reconnect should be scheduled.
582
+ await expect(client.connect()).rejects.toThrow('Failed to create WebSocket');
583
+
584
+ // At this point a reconnect timer should be pending. Advance time
585
+ // past the first backoff (1000ms for attempt 0) to fire it.
586
+ const WebSocketCtor = globalThis.WebSocket as unknown as ReturnType<typeof vi.fn>;
587
+ const constructCallsBefore = WebSocketCtor.mock.calls.length;
588
+ expect(constructCallsBefore).toBe(1);
589
+
590
+ vi.advanceTimersByTime(1_100);
591
+ // Yield so the async setTimeout callback gets a chance to run the
592
+ // nested connect(), which will also throw.
593
+ await Promise.resolve();
594
+ await Promise.resolve();
595
+
596
+ // The scheduled retry must have attempted to construct a new socket.
597
+ // If the sync-throw catch branch failed to schedule, this count
598
+ // would remain at 1 and the retry chain would be dead.
599
+ expect(WebSocketCtor.mock.calls.length).toBeGreaterThan(constructCallsBefore);
600
+ } finally {
601
+ restore();
602
+ }
603
+ });
604
+ });