sygnal 5.3.7 → 5.4.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 (226) hide show
  1. package/README.md +53 -38
  2. package/dist/astro/client.cjs.js +18 -8106
  3. package/dist/astro/client.cjs.js.map +1 -0
  4. package/dist/astro/client.mjs +17 -8105
  5. package/dist/astro/client.mjs.map +1 -0
  6. package/dist/astro/index.cjs.js +768 -9
  7. package/dist/astro/index.cjs.js.map +1 -0
  8. package/dist/astro/index.mjs +768 -9
  9. package/dist/astro/index.mjs.map +1 -0
  10. package/dist/astro/server.cjs.js +4 -1
  11. package/dist/astro/server.cjs.js.map +1 -0
  12. package/dist/astro/server.mjs +4 -1
  13. package/dist/astro/server.mjs.map +1 -0
  14. package/dist/diagnostics.cjs.js +1297 -0
  15. package/dist/diagnostics.cjs.js.map +1 -0
  16. package/dist/diagnostics.esm.js +1267 -0
  17. package/dist/diagnostics.esm.js.map +1 -0
  18. package/dist/index.cjs.js +2981 -1386
  19. package/dist/index.cjs.js.map +1 -0
  20. package/dist/index.d.ts +848 -111
  21. package/dist/index.esm.js +2997 -1411
  22. package/dist/index.esm.js.map +1 -0
  23. package/dist/jsx-dev-runtime.cjs.js +15 -9
  24. package/dist/jsx-dev-runtime.cjs.js.map +1 -0
  25. package/dist/jsx-dev-runtime.esm.js +15 -9
  26. package/dist/jsx-dev-runtime.esm.js.map +1 -0
  27. package/dist/jsx-runtime.cjs.js +15 -9
  28. package/dist/jsx-runtime.cjs.js.map +1 -0
  29. package/dist/jsx-runtime.esm.js +15 -9
  30. package/dist/jsx-runtime.esm.js.map +1 -0
  31. package/dist/jsx.cjs.js +15 -9
  32. package/dist/jsx.cjs.js.map +1 -0
  33. package/dist/jsx.esm.js +15 -9
  34. package/dist/jsx.esm.js.map +1 -0
  35. package/dist/sygnal.min.js +2 -1
  36. package/dist/sygnal.min.js.map +1 -0
  37. package/dist/vike/+config.cjs.js +1 -0
  38. package/dist/vike/+config.cjs.js.map +1 -0
  39. package/dist/vike/+config.js +1 -0
  40. package/dist/vike/+config.js.map +1 -0
  41. package/dist/vike/ClientOnly.cjs.js +1 -0
  42. package/dist/vike/ClientOnly.cjs.js.map +1 -0
  43. package/dist/vike/ClientOnly.mjs +1 -0
  44. package/dist/vike/ClientOnly.mjs.map +1 -0
  45. package/dist/vike/onRenderClient.cjs.js +1 -0
  46. package/dist/vike/onRenderClient.cjs.js.map +1 -0
  47. package/dist/vike/onRenderClient.mjs +1 -0
  48. package/dist/vike/onRenderClient.mjs.map +1 -0
  49. package/dist/vike/onRenderHtml.cjs.js +1 -0
  50. package/dist/vike/onRenderHtml.cjs.js.map +1 -0
  51. package/dist/vike/onRenderHtml.mjs +1 -0
  52. package/dist/vike/onRenderHtml.mjs.map +1 -0
  53. package/dist/vite/plugin.cjs.js +670 -44
  54. package/dist/vite/plugin.cjs.js.map +1 -0
  55. package/dist/vite/plugin.mjs +670 -44
  56. package/dist/vite/plugin.mjs.map +1 -0
  57. package/llms.txt +250 -0
  58. package/package.json +15 -6
  59. package/src/astro/client.ts +13 -2
  60. package/src/astro/index.d.ts +24 -1
  61. package/src/astro/index.ts +40 -9
  62. package/src/astro/server.ts +3 -1
  63. package/src/collection.ts +5 -2
  64. package/src/component.ts +375 -330
  65. package/src/cycle/dom/MainDOMSource.ts +4 -0
  66. package/src/cycle/dom/classNameModule.ts +74 -0
  67. package/src/cycle/dom/controlledInputModule.ts +33 -0
  68. package/src/cycle/dom/enrichEventStream.ts +9 -3
  69. package/src/cycle/dom/makeDOMDriver.ts +1 -2
  70. package/src/cycle/dom/mockDOMSource.ts +62 -8
  71. package/src/cycle/dom/modules.ts +5 -1
  72. package/src/cycle/state/Collection.ts +13 -3
  73. package/src/cycle/state/StateSource.ts +1 -1
  74. package/src/cycle/state/pickCombine.ts +35 -1
  75. package/src/cycle/state/withState.ts +1 -1
  76. package/src/extra/devtools.ts +8 -0
  77. package/src/extra/diagnostics/checks/collections.ts +67 -0
  78. package/src/extra/diagnostics/checks/dom.ts +241 -0
  79. package/src/extra/diagnostics/checks/events.ts +68 -0
  80. package/src/extra/diagnostics/checks/index.ts +109 -0
  81. package/src/extra/diagnostics/checks/inspect.ts +319 -0
  82. package/src/extra/diagnostics/checks/props.ts +51 -0
  83. package/src/extra/diagnostics/checks/public.d.ts +211 -0
  84. package/src/extra/diagnostics/checks/rxjsHints.ts +88 -0
  85. package/src/extra/diagnostics/checks/shared.ts +119 -0
  86. package/src/extra/diagnostics/checks/state.ts +55 -0
  87. package/src/extra/diagnostics/checks/strict.ts +112 -0
  88. package/src/extra/diagnostics/checks/wiring.ts +65 -0
  89. package/src/extra/diagnostics/codes.ts +232 -0
  90. package/src/extra/diagnostics/index.ts +328 -0
  91. package/src/extra/diagnostics/legacy.ts +59 -0
  92. package/src/extra/driverFactories.ts +106 -58
  93. package/src/extra/eventDriver.ts +4 -0
  94. package/src/extra/flatten.ts +75 -0
  95. package/src/extra/reducers.ts +12 -5
  96. package/src/extra/run.ts +11 -0
  97. package/src/extra/testing.ts +890 -180
  98. package/src/extra/xstreamExtras.ts +269 -0
  99. package/src/index.d.ts +428 -17
  100. package/src/index.ts +7 -6
  101. package/src/pragma/index.ts +14 -9
  102. package/src/switchable.ts +10 -8
  103. package/src/vike/types.ts +21 -4
  104. package/src/vite/plugin.d.ts +95 -2
  105. package/src/vite/plugin.ts +720 -54
  106. package/dist/astro/astro/client.d.ts +0 -5
  107. package/dist/astro/astro/index.d.ts +0 -16
  108. package/dist/astro/astro/server.d.ts +0 -12
  109. package/dist/astro/client.d.ts +0 -5
  110. package/dist/astro/client.esm.js +0 -4665
  111. package/dist/astro/collection.d.ts +0 -12
  112. package/dist/astro/component.d.ts +0 -22
  113. package/dist/astro/cycle/dom/BodyDOMSource.d.ts +0 -10
  114. package/dist/astro/cycle/dom/DOMSource.d.ts +0 -11
  115. package/dist/astro/cycle/dom/DocumentDOMSource.d.ts +0 -12
  116. package/dist/astro/cycle/dom/ElementFinder.d.ts +0 -8
  117. package/dist/astro/cycle/dom/EventDelegator.d.ts +0 -34
  118. package/dist/astro/cycle/dom/IsolateModule.d.ts +0 -23
  119. package/dist/astro/cycle/dom/MainDOMSource.d.ts +0 -32
  120. package/dist/astro/cycle/dom/PriorityQueue.d.ts +0 -7
  121. package/dist/astro/cycle/dom/ScopeChecker.d.ts +0 -9
  122. package/dist/astro/cycle/dom/SymbolTree.d.ts +0 -9
  123. package/dist/astro/cycle/dom/VNodeWrapper.d.ts +0 -8
  124. package/dist/astro/cycle/dom/enrichEventStream.d.ts +0 -24
  125. package/dist/astro/cycle/dom/fromEvent.d.ts +0 -6
  126. package/dist/astro/cycle/dom/index.d.ts +0 -8
  127. package/dist/astro/cycle/dom/isolate.d.ts +0 -9
  128. package/dist/astro/cycle/dom/makeDOMDriver.d.ts +0 -11
  129. package/dist/astro/cycle/dom/mockDOMSource.d.ts +0 -17
  130. package/dist/astro/cycle/dom/modules.d.ts +0 -5
  131. package/dist/astro/cycle/dom/snabbdom.d.ts +0 -22
  132. package/dist/astro/cycle/dom/styleModule.d.ts +0 -13
  133. package/dist/astro/cycle/dom/thunk.d.ts +0 -11
  134. package/dist/astro/cycle/dom/utils.d.ts +0 -8
  135. package/dist/astro/cycle/isolate/index.d.ts +0 -42
  136. package/dist/astro/cycle/run/adapt.d.ts +0 -6
  137. package/dist/astro/cycle/run/index.d.ts +0 -36
  138. package/dist/astro/cycle/run/internals.d.ts +0 -8
  139. package/dist/astro/cycle/run/types.d.ts +0 -49
  140. package/dist/astro/cycle/state/Collection.d.ts +0 -29
  141. package/dist/astro/cycle/state/StateSource.d.ts +0 -20
  142. package/dist/astro/cycle/state/index.d.ts +0 -5
  143. package/dist/astro/cycle/state/pickCombine.d.ts +0 -3
  144. package/dist/astro/cycle/state/pickMerge.d.ts +0 -3
  145. package/dist/astro/cycle/state/types.d.ts +0 -18
  146. package/dist/astro/cycle/state/withState.d.ts +0 -17
  147. package/dist/astro/extra/classes.d.ts +0 -7
  148. package/dist/astro/extra/devtools.d.ts +0 -78
  149. package/dist/astro/extra/dragDriver.d.ts +0 -20
  150. package/dist/astro/extra/driverFactories.d.ts +0 -12
  151. package/dist/astro/extra/eventDriver.d.ts +0 -9
  152. package/dist/astro/extra/exactState.d.ts +0 -1
  153. package/dist/astro/extra/hmr.d.ts +0 -17
  154. package/dist/astro/extra/logDriver.d.ts +0 -2
  155. package/dist/astro/extra/processDrag.d.ts +0 -19
  156. package/dist/astro/extra/processForm.d.ts +0 -15
  157. package/dist/astro/extra/run.d.ts +0 -13
  158. package/dist/astro/extra/xstreamCompat.d.ts +0 -5
  159. package/dist/astro/index.d.ts +0 -19
  160. package/dist/astro/index.esm.js +0 -27
  161. package/dist/astro/jsx-dev-runtime.d.ts +0 -1
  162. package/dist/astro/jsx-runtime.d.ts +0 -4
  163. package/dist/astro/jsx.d.ts +0 -2
  164. package/dist/astro/pragma/fn.d.ts +0 -7
  165. package/dist/astro/pragma/index.d.ts +0 -7
  166. package/dist/astro/pragma/is.d.ts +0 -10
  167. package/dist/astro/server.d.ts +0 -12
  168. package/dist/astro/server.esm.js +0 -25
  169. package/dist/astro/switchable.d.ts +0 -7
  170. package/dist/collection.d.ts +0 -12
  171. package/dist/component.d.ts +0 -22
  172. package/dist/cycle/dom/BodyDOMSource.d.ts +0 -10
  173. package/dist/cycle/dom/DOMSource.d.ts +0 -11
  174. package/dist/cycle/dom/DocumentDOMSource.d.ts +0 -12
  175. package/dist/cycle/dom/ElementFinder.d.ts +0 -8
  176. package/dist/cycle/dom/EventDelegator.d.ts +0 -34
  177. package/dist/cycle/dom/IsolateModule.d.ts +0 -23
  178. package/dist/cycle/dom/MainDOMSource.d.ts +0 -32
  179. package/dist/cycle/dom/PriorityQueue.d.ts +0 -7
  180. package/dist/cycle/dom/ScopeChecker.d.ts +0 -9
  181. package/dist/cycle/dom/SymbolTree.d.ts +0 -9
  182. package/dist/cycle/dom/VNodeWrapper.d.ts +0 -8
  183. package/dist/cycle/dom/enrichEventStream.d.ts +0 -24
  184. package/dist/cycle/dom/fromEvent.d.ts +0 -6
  185. package/dist/cycle/dom/index.d.ts +0 -8
  186. package/dist/cycle/dom/isolate.d.ts +0 -9
  187. package/dist/cycle/dom/makeDOMDriver.d.ts +0 -11
  188. package/dist/cycle/dom/mockDOMSource.d.ts +0 -17
  189. package/dist/cycle/dom/modules.d.ts +0 -5
  190. package/dist/cycle/dom/snabbdom.d.ts +0 -22
  191. package/dist/cycle/dom/styleModule.d.ts +0 -13
  192. package/dist/cycle/dom/thunk.d.ts +0 -11
  193. package/dist/cycle/dom/utils.d.ts +0 -8
  194. package/dist/cycle/isolate/index.d.ts +0 -42
  195. package/dist/cycle/run/adapt.d.ts +0 -6
  196. package/dist/cycle/run/index.d.ts +0 -36
  197. package/dist/cycle/run/internals.d.ts +0 -8
  198. package/dist/cycle/run/types.d.ts +0 -49
  199. package/dist/cycle/state/Collection.d.ts +0 -29
  200. package/dist/cycle/state/StateSource.d.ts +0 -20
  201. package/dist/cycle/state/index.d.ts +0 -5
  202. package/dist/cycle/state/pickCombine.d.ts +0 -3
  203. package/dist/cycle/state/pickMerge.d.ts +0 -3
  204. package/dist/cycle/state/types.d.ts +0 -18
  205. package/dist/cycle/state/withState.d.ts +0 -17
  206. package/dist/extra/classes.d.ts +0 -7
  207. package/dist/extra/devtools.d.ts +0 -78
  208. package/dist/extra/dragDriver.d.ts +0 -20
  209. package/dist/extra/driverFactories.d.ts +0 -12
  210. package/dist/extra/eventDriver.d.ts +0 -9
  211. package/dist/extra/exactState.d.ts +0 -1
  212. package/dist/extra/hmr.d.ts +0 -17
  213. package/dist/extra/logDriver.d.ts +0 -2
  214. package/dist/extra/processDrag.d.ts +0 -19
  215. package/dist/extra/processForm.d.ts +0 -15
  216. package/dist/extra/run.d.ts +0 -13
  217. package/dist/extra/xstreamCompat.d.ts +0 -5
  218. package/dist/jsx-dev-runtime.d.ts +0 -1
  219. package/dist/jsx-dev-runtime.js +0 -224
  220. package/dist/jsx-runtime.d.ts +0 -4
  221. package/dist/jsx-runtime.js +0 -224
  222. package/dist/jsx.d.ts +0 -2
  223. package/dist/pragma/fn.d.ts +0 -7
  224. package/dist/pragma/index.d.ts +0 -7
  225. package/dist/pragma/is.d.ts +0 -10
  226. package/dist/switchable.d.ts +0 -7
