sygnal 5.4.0 → 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 (316) hide show
  1. package/CHANGELOG.md +646 -0
  2. package/README.md +24 -9
  3. package/dist/astro/client.cjs.js +16 -9
  4. package/dist/astro/client.cjs.js.map +1 -1
  5. package/dist/astro/client.mjs +16 -9
  6. package/dist/astro/client.mjs.map +1 -1
  7. package/dist/astro/index.cjs.js +362 -91
  8. package/dist/astro/index.cjs.js.map +1 -1
  9. package/dist/astro/index.mjs +362 -92
  10. package/dist/astro/index.mjs.map +1 -1
  11. package/dist/astro/server.cjs.js +3396 -165
  12. package/dist/astro/server.cjs.js.map +1 -1
  13. package/dist/astro/server.mjs +3396 -165
  14. package/dist/astro/server.mjs.map +1 -1
  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 +3032 -296
  20. package/dist/diagnostics.cjs.js.map +1 -1
  21. package/dist/diagnostics.esm.js +3032 -296
  22. package/dist/diagnostics.esm.js.map +1 -1
  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 +13178 -5769
  67. package/dist/index.cjs.js.map +1 -1
  68. package/dist/index.d.ts +2908 -276
  69. package/dist/index.esm.js +13149 -5779
  70. package/dist/index.esm.js.map +1 -1
  71. package/dist/jsx-dev-runtime.cjs.js +26 -334
  72. package/dist/jsx-dev-runtime.cjs.js.map +1 -1
  73. package/dist/jsx-dev-runtime.esm.js +19 -327
  74. package/dist/jsx-dev-runtime.esm.js.map +1 -1
  75. package/dist/jsx-runtime.cjs.js +26 -334
  76. package/dist/jsx-runtime.cjs.js.map +1 -1
  77. package/dist/jsx-runtime.esm.js +19 -327
  78. package/dist/jsx-runtime.esm.js.map +1 -1
  79. package/dist/jsx.cjs.js +10 -314
  80. package/dist/jsx.cjs.js.map +1 -1
  81. package/dist/jsx.esm.js +8 -315
  82. package/dist/jsx.esm.js.map +1 -1
  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 +1 -1
  89. package/dist/sygnal.min.js.map +1 -1
  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 +4 -14
  107. package/dist/vike/ClientOnly.cjs.js.map +1 -1
  108. package/dist/vike/ClientOnly.mjs +4 -14
  109. package/dist/vike/ClientOnly.mjs.map +1 -1
  110. package/dist/vike/{+config.js → config/+config.js} +9 -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 +165 -95
  114. package/dist/vike/onRenderClient.cjs.js.map +1 -1
  115. package/dist/vike/onRenderClient.mjs +165 -95
  116. package/dist/vike/onRenderClient.mjs.map +1 -1
  117. package/dist/vike/onRenderHtml.cjs.js +59 -24
  118. package/dist/vike/onRenderHtml.cjs.js.map +1 -1
  119. package/dist/vike/onRenderHtml.mjs +60 -25
  120. package/dist/vike/onRenderHtml.mjs.map +1 -1
  121. package/dist/vite/plugin.cjs.js +323 -88
  122. package/dist/vite/plugin.cjs.js.map +1 -1
  123. package/dist/vite/plugin.mjs +323 -89
  124. package/dist/vite/plugin.mjs.map +1 -1
  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 +166 -103
  130. package/package.json +98 -14
  131. package/src/astro/client.ts +27 -16
  132. package/src/astro/index.d.ts +24 -0
  133. package/src/astro/index.ts +69 -3
  134. package/src/astro/server.ts +8 -2
  135. package/src/collection.ts +6 -93
  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 +9 -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 +9 -7
  162. package/src/cycle/dom/controlledInputModule.ts +23 -6
  163. package/src/cycle/dom/enrichEventStream.ts +25 -47
  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 -56
  167. package/src/cycle/dom/mockDOMSource.ts +21 -1
  168. package/src/cycle/dom/modules.ts +16 -3
  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 +19 -3
  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 +101 -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 +1 -1
  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 +20 -11
  201. package/src/extra/diagnostics/checks/elementCommands.ts +175 -0
  202. package/src/extra/diagnostics/checks/events.ts +17 -2
  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 +97 -4
  206. package/src/extra/diagnostics/checks/inspect.ts +140 -32
  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 +10 -5
  210. package/src/extra/diagnostics/checks/public.d.ts +98 -7
  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 +1 -1
  214. package/src/extra/diagnostics/checks/shared.ts +86 -2
  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 +100 -7
  218. package/src/extra/diagnostics/checks/statics.ts +48 -0
  219. package/src/extra/diagnostics/checks/strict.ts +10 -67
  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 +66 -10
  225. package/src/extra/diagnostics/codes.ts +268 -23
  226. package/src/extra/diagnostics/index.ts +34 -9
  227. package/src/extra/diagnostics/legacy.ts +17 -0
  228. package/src/extra/driverFactories.ts +83 -19
  229. package/src/extra/elementCommands.ts +53 -0
  230. package/src/extra/fetchDriver.ts +519 -0
  231. package/src/extra/focusWithin.ts +27 -0
  232. package/src/extra/form.ts +268 -0
  233. package/src/extra/formHelpers.ts +139 -0
  234. package/src/extra/head.ts +106 -0
  235. package/src/extra/hmr.ts +2 -3
  236. package/src/extra/owned.ts +24 -0
  237. package/src/extra/pager.ts +50 -0
  238. package/src/extra/persist.ts +133 -0
  239. package/src/extra/pwa.ts +1 -1
  240. package/src/extra/queryCache.ts +107 -0
  241. package/src/extra/reducers.ts +1 -1
  242. package/src/extra/reduxDevtools.ts +92 -0
  243. package/src/extra/replies.ts +54 -0
  244. package/src/extra/router.ts +323 -0
  245. package/src/extra/run.ts +90 -217
  246. package/src/extra/selection.ts +76 -0
  247. package/src/extra/socketDriver.ts +265 -0
  248. package/src/extra/sortable.ts +377 -0
  249. package/src/extra/ssr.ts +427 -160
  250. package/src/extra/standardSchema.ts +27 -0
  251. package/src/extra/testing.ts +2433 -175
  252. package/src/extra/timers.ts +95 -0
  253. package/src/extra/undo.ts +261 -0
  254. package/src/extra/viewTransitions.ts +38 -0
  255. package/src/extra/virtual.ts +513 -0
  256. package/src/extra/widget.ts +201 -0
  257. package/src/index.d.ts +2631 -117
  258. package/src/index.ts +25 -4
  259. package/src/jsx-runtime.ts +15 -1
  260. package/src/jsx.ts +2 -1
  261. package/src/lazy.ts +50 -7
  262. package/src/portal.ts +3 -1
  263. package/src/pragma/index.ts +256 -133
  264. package/src/react-peers.d.ts +5 -0
  265. package/src/react.d.ts +43 -0
  266. package/src/react.ts +110 -0
  267. package/src/shared.ts +48 -0
  268. package/src/slot.ts +1 -1
  269. package/src/suspense.ts +3 -1
  270. package/src/switchable.ts +6 -121
  271. package/src/transition.ts +3 -1
  272. package/src/ui/accordion.ts +64 -0
  273. package/src/ui/dialog.ts +129 -0
  274. package/src/ui/disclosure.ts +35 -0
  275. package/src/ui/popover.ts +45 -0
  276. package/src/ui/shared.ts +94 -0
  277. package/src/ui/tabs.ts +82 -0
  278. package/src/ui/toaster.ts +198 -0
  279. package/src/ui/tooltip.ts +70 -0
  280. package/src/ui/zag/combobox.ts +115 -0
  281. package/src/ui/zag/menu.ts +40 -0
  282. package/src/ui/zag/select.ts +54 -0
  283. package/src/ui/zag/shared.ts +43 -0
  284. package/src/ui-combobox.d.ts +20 -0
  285. package/src/ui-combobox.ts +6 -0
  286. package/src/ui-menu.d.ts +30 -0
  287. package/src/ui-menu.ts +6 -0
  288. package/src/ui-select.d.ts +17 -0
  289. package/src/ui-select.ts +6 -0
  290. package/src/ui-zag-types.d.ts +42 -0
  291. package/src/ui.d.ts +208 -0
  292. package/src/ui.ts +15 -0
  293. package/src/vike/+config.ts +9 -1
  294. package/src/vike/ClientOnly.ts +1 -1
  295. package/src/vike/onRenderClient.ts +144 -96
  296. package/src/vike/onRenderHtml.ts +69 -26
  297. package/src/vike/types.ts +7 -1
  298. package/src/vite/globalthis-shim.ts +18 -0
  299. package/src/vite/globalthis.ts +56 -0
  300. package/src/vite/plugin.d.ts +22 -3
  301. package/src/vite/plugin.ts +222 -24
  302. package/src/zag.d.ts +64 -0
  303. package/src/zag.ts +238 -0
  304. package/dist/vike/+config.cjs.js +0 -66
  305. package/dist/vike/+config.cjs.js.map +0 -1
  306. package/dist/vike/+config.js.map +0 -1
  307. package/src/component.ts +0 -2301
  308. package/src/cycle/isolate/index.ts +0 -196
  309. package/src/cycle/run/index.ts +0 -151
  310. package/src/cycle/run/internals.ts +0 -143
  311. package/src/cycle/state/Collection.ts +0 -184
  312. package/src/cycle/state/index.ts +0 -5
  313. package/src/cycle/state/pickCombine.ts +0 -201
  314. package/src/cycle/state/pickMerge.ts +0 -122
  315. package/src/cycle/state/withState.ts +0 -47
  316. package/src/pragma/fn.ts +0 -55
