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