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.
- package/README.md +53 -38
- package/dist/astro/client.cjs.js +18 -8106
- package/dist/astro/client.cjs.js.map +1 -0
- package/dist/astro/client.mjs +17 -8105
- package/dist/astro/client.mjs.map +1 -0
- package/dist/astro/index.cjs.js +768 -9
- package/dist/astro/index.cjs.js.map +1 -0
- package/dist/astro/index.mjs +768 -9
- package/dist/astro/index.mjs.map +1 -0
- package/dist/astro/server.cjs.js +4 -1
- package/dist/astro/server.cjs.js.map +1 -0
- package/dist/astro/server.mjs +4 -1
- package/dist/astro/server.mjs.map +1 -0
- package/dist/diagnostics.cjs.js +1297 -0
- package/dist/diagnostics.cjs.js.map +1 -0
- package/dist/diagnostics.esm.js +1267 -0
- package/dist/diagnostics.esm.js.map +1 -0
- package/dist/index.cjs.js +2981 -1386
- package/dist/index.cjs.js.map +1 -0
- package/dist/index.d.ts +848 -111
- package/dist/index.esm.js +2997 -1411
- package/dist/index.esm.js.map +1 -0
- package/dist/jsx-dev-runtime.cjs.js +15 -9
- package/dist/jsx-dev-runtime.cjs.js.map +1 -0
- package/dist/jsx-dev-runtime.esm.js +15 -9
- package/dist/jsx-dev-runtime.esm.js.map +1 -0
- package/dist/jsx-runtime.cjs.js +15 -9
- package/dist/jsx-runtime.cjs.js.map +1 -0
- package/dist/jsx-runtime.esm.js +15 -9
- package/dist/jsx-runtime.esm.js.map +1 -0
- package/dist/jsx.cjs.js +15 -9
- package/dist/jsx.cjs.js.map +1 -0
- package/dist/jsx.esm.js +15 -9
- package/dist/jsx.esm.js.map +1 -0
- package/dist/sygnal.min.js +2 -1
- package/dist/sygnal.min.js.map +1 -0
- package/dist/vike/+config.cjs.js +1 -0
- package/dist/vike/+config.cjs.js.map +1 -0
- package/dist/vike/+config.js +1 -0
- package/dist/vike/+config.js.map +1 -0
- package/dist/vike/ClientOnly.cjs.js +1 -0
- package/dist/vike/ClientOnly.cjs.js.map +1 -0
- package/dist/vike/ClientOnly.mjs +1 -0
- package/dist/vike/ClientOnly.mjs.map +1 -0
- package/dist/vike/onRenderClient.cjs.js +1 -0
- package/dist/vike/onRenderClient.cjs.js.map +1 -0
- package/dist/vike/onRenderClient.mjs +1 -0
- package/dist/vike/onRenderClient.mjs.map +1 -0
- package/dist/vike/onRenderHtml.cjs.js +1 -0
- package/dist/vike/onRenderHtml.cjs.js.map +1 -0
- package/dist/vike/onRenderHtml.mjs +1 -0
- package/dist/vike/onRenderHtml.mjs.map +1 -0
- package/dist/vite/plugin.cjs.js +670 -44
- package/dist/vite/plugin.cjs.js.map +1 -0
- package/dist/vite/plugin.mjs +670 -44
- package/dist/vite/plugin.mjs.map +1 -0
- package/llms.txt +250 -0
- package/package.json +15 -6
- package/src/astro/client.ts +13 -2
- package/src/astro/index.d.ts +24 -1
- package/src/astro/index.ts +40 -9
- package/src/astro/server.ts +3 -1
- package/src/collection.ts +5 -2
- package/src/component.ts +375 -330
- package/src/cycle/dom/MainDOMSource.ts +4 -0
- package/src/cycle/dom/classNameModule.ts +74 -0
- package/src/cycle/dom/controlledInputModule.ts +33 -0
- package/src/cycle/dom/enrichEventStream.ts +9 -3
- package/src/cycle/dom/makeDOMDriver.ts +1 -2
- package/src/cycle/dom/mockDOMSource.ts +62 -8
- package/src/cycle/dom/modules.ts +5 -1
- package/src/cycle/state/Collection.ts +13 -3
- package/src/cycle/state/StateSource.ts +1 -1
- package/src/cycle/state/pickCombine.ts +35 -1
- package/src/cycle/state/withState.ts +1 -1
- package/src/extra/devtools.ts +8 -0
- package/src/extra/diagnostics/checks/collections.ts +67 -0
- package/src/extra/diagnostics/checks/dom.ts +241 -0
- package/src/extra/diagnostics/checks/events.ts +68 -0
- package/src/extra/diagnostics/checks/index.ts +109 -0
- package/src/extra/diagnostics/checks/inspect.ts +319 -0
- package/src/extra/diagnostics/checks/props.ts +51 -0
- package/src/extra/diagnostics/checks/public.d.ts +211 -0
- package/src/extra/diagnostics/checks/rxjsHints.ts +88 -0
- package/src/extra/diagnostics/checks/shared.ts +119 -0
- package/src/extra/diagnostics/checks/state.ts +55 -0
- package/src/extra/diagnostics/checks/strict.ts +112 -0
- package/src/extra/diagnostics/checks/wiring.ts +65 -0
- package/src/extra/diagnostics/codes.ts +232 -0
- package/src/extra/diagnostics/index.ts +328 -0
- package/src/extra/diagnostics/legacy.ts +59 -0
- package/src/extra/driverFactories.ts +106 -58
- package/src/extra/eventDriver.ts +4 -0
- package/src/extra/flatten.ts +75 -0
- package/src/extra/reducers.ts +12 -5
- package/src/extra/run.ts +11 -0
- package/src/extra/testing.ts +890 -180
- package/src/extra/xstreamExtras.ts +269 -0
- package/src/index.d.ts +428 -17
- package/src/index.ts +7 -6
- package/src/pragma/index.ts +14 -9
- package/src/switchable.ts +10 -8
- package/src/vike/types.ts +21 -4
- package/src/vite/plugin.d.ts +95 -2
- package/src/vite/plugin.ts +720 -54
- package/dist/astro/astro/client.d.ts +0 -5
- package/dist/astro/astro/index.d.ts +0 -16
- package/dist/astro/astro/server.d.ts +0 -12
- package/dist/astro/client.d.ts +0 -5
- package/dist/astro/client.esm.js +0 -4665
- package/dist/astro/collection.d.ts +0 -12
- package/dist/astro/component.d.ts +0 -22
- package/dist/astro/cycle/dom/BodyDOMSource.d.ts +0 -10
- package/dist/astro/cycle/dom/DOMSource.d.ts +0 -11
- package/dist/astro/cycle/dom/DocumentDOMSource.d.ts +0 -12
- package/dist/astro/cycle/dom/ElementFinder.d.ts +0 -8
- package/dist/astro/cycle/dom/EventDelegator.d.ts +0 -34
- package/dist/astro/cycle/dom/IsolateModule.d.ts +0 -23
- package/dist/astro/cycle/dom/MainDOMSource.d.ts +0 -32
- package/dist/astro/cycle/dom/PriorityQueue.d.ts +0 -7
- package/dist/astro/cycle/dom/ScopeChecker.d.ts +0 -9
- package/dist/astro/cycle/dom/SymbolTree.d.ts +0 -9
- package/dist/astro/cycle/dom/VNodeWrapper.d.ts +0 -8
- package/dist/astro/cycle/dom/enrichEventStream.d.ts +0 -24
- package/dist/astro/cycle/dom/fromEvent.d.ts +0 -6
- package/dist/astro/cycle/dom/index.d.ts +0 -8
- package/dist/astro/cycle/dom/isolate.d.ts +0 -9
- package/dist/astro/cycle/dom/makeDOMDriver.d.ts +0 -11
- package/dist/astro/cycle/dom/mockDOMSource.d.ts +0 -17
- package/dist/astro/cycle/dom/modules.d.ts +0 -5
- package/dist/astro/cycle/dom/snabbdom.d.ts +0 -22
- package/dist/astro/cycle/dom/styleModule.d.ts +0 -13
- package/dist/astro/cycle/dom/thunk.d.ts +0 -11
- package/dist/astro/cycle/dom/utils.d.ts +0 -8
- package/dist/astro/cycle/isolate/index.d.ts +0 -42
- package/dist/astro/cycle/run/adapt.d.ts +0 -6
- package/dist/astro/cycle/run/index.d.ts +0 -36
- package/dist/astro/cycle/run/internals.d.ts +0 -8
- package/dist/astro/cycle/run/types.d.ts +0 -49
- package/dist/astro/cycle/state/Collection.d.ts +0 -29
- package/dist/astro/cycle/state/StateSource.d.ts +0 -20
- package/dist/astro/cycle/state/index.d.ts +0 -5
- package/dist/astro/cycle/state/pickCombine.d.ts +0 -3
- package/dist/astro/cycle/state/pickMerge.d.ts +0 -3
- package/dist/astro/cycle/state/types.d.ts +0 -18
- package/dist/astro/cycle/state/withState.d.ts +0 -17
- package/dist/astro/extra/classes.d.ts +0 -7
- package/dist/astro/extra/devtools.d.ts +0 -78
- package/dist/astro/extra/dragDriver.d.ts +0 -20
- package/dist/astro/extra/driverFactories.d.ts +0 -12
- package/dist/astro/extra/eventDriver.d.ts +0 -9
- package/dist/astro/extra/exactState.d.ts +0 -1
- package/dist/astro/extra/hmr.d.ts +0 -17
- package/dist/astro/extra/logDriver.d.ts +0 -2
- package/dist/astro/extra/processDrag.d.ts +0 -19
- package/dist/astro/extra/processForm.d.ts +0 -15
- package/dist/astro/extra/run.d.ts +0 -13
- package/dist/astro/extra/xstreamCompat.d.ts +0 -5
- package/dist/astro/index.d.ts +0 -19
- package/dist/astro/index.esm.js +0 -27
- package/dist/astro/jsx-dev-runtime.d.ts +0 -1
- package/dist/astro/jsx-runtime.d.ts +0 -4
- package/dist/astro/jsx.d.ts +0 -2
- package/dist/astro/pragma/fn.d.ts +0 -7
- package/dist/astro/pragma/index.d.ts +0 -7
- package/dist/astro/pragma/is.d.ts +0 -10
- package/dist/astro/server.d.ts +0 -12
- package/dist/astro/server.esm.js +0 -25
- package/dist/astro/switchable.d.ts +0 -7
- package/dist/collection.d.ts +0 -12
- package/dist/component.d.ts +0 -22
- package/dist/cycle/dom/BodyDOMSource.d.ts +0 -10
- package/dist/cycle/dom/DOMSource.d.ts +0 -11
- package/dist/cycle/dom/DocumentDOMSource.d.ts +0 -12
- package/dist/cycle/dom/ElementFinder.d.ts +0 -8
- package/dist/cycle/dom/EventDelegator.d.ts +0 -34
- package/dist/cycle/dom/IsolateModule.d.ts +0 -23
- package/dist/cycle/dom/MainDOMSource.d.ts +0 -32
- package/dist/cycle/dom/PriorityQueue.d.ts +0 -7
- package/dist/cycle/dom/ScopeChecker.d.ts +0 -9
- package/dist/cycle/dom/SymbolTree.d.ts +0 -9
- package/dist/cycle/dom/VNodeWrapper.d.ts +0 -8
- package/dist/cycle/dom/enrichEventStream.d.ts +0 -24
- package/dist/cycle/dom/fromEvent.d.ts +0 -6
- package/dist/cycle/dom/index.d.ts +0 -8
- package/dist/cycle/dom/isolate.d.ts +0 -9
- package/dist/cycle/dom/makeDOMDriver.d.ts +0 -11
- package/dist/cycle/dom/mockDOMSource.d.ts +0 -17
- package/dist/cycle/dom/modules.d.ts +0 -5
- package/dist/cycle/dom/snabbdom.d.ts +0 -22
- package/dist/cycle/dom/styleModule.d.ts +0 -13
- package/dist/cycle/dom/thunk.d.ts +0 -11
- package/dist/cycle/dom/utils.d.ts +0 -8
- package/dist/cycle/isolate/index.d.ts +0 -42
- package/dist/cycle/run/adapt.d.ts +0 -6
- package/dist/cycle/run/index.d.ts +0 -36
- package/dist/cycle/run/internals.d.ts +0 -8
- package/dist/cycle/run/types.d.ts +0 -49
- package/dist/cycle/state/Collection.d.ts +0 -29
- package/dist/cycle/state/StateSource.d.ts +0 -20
- package/dist/cycle/state/index.d.ts +0 -5
- package/dist/cycle/state/pickCombine.d.ts +0 -3
- package/dist/cycle/state/pickMerge.d.ts +0 -3
- package/dist/cycle/state/types.d.ts +0 -18
- package/dist/cycle/state/withState.d.ts +0 -17
- package/dist/extra/classes.d.ts +0 -7
- package/dist/extra/devtools.d.ts +0 -78
- package/dist/extra/dragDriver.d.ts +0 -20
- package/dist/extra/driverFactories.d.ts +0 -12
- package/dist/extra/eventDriver.d.ts +0 -9
- package/dist/extra/exactState.d.ts +0 -1
- package/dist/extra/hmr.d.ts +0 -17
- package/dist/extra/logDriver.d.ts +0 -2
- package/dist/extra/processDrag.d.ts +0 -19
- package/dist/extra/processForm.d.ts +0 -15
- package/dist/extra/run.d.ts +0 -13
- package/dist/extra/xstreamCompat.d.ts +0 -5
- package/dist/jsx-dev-runtime.d.ts +0 -1
- package/dist/jsx-dev-runtime.js +0 -224
- package/dist/jsx-runtime.d.ts +0 -4
- package/dist/jsx-runtime.js +0 -224
- package/dist/jsx.d.ts +0 -2
- package/dist/pragma/fn.d.ts +0 -7
- package/dist/pragma/index.d.ts +0 -7
- package/dist/pragma/is.d.ts +0 -10
- package/dist/switchable.d.ts +0 -7
package/src/extra/testing.ts
CHANGED
|
@@ -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/
|
|
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
|
|
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
|
|
158
|
+
/** Push an action into the intent→model pipeline (all sinks of the entry run) */
|
|
30
159
|
simulateAction: (actionName: string, data?: any) => void;
|
|
31
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
551
|
+
const restore = () => {
|
|
552
|
+
offCheck();
|
|
553
|
+
offDiag();
|
|
554
|
+
if (!--active) {
|
|
555
|
+
configureDiagnostics(savedConfig);
|
|
556
|
+
core.strict = savedStrict;
|
|
557
|
+
}
|
|
558
|
+
};
|
|
121
559
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
181
|
-
const
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
197
|
-
const
|
|
198
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
)
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
}
|