@@ -1,15 +1,27 @@
1
- import {setup} from '../cycle/run/index';
2
- import {withState} from '../cycle/state/index';
1
+ import {start as startNext} from '../core/runtime';
3
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 {renderToString} from './ssr';
8
- import {_getDiagnosticsConfig, configureDiagnostics, getDiagnosticsMode, isDiagnosticsEnabled, onDiagnostic, registerCheck, report} from './diagnostics/index';
7
+ import {ownedCopy} from './owned';
8
+ import {renderToInnerHtml} from './ssr';
9
+ import {_getDiagnosticsConfig, configureDiagnostics, getDiagnosticsMode, isDiagnosticsEnabled, onDiagnostic, report} from './diagnostics/index';
9
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';
10
19
  import type {Stream} from 'xstream';
11
20
  import type {Diagnostic, DiagnosticsMode} from './diagnostics/index';
12
- import type {InspectGraph} from './diagnostics/checks/public';
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';
13
25
 
14
26
  /*
15
27
  * (Docs live on these type-only declarations so the TypeScript emit drops
@@ -85,17 +97,50 @@ import type {InspectGraph} from './diagnostics/checks/public';
85
97
  * - Sinks named in the model without a driver get a no-op driver so their
86
98
  * output stays observable. sinkValues: EVENTS entries drop the devtools
87
99
  * stamps, PARENT entries are unwrapped from {name, component, value}.
88
- * - Input is buffered until 12ms after the first render: root and child
89
- * action streams subscribe 1-10ms (BOOTSTRAP) after construction. With no
90
- * render within 30ms (e.g. a model but no initialState), the 12ms start then.
91
- * dispose() before ready leaves the buffered calls undelivered.
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).
92
113
  * - "Rendered by the whole tree" = the root rendered the state and no render,
93
114
  * reducer, state or input happened anywhere for 10ms (checked twice; capped at
94
- * 250ms). Child renders are seen through the onRender diagnostics hook.
115
+ * 250ms). Child renders are seen through the core's onRender hook.
95
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.
96
141
  * - SYG103/104: the mock DOM source reports each events() call (selector path,
97
- * isolation scopes included as '.___scope'); a diagnostics check's onIntent
98
- * maps each component's innermost scope to its name. The nearest '.___'
142
+ * isolation scopes included as '.___scope'); the onCreate hook maps each
143
+ * component's innermost scope to its name. The nearest '.___'
99
144
  * class on a vnode or its ancestors is the scope that owns it.
100
145
  */