@@ -1,10 +1,125 @@
1
1
  import {setup} from '../cycle/run/index';
2
2
  import {withState} from '../cycle/state/index';
3
- import {mockDOMSource} from '../cycle/dom/index';
3
+ import {mockDOMSource} from '../cycle/dom/mockDOMSource';
4
4
  import eventBusDriver from './eventDriver';
5
5
  import logDriver from './logDriver';
6
6
  import component from '../component';
7
- import xs, {Stream} from './xstreamCompat';
7
+ import {renderToString} from './ssr';
8
+ import {_getDiagnosticsConfig, configureDiagnostics, getDiagnosticsMode, isDiagnosticsEnabled, onDiagnostic, registerCheck, report} from './diagnostics/index';
9
+ import xs from './xstreamCompat';
10
+ import type {Stream} from 'xstream';
11
+ import type {Diagnostic, DiagnosticsMode} from './diagnostics/index';
12
+ import type {InspectGraph} from './diagnostics/checks/public';
13
+
14
+ /*
15
+ * (Docs live on these type-only declarations so the TypeScript emit drops
16
+ * them — keeps them out of the published bundle.)
17
+ *
18
+ * renderComponent(Component, options?) runs a component on a minimal runtime
19
+ * (mock DOM, EVENTS, LOG, STATE + any `drivers`).
20
+ *
21
+ * - simulateEvent(selector, type, init?) sends a synthetic DOM event through
22
+ * the mock DOM source, so the component's real intent streams fire
23
+ * (DOM.click('.x'), DOM.select('.x').events('click'), .value(), .data(), ...).
24
+ * The target is the first rendered element matching `selector`; it is
25
+ * delivered to every listener whose selector matches that element or one of
26
+ * its ancestors in the same isolation scope (bubbling), and to
27
+ * DOM.select('document'|'body') listeners. `simulateEvent('document'|'body', ...)`
28
+ * goes to the listeners with exactly that selector. Selectors match the
29
+ * rendered vnode tree like the real DOM: tag, .class, #id, [attr], [attr=v]
30
+ * (^= $= *= ~=), :first-child, :last-child, :only-child, :nth-child(an+b),
31
+ * :nth-last-child, :*-of-type, :not(), ' ' and '>' combinators, ',' lists
32
+ * (G-070); other syntax (:has(), '+', '~', ...) throws at the call. If no
33
+ * rendered element matches yet, the event waits (up to 300ms, re-tried on
34
+ * every render) for one, then targets the first match. If none appears, the
35
+ * test fails (G-070): the error rejects the pending next()/waitForState()/
36
+ * settle(), or is thrown by the next t.* call or dispose(); when nothing is
37
+ * pending and the tree is quiet it is thrown by simulateEvent itself. With
38
+ * `{allowMissing: true}` it is dropped with SYG103 instead (it is never sent
39
+ * to every listener with that selector string, G-049).
40
+ * It also waits until the listeners it would reach are subscribed (a
41
+ * just-mounted child subscribes a few ms after it renders, G-039).
42
+ * `target.value/checked/dataset` default from the element's
43
+ * vnode (as strings, like the DOM) and are overridden by `init`.
44
+ * - simulateAction(name, data?) pushes `{type: name, data}` into the real
45
+ * intent → model pipeline, so every sink of the model entry runs and hooks /
46
+ * diagnostics see the real action name. (Model actions that have no intent
47
+ * stream get one added under their real name; the injected names are listed
48
+ * on the intent object's non-enumerable `__sygnalTestActions` property.)
49
+ * - simulateAction/simulateEvent calls are delivered in call order. Calls made
50
+ * before the component is subscribed are buffered and replayed once it is
51
+ * ready; `await t.ready()` is an explicit sync point. An event that waits
52
+ * (see above) holds the calls after it.
53
+ * - Waiting: waitForState(pred) matches the recorded history too; next(pred)
54
+ * matches only states emitted after the call; both resolve once the whole
55
+ * tree has rendered the state. settle() resolves once nothing is pending.
56
+ * - Diagnostics: `diagnostics` (default 'collect', or the already-active
57
+ * mode when diagnostics are on) is applied with configureDiagnostics. The
58
+ * explicit config from before the first live instance is restored when the
59
+ * last live instance is disposed (overlapping/nested instances are fine). Most runtime checks live
60
+ * in a separate entry: `import 'sygnal/diagnostics'` in the test (or vitest
61
+ * setupFiles) to enable them. Two DOM checks are built in (G-024), since the
62
+ * real-DOM versions can't run on the mock DOM:
63
+ * - SYG104 (warn): an intent selector matches nothing in its component's own
64
+ * scope but matches elements inside a child component / Collection item
65
+ * (parents can't see those events). Checked after every render and when
66
+ * simulateEvent targets such an element; reported once per component +
67
+ * selector.
68
+ * - SYG103 (info): simulateEvent's selector matches no rendered element and
69
+ * no intent listens on it (typo?).
70
+ *
71
+ * ```js
72
+ * const t = renderComponent(Counter, { initialState: { count: 0 } })
73
+ * t.simulateEvent('.inc', 'click')
74
+ * await t.waitForState(s => s.count === 1)
75
+ * t.dispose()
76
+ * ```
77
+ *
78
+ * Implementation notes:
79
+ * - parse() turns a selector into compounds + combinators (cached; unsupported
80
+ * syntax throws); index() records each vnode's parent and element siblings
81
+ * on every render (for '>' and :nth-*); is(v, compound) matches one vnode;
82
+ * matches(sel, chain) matches the last vnode of an ancestor chain; find()
83
+ * returns the root → element chain of the first match.
84
+ * - port(): producer-backed stream; emitting with no listener is a silent drop.
85
+ * - Sinks named in the model without a driver get a no-op driver so their
86
+ * output stays observable. sinkValues: EVENTS entries drop the devtools
87
+ * 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.
92
+ * - "Rendered by the whole tree" = the root rendered the state and no render,
93
+ * reducer, state or input happened anywhere for 10ms (checked twice; capped at
94
+ * 250ms). Child renders are seen through the onRender diagnostics hook.
95
+ * - dispose() fires the component's DISPOSE action via sinks.__dispose.
96
+ * - 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 '.___'
99
+ * class on a vnode or its ancestors is the scope that owns it.
100
+ */
101
+
102
+ export interface SimulatedEventInit {
103
+ /** Merged into `event.target` (value, checked, dataset, ...) */
104
+ target?: Record<string, any>;
105
+ /** Shorthand for target.value */
106
+ value?: any;
107
+ /** Shorthand for target.checked */
108
+ checked?: boolean;
109
+ /** Merged into target.dataset (values become strings, like the DOM) */
110
+ dataset?: Record<string, any>;
111
+ /** Alias for dataset */
112
+ data?: Record<string, any>;
113
+ /** Keyboard key (e.key) */
114
+ key?: string;
115
+ /**
116
+ * Don't fail when the selector matches no rendered element: wait up to 300ms for it, then
117
+ * drop the event with SYG103 (info). Not copied onto the event.
118
+ */
119
+ allowMissing?: boolean;
120
+ /** Any other event properties are copied onto the event */
121
+ [prop: string]: any;
122
+ }
8
123
 
