sygnal 5.3.7 → 6.0.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 (438) hide show
  1. package/CHANGELOG.md +646 -0
  2. package/README.md +77 -47
  3. package/dist/astro/client.cjs.js +33 -8114
  4. package/dist/astro/client.cjs.js.map +1 -0
  5. package/dist/astro/client.mjs +33 -8114
  6. package/dist/astro/client.mjs.map +1 -0
  7. package/dist/astro/index.cjs.js +1039 -9
  8. package/dist/astro/index.cjs.js.map +1 -0
  9. package/dist/astro/index.mjs +1038 -9
  10. package/dist/astro/index.mjs.map +1 -0
  11. package/dist/astro/server.cjs.js +3400 -166
  12. package/dist/astro/server.cjs.js.map +1 -0
  13. package/dist/astro/server.mjs +3400 -166
  14. package/dist/astro/server.mjs.map +1 -0
  15. package/dist/devtools.cjs.js +1431 -0
  16. package/dist/devtools.cjs.js.map +1 -0
  17. package/dist/devtools.esm.js +1419 -0
  18. package/dist/devtools.esm.js.map +1 -0
  19. package/dist/diagnostics.cjs.js +4033 -0
  20. package/dist/diagnostics.cjs.js.map +1 -0
  21. package/dist/diagnostics.esm.js +4003 -0
  22. package/dist/diagnostics.esm.js.map +1 -0
  23. package/dist/element.cjs.js +230 -0
  24. package/dist/element.cjs.js.map +1 -0
  25. package/dist/element.esm.js +228 -0
  26. package/dist/element.esm.js.map +1 -0
  27. package/dist/guide/accessibility.md +257 -0
  28. package/dist/guide/adapters.md +221 -0
  29. package/dist/guide/behaviors.md +312 -0
  30. package/dist/guide/browser-sources.md +203 -0
  31. package/dist/guide/drag-and-drop.md +342 -0
  32. package/dist/guide/element-commands.md +306 -0
  33. package/dist/guide/error-boundaries.md +169 -0
  34. package/dist/guide/forms-reference.md +381 -0
  35. package/dist/guide/forms.md +250 -0
  36. package/dist/guide/http.md +299 -0
  37. package/dist/guide/inputs.md +173 -0
  38. package/dist/guide/persistence.md +245 -0
  39. package/dist/guide/recipes/carousel.md +155 -0
  40. package/dist/guide/recipes/charts.md +177 -0
  41. package/dist/guide/recipes/code-editor.md +135 -0
  42. package/dist/guide/recipes/data-grid.md +155 -0
  43. package/dist/guide/recipes/data-table.md +196 -0
  44. package/dist/guide/recipes/i18n.md +222 -0
  45. package/dist/guide/recipes/icons.md +115 -0
  46. package/dist/guide/recipes/overview.md +33 -0
  47. package/dist/guide/recipes/rich-text.md +154 -0
  48. package/dist/guide/resources.md +471 -0
  49. package/dist/guide/ssr.md +255 -0
  50. package/dist/guide/timers.md +178 -0
  51. package/dist/guide/ui/accordion.md +93 -0
  52. package/dist/guide/ui/combobox.md +144 -0
  53. package/dist/guide/ui/dialog.md +124 -0
  54. package/dist/guide/ui/disclosure.md +60 -0
  55. package/dist/guide/ui/menu.md +125 -0
  56. package/dist/guide/ui/overview.md +166 -0
  57. package/dist/guide/ui/popover.md +101 -0
  58. package/dist/guide/ui/select.md +114 -0
  59. package/dist/guide/ui/tabs.md +161 -0
  60. package/dist/guide/ui/toaster.md +152 -0
  61. package/dist/guide/ui/tooltip.md +103 -0
  62. package/dist/guide/undo.md +178 -0
  63. package/dist/guide/virtual-collections.md +183 -0
  64. package/dist/guide/web-components.md +179 -0
  65. package/dist/guide/widgets.md +217 -0
  66. package/dist/index.cjs.js +15283 -6279
  67. package/dist/index.cjs.js.map +1 -0
  68. package/dist/index.d.ts +3739 -370
  69. package/dist/index.esm.js +15230 -6274
  70. package/dist/index.esm.js.map +1 -0
  71. package/dist/jsx-dev-runtime.cjs.js +27 -329
  72. package/dist/jsx-dev-runtime.cjs.js.map +1 -0
  73. package/dist/jsx-dev-runtime.esm.js +20 -322
  74. package/dist/jsx-dev-runtime.esm.js.map +1 -0
  75. package/dist/jsx-runtime.cjs.js +27 -329
  76. package/dist/jsx-runtime.cjs.js.map +1 -0
  77. package/dist/jsx-runtime.esm.js +20 -322
  78. package/dist/jsx-runtime.esm.js.map +1 -0
  79. package/dist/jsx.cjs.js +11 -309
  80. package/dist/jsx.cjs.js.map +1 -0
  81. package/dist/jsx.esm.js +9 -310
  82. package/dist/jsx.esm.js.map +1 -0
  83. package/dist/react.cjs.js +117 -0
  84. package/dist/react.cjs.js.map +1 -0
  85. package/dist/react.esm.js +115 -0
  86. package/dist/react.esm.js.map +1 -0
  87. package/dist/shims/globalthis.cjs +20 -0
  88. package/dist/sygnal.min.js +2 -1
  89. package/dist/sygnal.min.js.map +1 -0
  90. package/dist/ui-combobox.cjs.js +169 -0
  91. package/dist/ui-combobox.cjs.js.map +1 -0
  92. package/dist/ui-combobox.esm.js +148 -0
  93. package/dist/ui-combobox.esm.js.map +1 -0
  94. package/dist/ui-menu.cjs.js +87 -0
  95. package/dist/ui-menu.cjs.js.map +1 -0
  96. package/dist/ui-menu.esm.js +66 -0
  97. package/dist/ui-menu.esm.js.map +1 -0
  98. package/dist/ui-select.cjs.js +104 -0
  99. package/dist/ui-select.cjs.js.map +1 -0
  100. package/dist/ui-select.esm.js +83 -0
  101. package/dist/ui-select.esm.js.map +1 -0
  102. package/dist/ui.cjs.js +738 -0
  103. package/dist/ui.cjs.js.map +1 -0
  104. package/dist/ui.esm.js +727 -0
  105. package/dist/ui.esm.js.map +1 -0
  106. package/dist/vike/ClientOnly.cjs.js +5 -14
  107. package/dist/vike/ClientOnly.cjs.js.map +1 -0
  108. package/dist/vike/ClientOnly.mjs +5 -14
  109. package/dist/vike/ClientOnly.mjs.map +1 -0
  110. package/dist/vike/{+config.js → config/+config.js} +10 -1
  111. package/dist/vike/config/+config.js.map +1 -0
  112. package/dist/vike/config/package.json +6 -0
  113. package/dist/vike/onRenderClient.cjs.js +166 -95
  114. package/dist/vike/onRenderClient.cjs.js.map +1 -0
  115. package/dist/vike/onRenderClient.mjs +166 -95
  116. package/dist/vike/onRenderClient.mjs.map +1 -0
  117. package/dist/vike/onRenderHtml.cjs.js +60 -24
  118. package/dist/vike/onRenderHtml.cjs.js.map +1 -0
  119. package/dist/vike/onRenderHtml.mjs +61 -25
  120. package/dist/vike/onRenderHtml.mjs.map +1 -0
  121. package/dist/vite/plugin.cjs.js +926 -65
  122. package/dist/vite/plugin.cjs.js.map +1 -0
  123. package/dist/vite/plugin.mjs +925 -65
  124. package/dist/vite/plugin.mjs.map +1 -0
  125. package/dist/zag.cjs.js +408 -0
  126. package/dist/zag.cjs.js.map +1 -0
  127. package/dist/zag.esm.js +405 -0
  128. package/dist/zag.esm.js.map +1 -0
  129. package/llms.txt +313 -0
  130. package/package.json +109 -16
  131. package/src/astro/client.ts +40 -18
  132. package/src/astro/index.d.ts +48 -1
  133. package/src/astro/index.ts +106 -9
  134. package/src/astro/server.ts +11 -3
  135. package/src/collection.ts +6 -90
  136. package/src/core/actions.ts +134 -0
  137. package/src/core/cell.ts +202 -0
  138. package/src/core/debug.ts +23 -0
  139. package/src/core/define.ts +242 -0
  140. package/src/core/hooks.ts +314 -0
  141. package/src/core/hosts/collection.ts +325 -0
  142. package/src/core/hosts/switchable.ts +146 -0
  143. package/src/core/instance.ts +592 -0
  144. package/src/core/markers/clientonly.ts +13 -0
  145. package/src/core/markers/lazy.ts +40 -0
  146. package/src/core/markers/portal.ts +96 -0
  147. package/src/core/markers/suspense.ts +41 -0
  148. package/src/core/markers/transition.ts +70 -0
  149. package/src/core/registry.ts +25 -0
  150. package/src/core/runtime.ts +573 -0
  151. package/src/core/statics.ts +104 -0
  152. package/src/core/teardown.ts +60 -0
  153. package/src/core/view.ts +37 -0
  154. package/src/cycle/dom/DocumentDOMSource.ts +13 -8
  155. package/src/cycle/dom/ElementFinder.ts +7 -9
  156. package/src/cycle/dom/EventDelegator.ts +174 -223
  157. package/src/cycle/dom/IsolateModule.ts +65 -35
  158. package/src/cycle/dom/MainDOMSource.ts +13 -2
  159. package/src/cycle/dom/PriorityQueue.ts +4 -2
  160. package/src/cycle/dom/SymbolTree.ts +18 -47
  161. package/src/cycle/dom/classNameModule.ts +76 -0
  162. package/src/cycle/dom/controlledInputModule.ts +50 -0
  163. package/src/cycle/dom/enrichEventStream.ts +29 -45
  164. package/src/cycle/dom/fragment.ts +7 -0
  165. package/src/cycle/dom/isolate.ts +30 -27
  166. package/src/cycle/dom/makeDOMDriver.ts +115 -57
  167. package/src/cycle/dom/mockDOMSource.ts +83 -9
  168. package/src/cycle/dom/modules.ts +21 -4
  169. package/src/cycle/dom/propsModule.ts +65 -0
  170. package/src/cycle/dom/selectModule.ts +22 -17
  171. package/src/cycle/dom/snabbdom.ts +1 -6
  172. package/src/cycle/dom/thunk.ts +3 -0
  173. package/src/cycle/dom/utils.ts +98 -0
  174. package/src/cycle/dom/viewTransition.ts +43 -0
  175. package/src/cycle/state/StateSource.ts +20 -4
  176. package/src/cycle/state/objIsEqual.ts +50 -0
  177. package/src/cycle/state/types.ts +0 -10
  178. package/src/defineComponent.ts +34 -0
  179. package/src/devtools.d.ts +151 -0
  180. package/src/devtools.ts +34 -0
  181. package/src/element.d.ts +59 -0
  182. package/src/element.ts +235 -0
  183. package/src/extra/backoff.ts +9 -0
  184. package/src/extra/behaviors.ts +169 -0
  185. package/src/extra/browserSignals.ts +23 -0
  186. package/src/extra/browserSources.ts +326 -0
  187. package/src/extra/command.ts +3 -3
  188. package/src/extra/controls.ts +47 -0
  189. package/src/extra/copyAsTest.ts +286 -0
  190. package/src/extra/devtools.ts +109 -31
  191. package/src/extra/devtoolsActions.ts +379 -0
  192. package/src/extra/devtoolsHook.ts +9 -0
  193. package/src/extra/devtoolsNext.ts +83 -0
  194. package/src/extra/diagnostics/checks/actionLog.ts +108 -0
  195. package/src/extra/diagnostics/checks/behaviors.ts +49 -0
  196. package/src/extra/diagnostics/checks/browserSources.ts +96 -0
  197. package/src/extra/diagnostics/checks/collections.ts +67 -0
  198. package/src/extra/diagnostics/checks/controls.ts +148 -0
  199. package/src/extra/diagnostics/checks/dataset.ts +57 -0
  200. package/src/extra/diagnostics/checks/dom.ts +250 -0
  201. package/src/extra/diagnostics/checks/elementCommands.ts +175 -0
  202. package/src/extra/diagnostics/checks/events.ts +83 -0
  203. package/src/extra/diagnostics/checks/fetch.ts +135 -0
  204. package/src/extra/diagnostics/checks/forms.ts +163 -0
  205. package/src/extra/diagnostics/checks/index.ts +202 -0
  206. package/src/extra/diagnostics/checks/inspect.ts +427 -0
  207. package/src/extra/diagnostics/checks/next.ts +417 -0
  208. package/src/extra/diagnostics/checks/persist.ts +54 -0
  209. package/src/extra/diagnostics/checks/props.ts +56 -0
  210. package/src/extra/diagnostics/checks/public.d.ts +302 -0
  211. package/src/extra/diagnostics/checks/replies.ts +128 -0
  212. package/src/extra/diagnostics/checks/router.ts +100 -0
  213. package/src/extra/diagnostics/checks/rxjsHints.ts +88 -0
  214. package/src/extra/diagnostics/checks/shared.ts +203 -0
  215. package/src/extra/diagnostics/checks/shorthand.ts +115 -0
  216. package/src/extra/diagnostics/checks/sortable.ts +129 -0
  217. package/src/extra/diagnostics/checks/state.ts +148 -0
  218. package/src/extra/diagnostics/checks/statics.ts +48 -0
  219. package/src/extra/diagnostics/checks/strict.ts +55 -0
  220. package/src/extra/diagnostics/checks/timers.ts +80 -0
  221. package/src/extra/diagnostics/checks/viewTransitions.ts +47 -0
  222. package/src/extra/diagnostics/checks/virtual.ts +73 -0
  223. package/src/extra/diagnostics/checks/widgets.ts +109 -0
  224. package/src/extra/diagnostics/checks/wiring.ts +121 -0
  225. package/src/extra/diagnostics/codes.ts +477 -0
  226. package/src/extra/diagnostics/index.ts +353 -0
  227. package/src/extra/diagnostics/legacy.ts +76 -0
  228. package/src/extra/driverFactories.ts +172 -60
  229. package/src/extra/elementCommands.ts +53 -0
  230. package/src/extra/eventDriver.ts +4 -0
  231. package/src/extra/fetchDriver.ts +519 -0
  232. package/src/extra/flatten.ts +75 -0
  233. package/src/extra/focusWithin.ts +27 -0
  234. package/src/extra/form.ts +268 -0
  235. package/src/extra/formHelpers.ts +139 -0
  236. package/src/extra/head.ts +106 -0
  237. package/src/extra/hmr.ts +2 -3
  238. package/src/extra/owned.ts +24 -0
  239. package/src/extra/pager.ts +50 -0
  240. package/src/extra/persist.ts +133 -0
  241. package/src/extra/pwa.ts +1 -1
  242. package/src/extra/queryCache.ts +107 -0
  243. package/src/extra/reducers.ts +13 -6
  244. package/src/extra/reduxDevtools.ts +92 -0
  245. package/src/extra/replies.ts +54 -0
  246. package/src/extra/router.ts +323 -0
  247. package/src/extra/run.ts +94 -210
  248. package/src/extra/selection.ts +76 -0
  249. package/src/extra/socketDriver.ts +265 -0
  250. package/src/extra/sortable.ts +377 -0
  251. package/src/extra/ssr.ts +427 -160
  252. package/src/extra/standardSchema.ts +27 -0
  253. package/src/extra/testing.ts +3158 -190
  254. package/src/extra/timers.ts +95 -0
  255. package/src/extra/undo.ts +261 -0
  256. package/src/extra/viewTransitions.ts +38 -0
  257. package/src/extra/virtual.ts +513 -0
  258. package/src/extra/widget.ts +201 -0
  259. package/src/extra/xstreamExtras.ts +269 -0
  260. package/src/index.d.ts +3012 -87
  261. package/src/index.ts +32 -10
  262. package/src/jsx-runtime.ts +15 -1
  263. package/src/jsx.ts +2 -1
  264. package/src/lazy.ts +50 -7
  265. package/src/portal.ts +3 -1
  266. package/src/pragma/index.ts +262 -134
  267. package/src/react-peers.d.ts +5 -0
  268. package/src/react.d.ts +43 -0
  269. package/src/react.ts +110 -0
  270. package/src/shared.ts +48 -0
  271. package/src/slot.ts +1 -1
  272. package/src/suspense.ts +3 -1
  273. package/src/switchable.ts +6 -119
  274. package/src/transition.ts +3 -1
  275. package/src/ui/accordion.ts +64 -0
  276. package/src/ui/dialog.ts +129 -0
  277. package/src/ui/disclosure.ts +35 -0
  278. package/src/ui/popover.ts +45 -0
  279. package/src/ui/shared.ts +94 -0
  280. package/src/ui/tabs.ts +82 -0
  281. package/src/ui/toaster.ts +198 -0
  282. package/src/ui/tooltip.ts +70 -0
  283. package/src/ui/zag/combobox.ts +115 -0
  284. package/src/ui/zag/menu.ts +40 -0
  285. package/src/ui/zag/select.ts +54 -0
  286. package/src/ui/zag/shared.ts +43 -0
  287. package/src/ui-combobox.d.ts +20 -0
  288. package/src/ui-combobox.ts +6 -0
  289. package/src/ui-menu.d.ts +30 -0
  290. package/src/ui-menu.ts +6 -0
  291. package/src/ui-select.d.ts +17 -0
  292. package/src/ui-select.ts +6 -0
  293. package/src/ui-zag-types.d.ts +42 -0
  294. package/src/ui.d.ts +208 -0
  295. package/src/ui.ts +15 -0
  296. package/src/vike/+config.ts +9 -1
  297. package/src/vike/ClientOnly.ts +1 -1
  298. package/src/vike/onRenderClient.ts +144 -96
  299. package/src/vike/onRenderHtml.ts +69 -26
  300. package/src/vike/types.ts +27 -4
  301. package/src/vite/globalthis-shim.ts +18 -0
  302. package/src/vite/globalthis.ts +56 -0
  303. package/src/vite/plugin.d.ts +114 -2
  304. package/src/vite/plugin.ts +920 -56
  305. package/src/zag.d.ts +64 -0
  306. package/src/zag.ts +238 -0
  307. package/dist/astro/astro/client.d.ts +0 -5
  308. package/dist/astro/astro/index.d.ts +0 -16
  309. package/dist/astro/astro/server.d.ts +0 -12
  310. package/dist/astro/client.d.ts +0 -5
  311. package/dist/astro/client.esm.js +0 -4665
  312. package/dist/astro/collection.d.ts +0 -12
  313. package/dist/astro/component.d.ts +0 -22
  314. package/dist/astro/cycle/dom/BodyDOMSource.d.ts +0 -10
  315. package/dist/astro/cycle/dom/DOMSource.d.ts +0 -11
  316. package/dist/astro/cycle/dom/DocumentDOMSource.d.ts +0 -12
  317. package/dist/astro/cycle/dom/ElementFinder.d.ts +0 -8
  318. package/dist/astro/cycle/dom/EventDelegator.d.ts +0 -34
  319. package/dist/astro/cycle/dom/IsolateModule.d.ts +0 -23
  320. package/dist/astro/cycle/dom/MainDOMSource.d.ts +0 -32
  321. package/dist/astro/cycle/dom/PriorityQueue.d.ts +0 -7
  322. package/dist/astro/cycle/dom/ScopeChecker.d.ts +0 -9
  323. package/dist/astro/cycle/dom/SymbolTree.d.ts +0 -9
  324. package/dist/astro/cycle/dom/VNodeWrapper.d.ts +0 -8
  325. package/dist/astro/cycle/dom/enrichEventStream.d.ts +0 -24
  326. package/dist/astro/cycle/dom/fromEvent.d.ts +0 -6
  327. package/dist/astro/cycle/dom/index.d.ts +0 -8
  328. package/dist/astro/cycle/dom/isolate.d.ts +0 -9
  329. package/dist/astro/cycle/dom/makeDOMDriver.d.ts +0 -11
  330. package/dist/astro/cycle/dom/mockDOMSource.d.ts +0 -17
  331. package/dist/astro/cycle/dom/modules.d.ts +0 -5
  332. package/dist/astro/cycle/dom/snabbdom.d.ts +0 -22
  333. package/dist/astro/cycle/dom/styleModule.d.ts +0 -13
  334. package/dist/astro/cycle/dom/thunk.d.ts +0 -11
  335. package/dist/astro/cycle/dom/utils.d.ts +0 -8
  336. package/dist/astro/cycle/isolate/index.d.ts +0 -42
  337. package/dist/astro/cycle/run/adapt.d.ts +0 -6
  338. package/dist/astro/cycle/run/index.d.ts +0 -36
  339. package/dist/astro/cycle/run/internals.d.ts +0 -8
  340. package/dist/astro/cycle/run/types.d.ts +0 -49
  341. package/dist/astro/cycle/state/Collection.d.ts +0 -29
  342. package/dist/astro/cycle/state/StateSource.d.ts +0 -20
  343. package/dist/astro/cycle/state/index.d.ts +0 -5
  344. package/dist/astro/cycle/state/pickCombine.d.ts +0 -3
  345. package/dist/astro/cycle/state/pickMerge.d.ts +0 -3
  346. package/dist/astro/cycle/state/types.d.ts +0 -18
  347. package/dist/astro/cycle/state/withState.d.ts +0 -17
  348. package/dist/astro/extra/classes.d.ts +0 -7
  349. package/dist/astro/extra/devtools.d.ts +0 -78
  350. package/dist/astro/extra/dragDriver.d.ts +0 -20
  351. package/dist/astro/extra/driverFactories.d.ts +0 -12
  352. package/dist/astro/extra/eventDriver.d.ts +0 -9
  353. package/dist/astro/extra/exactState.d.ts +0 -1
  354. package/dist/astro/extra/hmr.d.ts +0 -17
  355. package/dist/astro/extra/logDriver.d.ts +0 -2
  356. package/dist/astro/extra/processDrag.d.ts +0 -19
  357. package/dist/astro/extra/processForm.d.ts +0 -15
  358. package/dist/astro/extra/run.d.ts +0 -13
  359. package/dist/astro/extra/xstreamCompat.d.ts +0 -5
  360. package/dist/astro/index.d.ts +0 -19
  361. package/dist/astro/index.esm.js +0 -27
  362. package/dist/astro/jsx-dev-runtime.d.ts +0 -1
  363. package/dist/astro/jsx-runtime.d.ts +0 -4
  364. package/dist/astro/jsx.d.ts +0 -2
  365. package/dist/astro/pragma/fn.d.ts +0 -7
  366. package/dist/astro/pragma/index.d.ts +0 -7
  367. package/dist/astro/pragma/is.d.ts +0 -10
  368. package/dist/astro/server.d.ts +0 -12
  369. package/dist/astro/server.esm.js +0 -25
  370. package/dist/astro/switchable.d.ts +0 -7
  371. package/dist/collection.d.ts +0 -12
  372. package/dist/component.d.ts +0 -22
  373. package/dist/cycle/dom/BodyDOMSource.d.ts +0 -10
  374. package/dist/cycle/dom/DOMSource.d.ts +0 -11
  375. package/dist/cycle/dom/DocumentDOMSource.d.ts +0 -12
  376. package/dist/cycle/dom/ElementFinder.d.ts +0 -8
  377. package/dist/cycle/dom/EventDelegator.d.ts +0 -34
  378. package/dist/cycle/dom/IsolateModule.d.ts +0 -23
  379. package/dist/cycle/dom/MainDOMSource.d.ts +0 -32
  380. package/dist/cycle/dom/PriorityQueue.d.ts +0 -7
  381. package/dist/cycle/dom/ScopeChecker.d.ts +0 -9
  382. package/dist/cycle/dom/SymbolTree.d.ts +0 -9
  383. package/dist/cycle/dom/VNodeWrapper.d.ts +0 -8
  384. package/dist/cycle/dom/enrichEventStream.d.ts +0 -24
  385. package/dist/cycle/dom/fromEvent.d.ts +0 -6
  386. package/dist/cycle/dom/index.d.ts +0 -8
  387. package/dist/cycle/dom/isolate.d.ts +0 -9
  388. package/dist/cycle/dom/makeDOMDriver.d.ts +0 -11
  389. package/dist/cycle/dom/mockDOMSource.d.ts +0 -17
  390. package/dist/cycle/dom/modules.d.ts +0 -5
  391. package/dist/cycle/dom/snabbdom.d.ts +0 -22
  392. package/dist/cycle/dom/styleModule.d.ts +0 -13
  393. package/dist/cycle/dom/thunk.d.ts +0 -11
  394. package/dist/cycle/dom/utils.d.ts +0 -8
  395. package/dist/cycle/isolate/index.d.ts +0 -42
  396. package/dist/cycle/run/adapt.d.ts +0 -6
  397. package/dist/cycle/run/index.d.ts +0 -36
  398. package/dist/cycle/run/internals.d.ts +0 -8
  399. package/dist/cycle/run/types.d.ts +0 -49
  400. package/dist/cycle/state/Collection.d.ts +0 -29
  401. package/dist/cycle/state/StateSource.d.ts +0 -20
  402. package/dist/cycle/state/index.d.ts +0 -5
  403. package/dist/cycle/state/pickCombine.d.ts +0 -3
  404. package/dist/cycle/state/pickMerge.d.ts +0 -3
  405. package/dist/cycle/state/types.d.ts +0 -18
  406. package/dist/cycle/state/withState.d.ts +0 -17
  407. package/dist/extra/classes.d.ts +0 -7
  408. package/dist/extra/devtools.d.ts +0 -78
  409. package/dist/extra/dragDriver.d.ts +0 -20
  410. package/dist/extra/driverFactories.d.ts +0 -12
  411. package/dist/extra/eventDriver.d.ts +0 -9
  412. package/dist/extra/exactState.d.ts +0 -1
  413. package/dist/extra/hmr.d.ts +0 -17
  414. package/dist/extra/logDriver.d.ts +0 -2
  415. package/dist/extra/processDrag.d.ts +0 -19
  416. package/dist/extra/processForm.d.ts +0 -15
  417. package/dist/extra/run.d.ts +0 -13
  418. package/dist/extra/xstreamCompat.d.ts +0 -5
  419. package/dist/jsx-dev-runtime.d.ts +0 -1
  420. package/dist/jsx-dev-runtime.js +0 -224
  421. package/dist/jsx-runtime.d.ts +0 -4
  422. package/dist/jsx-runtime.js +0 -224
  423. package/dist/jsx.d.ts +0 -2
  424. package/dist/pragma/fn.d.ts +0 -7
  425. package/dist/pragma/index.d.ts +0 -7
  426. package/dist/pragma/is.d.ts +0 -10
  427. package/dist/switchable.d.ts +0 -7
  428. package/dist/vike/+config.cjs.js +0 -65
  429. package/src/component.ts +0 -2256
  430. package/src/cycle/isolate/index.ts +0 -196
  431. package/src/cycle/run/index.ts +0 -151
  432. package/src/cycle/run/internals.ts +0 -143
  433. package/src/cycle/state/Collection.ts +0 -174
  434. package/src/cycle/state/index.ts +0 -5
  435. package/src/cycle/state/pickCombine.ts +0 -167
  436. package/src/cycle/state/pickMerge.ts +0 -122
  437. package/src/cycle/state/withState.ts +0 -47
  438. package/src/pragma/fn.ts +0 -55
@@ -1,20 +1,361 @@
1
- import {setup} from '../cycle/run/index';
2
- import {withState} from '../cycle/state/index';
3
- import {mockDOMSource} from '../cycle/dom/index';
1
+ import {start as startNext} from '../core/runtime';
2
+ import {mockDOMSource} from '../cycle/dom/mockDOMSource';
3
+ import {makeDOMDriver} from '../cycle/dom/makeDOMDriver';
4
+ import {enrichEventStream} from '../cycle/dom/enrichEventStream';
4
5
  import eventBusDriver from './eventDriver';
5
6
  import logDriver from './logDriver';