101
146
 
@@ -121,9 +166,69 @@ export interface SimulatedEventInit {
121
166
  [prop: string]: any;
122
167
  }
123
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);
221
+
124
222
  export interface RenderOptions {
125
223
  /** Override or provide initial state (defaults to component's .initialState) */
126
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>;
127
232
  /** Mock DOM configuration — maps selectors to event streams */
128
233
  mockConfig?: Record<string, any>;
129
234
  /** Additional drivers beyond DOM, EVENTS, STATE, and LOG */
@@ -142,8 +247,115 @@ export interface RenderOptions {
142
247
  * Default: unchanged (off unless configureStrict(true) was called).
143
248
  */
144
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>;
145
325
  }
146
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
+
147
359
  export interface RenderResult {
148
360
  /** Stream of state values */
149
361
  state$: Stream<any>;
@@ -159,7 +371,11 @@ export interface RenderResult {
159
371
  simulateAction: (actionName: string, data?: any) => void;
160
372
  /** Dispatch a synthetic DOM event through the mock DOM source */
161
373
  simulateEvent: (selector: string, eventType: string, eventInit?: SimulatedEventInit) => void;
162
- /** Resolves once the component is subscribed and rendered (earlier calls are buffered) */
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
+ */
163
379
  ready: () => Promise<void>;
164
380
  /**
165
381
  * Wait for a state that satisfies the predicate, searching the HISTORY first: a state
@@ -169,27 +385,136 @@ export interface RenderResult {
169
385
  */
170
386
  waitForState: (predicate: (state: any) => boolean, timeoutMs?: number) => Promise<any>;
171
387
  /**
172
- * Wait for the next state emitted AFTER this call that satisfies the predicate (default:
173
- * any). Resolves with it once the whole tree (children included) has rendered it.
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.
174
393
  */
175
394
  next: (predicate?: (state: any) => boolean, timeoutMs?: number) => Promise<any>;
176
395
  /**
177
396
  * Resolves when the component is quiet: ready, no simulated input pending, and no render,
178
- * reducer or state change anywhere in the tree for a short window (20ms; covers next()'s
179
- * default delay). Rejects after timeoutMs (default 2000) if it never calms down.
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.
180
399
  */
181
400
  settle: (timeoutMs?: number) => Promise<void>;
182
401
  /** Collected state values — grows as new states are emitted */
183
402
  states: any[];
184
- /** Live array of values emitted on a sink (EVENTS, PARENT, custom drivers, ...) */
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
+ */
185
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;
186
501
  /** Live array of EVENTS sink emissions ({type, data}) */
187
502
  emitted: any[];
188
503
  /** Live array of diagnostics reported while rendered */
189
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[];
190
512
  /** Throws (with the formatted texts) if any warn/error diagnostics were collected */
191
513
  expectNoDiagnostics: () => void;
192
- /** Latest rendered VNode serialized to HTML ('' before the first render) */
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
+ */
193
518
  html: () => string;
194
519
  /** Tear down the component, clean up listeners and restore the diagnostics mode */
195
520
  dispose: () => void;
@@ -197,10 +522,31 @@ export interface RenderResult {
197
522
  * The app graph (2B) of the rendered tree: components, actions, selectors (with the mock DOM's
198
523
  * match / isolation results), EVENTS and diagnostics. Requires `import 'sygnal/diagnostics'`.
199
524
  */
200
- inspect: () => InspectGraph;
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};
201
545
  }
202
546
 
203
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;
204
550
  /** a listener path's selector text (isolation scopes dropped, whitespace normalized) */
205
551
  const selText = (path: string[]) => norm(path.filter(s => !isScope(s)).join(' '));
206
552
  const norm = (s: string) => s.trim().replace(/\s*>\s*/g, ' > ').replace(/\s+/g, ' ');
@@ -219,8 +565,10 @@ type Pseudo = {k: string; a: number; b: number; last?: boolean; type?: boolean;
219
565
  type Compound = {tag?: string; id?: string; cls: string[]; attrs: [string, string, string?][]; ps: Pseudo[]};
220
566
  type Complex = {parts: Compound[]; combs: string[]};
221
567
  type Sel = Complex[];
222
- 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(...), the descendant (' ') and child ('>') combinators, ',' lists";
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";
223
569
  const IDENT = /^(?:[\w-]|\\.)+/;
570
+ const STATES = ['checked', 'disabled', 'enabled'];
571
+ const FORM = ['button', 'input', 'select', 'textarea', 'option', 'optgroup', 'fieldset'];
224
572
  const unesc = (x: string) => x.replace(/\\(.)/g, '$1');
225
573
  const parsed = new Map<string, Sel | Error>();
226
574
  function parse(src: string): Sel {
@@ -236,7 +584,7 @@ const tryParse = (src: string): Sel | undefined => { try { return parse(src); }
236
584
  function parseSel(src: string): Sel {
237
585
  let i = 0;
238
586
  const bad = (what: string): never => {
239
- 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"]`);
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`);
240
588
  e.unsupported = true;
241
589
  throw e;
242
590
  };
@@ -297,6 +645,8 @@ function parseSel(src: string): Sel {
297
645
  [p.a, p.b] = anb(arg!);
298
646
  p.last = !!m[2];
299
647
  }
648
+ } else if (STATES.includes(k) && arg === undefined) {
649
+ // 6-B: element state, as the view rendered it
300
650
  } else if (k == 'not' && arg !== undefined) {
301
651
  p.not = parse(arg);
302
652
  if (p.not.some(cx => cx.parts.length > 1)) bad("combinators inside ':not()'");
@@ -383,6 +733,10 @@ function is(v: any, c: Compound): boolean {
383
733
  }
384
734
  for (const ps of c.ps) {
385
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
+ }
386
740
  let sibs = (meta.get(v) || {sibs: [v]}).sibs;
387
741
  if (ps.type) sibs = sibs.filter(s => tagOf(s) == tagOf(v));
388
742
  if (ps.k == 'only') { if (sibs.length != 1) return false; continue; }
@@ -416,6 +770,247 @@ function find(v: any, sel: Sel, chain: any[] = []): any[] | undefined {
416
770
  if (r) return r;
417
771
  }
418
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
+
920
+ /**
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
+ }
419
1014
 
420
1015
  /** Internal (perf-guard tests): number of SYG104 tree walks */
421
1016
  export const _testingStats = {walks: 0};
@@ -423,21 +1018,458 @@ export const _testingStats = {walks: 0};
423
1018
  // 1H-5: live renderComponent instances; the explicit diagnostics config and the strict flag
424
1019
  // (R4) from before the outermost one are restored when the last one is disposed
425
1020
  let active = 0;
426
- /** simulateEvent: how long an event waits for its element / listeners (G-049, G-039) */
427
- const WAIT_MS = 300;
428
1021
  /** quiet window after which the whole tree counts as rendered (G-047) */
429
1022
  const QUIET_MS = 10;
430
- /** quiet window for settle(): longer than next()'s default 10ms delay */
431
- const SETTLE_MS = 20;
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
+ };
432
1336
  let savedConfig: ReturnType<typeof _getDiagnosticsConfig>;
433
1337
  let savedStrict: any;
434
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.
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
+
435
1433
  export function renderComponent(
436
1434
  componentDef: any,
437
1435
  options: RenderOptions = {}
438
1436
  ): RenderResult {
439
- const {initialState, mockConfig = {}, drivers = {}, diagnostics, strict} = options;
1437
+ const {initialState, mockConfig = {}, drivers = {}, diagnostics, strict, dom = 'mock', autoConnect = true, socketSink = 'WS', resourceSink = 'HTTP', http: httpOptions, context: ancestors} = options;
440
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 }))`);
441
1473
 
442
1474
  const prevMode = getDiagnosticsMode();
443
1475
  // 2A: strict flag on the core bridge (read by the 'sygnal/diagnostics' strict checks)
@@ -452,6 +1484,11 @@ export function renderComponent(
452
1484
  }
453
1485
  configureDiagnostics({mode: diagnostics || (prevMode == 'off' ? 'collect' : prevMode)});
454
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;
455
1492
  const collected: Diagnostic[] = [];
456
1493
  const offDiag = onDiagnostic(d => collected.push(d));
457
1494
 
@@ -472,27 +1509,79 @@ export function renderComponent(
472
1509
  };
473
1510
  // G-047: activity anywhere in the tree (any component's render/reducer, state, input), for
474
1511
  // the "the full tree has rendered" / settle() quiet windows
475
- let activity = 0, lastActivity = Date.now();
476
- const bump = () => { activity++; lastActivity = Date.now(); };
477
- const offCheck = registerCheck({
478
- id: 'renderComponent',
479
- onRender: bump,
480
- onReducer: bump,
481
- onIntent(c: any) {
482
- bump();
483
- const sc = scopeOf(c);
484
- if (sc) owners.set(sc, c.name);
485
- if (c.sources[c.DOMSourceName || 'DOM']?._hub == hub.$) scopeIds.set(sc || '', c._componentNumber);
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);
486
1536
  },
487
- // 1H-11: forget a disposed child's listeners, so they aren't checked on every render
488
- onDispose(c: any) {
489
- const sc = scopeOf(c);
490
- if (!sc) return;
491
- owners.delete(sc);
492
- scopeIds.delete(sc);
493
- listeners.forEach((path, k) => { if (path.filter(isScope).pop() == sc) listeners.delete(k); });
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);
494
1542
  },
495
- });
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
+ };
496
1585
  const raise = (code: string, component: string, message: string, fix: string, data: any) => {
497
1586
  try { report(code, {component, message, fix, data}); } catch (e) { setTimeout(() => { throw e; }); }
498
1587
  };
@@ -506,8 +1595,10 @@ export function renderComponent(
506
1595
  const desc = (_: any, els: any[]) => matches(sel, els);
507
1596
  // chain: [vnode, nearest scope][] from the root; inside: under this component's root
508
1597
  const walk = (v: any, chain: any[], cur: any, inside: boolean, boundary: any): void => {
509
- if (!v || !v.sel || own) return;
510
- const sc = (v.sel.match(/\.___[^.#]+/) || [])[0] || cur;
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;
511
1602
  const c = chain.concat([[v, sc]]);
512
1603
  inside = inside || sc == scope;
513
1604
  if (inside) {
@@ -528,6 +1619,8 @@ export function renderComponent(
528
1619
  return {own, child, hit};
529
1620
  };
530
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;
531
1624
  if (!vtree || !isDiagnosticsEnabled() || (!target && vtree === checkedTree && !newListener)) return;
532
1625
  if (!target) checkedTree = vtree, newListener = false;
533
1626
  listeners.forEach(path => {
@@ -541,19 +1634,23 @@ export function renderComponent(
541
1634
  if (!child || !hit) return;
542
1635
  done.add(key);
543
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];
544
1639
  raise('SYG104', name,
545
- `DOM.select('${selector}') in ${name} matches elements inside ${childName} (isolated), so ${name} never receives their events`,
546
- `Handle the event in ${childName} and send it up with PARENT (read it here with CHILD.select(${childName})), or use EVENTS`,
547
- {selector, child: childName});
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});
548
1643
  });
549
1644
  };
550
1645
 
551
1646
  const restore = () => {
552
- offCheck();
553
1647
  offDiag();
1648
+ if (ownBridge && core.elementCommand === reportElementCommand) core.elementCommand = undefined;
554
1649
  if (!--active) {
555
1650
  configureDiagnostics(savedConfig);
556
1651
  core.strict = savedStrict;
1652
+ domFakes?.();
1653
+ domFakes = undefined;
557
1654
  }
558
1655
  };
559
1656
 
@@ -566,81 +1663,488 @@ export function renderComponent(
566
1663
  });
567
1664
  return p;
568
1665
  };
569
- const actions = port();
570
1666
  const hub = port();
571
1667
 
572
- const names = Object.keys(model)
573
- .map(k => k.split('|')[0].trim())
574
- .filter(n => n != 'INITIALIZE');
575
- const actionStream = (type: string) =>
576
- actions.$.filter((a: any) => a.type == type).map((a: any) => a.data);
577
-
578
- const wrappedIntent = (sources: any) => {
579
- const res = intent ? intent(sources) : {};
580
- if (res && typeof res.addListener == 'function') return xs.merge(res, actions.$);
581
- const out: any = {...res};
582
- const added = names.filter(n => !(n in out));
583
- for (const n of new Set([...Object.keys(out), ...added])) {
584
- out[n] = out[n] ? xs.merge(out[n], actionStream(n)) : actionStream(n);
585
- }
586
- Object.defineProperty(out, '__sygnalTestActions', {value: added});
587
- return out;
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!;
588
1752
  };
589
-
590
- const {context, calculated, storeCalculatedInState, onError, hmrActions, components} = componentDef;
591
- const init = initialState !== undefined ? initialState : componentDef.initialState;
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;
592
1864
  // G-028: with no intent, model or initialState nothing would ever emit state, so the view
593
1865
  // never renders. Leave intent/model unset so the component falls back to the same no-op
594
1866
  // model run() uses, and renders.
595
- const bare = !intent && !Object.keys(model).length && init === undefined;
596
- const app = component({
597
- name: componentDef.name || componentDef.componentName || 'TestComponent',
598
- view: componentDef,
599
- intent: bare ? undefined : wrappedIntent,
600
- model: bare ? undefined : model,
601
- hmrActions,
602
- components,
603
- context,
604
- calculated,
605
- storeCalculatedInState,
606
- onError,
607
- initialState: init,
608
- });
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>()};
609
1985
  const allDrivers: any = {
610
- DOM: () => mockDOMSource(mockConfig, hub.$, (path, type, on) => {
611
- const k = path.join('\u0000');
612
- if (on === undefined) {
613
- if (!listeners.has(k)) listeners.set(k, path), newListener = true;
614
- (evTypes[k] = evTypes[k] || []).push(type);
615
- } else {
616
- // G-039: subscribed / unsubscribed listeners (a just-mounted child subscribes late)
617
- const lk = k + '\u0000' + type;
618
- live.set(lk, (live.get(lk) || 0) + (on ? 1 : -1));
619
- if (on) retry(0);
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);
620
1992
  }
621
- }),
1993
+ : () => mockDOMSource(mockConfig, hub.$, onEvents),
622
1994
  EVENTS: eventBusDriver,
623
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}),
624
2001
  ...drivers,
2002
+ ...(options.onError && {__e: () => options.onError}),
2003
+ ...(ps && {__storage: () => ps}),
625
2004
  };
