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