6
- import component from '../component';
7
- import xs, {Stream} from './xstreamCompat';
7
+ import {ownedCopy} from './owned';
8
+ import {renderToInnerHtml} from './ssr';
9
+ import {_getDiagnosticsConfig, configureDiagnostics, getDiagnosticsMode, isDiagnosticsEnabled, onDiagnostic, report} from './diagnostics/index';
10
+ import xs from './xstreamCompat';
11
+ import {tagRequest, inScope, makeFetchDriver} from './fetchDriver';
12
+ import {senderOf} from './replies';
13
+ import {makeSocketDriver} from './socketDriver';
14
+ import {makeRouter, paramsOf} from './router';
15
+ import {mergeHead} from './head';
16
+ import {timerDriver} from './timers';
17
+ import {browserDriver} from './browserSources';
18
+ import {makeReplies} from './replies';
19
+ import type {Stream} from 'xstream';
20
+ import type {Diagnostic, DiagnosticsMode} from './diagnostics/index';
21
+ import type {InspectGraph, InspectOptions} from './diagnostics/checks/public';
22
+ import {actionHooks} from './diagnostics/checks/actionLog';
23
+ import type {ActionCause, ActionListener, ActionRecord} from './diagnostics/checks/actionLog';
24
+ import {reportElementCommand, checkSentCommand, NATIVE_COMMAND_NAMES} from './diagnostics/checks/elementCommands';
25
+
26
+ /*
27
+ * (Docs live on these type-only declarations so the TypeScript emit drops
28
+ * them — keeps them out of the published bundle.)
29
+ *
30
+ * renderComponent(Component, options?) runs a component on a minimal runtime
31
+ * (mock DOM, EVENTS, LOG, STATE + any `drivers`).
32
+ *
33
+ * - simulateEvent(selector, type, init?) sends a synthetic DOM event through
34
+ * the mock DOM source, so the component's real intent streams fire
35
+ * (DOM.click('.x'), DOM.select('.x').events('click'), .value(), .data(), ...).
36
+ * The target is the first rendered element matching `selector`; it is
37
+ * delivered to every listener whose selector matches that element or one of
38
+ * its ancestors in the same isolation scope (bubbling), and to
39
+ * DOM.select('document'|'body') listeners. `simulateEvent('document'|'body', ...)`
40
+ * goes to the listeners with exactly that selector. Selectors match the
41
+ * rendered vnode tree like the real DOM: tag, .class, #id, [attr], [attr=v]
42
+ * (^= $= *= ~=), :first-child, :last-child, :only-child, :nth-child(an+b),
43
+ * :nth-last-child, :*-of-type, :not(), ' ' and '>' combinators, ',' lists
44
+ * (G-070); other syntax (:has(), '+', '~', ...) throws at the call. If no
45
+ * rendered element matches yet, the event waits (up to 300ms, re-tried on
46
+ * every render) for one, then targets the first match. If none appears, the
47
+ * test fails (G-070): the error rejects the pending next()/waitForState()/
48
+ * settle(), or is thrown by the next t.* call or dispose(); when nothing is
49
+ * pending and the tree is quiet it is thrown by simulateEvent itself. With
50
+ * `{allowMissing: true}` it is dropped with SYG103 instead (it is never sent
51
+ * to every listener with that selector string, G-049).
52
+ * It also waits until the listeners it would reach are subscribed (a
53
+ * just-mounted child subscribes a few ms after it renders, G-039).
54
+ * `target.value/checked/dataset` default from the element's
55
+ * vnode (as strings, like the DOM) and are overridden by `init`.
56
+ * - simulateAction(name, data?) pushes `{type: name, data}` into the real
57
+ * intent → model pipeline, so every sink of the model entry runs and hooks /
58
+ * diagnostics see the real action name. (Model actions that have no intent
59
+ * stream get one added under their real name; the injected names are listed
60
+ * on the intent object's non-enumerable `__sygnalTestActions` property.)
61
+ * - simulateAction/simulateEvent calls are delivered in call order. Calls made
62
+ * before the component is subscribed are buffered and replayed once it is
63
+ * ready; `await t.ready()` is an explicit sync point. An event that waits
64
+ * (see above) holds the calls after it.
65
+ * - Waiting: waitForState(pred) matches the recorded history too; next(pred)
66
+ * matches only states emitted after the call; both resolve once the whole
67
+ * tree has rendered the state. settle() resolves once nothing is pending.
68
+ * - Diagnostics: `diagnostics` (default 'collect', or the already-active
69
+ * mode when diagnostics are on) is applied with configureDiagnostics. The
70
+ * explicit config from before the first live instance is restored when the
71
+ * last live instance is disposed (overlapping/nested instances are fine). Most runtime checks live
72
+ * in a separate entry: `import 'sygnal/diagnostics'` in the test (or vitest
73
+ * setupFiles) to enable them. Two DOM checks are built in (G-024), since the
74
+ * real-DOM versions can't run on the mock DOM:
75
+ * - SYG104 (warn): an intent selector matches nothing in its component's own
76
+ * scope but matches elements inside a child component / Collection item
77
+ * (parents can't see those events). Checked after every render and when
78
+ * simulateEvent targets such an element; reported once per component +
79
+ * selector.
80
+ * - SYG103 (info): simulateEvent's selector matches no rendered element and
81
+ * no intent listens on it (typo?).
82
+ *
83
+ * ```js
84
+ * const t = renderComponent(Counter, { initialState: { count: 0 } })
85
+ * t.simulateEvent('.inc', 'click')
86
+ * await t.waitForState(s => s.count === 1)
87
+ * t.dispose()
88
+ * ```
89
+ *
90
+ * Implementation notes:
91
+ * - parse() turns a selector into compounds + combinators (cached; unsupported
92
+ * syntax throws); index() records each vnode's parent and element siblings
93
+ * on every render (for '>' and :nth-*); is(v, compound) matches one vnode;
94
+ * matches(sel, chain) matches the last vnode of an ancestor chain; find()
95
+ * returns the root → element chain of the first match.
96
+ * - port(): producer-backed stream; emitting with no listener is a silent drop.
97
+ * - Sinks named in the model without a driver get a no-op driver so their
98
+ * output stays observable. sinkValues: EVENTS entries drop the devtools
99
+ * stamps, PARENT entries are unwrapped from {name, component, value}.
100
+ * G-064: a descendant's model sink with no driver is recorded from the core's onSink
101
+ * hook and sent to the fake (its replies subscribed in onCreate, G-324; removed in
102
+ * onDispose); a passed driver wins.
103
+ * - G-065: ready() arms a cursor (states.length when the component became
104
+ * ready, or at the call once ready) that the next next() starts from; any
105
+ * other t.* call disarms it.
106
+ * - G-053: timing options eventWaitMs / settleMs / timeoutMs. Model next() calls
107
+ * are seen through the core's onNext hook; wait timeouts name them.
108
+ * - Input is buffered until 12ms after the first render. With no render within 30ms
109
+ * (e.g. a model but no initialState), the 12ms start then. dispose() before ready
110
+ * leaves the buffered calls undelivered. D165/D176: an input's reducer runs when it is
111
+ * delivered, so a next() in the same tick starts at the state it causes; the cursor
112
+ * expires at the next macrotask (G-326).
113
+ * - "Rendered by the whole tree" = the root rendered the state and no render,
114
+ * reducer, state or input happened anywhere for 10ms (checked twice; capped at
115
+ * 250ms). Child renders are seen through the core's onRender hook.
116
+ * - dispose() fires the component's DISPOSE action via sinks.__dispose.
117
+ * - E11: fake timers (vi.useFakeTimers(), Jest's modern timers). The harness's own timers are
118
+ * faked with the app's, and its time is the clock's (clockNow). ready()/next()/
119
+ * waitForState()/settle() drive the clock (drive(): nextAsync until the wait settles), so
120
+ * they resolve without the test advancing it; their timeouts are clock time.
121
+ * - E4 `dom: 'real'`: the DOM driver is run()'s makeDOMDriver on a fresh container in
122
+ * document.body; trackSource() wraps its sources so events() calls / subscriptions feed the
123
+ * same listener registry (paths with '.___scope' segments, `_hub`/`_path` like the mock).
124
+ * Real isolation keeps scopes in vnode data.isolate, not in the sel (scopeOfV reads both).
125
+ * simulateEvent finds the element with querySelector (container + mounted Portal content),
126
+ * maps it back to its vnode chain via vnode.elm (G-039 waits, SYG104), and fire()s a real
127
+ * event. The pump runs a macrotask after a render (the patch is a microtask after the sink
128
+ * emits) and each input waits for a QUIET_MS-quiet tree (capped at 100ms).
129
+ * - 4-A1 real-mode waits: the driver's vnode input is gated. Each emitted tree is tagged with
130
+ * the number of states recorded when a view in the tree last ran (viewTag; a render
131
+ * follows the state in the next flush, a microtask later). A wait that matches holds renders of later states, resolves
132
+ * once its state is patched (and the tree is quiet, or a later state arrived), and releases
133
+ * the held render on the next macrotask, so the code after `await` reads the DOM of the
134
+ * state it got. The next next() starts after that state (`shown`), so the held states still
135
+ * match it. ready() resolves after the first patch; query()/queryAll()/html() before the
136
+ * first render throw (G-125).
137
+ * - PLAN-4 2-C (GS-10) t.actions: ./diagnostics/checks/actionLog's actionHooks, a layer of the
138
+ * app's hooks (onAction, wrapHandler), so nothing is in the core. simulateAction() dispatches
139
+ * through the runtime API with the cause 'simulateAction'. t.explain() pairs each action whose
140
+ * STATE reducer produced a value with the next recorded root state.
141
+ * - SYG103/104: the mock DOM source reports each events() call (selector path,
142
+ * isolation scopes included as '.___scope'); the onCreate hook maps each
143
+ * component's innermost scope to its name. The nearest '.___'
144
+ * class on a vnode or its ancestors is the scope that owns it.
145
+ */
146
+
147
+ export interface SimulatedEventInit {
148
+ /** Merged into `event.target` (value, checked, dataset, ...) */
149
+ target?: Record<string, any>;
150
+ /** Shorthand for target.value */
151
+ value?: any;
152
+ /** Shorthand for target.checked */
153
+ checked?: boolean;
154
+ /** Merged into target.dataset (values become strings, like the DOM) */
155
+ dataset?: Record<string, any>;
156
+ /** Alias for dataset */
157
+ data?: Record<string, any>;
158
+ /** Keyboard key (e.key) */
159
+ key?: string;
160
+ /**
161
+ * Don't fail when the selector matches no rendered element: wait up to 300ms for it, then
162
+ * drop the event with SYG103 (info). Not copied onto the event.
163
+ */
164
+ allowMissing?: boolean;
165
+ /** Any other event properties are copied onto the event */
166
+ [prop: string]: any;
167
+ }
168
+
169
+ /**
170
+ * E2 / PLAN-3 1-C: which request a t.respond()/t.fail() answers, as options. An object with
171
+ * only these keys is options; any other object is a request pattern compared by value.
172
+ */
173
+ export interface FakeReplyOptions {
174
+ /** Only requests with this category */
175
+ category?: string;
176
+ /**
177
+ * The request, compared by value: a request object (among equal pending requests that very
178
+ * object, else the newest), a partial request, a URL string, or a predicate. `null`: push
179
+ * the value without a request (for a source that emits on its own); `category` then sets
180
+ * its category.
181
+ */
182
+ request?: any;
183
+ /** Response status (respond: default 200), or fail(): an HTTP error response with this status */
184
+ status?: number;
185
+ /** fail(): the parsed error body */
186
+ body?: any;
187
+ /**
188
+ * 6-B: that very request by its position in t.requests(name) (counting only those matching
189
+ * `request`/`category`): 0 the first, -1 the newest; throws at the call when it isn't pending
190
+ */
191
+ nth?: number;
192
+ }
193
+ /** E2 / PLAN-3 1-C: a t.respond()/t.fail() target */
194
+ export type FakeReplyTarget = string | FakeReplyOptions | Record<string, any> | ((request: any) => boolean);
195
+
196
+ /**
197
+ * PLAN-3 2-C: a connection declared on a fake socket source (t.connections(name)): the spec as
198
+ * declared (`socket` or `sse`, action names, reconnect...) plus where and what it is.
199
+ */
200
+ export interface FakeConnection {
201
+ /** The connection's name in `{ connections: { [name]: spec } }` */
202
+ name: string;
203
+ /** The URL as declared (`socket` connections) */
204
+ socket?: string;
205
+ /** The URL as declared (`sse` connections) */
206
+ sse?: string;
207
+ /** The URL the socket was opened with (a socket path resolves to ws:/wss: on the page's host) */
208
+ url: string;
209
+ /** 'closed': dropped (t.drop), waiting for a retry or (reconnect: false) gone */
210
+ state: 'connecting' | 'open' | 'closed';
211
+ /** The name of the component that declared it */
212
+ sender: string;
213
+ [key: string]: any;
214
+ }
215
+ /**
216
+ * PLAN-3 2-C: which connections a t.open/t.push/t.drop acts on: a connection name or URL (as
217
+ * declared or as opened), a partial FakeConnection compared by value (`{ socket: '/ws/a' }`),
218
+ * or a predicate. Nothing: the newest one that can take the call.
219
+ */
220
+ export type FakeConnectionTarget = string | Record<string, any> | ((connection: FakeConnection) => boolean);
8
221
 
9
222
  export interface RenderOptions {
10
223
  /** Override or provide initial state (defaults to component's .initialState) */
11
224
  initialState?: any;
225
+ /**
226
+ * D214: context from the ancestors the rendered component would have, for testing a child
227
+ * alone: `renderComponent(Greeting, { context: { lang: 'fr' } })`. The view, reducers and
228
+ * descendants read it as `context.lang`; the component's own `.context` entries win over a key
229
+ * of the same name. Fixed values for the test's lifetime.
230
+ */
231
+ context?: Record<string, any>;
12
232
  /** Mock DOM configuration — maps selectors to event streams */
13
233
  mockConfig?: Record<string, any>;
14
234
  /** Additional drivers beyond DOM, EVENTS, STATE, and LOG */
15
235
  drivers?: Record<string, any>;
236
+ /**
237
+ * Diagnostics mode while the component is rendered. Default: 'collect', or
238
+ * the current mode when diagnostics are already on (e.g. 'error' set in a
239
+ * setup file). Restored on dispose(). SYG103/SYG104 (selector typos and
240
+ * parent selectors that only match inside child components) are checked
241
+ * built in; the other checks require `import 'sygnal/diagnostics'`.
242
+ */
243
+ diagnostics?: DiagnosticsMode;
244
+ /**
245
+ * Strict mode (canonical-form checks SYG501/502/504) while the component is
246
+ * rendered; restored on dispose(). Requires `import 'sygnal/diagnostics'`.
247
+ * Default: unchanged (off unless configureStrict(true) was called).
248
+ */
249
+ strict?: boolean;
250
+ /**
251
+ * settle()'s quiet window in ms (default 20): settle() resolves once nothing in the tree has
252
+ * rendered, reduced or changed state for this long. A model `next('X', data, ms)` with a
253
+ * longer delay fires after settle() resolved; raise this, or wait with t.next(pred).
254
+ */
255
+ settleMs?: number;
256
+ /** How long simulateEvent waits for a matching element / its listeners, in ms (default 300) */
257
+ eventWaitMs?: number;
258
+ /** Default timeout of next(), waitForState() and settle(), in ms (default 2000) */
259
+ timeoutMs?: number;
260
+ /**
261
+ * E4: 'mock' (default) renders into the mock DOM. 'real' patches the tree into a real
262
+ * container element (needs a DOM: Vitest `environment: 'jsdom'` or 'happy-dom'), so
263
+ * `checked`, `value`, `disabled`, focus (`document.activeElement`), refs and Portals are
264
+ * real. simulateEvent then dispatches a real DOM event on the first matching element;
265
+ * `t.container`, `t.query(sel)` and `t.queryAll(sel)` return real elements.
266
+ */
267
+ dom?: 'mock' | 'real';
268
+ /**
269
+ * PLAN-3 2-C: fake socket connections (a sink with no driver that gets `{ connections }`)
270
+ * open by themselves (default true), reconnects included. false: they stay 'connecting' until
271
+ * t.open(), for "Connecting…" assertions and failures to open (t.drop on a connecting one).
272
+ */
273
+ autoConnect?: boolean;
274
+ /** PLAN-3 G-160: the driverless sink that receives the `connections` static (default 'WS') */
275
+ socketSink?: string;
276
+ /**
277
+ * PLAN-3 3-A (exp): the driverless sink that receives the `resources` static (default
278
+ * 'HTTP'). Its fake (5-1: makeFetchDriver over an in-memory fetch) keeps each resource fetch
279
+ * pending until t.respond / t.fail (target: the resource name, its URL or a partial request)
280
+ * answers it; t.requests lists it as `{ url, ...request, resource: name }` (5-5: a `{ prefetch }`
281
+ * fetch as `{ url, ...request, prefetch: true }`).
282
+ */
283
+ resourceSink?: string;
284
+ /**
285
+ * PLAN-3 5-3: options for the HTTP fakes' makeFetchDriver (all but `fetch`), e.g.
286
+ * `{ cache: queryCache() }` or `{ cache: queryCache({ staleTime: 30000 }), retry: 2 }` (D88; a
287
+ * seeded cache: `queryCache({ initial: snapshot })`). Focus / reconnect refetches
288
+ * come only from t.focus() / t.online(), never from the test's window.
289
+ */
290
+ http?: Record<string, any>;
291
+ /**
292
+ * PLAN-3 5-4c: the app's router (the object makeRouter() returns). With no driver for
293
+ * `routerSink`, its real driver runs over an in-memory window (location, history with async
294
+ * popstate, document listeners): t.navigate / t.back / t.forward / t.location / t.sent. Required
295
+ * when the component declares `route` (else renderComponent throws, naming this option).
296
+ */
297
+ router?: any;
298
+ /** PLAN-3 5-4c: the router fake's start URL (default '/') */
299
+ url?: string;
300
+ /** PLAN-3 5-4c: the sink the router fake serves (default 'ROUTER') */
301
+ routerSink?: string;
302
+ /** PLAN-3 5-4c: run the router's scroll handling in the fake (default false; positions are kept in memory) */
303
+ routerScroll?: boolean;
304
+ /** PLAN-3 5-4c: run the router's focus handling (default false; true: the router's own selector; a string: selectors) */
305
+ routerFocus?: boolean | string;
306
+ /** PLAN-3 5-4c: the sink the HEAD fake serves (default 'HEAD'); t.head() reads it */
307
+ headSink?: string;
308
+ /** PLAN-4 GS-7: the sink the timer fake serves (default 'TIMER'; the real makeTimerDriver()); t.timers() reads it */
309
+ timerSink?: string;
310
+ /** PLAN-5 B-3: the sink the browser fake serves (default 'BROWSER'); with no driver for it, the real browser driver runs over fake sources that t.browser drives */
311
+ browserSink?: string;
312
+ /** PLAN-5 B-3: the browser fake's environment at start (default: no media query matches, empty storage, visible, online, empty clipboard, no position, nothing denied) */
313
+ browser?: BrowserFakeOptions;
314
+ /** PLAN-3 5-4c: the HEAD fake's titleTemplate ('%s · App'), as makeHeadDriver's */
315
+ titleTemplate?: string;
316
+ /** PLAN-4 GS-11: the app-level error hook, as run()'s `onError` option */
317
+ onError?: (error: any, info: {componentName?: string; action?: string; phase: string; driver?: string}) => void;
318
+ /**
319
+ * PLAN-4 GS-5: the fake storage behind a root's `persist()` ('local' and 'session' alike), as
320
+ * key -> stored entry (`{ version, state }`, or a raw string). It is used as is, not copied:
321
+ * writes land in it, and two renderComponent calls given the same object share one storage
322
+ * (`sync: true` then applies one's writes in the other). Default: a new empty object.
323
+ */
324
+ storage?: Record<string, any>;
16
325
  }
17
326
 