2005
+ const faked = new Set<string>();
626
2006
  for (const k in model) {
627
- const e = model[k], [, sink] = k.split('|');
628
- for (const n of sink ? [sink.trim()] : e && typeof e == 'object' ? Object.keys(e) : []) {
629
- if (!allDrivers[n] && !/^(STATE|EFFECT|PARENT|READY)$/.test(n)) {
630
- allDrivers[n] = () => ({select: () => xs.never()});
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);
631
2012
  }
632
2013
  }
633
2014
  }
634
- let sources: any, sinks: any, rawDispose: () => void;
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]);
2066
+ }
2067
+ replySubs.set(iv.id, subs);
2068
+ }
2069
+ },
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;
635
2125
  try {
636
- const p: any = setup(withState(app, 'STATE') as any, allDrivers);
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
+ });
637
2139
  ({sources, sinks} = p);
638
- rawDispose = p.run();
2140
+ api = p.api;
639
2141
  } catch (e) {
640
2142
  restore();
2143
+ container?.remove();
641
2144
  throw e;
642
2145
  }
643
2146
 
2147
+ started = true;
644
2148
  const subs: Array<[any, any]> = [];
645
2149
  const listen = (s: any, next: (v: any) => void) => {
646
2150
  const l = {next, error: noop, complete: noop};
@@ -649,33 +2153,55 @@ export function renderComponent(
649
2153
  };
650
2154
 
651
2155
  const states: any[] = [];
2156
+ let syncAt: number | undefined;
652
2157
  const stateStream: Stream<any> = sources.STATE?.stream || xs.never();
653
- listen(stateStream, s => { states.push(s); bump(); });
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
+ });
654
2170
 
