@invisibleloop/pulse 0.1.21

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 (344) hide show
  1. package/.claude/commands/build-page.md +59 -0
  2. package/.claude/commands/new-doc-page.md +45 -0
  3. package/.claude/commands/verify.md +52 -0
  4. package/.claude/pulse-checklist.md +111 -0
  5. package/.claude/settings.local.json +102 -0
  6. package/.github/workflows/ci.yml +22 -0
  7. package/.github/workflows/publish.yml +41 -0
  8. package/.pulse/load-reports/home/1773432711417.json +22 -0
  9. package/CLAUDE.md +383 -0
  10. package/README.md +95 -0
  11. package/docs/.claude/pulse-checklist.md +111 -0
  12. package/docs/public/.pulse-ui-version +1 -0
  13. package/docs/public/dist/accessibility.boot-5DVTARJU.js +115 -0
  14. package/docs/public/dist/actions.boot-P66HKQEM.js +164 -0
  15. package/docs/public/dist/auth.boot-IMAJAUPH.js +140 -0
  16. package/docs/public/dist/caching.boot-DVR6KDE7.js +53 -0
  17. package/docs/public/dist/components--accordion.boot-3HVKMNWC.js +11 -0
  18. package/docs/public/dist/components--alert.boot-GCEXOZAC.js +6 -0
  19. package/docs/public/dist/components--app-badge.boot-DVT3GCHJ.js +6 -0
  20. package/docs/public/dist/components--avatar.boot-PSW24EVA.js +5 -0
  21. package/docs/public/dist/components--badge.boot-TYDY2RMK.js +7 -0
  22. package/docs/public/dist/components--banner.boot-EI5PZSZK.js +7 -0
  23. package/docs/public/dist/components--breadcrumbs.boot-SMA2E2GO.js +34 -0
  24. package/docs/public/dist/components--button.boot-J54BQM2E.js +23 -0
  25. package/docs/public/dist/components--card.boot-PZGNDIB6.js +138 -0
  26. package/docs/public/dist/components--carousel.boot-TP6LPFZZ.js +12 -0
  27. package/docs/public/dist/components--charts.boot-2EOYQWKL.js +108 -0
  28. package/docs/public/dist/components--checkbox.boot-DS5BSL6T.js +54 -0
  29. package/docs/public/dist/components--cluster.boot-HHVIBBJG.js +9 -0
  30. package/docs/public/dist/components--code-window.boot-2GR2DV33.js +20 -0
  31. package/docs/public/dist/components--container.boot-7LOOGK2K.js +5 -0
  32. package/docs/public/dist/components--cta.boot-FSNZ5YRT.js +11 -0
  33. package/docs/public/dist/components--divider.boot-3NI2C3QG.js +6 -0
  34. package/docs/public/dist/components--empty.boot-YX2UR3PV.js +7 -0
  35. package/docs/public/dist/components--feature.boot-MUD7NSUO.js +13 -0
  36. package/docs/public/dist/components--fieldset.boot-J7BYHMKF.js +19 -0
  37. package/docs/public/dist/components--fileupload.boot-NIKVTTPD.js +52 -0
  38. package/docs/public/dist/components--footer.boot-EYUK5FRG.js +14 -0
  39. package/docs/public/dist/components--grid.boot-URDQVDDR.js +59 -0
  40. package/docs/public/dist/components--heading.boot-BPQKU43E.js +44 -0
  41. package/docs/public/dist/components--hero.boot-4RAPRGAB.js +17 -0
  42. package/docs/public/dist/components--icons.boot-ZITNU5JP.js +68 -0
  43. package/docs/public/dist/components--image.boot-XEEGHQZF.js +19 -0
  44. package/docs/public/dist/components--input.boot-SGASZG5K.js +7 -0
  45. package/docs/public/dist/components--list.boot-W3XC5MHD.js +55 -0
  46. package/docs/public/dist/components--media.boot-5VFIETZO.js +13 -0
  47. package/docs/public/dist/components--modal.boot-RZUYXBN2.js +47 -0
  48. package/docs/public/dist/components--nav.boot-ODBOHU7O.js +33 -0
  49. package/docs/public/dist/components--pricing.boot-4AQ4ZVBY.js +21 -0
  50. package/docs/public/dist/components--progress.boot-GHAGYZOK.js +30 -0
  51. package/docs/public/dist/components--prose.boot-QANJL6JI.js +67 -0
  52. package/docs/public/dist/components--pullquote.boot-Q2WMNAZU.js +22 -0
  53. package/docs/public/dist/components--radio.boot-TJRDQ2OL.js +75 -0
  54. package/docs/public/dist/components--rating.boot-QBAN6DEL.js +38 -0
  55. package/docs/public/dist/components--search.boot-PXH5O5AG.js +17 -0
  56. package/docs/public/dist/components--section.boot-AQGIYHWW.js +12 -0
  57. package/docs/public/dist/components--segmented.boot-BEVTKEJO.js +33 -0
  58. package/docs/public/dist/components--select.boot-47X5RHOC.js +10 -0
  59. package/docs/public/dist/components--slider.boot-PSRRX7XL.js +47 -0
  60. package/docs/public/dist/components--spinner.boot-MZ5MO2OH.js +22 -0
  61. package/docs/public/dist/components--stack.boot-DI4NJXBF.js +9 -0
  62. package/docs/public/dist/components--stat.boot-QMFUWBQT.js +9 -0
  63. package/docs/public/dist/components--stepper.boot-34PP2NEV.js +22 -0
  64. package/docs/public/dist/components--table.boot-FCQGSFIQ.js +11 -0
  65. package/docs/public/dist/components--testimonial.boot-DWQPDKYG.js +11 -0
  66. package/docs/public/dist/components--textarea.boot-QVXLBOJ5.js +4 -0
  67. package/docs/public/dist/components--timeline.boot-26LN52P2.js +95 -0
  68. package/docs/public/dist/components--toggle.boot-IQQEI76S.js +29 -0
  69. package/docs/public/dist/components--tooltip.boot-LGHCO6NN.js +9 -0
  70. package/docs/public/dist/components.boot-SE6PQ4P7.js +103 -0
  71. package/docs/public/dist/config.boot-DTRRWUE6.js +126 -0
  72. package/docs/public/dist/constraints.boot-DUHDZBMC.js +71 -0
  73. package/docs/public/dist/deploy.boot-SLAD3NI2.js +163 -0
  74. package/docs/public/dist/docs-8e3d4b5c.css +1 -0
  75. package/docs/public/dist/extending.boot-UA3CN243.js +159 -0
  76. package/docs/public/dist/faq.boot-6EQAWLQR.js +43 -0
  77. package/docs/public/dist/getting-started.boot-TDKIFL5U.js +86 -0
  78. package/docs/public/dist/guard.boot-AUHAWTG4.js +80 -0
  79. package/docs/public/dist/home.boot-BVQXRH32.js +383 -0
  80. package/docs/public/dist/how-it-works.boot-LTWAKWKW.js +104 -0
  81. package/docs/public/dist/hydration.boot-JRM6IPJL.js +78 -0
  82. package/docs/public/dist/images.boot-M6ZVKTZS.js +80 -0
  83. package/docs/public/dist/manifest.json +94 -0
  84. package/docs/public/dist/meta.boot-7NXGPHR4.js +79 -0
  85. package/docs/public/dist/mutations.boot-F6F43UDX.js +79 -0
  86. package/docs/public/dist/navigation.boot-AOXWS3ZF.js +57 -0
  87. package/docs/public/dist/performance.boot-C3UPCOBK.js +98 -0
  88. package/docs/public/dist/persist.boot-WT32PQOQ.js +61 -0
  89. package/docs/public/dist/project-structure.boot-FB3LRVJ4.js +63 -0
  90. package/docs/public/dist/prompt-examples.boot-YKR4VDK4.js +31 -0
  91. package/docs/public/dist/pulse-ui-81a85c03.css +1 -0
  92. package/docs/public/dist/raw-responses.boot-M4KA5YXL.js +104 -0
  93. package/docs/public/dist/routing.boot-FNX5FDGH.js +70 -0
  94. package/docs/public/dist/runtime-B73WLANC.js +1 -0
  95. package/docs/public/dist/runtime-KO4BHUQ3.js +49 -0
  96. package/docs/public/dist/runtime-L2HNXIHW.js +59 -0
  97. package/docs/public/dist/runtime-QFURDKA2.js +5 -0
  98. package/docs/public/dist/runtime-UVPXO4IR.js +375 -0
  99. package/docs/public/dist/runtime-VMJA3Z4N.js +10 -0
  100. package/docs/public/dist/runtime-ZJ4FXT5O.js +11 -0
  101. package/docs/public/dist/server-api.boot-K7X3LCFB.js +219 -0
  102. package/docs/public/dist/server-data.boot-Y7HQYC4R.js +157 -0
  103. package/docs/public/dist/slash-commands.boot-V2UV7OW2.js +26 -0
  104. package/docs/public/dist/spec.boot-2WU7ZHCV.js +159 -0
  105. package/docs/public/dist/state.boot-B24GUE3R.js +73 -0
  106. package/docs/public/dist/store.boot-TLIB4XHH.js +150 -0
  107. package/docs/public/dist/streaming.boot-W2DZSMW4.js +80 -0
  108. package/docs/public/dist/stripe.boot-QN3C2GEL.js +164 -0
  109. package/docs/public/dist/supabase.boot-BG4XXLZE.js +303 -0
  110. package/docs/public/dist/testing.boot-6U4WKMTE.js +130 -0
  111. package/docs/public/dist/validation.boot-PQHYGW5B.js +100 -0
  112. package/docs/public/docs.css +2020 -0
  113. package/docs/public/menu.js +83 -0
  114. package/docs/public/pulse-ui.css +2739 -0
  115. package/docs/public/pulse-ui.js +236 -0
  116. package/docs/server.js +192 -0
  117. package/docs/src/lib/component-page.js +47 -0
  118. package/docs/src/lib/highlight.js +255 -0
  119. package/docs/src/lib/layout.js +131 -0
  120. package/docs/src/lib/metrics-store.js +6 -0
  121. package/docs/src/lib/nav.js +159 -0
  122. package/docs/src/lib/stats.js +81 -0
  123. package/docs/src/pages/accessibility.js +157 -0
  124. package/docs/src/pages/actions.js +191 -0
  125. package/docs/src/pages/auth.js +177 -0
  126. package/docs/src/pages/caching.js +95 -0
  127. package/docs/src/pages/components/accordion.js +48 -0
  128. package/docs/src/pages/components/alert.js +35 -0
  129. package/docs/src/pages/components/app-badge.js +41 -0
  130. package/docs/src/pages/components/avatar.js +35 -0
  131. package/docs/src/pages/components/badge.js +36 -0
  132. package/docs/src/pages/components/banner.js +45 -0
  133. package/docs/src/pages/components/breadcrumbs.js +94 -0
  134. package/docs/src/pages/components/button.js +84 -0
  135. package/docs/src/pages/components/card.js +225 -0
  136. package/docs/src/pages/components/carousel.js +72 -0
  137. package/docs/src/pages/components/charts.js +278 -0
  138. package/docs/src/pages/components/checkbox.js +129 -0
  139. package/docs/src/pages/components/cluster.js +47 -0
  140. package/docs/src/pages/components/code-window.js +57 -0
  141. package/docs/src/pages/components/container.js +40 -0
  142. package/docs/src/pages/components/cta.js +53 -0
  143. package/docs/src/pages/components/divider.js +37 -0
  144. package/docs/src/pages/components/empty.js +36 -0
  145. package/docs/src/pages/components/feature.js +60 -0
  146. package/docs/src/pages/components/fieldset.js +65 -0
  147. package/docs/src/pages/components/fileupload.js +127 -0
  148. package/docs/src/pages/components/footer.js +58 -0
  149. package/docs/src/pages/components/grid.js +165 -0
  150. package/docs/src/pages/components/heading.js +107 -0
  151. package/docs/src/pages/components/hero.js +65 -0
  152. package/docs/src/pages/components/icons.js +285 -0
  153. package/docs/src/pages/components/image.js +71 -0
  154. package/docs/src/pages/components/input.js +51 -0
  155. package/docs/src/pages/components/list.js +112 -0
  156. package/docs/src/pages/components/media.js +51 -0
  157. package/docs/src/pages/components/modal.js +111 -0
  158. package/docs/src/pages/components/nav.js +86 -0
  159. package/docs/src/pages/components/pricing.js +68 -0
  160. package/docs/src/pages/components/progress.js +102 -0
  161. package/docs/src/pages/components/prose.js +111 -0
  162. package/docs/src/pages/components/pullquote.js +71 -0
  163. package/docs/src/pages/components/radio.js +194 -0
  164. package/docs/src/pages/components/rating.js +106 -0
  165. package/docs/src/pages/components/search.js +61 -0
  166. package/docs/src/pages/components/section.js +59 -0
  167. package/docs/src/pages/components/segmented.js +121 -0
  168. package/docs/src/pages/components/select.js +45 -0
  169. package/docs/src/pages/components/slider.js +114 -0
  170. package/docs/src/pages/components/spinner.js +73 -0
  171. package/docs/src/pages/components/stack.js +48 -0
  172. package/docs/src/pages/components/stat.js +55 -0
  173. package/docs/src/pages/components/stepper.js +66 -0
  174. package/docs/src/pages/components/table.js +45 -0
  175. package/docs/src/pages/components/testimonial.js +49 -0
  176. package/docs/src/pages/components/textarea.js +31 -0
  177. package/docs/src/pages/components/timeline.js +227 -0
  178. package/docs/src/pages/components/toggle.js +84 -0
  179. package/docs/src/pages/components/tooltip.js +48 -0
  180. package/docs/src/pages/components.js +204 -0
  181. package/docs/src/pages/config.js +193 -0
  182. package/docs/src/pages/constraints.js +99 -0
  183. package/docs/src/pages/deploy.js +233 -0
  184. package/docs/src/pages/extending.js +198 -0
  185. package/docs/src/pages/faq.js +96 -0
  186. package/docs/src/pages/getting-started.js +106 -0
  187. package/docs/src/pages/guard.js +121 -0
  188. package/docs/src/pages/home.js +401 -0
  189. package/docs/src/pages/how-it-works.js +183 -0
  190. package/docs/src/pages/hydration.js +98 -0
  191. package/docs/src/pages/images.js +121 -0
  192. package/docs/src/pages/meta.js +120 -0
  193. package/docs/src/pages/mutations.js +106 -0
  194. package/docs/src/pages/navigation.js +85 -0
  195. package/docs/src/pages/performance.js +157 -0
  196. package/docs/src/pages/persist.js +88 -0
  197. package/docs/src/pages/project-structure.js +90 -0
  198. package/docs/src/pages/prompt-examples.js +186 -0
  199. package/docs/src/pages/raw-responses.js +124 -0
  200. package/docs/src/pages/routing.js +99 -0
  201. package/docs/src/pages/server-api.js +281 -0
  202. package/docs/src/pages/server-data.js +185 -0
  203. package/docs/src/pages/slash-commands.js +55 -0
  204. package/docs/src/pages/spec.js +207 -0
  205. package/docs/src/pages/state.js +101 -0
  206. package/docs/src/pages/store.js +181 -0
  207. package/docs/src/pages/streaming.js +108 -0
  208. package/docs/src/pages/stripe.js +193 -0
  209. package/docs/src/pages/supabase.js +323 -0
  210. package/docs/src/pages/testing.js +198 -0
  211. package/docs/src/pages/validation.js +138 -0
  212. package/examples/contact.js +166 -0
  213. package/examples/counter.js +94 -0
  214. package/examples/dev.server.js +91 -0
  215. package/examples/examples.test.js +394 -0
  216. package/examples/pricing.js +244 -0
  217. package/examples/products.js +191 -0
  218. package/examples/quiz.js +208 -0
  219. package/examples/shared.js +78 -0
  220. package/examples/todos.js +162 -0
  221. package/package.json +75 -0
  222. package/public/.pulse-ui-version +1 -0
  223. package/public/chippy-bird.css +246 -0
  224. package/public/examples/contact.css +119 -0
  225. package/public/examples/counter.css +79 -0
  226. package/public/examples/pricing.css +132 -0
  227. package/public/examples/products.css +100 -0
  228. package/public/examples/quiz.css +200 -0
  229. package/public/examples/todos.css +137 -0
  230. package/public/favicon.ico +0 -0
  231. package/public/log-dashboard.css +383 -0
  232. package/public/pulse-ui.css +2740 -0
  233. package/public/pulse-ui.js +236 -0
  234. package/public/pulse.css +149 -0
  235. package/scripts/build.js +411 -0
  236. package/src/agent/checklist.md +111 -0
  237. package/src/agent/coverage-check.js +66 -0
  238. package/src/agent/guide-components.md +274 -0
  239. package/src/agent/guide-examples.md +54 -0
  240. package/src/agent/guide-routing.md +36 -0
  241. package/src/agent/guide-server.md +258 -0
  242. package/src/agent/guide-spec.md +103 -0
  243. package/src/agent/guide-styles.md +191 -0
  244. package/src/agent/guide.md +979 -0
  245. package/src/agent/identity.md +106 -0
  246. package/src/agent/workflow.md +108 -0
  247. package/src/cli/cli.test.js +82 -0
  248. package/src/cli/dev.js +195 -0
  249. package/src/cli/discover.js +113 -0
  250. package/src/cli/index.js +361 -0
  251. package/src/cli/load-report.js +91 -0
  252. package/src/cli/load-runner.js +121 -0
  253. package/src/cli/report-server.js +723 -0
  254. package/src/cli/report.js +116 -0
  255. package/src/cli/scaffold.archive.js +1371 -0
  256. package/src/cli/scaffold.js +349 -0
  257. package/src/cli/start.js +74 -0
  258. package/src/html.js +19 -0
  259. package/src/mcp/server.js +884 -0
  260. package/src/mcp/validate-worker.js +110 -0
  261. package/src/runtime/image.js +74 -0
  262. package/src/runtime/image.test.js +111 -0
  263. package/src/runtime/index.js +621 -0
  264. package/src/runtime/navigate.js +146 -0
  265. package/src/runtime/runtime.test.js +773 -0
  266. package/src/runtime/ssr.js +464 -0
  267. package/src/runtime/ssr.test.js +421 -0
  268. package/src/runtime/store.js +92 -0
  269. package/src/runtime/toast.js +163 -0
  270. package/src/server/index.js +1386 -0
  271. package/src/server/server.test.js +1248 -0
  272. package/src/spec/schema.js +428 -0
  273. package/src/spec/schema.test.js +291 -0
  274. package/src/store/index.js +102 -0
  275. package/src/store/store.test.js +210 -0
  276. package/src/testing/html.js +283 -0
  277. package/src/testing/index.js +249 -0
  278. package/src/testing/testing.test.js +450 -0
  279. package/src/ui/accordion.js +28 -0
  280. package/src/ui/alert.js +43 -0
  281. package/src/ui/app-badge.js +48 -0
  282. package/src/ui/avatar.js +47 -0
  283. package/src/ui/badge.js +24 -0
  284. package/src/ui/banner.js +26 -0
  285. package/src/ui/breadcrumbs.js +38 -0
  286. package/src/ui/button.js +66 -0
  287. package/src/ui/card.js +34 -0
  288. package/src/ui/carousel.js +59 -0
  289. package/src/ui/charts.js +321 -0
  290. package/src/ui/checkbox.js +65 -0
  291. package/src/ui/cluster.js +44 -0
  292. package/src/ui/code-window.js +39 -0
  293. package/src/ui/container.js +24 -0
  294. package/src/ui/cta.js +37 -0
  295. package/src/ui/divider.js +29 -0
  296. package/src/ui/empty.js +33 -0
  297. package/src/ui/feature.js +33 -0
  298. package/src/ui/fieldset.js +37 -0
  299. package/src/ui/fileupload.js +89 -0
  300. package/src/ui/footer.js +38 -0
  301. package/src/ui/grid.js +36 -0
  302. package/src/ui/heading.js +45 -0
  303. package/src/ui/hero.js +37 -0
  304. package/src/ui/icons.js +161 -0
  305. package/src/ui/index.js +89 -0
  306. package/src/ui/input.js +74 -0
  307. package/src/ui/list.js +36 -0
  308. package/src/ui/media.js +44 -0
  309. package/src/ui/modal.js +80 -0
  310. package/src/ui/nav.js +61 -0
  311. package/src/ui/pricing.js +56 -0
  312. package/src/ui/progress.js +62 -0
  313. package/src/ui/prose.js +29 -0
  314. package/src/ui/pullquote.js +34 -0
  315. package/src/ui/radio.js +102 -0
  316. package/src/ui/rating.js +93 -0
  317. package/src/ui/search.js +77 -0
  318. package/src/ui/section.js +69 -0
  319. package/src/ui/segmented.js +50 -0
  320. package/src/ui/select.js +77 -0
  321. package/src/ui/slider.js +84 -0
  322. package/src/ui/spinner.js +34 -0
  323. package/src/ui/stack.js +36 -0
  324. package/src/ui/stat.js +52 -0
  325. package/src/ui/stepper.js +46 -0
  326. package/src/ui/switch.js +57 -0
  327. package/src/ui/table.js +45 -0
  328. package/src/ui/testimonial.js +48 -0
  329. package/src/ui/textarea.js +72 -0
  330. package/src/ui/timeline.js +72 -0
  331. package/src/ui/tooltip.js +28 -0
  332. package/src/ui/ui.test.js +1241 -0
  333. package/src/ui/uiimage.js +65 -0
  334. package/tsconfig.json +13 -0
  335. package/types/html.d.ts +17 -0
  336. package/types/image.d.ts +70 -0
  337. package/types/index.d.ts +7 -0
  338. package/types/navigate.d.ts +38 -0
  339. package/types/runtime.d.ts +63 -0
  340. package/types/schema.d.ts +243 -0
  341. package/types/server.d.ts +145 -0
  342. package/types/ssr.d.ts +110 -0
  343. package/types/testing.d.ts +154 -0
  344. package/types/ui.d.ts +704 -0