9
124
  export interface RenderOptions {
10
125
  /** Override or provide initial state (defaults to component's .initialState) */
@@ -13,6 +128,20 @@ export interface RenderOptions {
13
128
  mockConfig?: Record<string, any>;
14
129
  /** Additional drivers beyond DOM, EVENTS, STATE, and LOG */
15
130
  drivers?: Record<string, any>;
131
+ /**
132
+ * Diagnostics mode while the component is rendered. Default: 'collect', or
133
+ * the current mode when diagnostics are already on (e.g. 'error' set in a
134
+ * setup file). Restored on dispose(). SYG103/SYG104 (selector typos and
135
+ * parent selectors that only match inside child components) are checked
136
+ * built in; the other checks require `import 'sygnal/diagnostics'`.
137
+ */
138
+ diagnostics?: DiagnosticsMode;
139
+ /**
140
+ * Strict mode (canonical-form checks SYG501/502/504) while the component is
141
+ * rendered; restored on dispose(). Requires `import 'sygnal/diagnostics'`.
142
+ * Default: unchanged (off unless configureStrict(true) was called).
143
+ */
144
+ strict?: boolean;
16
145
  }
17
146
 
18
147
  export interface RenderResult {
@@ -26,234 +155,805 @@ export interface RenderResult {
26
155
  sinks: Record<string, any>;
27
156
  /** All source objects by driver name */
28
157
  sources: Record<string, any>;
29
- /** Push an action directly into the intent→model pipeline */
158
+ /** Push an action into the intent→model pipeline (all sinks of the entry run) */
30
159
  simulateAction: (actionName: string, data?: any) => void;
31
- /** Wait for state to satisfy a predicate (resolves with the matching state) */
160
+ /** Dispatch a synthetic DOM event through the mock DOM source */
161
+ simulateEvent: (selector: string, eventType: string, eventInit?: SimulatedEventInit) => void;
162
+ /** Resolves once the component is subscribed and rendered (earlier calls are buffered) */
163
+ ready: () => Promise<void>;
164
+ /**
165
+ * Wait for a state that satisfies the predicate, searching the HISTORY first: a state
166
+ * recorded before the call matches too (e.g. `count === 0` right after a reset resolves
167
+ * at once with the initial state). Resolves with the matching state once the whole tree
168
+ * (children included) has rendered it. Use next() to match only new states.
169
+ */
32
170
  waitForState: (predicate: (state: any) => boolean, timeoutMs?: number) => Promise<any>;
171
+ /**
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.
174
+ */
175
+ next: (predicate?: (state: any) => boolean, timeoutMs?: number) => Promise<any>;
176
+ /**
177
+ * 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.
180
+ */
181
+ settle: (timeoutMs?: number) => Promise<void>;
33
182
  /** Collected state values — grows as new states are emitted */
34
183
  states: any[];
35
- /** Tear down the component and clean up all listeners */
184
+ /** Live array of values emitted on a sink (EVENTS, PARENT, custom drivers, ...) */
185
+ sinkValues: (sinkName: string) => any[];
186
+ /** Live array of EVENTS sink emissions ({type, data}) */
187
+ emitted: any[];
188
+ /** Live array of diagnostics reported while rendered */
189
+ diagnostics: Diagnostic[];
190
+ /** Throws (with the formatted texts) if any warn/error diagnostics were collected */
191
+ expectNoDiagnostics: () => void;
192
+ /** Latest rendered VNode serialized to HTML ('' before the first render) */
193
+ html: () => string;
194
+ /** Tear down the component, clean up listeners and restore the diagnostics mode */
36
195
  dispose: () => void;
196
+ /**
197
+ * The app graph (2B) of the rendered tree: components, actions, selectors (with the mock DOM's
198
+ * match / isolation results), EVENTS and diagnostics. Requires `import 'sygnal/diagnostics'`.
199
+ */
200
+ inspect: () => InspectGraph;
37
201
  }
38
202
 
39
- /**
40
- * Render a Sygnal component in isolation for testing.
41
- *
42
- * Creates a minimal Cycle.js runtime with mocked DOM, event bus,
43
- * and state drivers. Returns streams and helpers for inspecting
44
- * component behavior.
45
- *
46
- * ```js
47
- * const t = renderComponent(Counter, { initialState: { count: 0 } })
48
- * t.simulateAction('INCREMENT')
49
- * await t.waitForState(s => s.count === 1)
50
- * t.dispose()
51
- * ```
52
- */
203
+ const isScope = (s: string) => s.startsWith('.___');
204
+ /** a listener path's selector text (isolation scopes dropped, whitespace normalized) */
205
+ const selText = (path: string[]) => norm(path.filter(s => !isScope(s)).join(' '));
206
+ const norm = (s: string) => s.trim().replace(/\s*>\s*/g, ' > ').replace(/\s+/g, ' ');
207
+ /** 'document' / 'body' (optionally followed by more) name a page listener, not an element */
208
+ const PAGE = /^(document|body)(\s+|$)/;
209
+ const str = (o: any) => {
210
+ const r: any = {};
211
+ for (const k in o) if (o[k] != null) r[k] = String(o[k]);
212
+ return r;
213
+ };
214
+
215
+ // ── Selector engine (G-070) ──────────────────────────────────────────────────
216
+ // Matches the rendered vnode tree with real-DOM semantics. Anything not listed in
217
+ // SUPPORTED throws "unsupported selector syntax" instead of silently matching nothing.
218
+ type Pseudo = {k: string; a: number; b: number; last?: boolean; type?: boolean; not?: Sel};
219
+ type Compound = {tag?: string; id?: string; cls: string[]; attrs: [string, string, string?][]; ps: Pseudo[]};
220
+ type Complex = {parts: Compound[]; combs: string[]};
221
+ 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";
223
+ const IDENT = /^(?:[\w-]|\\.)+/;
224
+ const unesc = (x: string) => x.replace(/\\(.)/g, '$1');
225
+ const parsed = new Map<string, Sel | Error>();
226
+ function parse(src: string): Sel {
227
+ let r = parsed.get(src);
228
+ if (!r) {
229
+ try { r = parseSel(src); } catch (e: any) { r = e; }
230
+ parsed.set(src, r!);
231
+ }
232
+ if (r instanceof Error) throw r;
233
+ return r!;
234
+ }
235
+ const tryParse = (src: string): Sel | undefined => { try { return parse(src); } catch (_) { return undefined; } };
236
+ function parseSel(src: string): Sel {
237
+ let i = 0;
238
+ 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"]`);
240
+ e.unsupported = true;
241
+ throw e;
242
+ };
243
+ const ws = () => { const n = src.slice(i).match(/^\s*/)![0].length; i += n; return n > 0; };
244
+ const ident = (what: string) => {
245
+ const m = src.slice(i).match(IDENT);
246
+ if (!m) return bad(`expected ${what} at '${src.slice(i) || 'end'}'`);
247
+ i += m[0].length;
248
+ return unesc(m[0]);
249
+ };
250
+ const anb = (arg: string): [number, number] => {
251
+ const x = arg.trim().toLowerCase().replace(/\s+/g, '');
252
+ if (x == 'odd') return [2, 1];
253
+ if (x == 'even') return [2, 0];
254
+ if (/^[+-]?\d+$/.test(x)) return [0, +x];
255
+ const m = x.match(/^([+-]?\d*)n([+-]\d+)?$/);
256
+ if (!m) return bad(`'${arg}' is not an+b`);
257
+ return [m[1] == '' || m[1] == '+' ? 1 : m[1] == '-' ? -1 : +m[1], m[2] ? +m[2] : 0];
258
+ };
259
+ const compound = (): Compound => {
260
+ const c: Compound = {cls: [], attrs: [], ps: []};
261
+ const from = i;
262
+ if (src[i] == '*') i++;
263
+ else if (IDENT.test(src.slice(i))) c.tag = ident('tag').toLowerCase();
264
+ for (;;) {
265
+ const ch = src[i];
266
+ if (ch == '.') { i++; c.cls.push(ident('a class name')); }
267
+ else if (ch == '#') { i++; c.id = ident('an id'); }
268
+ else if (ch == '[') {
269
+ const m = src.slice(i).match(/^\[\s*((?:[\w-]|\\.)+)\s*(?:([~^$*|]?=)\s*(?:"([^"]*)"|'([^']*)'|((?:[\w-]|\\.)+))\s*)?\]/);
270
+ if (!m) return bad(`attribute selector '${src.slice(i)}'`);
271
+ if (m[2] == '|=') bad("the '|=' attribute operator");
272
+ i += m[0].length;
273
+ c.attrs.push([unesc(m[1]), m[2] || '', m[2] ? (m[3] ?? m[4] ?? unesc(m[5])) : undefined]);
274
+ } else if (ch == ':') {
275
+ if (src[i + 1] == ':') bad('pseudo-elements');
276
+ i++;
277
+ const k = ident('a pseudo-class').toLowerCase();
278
+ let arg: string | undefined;
279
+ if (src[i] == '(') {
280
+ let depth = 0, j = i;
281
+ for (; j < src.length; j++) {
282
+ if (src[j] == '(') depth++;
283
+ else if (src[j] == ')' && !--depth) break;
284
+ }
285
+ if (j >= src.length) bad(`unclosed ':${k}('`);
286
+ arg = src.slice(i + 1, j);
287
+ i = j + 1;
288
+ }
289
+ const p: Pseudo = {k, a: 0, b: 1};
290
+ const m = k.match(/^(?:(first|last|only)|nth(-last)?)-(child|of-type)$/);
291
+ if (m && (arg === undefined) == !!m[1]) {
292
+ p.type = m[3] == 'of-type';
293
+ if (m[1] == 'only') p.k = 'only';
294
+ else if (m[1]) p.last = m[1] == 'last';
295
+ else {
296
+ if (/\bof\b/.test(arg!)) bad(`':${k}(... of S)'`);
297
+ [p.a, p.b] = anb(arg!);
298
+ p.last = !!m[2];
299
+ }
300
+ } else if (k == 'not' && arg !== undefined) {
301
+ p.not = parse(arg);
302
+ if (p.not.some(cx => cx.parts.length > 1)) bad("combinators inside ':not()'");
303
+ } else bad(`':${k}${arg === undefined ? '' : '(' + arg + ')'}'`);
304
+ c.ps.push(p);
305
+ } else break;
306
+ }
307
+ if (i == from) bad(`unexpected '${src[i] || 'end of selector'}'`);
308
+ return c;
309
+ };
310
+ const out: Sel = [];
311
+ ws();
312
+ if (i >= src.length) return out;
313
+ for (;;) {
314
+ const cx: Complex = {parts: [compound()], combs: []};
315
+ for (;;) {
316
+ const space = ws();
317
+ const ch = src[i];
318
+ if (i >= src.length || ch == ',') break;
319
+ if (ch == '+' || ch == '~') bad(`the '${ch}' combinator`);
320
+ if (ch == '>') { i++; ws(); cx.combs.push('>'); }
321
+ else if (space) cx.combs.push(' ');
322
+ else bad(`unexpected '${ch}'`);
323
+ cx.parts.push(compound());
324
+ }
325
+ out.push(cx);
326
+ if (i >= src.length) return out;
327
+ i++; // ','
328
+ ws();
329
+ }
330
+ }
331
+
332
+ /** element children of a vnode (fragments flattened, text and comments skipped) */
333
+ function kids(v: any): any[] {
334
+ const out: any[] = [];
335
+ for (const k of [].concat(v.children || [])) {
336
+ if (!k || typeof k != 'object') continue;
337
+ if ((k as any).sel) { if ((k as any).sel != '!') out.push(k); }
338
+ else if ((k as any).children) out.push(...kids(k));
339
+ }
340
+ return out;
341
+ }
342
+ // parent + element siblings of each vnode of the latest rendered tree (for '>' and :nth-*)
343
+ const meta = new WeakMap<any, {p: any; sibs: any[]}>();
344
+ function index(root: any) {
345
+ if (!root || typeof root != 'object') return;
346
+ const walk = (v: any) => {
347
+ for (const ks of [kids(v), kids({children: v.data?.portalChildren || []})]) {
348
+ for (const k of ks) { meta.set(k, {p: v, sibs: ks}); walk(k); }
349
+ }
350
+ };
351
+ const tops = root.sel ? [root] : kids(root);
352
+ for (const t of tops) { meta.set(t, {p: undefined, sibs: tops}); walk(t); }
353
+ }
354
+ const tagOf = (v: any) => v.sel.split(/[.#]/)[0].toLowerCase();
355
+ function is(v: any, c: Compound): boolean {
356
+ const d = v.data || {}, p = d.props || {}, a = d.attrs || {};
357
+ const [tagId, ...cls] = v.sel.split('.');
358
+ const [tag, sid] = tagId.split('#');
359
+ if (c.tag && tag.toLowerCase() != c.tag) return false;
360
+ const id = sid || p.id || a.id;
361
+ if (c.id !== undefined && id != c.id) return false;
362
+ if (c.cls.length) {
363
+ const classes = cls.concat(
364
+ `${p.className || ''} ${a.class || ''}`.split(' '),
365
+ Object.keys(d.class || {}).filter(k => d.class[k])
366
+ );
367
+ if (!c.cls.every(n => classes.includes(n))) return false;
368
+ }
369
+ for (const [name, op, val] of c.attrs) {
370
+ const raw = name == 'id' ? id
371
+ : name in a ? a[name]
372
+ : name in p ? p[name]
373
+ : name.startsWith('data-') ? str(d.dataset)[name.slice(5).replace(/-(\w)/g, (_: any, l: string) => l.toUpperCase())]
374
+ : undefined;
375
+ if (raw == null || raw === false) return false;
376
+ const x = String(raw);
377
+ if (op && !(op == '=' ? x == val
378
+ : !val ? false
379
+ : op == '^=' ? x.startsWith(val)
380
+ : op == '$=' ? x.endsWith(val)
381
+ : op == '*=' ? x.includes(val)
382
+ : x.split(/\s+/).includes(val))) return false;
383
+ }
384
+ for (const ps of c.ps) {
385
+ if (ps.not) { if (matches(ps.not, [v])) return false; continue; }
386
+ let sibs = (meta.get(v) || {sibs: [v]}).sibs;
387
+ if (ps.type) sibs = sibs.filter(s => tagOf(s) == tagOf(v));
388
+ if (ps.k == 'only') { if (sibs.length != 1) return false; continue; }
389
+ const n = sibs.indexOf(v), pos = ps.last ? sibs.length - n : n + 1;
390
+ if (ps.a == 0 ? pos != ps.b : (pos - ps.b) / ps.a < 0 || (pos - ps.b) % ps.a != 0) return false;
391
+ }
392
+ return true;
393
+ }
394
+ /** does the last of `els` (an ancestor chain, root first, maybe scope-filtered) match `sel`? */
395
+ function matches(sel: Sel, els: any[]): boolean {
396
+ const at = (cx: Complex, j: number, i: number): boolean => {
397
+ if (i < 0 || !is(els[i], cx.parts[j])) return false;
398
+ if (j == 0) return true;
399
+ if (cx.combs[j - 1] == '>') {
400
+ const m = meta.get(els[i]);
401
+ return i > 0 && (!m || els[i - 1] === m.p) && at(cx, j - 1, i - 1);
402
+ }
403
+ for (let k = i - 1; k >= 0; k--) if (at(cx, j - 1, k)) return true;
404
+ return false;
405
+ };
406
+ return sel.some(cx => at(cx, cx.parts.length - 1, els.length - 1));
407
+ }
408
+ /** root → element chain of the first element (document order) matching `sel` */
409
+ function find(v: any, sel: Sel, chain: any[] = []): any[] | undefined {
410
+ if (!v || typeof v != 'object') return;
411
+ const c = v.sel ? chain.concat(v) : chain;
412
+ if (v.sel && matches(sel, c)) return c;
413
+ // a <Portal>'s content is rendered elsewhere; it is kept on its placeholder
414
+ for (const k of [].concat(v.children || [], v.data?.portalChildren || [])) {
415
+ const r = find(k, sel, c);
416
+ if (r) return r;
417
+ }
418
+ }
419
+
420
+ /** Internal (perf-guard tests): number of SYG104 tree walks */
421
+ export const _testingStats = {walks: 0};
422
+
423
+ // 1H-5: live renderComponent instances; the explicit diagnostics config and the strict flag
424
+ // (R4) from before the outermost one are restored when the last one is disposed
425
+ let active = 0;
426
+ /** simulateEvent: how long an event waits for its element / listeners (G-049, G-039) */
427
+ const WAIT_MS = 300;
428
+ /** quiet window after which the whole tree counts as rendered (G-047) */
429
+ const QUIET_MS = 10;
430
+ /** quiet window for settle(): longer than next()'s default 10ms delay */
431
+ const SETTLE_MS = 20;
432
+ let savedConfig: ReturnType<typeof _getDiagnosticsConfig>;
433
+ let savedStrict: any;
434
+
53
435
  export function renderComponent(
54
436
  componentDef: any,
55
437
  options: RenderOptions = {}
56
438
  ): RenderResult {
57
- const {initialState, mockConfig = {}, drivers = {}} = options;
439
+ const {initialState, mockConfig = {}, drivers = {}, diagnostics, strict} = options;
440
+ const {intent, model = {}} = componentDef;
58
441
 
59
- const name = componentDef.name || componentDef.componentName || 'TestComponent';
60
- const view = componentDef;
61
- const {
62
- intent,
63
- model,
64
- context,
65
- calculated,
66
- storeCalculatedInState,
67
- onError,
68
- } = componentDef;
69
-
70
- const resolvedInitialState =
71
- initialState !== undefined ? initialState : componentDef.initialState;
72
-
73
- // Create a test action$ stream that simulateAction can push into.
74
- // The component's intent function will receive this as a source,
75
- // and we merge test actions with any real intent streams.
76
- const testActionListener: {next: (val: any) => void} = {next: () => {}};
77
- const testAction$: Stream<any> = xs.create({
78
- start(listener: any) {
79
- testActionListener.next = (val: any) => listener.next(val);
442
+ const prevMode = getDiagnosticsMode();
443
+ // 2A: strict flag on the core bridge (read by the 'sygnal/diagnostics' strict checks)
444
+ const core = (globalThis as any).__SYGNAL_DIAGNOSTICS__;
445
+ if (!active++) {
446
+ savedConfig = _getDiagnosticsConfig();
447
+ savedStrict = core.strict;
448
+ // G-051: forget the checks' report dedupe (once()), so a finding from an earlier test is
449
+ // reported again. Only that set: resetChecks() would also drop inspect()'s records of
450
+ // other live instances / apps.
451
+ core.resetOnce?.();
452
+ }
453
+ configureDiagnostics({mode: diagnostics || (prevMode == 'off' ? 'collect' : prevMode)});
454
+ if (strict !== undefined) core.strict = strict;
455
+ const collected: Diagnostic[] = [];
456
+ const offDiag = onDiagnostic(d => collected.push(d));
457
+
458
+ // G-024: SYG103/SYG104 on the mock DOM
459
+ const rootName = componentDef.name || componentDef.componentName || 'TestComponent';
460
+ const listeners = new Map<string, string[]>();
461
+ // G-039: subscribed listener count per path + event type
462
+ const live = new Map<string, number>();
463
+ let disposed = false;
464
+ const owners = new Map<string, string>([['', rootName]]);
465
+ const scopeIds = new Map<string, number>();
466
+ const evTypes: Record<string, string[]> = {};
467
+ const done = new Set<string>();
468
+ // the innermost isolation scope of a component's DOM source on this hub
469
+ const scopeOf = (c: any) => {
470
+ const d = c && c.sources && c.sources[c.DOMSourceName || 'DOM'];
471
+ return d && d._hub === hub.$ && (d._path || []).filter(isScope).pop();
472
+ };
473
+ // G-047: activity anywhere in the tree (any component's render/reducer, state, input), for
474
+ // 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);
80
486
  },
81
- stop() {
82
- testActionListener.next = () => {};
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); });
83
494
  },
84
495
  });
85
-
86
- // Wrap the original intent to merge in test actions
87
- const wrappedIntent = intent
88
- ? (sources: any) => {
89
- const intentResult = intent(sources);
90
- // Intent returns a map of { ACTION: stream$ }
91
- // We add a special __TEST_ACTION__ entry that carries the test actions
92
- return {...intentResult, __TEST_ACTION__: testAction$};
93
- }
94
- : (sources: any) => ({__TEST_ACTION__: testAction$});
95
-
96
- // Wrap the model to handle __TEST_ACTION__ by dispatching to the real action
97
- const wrappedModel: Record<string, any> = {
98
- ...(model || {}),
99
- __TEST_ACTION__: {
100
- STATE: (state: any, action: any) => {
101
- if (!action || !action.type) return state;
102
- const {type, data} = action;
103
-
104
- // Find the model entry for this action
105
- let entry = model?.[type];
106
-
107
- // Check for shorthand entries ('ACTION | DRIVER')
108
- if (!entry) {
109
- for (const key of Object.keys(model || {})) {
110
- if (key.includes('|')) {
111
- const parts = key.split('|').map((s: string) => s.trim());
112
- if (parts[0] === type) {
113
- entry = {[parts[1]]: model[key]};
114
- break;
115
- }
116
- }
496
+ const raise = (code: string, component: string, message: string, fix: string, data: any) => {
497
+ try { report(code, {component, message, fix, data}); } catch (e) { setTimeout(() => { throw e; }); }
498
+ };
499
+ // 1H-11: a render with the same tree and no new listener can't change the result
500
+ let checkedTree: any, newListener = false;
501
+ // sels: the selector (inspect() passes it split into words)
502
+ const probe = (sels: string | string[], scope?: string, target?: any) => {
503
+ let own = false, child: string | undefined, hit = !target;
504
+ const sel = tryParse(Array.isArray(sels) ? sels.join(' ') : sels);
505
+ if (!sel || !sel.length) return {own, child, hit: false};
506
+ const desc = (_: any, els: any[]) => matches(sel, els);
507
+ // chain: [vnode, nearest scope][] from the root; inside: under this component's root
508
+ 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;
511
+ const c = chain.concat([[v, sc]]);
512
+ inside = inside || sc == scope;
513
+ if (inside) {
514
+ if (sc == scope) {
515
+ if (desc(sels, c.filter(x => x[1] == scope).map(x => x[0]))) own = true;
516
+ } else {
517
+ boundary = boundary || sc;
518
+ if (desc(sels, c.map(x => x[0]))) {
519
+ child = child || boundary;
520
+ if (v === target) hit = true;
117
521
  }
118
522
  }
523
+ }
524
+ for (const k of [].concat(v.children || [])) walk(k, c, sc, inside, sc == scope ? undefined : boundary);
525
+ };
526
+ _testingStats.walks++;
527
+ walk(vtree, [], undefined, !scope, undefined);
528
+ return {own, child, hit};
529
+ };
530
+ const check104 = (target?: any) => {
531
+ if (!vtree || !isDiagnosticsEnabled() || (!target && vtree === checkedTree && !newListener)) return;
532
+ if (!target) checkedTree = vtree, newListener = false;
533
+ listeners.forEach(path => {
534
+ const selector = selText(path);
535
+ const scope = path.filter(isScope).pop();
536
+ const name = owners.get(scope || '') || 'Component';
537
+ const key = name + '\u0000' + selector;
538
+ if (!selector || PAGE.test(selector) || done.has(key)) return;
539
+ const {own, child, hit} = probe(selector, scope, target);
540
+ if (own) return done.add(key);
541
+ if (!child || !hit) return;
542
+ done.add(key);
543
+ const childName = owners.get(child) || 'a child component';
544
+ 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});
548
+ });
549
+ };
119
550
 
120
- if (!entry) return state;
551
+ const restore = () => {
552
+ offCheck();
553
+ offDiag();
554
+ if (!--active) {
555
+ configureDiagnostics(savedConfig);
556
+ core.strict = savedStrict;
557
+ }
558
+ };
121
559
 
122
- // Plain function = state reducer
123
- if (typeof entry === 'function') {
124
- const result = entry(state, data);
125
- // Match component.ts ABORT handling
126
- if (typeof result === 'symbol') return state;
127
- return result !== undefined ? result : state;
128
- }
560
+ const noop = () => {};
561
+ const port = () => {
562
+ const p = {emit: noop as (v: any) => void, $: null as any};
563
+ p.$ = xs.create({
564
+ start: (l: any) => { p.emit = v => l.next(v); },
565
+ stop: () => { p.emit = noop; },
566
+ });
567
+ return p;
568
+ };
569
+ const actions = port();
570
+ const hub = port();
129
571
 
130
- // Object with sink entries — apply STATE reducer if present
131
- if (typeof entry === 'object') {
132
- const stateReducer = entry.STATE || entry[stateSourceName];
133
- if (typeof stateReducer === 'function') {
134
- const result = stateReducer(state, data);
135
- if (typeof result === 'symbol') return state;
136
- return result !== undefined ? result : state;
137
- }
138
- // EFFECT and other non-state sinks
139
- const effectReducer = entry.EFFECT;
140
- if (typeof effectReducer === 'function') {
141
- const next = (nextType: string, nextData?: any) => {
142
- setTimeout(
143
- () => testActionListener.next({type: nextType, data: nextData}),
144
- 10
145
- );
146
- };
147
- effectReducer(state, data, next, {});
148
- }
149
- }
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);
150
577
 
151
- return state;
152
- },
153
- },
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;
154
588
  };
155
589
 
156
- const stateSourceName = 'STATE';
157
-
590
+ const {context, calculated, storeCalculatedInState, onError, hmrActions, components} = componentDef;
591
+ const init = initialState !== undefined ? initialState : componentDef.initialState;
592
+ // G-028: with no intent, model or initialState nothing would ever emit state, so the view
593
+ // never renders. Leave intent/model unset so the component falls back to the same no-op
594
+ // model run() uses, and renders.
595
+ const bare = !intent && !Object.keys(model).length && init === undefined;
158
596
  const app = component({
159
- name,
160
- view,
161
- intent: wrappedIntent,
162
- model: wrappedModel,
597
+ name: componentDef.name || componentDef.componentName || 'TestComponent',
598
+ view: componentDef,
599
+ intent: bare ? undefined : wrappedIntent,
600
+ model: bare ? undefined : model,
601
+ hmrActions,
602
+ components,
163
603
  context,
164
- initialState: resolvedInitialState,
165
604
  calculated,
166
605
  storeCalculatedInState,
167
606
  onError,
607
+ initialState: init,
168
608
  });
169
-
170
- const wrapped = withState(app, stateSourceName);
171
- const mockDOM = () => mockDOMSource(mockConfig);
172
-
173
- const baseDrivers: Record<string, any> = {
174
- DOM: mockDOM,
609
+ 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);
620
+ }
621
+ }),
175
622
  EVENTS: eventBusDriver,