655
2171
  const values: Record<string, any[]> = {};
656
2172
  const sinkValues = (k: string) => (values[k] = values[k] || []);
657
2173
  for (const k in sinks) {
658
2174
  if (k != 'DOM' && k != 'STATE' && typeof sinks[k]?.addListener == 'function') {
659
- listen(sinks[k], v => sinkValues(k).push(
660
- k == 'EVENTS' ? {type: v.type, data: v.data} : k == 'PARENT' ? v.value : v
661
- ));
2175
+ listen(sinks[k], v => record(k, k == 'EVENTS' ? {type: v.type, data: v.data} : k == 'PARENT' ? v.value : v, faked.has(k)));
662
2176
  }
663
2177
  }
664
2178
  // Input queue (G-049/G-039): simulateAction/simulateEvent calls are delivered in order,
665
2179
  // once the component is ready. An event whose selector matches no rendered element yet, or
666
2180
  // whose matching listeners aren't subscribed yet, holds the queue until it can be delivered
667
- // (re-tried on every render), at most WAIT_MS; then it is delivered to the live listeners
2181
+ // (re-tried on every render), at most eventWaitMs; then it is delivered to the live listeners
668
2182
  // (or, with no matching element, fails the test, G-070; with allowMissing it is dropped
669
2183
  // with SYG103).