@@ -0,0 +1,773 @@
1
+ /**
2
+ * Pulse 2 — Runtime tests
3
+ * run: node src/runtime/runtime.test.js
4
+ *
5
+ * Uses a minimal DOM shim — no browser, no jsdom dependency.
6
+ */
7
+
8
+ import { mount, validateFields, debounce, throttle } from './index.js'
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Minimal DOM shim
12
+ // ---------------------------------------------------------------------------
13
+
14
+ class FakeElement {
15
+ constructor() {
16
+ this.innerHTML = ''
17
+ this._listeners = {}
18
+ }
19
+
20
+ querySelectorAll(selector) {
21
+ // Parse our own innerHTML to find matching elements
22
+ return parseElements(this.innerHTML, selector)
23
+ }
24
+
25
+ addEventListener() {}
26
+ }
27
+
28
+ /**
29
+ * Tiny HTML parser — finds elements with a given attribute.
30
+ * Good enough for our tests. Not a real DOM parser.
31
+ */
32
+ function parseElements(html, selector) {
33
+ const attr = selector.match(/\[([^\]]+)\]/)?.[1]
34
+ if (!attr) return []
35
+
36
+ const regex = new RegExp(`<[^>]+${attr}="([^"]*)"[^>]*>`, 'g')
37
+ const matches = []
38
+ let m
39
+
40
+ while ((m = regex.exec(html)) !== null) {
41
+ const attrValue = m[1]
42
+ const el = {
43
+ dataset: {},
44
+ _fired: {},
45
+ addEventListener(event, fn) { this._fn = fn; this._event = event },
46
+ dispatchEvent(event, data) { this._fn?.(data) }
47
+ }
48
+
49
+ if (attr === 'data-event') el.dataset.event = attrValue
50
+ if (attr === 'data-action') el.dataset.action = attrValue
51
+ matches.push(el)
52
+ }
53
+
54
+ return matches
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Test runner
59
+ // ---------------------------------------------------------------------------
60
+
61
+ let passed = 0
62
+ let failed = 0
63
+
64
+ function test(label, fn) {
65
+ try {
66
+ fn()
67
+ console.log(` ✓ ${label}`)
68
+ passed++
69
+ } catch (e) {
70
+ console.log(` ✗ ${label}`)
71
+ console.log(` ${e.message}`)
72
+ failed++
73
+ }
74
+ }
75
+
76
+ async function testAsync(label, fn) {
77
+ try {
78
+ await fn()
79
+ console.log(` ✓ ${label}`)
80
+ passed++
81
+ } catch (e) {
82
+ console.log(` ✗ ${label}`)
83
+ console.log(` ${e.message}`)
84
+ failed++
85
+ }
86
+ }
87
+
88
+ function assert(condition, msg) {
89
+ if (!condition) throw new Error(msg || 'Assertion failed')
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Browser globals shim (localStorage, location)
94
+ // ---------------------------------------------------------------------------
95
+
96
+ const _store = {}
97
+ global.localStorage = {
98
+ getItem: (k) => _store[k] ?? null,
99
+ setItem: (k, v) => { _store[k] = v },
100
+ removeItem: (k) => { delete _store[k] },
101
+ }
102
+ global.location = { pathname: '/test' }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Counter spec — used across multiple tests
106
+ // ---------------------------------------------------------------------------
107
+
108
+ const counterSpec = {
109
+ route: '/counter',
110
+ state: { count: 0 },
111
+ constraints: {
112
+ count: { min: 0, max: 10 }
113
+ },
114
+ view: (state) =>
115
+ `<div>
116
+ <button data-event="decrement">-</button>
117
+ <span id="count">${state.count}</span>
118
+ <button data-event="increment">+</button>
119
+ <button data-event="reset">reset</button>
120
+ </div>`,
121
+ mutations: {
122
+ increment: (state) => ({ count: state.count + 1 }),
123
+ decrement: (state) => ({ count: state.count - 1 }),
124
+ reset: () => ({ count: 0 })
125
+ }
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+
130
+ console.log('\nMount & initial render\n')
131
+
132
+ test('renders initial state to element', () => {
133
+ const el = new FakeElement()
134
+ mount(counterSpec, el)
135
+ assert(el.innerHTML.includes('>0<'), `Expected count 0 in HTML, got: ${el.innerHTML}`)
136
+ })
137
+
138
+ test('throws on invalid spec', () => {
139
+ const el = new FakeElement()
140
+ let threw = false
141
+ try { mount({ route: '/bad' }, el) } catch { threw = true }
142
+ assert(threw, 'Expected mount to throw on invalid spec')
143
+ })
144
+
145
+ test('accepts server state', () => {
146
+ const el = new FakeElement()
147
+ const spec = {
148
+ route: '/hello',
149
+ state: {},
150
+ view: (state, server) => `<h1>${server.greeting}</h1>`
151
+ }
152
+ mount(spec, el, { greeting: 'Hello world' })
153
+ assert(el.innerHTML.includes('Hello world'), `Got: ${el.innerHTML}`)
154
+ })
155
+
156
+ // ---------------------------------------------------------------------------
157
+
158
+ console.log('\nMutations\n')
159
+
160
+ test('dispatch applies mutation and re-renders', () => {
161
+ const el = new FakeElement()
162
+ const instance = mount(counterSpec, el)
163
+
164
+ instance.dispatch('increment')
165
+ assert(el.innerHTML.includes('>1<'), `Expected count 1, got: ${el.innerHTML}`)
166
+
167
+ instance.dispatch('increment')
168
+ assert(el.innerHTML.includes('>2<'), `Expected count 2, got: ${el.innerHTML}`)
169
+ })
170
+
171
+ test('mutation receives current state', () => {
172
+ const el = new FakeElement()
173
+ const instance = mount(counterSpec, el)
174
+
175
+ instance.dispatch('increment')
176
+ instance.dispatch('increment')
177
+ instance.dispatch('decrement')
178
+ assert(el.innerHTML.includes('>1<'), `Expected count 1, got: ${el.innerHTML}`)
179
+ })
180
+
181
+ test('reset mutation works', () => {
182
+ const el = new FakeElement()
183
+ const instance = mount(counterSpec, el)
184
+
185
+ instance.dispatch('increment')
186
+ instance.dispatch('increment')
187
+ instance.dispatch('reset')
188
+ assert(el.innerHTML.includes('>0<'), `Expected count 0 after reset, got: ${el.innerHTML}`)
189
+ })
190
+
191
+ test('getState returns deep clone of current state', () => {
192
+ const el = new FakeElement()
193
+ const instance = mount(counterSpec, el)
194
+
195
+ instance.dispatch('increment')
196
+ const state = instance.getState()
197
+ assert(state.count === 1, `Expected count 1, got ${state.count}`)
198
+
199
+ // Mutating returned state should not affect internal state
200
+ state.count = 999
201
+ assert(instance.getState().count === 1, 'getState should return a clone, not a reference')
202
+ })
203
+
204
+ test('warns on unknown mutation', () => {
205
+ const el = new FakeElement()
206
+ const instance = mount(counterSpec, el)
207
+ const warnings = []
208
+ const orig = console.warn
209
+ console.warn = (...args) => warnings.push(args.join(' '))
210
+
211
+ instance.dispatch('nonexistent')
212
+
213
+ console.warn = orig
214
+ assert(warnings.some(w => w.includes('nonexistent')), 'Expected warning for unknown mutation')
215
+ })
216
+
217
+ // ---------------------------------------------------------------------------
218
+
219
+ console.log('\nConstraints\n')
220
+
221
+ test('max constraint prevents count exceeding limit', () => {
222
+ const el = new FakeElement()
223
+ const instance = mount(counterSpec, el)
224
+
225
+ for (let i = 0; i < 15; i++) instance.dispatch('increment')
226
+
227
+ const state = instance.getState()
228
+ assert(state.count === 10, `Expected count capped at 10, got ${state.count}`)
229
+ })
230
+
231
+ test('min constraint prevents count going below 0', () => {
232
+ const el = new FakeElement()
233
+ const instance = mount(counterSpec, el)
234
+
235
+ for (let i = 0; i < 5; i++) instance.dispatch('decrement')
236
+
237
+ const state = instance.getState()
238
+ assert(state.count === 0, `Expected count floored at 0, got ${state.count}`)
239
+ })
240
+
241
+ test('constraints on nested paths', () => {
242
+ const el = new FakeElement()
243
+ const spec = {
244
+ route: '/nested',
245
+ state: { slider: { value: 5 } },
246
+ constraints: { 'slider.value': { min: 0, max: 100 } },
247
+ view: (s) => `<span>${s.slider.value}</span>`,
248
+ mutations: {
249
+ set: (state, val) => ({ slider: { ...state.slider, value: val } })
250
+ }
251
+ }
252
+ const instance = mount(spec, el)
253
+ instance.dispatch('set', 150)
254
+ assert(instance.getState().slider.value === 100, `Expected 100, got ${instance.getState().slider.value}`)
255
+ })
256
+
257
+ // ---------------------------------------------------------------------------
258
+
259
+ console.log('\nActions\n')
260
+
261
+ await testAsync('action run is called and onSuccess updates state', async () => {
262
+ const el = new FakeElement()
263
+ let ran = false
264
+
265
+ const spec = {
266
+ route: '/form',
267
+ state: { status: 'idle' },
268
+ view: (s) => `<form data-action="submit"><span>${s.status}</span></form>`,
269
+ actions: {
270
+ submit: {
271
+ run: async () => { ran = true },
272
+ onSuccess: () => ({ status: 'success' }),
273
+ onError: () => ({ status: 'error' })
274
+ }
275
+ }
276
+ }
277
+
278
+ const instance = mount(spec, el)
279
+ await instance.dispatch('submit')
280
+
281
+ assert(ran, 'Expected action.run to be called')
282
+ assert(instance.getState().status === 'success', `Expected status "success", got "${instance.getState().status}"`)
283
+ })
284
+
285
+ await testAsync('onSuccess receives the return value of run()', async () => {
286
+ const el = new FakeElement()
287
+
288
+ const spec = {
289
+ route: '/api-test',
290
+ state: { items: [] },
291
+ view: (s) => `<div>${JSON.stringify(s.items)}</div>`,
292
+ actions: {
293
+ load: {
294
+ run: async () => [{ id: 1, name: 'transformed' }],
295
+ onSuccess: (state, result) => ({ items: result }),
296
+ onError: () => ({})
297
+ }
298
+ }
299
+ }
300
+
301
+ const instance = mount(spec, el)
302
+ await instance.dispatch('load')
303
+
304
+ const { items } = instance.getState()
305
+ assert(Array.isArray(items) && items.length === 1, `Expected 1 item, got ${items.length}`)
306
+ assert(items[0].name === 'transformed', `Expected transformed data, got ${JSON.stringify(items[0])}`)
307
+ })
308
+
309
+ await testAsync('action onError is called when run throws', async () => {
310
+ const el = new FakeElement()
311
+
312
+ const spec = {
313
+ route: '/form',
314
+ state: { status: 'idle' },
315
+ view: (s) => `<span>${s.status}</span>`,
316
+ actions: {
317
+ submit: {
318
+ run: async () => { throw new Error('network error') },
319
+ onSuccess: () => ({ status: 'success' }),
320
+ onError: () => ({ status: 'error' })
321
+ }
322
+ }
323
+ }
324
+
325
+ const instance = mount(spec, el)
326
+
327
+ const errors = []
328
+ const orig = console.error
329
+ console.error = (...args) => errors.push(args)
330
+
331
+ await instance.dispatch('submit')
332
+
333
+ console.error = orig
334
+ assert(instance.getState().status === 'error', `Expected "error", got "${instance.getState().status}"`)
335
+ })
336
+
337
+ await testAsync('onStart fires immediately before async work', async () => {
338
+ const el = new FakeElement()
339
+ const states = []
340
+
341
+ const spec = {
342
+ route: '/form',
343
+ state: { status: 'idle' },
344
+ view: (s) => `<span>${s.status}</span>`,
345
+ actions: {
346
+ submit: {
347
+ onStart: () => ({ status: 'loading' }),
348
+ run: async () => { states.push('during') },
349
+ onSuccess: () => ({ status: 'done' }),
350
+ onError: () => ({ status: 'error' })
351
+ }
352
+ }
353
+ }
354
+
355
+ const instance = mount(spec, el)
356
+
357
+ // Capture state at each render
358
+ let renderCount = 0
359
+ const origRefresh = instance.refresh
360
+ const capturedStates = []
361
+
362
+ await instance.dispatch('submit')
363
+
364
+ assert(instance.getState().status === 'done', `Final status should be "done", got "${instance.getState().status}"`)
365
+ })
366
+
367
+ await testAsync('validate:true blocks action when fields invalid', async () => {
368
+ const el = new FakeElement()
369
+ let ran = false
370
+
371
+ const spec = {
372
+ route: '/form',
373
+ state: { fields: { email: 'not-an-email' } },
374
+ validation: { 'fields.email': { format: 'email' } },
375
+ view: (s) => `<span>${s.fields.email}</span>`,
376
+ actions: {
377
+ submit: {
378
+ validate: true,
379
+ run: async () => { ran = true },
380
+ onSuccess: () => ({}),
381
+ onError: () => ({})
382
+ }
383
+ }
384
+ }
385
+
386
+ const instance = mount(spec, el)
387
+
388
+ const warnings = []
389
+ const orig = console.warn
390
+ console.warn = (...args) => warnings.push(args.join(' '))
391
+ await instance.dispatch('submit')
392
+ console.warn = orig
393
+
394
+ assert(!ran, 'Expected action.run NOT to be called when validation fails')
395
+ assert(warnings.some(w => w.includes('Validation failed')), 'Expected validation warning')
396
+ })
397
+
398
+ // ---------------------------------------------------------------------------
399
+
400
+ console.log('\nValidation\n')
401
+
402
+ test('required rule catches empty string', () => {
403
+ const errors = validateFields(
404
+ { fields: { name: '' } },
405
+ { 'fields.name': { required: true } }
406
+ )
407
+ assert(errors.length === 1, `Expected 1 error, got ${errors.length}`)
408
+ assert(errors[0].rule === 'required', `Expected required error`)
409
+ })
410
+
411
+ test('email format rule', () => {
412
+ const errors = validateFields(
413
+ { fields: { email: 'notanemail' } },
414
+ { 'fields.email': { format: 'email' } }
415
+ )
416
+ assert(errors.length === 1, `Expected 1 error`)
417
+ assert(errors[0].rule === 'format', `Expected format error`)
418
+ })
419
+
420
+ test('valid email passes', () => {
421
+ const errors = validateFields(
422
+ { fields: { email: 'test@example.com' } },
423
+ { 'fields.email': { format: 'email' } }
424
+ )
425
+ assert(errors.length === 0, `Expected no errors, got ${errors.length}`)
426
+ })
427
+
428
+ test('minLength rule', () => {
429
+ const errors = validateFields(
430
+ { fields: { message: 'hi' } },
431
+ { 'fields.message': { minLength: 10 } }
432
+ )
433
+ assert(errors.length === 1, `Expected 1 error`)
434
+ assert(errors[0].rule === 'minLength')
435
+ })
436
+
437
+ test('maxLength rule', () => {
438
+ const errors = validateFields(
439
+ { fields: { message: 'a'.repeat(1001) } },
440
+ { 'fields.message': { maxLength: 1000 } }
441
+ )
442
+ assert(errors.length === 1, `Expected 1 error`)
443
+ assert(errors[0].rule === 'maxLength')
444
+ })
445
+
446
+ test('multiple rules — all errors returned', () => {
447
+ const errors = validateFields(
448
+ { fields: { name: '', email: 'bad', message: 'hi' } },
449
+ {
450
+ 'fields.name': { required: true },
451
+ 'fields.email': { required: true, format: 'email' },
452
+ 'fields.message': { minLength: 10 }
453
+ }
454
+ )
455
+ assert(errors.length === 3, `Expected 3 errors, got ${errors.length}: ${errors.map(e => e.rule).join(', ')}`)
456
+ })
457
+
458
+ test('no errors on valid state', () => {
459
+ const errors = validateFields(
460
+ { fields: { name: 'Andy', email: 'andy@example.com', message: 'Hello world this is a message' } },
461
+ {
462
+ 'fields.name': { required: true, minLength: 2 },
463
+ 'fields.email': { required: true, format: 'email' },
464
+ 'fields.message': { required: true, minLength: 10, maxLength: 1000 }
465
+ }
466
+ )
467
+ assert(errors.length === 0, `Expected no errors, got: ${errors.map(e => e.message).join(', ')}`)
468
+ })
469
+
470
+ // ---------------------------------------------------------------------------
471
+
472
+ console.log('\nDestroy\n')
473
+
474
+ test('destroy clears the element', () => {
475
+ const el = new FakeElement()
476
+ const instance = mount(counterSpec, el)
477
+ assert(el.innerHTML !== '', 'Should have content after mount')
478
+ instance.destroy()
479
+ assert(el.innerHTML === '', 'Should be empty after destroy')
480
+ })
481
+
482
+ // ---------------------------------------------------------------------------
483
+
484
+ console.log('\nPersistence\n')
485
+
486
+ const persistSpec = {
487
+ route: '/persist-test',
488
+ state: { count: 0, name: 'default' },
489
+ persist: ['count'],
490
+ view: (s) => `<span>${s.count}</span>`,
491
+ mutations: {
492
+ increment: (s) => ({ count: s.count + 1 }),
493
+ }
494
+ }
495
+
496
+ test('persist writes state to localStorage after mutation', () => {
497
+ localStorage.removeItem('pulse:/persist-test')
498
+ const el = new FakeElement()
499
+ const instance = mount(persistSpec, el)
500
+ instance.dispatch('increment')
501
+ const saved = JSON.parse(localStorage.getItem('pulse:/persist-test'))
502
+ assert(saved?.count === 1, `Expected saved count 1, got ${saved?.count}`)
503
+ })
504
+
505
+ test('persist restores state from localStorage on mount', () => {
506
+ localStorage.setItem('pulse:/persist-test', JSON.stringify({ count: 7 }))
507
+ const el = new FakeElement()
508
+ const instance = mount(persistSpec, el)
509
+ assert(instance.getState().count === 7, `Expected restored count 7, got ${instance.getState().count}`)
510
+ })
511
+
512
+ test('persist only saves declared keys, not full state', () => {
513
+ localStorage.removeItem('pulse:/persist-test')
514
+ const el = new FakeElement()
515
+ const instance = mount(persistSpec, el)
516
+ instance.dispatch('increment')
517
+ const saved = JSON.parse(localStorage.getItem('pulse:/persist-test'))
518
+ assert(!('name' in saved), `Should not persist undeclared key "name"`)
519
+ })
520
+
521
+ test('persist re-renders on SSR mount when saved value differs from default', () => {
522
+ localStorage.setItem('pulse:/persist-test', JSON.stringify({ count: 5 }))
523
+ const el = new FakeElement()
524
+ mount(persistSpec, el, {}, { ssr: true })
525
+ assert(el.innerHTML.includes('5'), `Expected restored count 5 in HTML, got: ${el.innerHTML}`)
526
+ })
527
+
528
+ test('persist skips re-render on SSR mount when saved value matches default', () => {
529
+ localStorage.setItem('pulse:/persist-test', JSON.stringify({ count: 0 }))
530
+ const el = new FakeElement()
531
+ el.innerHTML = '<span>server-rendered</span>'
532
+ mount(persistSpec, el, {}, { ssr: true })
533
+ assert(el.innerHTML === '<span>server-rendered</span>', 'Should preserve SSR HTML when state matches default')
534
+ })
535
+
536
+ test('spec without persist does not write to localStorage', () => {
537
+ const key = 'pulse:/counter'
538
+ localStorage.removeItem(key)
539
+ const el = new FakeElement()
540
+ const instance = mount(counterSpec, el)
541
+ instance.dispatch('increment')
542
+ assert(localStorage.getItem(key) === null, 'Should not write to localStorage without persist')
543
+ })
544
+
545
+ // ---------------------------------------------------------------------------
546
+
547
+ console.log('\nStore mutations\n')
548
+
549
+ import { registerStoreMutations, dispatchStoreMutation, getStoreState, updateStore } from './store.js'
550
+
551
+ // Register all mutations once — the singleton ignores subsequent calls (by design)
552
+ registerStoreMutations({
553
+ bump: (store) => ({ count: (store.count ?? 0) + 1 }),
554
+ setTheme: (store, e) => ({ theme: e.target?.value ?? e }),
555
+ inc: (store) => ({ counter: (store.counter ?? 0) + 1 }),
556
+ })
557
+
558
+ test('registerStoreMutations registers mutation functions', () => {
559
+ dispatchStoreMutation('bump')
560
+ assert(getStoreState().count === 1, `Expected count 1, got ${getStoreState().count}`)
561
+ })
562
+
563
+ test('dispatchStoreMutation with payload passes it to the mutation', () => {
564
+ dispatchStoreMutation('setTheme', { target: { value: 'light' } })
565
+ assert(getStoreState().theme === 'light', `Expected theme "light", got ${getStoreState().theme}`)
566
+ })
567
+
568
+ test('dispatchStoreMutation warns on unknown mutation', () => {
569
+ const warnings = []
570
+ const orig = console.warn
571
+ console.warn = (...a) => warnings.push(a.join(' '))
572
+ dispatchStoreMutation('doesNotExist')
573
+ console.warn = orig
574
+ assert(warnings.some(w => w.includes('doesNotExist')), 'Expected warning for unknown store mutation')
575
+ })
576
+
577
+ test('dispatchStoreMutation notifies subscribed pages', () => {
578
+ updateStore({ counter: 0 })
579
+ const el = new FakeElement()
580
+ const spec = {
581
+ route: '/store-sub',
582
+ store: ['counter'],
583
+ state: {},
584
+ view: (state, server) => `<span>${server.counter ?? 0}</span>`,
585
+ }
586
+ mount(spec, el, { counter: 0 })
587
+ const before = el.innerHTML
588
+ dispatchStoreMutation('inc')
589
+ assert(el.innerHTML !== before, `Page should re-render after store mutation`)
590
+ assert(el.innerHTML.includes('1'), `Expected count 1 in HTML, got: ${el.innerHTML}`)
591
+ })
592
+
593
+ test('registerStoreMutations is a no-op after first call', () => {
594
+ const before = getStoreState().count
595
+ registerStoreMutations({ bump: () => ({ count: 999 }) }) // different fn — should be ignored
596
+ dispatchStoreMutation('bump')
597
+ assert(getStoreState().count === before + 1, `Should still use original mutation, got ${getStoreState().count}`)
598
+ })
599
+
600
+ // ---------------------------------------------------------------------------
601
+
602
+ console.log('\nDebounce / throttle\n')
603
+
604
+ await testAsync('debounce: fires once after delay, not on every call', async () => {
605
+ let calls = 0
606
+ const fn = debounce(() => { calls++ }, 20)
607
+ fn(); fn(); fn()
608
+ assert(calls === 0, 'Should not have fired yet')
609
+ await new Promise(r => setTimeout(r, 40))
610
+ assert(calls === 1, `Expected 1 call, got ${calls}`)
611
+ })
612
+
613
+ await testAsync('debounce: resets timer on each call', async () => {
614
+ let calls = 0
615
+ const fn = debounce(() => { calls++ }, 30)
616
+ fn()
617
+ await new Promise(r => setTimeout(r, 15))
618
+ fn() // reset
619
+ await new Promise(r => setTimeout(r, 15))
620
+ assert(calls === 0, 'Should not have fired — timer was reset')
621
+ await new Promise(r => setTimeout(r, 25))
622
+ assert(calls === 1, `Expected 1 call after full delay, got ${calls}`)
623
+ })
624
+
625
+ await testAsync('debounce: passes latest args to handler', async () => {
626
+ let last
627
+ const fn = debounce((v) => { last = v }, 20)
628
+ fn('a'); fn('b'); fn('c')
629
+ await new Promise(r => setTimeout(r, 40))
630
+ assert(last === 'c', `Expected "c", got "${last}"`)
631
+ })
632
+
633
+ await testAsync('throttle: fires immediately on first call', async () => {
634
+ let calls = 0
635
+ const fn = throttle(() => { calls++ }, 50)
636
+ fn()
637
+ assert(calls === 1, `Expected 1 call, got ${calls}`)
638
+ })
639
+
640
+ await testAsync('throttle: suppresses calls within the delay window', async () => {
641
+ let calls = 0
642
+ const fn = throttle(() => { calls++ }, 50)
643
+ fn(); fn(); fn()
644
+ assert(calls === 1, `Expected 1 call, got ${calls}`)
645
+ })
646
+
647
+ await testAsync('throttle: allows another call after delay has passed', async () => {
648
+ let calls = 0
649
+ const fn = throttle(() => { calls++ }, 30)
650
+ fn()
651
+ await new Promise(r => setTimeout(r, 40))
652
+ fn()
653
+ assert(calls === 2, `Expected 2 calls, got ${calls}`)
654
+ })
655
+
656
+ // ---------------------------------------------------------------------------
657
+
658
+ console.log('\nError boundaries\n')
659
+
660
+ test('view error: renders default fallback when view throws', () => {
661
+ const el = new FakeElement()
662
+ const spec = {
663
+ route: '/err',
664
+ state: {},
665
+ view: () => { throw new Error('boom') }
666
+ }
667
+ mount(spec, el)
668
+ assert(el.innerHTML.includes('View error'), `Expected fallback HTML, got: ${el.innerHTML}`)
669
+ assert(el.innerHTML.includes('boom'), `Expected error message in fallback, got: ${el.innerHTML}`)
670
+ })
671
+
672
+ test('view error: calls spec.onViewError when view throws', () => {
673
+ const el = new FakeElement()
674
+ let capturedErr, capturedState
675
+ const spec = {
676
+ route: '/err',
677
+ state: { x: 42 },
678
+ view: () => { throw new Error('oops') },
679
+ onViewError: (err, state) => {
680
+ capturedErr = err
681
+ capturedState = state
682
+ return '<p>custom fallback</p>'
683
+ }
684
+ }
685
+ mount(spec, el)
686
+ assert(el.innerHTML.includes('custom fallback'), `Expected custom fallback, got: ${el.innerHTML}`)
687
+ assert(capturedErr?.message === 'oops', `Expected err.message "oops", got "${capturedErr?.message}"`)
688
+ assert(capturedState?.x === 42, `Expected state.x 42, got ${capturedState?.x}`)
689
+ })
690
+
691
+ test('view error: continues working after recovery — next dispatch re-renders', () => {
692
+ const el = new FakeElement()
693
+ let shouldThrow = true
694
+ const spec = {
695
+ route: '/err',
696
+ state: { ok: false },
697
+ view: (state) => {
698
+ if (shouldThrow) throw new Error('transient')
699
+ return `<p>${state.ok}</p>`
700
+ },
701
+ mutations: { fix: () => ({ ok: true }) }
702
+ }
703
+ const app = mount(spec, el)
704
+ assert(el.innerHTML.includes('View error'), 'Should show fallback initially')
705
+ shouldThrow = false
706
+ app.dispatch('fix')
707
+ assert(el.innerHTML.includes('true'), `Expected recovered render, got: ${el.innerHTML}`)
708
+ })
709
+
710
+ // ---------------------------------------------------------------------------
711
+
712
+ console.log('\nToast (_toast)\n')
713
+
714
+ test('_toast in mutation is stripped from spec state', () => {
715
+ const el = new FakeElement()
716
+ const spec = {
717
+ route: '/t',
718
+ state: { count: 0 },
719
+ view: (state) => `<p>${state.count}${state._toast ?? ''}</p>`,
720
+ mutations: {
721
+ inc: (state) => ({ count: state.count + 1, _toast: { message: 'Done!' } })
722
+ }
723
+ }
724
+ const app = mount(spec, el)
725
+ app.dispatch('inc')
726
+ assert(app.getState().count === 1, 'count should increment')
727
+ assert(app.getState()._toast === undefined, '_toast must not be in state')
728
+ })
729
+
730
+ await testAsync('_toast in onSuccess is stripped from spec state', async () => {
731
+ const el = new FakeElement()
732
+ const spec = {
733
+ route: '/t',
734
+ state: { saved: false },
735
+ view: (state) => `<p>${state.saved}${state._toast ?? ''}</p>`,
736
+ actions: {
737
+ save: {
738
+ run: async () => 'ok',
739
+ onSuccess: () => ({ saved: true, _toast: { message: 'Saved!', variant: 'success' } }),
740
+ onError: () => ({ saved: false, _toast: { message: 'Error', variant: 'error' } }),
741
+ }
742
+ }
743
+ }
744
+ const app = mount(spec, el)
745
+ await app.dispatch('save')
746
+ assert(app.getState().saved === true, 'saved should be true')
747
+ assert(app.getState()._toast === undefined, '_toast must not be in state')
748
+ })
749
+
750
+ await testAsync('_toast in onError is stripped from spec state', async () => {
751
+ const el = new FakeElement()
752
+ const spec = {
753
+ route: '/t',
754
+ state: { failed: false },
755
+ view: (state) => `${state.failed}`,
756
+ actions: {
757
+ save: {
758
+ run: async () => { throw new Error('network') },
759
+ onSuccess: () => ({}),
760
+ onError: () => ({ failed: true, _toast: { message: 'Failed', variant: 'error' } }),
761
+ }
762
+ }
763
+ }
764
+ const app = mount(spec, el)
765
+ await app.dispatch('save')
766
+ assert(app.getState().failed === true, 'failed should be true')
767
+ assert(app.getState()._toast === undefined, '_toast must not be in state')
768
+ })
769
+
770
+ // ---------------------------------------------------------------------------
771
+
772
+ console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
773
+ if (failed > 0) process.exit(1)