327
+ /**
328
+ * PLAN-4 2-C (GS-10): one action in t.actions. `sinks` fills in as the action's reducers run
329
+ * (STATE a microtask later), so the entry is live.
330
+ */
331
+ export interface TestAction {
332
+ /** The action name (a behavior's actions are namespaced, `pager.NEXT`) */
333
+ type: string;
334
+ /** Its data (the DOM event for a DOM intent stream) */
335
+ data: any;
336
+ /** The name of the component that ran it */
337
+ component: string;
338
+ /** That component instance's id (stable for its life; inspect()'s component id) */
339
+ instance: string;
340
+ /** The sinks that produced a value for it: not ABORT; STATE not the unchanged state; EFFECT when it ran */
341
+ sinks: string[];
342
+ /** Where it came from */
343
+ cause: ActionCause;
344
+ /** ms since renderComponent() was called (the fake clock under fake timers) */
345
+ at: number;
346
+ }
347
+ export type {ActionCause};
348
+ /** PLAN-4 2-C: what t.explain() returns: the action, the root state it produced, and its STATE reducer */
349
+ export interface ExplainedAction extends TestAction {
350
+ /** The first recorded root state after the action's STATE reducer ran */
351
+ state: any;
352
+ /** The STATE reducer: the model's function and its source text (JS exposes no source location) */
353
+ reducer?: {action: string; sink: string; fn: Function; source: string};
354
+ }
355
+
356
+ /** PLAN-3 5-4c: what t.navigate takes: an href, or a route command */
357
+ export type FakeNavigateTarget = string | {to: string; params?: Record<string, any>; query?: Record<string, any>; hash?: string; replace?: boolean};
358
+
18
359
  export interface RenderResult {
19
360
  /** Stream of state values */
20
361
  state$: Stream<any>;
@@ -26,234 +367,2818 @@ export interface RenderResult {
26
367
  sinks: Record<string, any>;
27
368
  /** All source objects by driver name */
28
369
  sources: Record<string, any>;
29
- /** Push an action directly into the intent→model pipeline */
370
+ /** Push an action into the intent→model pipeline (all sinks of the entry run) */
30
371
  simulateAction: (actionName: string, data?: any) => void;
31
- /** Wait for state to satisfy a predicate (resolves with the matching state) */
372
+ /** Dispatch a synthetic DOM event through the mock DOM source */
373
+ simulateEvent: (selector: string, eventType: string, eventInit?: SimulatedEventInit) => void;
374
+ /**
375
+ * Resolves once the component is subscribed and rendered (earlier calls are buffered and
376
+ * replayed). Also a cursor (G-065): the first next() after `await t.ready()` also matches the
377
+ * states the replayed calls produced, unless another t.* call came in between.
378
+ */
379
+ ready: () => Promise<void>;
380
+ /**
381
+ * Wait for a state that satisfies the predicate, searching the HISTORY first: a state
382
+ * recorded before the call matches too (e.g. `count === 0` right after a reset resolves
383
+ * at once with the initial state). Resolves with the matching state once the whole tree
384
+ * (children included) has rendered it. Use next() to match only new states.
385
+ */
32
386
  waitForState: (predicate: (state: any) => boolean, timeoutMs?: number) => Promise<any>;
387
+ /**
388
+ * Wait for the next state emitted AFTER this call (or, right after `await t.ready()`, after
389
+ * the component became ready) that satisfies the predicate (default: any). Resolves with it
390
+ * once the whole tree (children included) has rendered it. The timeout error names a model
391
+ * next() still scheduled, and a recorded state that already matched. `dom: 'real'` (4-A1):
392
+ * right after another wait (no input in between) it starts after that wait's state.
393
+ */
394
+ next: (predicate?: (state: any) => boolean, timeoutMs?: number) => Promise<any>;
395
+ /**
396
+ * Resolves when the component is quiet: ready, no simulated input pending, and no render,
397
+ * reducer or state change anywhere in the tree for `settleMs` (default 20; covers a model
398
+ * next()'s default 10ms delay). Rejects after timeoutMs (default 2000) if it never calms down.
399
+ */
400
+ settle: (timeoutMs?: number) => Promise<void>;
33
401
  /** Collected state values — grows as new states are emitted */
34
402
  states: any[];
35
- /** Tear down the component and clean up all listeners */
403
+ /**
404
+ * PLAN-4 2-C (GS-10): every action the rendered tree ran (children and Collection items
405
+ * included), in order: `{ type, data, component, instance, sinks, cause, at }`. cause is
406
+ * 'intent' | 'next' | 'reply' | 'built-in' | 'simulateAction' | 'behavior'. Live array.
407
+ */
408
+ actions: TestAction[];
409
+ /**
410
+ * PLAN-4 2-C: the first action whose resulting root state matches `predicate`, with that state
411
+ * and its STATE reducer (function and source text); undefined when none did.
412
+ */
413
+ explain: (predicate: (state: any) => boolean) => ExplainedAction | undefined;
414
+ /** G-125: the latest recorded state (`states.at(-1)`; undefined before the first). Read-only */
415
+ readonly state: any;
416
+ /**
417
+ * Live array of values emitted on a sink (EVENTS, PARENT, custom drivers, ...). A custom sink
418
+ * with no driver is recorded for every component in the tree (children included, G-064).
419
+ */
420
+ sinkValues: (sinkName: string) => any[];
421
+ /**
422
+ * E2: the requests sent to a sink (live), as objects (G-171: a string is `{ url }`, a resource
423
+ * fetch `{ url, ...request, resource: name }`); never `{ abort }` commands (G-141), `{ resources }`
424
+ * declarations or `{ refresh }` commands. Answer the pending ones of a driverless sink with
425
+ * respond() / fail().
426
+ */
427
+ requests: (sinkName: string) => any[];
428
+ /**
429
+ * E2 / PLAN-3 1-C / 5-1: answer a pending request on a fake source (a sink/source with no
430
+ * driver, e.g. `HTTP` with no `drivers: { HTTP }`: makeFetchDriver over an in-memory fetch) with
431
+ * a response whose body is `value` (JSON; text for a string): a request with reply actions
432
+ * (`ok: 'LOADED'`) gets the parsed body as its LOADED action, on exactly its sender; a plain one
433
+ * `{ category, value, status, request }` on `select()`. The request: the newest pending one
434
+ * matching `target` (an ok/error action name, key, category, resource name or URL; a partial
435
+ * request compared by value with its t.requests form; a predicate; FakeReplyOptions), or the
436
+ * newest pending one. Throws at the call when
437
+ * none matches, unless input is still queued before it or the component isn't ready: then
438
+ * it waits up to 1s (half of timeoutMs if lower) for one. Resolves after the reply has been
439
+ * reduced and rendered; rejects (and, un-awaited, fails the next wait) otherwise.
440
+ */
441
+ respond: (sinkName: string, value: any, target?: FakeReplyTarget) => Promise<void>;
442
+ /**
443
+ * E2 / PLAN-3 1-C: fail a pending request (chosen as in respond()): one with reply actions
444
+ * (`error: 'FAILED'`) gets `{ error, request, status?, body? }` as its FAILED action; an
445
+ * plain one `{ error, category, request, status, body }` on `errors()`. `error` may be an HTTP
446
+ * status number or `{ status }` (an error response: the driver's Error 'HTTP 404: url' with
447
+ * `status`/`body`), or an Error / message (a network failure: the fetch rejects with it).
448
+ */
449
+ fail: (sinkName: string, error: any, target?: FakeReplyTarget) => Promise<void>;
450
+ /**
451
+ * PLAN-3 2-C: a sink with no driver that gets `{ connections }` / `{ to }` values behaves like
452
+ * makeSocketDriver (reply actions for open/message/close/error, diffed per component and name, shared by
453
+ * URL, reconnect per spec on the test's timers). The connections declared now, in order.
454
+ */
455
+ connections: (sinkName: string) => FakeConnection[];
456
+ /**
457
+ * PLAN-3 5-3: the cache entries of an HTTP fake (renderComponent(C, { http: { cache: queryCache() } })):
458
+ * `{ key, age, stale, subscribers, data, tags }` each (age in ms; undefined before data arrives);
459
+ * [] without a queryCache
460
+ */
461
+ cache: (sinkName: string) => Array<{key: string; age?: number; stale: boolean; subscribers: number; data: any; tags?: string[]}>;
462
+ /** PLAN-3 5-3: the window regains focus (queued like simulate*): stale mounted resources refetch (cache on) */
463
+ focus: () => void;
464
+ /** PLAN-3 5-3: the browser goes back online (queued like simulate*): stale mounted resources refetch (cache on) */
465
+ online: () => void;
466
+ /** PLAN-3 2-C: complete the open of connecting connection(s) (`autoConnect: false`, or a pending reconnect): `open` fires */
467
+ open: (sinkName: string, target?: FakeConnectionTarget) => Promise<void>;
468
+ /**
469
+ * PLAN-3 2-C: the server sends `data` (objects as JSON text) on the open connection(s): `message`
470
+ * fires (the data JSON-parsed when it parses). `{ event, connection? }`: an SSE named event.
471
+ */
472
+ push: (sinkName: string, data: any, target?: FakeConnectionTarget | {event?: string; connection?: FakeConnectionTarget}) => Promise<void>;
473
+ /**
474
+ * PLAN-3 2-C: the connection(s) close without the app closing them (a connecting one fails to
475
+ * open: `error` first): `close` fires with `{ code, reason, willReconnect }` and the fake
476
+ * reconnects per the spec. `close` defaults to `{ code: 1006, reason: '' }`.
477
+ */
478
+ drop: (sinkName: string, close?: {code?: number; reason?: string} | FakeConnectionTarget, target?: FakeConnectionTarget) => Promise<void>;
479
+ /** PLAN-3 2-C: the `{ to, json | text | binary }` values the components sent (live; with `to`: those to that connection) */
480
+ sent: (sinkName: string, to?: string) => any[];
481
+ /**
482
+ * PLAN-3 5-4c (router fake): navigate as a link click (a URL) or a command (`{ to, params,
483
+ * query?, hash?, replace? }`) would, through `block`. Throws at the call for an unknown route,
484
+ * a missing param or another origin; resolves once reduced and rendered.
485
+ */
486
+ navigate: (target: FakeNavigateTarget) => Promise<void>;
487
+ /** PLAN-3 5-4c: the browser's back button on the in-memory history (throws with no entry to go back to) */
488
+ back: () => Promise<void>;
489
+ /** PLAN-3 5-4c: the browser's forward button */
490
+ forward: () => Promise<void>;
491
+ /** PLAN-3 5-4c: the in-memory location */
492
+ readonly location: {path: string; search: string; hash: string; href: string};
493
+ /** PLAN-3 5-4c: the HEAD fake's merged head (titleTemplate applied) */
494
+ head: () => {title: string | undefined; meta: Record<string, any>; link: any[]};
495
+ /** PLAN-4 GS-7: the timer fake's active timers, in start order: `{ name, every | after | frame, action, background?, component }` */
496
+ timers: () => Array<Record<string, any>>;
497
+ /** PLAN-5 B-3: the browser fake's controls (see BrowserFake) */
498
+ browser: BrowserFake;
499
+ /** PLAN-4 GS-5: the fake storage's entry for `key` (`{ version, state }`), undefined when none. Pending writes are flushed by t.settle() */
500
+ storage: (key: string) => any;
501
+ /** Live array of EVENTS sink emissions ({type, data}) */
502
+ emitted: any[];
503
+ /** Live array of diagnostics reported while rendered */
504
+ diagnostics: Diagnostic[];
505
+ /**
506
+ * PLAN-4 GS-2: the element commands (`ELEMENT`) the tree's instances sent, one entry per
507
+ * command (arrays flattened), as sent: `[{ focus: Email }]` (a sygnal/ui dialog / popover
508
+ * command as its selector: `{ showModal: '.profile' }`). The mock DOM only records them;
509
+ * `dom: 'real'` also runs them. Any other sink name: its sinkValues.
510
+ */
511
+ commands: (sinkName?: string) => any[];
512
+ /** Throws (with the formatted texts) if any warn/error diagnostics were collected */
513
+ expectNoDiagnostics: () => void;
514
+ /**
515
+ * Latest rendered VNode serialized to HTML. G-125: throws before the first render (await
516
+ * t.ready() first); '' for a component that renders nothing.
517
+ */
518
+ html: () => string;
519
+ /** Tear down the component, clean up listeners and restore the diagnostics mode */
36
520
  dispose: () => void;
521
+ /**
522
+ * The app graph (2B) of the rendered tree: components, actions, selectors (with the mock DOM's
523
+ * match / isolation results), EVENTS and diagnostics. Requires `import 'sygnal/diagnostics'`.
524
+ */
525
+ inspect: (options?: Pick<InspectOptions, 'actions'>) => InspectGraph;
526
+ /** `{ dom: 'real' }`: the element the tree is mounted in (removed on dispose()); else null */
527
+ container: Element | null;
528
+ /**
529
+ * The first element matching a selector in the rendered tree (Portal content included), or
530
+ * null. `{ dom: 'real' }`: the real element (`document.activeElement === t.query(...)`). 6-B:
531
+ * the mock DOM gives a MockElement, a snapshot of what the view rendered (`.textContent`,
532
+ * `.value`, `.checked`, `.disabled`, getAttribute, querySelector...). Throws before the first
533
+ * render (await t.ready() first). 4-A1: after `await` of any wait, it shows the state the wait
534
+ * resolved with (a later render is held back until the next macrotask).
535
+ */
536
+ query: (selector: string) => Element | null;
537
+ /** Every element matching a selector in the rendered tree (Portals included), as query() */
538
+ queryAll: (selector: string) => Element[];
539
+ /**
540
+ * PLAN-5 W-1: a widget's host by selector or control: `.props` (what the view passed it),
541
+ * `.instance` (`dom: 'real'`: what mount returned) and `.dispatch(name, detail)` (the event
542
+ * mount's `dispatch` sends, through simulateEvent; D201: `.emit` is an alias)
543
+ */
544
+ widget: (target: any) => {readonly props: any; readonly instance: any; dispatch: (name: string, detail?: any) => void; emit: (name: string, detail?: any) => void};
545
+ }
546
+
547
+ const isScope = (s: string) => s.startsWith('.___');
548
+ /** CT-1: a control (controls()) as its selector, [data-control="<Key>"]; anything else as is */
549
+ const selOf = (s: any) => typeof s == 'function' && s.__sygnalControl ? '' + s : s;
550
+ /** a listener path's selector text (isolation scopes dropped, whitespace normalized) */
551
+ const selText = (path: string[]) => norm(path.filter(s => !isScope(s)).join(' '));
552
+ const norm = (s: string) => s.trim().replace(/\s*>\s*/g, ' > ').replace(/\s+/g, ' ');
553
+ /** 'document' / 'body' (optionally followed by more) name a page listener, not an element */
554
+ const PAGE = /^(document|body)(\s+|$)/;
555
+ const str = (o: any) => {
556
+ const r: any = {};
557
+ for (const k in o) if (o[k] != null) r[k] = String(o[k]);
558
+ return r;
559
+ };
560
+
561
+ // ── Selector engine (G-070) ──────────────────────────────────────────────────
562
+ // Matches the rendered vnode tree with real-DOM semantics. Anything not listed in
563
+ // SUPPORTED throws "unsupported selector syntax" instead of silently matching nothing.
564
+ type Pseudo = {k: string; a: number; b: number; last?: boolean; type?: boolean; not?: Sel};
565
+ type Compound = {tag?: string; id?: string; cls: string[]; attrs: [string, string, string?][]; ps: Pseudo[]};
566
+ type Complex = {parts: Compound[]; combs: string[]};
567
+ type Sel = Complex[];
568
+ const SUPPORTED = "tag, *, .class, #id, [attr], [attr=\"v\"] (also ^= $= *= ~=), :first-child, :last-child, :only-child, :nth-child(an+b|odd|even), :nth-last-child(), :first-of-type, :last-of-type, :only-of-type, :nth-of-type(), :nth-last-of-type(), :not(...), :checked, :disabled, :enabled, the descendant (' ') and child ('>') combinators, ',' lists";
569
+ const IDENT = /^(?:[\w-]|\\.)+/;
570
+ const STATES = ['checked', 'disabled', 'enabled'];
571
+ const FORM = ['button', 'input', 'select', 'textarea', 'option', 'optgroup', 'fieldset'];
572
+ const unesc = (x: string) => x.replace(/\\(.)/g, '$1');
573
+ const parsed = new Map<string, Sel | Error>();
574
+ function parse(src: string): Sel {
575
+ let r = parsed.get(src);
576
+ if (!r) {
577
+ try { r = parseSel(src); } catch (e: any) { r = e; }
578
+ parsed.set(src, r!);
579
+ }
580
+ if (r instanceof Error) throw r;
581
+ return r!;
582
+ }
583
+ const tryParse = (src: string): Sel | undefined => { try { return parse(src); } catch (_) { return undefined; } };
584
+ function parseSel(src: string): Sel {
585
+ let i = 0;
586
+ const bad = (what: string): never => {
587
+ const e: any = new Error(`[Sygnal] Unsupported selector syntax in '${src}': ${what}. Supported: ${SUPPORTED}. Or give the element an attribute and select it, e.g. [data-id="3"]. With renderComponent(C, { dom: 'real' }) any CSS selector works`);
588
+ e.unsupported = true;
589
+ throw e;
590
+ };
591
+ const ws = () => { const n = src.slice(i).match(/^\s*/)![0].length; i += n; return n > 0; };
592
+ const ident = (what: string) => {
593
+ const m = src.slice(i).match(IDENT);
594
+ if (!m) return bad(`expected ${what} at '${src.slice(i) || 'end'}'`);
595
+ i += m[0].length;
596
+ return unesc(m[0]);
597
+ };
598
+ const anb = (arg: string): [number, number] => {
599
+ const x = arg.trim().toLowerCase().replace(/\s+/g, '');
600
+ if (x == 'odd') return [2, 1];
601
+ if (x == 'even') return [2, 0];
602
+ if (/^[+-]?\d+$/.test(x)) return [0, +x];
603
+ const m = x.match(/^([+-]?\d*)n([+-]\d+)?$/);
604
+ if (!m) return bad(`'${arg}' is not an+b`);
605
+ return [m[1] == '' || m[1] == '+' ? 1 : m[1] == '-' ? -1 : +m[1], m[2] ? +m[2] : 0];
606
+ };
607
+ const compound = (): Compound => {
608
+ const c: Compound = {cls: [], attrs: [], ps: []};
609
+ const from = i;
610
+ if (src[i] == '*') i++;
611
+ else if (IDENT.test(src.slice(i))) c.tag = ident('tag').toLowerCase();
612
+ for (;;) {
613
+ const ch = src[i];
614
+ if (ch == '.') { i++; c.cls.push(ident('a class name')); }
615
+ else if (ch == '#') { i++; c.id = ident('an id'); }
616
+ else if (ch == '[') {
617
+ const m = src.slice(i).match(/^\[\s*((?:[\w-]|\\.)+)\s*(?:([~^$*|]?=)\s*(?:"([^"]*)"|'([^']*)'|((?:[\w-]|\\.)+))\s*)?\]/);
618
+ if (!m) return bad(`attribute selector '${src.slice(i)}'`);
619
+ if (m[2] == '|=') bad("the '|=' attribute operator");
620
+ i += m[0].length;
621
+ c.attrs.push([unesc(m[1]), m[2] || '', m[2] ? (m[3] ?? m[4] ?? unesc(m[5])) : undefined]);
622
+ } else if (ch == ':') {
623
+ if (src[i + 1] == ':') bad('pseudo-elements');
624
+ i++;
625
+ const k = ident('a pseudo-class').toLowerCase();
626
+ let arg: string | undefined;
627
+ if (src[i] == '(') {
628
+ let depth = 0, j = i;
629
+ for (; j < src.length; j++) {
630
+ if (src[j] == '(') depth++;
631
+ else if (src[j] == ')' && !--depth) break;
632
+ }
633
+ if (j >= src.length) bad(`unclosed ':${k}('`);
634
+ arg = src.slice(i + 1, j);
635
+ i = j + 1;
636
+ }
637
+ const p: Pseudo = {k, a: 0, b: 1};
638
+ const m = k.match(/^(?:(first|last|only)|nth(-last)?)-(child|of-type)$/);
639
+ if (m && (arg === undefined) == !!m[1]) {
640
+ p.type = m[3] == 'of-type';
641
+ if (m[1] == 'only') p.k = 'only';
642
+ else if (m[1]) p.last = m[1] == 'last';
643
+ else {
644
+ if (/\bof\b/.test(arg!)) bad(`':${k}(... of S)'`);
645
+ [p.a, p.b] = anb(arg!);
646
+ p.last = !!m[2];
647
+ }
648
+ } else if (STATES.includes(k) && arg === undefined) {
649
+ // 6-B: element state, as the view rendered it
650
+ } else if (k == 'not' && arg !== undefined) {
651
+ p.not = parse(arg);
652
+ if (p.not.some(cx => cx.parts.length > 1)) bad("combinators inside ':not()'");
653
+ } else bad(`':${k}${arg === undefined ? '' : '(' + arg + ')'}'`);
654
+ c.ps.push(p);
655
+ } else break;
656
+ }
657
+ if (i == from) bad(`unexpected '${src[i] || 'end of selector'}'`);
658
+ return c;
659
+ };
660
+ const out: Sel = [];
661
+ ws();
662
+ if (i >= src.length) return out;
663
+ for (;;) {
664
+ const cx: Complex = {parts: [compound()], combs: []};
665
+ for (;;) {
666
+ const space = ws();
667
+ const ch = src[i];
668
+ if (i >= src.length || ch == ',') break;
669
+ if (ch == '+' || ch == '~') bad(`the '${ch}' combinator`);
670
+ if (ch == '>') { i++; ws(); cx.combs.push('>'); }
671
+ else if (space) cx.combs.push(' ');
672
+ else bad(`unexpected '${ch}'`);
673
+ cx.parts.push(compound());
674
+ }
675
+ out.push(cx);
676
+ if (i >= src.length) return out;
677
+ i++; // ','
678
+ ws();
679
+ }
37
680
  }
38
681
 
682
+ /** element children of a vnode (fragments flattened, text and comments skipped) */
683
+ function kids(v: any): any[] {
684
+ const out: any[] = [];
685
+ for (const k of [].concat(v.children || [])) {
686
+ if (!k || typeof k != 'object') continue;
687
+ if ((k as any).sel) { if ((k as any).sel != '!') out.push(k); }
688
+ else if ((k as any).children) out.push(...kids(k));
689
+ }
690
+ return out;
691
+ }
692
+ // parent + element siblings of each vnode of the latest rendered tree (for '>' and :nth-*)
693
+ const meta = new WeakMap<any, {p: any; sibs: any[]}>();
694
+ function index(root: any) {
695
+ if (!root || typeof root != 'object') return;
696
+ const walk = (v: any) => {
697
+ for (const ks of [kids(v), kids({children: v.data?.portalChildren || []})]) {
698
+ for (const k of ks) { meta.set(k, {p: v, sibs: ks}); walk(k); }
699
+ }
700
+ };
701
+ const tops = root.sel ? [root] : kids(root);
702
+ for (const t of tops) { meta.set(t, {p: undefined, sibs: tops}); walk(t); }
703
+ }
704
+ const tagOf = (v: any) => v.sel.split(/[.#]/)[0].toLowerCase();
705
+ function is(v: any, c: Compound): boolean {
706
+ const d = v.data || {}, p = d.props || {}, a = d.attrs || {};
707
+ const [tagId, ...cls] = v.sel.split('.');
708
+ const [tag, sid] = tagId.split('#');
709
+ if (c.tag && tag.toLowerCase() != c.tag) return false;
710
+ const id = sid || p.id || a.id;
711
+ if (c.id !== undefined && id != c.id) return false;
712
+ if (c.cls.length) {
713
+ const classes = cls.concat(
714
+ `${p.className || ''} ${a.class || ''}`.split(' '),
715
+ Object.keys(d.class || {}).filter(k => d.class[k])
716
+ );
717
+ if (!c.cls.every(n => classes.includes(n))) return false;
718
+ }
719
+ for (const [name, op, val] of c.attrs) {
720
+ const raw = name == 'id' ? id
721
+ : name in a ? a[name]
722
+ : name in p ? p[name]
723
+ : name.startsWith('data-') ? str(d.dataset)[name.slice(5).replace(/-(\w)/g, (_: any, l: string) => l.toUpperCase())]
724
+ : undefined;
725
+ if (raw == null || raw === false) return false;
726
+ const x = String(raw);
727
+ if (op && !(op == '=' ? x == val
728
+ : !val ? false
729
+ : op == '^=' ? x.startsWith(val)
730
+ : op == '$=' ? x.endsWith(val)
731
+ : op == '*=' ? x.includes(val)
732
+ : x.split(/\s+/).includes(val))) return false;
733
+ }
734
+ for (const ps of c.ps) {
735
+ if (ps.not) { if (matches(ps.not, [v])) return false; continue; }
736
+ if (STATES.includes(ps.k)) {
737
+ if (ps.k == 'checked' ? !flag(v, 'checked') && !(tagOf(v) == 'option' && flag(v, 'selected')) : !FORM.includes(tagOf(v)) || flag(v, 'disabled') == (ps.k == 'enabled')) return false;
738
+ continue;
739
+ }
740
+ let sibs = (meta.get(v) || {sibs: [v]}).sibs;
741
+ if (ps.type) sibs = sibs.filter(s => tagOf(s) == tagOf(v));
742
+ if (ps.k == 'only') { if (sibs.length != 1) return false; continue; }
743
+ const n = sibs.indexOf(v), pos = ps.last ? sibs.length - n : n + 1;
744
+ if (ps.a == 0 ? pos != ps.b : (pos - ps.b) / ps.a < 0 || (pos - ps.b) % ps.a != 0) return false;
745
+ }
746
+ return true;
747
+ }
748
+ /** does the last of `els` (an ancestor chain, root first, maybe scope-filtered) match `sel`? */
749
+ function matches(sel: Sel, els: any[]): boolean {
750
+ const at = (cx: Complex, j: number, i: number): boolean => {
751
+ if (i < 0 || !is(els[i], cx.parts[j])) return false;
752
+ if (j == 0) return true;
753
+ if (cx.combs[j - 1] == '>') {
754
+ const m = meta.get(els[i]);
755
+ return i > 0 && (!m || els[i - 1] === m.p) && at(cx, j - 1, i - 1);
756
+ }
757
+ for (let k = i - 1; k >= 0; k--) if (at(cx, j - 1, k)) return true;
758
+ return false;
759
+ };
760
+ return sel.some(cx => at(cx, cx.parts.length - 1, els.length - 1));
761
+ }
762
+ /** root → element chain of the first element (document order) matching `sel` */
763
+ function find(v: any, sel: Sel, chain: any[] = []): any[] | undefined {
764
+ if (!v || typeof v != 'object') return;
765
+ const c = v.sel ? chain.concat(v) : chain;
766
+ if (v.sel && matches(sel, c)) return c;
767
+ // a <Portal>'s content is rendered elsewhere; it is kept on its placeholder
768
+ for (const k of [].concat(v.children || [], v.data?.portalChildren || [])) {
769
+ const r = find(k, sel, c);
770
+ if (r) return r;
771
+ }
772
+ }
773
+ /** root → element chains of every element (document order) matching `sel`, below `v` (and `v` with `self`) */
774
+ function findAll(v: any, sel: Sel, chain: any[], out: any[][], self = true): any[][] {
775
+ if (!v || typeof v != 'object') return out;
776
+ const c = v.sel ? chain.concat(v) : chain;
777
+ if (v.sel && self && matches(sel, c)) out.push(c);
778
+ for (const k of [].concat(v.children || [], v.data?.portalChildren || [])) findAll(k, sel, c, out);
779
+ return out;
780
+ }
781
+
782
+ // ── Mock elements (PLAN-3 6-B, G-185) ────────────────────────────────────────
783
+ // t.query() / t.queryAll() on the mock DOM return these: read-only snapshots of one rendered
784
+ // vnode with the common Element reads (text, attributes, classes, form state, traversal,
785
+ // querySelector). They show what the view rendered: an input's value is its `value` prop, a
786
+ // checkbox is checked when the view says so; nothing is typed, focused or laid out.
787
+ const PROP_OF: Record<string, string> = {class: 'className', for: 'htmlFor', readonly: 'readOnly', tabindex: 'tabIndex'};
788
+ /** a boolean property (checked, disabled, ...) as rendered: the prop, else the attribute */
789
+ function flag(v: any, name: string): boolean {
790
+ const d = v.data || {}, p = d.props || {}, a = d.attrs || {};
791
+ if (name in p) return !!p[name];
792
+ const x = name in a ? a[name] : a[name.toLowerCase()];
793
+ return x != null && x !== false;
794
+ }
795
+ function classesOf(v: any): string[] {
796
+ const d = v.data || {}, p = d.props || {}, a = d.attrs || {};
797
+ const all: string[] = v.sel.split('#').flatMap((x: string) => x.split('.').slice(1)).concat(
798
+ `${p.className || ''} ${a.class || ''}`.split(/\s+/), Object.keys(d.class || {}).filter(k => d.class[k]));
799
+ return all.filter((c, i) => c && !c.startsWith('___') && all.indexOf(c) == i);
800
+ }
801
+ /** an attribute as getAttribute() reads it (null: absent) */
802
+ function attrOf(v: any, name: string): string | null {
803
+ const d = v.data || {}, p = d.props || {}, a = d.attrs || {};
804
+ const n = name.toLowerCase();
805
+ if (n == 'class') { const c = classesOf(v); return c.length ? c.join(' ') : null; }
806
+ if (n == 'id' && v.sel.includes('#')) return v.sel.split('#')[1].split('.')[0];
807
+ const x = name in a ? a[name] : n in a ? a[n]
808
+ : name in p ? p[name] : PROP_OF[n] && PROP_OF[n] in p ? p[PROP_OF[n]]
809
+ : n.startsWith('data-') ? (d.dataset || {})[n.slice(5).replace(/-(\w)/g, (_: any, l: string) => l.toUpperCase())]
810
+ : undefined;
811
+ return x == null || x === false ? null : x === true ? '' : String(x);
812
+ }
813
+ function textOf(v: any): string {
814
+ if (v == null || typeof v == 'boolean') return '';
815
+ if (typeof v != 'object') return String(v);
816
+ if (v.sel == '!') return '';
817
+ return (v.text != null ? String(v.text) : '') + [].concat(v.children || []).map(textOf).join('');
818
+ }
819
+ const mockEls = new WeakMap<any, MockElement>();
820
+ /** the MockElement for the last vnode of a root → element chain */
821
+ const mockOf = (chain: any[], html: (v: any) => string): MockElement =>
822
+ chain.reduce((parent: MockElement | null, v: any) => {
823
+ const m = mockEls.get(v);
824
+ return m && m._p === parent ? m : new MockElement(v, parent, html);
825
+ }, null)!;
826
+ class MockElement {
827
+ declare readonly nodeType: 1;
828
+ declare private _v: any;
829
+ declare readonly _p: MockElement | null;
830
+ declare private _html: (v: any) => string;
831
+ constructor(v: any, parent: MockElement | null, html: (v: any) => string) {
832
+ Object.defineProperties(this, {_v: {value: v}, _p: {value: parent}, _html: {value: html}, nodeType: {value: 1}});
833
+ mockEls.set(v, this);
834
+ }
835
+ private chain(): any[] {
836
+ const c: any[] = [];
837
+ for (let e: MockElement | null = this; e; e = e._p) c.unshift(e._v);
838
+ return c;
839
+ }
840
+ get tagName() { return tagOf(this._v).toUpperCase(); }
841
+ get nodeName() { return this.tagName; }
842
+ get localName() { return tagOf(this._v); }
843
+ get id() { return attrOf(this._v, 'id') ?? ''; }
844
+ get className() { return classesOf(this._v).join(' '); }
845
+ get classList() {
846
+ const c = classesOf(this._v);
847
+ return Object.assign(c, {contains: (x: string) => c.includes(x), item: (i: number) => c[i] ?? null, value: c.join(' ')});
848
+ }
849
+ get dataset() {
850
+ const d = this._v.data || {}, out: Record<string, string> = {...str(d.dataset)};
851
+ for (const src of [d.props || {}, d.attrs || {}]) {
852
+ for (const k in src) if (k.startsWith('data-') && src[k] != null) out[k.slice(5).replace(/-(\w)/g, (_: any, l: string) => l.toUpperCase())] = String(src[k]);
853
+ }
854
+ return out;
855
+ }
856
+ get style() { return {...(this._v.data?.style || {})}; }
857
+ get textContent() { return textOf(this._v); }
858
+ get innerText() { return this.textContent; }
859
+ get outerHTML() { return this._html(this._v); }
860
+ get innerHTML() {
861
+ const o = this.outerHTML, end = `</${this.localName}>`;
862
+ return o.endsWith(end) ? o.slice(o.indexOf('>') + 1, -end.length) : '';
863
+ }
864
+ get value(): string {
865
+ const v = this._v, p = v.data?.props || {}, tag = this.localName;
866
+ if (p.value != null) return String(p.value);
867
+ const a = attrOf(v, 'value');
868
+ if (a !== null) return a;
869
+ if (tag == 'textarea' || tag == 'option') return this.textContent;
870
+ if (tag == 'select') {
871
+ const o = this.querySelector('option:checked') || this.querySelector('option');
872
+ return o ? o.value : '';
873
+ }
874
+ return tag == 'input' && /^(checkbox|radio)$/.test(this.type) ? 'on' : '';
875
+ }
876
+ get checked() { return flag(this._v, 'checked'); }
877
+ get selected() { return flag(this._v, 'selected'); }
878
+ get disabled() { return flag(this._v, 'disabled'); }
879
+ get readOnly() { return flag(this._v, 'readOnly') || flag(this._v, 'readonly'); }
880
+ get required() { return flag(this._v, 'required'); }
881
+ get hidden() { return flag(this._v, 'hidden'); }
882
+ get type() { return attrOf(this._v, 'type') ?? (this.localName == 'input' ? 'text' : this.localName == 'button' ? 'submit' : ''); }
883
+ get name() { return attrOf(this._v, 'name') ?? ''; }
884
+ /** as written in the view (a real <a>'s href is absolute) */
885
+ get href() { return attrOf(this._v, 'href') ?? ''; }
886
+ get src() { return attrOf(this._v, 'src') ?? ''; }
887
+ get placeholder() { return attrOf(this._v, 'placeholder') ?? ''; }
888
+ get title() { return attrOf(this._v, 'title') ?? ''; }
889
+ get alt() { return attrOf(this._v, 'alt') ?? ''; }
890
+ get htmlFor() { return attrOf(this._v, 'for') ?? ''; }
891
+ getAttribute(name: string) { return attrOf(this._v, name); }
892
+ hasAttribute(name: string) { return attrOf(this._v, name) !== null; }
893
+ get parentElement() { return this._p; }
894
+ get children(): MockElement[] { return kids(this._v).map(k => mockOf([...this.chain(), k], this._html)); }
895
+ get childElementCount() { return this.children.length; }
896
+ get firstElementChild() { return this.children[0] ?? null; }
897
+ get lastElementChild() { const c = this.children; return c[c.length - 1] ?? null; }
898
+ matches(selector: string) { return matches(parse(norm(selector)), this.chain()); }
899
+ closest(selector: string): MockElement | null {
900
+ const sel = parse(norm(selector));
901
+ for (let e: MockElement | null = this; e; e = e._p) if (matches(sel, e.chain())) return e;
902
+ return null;
903
+ }
904
+ querySelectorAll(selector: string): MockElement[] {
905
+ return findAll(this._v, parse(norm(selector)), this.chain().slice(0, -1), [], false).map(ch => mockOf(ch, this._html));
906
+ }
907
+ querySelector(selector: string): MockElement | null { return this.querySelectorAll(selector)[0] ?? null; }
908
+ /** vitest / pretty-format print an element as its HTML */
909
+ toJSON() { return this.outerHTML; }
910
+ // no events, focus or layout on a snapshot
911
+ focus(): never { throw realOnly('focus'); }
912
+ blur(): never { throw realOnly('blur'); }
913
+ click(): never { throw realOnly('click'); }
914
+ dispatchEvent(_e?: any): never { throw realOnly('dispatchEvent'); }
915
+ addEventListener(..._a: any[]): never { throw realOnly('addEventListener'); }
916
+ getBoundingClientRect(): never { throw realOnly('getBoundingClientRect'); }
917
+ }
918
+ const realOnly = (fn: string) => new Error(`[Sygnal] element.${fn}(): t.query() on the default mock DOM returns a snapshot of what the view rendered. Fire events with t.simulateEvent(selector, type), or use real elements: renderComponent(C, { dom: 'real' })`);
919
+
39
920
  /**
40
- * Render a Sygnal component in isolation for testing.
41
- *
42
- * Creates a minimal Cycle.js runtime with mocked DOM, event bus,
43
- * and state drivers. Returns streams and helpers for inspecting
44
- * component behavior.
45
- *
46
- * ```js
47
- * const t = renderComponent(Counter, { initialState: { count: 0 } })
48
- * t.simulateAction('INCREMENT')
49
- * await t.waitForState(s => s.count === 1)
50
- * t.dispose()
51
- * ```
921
+ * The isolation scope a vnode starts ('.___scope'): the mock DOM appends it to the sel, the
922
+ * real DOM driver's isolateSink puts it last in data.isolate (E4).
923
+ */
924
+ function scopeOfV(v: any): string | undefined {
925
+ const m = v.sel.match(/\.___[^.#]+/);
926
+ if (m) return m[0];
927
+ const iso = v.data?.isolate;
928
+ return iso && iso.length ? '.___' + iso[iso.length - 1].scope : undefined;
929
+ }
930
+ /** root → element chain of the vnode patched into `el` (E4, real DOM) */
931
+ function chainOf(v: any, el: any, chain: any[] = []): any[] | undefined {
932
+ if (!v || typeof v != 'object') return;
933
+ const c = v.sel ? chain.concat(v) : chain;
934
+ if (v.sel && v.elm === el) return c;
935
+ for (const k of [].concat(v.children || [], v.data?.portalChildren || [])) {
936
+ const r = chainOf(k, el, c);
937
+ if (r) return r;
938
+ }
939
+ }
940
+
941
+ // ── Real DOM (E4) ────────────────────────────────────────────────────────────
942
+ const INNER = Symbol('sygnal.testing.inner');
943
+ /**
944
+ * Wraps a real DOM source (MainDOMSource / DocumentDOMSource / BodyDOMSource) so its events()
945
+ * calls and subscriptions are reported like the mock's (G-039 waits, SYG103/104 bookkeeping),
946
+ * with the same '.___scope' path; everything else is the real source.
947
+ */
948
+ function trackSource(inner: any, path: string[], hub$: any, on: (path: string[], type: string, live?: boolean) => void): any {
949
+ const own: any = {
950
+ [INNER]: inner,
951
+ _hub: hub$,
952
+ _path: path,
953
+ select: (sel: any) => trackSource(inner.select(sel), path.concat(selOf(sel)), hub$, on),
954
+ events: (type: string, options?: any, bubbles?: boolean) => {
955
+ on(path, type);
956
+ const ev$ = inner.events(type, options, bubbles);
957
+ let l: any;
958
+ const out = enrichEventStream(xs.create({
959
+ start: (x: any) => {
960
+ ev$.addListener(l = {next: (v: any) => x.next(v), error: (e: any) => x.error(e), complete: () => x.complete()});
961
+ on(path, type, true);
962
+ },
963
+ stop: () => { ev$.removeListener(l); on(path, type, false); },
964
+ }));
965
+ out._isCycleSource = ev$._isCycleSource;
966
+ return out;
967
+ },
968
+ };
969
+ if (typeof inner.isolateSource == 'function') {
970
+ own.isolateSource = (source: any, scope: string) =>
971
+ trackSource(inner.isolateSource(source[INNER] || source, scope), (source._path || path).concat('.___' + scope), hub$, on);
972
+ }
973
+ return new Proxy(inner, {
974
+ get: (t, k) => k in own ? own[k] : typeof t[k] == 'function' ? t[k].bind(t) : t[k],
975
+ has: (t, k) => k in own || k in t,
976
+ });
977
+ }
978
+ // events the browser fires without bubbling (cf. eventTypesThatDontBubble; submit/reset do bubble)
979
+ const NO_BUBBLE = /^(blur|focus|mouseenter|mouseleave|pointerenter|pointerleave|load|unload|scroll|scrollend|invalid|close|cancel|toggle|beforetoggle|error|abort)$/;
980
+ /** dispatch a real DOM event, like a user would cause it (E4) */
981
+ function fire(el: any, type: string, init: SimulatedEventInit) {
982
+ const {target: t = {}, value, checked, dataset, data, key, ...rest} = init;
983
+ if ('value' in init) el.value = value;
984
+ if ('checked' in init) el.checked = checked;
985
+ for (const k in t) if (k != 'dataset') try { el[k] = t[k]; } catch (_) {}
986
+ const ds = {...dataset, ...data, ...t.dataset};
987
+ if (el.dataset) for (const k in ds) el.dataset[k] = String(ds[k]);
988
+ const doc = el.ownerDocument || el;
989
+ // focus()/blur() move document.activeElement and fire the events themselves
990
+ if (type == 'focus' && typeof el.focus == 'function') {
991
+ el.focus();
992
+ if (doc.activeElement === el) return;
993
+ }
994
+ if (type == 'blur' && doc.activeElement === el && typeof el.blur == 'function') return el.blur();
995
+ // click() runs the default action (checkbox/radio toggle, label, submit) and skips disabled controls
996
+ if (type == 'click' && !Object.keys(rest).length && typeof el.click == 'function') return el.click();
997
+ const W: any = (doc.defaultView || globalThis);
998
+ const Ctor = /^key/.test(type) ? W.KeyboardEvent
999
+ : /^(focus|blur|focusin|focusout)$/.test(type) ? W.FocusEvent
1000
+ : /^pointer/.test(type) ? W.PointerEvent || W.MouseEvent
1001
+ : /click|^mouse|^contextmenu$/.test(type) ? W.MouseEvent
1002
+ : /^(drag|drop)/.test(type) ? W.DragEvent || W.MouseEvent
1003
+ : /^(before)?input$/.test(type) ? W.InputEvent
1004
+ : W.Event;
1005
+ const ev = new (Ctor || W.Event)(type, {bubbles: !NO_BUBBLE.test(type), cancelable: true, ...(key !== undefined && {key}), ...rest});
1006
+ const extra: any = {...rest, ...(key !== undefined && {key})};
1007
+ if (/^(drag|drop)/.test(type) && !ev.dataTransfer) {
1008
+ const store: Record<string, string> = {};
1009
+ extra.dataTransfer = {setData: (f: string, v: string) => { store[f] = String(v); }, getData: (f: string) => store[f] ?? '', ...rest.dataTransfer};
1010
+ }
1011
+ for (const k in extra) if (ev[k] !== extra[k]) try { Object.defineProperty(ev, k, {value: extra[k]}); } catch (_) {}
1012
+ el.dispatchEvent(ev);
1013
+ }
1014
+
1015
+ /** Internal (perf-guard tests): number of SYG104 tree walks */
1016
+ export const _testingStats = {walks: 0};
1017
+
1018
+ // 1H-5: live renderComponent instances; the explicit diagnostics config and the strict flag
1019
+ // (R4) from before the outermost one are restored when the last one is disposed
1020
+ let active = 0;
1021
+ /** quiet window after which the whole tree counts as rendered (G-047) */
1022
+ const QUIET_MS = 10;
1023
+ // G-053: defaults of the timing options (eventWaitMs: how long an event waits for its element /
1024
+ // listeners, G-049/G-039; settleMs: settle()'s quiet window, longer than a model next()'s
1025
+ // default 10ms delay; timeoutMs: next()/waitForState()/settle())
1026
+ const TIMING = {eventWaitMs: 300, settleMs: 20, timeoutMs: 2000};
1027
+
1028
+ // PLAN-4 GS-5: a synchronous storage over a plain record (key -> parsed entry, or a raw string
1029
+ // that isn't JSON), for persist(). The record's subscribers (persist's sync) hear every write,
1030
+ // as other tabs hear a 'storage' event; persist skips its own writes.
1031
+ const storageSubs = new WeakMap<object, Set<(k: string, v: string | null) => void>>();
1032
+ const fakeStorage = (rec: Record<string, any>) => {
1033
+ let subs = storageSubs.get(rec);
1034
+ if (!subs) storageSubs.set(rec, subs = new Set());
1035
+ const notify = (k: string, v: string | null) => subs!.forEach(f => f(k, v));
1036
+ return {
1037
+ getItem: (k: string) => rec[k] == null ? null : typeof rec[k] == 'string' ? rec[k] : JSON.stringify(rec[k]),
1038
+ setItem: (k: string, v: string) => {
1039
+ v = String(v);
1040
+ try { rec[k] = JSON.parse(v); } catch (_) { rec[k] = v; }
1041
+ notify(k, v);
1042
+ },
1043
+ removeItem: (k: string) => { delete rec[k]; notify(k, null); },
1044
+ subscribe: (f: (k: string, v: string | null) => void) => (subs!.add(f), () => { subs!.delete(f); }),
1045
+ };
1046
+ };
1047
+ /** PLAN-5 B-3: renderComponent's `browser` option */
1048
+ export interface BrowserFakeOptions {
1049
+ /** media query -> matches */
1050
+ media?: Record<string, boolean>;
1051
+ /** localStorage, key -> stored string */
1052
+ storage?: Record<string, string>;
1053
+ /** sessionStorage, key -> stored string */
1054
+ sessionStorage?: Record<string, string>;
1055
+ visible?: boolean;
1056
+ online?: boolean;
1057
+ clipboard?: string;
1058
+ /** the position a geolocation declaration starts with (coords; the rest default) */
1059
+ position?: Record<string, any>;
1060
+ /** permissions denied from the start */
1061
+ deny?: Array<'geolocation' | 'clipboard'>;
1062
+ }
1063
+
1064
+ /** PLAN-5 B-3: t.browser. Each input resolves once its actions are reduced and the tree rendered */
1065
+ export interface BrowserFake {
1066
+ /** the declarations of `intersection: target` hear `{ visible, ratio (1 or 0), index: 0, dataset: {} , ...data }`; `at`: only the at-th of them (start order). Throws when nothing declares it */
1067
+ intersect: (target: string | true, visible?: boolean, data?: Record<string, any> & {at?: number}) => Promise<void>;
1068
+ /** the declarations of `resize: target` hear `{ width, height, index: 0, dataset: {}, ...size }` */
1069
+ resize: (target: string | true, size: Record<string, any> & {at?: number}) => Promise<void>;
1070
+ /** a position (the coords; accuracy 0, the rest null, timestamp now) or an error `{ code, message }` for the geolocation declarations */
1071
+ geolocation: (position: Record<string, any>) => Promise<void>;
1072
+ /** a media query now matches (or not) */
1073
+ media: (query: string, matches: boolean) => Promise<void>;
1074
+ visibility: (visible: boolean) => Promise<void>;
1075
+ online: (online: boolean) => Promise<void>;
1076
+ /** with a value: another tab writes the key (a non-string is stored as JSON; null removes it); without: the stored string or null */
1077
+ storage: {(key: string): string | null; (key: string, value: any, area?: 'local' | 'session'): Promise<void>};
1078
+ /** with text: the clipboard's text now; without: its text */
1079
+ clipboard: {(): string; (text: string): Promise<void>};
1080
+ /** deny permissions: copy/paste fail with NotAllowedError, geolocation with code 1 (running ones too) */
1081
+ deny: (...kinds: Array<'geolocation' | 'clipboard'>) => void;
1082
+ /** the running declarations: `{ name, ...spec, component }`, in start order */
1083
+ active: () => Array<Record<string, any>>;
1084
+ }
1085
+
1086
+ /**
1087
+ * PLAN-5 B-3: the browser fake's sources (the real browser driver runs over them): no DOM or
1088
+ * browser API. `live` holds the started declarations ({ k: kind, s: spec, c: its BrowserCtx });
1089
+ * t.browser.* reaches them. `o` is renderComponent's `browser` option (the environment at start).
1090
+ * G-387: intersection / resize start with the report a real observer sends for an element at
1091
+ * first (not visible, size 0), and under `dom: 'real'` (`real`) a selector that matches no
1092
+ * element of the component, once it has rendered, is SYG668.
1093
+ */
1094
+ const browserFake = (o: any = {}, real?: boolean) => {
1095
+ const live = new Set<any>(), runners = new Map<any, any>();
1096
+ const env: any = {media: {...o.media}, local: {...o.storage}, session: {...o.sessionStorage}, deny: new Set(o.deny || []),
1097
+ visible: o.visible ?? true, online: o.online ?? true, clip: o.clipboard ?? '', pos: o.position};
1098
+ const failed = (x: any) => ({name: x?.name, message: x?.message});
1099
+ const area = (s: any) => env[s.area == 'session' ? 'session' : 'local'];
1100
+ const read = (s: any) => { const v = area(s)[s.storage ?? s.setItem ?? s.removeItem]; return v == null ? null : s.json ? JSON.parse(v) : v; };
1101
+ const pos = (p: any) => ({latitude: 0, longitude: 0, accuracy: 0, altitude: null, altitudeAccuracy: null, heading: null, speed: null, timestamp: Date.now(), ...p});
1102
+ const DENIED = {code: 1, message: 'User denied Geolocation'};
1103
+ // a declaration kind: registered while it runs; `now` gives the value it starts with (undefined: none)
1104
+ const on = (k: string, now?: (s: any, c: any) => any) => (s: any, c: any) => {
1105
+ const e = {k, s, c};
1106
+ live.add(e);
1107
+ if (now) { let v; try { v = now(s, c); } catch (x) { c.fail(failed(x)); } if (v !== undefined) c.send(v); }
1108
+ return () => { live.delete(e); };
1109
+ };
1110
+ // dom: 'real': the target's elements after the next render (or now, when there are some)
1111
+ const seen = (k: string) => (s: any, c: any) => {
1112
+ if (real && c.dom) {
1113
+ let sync = true;
1114
+ const $ = c.dom.select(s[k] === true ? '' : '' + s[k]).elements(), l = {next: (els: any[]) => {
1115
+ if (sync && !els.length) return;
1116
+ els.length || c.miss('none');
1117
+ Promise.resolve().then(() => $.removeListener(l));
1118
+ }};
1119
+ $.addListener(l);
1120
+ sync = false;
1121
+ }
1122
+ return {index: 0, dataset: {}, ...(k == 'resize' ? {width: 0, height: 0} : {visible: false, ratio: 0})};
1123
+ };
1124
+ const each = (k: string, f: (e: any) => void, key?: any) => [...live].filter(e => e.k == k && (key === undefined || e.s[k] === key)).forEach(f);
1125
+ // a write to the fake storage, seen by the storage declarations of that key and area
1126
+ // G-384: an unchanged value is silent (as the browser's own `storage` event and the real driver)
1127
+ const write = (key: string, v: any, a = 'local') => {
1128
+ const st = env[a == 'session' ? 'session' : 'local'], old = st[key] ?? null;
1129
+ v == null ? delete st[key] : st[key] = typeof v == 'string' ? v : JSON.stringify(v);
1130
+ if (old === (st[key] ?? null)) return;
1131
+ each('storage', e => { if ((e.s.area == 'session' ? 'session' : 'local') == (a == 'session' ? 'session' : 'local')) { try { e.c.send({key, value: read(e.s)}); } catch (x) { e.c.fail(failed(x)); } } }, key);
1132
+ };
1133
+ const clipFail = (fail: any) => fail({name: 'NotAllowedError', message: 'Clipboard permission denied'});
1134
+ const src = {
1135
+ d: {
1136
+ intersection: on('intersection', seen('intersection')),
1137
+ resize: on('resize', seen('resize')),
1138
+ media: on('media', s => ({matches: !!env.media[s.media], media: s.media})),
1139
+ storage: on('storage', s => ({key: s.storage, value: read(s)})),
1140
+ visibility: on('visibility', () => ({visible: env.visible})),
1141
+ online: on('online', () => ({online: env.online})),
1142
+ geolocation: on('geolocation', (_, c) => env.deny.has('geolocation') ? void c.fail(DENIED) : env.pos && pos(env.pos)),
1143
+ },
1144
+ c: {
1145
+ copy: (v: any, ok: any, fail: any) => env.deny.has('clipboard') ? clipFail(fail) : (env.clip = '' + v.copy, ok({text: env.clip})),
1146
+ paste: (_: any, ok: any, fail: any) => env.deny.has('clipboard') ? clipFail(fail) : ok({text: env.clip}),
1147
+ setItem: (v: any, ok: any) => { write(v.setItem, v.json ? JSON.stringify(v.value) : '' + v.value, v.area); ok({key: v.setItem}); },
1148
+ removeItem: (v: any, ok: any) => { write(v.removeItem, null, v.area); ok({key: v.removeItem}); },
1149
+ },
1150
+ };
1151
+ return {src, runners, live, env, each, write, pos, DENIED};
1152
+ };
1153
+ // a model next() call, seen through the component's debug log (the 5.x core's makeOnAction /
1154
+ // makeEffectHandler: "... next() action: <TYPE> 400ms delay")
1155
+ const RESERVED_SINKS = /^(STATE|EFFECT|PARENT|READY|DOM|ELEMENT)$/;
1156
+ // E2: a source name that a driver would provide (fake sources are made only for these)
1157
+ const DRIVER_NAME = /^[A-Z][A-Z0-9_]*$/;
1158
+ // R2-5: setTimeout fires at once for a delay above 2^31-1 ms (and for Infinity/NaN)
1159
+ const MAX_MS = 2147483647;
1160
+ const validMs = (v: any) => typeof v == 'number' && Number.isFinite(v) && v >= 0 && v <= MAX_MS;
1161
+ // E11: fake timers. vi.useFakeTimers() (and Jest's modern timers) install @sinonjs/fake-timers,
1162
+ // which puts its clock on the faked setTimeout. The harness's own timers then run on that clock
1163
+ // too, so its time is the clock's (Date may be left real by `toFake`), and its waits drive it.
1164
+ const fakeClock = (): any => (setTimeout as any).clock;
1165
+ const clockNow = (): number => { const c = fakeClock(); return c ? c.now : Date.now(); };
1166
+ /**
1167
+ * Under fake timers, advance the clock timer by timer (nextAsync flushes promises around each
1168
+ * one) until `p` settles. Every pending wait has a timer (its timeout, a quiet-window tick), so
1169
+ * this ends; with no timer left it stops and leaves `p` to the test. Real timers: `p` as is.
1170
+ */
1171
+ /**
1172
+ * PLAN-3 1-C: what t.respond / t.fail return: a Promise that notes when it is awaited (then()),
1173
+ * so an un-awaited failing call can still fail the next wait, and an awaited one under fake
1174
+ * timers can drive the clock. Its then() returns a plain Promise.
1175
+ */
1176
+ class Reply extends Promise<void> {
1177
+ _seen?: () => void;
1178
+ static get [Symbol.species]() { return Promise; }
1179
+ then(a?: any, b?: any): any { this._seen?.(); return super.then(a, b); }
1180
+ }
1181
+ /**
1182
+ * PLAN-3 5-1 (H-9): the reply the HTTP fake's in-memory fetch resolves with: a Response-like
1183
+ * object the real driver parses (a string body is text/plain, anything else JSON).
1184
+ */
1185
+ const fakeResponse = (status: number, body: any, url: string): any => {
1186
+ const text = typeof body == 'string' ? body : body === undefined ? '' : JSON.stringify(body);
1187
+ const type = typeof body == 'string' ? 'text/plain;charset=UTF-8' : 'application/json';
1188
+ const isType = (k: string) => /^content-type$/i.test(k);
1189
+ return {
1190
+ ok: status > 199 && status < 300, status, statusText: '', url, redirected: false, type: 'basic', bodyUsed: false,
1191
+ headers: typeof Headers == 'function' ? new Headers({'content-type': type}) : {get: (k: string) => isType(k) ? type : null, has: isType},
1192
+ text: async () => text,
1193
+ json: async () => JSON.parse(text),
1194
+ clone: () => fakeResponse(status, body, url),
1195
+ };
1196
+ };
1197
+ /** a request field compared by value (t.respond / t.fail targets) */
1198
+ const same = (a: any, b: any): boolean => {
1199
+ if (a === b) return true;
1200
+ if (!a || !b || typeof a != 'object' || typeof b != 'object' || Array.isArray(a) != Array.isArray(b)) return false;
1201
+ const ka = Object.keys(a), kb = Object.keys(b);
1202
+ return ka.length == kb.length && ka.every(k => same(a[k], b[k]));
1203
+ };
1204
+ /**
1205
+ * PLAN-3 2-C: where makeSocketDriver (no baseUrl) opens a declared URL (its resolve(); keep the
1206
+ * two in step), and the transport identity it shares by (its key, without the share flag)
1207
+ */
1208
+ const sockUrl = (u: string, sse: boolean) => {
1209
+ const loc = (globalThis as any).location;
1210
+ if (sse || /^wss?:/i.test(u) || !loc) return u;
1211
+ try {
1212
+ const r = new URL(u, loc.href);
1213
+ r.protocol = r.protocol == 'https:' ? 'wss:' : 'ws:';
1214
+ return r.href;
1215
+ } catch (_) { return u; }
1216
+ };
1217
+ const alive = (s: any) => s && s.readyState < 2;
1218
+ const sockKey = (sse: boolean, url: string, arg: any) => (sse ? 'e' + !!(arg && arg.withCredentials) : 's' + JSON.stringify(arg)) + url;
1219
+ /**
1220
+ * PLAN-3 2-C: the in-memory WebSocket / EventSource the fake's makeSocketDriver opens. The
1221
+ * harness drives it (t.open / t.push / t.drop); the driver's own close() is the app's close.
1222
+ */
1223
+ const fakeSocketClass = (sse: boolean, made: (s: any) => void) => class {
1224
+ readyState = 0;
1225
+ sse = sse;
1226
+ byApp = false;
1227
+ k: string;
1228
+ url: string;
1229
+ ls: Record<string, any[]> = {};
1230
+ onopen: any; onmessage: any; onerror: any; onclose: any;
1231
+ constructor(url: string, arg?: any) {
1232
+ this.url = String(url);
1233
+ this.k = sockKey(sse, this.url, arg);
1234
+ made(this);
1235
+ }
1236
+ send() {}
1237
+ close() { if (this.readyState < 2) { this.readyState = 3; this.byApp = true; } }
1238
+ addEventListener(type: string, f: any) { (this.ls[type] = this.ls[type] || []).push(f); }
1239
+ };
1240
+ const brief = (v: any) => {
1241
+ let s: string;
1242
+ try { s = typeof v == 'string' ? `'${v}'` : JSON.stringify(v); } catch (_) { s = String(v); }
1243
+ return s && s.length > 80 ? s.slice(0, 80) + '…' : s;
1244
+ };
1245
+ const drive = <T>(p: Promise<T>, stop: () => boolean): Promise<T> => {
1246
+ const clock = fakeClock();
1247
+ if (!clock) return p;
1248
+ let settled = false;
1249
+ p.then(() => { settled = true; }, () => { settled = true; });
1250
+ return (async () => {
1251
+ while (!settled && !stop() && fakeClock() === clock) {
1252
+ if (!clock.countTimers()) {
1253
+ await clock.nextAsync(); // one more flush: a microtask may schedule one
1254
+ if (!clock.countTimers()) break;
1255
+ }
1256
+ await clock.nextAsync();
1257
+ }
1258
+ return p;
1259
+ })();
1260
+ };
1261
+ /**
1262
+ * PLAN-3 5-4c: the in-memory window the router fake gives makeRouter's real driver: a location,
1263
+ * a history whose go() fires popstate a task later (like a browser), window listeners, scroll
1264
+ * kept in memory, and a document whose listeners are recorded in `docLs` (the mock DOM's link
1265
+ * clicks call them) and, with `real` (dom: 'real'), also added to the real document, so real
1266
+ * clicks bubble into the driver's link interception. `baseURI` is the in-memory URL, so relative
1267
+ * links resolve against it.
1268
+ */
1269
+ function memoryWindow(start: string, real: boolean, query: (s: string) => any, observe: boolean) {
1270
+ const entries = [{url: start, state: null as any}];
1271
+ const on: Record<string, any[]> = {}, docLs: Record<string, any[]> = {};
1272
+ let i = 0;
1273
+ const at = () => new URL(entries[i].url);
1274
+ const fire = (type: string) => (on[type] || []).slice().forEach(f => f({type}));
1275
+ const D: any = real ? document : null;
1276
+ const w: any = {
1277
+ location: {
1278
+ get href() { return entries[i].url; }, get pathname() { return at().pathname; }, get search() { return at().search; },
1279
+ get hash() { return at().hash; }, get origin() { return at().origin; },
1280
+ },
1281
+ history: {
1282
+ get state() { return entries[i].state; }, get length() { return entries.length; },
1283
+ pushState(state: any, _: any, url: string) { entries.splice(i + 1); entries.push({url: new URL(url, entries[i].url).href, state}); i++; },
1284
+ replaceState(state: any, _: any, url?: string) { entries[i] = {url: url ? new URL(url, entries[i].url).href : entries[i].url, state}; },
1285
+ go(n: number) { const j = i + n; if (n && j >= 0 && j < entries.length) setTimeout(() => { i = j; fire('popstate'); }); },
1286
+ back() { this.go(-1); }, forward() { this.go(1); },
1287
+ },
1288
+ addEventListener(t: string, f: any) { (on[t] = on[t] || []).push(f); },
1289
+ removeEventListener(t: string, f: any) { on[t] = (on[t] || []).filter(x => x !== f); },
1290
+ document: {
1291
+ addEventListener(t: string, f: any) { (docLs[t] = docLs[t] || []).push(f); D?.addEventListener(t, f); },
1292
+ removeEventListener(t: string, f: any) { docLs[t] = (docLs[t] || []).filter(x => x !== f); D?.removeEventListener(t, f); },
1293
+ querySelector: (s: string) => (D ? query(s) : null),
1294
+ getElementById: (id: string) => (D ? D.getElementById(id) : null),
1295
+ get body() { return D?.body; },
1296
+ get baseURI() { return entries[i].url; },
1297
+ },
1298
+ scrollX: 0, scrollY: 0,
1299
+ scrollTo(x: any, y?: any) { if (typeof x == 'object') ({left: x = w.scrollX, top: y = w.scrollY} = x); w.scrollX = x; w.scrollY = y; },
1300
+ MutationObserver: real && observe ? (globalThis as any).MutationObserver : undefined,
1301
+ };
1302
+ return {w, docLs, index: () => i, size: () => entries.length};
1303
+ }
1304
+ /**
1305
+ * PLAN-3 5-4c: an element-like `<a>` for the router's click handler (mock DOM), from the vnode:
1306
+ * its attrs, its props (href, target, rel, download) and its dataset as data-* attributes
1307
+ */
1308
+ const anchorOf = (v: any): any => {
1309
+ const d = v.data || {}, all: Record<string, any> = {...d.attrs};
1310
+ for (const k in d.props || {}) if (k != 'className') all[k] = d.props[k];
1311
+ for (const k in d.dataset || {}) all['data-' + k.replace(/[A-Z]/g, c => '-' + c.toLowerCase())] = d.dataset[k];
1312
+ const get = (k: string) => all[k] == null || all[k] === false ? null : all[k] === true ? '' : String(all[k]);
1313
+ return {localName: 'a', getAttribute: get, hasAttribute: (k: string) => get(k) != null, getAttributeNS: () => null};
1314
+ };
1315
+ /**
1316
+ * PLAN-3 5-4c: the HEAD fake: the real driver's entry rules (one entry per component instance,
1317
+ * replaced by its next value, removed by a falsy value or its dispose), kept for t.head() to
1318
+ * merge with head.ts's mergeHead instead of written to a document
1319
+ */
1320
+ const headFake = () => {
1321
+ const entries = new Map<any, any>();
1322
+ const {replies} = makeReplies(s => { entries.delete(s); });
1323
+ const driver = (sink$: Stream<any>) => {
1324
+ sink$.addListener({
1325
+ next: (v: any) => {
1326
+ if (!v || typeof v != 'object') return;
1327
+ const s = senderOf(v), h = 'head' in v ? v.head : v;
1328
+ h && typeof h == 'object' ? entries.set(s, h) : entries.delete(s);
1329
+ },
1330
+ error: () => {}, complete: () => {},
1331
+ });
1332
+ return {...replies, __sygnalStatic: 'head'};
1333
+ };
1334
+ return {entries, driver};
1335
+ };
1336
+ let savedConfig: ReturnType<typeof _getDiagnosticsConfig>;
1337
+ let savedStrict: any;
1338
+
1339
+ /*
1340
+ * PLAN-4 GS-2, mock DOM: could the element a command targets have this method? The core runs any
1341
+ * method of the element; the mock DOM has no elements, so: the documented commands, common
1342
+ * methods of form fields and media, and (with a DOM) the prototype of the control's tag.
1343
+ */
1344
+ const COMMON_METHODS = ['play', 'pause', 'load', 'fastSeek', 'showPicker', 'requestSubmit', 'reset', 'checkValidity', 'reportValidity', 'setCustomValidity', 'setSelectionRange', 'setRangeText', 'stepUp', 'stepDown', 'requestFullscreen', 'scroll', 'scrollTo', 'scrollBy', 'animate', 'requestPointerLock'];
1345
+ const elementHas = (target: any, m: string, tag = typeof target == 'function' && typeof target.spec == 'string' ? target.spec : 'div'): boolean => {
1346
+ if (NATIVE_COMMAND_NAMES.includes(m) || COMMON_METHODS.includes(m)) return true;
1347
+ if (typeof document == 'undefined') return false;
1348
+ try {
1349
+ return typeof (document.createElement(tag) as any)[m] == 'function';
1350
+ } catch (_) {
1351
+ return false;
1352
+ }
1353
+ };
1354
+
1355
+ /*
1356
+ * PLAN-4 GS-2: jsdom has no <dialog> methods, no popovers and no scrollIntoView. While a
1357
+ * `dom: 'real'` test runs, the missing ones are added (and removed after the last one): show() /
1358
+ * showModal() set `open`; close(returnValue) clears it, sets returnValue and fires `close`; the
1359
+ * popover methods fire `beforetoggle` / `toggle` (with oldState / newState); scrollIntoView()
1360
+ * does nothing (spy on it after renderComponent). A browser's own methods are never replaced.
1361
+ * PLAN-5 2-T (D211): also ResizeObserver (observes nothing), CSS.escape and
1362
+ * Element.prototype.scrollTo (does nothing), which Zag's machines use.
52
1363
  */
1364
+ let domFakes: (() => void) | undefined;
1365
+ function fakeElementMethods(W: any): () => void {
1366
+ const added: Array<[any, string, any?]> = [];
1367
+ const add = (proto: any, name: string, fn: Function) => {
1368
+ if (proto && !(name in proto)) { proto[name] = fn; added.push([proto, name]); }
1369
+ };
1370
+ const D = W.HTMLDialogElement?.prototype, E = W.HTMLElement?.prototype;
1371
+ add(D, 'show', function (this: any) { this.open = true; });
1372
+ add(D, 'showModal', function (this: any) { this.open = true; });
1373
+ add(D, 'close', function (this: any, returnValue?: any) {
1374
+ if (!this.open) return;
1375
+ if (returnValue !== undefined) this.returnValue = String(returnValue);
1376
+ this.open = false;
1377
+ this.dispatchEvent(new W.Event('close'));
1378
+ });
1379
+ const shown = new WeakSet<any>();
1380
+ const toggle = (el: any, open: boolean) => {
1381
+ if (shown.has(el) == open) return;
1382
+ const ev = (type: string) => {
1383
+ const e = new W.Event(type, {cancelable: type == 'beforetoggle'});
1384
+ Object.defineProperties(e, {oldState: {value: open ? 'closed' : 'open'}, newState: {value: open ? 'open' : 'closed'}});
1385
+ return el.dispatchEvent(e);
1386
+ };
1387
+ if (!ev('beforetoggle') && open) return;
1388
+ open ? shown.add(el) : shown.delete(el);
1389
+ ev('toggle');
1390
+ };
1391
+ add(E, 'showPopover', function (this: any) { toggle(this, true); });
1392
+ add(E, 'hidePopover', function (this: any) { toggle(this, false); });
1393
+ add(E, 'togglePopover', function (this: any, options?: any) {
1394
+ const force = options && typeof options == 'object' ? options.force : options;
1395
+ toggle(this, force === undefined ? !shown.has(this) : !!force);
1396
+ return shown.has(this);
1397
+ });
1398
+ add(W.Element?.prototype, 'scrollIntoView', function () {});
1399
+ // PLAN-5 2-T (D211): what Zag's machines (sygnal/ui/menu, select, combobox; fromZag) need:
1400
+ // ResizeObserver (positioning), CSS.escape (selectors) and scrollTo (the highlighted option)
1401
+ add(W.Element?.prototype, 'scrollTo', function () {});
1402
+ const RO = class { observe() {} unobserve() {} disconnect() {} };
1403
+ // G-440: CSSOM's serialize-an-identifier (a leading digit, or '-' + digit, is hex-escaped)
1404
+ const esc = (v: any) => {
1405
+ const s = String(v);
1406
+ let out = '';
1407
+ for (let i = 0; i < s.length; i++) {
1408
+ const c = s.charCodeAt(i), ch = s[i], hex = '\\' + c.toString(16) + ' ';
1409
+ out += !c ? '\uFFFD'
1410
+ : c < 32 || c == 127 || (c > 47 && c < 58 && (!i || i == 1 && s[0] == '-')) ? hex
1411
+ : !i && ch == '-' && s.length == 1 ? '\\-'
1412
+ : c > 127 || /[\w-]/.test(ch) ? ch
1413
+ : '\\' + ch;
1414
+ }
1415
+ return out;
1416
+ };
1417
+ // (a global the environment declares as undefined counts as missing)
1418
+ const set = (g: any, name: string, v: any) => {
1419
+ if (g[name]) return;
1420
+ // defined over it (Vitest's jsdom globals are accessors that would store into the window)
1421
+ const own = Object.getOwnPropertyDescriptor(g, name);
1422
+ if (own && !own.configurable) return;
1423
+ Object.defineProperty(g, name, {value: v, configurable: true, writable: true});
1424
+ added.push([g, name, own]);
1425
+ };
1426
+ for (const g of new Set([W, globalThis])) {
1427
+ set(g, 'ResizeObserver', RO);
1428
+ g.CSS ? set(g.CSS, 'escape', esc) : set(g, 'CSS', {escape: esc});
1429
+ }
1430
+ return () => added.forEach(([proto, name, own]) => { delete proto[name]; if (own) Object.defineProperty(proto, name, own); });
1431
+ }
1432
+
53
1433
  export function renderComponent(
54
1434
  componentDef: any,
55
1435
  options: RenderOptions = {}
56
1436
  ): RenderResult {
57
- const {initialState, mockConfig = {}, drivers = {}} = options;
58
-
59
- const name = componentDef.name || componentDef.componentName || 'TestComponent';
60
- const view = componentDef;
61
- const {
62
- intent,
63
- model,
64
- context,
65
- calculated,
66
- storeCalculatedInState,
67
- onError,
68
- } = componentDef;
69
-
70
- const resolvedInitialState =
71
- initialState !== undefined ? initialState : componentDef.initialState;
72
-
73
- // Create a test action$ stream that simulateAction can push into.
74
- // The component's intent function will receive this as a source,
75
- // and we merge test actions with any real intent streams.
76
- const testActionListener: {next: (val: any) => void} = {next: () => {}};
77
- const testAction$: Stream<any> = xs.create({
78
- start(listener: any) {
79
- testActionListener.next = (val: any) => listener.next(val);
1437
+ const {initialState, mockConfig = {}, drivers = {}, diagnostics, strict, dom = 'mock', autoConnect = true, socketSink = 'WS', resourceSink = 'HTTP', http: httpOptions, context: ancestors} = options;
1438
+ const {intent, model = {}} = componentDef;
1439
+ // E4: real DOM mode
1440
+ const real = dom == 'real';
1441
+ if (dom != 'mock' && !real) throw new Error(`[Sygnal] renderComponent: dom must be 'mock' (default) or 'real' (got ${String(dom)})`);
1442
+ if (real && (typeof document == 'undefined' || !document.body)) {
1443
+ throw new Error(`[Sygnal] renderComponent(C, { dom: 'real' }) needs a DOM, and there is no document here. Run the test in a DOM environment: add the comment // @vitest-environment jsdom at the top of the test file, or set test.environment: 'jsdom' (or 'happy-dom') in the Vitest config (npm i -D jsdom)`);
1444
+ }
1445
+ if (real && options.mockConfig) throw new Error(`[Sygnal] renderComponent: mockConfig drives the mock DOM, so it can't be used with { dom: 'real' }. Use simulateEvent instead`);
1446
+ const timing = {...TIMING};
1447
+ for (const k of Object.keys(TIMING) as (keyof typeof TIMING)[]) {
1448
+ const v = options[k];
1449
+ if (v === undefined) continue;
1450
+ if (!validMs(v)) throw new Error(`[Sygnal] renderComponent: ${k} must be a finite number of ms between 0 and ${MAX_MS} (got ${String(v)})`);
1451
+ timing[k] = v;
1452
+ }
1453
+ const {eventWaitMs, settleMs, timeoutMs: defaultTimeout} = timing;
1454
+ // R2-5: settle() could never see a quiet window longer than its timeout
1455
+ if (settleMs > defaultTimeout) {
1456
+ throw new Error(`[Sygnal] renderComponent: settleMs (${settleMs}) is longer than timeoutMs (${defaultTimeout}), so settle() would always time out. Lower settleMs or raise timeoutMs`);
1457
+ }
1458
+ const checkMs = (name: string, ms: any) => {
1459
+ if (!validMs(ms)) throw new Error(`[Sygnal] ${name}: the timeout must be a finite number of ms between 0 and ${MAX_MS} (got ${String(ms)})`);
1460
+ };
1461
+ // PLAN-3 5-4c: the router fake (makeRouter's real driver over an in-memory window) when the
1462
+ // app's router is passed and no driver is; the HEAD fake unless a HEAD driver is passed
1463
+ const {router, url, routerSink = 'ROUTER', headSink = 'HEAD', titleTemplate} = options;
1464
+ const compName = componentDef.name || componentDef.componentName || 'TestComponent';
1465
+ if (router !== undefined && !(router && typeof router == 'object' && router.options && typeof router.driver == 'function')) {
1466
+ throw new Error(`[Sygnal] renderComponent: the router option takes the object makeRouter() returns (import { router } from './routes.js'), not ${typeof router == 'function' ? 'router.driver' : 'a ' + typeof router}`);
1467
+ }
1468
+ const fakeRouter = !!router && !drivers[routerSink];
1469
+ if (!router && componentDef.route && !drivers[routerSink]) {
1470
+ throw new Error(`[Sygnal] renderComponent(${compName}, { router }): ${compName} declares \`route\`, so the test needs the app's router: the object makeRouter() returns (import { router } from './routes.js'). renderComponent then runs its real driver over an in-memory history starting at the url option (default '/'). Or pass a ${routerSink} driver in drivers`);
1471
+ }
1472
+ if (url !== undefined && !fakeRouter) throw new Error(`[Sygnal] renderComponent: url is the router fake's start URL: pass the app's router too (renderComponent(${compName}, { router, url }))`);
1473
+
1474
+ const prevMode = getDiagnosticsMode();
1475
+ // 2A: strict flag on the core bridge (read by the 'sygnal/diagnostics' strict checks)
1476
+ const core = (globalThis as any).__SYGNAL_DIAGNOSTICS__;
1477
+ if (!active++) {
1478
+ savedConfig = _getDiagnosticsConfig();
1479
+ savedStrict = core.strict;
1480
+ // G-051: forget the checks' report dedupe (once()), so a finding from an earlier test is
1481
+ // reported again. Only that set: resetChecks() would also drop inspect()'s records of
1482
+ // other live instances / apps.
1483
+ core.resetOnce?.();
1484
+ }
1485
+ configureDiagnostics({mode: diagnostics || (prevMode == 'off' ? 'collect' : prevMode)});
1486
+ if (strict !== undefined) core.strict = strict;
1487
+ // PLAN-4 GS-2: element commands run on the real DOM (jsdom gets the missing methods), and their
1488
+ // SYG640/SYG641 are reported without the dev entry too
1489
+ if (real && !domFakes) domFakes = fakeElementMethods(document.defaultView || globalThis);
1490
+ const ownBridge = !core.elementCommand;
1491
+ if (ownBridge) core.elementCommand = reportElementCommand;
1492
+ const collected: Diagnostic[] = [];
1493
+ const offDiag = onDiagnostic(d => collected.push(d));
1494
+
1495
+ // G-024: SYG103/SYG104 on the mock DOM
1496
+ const rootName = componentDef.name || componentDef.componentName || 'TestComponent';
1497
+ const listeners = new Map<string, string[]>();
1498
+ // G-039: subscribed listener count per path + event type
1499
+ const live = new Map<string, number>();
1500
+ let disposed = false;
1501
+ const owners = new Map<string, string>([['', rootName]]);
1502
+ const scopeIds = new Map<string, number>();
1503
+ const evTypes: Record<string, string[]> = {};
1504
+ const done = new Set<string>();
1505
+ // the innermost isolation scope of a component's DOM source on this hub
1506
+ const scopeOf = (c: any) => {
1507
+ const d = c && c.sources && c.sources[c.DOMSourceName || 'DOM'];
1508
+ return d && d._hub === hub.$ && (d._path || []).filter(isScope).pop();
1509
+ };
1510
+ // G-047: activity anywhere in the tree (any component's render/reducer, state, input), for
1511
+ // the "the full tree has rendered" / settle() quiet windows
1512
+ let activity = 0, lastActivity = clockNow();
1513
+ const bump = () => { activity++; lastActivity = clockNow(); };
1514
+ // 4-A1 (real DOM): states recorded when a view in the tree last ran (see onModel)
1515
+ let viewTag = 0;
1516
+ const recorded = () => states.length;
1517
+ // G-053: model next() calls of the tree's components (for the timeout explanations)
1518
+ type Scheduled = {type: string; delay: number; at: number; due: number; by: string};
1519
+ const scheduled: Scheduled[] = [];
1520
+ // 2-C: component number (a request's sender) → name, for t.connections (a tagged copy of a
1521
+ // value keeps the sender, not the name)
1522
+ const senderNames = new Map<any, string>();
1523
+ // PLAN-4 2-C (GS-10): t.actions, from the action log (./diagnostics/checks/actionLog), which
1524
+ // patches each instance of this tree from the onIntent / onModel hooks (0 B in apps)
1525
+ const t0 = clockNow();
1526
+ const actionList: TestAction[] = [];
1527
+ const entryOf = new WeakMap<ActionRecord, TestAction>();
1528
+ const stateReducer = new WeakMap<TestAction, Function>();
1529
+ const resulting = new WeakMap<TestAction, {s: any}>();
1530
+ let awaiting: TestAction[] = [];
1531
+ const actionListener: ActionListener = {
1532
+ action(r) {
1533
+ const e: TestAction = {type: r.type, data: r.data, component: r.component, instance: r.instance, sinks: r.sinks, cause: r.cause, at: r.time - t0};
1534
+ entryOf.set(r, e);
1535
+ actionList.push(e);
80
1536
  },
81
- stop() {
82
- testActionListener.next = () => {};
1537
+ sink(r, sink, reducer) {
1538
+ const e = entryOf.get(r);
1539
+ if (!e || sink != 'STATE') return;
1540
+ if (typeof reducer == 'function') stateReducer.set(e, reducer);
1541
+ awaiting.push(e);
83
1542
  },
84
- });
85
-
86
- // Wrap the original intent to merge in test actions
87
- const wrappedIntent = intent
88
- ? (sources: any) => {
89
- const intentResult = intent(sources);
90
- // Intent returns a map of { ACTION: stream$ }
91
- // We add a special __TEST_ACTION__ entry that carries the test actions
92
- return {...intentResult, __TEST_ACTION__: testAction$};
93
- }
94
- : (sources: any) => ({__TEST_ACTION__: testAction$});
95
-
96
- // Wrap the model to handle __TEST_ACTION__ by dispatching to the real action
97
- const wrappedModel: Record<string, any> = {
98
- ...(model || {}),
99
- __TEST_ACTION__: {
100
- STATE: (state: any, action: any) => {
101
- if (!action || !action.type) return state;
102
- const {type, data} = action;
103
-
104
- // Find the model entry for this action
105
- let entry = model?.[type];
106
-
107
- // Check for shorthand entries ('ACTION | DRIVER')
108
- if (!entry) {
109
- for (const key of Object.keys(model || {})) {
110
- if (key.includes('|')) {
111
- const parts = key.split('|').map((s: string) => s.trim());
112
- if (parts[0] === type) {
113
- entry = {[parts[1]]: model[key]};
114
- break;
115
- }
116
- }
1543
+ };
1544
+ // PLAN-4 GS-2: t.commands('ELEMENT'). The core runs an instance's ELEMENT sink itself (after
1545
+ // initModel$, which ends with onModel): recorded here; on the mock DOM not run, but checked
1546
+ // (SYG641 when sent, SYG640 when the target is still missing from its view after 1 s)
1547
+ const commandLog: any[] = [];
1548
+ const commandTimers = new Set<any>();
1549
+ // PLAN-5 W-1 (F-c): the widget whose host a target matches among the sender's own elements
1550
+ // (1-R G-369: scoped as probe() is; its commands run for a selector target too, D196); an
1551
+ // undeclared one is SYG142, as on the real DOM
1552
+ const widgetOf = (target: any, c: any, k = 'ww'): any => {
1553
+ if (typeof target == 'function' && !target.__sygnalControl) return;
1554
+ const sel = tryParse(norm(String(selOf(target) ?? '')));
1555
+ const scope = scopeOf(c) || undefined;
1556
+ const ch = sel && vtree && findAll(vtree, sel, [], []).find(ch => {
1557
+ let s: any;
1558
+ for (const v of ch) s = scopeOfV(v) || s;
1559
+ return s == scope;
1560
+ });
1561
+ return ch && ch[ch.length - 1]?.data?.[k];
1562
+ };
1563
+ const findVc = (target: any, c: any) => widgetOf(target, c, 'vc');
1564
+ const checkCommand = (c: any, cmd: any) => {
1565
+ if (typeof cmd != 'object' || Array.isArray(cmd)) return;
1566
+ const m = Object.keys(cmd)[0], target = cmd[m];
1567
+ if (m === undefined) return;
1568
+ const w = target?.spec?.commands ? undefined : widgetOf(target, c);
1569
+ // PLAN-5 V-1: scrollToIndex / scrollToId are methods of a VirtualCollection's container
1570
+ const vc = m.startsWith('scrollTo') && findVc(target, c)?.commands[m];
1571
+ // G-369: a control wrapping a widget has its widget's host tag
1572
+ if (!vc && (w ? !w.commands[m] && !elementHas(0, m, w.def.tag || 'div') : !target?.spec?.commands?.[m] && !elementHas(target, m, target?.spec?.def?.tag))) {
1573
+ return reportElementCommand(c, cmd, w ? {tagName: w.def.tag || 'div', __sw: {w}} : {});
1574
+ }
1575
+ // D194: focusWithin(selector) looks under the sender's root, children included
1576
+ const within = target?.within, sel = within ?? (target == null ? '' : String(target));
1577
+ const id = setTimeout(() => {
1578
+ commandTimers.delete(id);
1579
+ if (disposed || (sel && !tryParse(sel))) return;
1580
+ const p = sel && vtree && probe(sel, scopeOf(c) || undefined);
1581
+ if (!p || !(p.own || (within != null && p.child))) reportElementCommand(c, cmd);
1582
+ }, 1e3);
1583
+ commandTimers.add(id);
1584
+ };
1585
+ const raise = (code: string, component: string, message: string, fix: string, data: any) => {
1586
+ try { report(code, {component, message, fix, data}); } catch (e) { setTimeout(() => { throw e; }); }
1587
+ };
1588
+ // 1H-11: a render with the same tree and no new listener can't change the result
1589
+ let checkedTree: any, newListener = false;
1590
+ // sels: the selector (inspect() passes it split into words)
1591
+ const probe = (sels: string | string[], scope?: string, target?: any) => {
1592
+ let own = false, child: string | undefined, hit = !target;
1593
+ const sel = tryParse(Array.isArray(sels) ? sels.join(' ') : sels);
1594
+ if (!sel || !sel.length) return {own, child, hit: false};
1595
+ const desc = (_: any, els: any[]) => matches(sel, els);
1596
+ // chain: [vnode, nearest scope][] from the root; inside: under this component's root
1597
+ const walk = (v: any, chain: any[], cur: any, inside: boolean, boundary: any): void => {
1598
+ if (!v || own) return;
1599
+ // a fragment (a Collection's, 4-H): its children are its parent's
1600
+ if (!v.sel) { if (v.children) for (const k of v.children) walk(k, chain, cur, inside, boundary); return; }
1601
+ const sc = scopeOfV(v) || cur;
1602
+ const c = chain.concat([[v, sc]]);
1603
+ inside = inside || sc == scope;
1604
+ if (inside) {
1605
+ if (sc == scope) {
1606
+ if (desc(sels, c.filter(x => x[1] == scope).map(x => x[0]))) own = true;
1607
+ } else {
1608
+ boundary = boundary || sc;
1609
+ if (desc(sels, c.map(x => x[0]))) {
1610
+ child = child || boundary;
1611
+ if (v === target) hit = true;
117
1612
  }
118
1613
  }
1614
+ }
1615
+ for (const k of [].concat(v.children || [])) walk(k, c, sc, inside, sc == scope ? undefined : boundary);
1616
+ };
1617
+ _testingStats.walks++;
1618
+ walk(vtree, [], undefined, !scope, undefined);
1619
+ return {own, child, hit};
1620
+ };
1621
+ const check104 = (target?: any) => {
1622
+ // E4: on the real DOM, 'sygnal/diagnostics' runs its own (real-DOM) SYG104 check
1623
+ if (real && core.__uninstallChecks) return;
1624
+ if (!vtree || !isDiagnosticsEnabled() || (!target && vtree === checkedTree && !newListener)) return;
1625
+ if (!target) checkedTree = vtree, newListener = false;
1626
+ listeners.forEach(path => {
1627
+ const selector = selText(path);
1628
+ const scope = path.filter(isScope).pop();
1629
+ const name = owners.get(scope || '') || 'Component';
1630
+ const key = name + '\u0000' + selector;
1631
+ if (!selector || PAGE.test(selector) || done.has(key)) return;
1632
+ const {own, child, hit} = probe(selector, scope, target);
1633
+ if (own) return done.add(key);
1634
+ if (!child || !hit) return;
1635
+ done.add(key);
1636
+ const childName = owners.get(child) || 'a child component';
1637
+ // CT-1: a control is named by its identifier
1638
+ const control = /^\[data-control="([^"]+)"\]$/.exec(selector)?.[1];
1639
+ raise('SYG104', name,
1640
+ `${control ? `The control ${control}` : `DOM.select('${selector}')`} in ${name} matches elements inside ${childName} (isolated), so ${name} never receives their events`,
1641
+ `Handle the event in ${childName}${control ? ` (DOM.<event>(${control}) in its intent)` : ''} and send it up with PARENT (read it here with CHILD.select(${childName})), or use EVENTS`,
1642
+ control ? {selector, child: childName, control} : {selector, child: childName});
1643
+ });
1644
+ };
119
1645
 
120
- if (!entry) return state;
1646
+ const restore = () => {
1647
+ offDiag();
1648
+ if (ownBridge && core.elementCommand === reportElementCommand) core.elementCommand = undefined;
1649
+ if (!--active) {
1650
+ configureDiagnostics(savedConfig);
1651
+ core.strict = savedStrict;
1652
+ domFakes?.();
1653
+ domFakes = undefined;
1654
+ }
1655
+ };
121
1656
 
122
- // Plain function = state reducer
123
- if (typeof entry === 'function') {
124
- const result = entry(state, data);
125
- // Match component.ts ABORT handling
126
- if (typeof result === 'symbol') return state;
127
- return result !== undefined ? result : state;
128
- }
1657
+ const noop = () => {};
1658
+ const port = () => {
1659
+ const p = {emit: noop as (v: any) => void, $: null as any};
1660
+ p.$ = xs.create({
1661
+ start: (l: any) => { p.emit = v => l.next(v); },
1662
+ stop: () => { p.emit = noop; },
1663
+ });
1664
+ return p;
1665
+ };
1666
+ const hub = port();
129
1667
 
130
- // Object with sink entries — apply STATE reducer if present
131
- if (typeof entry === 'object') {
132
- const stateReducer = entry.STATE || entry[stateSourceName];
133
- if (typeof stateReducer === 'function') {
134
- const result = stateReducer(state, data);
135
- if (typeof result === 'symbol') return state;
136
- return result !== undefined ? result : state;
137
- }
138
- // EFFECT and other non-state sinks
139
- const effectReducer = entry.EFFECT;
140
- if (typeof effectReducer === 'function') {
141
- const next = (nextType: string, nextData?: any) => {
142
- setTimeout(
143
- () => testActionListener.next({type: nextType, data: nextData}),
144
- 10
145
- );
146
- };
147
- effectReducer(state, data, next, {});
1668
+ // E2: scriptable fake sources (t.respond / t.fail) for sinks/sources with no driver. Same
1669
+ // source API as makeFetchDriver / driverFromAsync: select(category?) and errors(category?),
1670
+ // where the selector is a category string, a predicate, or nothing (everything).
1671
+ // R4-2: isolated like the drivers: a source at scope path `ns` sees the replies to requests
1672
+ // made at or under it; requests are tagged by isolateSink (or, for a child-only sink with no
1673
+ // driver, with the component's place in the tree, see nsOf)
1674
+ // PLAN-3 5-1 (H-9): the HTTP half IS makeFetchDriver, run over an in-memory fetch. Every
1675
+ // fetch is a pending entry until t.respond / t.fail resolve it with a Response-like object (or
1676
+ // reject it); the driver aborts superseded / cancelled / disposed requests through their
1677
+ // AbortSignal, which takes them off the pending list. So latest, abort, timeouts, isolation,
1678
+ // reply actions (each to exactly its sender) and resources are the driver's own rules, with no
1679
+ // copy here. `subs` mirrors each select()/errors() subscription, for `request: null` pushes and
1680
+ // for the "nothing receives it" check on plain replies.
1681
+ type FakeSub = {l: any; sel: any; err: boolean; ns: any[]};
1682
+ // value: what t.requests lists (normalised); req: the request the driver got; res: the resource;
1683
+ // pf: a { prefetch } fetch (5-5: answered into the cache, no reply)
1684
+ type Pending = {value: any; req: any; category: any; res?: string; pf?: any; live: boolean; settle: (ok: boolean, v: any) => void};
1685
+ type Fake = {select: any; errors: any; subs: Set<FakeSub>; at: (ns: any[]) => any; pending: Pending[]; in$: any; http: any; ws: Sock};
1686
+ const fakes = new Map<string, Fake>();
1687
+ // 5-3: the fake drivers' focus / online listeners (t.focus, t.online)
1688
+ const signals = new Set<(s: string) => void>();
1689
+ // the value record() is sending (as listed in t.requests), and what the driver's _tap named
1690
+ let sending: any, tapped: any[] | undefined;
1691
+ const fake = (name: string): Fake => {
1692
+ let f = fakes.get(name);
1693
+ if (!f) {
1694
+ const subs = new Set<FakeSub>();
1695
+ const pending: Pending[] = [];
1696
+ const in$ = xs.create();
1697
+ const ws = sockFake();
1698
+ const http = makeFetchDriver({
1699
+ ...httpOptions,
1700
+ // 5-3: focus / online come from t.focus() / t.online() only
1701
+ _on: (f: any) => { signals.add(f); return () => signals.delete(f); },
1702
+ _tap: (req: any, res?: string, pf?: any) => { tapped = [req, res, pf]; },
1703
+ fetch: (url: string, init: any) => new Promise((resolve, reject) => {
1704
+ const [req, res, pf] = tapped || [{url}];
1705
+ tapped = undefined;
1706
+ // G-171(1): a resource fetch is listed as { url, ...request, resource: name }; 5-5: a
1707
+ // { prefetch } fetch as { url, ...request, prefetch: true }
1708
+ const value = res !== undefined ? {...req, resource: res} : pf ? {...req, prefetch: true} : sending !== undefined ? sending : req;
1709
+ if (res !== undefined || pf) requests(name).push(value);
1710
+ const p: Pending = {value, req, category: req.category, res, pf, live: true, settle: (ok, v) => { p.live = false; (ok ? resolve : reject)(v); }};
1711
+ pending.push(p);
1712
+ init?.signal?.addEventListener?.('abort', () => {
1713
+ if (!p.live) return;
1714
+ p.live = false;
1715
+ const e: any = new Error('The operation was aborted.');
1716
+ e.name = 'AbortError';
1717
+ reject(e);
1718
+ });
1719
+ }),
1720
+ })(in$);
1721
+ const at = (ns: any[]): any => {
1722
+ const own = (err: boolean) => (sel?: any) => {
1723
+ let sub: FakeSub;
1724
+ return xs.create({
1725
+ start: (l: any) => { subs.add((sub = {l, sel, err, ns})); },
1726
+ stop: () => { subs.delete(sub); },
1727
+ });
1728
+ };
1729
+ const hs = ns.reduce((s, sc) => s.isolateSource(s, sc), http);
1730
+ // 2-C: the socket driver's source, isolated alike (events without an action reach select(name?))
1731
+ const sock = ns.reduce((s, sc) => s.isolateSource(s, sc), ws.src);
1732
+ return {
1733
+ select: (sel?: any) => xs.merge(hs.select(sel), own(false)(sel), sock.select(sel)),
1734
+ errors: (sel?: any) => xs.merge(hs.errors(sel), own(true)(sel)),
1735
+ subs, at, pending, in$, http, ws,
1736
+ // 5-3: the driver's matcher / inspection (the dev checks' SYG632, t.cache)
1737
+ __matches: http.__matches, __inspect: http.__inspect,
1738
+ isolateSource: (_: any, scope: any) => at(ns.concat(scope)),
1739
+ isolateSink: (sink$: any, scope: any) => sink$.map((v: any) => tag(v, scope)),
1740
+ // G-160: the fake named by `socketSink` receives the components' connections static
1741
+ // 3-A: and the one named by `resourceSink` the resources static
1742
+ ...(name == socketSink ? {__sygnalStatic: 'connections'} : name == resourceSink ? {__sygnalStatic: 'resources'} : {}),
1743
+ __sygnalReplies: true,
1744
+ // both drivers' reply actions; a disposed sender's connections leave t.connections
1745
+ replies: (sender: any) => xs.merge(http.replies(sender), ws.src.replies(sender),
1746
+ xs.create({start: noop, stop: () => { ws.conns.delete(sender); }})),
1747
+ };
1748
+ };
1749
+ fakes.set(name, (f = at([])));
1750
+ }
1751
+ return f!;
1752
+ };
1753
+ // PLAN-3 2-C: the socket half of a fake source. Values with `connections` / `to` go to a real
1754
+ // makeSocketDriver over in-memory sockets, so diffing, reply actions, sharing, queueing and
1755
+ // reconnect are the driver's own. `conns` mirrors what each sender has declared (sender →
1756
+ // name → Conn) and which fake socket serves each connection, for t.connections and targets.
1757
+ type Conn = {by: string; name: string; spec: any; sse: boolean; url: string; k: string; own: boolean; s?: any};
1758
+ type Sock = {src: any; in$: any; conns: Map<any, Map<string, Conn>>; sockets: any[]; sent: any[]; declaring: Conn[] | null};
1759
+ const sockFake = (): Sock => {
1760
+ const w: Sock = {src: null, in$: xs.create(), conns: new Map(), sockets: [], sent: [], declaring: null};
1761
+ const made = (s: any) => {
1762
+ w.sockets.push(s);
1763
+ const all = [...w.conns.values()].flatMap(m => [...m.values()]).filter(c => c.k == s.k);
1764
+ if (w.declaring) {
1765
+ // a declaration opened it: the first new connection with its key (and, shared, the
1766
+ // other new shared ones)
1767
+ const first = w.declaring.find(c => c.k == s.k && !c.s);
1768
+ if (first) [first, ...(first.own ? [] : w.declaring.filter(c => c.k == s.k && !c.s && !c.own))].forEach(c => { c.s = s; });
1769
+ } else {
1770
+ // a reconnect: replaces the oldest dropped socket with its key
1771
+ const old = all.map(c => c.s).filter(x => x && !alive(x)).sort((a, b) => w.sockets.indexOf(a) - w.sockets.indexOf(b))[0];
1772
+ all.forEach(c => { if (c.s === old) c.s = s; });
1773
+ }
1774
+ // autoConnect: it opens on the next macrotask (or at once when a t.push / t.drop needs it)
1775
+ if (autoConnect) {
1776
+ s.auto = true;
1777
+ setTimeout(() => { if (s.readyState === 0 && !disposed) sockOpen(s); });
1778
+ }
1779
+ };
1780
+ w.src = makeSocketDriver({WebSocket: fakeSocketClass(false, made), EventSource: fakeSocketClass(true, made)})(w.in$);
1781
+ return w;
1782
+ };
1783
+ const sockValue = (v: any) => !!v && typeof v == 'object' && ('connections' in v || 'to' in v);
1784
+ const sockRecord = (w: Sock, v: any) => {
1785
+ if ('to' in v && !('connections' in v)) w.sent.push(v);
1786
+ else if (!('then' in v || 'catch' in v)) {
1787
+ const sender = senderOf(v), next = v.connections || {}, old = w.conns.get(sender) || new Map(), now = new Map<string, Conn>();
1788
+ const fresh: Conn[] = [];
1789
+ Object.keys(next).forEach(name => {
1790
+ const spec = next[name];
1791
+ if (!spec || typeof spec != 'object' || (typeof spec.socket != 'string' && typeof spec.sse != 'string') || 'then' in spec || 'catch' in spec) return;
1792
+ const sse = typeof spec.sse == 'string', url = sockUrl(sse ? spec.sse : spec.socket, sse);
1793
+ const k = sockKey(sse, url, sse ? spec : spec.protocols), own = spec.share === false;
1794
+ const c = old.get(name);
1795
+ if (c && c.k == k && c.own == own) { c.spec = spec; now.set(name, c); }
1796
+ else { const n: Conn = {by: v.__emitterName ?? senderNames.get(sender), name, spec, sse, url, k, own}; now.set(name, n); fresh.push(n); }
1797
+ });
1798
+ w.conns.set(sender, now);
1799
+ w.declaring = fresh;
1800
+ try { w.in$.shamefullySendNext(v); } finally { w.declaring = null; }
1801
+ // joined a shared socket that was already there
1802
+ fresh.forEach(c => { if (!c.s && !c.own) c.s = w.sockets.filter(s => s.k == c.k && alive(s)).pop(); });
1803
+ return;
1804
+ }
1805
+ w.in$.shamefullySendNext(v);
1806
+ };
1807
+ const sockFire = (s: any, type: string, ev: any) => {
1808
+ try { s['on' + type]?.(ev); } catch (e) { console.error(e); }
1809
+ };
1810
+ const sockOpen = (s: any) => { s.readyState = 1; sockFire(s, 'open', {type: 'open'}); };
1811
+ const sockState = (c: Conn) => !c.s || c.s.readyState == 0 ? 'connecting' : c.s.readyState == 1 ? 'open' : 'closed';
1812
+ const sockView = (c: Conn): FakeConnection => ({...c.spec, name: c.name, url: c.url, state: sockState(c), sender: c.by});
1813
+ // G-131: a string request is scope-tagged as { url } (like makeFetchDriver's isolateSink),
1814
+ // remembering the string, so t.requests still shows what the component sent
1815
+ const STR = '__sygnalString';
1816
+ const tag = (v: any, scope: any) => {
1817
+ // (G-335: a request is a string or a plain object; an array, a Date, ... is sent as is)
1818
+ if (typeof v != 'string' && !(v && typeof v == 'object' && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null))) return v;
1819
+ const r = tagRequest(v, scope), str = typeof v == 'string' ? v : v[STR];
1820
+ if (str !== undefined) Object.defineProperty(r, STR, {value: str});
1821
+ return r;
1822
+ };
1823
+ const requests = (k: string) => (reqValues[k] = reqValues[k] || []);
1824
+ const reqValues: Record<string, any[]> = {};
1825
+ // a sink value: recorded (t.sinkValues: everything) and, on a fake source, sent to its driver.
1826
+ // G-141 / G-171(1): t.requests lists the requests, normalised (a string is { url }); never the
1827
+ // { abort } commands, { resources } declarations or { refresh } commands. A resource's fetches
1828
+ // are listed by the fake's fetch, with `resource: name`.
1829
+ // 5-7: resource names declared on each sink, so t.respond(name, …, 'quote') right after the
1830
+ // state change that (re)fetches 'quote' waits for that fetch instead of throwing
1831
+ const declaredRes = new Map<string, Set<string>>();
1832
+ // states.length when the last simulate* call was made: no state since then = its change is pending
1833
+ let simAt = -1;
1834
+ // G-189: true from a simulate* call until the requests it causes have left: a sink that also
1835
+ // carries `resources` sends them two microtasks after the action (G-158)
1836
+ let sendDue = false, dueSeq = 0;
1837
+ const record = (name: string, v: any, track: boolean) => {
1838
+ const shown = v && typeof v == 'object' && v[STR] !== undefined ? v[STR] : v;
1839
+ if (v && typeof v == 'object' && v.resources && typeof v.resources == 'object') {
1840
+ const set = declaredRes.get(name) || new Set<string>();
1841
+ Object.keys(v.resources).forEach(k => set.add(k));
1842
+ declaredRes.set(name, set);
1843
+ }
1844
+ sinkValues(name).push(shown);
1845
+ const obj = !!v && typeof v == 'object';
1846
+ const listed = !(obj && (v.abort || v.resources || v.refresh || 'invalidate' in v || 'prefetch' in v));
1847
+ const value = typeof shown == 'string' ? {url: shown} : shown;
1848
+ if (listed) requests(name).push(value);
1849
+ if (!track) return;
1850
+ // 2-C: a socket value goes to the fake's socket driver (it reports SYG610/SYG611 itself)
1851
+ if (obj && sockValue(v)) return sockRecord(fake(name).ws, v);
1852
+ const f = fake(name);
1853
+ sending = listed ? value : undefined;
1854
+ try { f.in$.shamefullySendNext(v); } finally { sending = tapped = undefined; }
1855
+ };
1856
+ const names = Object.keys(model).filter(n => n != 'INITIALIZE');
1857
+ // GS-1: a behavior's actions ('pager.NEXT') can be simulated too; behaviors.ts merges these
1858
+ // marked streams (__sygnalTestActions) with the behavior's own trigger
1859
+ const uses = componentDef.uses || {};
1860
+ for (const k in uses) for (const a in uses[k]?.model || {}) names.push(k + '.' + a);
1861
+ // G-275: the caller's initialState is owned (the dev statics freeze leaves it alone). G-289: a
1862
+ // copy is marked, not the caller's object
1863
+ const init = initialState !== undefined ? ownedCopy(initialState) : componentDef.initialState;
1864
+ // G-028: with no intent, model or initialState nothing would ever emit state, so the view
1865
+ // never renders. Leave intent/model unset so the component falls back to the same no-op
1866
+ // model run() uses, and renders.
1867
+ // (behavior actions count: a host with only `uses` still gets the simulateAction streams)
1868
+ const bare = !intent && !Object.keys(model).length && !names.length && init === undefined;
1869
+ let started = false;
1870
+ const onEvents = (path: string[], type: string, on?: boolean) => {
1871
+ const k = path.join('\u0000');
1872
+ if (on === undefined) {
1873
+ if (!listeners.has(k)) listeners.set(k, path), newListener = true;
1874
+ (evTypes[k] = evTypes[k] || []).push(type);
1875
+ } else {
1876
+ // G-039: subscribed / unsubscribed listeners (a just-mounted child subscribes late)
1877
+ const lk = k + '\u0000' + type;
1878
+ live.set(lk, (live.get(lk) || 0) + (on ? 1 : -1));
1879
+ // (the core subscribes the intent while starting, before retry exists: a microtask
1880
+ // later; G-299: not at all when the start threw)
1881
+ if (on) started ? retry(0) : queueMicrotask(() => { if (started) retry(0); });
1882
+ }
1883
+ };
1884
+ // E4: the real DOM driver (as run() sets it up) patching into a fresh container
1885
+ let container: Element | null = null;
1886
+ let realDOM: any;
1887
+ if (real) {
1888
+ container = document.createElement('div');
1889
+ // a class, not an attribute: the DOM driver's first patch keeps only the root's id and class
1890
+ container.className = 'sygnal-test';
1891
+ document.body.appendChild(container);
1892
+ // G-537 / G-550: a patch error goes through run()'s reporter (start() gives it to the driver's
1893
+ // isolate module): the onError option (G-547: one that throws is logged) and the hooks' onError;
1894
+ // console.error without an onError
1895
+ realDOM = makeDOMDriver(container);
1896
+ }
1897
+ // 4-A1: real-mode patch tracking. Every vtree the DOM sink emits is tagged with the number
1898
+ // of states recorded when it rendered (renderNo); the driver's input is gated so the harness
1899
+ // knows which render is in the DOM (patchedUpTo, lastPatched) and can hold a newer one back
1900
+ // while a wait resolves (holds: the state index a wait resolved at; a vtree rendered after a
1901
+ // newer state waits in `held` until the waiting code has run, i.e. the next macrotask).
1902
+ const renderNo = new WeakMap<object, number>();
1903
+ let patchedUpTo = 0, lastPatched: any, held: any, gateOut: any;
1904
+ const holds: number[] = [];
1905
+ const tagOf = (v: any) => renderNo.get(v) ?? states.length;
1906
+ // G-348: each emitted tree is tagged when it arrives, in both listeners (the gate runs first:
1907
+ // the driver subscribes before the harness). A view that runs again with the same output
1908
+ // re-emits its last vnode (P46-P), so the tag left from the earlier emission would read as a
1909
+ // stale render and hold every input until the next real render
1910
+ const tagTree = (v: any) => { if (v && typeof v == 'object') renderNo.set(v, viewTag || states.length); };
1911
+ const holdLimit = () => (holds.length ? Math.min(...holds) + 1 : Infinity);
1912
+ // G-538: the patches the driver has made (a fragment root never gets an element: flattened, its
1913
+ // children are patched into the container; and a View Transition patches later)
1914
+ let patches = 0;
1915
+ const toDOM = (v: any) => {
1916
+ // the driver patches synchronously when the document is ready; snabbdom sets vnode.elm
1917
+ const p = patches;
1918
+ gateOut?.next(v);
1919
+ if (v?.elm || v && patches > p) patchedUpTo = Math.max(patchedUpTo, tagOf(v)), lastPatched = v;
1920
+ };
1921
+ const gated = (vnode$: any) => {
1922
+ let l: any;
1923
+ return xs.create({
1924
+ start(out: any) {
1925
+ gateOut = out;
1926
+ vnode$.addListener(l = {
1927
+ next: (v: any) => {
1928
+ tagTree(v);
1929
+ // (a state replaced before it ever rendered isn't waited for: the newer render goes in)
1930
+ if (tagOf(v) > holdLimit() && patchedUpTo >= holdLimit()) held = v;
1931
+ else { held = undefined; toDOM(v); }
1932
+ },
1933
+ error: (e: any) => out.error(e),
1934
+ complete: () => out.complete(),
1935
+ });
1936
+ },
1937
+ stop() { vnode$.removeListener(l); gateOut = undefined; },
1938
+ });
1939
+ };
1940
+ const release = (h: number) => {
1941
+ const k = holds.indexOf(h);
1942
+ if (k < 0) return;
1943
+ holds.splice(k, 1);
1944
+ if (held && !disposed && tagOf(held) <= holdLimit()) {
1945
+ const v = held;
1946
+ held = undefined;
1947
+ toDOM(v);
1948
+ bump();
1949
+ }
1950
+ };
1951
+ // PLAN-3 5-4c: the router fake: the app router's options with an in-memory window (scroll and
1952
+ // focus off unless asked for, no Vike navigate), commands the app sent (t.sent), and t.*'s own
1953
+ // commands merged into the driver's input (no sender: they go through `block` like a click)
1954
+ type RouterFake = {mem: ReturnType<typeof memoryWindow>; r: any; cmd$: any; sent: any[]};
1955
+ let rt: RouterFake | undefined;
1956
+ if (fakeRouter) {
1957
+ const loc = (globalThis as any).location;
1958
+ const origin = loc && /^https?:$/.test(loc.protocol) ? loc.origin : 'http://localhost';
1959
+ const mem = memoryWindow(new URL(url ?? '/', origin + '/').href, real, (s: string) => queryIn(s), !!(options.routerScroll || options.routerFocus));
1960
+ const o = router.options;
1961
+ const r = makeRouter({...o, navigate: undefined, location: undefined, history: undefined, document: undefined, window: mem.w,
1962
+ scroll: !!options.routerScroll, focus: options.routerFocus ? (typeof options.routerFocus == 'string' ? options.routerFocus : o.focus) : false});
1963
+ rt = {mem, r, cmd$: xs.create(), sent: []};
1964
+ }
1965
+ const routerDriver = (sink$: any) => {
1966
+ const f = rt!;
1967
+ const app$ = sink$.map((v: any) => {
1968
+ // the `{ route }` declarations are the core's, not commands
1969
+ if (v && typeof v == 'object' && !('route' in v && Object.keys(v).length == 1)) f.sent.push(v);
1970
+ return v;
1971
+ });
1972
+ return f.r.driver(xs.merge(app$, f.cmd$));
1973
+ };
1974
+ const hd = drivers[headSink] ? undefined : headFake();
1975
+ // PLAN-4 GS-7: the timer fake (the real makeTimerDriver() over a runner map t.timers() reads),
1976
+ // unless a driver is passed under timerSink; the test's timers (fake ones too) drive it
1977
+ const {timerSink = 'TIMER'} = options;
1978
+ const tm = drivers[timerSink] ? undefined : new Map<any, any>();
1979
+ // PLAN-5 B-3: the browser fake (the real browser driver over fake sources, browserFake), unless
1980
+ // a driver is passed under browserSink; t.browser.* drives it
1981
+ const {browserSink = 'BROWSER'} = options;
1982
+ const bw = drivers[browserSink] ? undefined : browserFake(options.browser, real);
1983
+ // PLAN-4 GS-5: the fake storage a root's persist() uses (the __storage source; see persist.ts)
1984
+ const store = options.storage || {}, ps = componentDef.persist && {local: fakeStorage(store), session: fakeStorage(store), f: new Set<() => void>()};
1985
+ const allDrivers: any = {
1986
+ DOM: real
1987
+ ? (vnode$: any, name: string) => {
1988
+ const src = realDOM(gated(vnode$), name);
1989
+ // G-538: the root element stream emits after each patch (a fragment root has no element)
1990
+ src._rootElement$.addListener({next: () => patches++});
1991
+ return trackSource(src, [], hub.$, onEvents);
1992
+ }
1993
+ : () => mockDOMSource(mockConfig, hub.$, onEvents),
1994
+ EVENTS: eventBusDriver,
1995
+ LOG: logDriver,
1996
+ ...(hd && {[headSink]: hd.driver}),
1997
+ // (it stands down when a timer driver is passed under another key: one runs the timers)
1998
+ ...(tm && {[timerSink]: (s$: any) => timerDriver(tm)(s$.filter(() => !Object.keys(sources || {}).some(k => k != timerSink && sources[k]?.__sygnalStatic == 'timers')))}),
1999
+ ...(bw && {[browserSink]: (s$: any, n: string) => browserDriver([bw.src], bw.runners)(s$.filter(() => !Object.keys(sources || {}).some(k => k != browserSink && sources[k]?.__sygnalStatic == 'browser')), n)}),
2000
+ ...(rt && {[routerSink]: routerDriver}),
2001
+ ...drivers,
2002
+ ...(options.onError && {__e: () => options.onError}),
2003
+ ...(ps && {__storage: () => ps}),
2004
+ };
2005
+ const faked = new Set<string>();
2006
+ for (const k in model) {
2007
+ const e = model[k];
2008
+ for (const n of e && typeof e == 'object' ? Object.keys(e) : []) {
2009
+ if (!allDrivers[n] && !/^(STATE|EFFECT|PARENT|READY|ELEMENT|PERSIST)$/.test(n)) {
2010
+ allDrivers[n] = () => fake(n);
2011
+ faked.add(n);
2012
+ }
2013
+ }
2014
+ }
2015
+ // G-160, 3-A: the connections / resources statics of any component in the tree get their
2016
+ // fakes. The core reads its drivers once, at start, so the two fakes are drivers from the
2017
+ // start (unused otherwise)
2018
+ for (const n of [socketSink, resourceSink]) {
2019
+ if (!allDrivers[n]) { allDrivers[n] = () => fake(n); faked.add(n); }
2020
+ }
2021
+ // PLAN-4.6 R4: the harness's bookkeeping is a layer of the app's hooks (04 §3.4), recorded
2022
+ // from InstanceViews. `api`: the runtime API (simulateAction dispatches through it)
2023
+ let api: any;
2024
+ const testActions: string[] = [];
2025
+ const isRes = (n: string) => RESERVED_SINKS.test(n) || n == 'PERSIST';
2026
+ // a component's place in the tree below the root (the child-only fakes' scope path, R4-2)
2027
+ const nsCache = new Map<number, any[]>();
2028
+ const nsOfView = (iv: any): any[] => {
2029
+ if (iv.isRoot) return [];
2030
+ let ns = nsCache.get(iv.id);
2031
+ if (!ns) {
2032
+ const p = api?.get(iv.parentId);
2033
+ nsCache.set(iv.id, ns = (p ? nsOfView(p) : []).concat(iv.id));
2034
+ }
2035
+ return ns;
2036
+ };
2037
+ const viewScope = (iv: any) => {
2038
+ const d = iv.sources.DOM;
2039
+ return d && d._hub === hub.$ && (d._path || []).filter(isScope).pop();
2040
+ };
2041
+ // the child-only sinks of an instance (no driver: its sends are recorded and answered by the fake)
2042
+ const childSinks2 = (iv: any) => [...iv.def.sinks].filter((n: string) => !(n in allDrivers) && !isRes(n));
2043
+ const replySubs = new Map<any, Array<[any, any]>>();
2044
+ const nextHooks = () => {
2045
+ const log = actionHooks(actionListener);
2046
+ return {
2047
+ ...log,
2048
+ onCreate(iv: any) {
2049
+ log.onCreate(iv);
2050
+ bump();
2051
+ // (every instance, with or without an intent: SYG104 names the child, inspect() its id)
2052
+ const sc = viewScope(iv);
2053
+ if (sc) owners.set(sc, iv.name);
2054
+ if (iv.sources.DOM?._hub == hub.$) scopeIds.set(sc || '', iv.id);
2055
+ senderNames.set(iv.id, iv.name);
2056
+ if (iv.def.view?.route && !(routerSink in allDrivers)) failWith(new Error(`[Sygnal] ${iv.name} declares \`route\`, and nothing answers it: pass the app's router, renderComponent(${compName}, { router }) (the object makeRouter() returns), or a ${routerSink} driver in drivers`));
2057
+ // the replies to its child-only requests (as the core subscribes a driver's replies).
2058
+ // G-324: here, for every instance: wrapSources runs only for an instance with an intent
2059
+ const extra = childSinks2(iv);
2060
+ if (extra.length) {
2061
+ const subs: Array<[any, any]> = [];
2062
+ for (const n of extra) {
2063
+ const r$ = fake(n).at(nsOfView(iv)).replies(iv.id), l = {next: (a: any) => a && api?.dispatch(iv.id, a.type, a.data, 'reply'), error: noop, complete: noop};
2064
+ r$.addListener(l);
2065
+ subs.push([r$, l]);
148
2066
  }
2067
+ replySubs.set(iv.id, subs);
149
2068
  }
150
-
151
- return state;
152
2069
  },
153
- },
2070
+ wrapSources(iv: any, so: any) {
2071
+ const extra = childSinks2(iv);
2072
+ if (typeof Proxy != 'function') return;
2073
+ // E2 / G-151: a source no driver provides (driver-like name, or one of its sinks) is the
2074
+ // scriptable fake, scoped to the component (R4-2)
2075
+ let ns: any[] | undefined;
2076
+ return new Proxy(so, {
2077
+ get: (t: any, k: any) => typeof k == 'string' && !(k in t) && (DRIVER_NAME.test(k) || extra.includes(k)) ? fake(k).at(ns ||= nsOfView(iv)) : t[k],
2078
+ has: (t: any, k: any) => k in t || (typeof k == 'string' && extra.includes(k)),
2079
+ });
2080
+ },
2081
+ onRender() { bump(); if (real) viewTag = recorded(); },
2082
+ onReducer() { bump(); },
2083
+ onNext(iv: any, type: string, _d: any, ms: number) {
2084
+ const at = clockNow();
2085
+ if (scheduled.length > 50) scheduled.splice(0, scheduled.length - 50);
2086
+ scheduled.push({type, delay: ms, at, due: at + ms, by: iv.name});
2087
+ },
2088
+ // G-064 / G-151: a value on a sink no driver takes: recorded (t.sinkValues, t.requests) and
2089
+ // sent to the fake, stamped with its sender and tagged with its place in the tree
2090
+ onSink(iv: any, type: any, sink: string, v: any) {
2091
+ bump();
2092
+ if (type === null || sink in allDrivers || isRes(sink)) return;
2093
+ // G-335: only a plain object is copied and stamped (an array or a Date keeps its type)
2094
+ if (v && typeof v == 'object' && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null)) v = Object.defineProperties({...v}, {__emitterId: {value: iv.id, configurable: true}, __emitterName: {value: iv.name, configurable: true}});
2095
+ record(sink, nsOfView(iv).reduceRight(tag, v), true);
2096
+ },
2097
+ onDispose(iv: any) {
2098
+ nsCache.delete(iv.id);
2099
+ const subs = replySubs.get(iv.id);
2100
+ if (subs) { replySubs.delete(iv.id); subs.forEach(([s, l]) => { try { s.removeListener(l); } catch (_) {} }); }
2101
+ const sc = viewScope(iv);
2102
+ if (!sc) return;
2103
+ owners.delete(sc);
2104
+ scopeIds.delete(sc);
2105
+ listeners.forEach((path, k) => { if (path.filter(isScope).pop() == sc) listeners.delete(k); });
2106
+ },
2107
+ // t.commands('ELEMENT') records the commands sent and checks them as recordCommands does
2108
+ // (SYG641 when sent, unless the dev entry does; mock DOM: SYG640/641 by the view); the
2109
+ // mock DOM doesn't run them (false)
2110
+ onElementCommand(iv: any, v: any) {
2111
+ bump();
2112
+ const c = {get name() { return iv.name; }, get _disposed() { return iv.disposed; }, get sources() { return iv.sources; }, DOMSourceName: 'DOM'};
2113
+ for (const cmd of ([] as any[]).concat(v)) if (cmd) {
2114
+ // G-425: a sygnal/ui target (a selector with a guarded command) is logged as its selector
2115
+ const m = typeof cmd == 'object' && Object.keys(cmd)[0], x = m && cmd[m];
2116
+ commandLog.push(x?.__sel !== undefined ? {...cmd, [m as string]: x.__sel} : cmd);
2117
+ if (!core.__uninstallChecks) checkSentCommand(c, cmd);
2118
+ if (!real) checkCommand(c, cmd);
2119
+ }
2120
+ return real ? undefined : false;
2121
+ },
2122
+ };
2123
+ };
2124
+ let sources: any, sinks: any;
2125
+ try {
2126
+ // the root runs with the test intent, model, initial state and name, and the drivers above.
2127
+ // G-325: without an intent, every model action is simulate-only (no false SYG102)
2128
+ if (!intent) testActions.push(...names);
2129
+ const p = startNext(componentDef, allDrivers, {
2130
+ useDefaultDrivers: false, onError: options.onError,
2131
+ __hooks: nextHooks(),
2132
+ // simulateAction dispatches through the runtime (cause 'simulateAction'), so the root runs
2133
+ // its own intent; testActions: the model actions it doesn't name (wiring, inspect())
2134
+ __override: {intent: intent ? (s: any) => { const r = intent(s); if (r && typeof r == 'object') testActions.push(...names.filter(n => !(n in r))); return r; } : undefined,
2135
+ model: bare ? undefined : model, initialState: init, name: compName, testActions,
2136
+ // D214: the ancestors' context as constant entries under the component's own
2137
+ ...(ancestors && {context: {...Object.fromEntries(Object.keys(ancestors).map((k) => [k, () => ancestors[k]])), ...componentDef.context}})},
2138
+ });
2139
+ ({sources, sinks} = p);
2140
+ api = p.api;
2141
+ } catch (e) {
2142
+ restore();
2143
+ container?.remove();
2144
+ throw e;
2145
+ }
2146
+
2147
+ started = true;
2148
+ const subs: Array<[any, any]> = [];
2149
+ const listen = (s: any, next: (v: any) => void) => {
2150
+ const l = {next, error: noop, complete: noop};
2151
+ s.addListener(l);
2152
+ subs.push([s, l]);
154
2153
  };
155
2154
 
156
- const stateSourceName = 'STATE';
2155
+ const states: any[] = [];
2156
+ let syncAt: number | undefined;
2157
+ const stateStream: Stream<any> = sources.STATE?.stream || xs.never();
2158
+ listen(stateStream, s => {
2159
+ states.push(s);
2160
+ // PLAN-4.6 R4 (D176): a reducer runs synchronously, so a state caused by input
2161
+ // the harness didn't deliver (a real element's click(), a driver answering at once) can be
2162
+ // recorded before the test's next() call in the same tick: a next() called in that tick
2163
+ // starts at the first such state (internal; a later tick starts after the call, as documented)
2164
+ if (syncAt === undefined) { syncAt = states.length - 1; queueMicrotask(() => { syncAt = undefined; }); }
2165
+ // 2-C: the actions whose STATE reducer ran since the last state produced this one
2166
+ for (const e of awaiting) resulting.set(e, {s});
2167
+ awaiting = [];
2168
+ bump();
2169
+ });
157
2170
 
158
- const app = component({
159
- name,
160
- view,
161
- intent: wrappedIntent,
162
- model: wrappedModel,
163
- context,
164
- initialState: resolvedInitialState,
165
- calculated,
166
- storeCalculatedInState,
167
- onError,
2171
+ const values: Record<string, any[]> = {};
2172
+ const sinkValues = (k: string) => (values[k] = values[k] || []);
2173
+ for (const k in sinks) {
2174
+ if (k != 'DOM' && k != 'STATE' && typeof sinks[k]?.addListener == 'function') {
2175
+ listen(sinks[k], v => record(k, k == 'EVENTS' ? {type: v.type, data: v.data} : k == 'PARENT' ? v.value : v, faked.has(k)));
2176
+ }
2177
+ }
2178
+ // Input queue (G-049/G-039): simulateAction/simulateEvent calls are delivered in order,
2179
+ // once the component is ready. An event whose selector matches no rendered element yet, or
2180
+ // whose matching listeners aren't subscribed yet, holds the queue until it can be delivered
2181
+ // (re-tried on every render), at most eventWaitMs; then it is delivered to the live listeners
2182
+ // (or, with no matching element, fails the test, G-070; with allowMissing it is dropped
2183
+ // with SYG103).
2184
+ type Input = {go: (last: boolean) => boolean, until?: number, wait?: number, at?: number, missing?: () => Error | undefined};
2185
+ const inputs: Input[] = [];
2186
+ let isReady = false, retryTimer: any;
2187
+ const pump = () => {
2188
+ if (!isReady || disposed) return;
2189
+ while (inputs.length) {
2190
+ const head = inputs[0];
2191
+ // E4: on the real DOM an input acts on the patched DOM, like a user: it waits until the
2192
+ // tree has been quiet for QUIET_MS (at most 100ms), so e.g. a button enabled by the
2193
+ // previous input is enabled when it is clicked
2194
+ const idle = clockNow() - lastActivity;
2195
+ if (real) {
2196
+ const waited = clockNow() - (head.at = head.at || clockNow());
2197
+ if (idle < QUIET_MS && waited < 100) return retry(QUIET_MS - idle);
2198
+ // 4-A1: and every recorded state's render is in the DOM (under load a render can lag the
2199
+ // quiet window), at most eventWaitMs; not while a render is held for a resolving wait
2200
+ const behind = !!vtree && (lastPatched !== vtree || patchedUpTo < states.length);
2201
+ if ((behind && waited < eventWaitMs) || held) return retry(1);
2202
+ }
2203
+ head.until = head.until || clockNow() + (head.wait ?? eventWaitMs);
2204
+ if (!head.go(clockNow() >= head.until)) return retry(5);
2205
+ inputs.shift();
2206
+ bump();
2207
+ }
2208
+ };
2209
+ const retry = (ms: number) => {
2210
+ if (!retryTimer && inputs.length) retryTimer = setTimeout(() => { retryTimer = 0; pump(); }, ms);
2211
+ };
2212
+ let markReady: () => void;
2213
+ // G-065: states.length when the component became ready (before the buffered input was
2214
+ // replayed). ready() arms a cursor there (or, when ready() is called on an already-ready
2215
+ // component, at the call), so `await t.ready()` doesn't make next() miss the states the
2216
+ // replayed input produced. R2-6: every next() call starts at the cursor while it is armed
2217
+ // (so `Promise.all([t.next(a), t.next(b)])` both do). It is disarmed by simulateEvent(),
2218
+ // simulateAction(), settle(), waitForState(), a later ready() (which re-arms it), and by
2219
+ // the first next() that started at it resolving. If no next() has used it by the
2220
+ // macrotask after ready() resolves, it expires (an un-awaited ready() in a beforeEach
2221
+ // doesn't make a much later next() return an old state).
2222
+ let fromInput = false;
2223
+ let readyAt = 0, cursor: number | undefined, shown: number | undefined, arming = 0, cursorUsed = false;
2224
+ // G-346: one expiry timer at a time, cleared once a next() uses the cursor (no timer is left
2225
+ // pending after `await t.next()`, e.g. vi.getTimerCount() under fake timers)
2226
+ let expiry: any = 0;
2227
+ const unexpire = () => { if (expiry) { clearTimeout(expiry); expiry = 0; } };
2228
+ const expire = (id: number) => {
2229
+ unexpire();
2230
+ expiry = setTimeout(() => { expiry = 0; if (id == arming && !cursorUsed) cursor = undefined; });
2231
+ };
2232
+ const readyPromise = new Promise<void>(r => {
2233
+ markReady = () => {
2234
+ // G-346: the fallback (no first render) has nothing left to do
2235
+ clearTimeout(fallback);
2236
+ readyAt = states.length;
2237
+ isReady = true;
2238
+ pump();
2239
+ r();
2240
+ };
168
2241
  });
2242
+ const ready = () => {
2243
+ // (D176: a state of this tick, e.g. from a simulate* call just before, is "now")
2244
+ cursor = isReady ? Math.min(fromInput && cursor !== undefined && cursor >= 0 ? cursor : states.length, syncAt ?? states.length) : -1;
2245
+ fromInput = false;
2246
+ shown = undefined;
2247
+ const id = ++arming;
2248
+ cursorUsed = false;
2249
+ readyPromise.then(() => { if (id == arming && !cursorUsed && !disposed) expire(id); });
2250
+ // 4-A1: on the real DOM, ready() also waits until the first render is in the DOM
2251
+ // R4-8: rejected by dispose()
2252
+ return drive(new Promise<void>((resolve, reject) => {
2253
+ readyWaiters.add(reject);
2254
+ (real ? readyPromise.then(() => untilPatched()) : readyPromise).then(() => { readyWaiters.delete(reject); resolve(); });
2255
+ }), () => disposed);
2256
+ };
2257
+ const readyWaiters = new Set<(e: Error) => void>();
2258
+ const later = (go: Input['go'], missing?: Input['missing']) => {
2259
+ const was = fromInput && cursor !== undefined ? cursor : undefined;
2260
+ cursor = shown = undefined;
2261
+ // D165: an input's STATE reducer is applied synchronously, so the state it causes can be
2262
+ // recorded before the test's next() call: next() starts at the input (several simulate*
2263
+ // calls in the same tick: the cursor stays at the first one's state)
2264
+ cursor = was ?? states.length;
2265
+ if (!fromInput) queueMicrotask(() => { fromInput = false; });
2266
+ fromInput = true; cursorUsed = false;
2267
+ // G-326: like ready()'s, the cursor expires at the next macrotask unless a next() used it,
2268
+ // so a test that moves the clock (or waits) before next() gets the state after the call
2269
+ expire(++arming);
2270
+ inputs.push({go, missing});
2271
+ pump();
2272
+ };
169
2273
 
170
- const wrapped = withState(app, stateSourceName);
171
- const mockDOM = () => mockDOMSource(mockConfig);
2274
+ let vtree: any;
2275
+ let timer: any;
2276
+ // states[0 .. renderedUpTo) were recorded before the latest render (1H-12)
2277
+ let renderedUpTo = 0;
2278
+ const arm = () => timer || (timer = setTimeout(() => markReady(), 12));
2279
+ if (sinks.DOM) {
2280
+ listen(sinks.DOM, v => {
2281
+ vtree = v;
2282
+ index(v);
2283
+ renderedUpTo = states.length;
2284
+ if (real) tagTree(v);
2285
+ bump();
2286
+ check104();
2287
+ arm();
2288
+ // E4: the real DOM is patched a microtask after the sink emits
2289
+ real ? retry(0) : pump();
2290
+ });
2291
+ }
2292
+ // 1H-4: a component that never renders on its own (a model but no initialState: no state
2293
+ // until an action sets it) still becomes ready, so buffered input is delivered.
2294
+ // G-176: one that has a state but hasn't rendered it yet is only slow (a loaded machine):
2295
+ // keep waiting for its first render (at most timeoutMs), or ready() resolves before it and
2296
+ // query() returns null
2297
+ const fallbackFrom = clockNow();
2298
+ const fallbackCheck = () => {
2299
+ if (!timer && sinks.DOM && states.length && clockNow() - fallbackFrom < defaultTimeout) fallback = setTimeout(fallbackCheck, 10);
2300
+ else arm();
2301
+ };
2302
+ let fallback = setTimeout(fallbackCheck, sinks.DOM ? 30 : 0);
172
2303
 
173
- const baseDrivers: Record<string, any> = {
174
- DOM: mockDOM,
175
- EVENTS: eventBusDriver,
176
- LOG: logDriver,
177
- ...drivers,
2304
+ const tick = (ms: number) => new Promise(r => setTimeout(r, ms));
2305
+ /**
2306
+ * G-047: resolves once the root has rendered states[0 .. n) and the whole tree has been
2307
+ * quiet (no render, reducer, state or input anywhere) for `quiet` ms, checked twice, so
2308
+ * child components have rendered them too. Gives up after `cap` ms (an app that never
2309
+ * goes quiet): false.
2310
+ */
2311
+ const quiesce = async (n: number, quiet: number, cap: number, busy = () => false): Promise<boolean> => {
2312
+ const start = clockNow();
2313
+ let seen = -1;
2314
+ while (!disposed) {
2315
+ const idle = clockNow() - lastActivity;
2316
+ if (clockNow() - start > cap) return false;
2317
+ if ((!sinks.DOM || renderedUpTo >= n || clockNow() - start > 100) && idle >= quiet && !busy()) {
2318
+ if (seen === activity) return true;
2319
+ seen = activity;
2320
+ await tick(3);
2321
+ } else {
2322
+ seen = -1;
2323
+ await tick(Math.max(1, quiet - idle));
2324
+ }
2325
+ }
2326
+ return true;
2327
+ };
2328
+ const treeRendered = (n: number) => quiesce(n, QUIET_MS, 250);
2329
+ /**
2330
+ * 4-A1 (real DOM): resolves once a render of states[0 .. n) is in the DOM and either the tree
2331
+ * has been quiet for QUIET_MS (children have rendered it too) or a newer state has arrived
2332
+ * (its render is held back by the caller's hold, so the DOM still shows state n - 1). A state
2333
+ * that never renders (same view) counts after 100ms of quiet. Gives up after 250ms.
2334
+ */
2335
+ const patchedTree = async (n: number): Promise<void> => {
2336
+ const start = clockNow();
2337
+ let seen = -1;
2338
+ while (!disposed && clockNow() - start <= 250) {
2339
+ const idle = clockNow() - lastActivity;
2340
+ const current = !vtree || lastPatched === vtree;
2341
+ if (patchedUpTo >= n && (held || states.length > n)) return;
2342
+ if ((patchedUpTo >= n || clockNow() - start > 100) && current && idle >= QUIET_MS) {
2343
+ if (seen === activity) return;
2344
+ seen = activity;
2345
+ await tick(3);
2346
+ } else {
2347
+ seen = -1;
2348
+ await tick(Math.max(1, QUIET_MS - idle));
2349
+ }
2350
+ }
2351
+ };
2352
+ /** 4-A1 (real DOM): until the latest render is in the DOM (at most 250ms) */
2353
+ const untilPatched = async (): Promise<void> => {
2354
+ const start = clockNow();
2355
+ while (!disposed && vtree && (lastPatched !== vtree || held) && clockNow() - start <= 250) await tick(1);
178
2356
  };
179
2357
 
180
- const {sources, sinks, run: _run} = setup(wrapped, baseDrivers as any);
181
- const rawDispose = _run();
2358
+ // G-070: a simulateEvent whose selector matches nothing fails the test. The error rejects
2359
+ // the pending next()/waitForState()/settle() calls; with none pending it is kept and thrown
2360
+ // by the next simulate*/wait/expectNoDiagnostics()/dispose() call.
2361
+ let failure: Error | undefined;
2362
+ const waiters = new Set<(e: Error) => void>();
2363
+ const failWith = (e: Error) => {
2364
+ if (!waiters.size) return void (failure = failure || e);
2365
+ const ws = [...waiters];
2366
+ waiters.clear();
2367
+ ws.forEach(r => r(e));
2368
+ };
2369
+ const takeFailure = () => { const f = failure; failure = undefined; return f; };
2370
+ const throwFailure = () => { const f = takeFailure(); if (f) throw f; };
2371
+ const noMatch = (selector: string, type: string, waited: boolean) => {
2372
+ const out = renderHtml();
2373
+ return new Error(`[Sygnal] simulateEvent('${selector}', '${type}'): the selector matched nothing in the rendered output` +
2374
+ (waited ? ` (waited ${eventWaitMs}ms for it to render; the eventWaitMs option sets this)` : '') +
2375
+ `. Check t.html() to see what rendered, or give the element an attribute and select it, e.g. [data-id="3"]` +
2376
+ ` (pass { allowMissing: true } to drop the event instead).\nRendered: ${out.length > 600 ? out.slice(0, 600) + '…' : out || '(nothing)'}`);
2377
+ };
182
2378
 
183
- // Collect state values
184
- const states: any[] = [];
185
- let stateListener: any = null;
186
- const stateStream: Stream<any> =
187
- sources.STATE && sources.STATE.stream ? sources.STATE.stream : xs.never();
188
-
189
- stateListener = {
190
- next: (s: any) => states.push(s),
191
- error: () => {},
192
- complete: () => {},
193
- };
194
- stateStream.addListener(stateListener);
195
-
196
- // simulateAction: push into the test action stream
197
- const simulateAction = (actionName: string, data?: any) => {
198
- testActionListener.next({type: actionName, data});
199
- };
200
-
201
- // waitForState: resolve when the predicate matches
202
- const waitForState = (
203
- predicate: (state: any) => boolean,
204
- timeoutMs: number = 2000
205
- ): Promise<any> => {
206
- return new Promise((resolve, reject) => {
207
- // Check already-collected states
208
- for (const s of states) {
209
- try {
210
- if (predicate(s)) return resolve(s);
211
- } catch (_) {}
2379
+ const due = () => {
2380
+ const n = ++dueSeq;
2381
+ sendDue = true;
2382
+ Promise.resolve().then(noop).then(noop).then(noop).then(() => { if (n == dueSeq) sendDue = false; });
2383
+ };
2384
+ const simulateAction = (type: string, data?: any) => {
2385
+ simAt = states.length; due();
2386
+ throwFailure();
2387
+ later(() => (api.dispatch('root', type, data, 'simulateAction'), true));
2388
+ };
2389
+
2390
+ // E2: t.respond / t.fail. PLAN-3 1-C: the request is chosen by content (G-140, E2 13-t4):
2391
+ // a string is an ok/error action name, key or category; a function a predicate; an object
2392
+ // (or `{ request }`) a partial request compared by value, preferring the very object of
2393
+ // t.requests among equal ones. With no target: the newest pending request.
2394
+ // When to pick: a call made while simulate*/respond/fail calls are still queued, or before
2395
+ // the component is ready, is queued behind them and picks its request when it is delivered,
2396
+ // waiting up to 1s for it (a debounce); otherwise the request must be pending at the call,
2397
+ // or the call throws (`expect(() => t.respond(...)).toThrow()`).
2398
+ // The returned promise resolves once the reply has been reduced and the tree rendered. A
2399
+ // failure later on rejects it; when nothing awaits it, it also fails the next wait.
2400
+ const replyWaits = new Set<(e?: Error, quiet?: boolean) => void>();
2401
+ const OPTION_KEYS = ['category', 'request', 'status', 'body', 'nth'];
2402
+ type Target = {match: (r: Pending) => boolean; exact?: any; desc: string; push?: boolean; o: any};
2403
+ // a pending request is matched by its t.requests form (`value`): a string request is { url },
2404
+ // a resource fetch carries `resource: name`
2405
+ const targetOf = (opts: any): Target => {
2406
+ const isOpts = !!opts && typeof opts == 'object' && !Array.isArray(opts) && Object.keys(opts).every(k => OPTION_KEYS.includes(k));
2407
+ const o = isOpts ? opts : {};
2408
+ // request: null pushes a value no request asked for (a source that emits on its own)
2409
+ if (isOpts && o.request === null) return {match: () => false, desc: '', push: true, o};
2410
+ let tg = isOpts ? o.request : opts;
2411
+ if (isOpts && typeof tg == 'string') tg = {url: tg};
2412
+ const cat = 'category' in o ? (r: Pending) => r.category === o.category : () => true;
2413
+ if ('nth' in o && !Number.isInteger(o.nth)) throw new Error(`[Sygnal] t.respond/t.fail: nth must be an integer (a position in t.requests(name): 0 the first, -1 the newest; got ${brief(o.nth)})`);
2414
+ const catDesc = ('category' in o ? ` with category '${o.category}'` : '') + ('nth' in o ? ` at nth: ${o.nth}` : '');
2415
+ if (tg === undefined) return {match: cat, desc: catDesc, o};
2416
+ if (typeof tg == 'string') {
2417
+ // an ok/error action name, key, category, resource name or URL
2418
+ return {match: r => { const v = r.value || {}; return cat(r) && [v.ok, v.error, v.key, v.category, r.res, v.url].includes(tg); }, desc: ` matching '${tg}'${catDesc}`, o};
2419
+ }
2420
+ if (typeof tg == 'function') {
2421
+ return {match: r => { try { return cat(r) && !!tg(r.value); } catch (_) { return false; } }, desc: ` matching the predicate${catDesc}`, o};
2422
+ }
2423
+ if (tg && typeof tg == 'object') {
2424
+ const keys = Object.keys(tg);
2425
+ return {match: r => cat(r) && !!r.value && keys.every(k => same(tg[k], r.value[k])), exact: isOpts ? o.request : tg, desc: ` matching ${brief(tg)}${catDesc}`, o};
2426
+ }
2427
+ throw new Error(`[Sygnal] t.respond/t.fail: the target must be an action name, key, category, resource name or URL, a request object, a predicate or { request, category, status, body } options (got ${typeof tg})`);
2428
+ };
2429
+ // 6-B (G-185): `nth` picks one request of t.requests(name) (those matching the rest of the
2430
+ // target) by position, pending or not: identical requests can't be told apart by content
2431
+ const nthOf = (name: string, tg: Target): {list: Pending[]; hit?: Pending} => {
2432
+ const ps = fakes.get(name)?.pending || [];
2433
+ const list = requests(name).map(v => ps.find(p => p.value === v) ||
2434
+ {value: v, req: v, category: v?.category, res: v?.resource, live: false, settle: noop} as Pending).filter(tg.match);
2435
+ const n = tg.o.nth;
2436
+ return {list, hit: list[n < 0 ? list.length + n : n]};
2437
+ };
2438
+ const pick = (name: string, tg: Target): Pending | undefined => {
2439
+ if ('nth' in tg.o) {
2440
+ const {hit} = nthOf(name, tg);
2441
+ return hit?.live ? hit : undefined;
2442
+ }
2443
+ const live = (fakes.get(name)?.pending || []).filter(r => r.live && tg.match(r));
2444
+ return (tg.exact !== undefined && live.filter(r => r.value === tg.exact || r.req === tg.exact).pop()) || live.pop();
2445
+ };
2446
+ const noPending = (what: string, name: string, tg: Target, waited: number) => {
2447
+ if ('nth' in tg.o) {
2448
+ const {list, hit} = nthOf(name, tg), all = requests(name).length;
2449
+ return new Error(`[Sygnal] ${what}: ` + (hit
2450
+ ? `t.requests('${name}')[${requests(name).indexOf(hit.value)}] (the one${tg.desc}) is not pending: it was answered, aborted, or superseded by a later latest: true request or a refetch of its resource. That is what expect(() => t.respond(...)).toThrow() asserts.`
2451
+ : `no pending ${name} request${tg.desc}${waited ? ` after ${waited}ms` : ''}: t.requests('${name}') has ${list.length} request${list.length == 1 ? '' : 's'}${list.length < all ? ` matching (${all} in all)` : ''}.`));
2452
+ }
2453
+ const sent = requests(name).length, live = fakes.get(name)?.pending.filter(r => r.live) || [];
2454
+ return new Error(`[Sygnal] ${what}: no pending ${name} request${tg.desc}${waited ? ` after ${waited}ms` : ''}. ` +
2455
+ (live.length ? `Pending: ${live.map(r => brief(r.value)).join(', ')}. ` : '') +
2456
+ (sent ? `The component sent ${sent} (t.requests('${name}'))${live.length ? '; the others were' : ','} all answered, aborted or superseded by a later latest: true request.` :
2457
+ `The component sent none: check the model entry that returns the ${name} request (t.requests('${name}') is empty).`) +
2458
+ (waited ? '' : ` t.respond/t.fail answer a request already sent, or one sent by the simulate* calls queued before them: wait for a later one first (await t.next(...) or t.settle()).`));
2459
+ };
2460
+ /**
2461
+ * 5-1: `deliver(e, o)` settles the pending fetch `e` (the driver then routes the reply);
2462
+ * `payload(category, o, e?)` is what select()/errors() would get, for a `request: null` push
2463
+ * and for the "nothing receives it" check on a plain reply.
2464
+ */
2465
+ const reply = (fn: string, name: string, err: boolean, opts: any, deliver: (e: Pending, o: any) => void, payload: (category: any, o: any, e?: Pending) => any): Promise<void> => {
2466
+ throwFailure();
2467
+ if (drivers[name]) throw new Error(`[Sygnal] t.${fn}('${name}'): ${name} has a real driver (passed in drivers), so there is nothing to script. t.respond/t.fail answer the fake source renderComponent provides when no driver is passed`);
2468
+ const tg = targetOf(opts);
2469
+ const what = `t.${fn}('${name}'${typeof opts == 'string' ? `, …, '${opts}'` : ''})`;
2470
+ // a resource named by the target may still be about to fetch (its request follows the state
2471
+ // change): queue the call like one made behind queued input
2472
+ const later = typeof opts == 'string' && !!declaredRes.get(name)?.has(opts) && states.length <= simAt ||
2473
+ // G-189: called at once after a simulate* call, on a sink that carries `resources`: the
2474
+ // requests it causes leave two microtasks later (G-158), so the call waits for them.
2475
+ // G-218: so does any request: behind a same-tick STATE reducer (several queued simulate*
2476
+ // calls), an action's non-STATE sinks run in a microtask (B-003)
2477
+ sendDue && !tg.push && !('nth' in tg.o);
2478
+ return scripted(() => tg.push ? {} : pick(name, tg), w => noPending(what, name, tg, w), hit => {
2479
+ const f = fake(name), o = tg.o;
2480
+ const e: Pending | undefined = tg.push ? undefined : hit;
2481
+ const category = 'category' in o ? o.category : e?.category;
2482
+ // the driver delivers a resource's reply, and a reply action for a request that names one
2483
+ // for this outcome (from a component), as that action; a prefetch into the cache; anything
2484
+ // else on select()/errors()
2485
+ if (e && (e.res !== undefined || e.pf || (senderOf(e.req) !== undefined && (err ? e.req.error : e.req.ok)))) return deliver(e, o);
2486
+ const data = payload(category, o, e);
2487
+ let heard = false;
2488
+ f.subs.forEach(sub => {
2489
+ let hit = false;
2490
+ // a pushed value no request asked for (request: null) reaches every scope
2491
+ try { hit = sub.err === err && (!e || inScope(sub.ns, e.req)) && (sub.sel === undefined || (typeof sub.sel == 'function' ? sub.sel(data) : sub.sel === category)); } catch (_) {}
2492
+ if (hit) { heard = true; if (!e) sub.l.next(data); }
2493
+ });
2494
+ if (heard) return e && deliver(e, o);
2495
+ if (e) e.live = false;
2496
+ const ls = [...f.subs].filter(x => x.err === err).map(x => `${name}.${err ? 'errors' : 'select'}(${x.sel === undefined ? '' : typeof x.sel == 'function' ? 'fn' : `'${x.sel}'`})`);
2497
+ return new Error(`[Sygnal] ${what}: nothing receives it: no intent listens to ${name}.${err ? 'errors' : 'select'}(${category === undefined ? '' : `'${category}'`})` +
2498
+ (ls.length ? ` (listening: ${ls.join(', ')})` : '') + `. ` +
2499
+ (err ? `Name a reply action for the failure (error: 'FAILED' on the request), or handle it in the intent, e.g. FAILED: ${name}.errors('${category ?? 'category'}'), so a failed request can't leave the component loading.` :
2500
+ `Name a reply action for the reply (ok: 'LOADED' on the request), or select it in the intent, e.g. LOADED: ${name}.select('${category ?? 'category'}').`));
2501
+ }, later);
2502
+ };
2503
+ /**
2504
+ * G-140 / PLAN-3 1-C: a scripted input (t.respond/t.fail, 2-C's t.open/t.push/t.drop). With
2505
+ * nothing queued before it and the component ready, `find()` must match now or the call
2506
+ * throws `none(0)`; otherwise it is queued and finds its target when delivered, waiting up to
2507
+ * 1s (half of timeoutMs if lower). `act(hit)` delivers it (an Error: it failed). The promise
2508
+ * resolves once the result has been reduced and the whole tree rendered.
2509
+ */
2510
+ const scripted = (find: () => any, none: (waited: number) => Error, act: (hit: any) => Error | void, later = false): Promise<void> => {
2511
+ throwFailure();
2512
+ if (isReady && !inputs.length && !disposed && !later && !find()) throw none(0);
2513
+ let ok!: () => void, ko!: (e: Error) => void, seen = false, open = true;
2514
+ const inner = new Promise<void>((a, b) => { ok = a; ko = b; });
2515
+ inner.catch(noop);
2516
+ const out = new Reply((a, b) => inner.then(a, b));
2517
+ Promise.prototype.then.call(out, undefined, noop);
2518
+ out._seen = () => {
2519
+ if (seen) return;
2520
+ seen = true;
2521
+ // E11: under fake timers, an awaited reply drives the clock like the harness's waits
2522
+ if (open && fakeClock()) drive(inner, () => disposed).catch(noop);
2523
+ };
2524
+ const settle = (e?: Error, quiet?: boolean) => {
2525
+ if (!open) return;
2526
+ open = false;
2527
+ replyWaits.delete(settle);
2528
+ if (!e) return ok();
2529
+ if (!seen && !quiet) failWith(e);
2530
+ ko(e);
2531
+ };
2532
+ replyWaits.add(settle);
2533
+ const wait = Math.min(1000, defaultTimeout / 2);
2534
+ const input: Input = {
2535
+ // up to 1s (half of timeoutMs if lower), so a wait (next/settle) still times out later
2536
+ wait,
2537
+ go: last => {
2538
+ const hit = find();
2539
+ if (!hit) {
2540
+ if (!last) return false;
2541
+ settle(none(wait));
2542
+ return true;
2543
+ }
2544
+ const failed = act(hit);
2545
+ if (failed) {
2546
+ settle(failed);
2547
+ return true;
2548
+ }
2549
+ // resolved once the reply has been reduced and the whole tree rendered (in the DOM)
2550
+ treeRendered(states.length).then(() => real ? untilPatched() : undefined).then(() => settle(), noop);
2551
+ return true;
2552
+ },
2553
+ };
2554
+ cursor = shown = undefined;
2555
+ inputs.push(input);
2556
+ pump();
2557
+ return out;
2558
+ };
2559
+ // PLAN-5 B-3: t.browser, the browser fake's controls. An event-like input (intersect, resize,
2560
+ // geolocation) must reach a declaration (else it throws, as t.respond); an environment change
2561
+ // (media, storage, visibility, online, clipboard) is kept and sent to the declarations of it
2562
+ const bwOf = (what: string) => {
2563
+ if (!bw) throw new Error(`[Sygnal] t.browser.${what}(): ${browserSink} has a real driver (passed in drivers); t.browser drives the fake renderComponent provides when no driver is passed`);
2564
+ return bw;
2565
+ };
2566
+ const toLive = (what: string, k: string, key: any, at: number | undefined, f: (e: any) => void) => {
2567
+ const b = bwOf(what);
2568
+ const hits = () => { const l = [...b.live].filter(e => e.k == k && (key === undefined || e.s[k] === key)); return at === undefined ? l : l.slice(at, at + 1); };
2569
+ const desc = `${k}${key === undefined ? '' : ` ${typeof key == 'string' ? `'${key}'` : key}`}`;
2570
+ return scripted(() => hits().length ? hits() : undefined,
2571
+ (waited) => new Error(`[Sygnal] t.browser.${what}(): nothing declares ${desc}${at === undefined ? '' : ` at ${at}`}${waited ? ` (waited ${waited}ms)` : ''}. Declared: ${[...b.live].map(e => `${e.k} ${JSON.stringify(e.s[e.k])}`).join(', ') || 'none'}`),
2572
+ (l: any[]) => { l.forEach(f); });
2573
+ };
2574
+ const envChange = (what: string, f: (b: any) => void) => { const b = bwOf(what); return scripted(() => true, () => new Error(''), () => { f(b); }); };
2575
+ const tBrowser = {
2576
+ intersect: (target: string | true, visible = true, o: any = {}) => {
2577
+ const {at, ...d} = o;
2578
+ return toLive('intersect', 'intersection', target, at, e => e.c.send({visible, ratio: visible ? 1 : 0, index: 0, dataset: {}, ...d}));
2579
+ },
2580
+ resize: (target: string | true, size: any) => {
2581
+ const {at, ...d} = size || {};
2582
+ return toLive('resize', 'resize', target, at, e => e.c.send({width: 0, height: 0, index: 0, dataset: {}, ...d}));
2583
+ },
2584
+ geolocation: (p: any) => {
2585
+ const err = p && 'code' in p;
2586
+ if (!err) bwOf('geolocation').env.pos = p;
2587
+ return toLive('geolocation', 'geolocation', undefined, undefined, e => err ? e.c.fail({code: p.code, message: p.message ?? ''}) : e.c.send(bw!.pos(p)));
2588
+ },
2589
+ media: (query: string, matches: boolean) => envChange('media', b => { b.env.media[query] = matches; b.each('media', (e: any) => e.c.send({matches, media: query}), query); }),
2590
+ visibility: (visible: boolean) => envChange('visibility', b => { b.env.visible = visible; b.each('visibility', (e: any) => e.c.send({visible})); }),
2591
+ online: (online: boolean) => envChange('online', b => { b.env.online = online; b.each('online', (e: any) => e.c.send({online})); }),
2592
+ storage: function (key: string, value?: any, area: 'local' | 'session' = 'local'): any {
2593
+ if (arguments.length < 2) return bwOf('storage').env[area == 'session' ? 'session' : 'local'][key] ?? null;
2594
+ return envChange('storage', b => b.write(key, value, area));
2595
+ },
2596
+ clipboard: function (text?: string): any {
2597
+ if (!arguments.length) return bwOf('clipboard').env.clip;
2598
+ return envChange('clipboard', b => { b.env.clip = '' + text; });
2599
+ },
2600
+ deny: (...kinds: string[]) => {
2601
+ const b = bwOf('deny');
2602
+ kinds.forEach(k => b.env.deny.add(k));
2603
+ if (kinds.includes('geolocation')) b.each('geolocation', (e: any) => e.c.fail(b.DENIED));
2604
+ },
2605
+ active: () => {
2606
+ const b = bwOf('active'), list: any[] = [];
2607
+ b.runners.forEach((r: any) => { for (const name in r.on) { const {s} = r.on[name]; if ([...b.live].some((e: any) => e.s === s)) list.push({name, ...s, component: r.c}); } });
2608
+ return list;
2609
+ },
2610
+ };
2611
+ // 5-1: the fetch resolves with a Response-like object (status, the body as JSON, or text for a
2612
+ // string), which the driver parses as it would a server's (a Response passed in is used as is)
2613
+ const urlOf = (e: Pending) => e.value?.url ?? e.req.url ?? '';
2614
+ const respond = (name: string, value: any, opts?: FakeReplyTarget) =>
2615
+ reply('respond', name, false, opts,
2616
+ (e, o) => e.settle(true, typeof Response == 'function' && value instanceof Response ? value : fakeResponse(o.status ?? 200, value, urlOf(e))),
2617
+ (category, o, e) => ({category, value, status: o.status ?? 200, request: e?.req}));
2618
+ // 5-1: a number (or a `status` option) is an HTTP error response the driver turns into its
2619
+ // Error('HTTP 404: url') with `status` / `body`; an Error or a message is a network failure
2620
+ // (the fetch rejects with it)
2621
+ // D199: `{ status, body }` (not an Error) is an error response too, as the JSDoc says
2622
+ const failureOf = (error: any, o: any) => {
2623
+ const resp = !!error && typeof error == 'object' && !(error instanceof Error) && typeof error.status == 'number';
2624
+ const status = typeof error == 'number' ? error : resp ? error.status : o.status;
2625
+ const x = typeof error == 'string' ? new Error(error) : error;
2626
+ return {status, x, resp, body: o.body ?? (x && typeof x == 'object' ? x.body : undefined)};
2627
+ };
2628
+ const fail = (name: string, error: any, opts?: FakeReplyTarget) =>
2629
+ reply('fail', name, true, opts,
2630
+ (e, o) => {
2631
+ const {status, x, body} = failureOf(error, o);
2632
+ status !== undefined ? e.settle(true, fakeResponse(status, body, urlOf(e))) : e.settle(false, x);
2633
+ },
2634
+ (category, o, e) => {
2635
+ const {status, x, resp, body} = failureOf(error, o);
2636
+ const err = typeof error == 'number' || resp ? Object.assign(new Error(`HTTP ${status}`), {status}) : x;
2637
+ return {error: err, category, request: e?.req, status: status ?? x?.status, body};
2638
+ });
2639
+
2640
+ // PLAN-3 2-C: socket fakes. t.connections lists what the components declared; t.open /
2641
+ // t.push / t.drop act on the fake sockets serving the connections `target` picks (a name or
2642
+ // URL, a partial connection, a predicate; nothing: the newest socket that can take the call),
2643
+ // with t.respond's rules (scripted()): they throw at the call when nothing matches.
2644
+ const conns = (name: string): Conn[] => [...(fakes.get(name)?.ws.conns.values() || [])].flatMap(m => [...m.values()]);
2645
+ const connections = (name: string) => conns(name).map(sockView);
2646
+ // 5-3: the driver's own view of its cache; t.focus / t.online fire the fake drivers' signals
2647
+ const cache = (name: string) => fake(name).http.__inspect().cache || [];
2648
+ const signal = (s: string) => later(() => (signals.forEach(f => f(s)), true));
2649
+ const sent = (name: string, to?: string) => {
2650
+ const all = fake(name).ws.sent;
2651
+ return to === undefined ? all : all.filter(v => v.to === to);
2652
+ };
2653
+ const connTarget = (tg: any): [(c: Conn) => boolean, string] => {
2654
+ if (tg === undefined) return [() => true, ''];
2655
+ if (typeof tg == 'string') return [c => c.name === tg || c.url === tg || (c.sse ? c.spec.sse : c.spec.socket) === tg, ` matching '${tg}'`];
2656
+ if (typeof tg == 'function') return [c => { try { return !!tg(sockView(c)); } catch (_) { return false; } }, ' matching the predicate'];
2657
+ if (tg && typeof tg == 'object') {
2658
+ return [c => { const v: any = sockView(c); return Object.keys(tg).every(k => same(tg[k], v[k])); }, ` matching ${brief(tg)}`];
2659
+ }
2660
+ throw new Error(`[Sygnal] the connection target must be a connection name or URL, a partial connection ({ socket: '/ws/a' }) or a predicate (got ${typeof tg})`);
2661
+ };
2662
+ const sockCall = (fn: string, name: string, tg: any, states: number[], act: (s: any) => void): Promise<void> => {
2663
+ if (drivers[name]) throw new Error(`[Sygnal] t.${fn}('${name}'): ${name} has a real driver (passed in drivers), so there is nothing to script. t.${fn} drives the fake socket source renderComponent provides when no driver is passed`);
2664
+ const [match, desc] = connTarget(tg);
2665
+ const kind = states.length > 1 ? 'open or connecting' : states[0] ? 'open' : 'connecting';
2666
+ const w = () => fake(name).ws;
2667
+ const find = () => {
2668
+ const ok = new Set<any>();
2669
+ // (autoConnect: a socket about to open counts as open)
2670
+ conns(name).forEach(c => { if (c.s && states.includes(c.s.auto && !c.s.readyState ? 1 : c.s.readyState) && match(c)) ok.add(c.s); });
2671
+ const list = [...ok];
2672
+ return list.length ? (tg === undefined ? [list.sort((a, b) => w().sockets.indexOf(a) - w().sockets.indexOf(b)).pop()] : list) : undefined;
2673
+ };
2674
+ const none = (waited: number) => {
2675
+ const list = connections(name);
2676
+ return new Error(`[Sygnal] t.${fn}('${name}'${desc ? ', …' : ''}): no ${kind} ${name} connection${desc}${waited ? ` after ${waited}ms` : ''}. ` +
2677
+ (list.length ? `Connections: ${list.map(c => `${c.name} (${c.socket ?? c.sse}, ${c.state})`).join(', ')}.` :
2678
+ `None is declared: declare it with { connections: { room: { socket: '/ws/…' } } } on the ${name} sink first (t.connections('${name}') is empty).`) +
2679
+ (states.includes(1) && list.some(c => c.state == 'connecting') ? ` A connecting one (autoConnect: false) opens with t.open('${name}').` : '') +
2680
+ (!states.includes(1) && autoConnect ? ` With autoConnect (the default) connections open by themselves: renderComponent(C, { autoConnect: false }) holds them for t.open.` : '') +
2681
+ (waited ? '' : ` A call made while simulate* / t.* calls are still queued waits for them; otherwise the connection must be there at the call.`));
2682
+ };
2683
+ return scripted(find, none, (hit: any[]) => hit.forEach(s => {
2684
+ if (s.auto && !s.readyState) sockOpen(s);
2685
+ act(s);
2686
+ }));
2687
+ };
2688
+ const open = (name: string, target?: FakeConnectionTarget) => sockCall('open', name, target, [0], sockOpen);
2689
+ const push = (name: string, data: any, target?: any) => {
2690
+ const o = target && typeof target == 'object' && !Array.isArray(target) && Object.keys(target).length && Object.keys(target).every(k => k == 'event' || k == 'connection') ? target : {connection: target};
2691
+ const raw = typeof data == 'string' || (data && typeof data == 'object' && (data instanceof ArrayBuffer || ArrayBuffer.isView(data) || (typeof Blob != 'undefined' && data instanceof Blob))) ? data : JSON.stringify(data);
2692
+ const ev = o.event;
2693
+ return sockCall('push', name, o.connection, [1], s => {
2694
+ const m = {type: ev || 'message', data: raw};
2695
+ if (!ev || ev == 'message') sockFire(s, 'message', m);
2696
+ if (s.sse) (s.ls[ev || 'message'] || []).forEach((f: any) => { try { f(m); } catch (e) { console.error(e); } });
2697
+ });
2698
+ };
2699
+ const drop = (name: string, close?: any, target?: any) => {
2700
+ const info = close && typeof close == 'object' && !Array.isArray(close) && Object.keys(close).every(k => k == 'code' || k == 'reason');
2701
+ const tg = info || close === undefined ? target : close;
2702
+ const {code = 1006, reason = ''} = info ? close : {};
2703
+ return sockCall('drop', name, tg, [0, 1], s => {
2704
+ const connecting = s.readyState == 0;
2705
+ if (s.sse) {
2706
+ // EventSource gave up (CLOSED): the driver's reconnect applies
2707
+ s.readyState = 2;
2708
+ return sockFire(s, 'error', {type: 'error'});
212
2709
  }
2710
+ s.readyState = 3;
2711
+ if (connecting) sockFire(s, 'error', {type: 'error'});
2712
+ sockFire(s, 'close', {type: 'close', code, reason, wasClean: false});
2713
+ });
2714
+ };
2715
+ // PLAN-3 5-4c: the router fake's test API. t.navigate / t.back / t.forward follow
2716
+ // t.respond's rules (scripted()): they throw at the call when they can't act, and resolve once
2717
+ // the result has been reduced and rendered.
2718
+ const needRouter = (fn: string): RouterFake => {
2719
+ if (rt) return rt;
2720
+ throw new Error(drivers[routerSink]
2721
+ ? `[Sygnal] t.${fn}(): ${routerSink} has a real driver (passed in drivers), so there is no in-memory history to drive. Drop it from drivers and pass the app's router: renderComponent(${compName}, { router })`
2722
+ : `[Sygnal] t.${fn}() needs the router fake: renderComponent(${compName}, { router }), with the object makeRouter() returns (import { router } from './routes.js')`);
2723
+ };
2724
+ const navigate = (target: any): Promise<void> => {
2725
+ const f = needRouter('navigate'), routes = f.r.routes, L = f.mem.w.location;
2726
+ let cmd: any;
2727
+ if (typeof target == 'string') {
2728
+ // an href, as a link has it: navigating to it is what a click on that link does
2729
+ const u = new URL(target, L.href);
2730
+ if (u.origin != L.origin) throw new Error(`[Sygnal] t.navigate('${target}'): that URL is on another origin (${u.origin}, the test's is ${L.origin}); the router leaves external links to the browser. Navigate to a path: t.navigate('/tasks/2')`);
2731
+ cmd = {url: u.pathname + u.search + u.hash};
2732
+ } else if (target && typeof target == 'object' && typeof target.to == 'string') {
2733
+ const pat = routes[target.to];
2734
+ if (pat == null || pat == '*') throw new Error(`[Sygnal] t.navigate({ to: '${target.to}' }): no route named '${target.to}'. Routes: ${Object.keys(routes).filter(k => routes[k] != '*').join(', ')}`);
2735
+ const missing = paramsOf(pat).filter(k => target.params?.[k] == null);
2736
+ if (missing.length) throw new Error(`[Sygnal] t.navigate({ to: '${target.to}' }): route '${target.to}' (${pat}) needs params: ${missing.join(', ')}. t.navigate({ to: '${target.to}', params: { ${missing.join(', ')} } })`);
2737
+ cmd = {...target};
2738
+ } else {
2739
+ throw new Error(`[Sygnal] t.navigate(): pass a URL ('/tasks/2') or { to: 'task', params: { id: 2 }, query?, hash?, replace? } (got ${brief(target)})`);
2740
+ }
2741
+ return scripted(() => true, () => new Error(''), () => { f.cmd$.shamefullySendNext(cmd); });
2742
+ };
2743
+ // the browser's back / forward buttons: the history moves, popstate fires a task later
2744
+ const traverse = (fn: string, n: number): Promise<void> => {
2745
+ const f = needRouter(fn), can = () => f.mem.index() + n >= 0 && f.mem.index() + n < f.mem.size();
2746
+ return scripted(can, w => new Error(`[Sygnal] t.${fn}(): no history entry to go ${n < 0 ? 'back' : 'forward'} to${w ? ` after ${w}ms` : ''} (at ${f.mem.w.location.pathname}, entry ${f.mem.index() + 1} of ${f.mem.size()}). Navigate first: await t.navigate('/…')`),
2747
+ () => { f.mem.w.history.go(n); });
2748
+ };
2749
+ const routerLocation = () => {
2750
+ const L = needRouter('location').mem.w.location;
2751
+ return {path: L.pathname, search: L.search, hash: L.hash, href: L.href};
2752
+ };
2753
+ // mock DOM: a click on (or inside) an <a> also reaches the router's document click listener,
2754
+ // as it would bubble to the document: the driver's own interception decides
2755
+ const routerClick = (chain: any[], ev: any) => {
2756
+ const ls = rt?.mem.docLs.click;
2757
+ if (!ls?.length) return;
2758
+ let k = chain.length - 1;
2759
+ while (k >= 0 && String(chain[k]?.sel || '').split(/[.#]/)[0].toLowerCase() != 'a') k--;
2760
+ if (k < 0) return;
2761
+ const a = anchorOf(chain[k]);
2762
+ const e: any = {
2763
+ type: 'click', button: 0, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, ...ev,
2764
+ defaultPrevented: !!ev.defaultPrevented, target: a, composedPath: () => [a],
2765
+ preventDefault() { e.defaultPrevented = true; },
2766
+ };
2767
+ ls.slice().forEach((l: any) => l(e));
2768
+ };
2769
+ const sentTo = (name: string, to?: string) => (rt && name == routerSink ? rt.sent : sent(name, to));
2770
+ const head = () => {
2771
+ if (!hd) throw new Error(`[Sygnal] t.head(): ${headSink} has a real driver (passed in drivers), so the fake that records the head isn't there. Read the document it writes, or drop it from drivers`);
2772
+ const m = mergeHead([...hd.entries.values()], titleTemplate);
2773
+ return {title: m.title, meta: Object.fromEntries(m.meta.map(([, k, c]) => [k, c])), link: m.link.map(([, l]) => l)};
2774
+ };
213
2775
 
214
- const timer = setTimeout(() => {
215
- try {
216
- stateStream.removeListener(listener);
217
- } catch (_) {}
218
- reject(new Error(`waitForState timed out after ${timeoutMs}ms`));
219
- }, timeoutMs);
2776
+ // E4: where real elements are looked up: the container, and the Portal content this tree
2777
+ // mounted outside it
2778
+ const roots = (): Element[] => {
2779
+ const out: Element[] = [container!];
2780
+ const walk = (v: any) => {
2781
+ if (!v || typeof v != 'object') return;
2782
+ const pe = v.data?._portalVnode?.elm;
2783
+ if (pe && !container!.contains(pe)) out.push(pe);
2784
+ for (const k of [].concat(v.children || [], v.data?.portalChildren || [])) walk(k);
2785
+ };
2786
+ walk(vtree);
2787
+ return out;
2788
+ };
2789
+ // G-125/4-A1: reading the output before the first render can only mislead (null, '')
2790
+ const notYet = (call: string) => {
2791
+ if (!isReady && !(real ? lastPatched : vtree)) {
2792
+ throw new Error(`[Sygnal] ${call} ran before the component's first render${real ? ' was in the DOM' : ''}. Wait for it first: await t.ready() (or await t.next(...))`);
2793
+ }
2794
+ };
2795
+ // 6-B (G-185): on the mock DOM, MockElement snapshots of the latest rendered tree
2796
+ const mockAll = (s: string): any[] => findAll(vtree, parse(norm(s)), [], []).map(c => mockOf(c, htmlOf));
2797
+ const queryAll = (s: string): Element[] => {
2798
+ s = selOf(s);
2799
+ notYet(`t.queryAll('${s}')`);
2800
+ if (!real) return mockAll(s);
2801
+ return roots().flatMap(r => Array.from(r.querySelectorAll(s)));
2802
+ };
2803
+ const queryIn = (s: string): Element | null => {
2804
+ for (const r of roots()) {
2805
+ const e = r.querySelector(s);
2806
+ if (e) return e;
2807
+ }
2808
+ return null;
2809
+ };
2810
+ const query = (s: string): Element | null => {
2811
+ s = selOf(s);
2812
+ notYet(`t.query('${s}')`);
2813
+ if (!real) return mockAll(s)[0] ?? null;
2814
+ return queryIn(s);
2815
+ };
220
2816
 
221
- const listener = {
222
- next: (s: any) => {
223
- try {
224
- if (predicate(s)) {
225
- clearTimeout(timer);
226
- stateStream.removeListener(listener);
227
- resolve(s);
228
- }
229
- } catch (_) {}
230
- },
231
- error: (err: any) => {
232
- clearTimeout(timer);
233
- reject(err);
234
- },
235
- complete: () => {
236
- clearTimeout(timer);
237
- reject(new Error('waitForState: state stream completed without matching'));
238
- },
2817
+ // PLAN-5 W-1: a widget host (selector or control): the props it was rendered with (the mock
2818
+ // DOM: the host vnode's; real: the mounted instance's), its instance (real) and dispatch, which
2819
+ // sends the CustomEvent mount's dispatch() would (through simulateEvent, so in input order);
2820
+ // D201: emit is an alias
2821
+ const widget = (target: any) => {
2822
+ const sel = String(selOf(target));
2823
+ const host = (): any => {
2824
+ const el: any = query(sel);
2825
+ const r = real ? el?.__sw : el?._v?.data?.ww && {p: el._v.data.wp};
2826
+ if (!r) throw new Error(`[Sygnal] t.widget('${sel}'): no ${el ? `mounted widget is the matched <${el.localName}>` : 'element matches it'}. Give the widget a className and pass its selector (t.widget('.due')), or pass its control`);
2827
+ return r;
2828
+ };
2829
+ const dispatch = (name: string, detail?: any) => simulateEvent(target, name, {detail} as any);
2830
+ return {
2831
+ get props() { return host().p; },
2832
+ get instance() { return host().i; },
2833
+ dispatch,
2834
+ emit: dispatch,
2835
+ };
2836
+ };
2837
+
2838
+ const simulateEvent = (target: any, type: string, init: SimulatedEventInit = {}) => {
2839
+ simAt = states.length; due();
2840
+ throwFailure();
2841
+ const {allowMissing, within: inside, ...evInit} = init;
2842
+ // CT-1: a control is its selector (also for `within`)
2843
+ const raw = String(selOf(target)), text = norm(raw);
2844
+ // 'document' / 'body' (and '') name a listener, not an element
2845
+ const page = !text || PAGE.test(text);
2846
+ // CT-1: `within` scopes the target to the first element matching it (e.g. one Collection item)
2847
+ const within = inside == null || page ? '' : norm(String(selOf(inside)));
2848
+ const selector = within ? `${raw}' within '${within}` : raw;
2849
+ // unsupported syntax throws here, at the call (G-070); the real DOM takes any CSS selector
2850
+ const sel = page || real ? [] : parse(text);
2851
+ const box = within && !real ? parse(within) : undefined;
2852
+ if (real && !page) {
2853
+ try { container!.querySelector(text); if (within) container!.querySelector(within); } catch (_) {
2854
+ throw new Error(`[Sygnal] simulateEvent('${selector}', '${type}'): not a valid CSS selector`);
2855
+ }
2856
+ }
2857
+ // the root → element chain of the target in the mock vtree (inside the `within` element)
2858
+ const findChain = (): any[] | undefined => {
2859
+ if (!box) return find(vtree, sel);
2860
+ const outer = find(vtree, box);
2861
+ return outer && findAll(outer[outer.length - 1], sel, outer.slice(0, -1), [], false)[0];
2862
+ };
2863
+ // E4: the real element: document / body / the root element for '' / the first match
2864
+ const realEl = (): any => text == 'document' ? document : text == 'body' ? document.body
2865
+ : !text ? container!.firstElementChild
2866
+ : within ? queryIn(within)?.querySelector(text)
2867
+ : queryIn(text.replace(PAGE, '') || text);
2868
+ const has = () => real ? !!realEl() : !!findChain();
2869
+ // nothing pending and the tree is quiet (as settle() would see it): fail at the call
2870
+ if (!page && !allowMissing && isReady && !inputs.length && vtree && renderedUpTo >= states.length &&
2871
+ clockNow() - lastActivity >= settleMs && !has()) {
2872
+ throw noMatch(selector, type, false);
2873
+ }
2874
+ later(last => {
2875
+ const rel = real ? realEl() : undefined;
2876
+ const chain: any[] | undefined = page ? undefined : real ? rel && (chainOf(vtree, rel) || []) : findChain();
2877
+ const el = real ? rel : chain?.[chain.length - 1];
2878
+ if (!page && !el) {
2879
+ if (!last) return false;
2880
+ if (!allowMissing) {
2881
+ failWith(noMatch(selector, type, true));
2882
+ return true;
2883
+ }
2884
+ raise('SYG103', rootName,
2885
+ `simulateEvent('${selector}', '${type}') matched no rendered element within ${eventWaitMs}ms, so the event was dropped`,
2886
+ `Check the selector against the view's className/id, or wait until the element is rendered (await t.next(...) or t.settle())`,
2887
+ {selector, type});
2888
+ return true;
2889
+ }
2890
+ // the chain index of the deepest element a listener at `path` hears this event on
2891
+ // (-1: a document/body listener), or undefined when it doesn't hear it
2892
+ const depthOf = (path: string[]): number | undefined => {
2893
+ let ls = selText(path);
2894
+ if (!chain) return ls == text ? 0 : undefined;
2895
+ let els = chain;
2896
+ const pageLs = PAGE.test(ls);
2897
+ if (pageLs) ls = ls.replace(PAGE, '');
2898
+ else {
2899
+ const scope = path.filter(isScope).pop();
2900
+ let cur: string | undefined;
2901
+ els = chain.filter(v => {
2902
+ const m = scopeOfV(v);
2903
+ if (m) cur = m;
2904
+ return cur == scope;
2905
+ });
2906
+ }
2907
+ if (!ls) return pageLs || !els.length ? -1 : chain.indexOf(els[0]);
2908
+ const lsel = tryParse(ls);
2909
+ if (!lsel) return undefined;
2910
+ for (let k = els.length - 1; k >= 0; k--) {
2911
+ if (matches(lsel, els.slice(0, k + 1))) return pageLs ? -1 : chain.indexOf(els[k]);
2912
+ }
2913
+ return undefined;
2914
+ };
2915
+ const match = (path: string[]) => depthOf(path) !== undefined;
2916
+ // G-039: wait until every listener this event would reach is subscribed
2917
+ if (!last) {
2918
+ for (const [k, path] of listeners) {
2919
+ if ((evTypes[k] || []).includes(type) && !live.get(k + '\u0000' + type) && match(path)) return false;
2920
+ }
2921
+ }
2922
+ if (real) {
2923
+ if (chain?.length) check104(chain[chain.length - 1]);
2924
+ fire(el, type, evInit);
2925
+ return true;
2926
+ }
2927
+ if (el) check104(el);
2928
+ const d = el?.data || {}, p = d.props || {};
2929
+ const {target: t = {}, value, checked, dataset, data, ...rest} = evInit;
2930
+ const vval = p.value ?? d.attrs?.value;
2931
+ const target: any = {
2932
+ tagName: el?.sel.split(/[.#]/)[0].toUpperCase(),
2933
+ // D199 (spike 0-S2): name/id/type/getAttribute, so listeners that delegate by name
2934
+ // (`e.target.name`, a form-level input listener) work on the mock DOM as on the real one
2935
+ ...(el && {name: attrOf(el, 'name') ?? '', id: attrOf(el, 'id') ?? '', type: attrOf(el, 'type') ?? '', getAttribute: (n: string) => attrOf(el, n)}),
2936
+ value: vval == null ? vval : String(vval),
2937
+ checked: p.checked ?? d.attrs?.checked,
2938
+ ...('value' in init && {value}),
2939
+ ...('checked' in init && {checked}),
2940
+ ...t,
2941
+ };
2942
+ target.dataset = str({...d.dataset, ...dataset, ...data, ...t.dataset});
2943
+ // 3E/R2: element.closest(sel) over the rendered ancestor chain, so helpers that look
2944
+ // up ancestors (e.g. `.data('taskId')` on a click inside the row carrying the data)
2945
+ // behave like the real DOM. Ancestors are element-like: tagName, dataset, closest.
2946
+ if (chain && !('closest' in t)) {
2947
+ const like = (i: number): any => {
2948
+ const v = chain[i];
2949
+ return {tagName: v.sel.split(/[.#]/)[0].toUpperCase(), dataset: str(v.data?.dataset), closest: closestFrom(i)};
2950
+ };
2951
+ const closestFrom = (from: number) => (s: string): any => {
2952
+ const cs = parse(String(s));
2953
+ for (let i = from; i >= 0; i--) {
2954
+ if (matches(cs, chain.slice(0, i + 1))) return i == chain.length - 1 ? target : like(i);
2955
+ }
2956
+ return null;
2957
+ };
2958
+ target.closest = closestFrom(chain.length - 1);
2959
+ }
2960
+ const noBubble = NO_BUBBLE.test(type);
2961
+ const event = {
2962
+ type,
2963
+ target,
2964
+ currentTarget: target,
2965
+ ownerTarget: target,
2966
+ dataTransfer: {},
2967
+ preventDefault: noop,
2968
+ stopPropagation: noop,
2969
+ bubbles: !noBubble,
2970
+ ...rest,
239
2971
  };
2972
+ // G-145: like native bubbling, listeners on deeper elements (a child's) hear the
2973
+ // event before those on their ancestors (the parent's wrapper), then document/body.
2974
+ // 1-F: an event that doesn't bubble (dialog close, focus, ...) reaches the target only
2975
+ if (!chain) hub.emit({type, event, match});
2976
+ else for (let k = chain.length - 1; k >= (noBubble ? chain.length - 1 : -1); k--) hub.emit({type, event, match: (p: string[]) => depthOf(p) === k});
2977
+ // PLAN-3 5-4c: and a link click reaches the router fake's document listener
2978
+ if (type == 'click' && chain) routerClick(chain, event);
2979
+ return true;
2980
+ }, page || allowMissing ? undefined : () => (has() ? undefined : noMatch(selector, type, false)));
2981
+ };
240
2982
 
2983
+ // first state at index >= from matching predicate; resolves after the full tree rendered it
2984
+ const waitMatch = (from: number, predicate: (state: any) => boolean, timeoutMs: number, name: string): Promise<any> =>
2985
+ new Promise((resolve, reject) => {
2986
+ const f = takeFailure();
2987
+ if (f) return reject(f);
2988
+ // 4-A1: on the real DOM, the wait resolves once the newest state at the match (h) is
2989
+ // patched into the DOM, and a render of a later state is held back until the code after
2990
+ // the `await` has run (the next macrotask), so t.query() reads the matched state
2991
+ let h = -1;
2992
+ const unhold = () => { if (h >= 0) { const k = h; h = -1; setTimeout(() => release(k)); } };
2993
+ const found = (i: number) => {
2994
+ clearTimeout(timer);
2995
+ stateStream.removeListener(listener);
2996
+ if (real) {
2997
+ holds.push(h = states.length - 1);
2998
+ patchedTree(h + 1).then(() => {
2999
+ waiters.delete(fail);
3000
+ // the states after h weren't in the DOM yet: the next next() may still match them
3001
+ if (h >= 0) shown = h + 1;
3002
+ resolve(states[i]);
3003
+ unhold();
3004
+ });
3005
+ } else {
3006
+ // G-129: the next next() starts after this state (as on the real DOM), so a state
3007
+ // that arrived while this one's render settled can still match
3008
+ treeRendered(i + 1).then(() => { waiters.delete(fail); if (!disposed) shown = i + 1; resolve(states[i]); });
3009
+ }
3010
+ };
3011
+ const test = (i: number) => { try { return predicate(states[i]); } catch (_) { return false; } };
3012
+ const fail = (err: Error) => { clearTimeout(timer); stateStream.removeListener(listener); waiters.delete(fail); unhold(); reject(err); };
3013
+ waiters.add(fail);
3014
+ const base = states.length;
3015
+ const listener = {
3016
+ // the recording listener (added first) has already pushed the new state; a
3017
+ // remembered state replayed on addListener isn't new (states.length == base)
3018
+ next: () => { if (states.length > base && test(states.length - 1)) found(states.length - 1); },
3019
+ error: (err: any) => fail(err),
3020
+ complete: () => fail(new Error(`${name}: state stream completed without matching`)),
3021
+ };
3022
+ const start = clockNow();
3023
+ const timer = setTimeout(() => {
3024
+ let msg = `${name} timed out after ${timeoutMs}ms.`;
3025
+ const [why, pending] = explainNext(start);
3026
+ msg += why;
3027
+ if (pending) msg += ` Wait longer: t.${name}(pred, ms), or the renderComponent timeoutMs option (default for every wait).`;
3028
+ // G-065: a state from before the call matched; next() only looks at new ones
3029
+ for (let i = from - 1; i >= 0; i--) {
3030
+ if (test(i)) {
3031
+ msg += ` A state recorded before this next() call already matches the predicate (t.states[${i}]); next() only matches new states.` +
3032
+ ` Use t.waitForState(pred) to search the history too, or call next() before the input that causes the state.`;
3033
+ break;
3034
+ }
3035
+ }
3036
+ fail(new Error(msg));
3037
+ }, timeoutMs);
3038
+ for (let i = from; i < states.length; i++) if (test(i)) return found(i);
241
3039
  stateStream.addListener(listener);
242
3040
  });
3041
+
3042
+ /**
3043
+ * G-053: the model next() calls scheduled during a wait that started at `start` (or still
3044
+ * pending): an explanation for a timeout, and whether one is still pending.
3045
+ */
3046
+ const explainNext = (start: number): [string, boolean] => {
3047
+ const now = clockNow(), byKey = new Map<string, Scheduled & {n: number}>();
3048
+ for (const s of scheduled) {
3049
+ if (s.due <= now && s.at < start) continue;
3050
+ const k = s.by + '\u0000' + s.type;
3051
+ byKey.set(k, {...s, n: (byKey.get(k)?.n || 0) + 1});
3052
+ }
3053
+ if (!byKey.size) return ['', false];
3054
+ let pending = false;
3055
+ const list = [...byKey.values()].map(s => {
3056
+ const left = s.due - now;
3057
+ if (left > 0) pending = true;
3058
+ return `next('${s.type}') scheduled by ${s.by} with a ${s.delay}ms delay` +
3059
+ (s.n > 1 ? ` (${s.n} times)` : '') + (left > 0 ? ` is still pending (fires in ${left}ms)` : '');
3060
+ });
3061
+ return [` Model next() calls during the wait: ${list.join('; ')}.`, pending];
243
3062
  };
244
3063
 
245
- const dispose = () => {
246
- if (stateListener) {
247
- try {
248
- stateStream.removeListener(stateListener);
249
- } catch (_) {}
250
- stateListener = null;
3064
+ const waitForState = (predicate: (state: any) => boolean, timeoutMs: number = defaultTimeout) => {
3065
+ checkMs('waitForState', timeoutMs);
3066
+ cursor = shown = undefined;
3067
+ return drive(waitMatch(0, predicate, timeoutMs, 'waitForState'), () => disposed);
3068
+ };
3069
+ const next = (predicate: (state: any) => boolean = () => true, timeoutMs: number = defaultTimeout) => {
3070
+ checkMs('next', timeoutMs);
3071
+ // 4-A1/G-129: right after a wait, start after the state it returned (real DOM: the state the DOM shows)
3072
+ if (cursor === undefined && shown !== undefined) return drive(waitMatch(Math.min(shown, states.length), predicate, timeoutMs, 'next'), () => disposed);
3073
+ if (cursor === undefined || (cursor < 0 && !isReady)) return drive(waitMatch(cursor === undefined && syncAt !== undefined ? syncAt : states.length, predicate, timeoutMs, 'next'), () => disposed);
3074
+ const id = arming;
3075
+ cursorUsed = true;
3076
+ unexpire();
3077
+ const p = waitMatch(cursor < 0 ? readyAt : cursor, predicate, timeoutMs, 'next');
3078
+ // the first next() from the cursor to resolve disarms it (sequential next() calls move on)
3079
+ p.then(() => { if (id == arming) cursor = undefined; }, noop);
3080
+ return drive(p, () => disposed);
3081
+ };
3082
+ const settle = (timeoutMs: number = defaultTimeout): Promise<void> => {
3083
+ checkMs('settle', timeoutMs);
3084
+ return drive(settleWait(timeoutMs), () => disposed);
3085
+ };
3086
+ const settleWait = (timeoutMs: number): Promise<void> => new Promise((resolve, reject) => {
3087
+ cursor = shown = undefined;
3088
+ const f = takeFailure();
3089
+ if (f) return reject(f);
3090
+ let wait: any;
3091
+ const done = (e?: Error) => { clearTimeout(wait); waiters.delete(done); e ? reject(e) : resolve(); };
3092
+ waiters.add(done);
3093
+ const start = clockNow();
3094
+ (async () => {
3095
+ await Promise.race([readyPromise, new Promise(r => { wait = setTimeout(r, timeoutMs); })]);
3096
+ clearTimeout(wait);
3097
+ // 4-A1: on the real DOM, quiet also means the latest render is in the DOM
3098
+ const busy = () => !isReady || inputs.length > 0 || (real && !!vtree && (lastPatched !== vtree || !!held));
3099
+ if (!await quiesce(states.length, settleMs, timeoutMs, busy)) {
3100
+ const [why] = explainNext(start);
3101
+ throw new Error(`settle timed out after ${timeoutMs}ms: ${inputs.length ? `${inputs.length} simulated input(s) still pending` : `the component kept rendering (it never was quiet for settleMs = ${settleMs}ms)`}.` +
3102
+ why + (why ? ' A next() loop never goes quiet: wait for a specific state with t.next(pred) instead.' : ''));
3103
+ }
3104
+ })().then(() => done(), done);
3105
+ });
3106
+
3107
+ const expectNoDiagnostics = () => {
3108
+ throwFailure();
3109
+ const bad = collected.filter(d => d.severity != 'info');
3110
+ if (bad.length) {
3111
+ throw new Error(`Expected no diagnostics, got ${bad.length}:\n` + bad.map(d => d.text).join('\n'));
251
3112
  }
252
- // Trigger the component's dispose() which fires the DISPOSE action and dispose$ stream
253
- if (typeof (sinks as any).__dispose === 'function') {
254
- try { (sinks as any).__dispose(); } catch (_) {}
3113
+ };
3114
+
3115
+ // a copy of the tree (the serializer reassigns children arrays; the app's vnodes stay as they are)
3116
+ const copy = (v: any): any => v && typeof v == 'object' && v.children ? {...v, children: v.children.map(copy)} : v;
3117
+ const html = () => {
3118
+ notYet('t.html()');
3119
+ return renderHtml();
3120
+ };
3121
+ const htmlOf = (v: any) =>
3122
+ renderToInnerHtml(() => copy(v)).replace(/ class="([^"]*)"/g, (_, c: string) =>
3123
+ (c = c.split(' ').filter(x => !x.startsWith('___')).join(' ')) ? ` class="${c}"` : ''
3124
+ );
3125
+ const renderHtml = () => vtree ? htmlOf(vtree) : '';
3126
+
3127
+ const inspect = (o: Pick<InspectOptions, 'actions'> = {}): InspectGraph => {
3128
+ if (!core.inspect) throw Error(`[Sygnal] t.inspect() needs import 'sygnal/diagnostics'`);
3129
+ return core.inspect({ids: [...scopeIds.values()], diagnostics: collected, mock: {listeners, evTypes, owners, scopeIds, probe, vtree}, ...(o.actions !== undefined && {actions: o.actions})});
3130
+ };
3131
+
3132
+ // PLAN-4 2-C: the first action whose resulting root state matches
3133
+ const explain = (pred: (state: any) => boolean): ExplainedAction | undefined => {
3134
+ if (typeof pred != 'function') throw new Error('[Sygnal] t.explain(predicate): pass a function of the state, e.g. t.explain(s => s.status === \'error\')');
3135
+ for (const e of actionList) {
3136
+ const r = resulting.get(e);
3137
+ let hit = false;
3138
+ if (r) try { hit = !!pred(r.s); } catch (_) {}
3139
+ if (!hit) continue;
3140
+ const fn = stateReducer.get(e);
3141
+ let source = '';
3142
+ try { source = fn ? Function.prototype.toString.call(fn) : ''; } catch (_) {}
3143
+ return {...e, sinks: [...e.sinks], state: r!.s, ...(fn && {reducer: {action: e.type, sink: 'STATE', fn, source: source.length > 400 ? source.slice(0, 400) + '…' : source}})};
255
3144
  }
256
- rawDispose();
3145
+ };
3146
+
3147
+ const dispose = () => {
3148
+ if (disposed) return;
3149
+ disposed = true;
3150
+ commandTimers.forEach(clearTimeout);
3151
+ clearTimeout(timer);
3152
+ clearTimeout(fallback);
3153
+ clearTimeout(retryTimer);
3154
+ unexpire();
3155
+ // G-070: an event still waiting for an element that never rendered fails the test
3156
+ const head = inputs[0];
3157
+ if (head && head.until && head.missing && !failure) failure = head.missing();
3158
+ inputs.length = 0;
3159
+ subs.forEach(([s, l]) => {
3160
+ try { s.removeListener(l); } catch (_) {}
3161
+ });
3162
+ try { sinks.__dispose?.(); } catch (_) {}
3163
+ // E4: unmount (the container and the Portal content mounted outside it)
3164
+ const mounted = real ? roots() : [];
3165
+ // 2-C: every fake connection closes (as the app's own close: no close action)
3166
+ // 5-1: and every fetch still in flight is aborted
3167
+ fakes.forEach(f => { f.ws.src.dispose(); f.ws.conns.clear(); f.http.dispose(); });
3168
+ mounted.forEach(e => e.remove());
3169
+ restore();
3170
+ // R4-8: every pending wait (ready, next, waitForState, settle) rejects now, its timers
3171
+ // cleared, so nothing hangs (fake timers) or fails much later
3172
+ const ws = [...waiters, ...readyWaiters];
3173
+ waiters.clear();
3174
+ readyWaiters.clear();
3175
+ const gone = new Error('[Sygnal] renderComponent was disposed while this wait was pending (t.ready/t.next/t.waitForState/t.settle). Await every wait before t.dispose()');
3176
+ ws.forEach(w => w(gone));
3177
+ // a t.respond/t.fail still queued rejects too (it never fails a later wait: there is none)
3178
+ const rs = [...replyWaits];
3179
+ replyWaits.clear();
3180
+ rs.forEach(r => r(new Error('[Sygnal] renderComponent was disposed before this t.respond/t.fail was delivered'), true));
3181
+ throwFailure();
257
3182
  };
258
3183
 
259
3184
  return {
@@ -263,8 +3188,51 @@ export function renderComponent(
263
3188
  sinks,
264
3189
  sources,
265
3190
  simulateAction,
3191
+ simulateEvent,
3192
+ ready,
266
3193
  waitForState,
3194
+ next,
3195
+ // GS-5: then the pending persist() writes
3196
+ settle: (ms?: number) => settle(ms).then(() => { if (ps) ps.f.forEach((f: () => void) => f()); }),
267
3197
  states,
3198
+ actions: actionList,
3199
+ explain,
3200
+ get state() { return states[states.length - 1]; },
3201
+ sinkValues,
3202
+ requests,
3203
+ respond,
3204
+ fail,
3205
+ connections,
3206
+ cache,
3207
+ focus: () => signal('focus'),
3208
+ online: () => signal('online'),
3209
+ open,
3210
+ push,
3211
+ drop,
3212
+ sent: sentTo,
3213
+ navigate,
3214
+ back: () => traverse('back', -1),
3215
+ forward: () => traverse('forward', 1),
3216
+ get location() { return routerLocation(); },
3217
+ head,
3218
+ timers: () => {
3219
+ if (!tm) throw new Error(`[Sygnal] t.timers(): ${timerSink} has a real driver (passed in drivers); t.timers lists the timers of the fake renderComponent provides when no driver is passed`);
3220
+ const list: any[] = [];
3221
+ tm.forEach(r => { for (const name in r.on) { const {ok, d, s} = r.on[name]; if (ok && !d) list.push({name, ...s, action: s.action ?? s.frame, component: r.c}); } });
3222
+ return list;
3223
+ },
3224
+ browser: tBrowser,
3225
+ storage: (key: string) => store[key],
3226
+ emitted: sinkValues('EVENTS'),
3227
+ diagnostics: collected,
3228
+ commands: (name = 'ELEMENT') => name == 'ELEMENT' ? commandLog : sinkValues(name),
3229
+ expectNoDiagnostics,
3230
+ html,
268
3231
  dispose,
3232
+ inspect,
3233
+ container,
3234
+ query,
3235
+ queryAll,
3236
+ widget,
269
3237
  };
270
3238
  }