670
- type Input = {go: (last: boolean) => boolean, until?: number, missing?: () => Error | undefined};
2184
+ type Input = {go: (last: boolean) => boolean, until?: number, wait?: number, at?: number, missing?: () => Error | undefined};
671
2185
  const inputs: Input[] = [];
672
2186
  let isReady = false, retryTimer: any;
673
2187
  const pump = () => {
674
2188
  if (!isReady || disposed) return;
675
2189
  while (inputs.length) {
676
2190
  const head = inputs[0];
677
- head.until = head.until || Date.now() + WAIT_MS;
678
- if (!head.go(Date.now() >= head.until)) return retry(5);
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);
679
2205
  inputs.shift();
680
2206
  bump();
681
2207
  }
@@ -684,14 +2210,66 @@ export function renderComponent(
684
2210
  if (!retryTimer && inputs.length) retryTimer = setTimeout(() => { retryTimer = 0; pump(); }, ms);
685
2211
  };
686
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
+ };
687
2232
  const readyPromise = new Promise<void>(r => {
688
2233
  markReady = () => {
2234
+ // G-346: the fallback (no first render) has nothing left to do
2235
+ clearTimeout(fallback);
2236
+ readyAt = states.length;
689
2237
  isReady = true;
690
2238
  pump();
691
2239
  r();
692
2240
  };
693
2241
  });
694
- const later = (go: Input['go'], missing?: Input['missing']) => { inputs.push({go, missing}); pump(); };
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
+ };
695
2273
 
696
2274
  let vtree: any;
697
2275
  let timer: any;
@@ -703,15 +2281,25 @@ export function renderComponent(
703
2281
  vtree = v;
704
2282
  index(v);
705
2283
  renderedUpTo = states.length;
2284
+ if (real) tagTree(v);
706
2285
  bump();
707
2286
  check104();
708
2287
  arm();
709
- pump();
2288
+ // E4: the real DOM is patched a microtask after the sink emits
2289
+ real ? retry(0) : pump();
710
2290
  });
711
2291
  }
712
2292
  // 1H-4: a component that never renders on its own (a model but no initialState: no state
713
- // until an action sets it) still becomes ready, so buffered input is delivered
714
- const fallback = setTimeout(arm, sinks.DOM ? 30 : 0);
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);
715
2303
 
716
2304
  const tick = (ms: number) => new Promise(r => setTimeout(r, ms));
717
2305
  /**
@@ -721,12 +2309,12 @@ export function renderComponent(
721
2309
  * goes quiet): false.
722
2310
  */
723
2311
  const quiesce = async (n: number, quiet: number, cap: number, busy = () => false): Promise<boolean> => {
724
- const start = Date.now();
2312
+ const start = clockNow();
725
2313
  let seen = -1;
726
2314
  while (!disposed) {
727
- const idle = Date.now() - lastActivity;
728
- if (Date.now() - start > cap) return false;
729
- if ((!sinks.DOM || renderedUpTo >= n || Date.now() - start > 100) && idle >= quiet && !busy()) {
2315
+ const idle = clockNow() - lastActivity;
2316
+ if (clockNow() - start > cap) return false;
2317
+ if ((!sinks.DOM || renderedUpTo >= n || clockNow() - start > 100) && idle >= quiet && !busy()) {
730
2318
  if (seen === activity) return true;
731
2319
  seen = activity;
732
2320
  await tick(3);
@@ -738,6 +2326,34 @@ export function renderComponent(
738
2326
  return true;
739
2327
  };
740
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);
2356
+ };
741
2357
 
742
2358
  // G-070: a simulateEvent whose selector matches nothing fails the test. The error rejects
743
2359
  // the pending next()/waitForState()/settle() calls; with none pending it is kept and thrown
@@ -753,34 +2369,512 @@ export function renderComponent(
753
2369
  const takeFailure = () => { const f = failure; failure = undefined; return f; };
754
2370
  const throwFailure = () => { const f = takeFailure(); if (f) throw f; };
755
2371
  const noMatch = (selector: string, type: string, waited: boolean) => {
756
- const out = html();
2372
+ const out = renderHtml();
757
2373
  return new Error(`[Sygnal] simulateEvent('${selector}', '${type}'): the selector matched nothing in the rendered output` +
758
- (waited ? ` (waited ${WAIT_MS}ms for it to render)` : '') +
2374
+ (waited ? ` (waited ${eventWaitMs}ms for it to render; the eventWaitMs option sets this)` : '') +
759
2375
  `. Check t.html() to see what rendered, or give the element an attribute and select it, e.g. [data-id="3"]` +
760
2376
  ` (pass { allowMissing: true } to drop the event instead).\nRendered: ${out.length > 600 ? out.slice(0, 600) + '…' : out || '(nothing)'}`);
761
2377
  };
762
2378
 
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
+ };
763
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> => {
764
2466
  throwFailure();
765
- later(() => (actions.emit({type, data}), true));
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'});
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
+ };
2775
+
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);
766
2815
  };