176
623
  LOG: logDriver,
177
624
  ...drivers,
178
625
  };
626
+ 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()});
631
+ }
632
+ }
633
+ }
634
+ let sources: any, sinks: any, rawDispose: () => void;
635
+ try {
636
+ const p: any = setup(withState(app, 'STATE') as any, allDrivers);
637
+ ({sources, sinks} = p);
638
+ rawDispose = p.run();
639
+ } catch (e) {
640
+ restore();
641
+ throw e;
642
+ }
179
643
 
180
- const {sources, sinks, run: _run} = setup(wrapped, baseDrivers as any);
181
- const rawDispose = _run();
644
+ const subs: Array<[any, any]> = [];
645
+ const listen = (s: any, next: (v: any) => void) => {
646
+ const l = {next, error: noop, complete: noop};
647
+ s.addListener(l);
648
+ subs.push([s, l]);
649
+ };
182
650
 
183
- // Collect state values
184
651
  const states: any[] = [];
185
- let stateListener: any = null;
186
- const stateStream: Stream<any> =
187
- sources.STATE && sources.STATE.stream ? sources.STATE.stream : xs.never();
188
-
189
- stateListener = {
190
- next: (s: any) => states.push(s),
191
- error: () => {},
192
- complete: () => {},
193
- };
194
- stateStream.addListener(stateListener);
652
+ const stateStream: Stream<any> = sources.STATE?.stream || xs.never();
653
+ listen(stateStream, s => { states.push(s); bump(); });
195
654
 