767
2816
 
768
- const simulateEvent = (selector: string, type: string, init: SimulatedEventInit = {}) => {
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();
769
2840
  throwFailure();
770
- const {allowMissing, ...evInit} = init;
771
- const text = norm(String(selector));
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);
772
2844
  // 'document' / 'body' (and '') name a listener, not an element
773
2845
  const page = !text || PAGE.test(text);
774
- // unsupported syntax throws here, at the call (G-070)
775
- const sel = page ? [] : parse(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();
776
2869
  // nothing pending and the tree is quiet (as settle() would see it): fail at the call
777
2870
  if (!page && !allowMissing && isReady && !inputs.length && vtree && renderedUpTo >= states.length &&
778
- Date.now() - lastActivity >= SETTLE_MS && !find(vtree, sel)) {
2871
+ clockNow() - lastActivity >= settleMs && !has()) {
779
2872
  throw noMatch(selector, type, false);
780
2873
  }
781
2874
  later(last => {
782
- const chain = page ? undefined : find(vtree, sel);
783
- const el = chain?.[chain.length - 1];
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];
784
2878
  if (!page && !el) {
785
2879
  if (!last) return false;
786
2880
  if (!allowMissing) {
@@ -788,41 +2882,57 @@ export function renderComponent(
788
2882
  return true;
789
2883
  }
790
2884
  raise('SYG103', rootName,
791
- `simulateEvent('${selector}', '${type}') matched no rendered element within ${WAIT_MS}ms, so the event was dropped`,
2885
+ `simulateEvent('${selector}', '${type}') matched no rendered element within ${eventWaitMs}ms, so the event was dropped`,
792
2886
  `Check the selector against the view's className/id, or wait until the element is rendered (await t.next(...) or t.settle())`,
793
2887
  {selector, type});
794
2888
  return true;
795
2889
  }
796
- const match = (path: string[]) => {
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 => {
797
2893
  let ls = selText(path);
798
- if (!chain) return ls == text;
2894
+ if (!chain) return ls == text ? 0 : undefined;
799
2895
  let els = chain;
800
- if (PAGE.test(ls)) ls = ls.replace(PAGE, '');
2896
+ const pageLs = PAGE.test(ls);
2897
+ if (pageLs) ls = ls.replace(PAGE, '');
801
2898
  else {
802
2899
  const scope = path.filter(isScope).pop();
803
2900
  let cur: string | undefined;
804
2901
  els = chain.filter(v => {
805
- const m = v.sel.match(/\.___[^.#]+/);
806
- if (m) cur = m[0];
2902
+ const m = scopeOfV(v);
2903
+ if (m) cur = m;
807
2904
  return cur == scope;
808
2905
  });
809
2906
  }
810
- if (!ls) return true;
2907
+ if (!ls) return pageLs || !els.length ? -1 : chain.indexOf(els[0]);
811
2908
  const lsel = tryParse(ls);
812
- return !!lsel && els.some((_, k) => matches(lsel, els.slice(0, k + 1)));
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;
813
2914
  };
2915
+ const match = (path: string[]) => depthOf(path) !== undefined;
814
2916
  // G-039: wait until every listener this event would reach is subscribed
815
2917
  if (!last) {
816
2918
  for (const [k, path] of listeners) {
817
2919
  if ((evTypes[k] || []).includes(type) && !live.get(k + '\u0000' + type) && match(path)) return false;
818
2920
  }
819
2921
  }
2922
+ if (real) {
2923
+ if (chain?.length) check104(chain[chain.length - 1]);
2924
+ fire(el, type, evInit);
2925
+ return true;
2926
+ }
820
2927
  if (el) check104(el);
821
2928
  const d = el?.data || {}, p = d.props || {};
822
2929
  const {target: t = {}, value, checked, dataset, data, ...rest} = evInit;
823
2930
  const vval = p.value ?? d.attrs?.value;
824
2931
  const target: any = {
825
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)}),
826
2936
  value: vval == null ? vval : String(vval),
827
2937
  checked: p.checked ?? d.attrs?.checked,
828
2938
  ...('value' in init && {value}),
@@ -847,6 +2957,7 @@ export function renderComponent(
847
2957
  };
848
2958
  target.closest = closestFrom(chain.length - 1);
849
2959
  }
2960
+ const noBubble = NO_BUBBLE.test(type);
850
2961
  const event = {
851
2962
  type,
852
2963
  target,
@@ -855,11 +2966,18 @@ export function renderComponent(
855
2966
  dataTransfer: {},
856
2967
  preventDefault: noop,
857
2968
  stopPropagation: noop,
2969
+ bubbles: !noBubble,
858
2970
  ...rest,
859
2971
  };
860
- hub.emit({type, event, match});
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);
861
2979
  return true;
862
- }, page || allowMissing ? undefined : () => (find(vtree, sel) ? undefined : noMatch(selector, type, false)));
2980
+ }, page || allowMissing ? undefined : () => (has() ? undefined : noMatch(selector, type, false)));
863
2981
  };
864
2982
 
865
2983
  // first state at index >= from matching predicate; resolves after the full tree rendered it
@@ -867,13 +2985,31 @@ export function renderComponent(
867
2985
  new Promise((resolve, reject) => {
868
2986
  const f = takeFailure();
869
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)); } };
870
2993
  const found = (i: number) => {
871
2994
  clearTimeout(timer);
872
2995
  stateStream.removeListener(listener);
873
- treeRendered(i + 1).then(() => { waiters.delete(fail); resolve(states[i]); });
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
+ }
874
3010
  };
875
3011
  const test = (i: number) => { try { return predicate(states[i]); } catch (_) { return false; } };
876
- const fail = (err: Error) => { clearTimeout(timer); stateStream.removeListener(listener); waiters.delete(fail); reject(err); };
3012
+ const fail = (err: Error) => { clearTimeout(timer); stateStream.removeListener(listener); waiters.delete(fail); unhold(); reject(err); };
877
3013
  waiters.add(fail);
878
3014
  const base = states.length;
879
3015
  const listener = {
@@ -883,24 +3019,87 @@ export function renderComponent(
883
3019
  error: (err: any) => fail(err),
884
3020
  complete: () => fail(new Error(`${name}: state stream completed without matching`)),
885
3021
  };
886
- const timer = setTimeout(() => fail(new Error(`${name} timed out after ${timeoutMs}ms`)), timeoutMs);
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);
887
3038
  for (let i = from; i < states.length; i++) if (test(i)) return found(i);
888
3039
  stateStream.addListener(listener);
889
3040
  });
890
3041
 
891
- const waitForState = (predicate: (state: any) => boolean, timeoutMs: number = 2000) =>
892
- waitMatch(0, predicate, timeoutMs, 'waitForState');
893
- const next = (predicate: (state: any) => boolean = () => true, timeoutMs: number = 2000) =>
894
- waitMatch(states.length, predicate, timeoutMs, 'next');
895
- const settle = (timeoutMs: number = 2000): Promise<void> => new Promise((resolve, reject) => {
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];
3062
+ };
3063
+
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;
896
3088
  const f = takeFailure();
897
3089
  if (f) return reject(f);
898
- const done = (e?: Error) => { waiters.delete(done); e ? reject(e) : resolve(); };
3090
+ let wait: any;
3091
+ const done = (e?: Error) => { clearTimeout(wait); waiters.delete(done); e ? reject(e) : resolve(); };
899
3092
  waiters.add(done);
3093
+ const start = clockNow();
900
3094
  (async () => {
901
- await Promise.race([readyPromise, tick(timeoutMs)]);
902
- if (!await quiesce(states.length, SETTLE_MS, timeoutMs, () => !isReady || inputs.length > 0)) {
903
- throw new Error(`settle timed out after ${timeoutMs}ms: ${inputs.length ? `${inputs.length} simulated input(s) still pending` : 'the component kept rendering'}`);
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.' : ''));
904
3103
  }
905
3104
  })().then(() => done(), done);
906
3105
  });
@@ -913,35 +3112,46 @@ export function renderComponent(
913
3112
  }
914
3113
  };
915
3114
 
916
- // G-040: a Collection's container keeps its marker props (of, from, filter, item props...)
917
- // as snabbdom props, i.e. DOM properties, not attributes: drop them, like the real DOM
918
- const unmark = (v: any): any => {
919
- if (!v || typeof v != 'object' || !v.sel) return v;
920
- let d = v.data;
921
- if (d?.isCollection) {
922
- const {className, id} = d.props || {};
923
- d = {...d, props: {className, id}};
924
- }
925
- return {...v, data: d, children: v.children && v.children.map(unmark)};
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();
926
3120
  };
927
- const html = () =>
928
- vtree
929
- ? renderToString(() => unmark(vtree)).replace(/ class="([^"]*)"/g, (_, c: string) =>
930
- (c = c.split(' ').filter(x => !x.startsWith('___')).join(' ')) ? ` class="${c}"` : ''
931
- )
932
- : '';
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) : '';
933
3126
 
934
- const inspect = (): InspectGraph => {
3127
+ const inspect = (o: Pick<InspectOptions, 'actions'> = {}): InspectGraph => {
935
3128
  if (!core.inspect) throw Error(`[Sygnal] t.inspect() needs import 'sygnal/diagnostics'`);
936
- return core.inspect({ids: [...scopeIds.values()], diagnostics: collected, mock: {listeners, evTypes, owners, scopeIds, probe, vtree}});
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}})};
3144
+ }
937
3145
  };
938
3146
 
939
3147
  const dispose = () => {
940
3148
  if (disposed) return;
941
3149
  disposed = true;
3150
+ commandTimers.forEach(clearTimeout);
942
3151
  clearTimeout(timer);
943
3152
  clearTimeout(fallback);
944
3153
  clearTimeout(retryTimer);
3154
+ unexpire();
945
3155
  // G-070: an event still waiting for an element that never rendered fails the test
946
3156
  const head = inputs[0];
947
3157
  if (head && head.until && head.missing && !failure) failure = head.missing();
@@ -950,9 +3160,24 @@ export function renderComponent(
950
3160
  try { s.removeListener(l); } catch (_) {}
951
3161
  });
952
3162
  try { sinks.__dispose?.(); } catch (_) {}
953
- rawDispose();
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());
954
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];
955
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));
956
3181
  throwFailure();
957
3182
  };
958
3183
 
@@ -964,17 +3189,50 @@ export function renderComponent(
964
3189
  sources,
965
3190
  simulateAction,
966
3191
  simulateEvent,
967
- ready: () => readyPromise,
3192
+ ready,
968
3193
  waitForState,
969
3194
  next,
970
- settle,
3195
+ // GS-5: then the pending persist() writes
3196
+ settle: (ms?: number) => settle(ms).then(() => { if (ps) ps.f.forEach((f: () => void) => f()); }),
971
3197
  states,
3198
+ actions: actionList,
3199
+ explain,
3200
+ get state() { return states[states.length - 1]; },
972
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],
973
3226
  emitted: sinkValues('EVENTS'),
974
3227
  diagnostics: collected,
3228
+ commands: (name = 'ELEMENT') => name == 'ELEMENT' ? commandLog : sinkValues(name),
975
3229
  expectNoDiagnostics,
976
3230
  html,
977
3231
  dispose,
978
3232
  inspect,
3233
+ container,
3234
+ query,
3235
+ queryAll,
3236
+ widget,
979
3237
  };
980
3238
  }