196
- // simulateAction: push into the test action stream
197
- const simulateAction = (actionName: string, data?: any) => {
198
- testActionListener.next({type: actionName, data});
655
+ const values: Record<string, any[]> = {};
656
+ const sinkValues = (k: string) => (values[k] = values[k] || []);
657
+ for (const k in sinks) {
658
+ 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
+ ));
662
+ }
663
+ }
664
+ // Input queue (G-049/G-039): simulateAction/simulateEvent calls are delivered in order,
665
+ // once the component is ready. An event whose selector matches no rendered element yet, or
666
+ // 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
668
+ // (or, with no matching element, fails the test, G-070; with allowMissing it is dropped
669
+ // with SYG103).
670
+ type Input = {go: (last: boolean) => boolean, until?: number, missing?: () => Error | undefined};
671
+ const inputs: Input[] = [];
672
+ let isReady = false, retryTimer: any;
673
+ const pump = () => {
674
+ if (!isReady || disposed) return;
675
+ while (inputs.length) {
676
+ const head = inputs[0];
677
+ head.until = head.until || Date.now() + WAIT_MS;
678
+ if (!head.go(Date.now() >= head.until)) return retry(5);
679
+ inputs.shift();
680
+ bump();
681
+ }
682
+ };
683
+ const retry = (ms: number) => {
684
+ if (!retryTimer && inputs.length) retryTimer = setTimeout(() => { retryTimer = 0; pump(); }, ms);
199
685
  };
686
+ let markReady: () => void;
687
+ const readyPromise = new Promise<void>(r => {
688
+ markReady = () => {
689
+ isReady = true;
690
+ pump();
691
+ r();
692
+ };
693
+ });
694
+ const later = (go: Input['go'], missing?: Input['missing']) => { inputs.push({go, missing}); pump(); };
200
695
 
201
- // waitForState: resolve when the predicate matches
202
- const waitForState = (
203
- predicate: (state: any) => boolean,
204
- timeoutMs: number = 2000
205
- ): Promise<any> => {
206
- return new Promise((resolve, reject) => {
207
- // Check already-collected states
208
- for (const s of states) {
209
- try {
210
- if (predicate(s)) return resolve(s);
211
- } catch (_) {}
696
+ let vtree: any;
697
+ let timer: any;
698
+ // states[0 .. renderedUpTo) were recorded before the latest render (1H-12)
699
+ let renderedUpTo = 0;
700
+ const arm = () => timer || (timer = setTimeout(() => markReady(), 12));
701
+ if (sinks.DOM) {
702
+ listen(sinks.DOM, v => {
703
+ vtree = v;
704
+ index(v);
705
+ renderedUpTo = states.length;
706
+ bump();
707
+ check104();
708
+ arm();
709
+ pump();
710
+ });
711
+ }
712
+ // 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);
715
+
716
+ const tick = (ms: number) => new Promise(r => setTimeout(r, ms));
717
+ /**
718
+ * G-047: resolves once the root has rendered states[0 .. n) and the whole tree has been
719
+ * quiet (no render, reducer, state or input anywhere) for `quiet` ms, checked twice, so
720
+ * child components have rendered them too. Gives up after `cap` ms (an app that never
721
+ * goes quiet): false.
722
+ */
723
+ const quiesce = async (n: number, quiet: number, cap: number, busy = () => false): Promise<boolean> => {
724
+ const start = Date.now();
725
+ let seen = -1;
726
+ 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()) {
730
+ if (seen === activity) return true;
731
+ seen = activity;
732
+ await tick(3);
733
+ } else {
734
+ seen = -1;
735
+ await tick(Math.max(1, quiet - idle));
212
736
  }
737
+ }
738
+ return true;
739
+ };
740
+ const treeRendered = (n: number) => quiesce(n, QUIET_MS, 250);
213
741
 
214
- const timer = setTimeout(() => {
215
- try {
216
- stateStream.removeListener(listener);
217
- } catch (_) {}
218
- reject(new Error(`waitForState timed out after ${timeoutMs}ms`));
219
- }, timeoutMs);
742
+ // G-070: a simulateEvent whose selector matches nothing fails the test. The error rejects
743
+ // the pending next()/waitForState()/settle() calls; with none pending it is kept and thrown
744
+ // by the next simulate*/wait/expectNoDiagnostics()/dispose() call.
745
+ let failure: Error | undefined;
746
+ const waiters = new Set<(e: Error) => void>();
747
+ const failWith = (e: Error) => {
748
+ if (!waiters.size) return void (failure = failure || e);
749
+ const ws = [...waiters];
750
+ waiters.clear();
751
+ ws.forEach(r => r(e));
752
+ };
753
+ const takeFailure = () => { const f = failure; failure = undefined; return f; };
754
+ const throwFailure = () => { const f = takeFailure(); if (f) throw f; };
755
+ const noMatch = (selector: string, type: string, waited: boolean) => {
756
+ const out = html();
757
+ 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)` : '') +
759
+ `. Check t.html() to see what rendered, or give the element an attribute and select it, e.g. [data-id="3"]` +
760
+ ` (pass { allowMissing: true } to drop the event instead).\nRendered: ${out.length > 600 ? out.slice(0, 600) + '…' : out || '(nothing)'}`);
761
+ };
220
762
 
221
- const listener = {
222
- next: (s: any) => {
223
- try {
224
- if (predicate(s)) {
225
- clearTimeout(timer);
226
- stateStream.removeListener(listener);
227
- resolve(s);
228
- }
229
- } catch (_) {}
230
- },
231
- error: (err: any) => {
232
- clearTimeout(timer);
233
- reject(err);
234
- },
235
- complete: () => {
236
- clearTimeout(timer);
237
- reject(new Error('waitForState: state stream completed without matching'));
238
- },
763
+ const simulateAction = (type: string, data?: any) => {
764
+ throwFailure();
765
+ later(() => (actions.emit({type, data}), true));
766
+ };
767
+
768
+ const simulateEvent = (selector: string, type: string, init: SimulatedEventInit = {}) => {
769
+ throwFailure();
770
+ const {allowMissing, ...evInit} = init;
771
+ const text = norm(String(selector));
772
+ // 'document' / 'body' (and '') name a listener, not an element
773
+ const page = !text || PAGE.test(text);
774
+ // unsupported syntax throws here, at the call (G-070)
775
+ const sel = page ? [] : parse(text);
776
+ // nothing pending and the tree is quiet (as settle() would see it): fail at the call
777
+ if (!page && !allowMissing && isReady && !inputs.length && vtree && renderedUpTo >= states.length &&
778
+ Date.now() - lastActivity >= SETTLE_MS && !find(vtree, sel)) {
779
+ throw noMatch(selector, type, false);
780
+ }
781
+ later(last => {
782
+ const chain = page ? undefined : find(vtree, sel);
783
+ const el = chain?.[chain.length - 1];
784
+ if (!page && !el) {
785
+ if (!last) return false;
786
+ if (!allowMissing) {
787
+ failWith(noMatch(selector, type, true));
788
+ return true;
789
+ }
790
+ raise('SYG103', rootName,
791
+ `simulateEvent('${selector}', '${type}') matched no rendered element within ${WAIT_MS}ms, so the event was dropped`,
792
+ `Check the selector against the view's className/id, or wait until the element is rendered (await t.next(...) or t.settle())`,
793
+ {selector, type});
794
+ return true;
795
+ }
796
+ const match = (path: string[]) => {
797
+ let ls = selText(path);
798
+ if (!chain) return ls == text;
799
+ let els = chain;
800
+ if (PAGE.test(ls)) ls = ls.replace(PAGE, '');
801
+ else {
802
+ const scope = path.filter(isScope).pop();
803
+ let cur: string | undefined;
804
+ els = chain.filter(v => {
805
+ const m = v.sel.match(/\.___[^.#]+/);
806
+ if (m) cur = m[0];
807
+ return cur == scope;
808
+ });
809
+ }
810
+ if (!ls) return true;
811
+ const lsel = tryParse(ls);
812
+ return !!lsel && els.some((_, k) => matches(lsel, els.slice(0, k + 1)));
239
813
  };
814
+ // G-039: wait until every listener this event would reach is subscribed
815
+ if (!last) {
816
+ for (const [k, path] of listeners) {
817
+ if ((evTypes[k] || []).includes(type) && !live.get(k + '\u0000' + type) && match(path)) return false;
818
+ }
819
+ }
820
+ if (el) check104(el);
821
+ const d = el?.data || {}, p = d.props || {};
822
+ const {target: t = {}, value, checked, dataset, data, ...rest} = evInit;
823
+ const vval = p.value ?? d.attrs?.value;
824
+ const target: any = {
825
+ tagName: el?.sel.split(/[.#]/)[0].toUpperCase(),
826
+ value: vval == null ? vval : String(vval),
827
+ checked: p.checked ?? d.attrs?.checked,
828
+ ...('value' in init && {value}),
829
+ ...('checked' in init && {checked}),
830
+ ...t,
831
+ };
832
+ target.dataset = str({...d.dataset, ...dataset, ...data, ...t.dataset});
833
+ // 3E/R2: element.closest(sel) over the rendered ancestor chain, so helpers that look
834
+ // up ancestors (e.g. `.data('taskId')` on a click inside the row carrying the data)
835
+ // behave like the real DOM. Ancestors are element-like: tagName, dataset, closest.
836
+ if (chain && !('closest' in t)) {
837
+ const like = (i: number): any => {
838
+ const v = chain[i];
839
+ return {tagName: v.sel.split(/[.#]/)[0].toUpperCase(), dataset: str(v.data?.dataset), closest: closestFrom(i)};
840
+ };
841
+ const closestFrom = (from: number) => (s: string): any => {
842
+ const cs = parse(String(s));
843
+ for (let i = from; i >= 0; i--) {
844
+ if (matches(cs, chain.slice(0, i + 1))) return i == chain.length - 1 ? target : like(i);
845
+ }
846
+ return null;
847
+ };
848
+ target.closest = closestFrom(chain.length - 1);
849
+ }
850
+ const event = {
851
+ type,
852
+ target,
853
+ currentTarget: target,
854
+ ownerTarget: target,
855
+ dataTransfer: {},
856
+ preventDefault: noop,
857
+ stopPropagation: noop,
858
+ ...rest,
859
+ };
860
+ hub.emit({type, event, match});
861
+ return true;
862
+ }, page || allowMissing ? undefined : () => (find(vtree, sel) ? undefined : noMatch(selector, type, false)));
863
+ };
240
864
 
865
+ // first state at index >= from matching predicate; resolves after the full tree rendered it
866
+ const waitMatch = (from: number, predicate: (state: any) => boolean, timeoutMs: number, name: string): Promise<any> =>
867
+ new Promise((resolve, reject) => {
868
+ const f = takeFailure();
869
+ if (f) return reject(f);
870
+ const found = (i: number) => {
871
+ clearTimeout(timer);
872
+ stateStream.removeListener(listener);
873
+ treeRendered(i + 1).then(() => { waiters.delete(fail); resolve(states[i]); });
874
+ };
875
+ 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); };
877
+ waiters.add(fail);
878
+ const base = states.length;
879
+ const listener = {
880
+ // the recording listener (added first) has already pushed the new state; a
881
+ // remembered state replayed on addListener isn't new (states.length == base)
882
+ next: () => { if (states.length > base && test(states.length - 1)) found(states.length - 1); },
883
+ error: (err: any) => fail(err),
884
+ complete: () => fail(new Error(`${name}: state stream completed without matching`)),
885
+ };
886
+ const timer = setTimeout(() => fail(new Error(`${name} timed out after ${timeoutMs}ms`)), timeoutMs);
887
+ for (let i = from; i < states.length; i++) if (test(i)) return found(i);
241
888
  stateStream.addListener(listener);
242
889
  });
243
- };
244
890
 
245
- const dispose = () => {
246
- if (stateListener) {
247
- try {
248
- stateStream.removeListener(stateListener);
249
- } catch (_) {}
250
- stateListener = null;
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) => {
896
+ const f = takeFailure();
897
+ if (f) return reject(f);
898
+ const done = (e?: Error) => { waiters.delete(done); e ? reject(e) : resolve(); };
899
+ waiters.add(done);
900
+ (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'}`);
904
+ }
905
+ })().then(() => done(), done);
906
+ });
907
+
908
+ const expectNoDiagnostics = () => {
909
+ throwFailure();
910
+ const bad = collected.filter(d => d.severity != 'info');
911
+ if (bad.length) {
912
+ throw new Error(`Expected no diagnostics, got ${bad.length}:\n` + bad.map(d => d.text).join('\n'));
251
913
  }
252
- // Trigger the component's dispose() which fires the DISPOSE action and dispose$ stream
253
- if (typeof (sinks as any).__dispose === 'function') {
254
- try { (sinks as any).__dispose(); } catch (_) {}
914
+ };
915
+
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}};
255
924
  }
925
+ return {...v, data: d, children: v.children && v.children.map(unmark)};
926
+ };
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
+ : '';
933
+
934
+ const inspect = (): InspectGraph => {
935
+ 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}});
937
+ };
938
+
939
+ const dispose = () => {
940
+ if (disposed) return;
941
+ disposed = true;
942
+ clearTimeout(timer);
943
+ clearTimeout(fallback);
944
+ clearTimeout(retryTimer);
945
+ // G-070: an event still waiting for an element that never rendered fails the test
946
+ const head = inputs[0];
947
+ if (head && head.until && head.missing && !failure) failure = head.missing();
948
+ inputs.length = 0;
949
+ subs.forEach(([s, l]) => {
950
+ try { s.removeListener(l); } catch (_) {}
951
+ });
952
+ try { sinks.__dispose?.(); } catch (_) {}
256
953
  rawDispose();
954
+ restore();
955
+ waiters.clear();
956
+ throwFailure();
257
957
  };
258
958
 
259
959
  return {
@@ -263,8 +963,18 @@ export function renderComponent(
263
963
  sinks,
264
964
  sources,
265
965
  simulateAction,
966
+ simulateEvent,
967
+ ready: () => readyPromise,
266
968
  waitForState,
969
+ next,
970
+ settle,
267
971
  states,
972
+ sinkValues,
973
+ emitted: sinkValues('EVENTS'),
974
+ diagnostics: collected,
975
+ expectNoDiagnostics,
976
+ html,
268
977
  dispose,
978
+ inspect,
269
979
  };
270
980
  }