sygnal 5.4.0 → 6.1.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 +716 -0
- package/README.md +24 -9
- package/dist/ai.cjs.js +115 -0
- package/dist/ai.cjs.js.map +1 -0
- package/dist/ai.esm.js +2 -0
- package/dist/ai.esm.js.map +1 -0
- package/dist/astro/client.cjs.js +16 -9
- package/dist/astro/client.cjs.js.map +1 -1
- package/dist/astro/client.mjs +16 -9
- package/dist/astro/client.mjs.map +1 -1
- package/dist/astro/index.cjs.js +958 -92
- package/dist/astro/index.cjs.js.map +1 -1
- package/dist/astro/index.mjs +958 -93
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/server.cjs.js +3396 -165
- package/dist/astro/server.cjs.js.map +1 -1
- package/dist/astro/server.mjs +3396 -165
- package/dist/astro/server.mjs.map +1 -1
- package/dist/devtools.cjs.js +1840 -0
- package/dist/devtools.cjs.js.map +1 -0
- package/dist/devtools.esm.js +1827 -0
- package/dist/devtools.esm.js.map +1 -0
- package/dist/diagnostics.cjs.js +3190 -296
- package/dist/diagnostics.cjs.js.map +1 -1
- package/dist/diagnostics.esm.js +3190 -296
- package/dist/diagnostics.esm.js.map +1 -1
- package/dist/element.cjs.js +230 -0
- package/dist/element.cjs.js.map +1 -0
- package/dist/element.esm.js +228 -0
- package/dist/element.esm.js.map +1 -0
- package/dist/guide/accessibility.md +310 -0
- package/dist/guide/adapters.md +221 -0
- package/dist/guide/agent.md +588 -0
- package/dist/guide/ai-chat.md +693 -0
- package/dist/guide/ai-decisions.md +330 -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 +409 -0
- package/dist/guide/forms.md +251 -0
- package/dist/guide/http.md +301 -0
- package/dist/guide/inputs.md +173 -0
- package/dist/guide/mcp-apps.md +272 -0
- package/dist/guide/persistence.md +245 -0
- package/dist/guide/recipes/ai-form-fill.md +163 -0
- package/dist/guide/recipes/ai-summarize.md +155 -0
- package/dist/guide/recipes/ai-support-inbox.md +190 -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 +43 -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/webmcp.md +226 -0
- package/dist/guide/widgets.md +217 -0
- package/dist/index.cjs.js +19288 -7408
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +4466 -552
- package/dist/index.esm.js +19274 -7461
- package/dist/index.esm.js.map +1 -1
- package/dist/jsx-dev-runtime.cjs.js +26 -334
- package/dist/jsx-dev-runtime.cjs.js.map +1 -1
- package/dist/jsx-dev-runtime.esm.js +19 -327
- package/dist/jsx-dev-runtime.esm.js.map +1 -1
- package/dist/jsx-runtime.cjs.js +26 -334
- package/dist/jsx-runtime.cjs.js.map +1 -1
- package/dist/jsx-runtime.esm.js +19 -327
- package/dist/jsx-runtime.esm.js.map +1 -1
- package/dist/jsx.cjs.js +10 -314
- package/dist/jsx.cjs.js.map +1 -1
- package/dist/jsx.esm.js +8 -315
- package/dist/jsx.esm.js.map +1 -1
- package/dist/react.cjs.js +117 -0
- package/dist/react.cjs.js.map +1 -0
- package/dist/react.esm.js +115 -0
- package/dist/react.esm.js.map +1 -0
- package/dist/shims/globalthis.cjs +20 -0
- package/dist/sygnal.min.js +1 -1
- package/dist/sygnal.min.js.map +1 -1
- package/dist/ui-combobox.cjs.js +169 -0
- package/dist/ui-combobox.cjs.js.map +1 -0
- package/dist/ui-combobox.esm.js +148 -0
- package/dist/ui-combobox.esm.js.map +1 -0
- package/dist/ui-menu.cjs.js +87 -0
- package/dist/ui-menu.cjs.js.map +1 -0
- package/dist/ui-menu.esm.js +66 -0
- package/dist/ui-menu.esm.js.map +1 -0
- package/dist/ui-select.cjs.js +104 -0
- package/dist/ui-select.cjs.js.map +1 -0
- package/dist/ui-select.esm.js +83 -0
- package/dist/ui-select.esm.js.map +1 -0
- package/dist/ui.cjs.js +738 -0
- package/dist/ui.cjs.js.map +1 -0
- package/dist/ui.esm.js +727 -0
- package/dist/ui.esm.js.map +1 -0
- package/dist/vike/ClientOnly.cjs.js +4 -14
- package/dist/vike/ClientOnly.cjs.js.map +1 -1
- package/dist/vike/ClientOnly.mjs +4 -14
- package/dist/vike/ClientOnly.mjs.map +1 -1
- package/dist/vike/{+config.js → config/+config.js} +9 -1
- package/dist/vike/config/+config.js.map +1 -0
- package/dist/vike/config/package.json +6 -0
- package/dist/vike/onRenderClient.cjs.js +165 -95
- package/dist/vike/onRenderClient.cjs.js.map +1 -1
- package/dist/vike/onRenderClient.mjs +165 -95
- package/dist/vike/onRenderClient.mjs.map +1 -1
- package/dist/vike/onRenderHtml.cjs.js +59 -24
- package/dist/vike/onRenderHtml.cjs.js.map +1 -1
- package/dist/vike/onRenderHtml.mjs +60 -25
- package/dist/vike/onRenderHtml.mjs.map +1 -1
- package/dist/vite/plugin.cjs.js +919 -89
- package/dist/vite/plugin.cjs.js.map +1 -1
- package/dist/vite/plugin.mjs +919 -90
- package/dist/vite/plugin.mjs.map +1 -1
- package/dist/zag.cjs.js +408 -0
- package/dist/zag.cjs.js.map +1 -0
- package/dist/zag.esm.js +405 -0
- package/dist/zag.esm.js.map +1 -0
- package/llms.txt +172 -103
- package/package.json +112 -14
- package/src/ai.d.ts +1186 -0
- package/src/ai.ts +23 -0
- package/src/astro/client.ts +27 -16
- package/src/astro/index.d.ts +24 -0
- package/src/astro/index.ts +69 -3
- package/src/astro/server.ts +8 -2
- package/src/collection.ts +6 -93
- package/src/core/actions.ts +134 -0
- package/src/core/cell.ts +202 -0
- package/src/core/debug.ts +23 -0
- package/src/core/define.ts +242 -0
- package/src/core/hooks.ts +314 -0
- package/src/core/hosts/collection.ts +325 -0
- package/src/core/hosts/switchable.ts +146 -0
- package/src/core/instance.ts +592 -0
- package/src/core/markers/clientonly.ts +13 -0
- package/src/core/markers/lazy.ts +40 -0
- package/src/core/markers/portal.ts +96 -0
- package/src/core/markers/suspense.ts +41 -0
- package/src/core/markers/transition.ts +70 -0
- package/src/core/registry.ts +25 -0
- package/src/core/runtime.ts +573 -0
- package/src/core/statics.ts +104 -0
- package/src/core/teardown.ts +60 -0
- package/src/core/view.ts +37 -0
- package/src/cycle/dom/DocumentDOMSource.ts +13 -8
- package/src/cycle/dom/ElementFinder.ts +7 -9
- package/src/cycle/dom/EventDelegator.ts +174 -223
- package/src/cycle/dom/IsolateModule.ts +65 -35
- package/src/cycle/dom/MainDOMSource.ts +9 -2
- package/src/cycle/dom/PriorityQueue.ts +4 -2
- package/src/cycle/dom/SymbolTree.ts +18 -47
- package/src/cycle/dom/classNameModule.ts +9 -7
- package/src/cycle/dom/controlledInputModule.ts +23 -6
- package/src/cycle/dom/enrichEventStream.ts +25 -47
- package/src/cycle/dom/fragment.ts +7 -0
- package/src/cycle/dom/isolate.ts +30 -27
- package/src/cycle/dom/makeDOMDriver.ts +115 -56
- package/src/cycle/dom/mockDOMSource.ts +21 -1
- package/src/cycle/dom/modules.ts +16 -3
- package/src/cycle/dom/propsModule.ts +65 -0
- package/src/cycle/dom/selectModule.ts +22 -17
- package/src/cycle/dom/snabbdom.ts +1 -6
- package/src/cycle/dom/thunk.ts +3 -0
- package/src/cycle/dom/utils.ts +98 -0
- package/src/cycle/dom/viewTransition.ts +43 -0
- package/src/cycle/state/StateSource.ts +19 -3
- package/src/cycle/state/objIsEqual.ts +50 -0
- package/src/cycle/state/types.ts +0 -10
- package/src/defineComponent.ts +34 -0
- package/src/devtools.d.ts +170 -0
- package/src/devtools.ts +37 -0
- package/src/element.d.ts +59 -0
- package/src/element.ts +235 -0
- package/src/extra/ai/agent/index.ts +484 -0
- package/src/extra/ai/answers.ts +115 -0
- package/src/extra/ai/chat/behavior.ts +336 -0
- package/src/extra/ai/chat/driver.ts +271 -0
- package/src/extra/ai/chat/memoryTransport.ts +61 -0
- package/src/extra/ai/chat/output.ts +37 -0
- package/src/extra/ai/commandBar.ts +281 -0
- package/src/extra/ai/decide.ts +104 -0
- package/src/extra/ai/index.ts +42 -0
- package/src/extra/ai/link.ts +90 -0
- package/src/extra/ai/mcpApp.ts +314 -0
- package/src/extra/ai/messages.ts +51 -0
- package/src/extra/ai/schema/index.ts +166 -0
- package/src/extra/ai/schema/jsonSchema.ts +72 -0
- package/src/extra/ai/schema/strict.ts +152 -0
- package/src/extra/ai/transports/agui.ts +177 -0
- package/src/extra/ai/transports/anthropicMessages.ts +175 -0
- package/src/extra/ai/transports/chatCompletions.ts +105 -0
- package/src/extra/ai/transports/chromePrompt.ts +77 -0
- package/src/extra/ai/transports/encodeOpenResponses.ts +87 -0
- package/src/extra/ai/transports/fromAISDK.ts +126 -0
- package/src/extra/ai/transports/openResponses.ts +105 -0
- package/src/extra/ai/transports/shared.ts +125 -0
- package/src/extra/ai/transports/tools.ts +59 -0
- package/src/extra/ai/transports/uiMessageStream.ts +107 -0
- package/src/extra/ai/webmcp.ts +260 -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/devMcp.ts +386 -0
- package/src/extra/devtools.ts +101 -31
- package/src/extra/devtoolsActions.ts +379 -0
- package/src/extra/devtoolsHook.ts +9 -0
- package/src/extra/devtoolsNext.ts +83 -0
- package/src/extra/diagnostics/checks/actionLog.ts +108 -0
- package/src/extra/diagnostics/checks/behaviors.ts +49 -0
- package/src/extra/diagnostics/checks/browserSources.ts +96 -0
- package/src/extra/diagnostics/checks/chat.ts +71 -0
- package/src/extra/diagnostics/checks/collections.ts +1 -1
- package/src/extra/diagnostics/checks/controls.ts +148 -0
- package/src/extra/diagnostics/checks/dataset.ts +57 -0
- package/src/extra/diagnostics/checks/dom.ts +20 -11
- package/src/extra/diagnostics/checks/elementCommands.ts +175 -0
- package/src/extra/diagnostics/checks/events.ts +17 -2
- package/src/extra/diagnostics/checks/fetch.ts +135 -0
- package/src/extra/diagnostics/checks/forms.ts +171 -0
- package/src/extra/diagnostics/checks/index.ts +103 -4
- package/src/extra/diagnostics/checks/inspect.ts +140 -32
- package/src/extra/diagnostics/checks/next.ts +417 -0
- package/src/extra/diagnostics/checks/persist.ts +54 -0
- package/src/extra/diagnostics/checks/props.ts +10 -5
- package/src/extra/diagnostics/checks/public.d.ts +115 -7
- package/src/extra/diagnostics/checks/replies.ts +128 -0
- package/src/extra/diagnostics/checks/router.ts +100 -0
- package/src/extra/diagnostics/checks/rxjsHints.ts +1 -1
- package/src/extra/diagnostics/checks/shared.ts +86 -2
- package/src/extra/diagnostics/checks/shorthand.ts +115 -0
- package/src/extra/diagnostics/checks/sortable.ts +129 -0
- package/src/extra/diagnostics/checks/state.ts +100 -7
- package/src/extra/diagnostics/checks/statics.ts +48 -0
- package/src/extra/diagnostics/checks/strict.ts +10 -67
- package/src/extra/diagnostics/checks/timers.ts +80 -0
- package/src/extra/diagnostics/checks/viewTransitions.ts +47 -0
- package/src/extra/diagnostics/checks/virtual.ts +73 -0
- package/src/extra/diagnostics/checks/widgets.ts +109 -0
- package/src/extra/diagnostics/checks/wiring.ts +69 -10
- package/src/extra/diagnostics/codes.ts +335 -23
- package/src/extra/diagnostics/index.ts +34 -9
- package/src/extra/diagnostics/legacy.ts +17 -0
- package/src/extra/driverFactories.ts +83 -19
- package/src/extra/elementCommands.ts +53 -0
- package/src/extra/fetchDriver.ts +519 -0
- package/src/extra/focusWithin.ts +27 -0
- package/src/extra/form.ts +284 -0
- package/src/extra/formHelpers.ts +139 -0
- package/src/extra/formTool.ts +150 -0
- package/src/extra/head.ts +106 -0
- package/src/extra/hmr.ts +2 -3
- package/src/extra/owned.ts +24 -0
- package/src/extra/pager.ts +50 -0
- package/src/extra/persist.ts +133 -0
- package/src/extra/pwa.ts +1 -1
- package/src/extra/queryCache.ts +107 -0
- package/src/extra/reducers.ts +1 -1
- package/src/extra/reduxDevtools.ts +92 -0
- package/src/extra/replies.ts +54 -0
- package/src/extra/router.ts +323 -0
- package/src/extra/run.ts +90 -217
- package/src/extra/selection.ts +76 -0
- package/src/extra/socketDriver.ts +265 -0
- package/src/extra/sortable.ts +377 -0
- package/src/extra/ssr.ts +427 -160
- package/src/extra/standardSchema.ts +27 -0
- package/src/extra/testing.ts +2550 -175
- package/src/extra/timers.ts +95 -0
- package/src/extra/undo.ts +261 -0
- package/src/extra/viewTransitions.ts +38 -0
- package/src/extra/virtual.ts +513 -0
- package/src/extra/widget.ts +201 -0
- package/src/index.d.ts +2754 -127
- package/src/index.ts +45 -4
- package/src/jsx-runtime.ts +15 -1
- package/src/jsx.ts +2 -1
- package/src/lazy.ts +50 -7
- package/src/portal.ts +3 -1
- package/src/pragma/index.ts +256 -133
- package/src/react-peers.d.ts +5 -0
- package/src/react.d.ts +43 -0
- package/src/react.ts +110 -0
- package/src/shared.ts +57 -0
- package/src/slot.ts +1 -1
- package/src/suspense.ts +3 -1
- package/src/switchable.ts +6 -121
- package/src/transition.ts +3 -1
- package/src/ui/accordion.ts +64 -0
- package/src/ui/dialog.ts +129 -0
- package/src/ui/disclosure.ts +35 -0
- package/src/ui/popover.ts +45 -0
- package/src/ui/shared.ts +94 -0
- package/src/ui/tabs.ts +82 -0
- package/src/ui/toaster.ts +198 -0
- package/src/ui/tooltip.ts +70 -0
- package/src/ui/zag/combobox.ts +115 -0
- package/src/ui/zag/menu.ts +40 -0
- package/src/ui/zag/select.ts +54 -0
- package/src/ui/zag/shared.ts +43 -0
- package/src/ui-combobox.d.ts +20 -0
- package/src/ui-combobox.ts +6 -0
- package/src/ui-menu.d.ts +30 -0
- package/src/ui-menu.ts +6 -0
- package/src/ui-select.d.ts +17 -0
- package/src/ui-select.ts +6 -0
- package/src/ui-zag-types.d.ts +42 -0
- package/src/ui.d.ts +208 -0
- package/src/ui.ts +15 -0
- package/src/vike/+config.ts +9 -1
- package/src/vike/ClientOnly.ts +1 -1
- package/src/vike/onRenderClient.ts +144 -96
- package/src/vike/onRenderHtml.ts +69 -26
- package/src/vike/types.ts +7 -1
- package/src/vite/globalthis-shim.ts +18 -0
- package/src/vite/globalthis.ts +56 -0
- package/src/vite/mcp.ts +568 -0
- package/src/vite/plugin.d.ts +47 -3
- package/src/vite/plugin.ts +262 -25
- package/src/zag.d.ts +64 -0
- package/src/zag.ts +238 -0
- package/dist/vike/+config.cjs.js +0 -66
- package/dist/vike/+config.cjs.js.map +0 -1
- package/dist/vike/+config.js.map +0 -1
- package/src/component.ts +0 -2301
- package/src/cycle/isolate/index.ts +0 -196
- package/src/cycle/run/index.ts +0 -151
- package/src/cycle/run/internals.ts +0 -143
- package/src/cycle/state/Collection.ts +0 -184
- package/src/cycle/state/index.ts +0 -5
- package/src/cycle/state/pickCombine.ts +0 -201
- package/src/cycle/state/pickMerge.ts +0 -122
- package/src/cycle/state/withState.ts +0 -47
- package/src/pragma/fn.ts +0 -55
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PLAN-4 3-E (GS-10): "Copy as test". Turns a recorded session (./devtoolsActions getSession())
|
|
3
|
+
* into a Vitest + renderComponent test that replays it:
|
|
4
|
+
*
|
|
5
|
+
* renderComponent(C, { initialState?, drivers? }) the session's initial state (left out when
|
|
6
|
+
* it is the component's own initialState)
|
|
7
|
+
* await t.ready()
|
|
8
|
+
* t.simulateAction(type, data) each action caused by 'intent' | 'simulateAction' | 'behavior' | 'agent'
|
|
9
|
+
* await t.respond(sink, body, OK) a reply action from a makeFetchDriver source (renderComponent's
|
|
10
|
+
* await t.fail(sink, status, ...) HTTP fake answers the request the replayed actions send again)
|
|
11
|
+
* await t.settle()
|
|
12
|
+
* expect(t.state).toEqual(final)
|
|
13
|
+
*
|
|
14
|
+
* 'next' and 'built-in' actions are consequences (the replay produces them again), never replayed;
|
|
15
|
+
* nor is an intent action whose data is the component's own state (an intent over STATE.stream).
|
|
16
|
+
* A t.respond / t.fail that follows simulateAction calls waits for the request they send (G-218).
|
|
17
|
+
* Data is written as JS literals: JSON values, Date, Map, Set, NaN / Infinity / -0, bigint. A DOM
|
|
18
|
+
* event or element becomes a stub ({ type, key, target: { dataset, value, checked, id } }), with a
|
|
19
|
+
* comment. Anything else (functions, class instances, cycles) is left out with a comment.
|
|
20
|
+
*
|
|
21
|
+
* The final-state assertion is written only when the replay is complete: every state-changing
|
|
22
|
+
* action could be replayed with its data, no descendant instance changed the state on its own
|
|
23
|
+
* (a child's intent: copy the test from that instance instead), the session wasn't truncated,
|
|
24
|
+
* and the final state is JSON-safe. Otherwise a comment says why. A test written with the
|
|
25
|
+
* assertion passes unchanged (for drivers the session's app had, pass `drivers`).
|
|
26
|
+
*/
|
|
27
|
+
import type {SessionRecording, SessionAction} from './devtoolsActions'
|
|
28
|
+
import {isElement, isEvent, deepEqual} from './devtoolsActions'
|
|
29
|
+
|
|
30
|
+
export interface CopyAsTestOptions {
|
|
31
|
+
/** the import line(s) for the component, e.g. "import App from './App.jsx'" (default: `import <Name> from './<Name>.js'`) */
|
|
32
|
+
componentImport?: string
|
|
33
|
+
/** the identifier the component has in the test (default: the recorded component name) */
|
|
34
|
+
componentName?: string
|
|
35
|
+
/** more import lines (drivers, helpers) */
|
|
36
|
+
imports?: string[]
|
|
37
|
+
/** drivers for renderComponent, as source code by sink name: { DND: 'mockDragDriver().driver' } */
|
|
38
|
+
drivers?: Record<string, string>
|
|
39
|
+
/** more renderComponent options, as source code: "strict: true" */
|
|
40
|
+
renderOptions?: string
|
|
41
|
+
/** the test's name (default: '<Component> session (copied from sygnal/devtools)') */
|
|
42
|
+
testName?: string
|
|
43
|
+
/** adds a `// @vitest-environment <env>` first line (e.g. 'jsdom') */
|
|
44
|
+
environment?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface CopyAsTestResult {
|
|
48
|
+
/** the test file */
|
|
49
|
+
code: string
|
|
50
|
+
/** true when the test asserts the final state */
|
|
51
|
+
complete: boolean
|
|
52
|
+
/** what was left out, and why (also comments in the code) */
|
|
53
|
+
warnings: string[]
|
|
54
|
+
/** actions written as simulateAction / respond / fail */
|
|
55
|
+
replayed: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const REPLAY = new Set(['intent', 'simulateAction', 'behavior', 'agent'])
|
|
59
|
+
const IDENT = /^[A-Za-z_$][\w$]*$/
|
|
60
|
+
|
|
61
|
+
class Unserializable extends Error {}
|
|
62
|
+
|
|
63
|
+
/** a single-quoted JS string literal */
|
|
64
|
+
const str = (s: string) => `'${JSON.stringify(s).slice(1, -1).replace(/\\"/g, '"').replace(/'/g, "\\'")}'`
|
|
65
|
+
|
|
66
|
+
interface Lit { code: string; stubbed: boolean }
|
|
67
|
+
|
|
68
|
+
/** `v` as a JS literal. mode 'data' turns DOM events / elements into stubs; 'state' refuses them. */
|
|
69
|
+
export function literal(v: any, mode: 'data' | 'state' = 'data', indent = ''): Lit {
|
|
70
|
+
const flag = {stubbed: false}
|
|
71
|
+
const code = lit(v, mode, indent, new Set(), flag)
|
|
72
|
+
return {code, stubbed: flag.stubbed}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function elementStub(el: any): any {
|
|
76
|
+
const out: any = {}
|
|
77
|
+
const ds = el.dataset && typeof el.dataset == 'object' ? {...el.dataset} : {}
|
|
78
|
+
if (Object.keys(ds).length) out.dataset = ds
|
|
79
|
+
if (el.id) out.id = el.id
|
|
80
|
+
const tag = String(el.tagName).toUpperCase()
|
|
81
|
+
if (/^(INPUT|SELECT|TEXTAREA)$/.test(tag) && typeof el.value == 'string') out.value = el.value
|
|
82
|
+
if (tag == 'INPUT' && /^(checkbox|radio)$/i.test(el.type || '')) out.checked = !!el.checked
|
|
83
|
+
return out
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function eventStub(e: any): any {
|
|
87
|
+
const out: any = {type: e.type}
|
|
88
|
+
if (typeof e.key == 'string') out.key = e.key
|
|
89
|
+
if (isElement(e.target)) {
|
|
90
|
+
const t = elementStub(e.target)
|
|
91
|
+
if (Object.keys(t).length) out.target = t
|
|
92
|
+
}
|
|
93
|
+
return out
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function lit(v: any, mode: 'data' | 'state', ind: string, seen: Set<any>, flag: {stubbed: boolean}): string {
|
|
97
|
+
if (v === null) return 'null'
|
|
98
|
+
switch (typeof v) {
|
|
99
|
+
case 'undefined': return 'undefined'
|
|
100
|
+
case 'boolean': return String(v)
|
|
101
|
+
case 'string': return str(v)
|
|
102
|
+
case 'number': return Object.is(v, -0) ? '-0' : String(v)
|
|
103
|
+
case 'bigint': return `${v}n`
|
|
104
|
+
case 'function': throw new Unserializable(`a function (${v.name || 'anonymous'})`)
|
|
105
|
+
case 'symbol': throw new Unserializable(`a symbol (${String(v)})`)
|
|
106
|
+
}
|
|
107
|
+
if (seen.has(v)) throw new Unserializable('a circular reference')
|
|
108
|
+
if (isEvent(v) || isElement(v)) {
|
|
109
|
+
if (mode == 'state') throw new Unserializable(isEvent(v) ? `a DOM ${v.type} event` : 'a DOM element')
|
|
110
|
+
flag.stubbed = true
|
|
111
|
+
return lit(isEvent(v) ? eventStub(v) : elementStub(v), mode, ind, seen, flag)
|
|
112
|
+
}
|
|
113
|
+
seen.add(v)
|
|
114
|
+
try {
|
|
115
|
+
const inner = ind + ' '
|
|
116
|
+
const list = (items: string[], open: string, close: string) => {
|
|
117
|
+
if (!items.length) return open + close
|
|
118
|
+
const one = open + (open == '{' ? ' ' : '') + items.join(', ') + (open == '{' ? ' ' : '') + close
|
|
119
|
+
if (one.length + ind.length <= 100 && !one.includes('\n')) return one
|
|
120
|
+
return open + '\n' + items.map(s => inner + s).join(',\n') + ',\n' + ind + close
|
|
121
|
+
}
|
|
122
|
+
if (Array.isArray(v)) return list(Array.from(v, x => lit(x, mode, inner, seen, flag)), '[', ']')
|
|
123
|
+
if (v instanceof Date) return `new Date(${str(isNaN(+v) ? 'Invalid Date' : v.toISOString())})`
|
|
124
|
+
if (v instanceof Map) return `new Map(${list([...v].map(([k, x]) => list([lit(k, mode, inner + ' ', seen, flag), lit(x, mode, inner + ' ', seen, flag)], '[', ']')), '[', ']')})`
|
|
125
|
+
if (v instanceof Set) return `new Set(${list([...v].map(x => lit(x, mode, inner, seen, flag)), '[', ']')})`
|
|
126
|
+
const proto = Object.getPrototypeOf(v)
|
|
127
|
+
// G-265: a vnode of the JSX pragma (its own constructor, flagged $p) is a plain object
|
|
128
|
+
if (proto !== Object.prototype && proto !== null && !v.$p) throw new Unserializable(`a ${(v.constructor && v.constructor.name) || 'class instance'}`)
|
|
129
|
+
const items: string[] = []
|
|
130
|
+
for (const k of Object.keys(v)) {
|
|
131
|
+
if (v[k] === undefined) continue
|
|
132
|
+
items.push(`${IDENT.test(k) ? k : str(k)}: ${lit(v[k], mode, inner, seen, flag)}`)
|
|
133
|
+
}
|
|
134
|
+
return list(items, '{', '}')
|
|
135
|
+
} finally {
|
|
136
|
+
seen.delete(v)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const q = str
|
|
141
|
+
const tryLit = (v: any, mode: 'data' | 'state', ind: string): Lit | {error: string} => {
|
|
142
|
+
try { return literal(v, mode, ind) } catch (e: any) {
|
|
143
|
+
if (e instanceof Unserializable) return {error: e.message}
|
|
144
|
+
throw e
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** an error reply from makeFetchDriver: { error, request, status?, body? } for the request's `error` action */
|
|
149
|
+
const isErrorReply = (a: SessionAction) =>
|
|
150
|
+
a.data && typeof a.data == 'object' && 'error' in a.data && a.data.request && typeof a.data.request == 'object' && a.data.request.error === a.type
|
|
151
|
+
|
|
152
|
+
/** The test for a recorded session (see the module comment), with what it left out. */
|
|
153
|
+
export function sessionToTest(rec: SessionRecording, options: CopyAsTestOptions = {}): CopyAsTestResult {
|
|
154
|
+
const name = options.componentName || (IDENT.test(rec.component) ? rec.component : 'Component')
|
|
155
|
+
const warnings: string[] = []
|
|
156
|
+
const incomplete: string[] = []
|
|
157
|
+
const body: string[] = []
|
|
158
|
+
const I = ' '
|
|
159
|
+
const drivers = options.drivers || {}
|
|
160
|
+
let replayed = 0
|
|
161
|
+
|
|
162
|
+
if (rec.truncated) incomplete.push('the session lost its oldest actions (clear the log, then record a shorter session)')
|
|
163
|
+
|
|
164
|
+
// render options
|
|
165
|
+
const opts: string[] = []
|
|
166
|
+
if (rec.initialState !== undefined && !deepEqual(rec.initialState, rec.definitionInitialState)) {
|
|
167
|
+
const l = tryLit(rec.initialState, 'state', I)
|
|
168
|
+
if ('error' in l) {
|
|
169
|
+
warnings.push(`the initial state holds ${l.error}: rendering with the component's own initialState`)
|
|
170
|
+
incomplete.push('the initial state could not be written')
|
|
171
|
+
} else opts.push(`initialState: ${l.code}`)
|
|
172
|
+
}
|
|
173
|
+
const fakeable = new Set(rec.fakeable)
|
|
174
|
+
const driverNames = [...new Set([...rec.drivers.filter(n => !fakeable.has(n)), ...Object.keys(drivers)])]
|
|
175
|
+
const given = driverNames.filter(n => drivers[n] !== undefined)
|
|
176
|
+
const missing = driverNames.filter(n => drivers[n] === undefined)
|
|
177
|
+
if (given.length) opts.push(`drivers: { ${given.map(n => `${IDENT.test(n) ? n : q(n)}: ${drivers[n]}`).join(', ')} }`)
|
|
178
|
+
if (missing.length) warnings.push(`the app had ${missing.length == 1 ? 'a driver' : 'drivers'} for ${missing.join(', ')}: pass ${missing.length == 1 ? 'it' : 'them'} with copyAsTest(..., { drivers: { ${missing[0]}: '...' } }) if the component reads ${missing.length == 1 ? 'it' : 'them'}`)
|
|
179
|
+
if (options.renderOptions) opts.push(options.renderOptions)
|
|
180
|
+
|
|
181
|
+
const names = rec.actionNames && new Set(rec.actionNames)
|
|
182
|
+
// (G-218: t.respond / t.fail right after queued simulateAction calls wait for the request
|
|
183
|
+
// those calls send, so no settle() is needed before them)
|
|
184
|
+
const answer = (line: string) => body.push(line)
|
|
185
|
+
for (const a of rec.actions) {
|
|
186
|
+
const changes = a.sinks.includes('STATE') || a.sinks.includes('state')
|
|
187
|
+
if (REPLAY.has(a.cause)) {
|
|
188
|
+
// an intent over the component's own STATE stream: the replay produces it again
|
|
189
|
+
if (a.echo) continue
|
|
190
|
+
// an intent action with no model entry does nothing (renderComponent can't send it)
|
|
191
|
+
if (names && !names.has(a.type) && a.cause != 'behavior') continue
|
|
192
|
+
replayed++
|
|
193
|
+
if (a.data === undefined) { body.push(`${I}t.simulateAction(${q(a.type)})`); continue }
|
|
194
|
+
const l = tryLit(a.data, 'data', I)
|
|
195
|
+
if ('error' in l) {
|
|
196
|
+
body.push(`${I}t.simulateAction(${q(a.type)}) // data left out: it held ${l.error}`)
|
|
197
|
+
if (changes) incomplete.push(`${a.type}'s data could not be written (${l.error})`)
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
if (l.stubbed) body.push(`${I}// ${a.type}: DOM event/element data as a stub (type, key, target dataset/value/checked/id)`)
|
|
201
|
+
body.push(`${I}t.simulateAction(${q(a.type)}, ${l.code})`)
|
|
202
|
+
} else if (a.cause == 'reply') {
|
|
203
|
+
const sink = a.replySink
|
|
204
|
+
if (!sink || a.replyKind != 'fetch' || drivers[sink] !== undefined) {
|
|
205
|
+
body.push(`${I}// reply ${a.type}${sink ? ` from ${sink}` : ''}: not replayed (renderComponent fakes makeFetchDriver sources only)`)
|
|
206
|
+
if (changes) incomplete.push(`the ${a.type} reply could not be replayed`)
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
if (isErrorReply(a)) {
|
|
210
|
+
const err = a.data.error, status = a.data.status ?? (err && err.status), errBody = a.data.body ?? (err && err.body)
|
|
211
|
+
if (typeof status == 'number') {
|
|
212
|
+
if (errBody === undefined) answer(`${I}await t.fail(${q(sink)}, ${status}, ${q(a.type)})`)
|
|
213
|
+
else {
|
|
214
|
+
const l = tryLit(errBody, 'state', I)
|
|
215
|
+
if ('error' in l) { answer(`${I}await t.fail(${q(sink)}, ${status}, ${q(a.type)}) // body left out: it held ${l.error}`); if (changes) incomplete.push(`the ${a.type} error body could not be written`) }
|
|
216
|
+
else answer(`${I}await t.fail(${q(sink)}, ${status}, { request: (r) => r.error === ${q(a.type)}, body: ${l.code} })`)
|
|
217
|
+
}
|
|
218
|
+
} else if (a.data.issues) {
|
|
219
|
+
body.push(`${I}// reply ${a.type}: a validation failure, not replayed`)
|
|
220
|
+
if (changes) incomplete.push(`the ${a.type} validation failure could not be replayed`)
|
|
221
|
+
continue
|
|
222
|
+
} else {
|
|
223
|
+
answer(`${I}await t.fail(${q(sink)}, new Error(${str(String(err && err.message || err))}), ${q(a.type)})`)
|
|
224
|
+
}
|
|
225
|
+
replayed++
|
|
226
|
+
continue
|
|
227
|
+
}
|
|
228
|
+
const l = tryLit(a.data, 'state', I)
|
|
229
|
+
if ('error' in l) {
|
|
230
|
+
body.push(`${I}// reply ${a.type} from ${sink}: its data held ${l.error}, not replayed`)
|
|
231
|
+
if (changes) incomplete.push(`the ${a.type} reply's data could not be written`)
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
answer(`${I}await t.respond(${q(sink)}, ${l.code}, ${q(a.type)})`)
|
|
235
|
+
replayed++
|
|
236
|
+
} else if (a.type == 'RESOURCE' && a.data && (a.data.status == 'success' || a.data.status == 'error')) {
|
|
237
|
+
body.push(`${I}// RESOURCE ${a.data.name}: ${a.data.status}, not replayed (answer it with t.respond / t.fail)`)
|
|
238
|
+
if (changes) incomplete.push(`resource '${a.data.name}' was answered`)
|
|
239
|
+
}
|
|
240
|
+
// 'next' and 'built-in': consequences, produced again by the replay
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (rec.foreign.length) {
|
|
244
|
+
const list = rec.foreign.slice(0, 5).map(f => `${f.component}#${f.instance} ${f.type}`).join(', ') + (rec.foreign.length > 5 ? ', ...' : '')
|
|
245
|
+
incomplete.push(`child components changed the state through their own actions (${list}): copy the test from that component instance instead`)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let assertion: string[]
|
|
249
|
+
if (!incomplete.length) {
|
|
250
|
+
const l = tryLit(rec.finalState, 'state', I)
|
|
251
|
+
if ('error' in l) incomplete.push(`the final state holds ${l.error}`)
|
|
252
|
+
else assertion = [`${I}expect(t.state).toEqual(${l.code})`]
|
|
253
|
+
}
|
|
254
|
+
if (incomplete.length) {
|
|
255
|
+
warnings.push(...incomplete.map(s => 'no final-state assertion: ' + s))
|
|
256
|
+
assertion = [`${I}// No final-state assertion: ${incomplete.join('; ')}`]
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const head: string[] = []
|
|
260
|
+
if (options.environment) head.push(`// @vitest-environment ${options.environment}`)
|
|
261
|
+
head.push(`// Copied from a sygnal/devtools session of ${rec.component} (${rec.actions.length} recorded actions, ${replayed} replayed)`)
|
|
262
|
+
for (const w of warnings) head.push(`// NOTE: ${w}`)
|
|
263
|
+
head.push(`import { it, expect } from 'vitest'`, `import { renderComponent } from 'sygnal'`)
|
|
264
|
+
head.push(...(options.imports || []))
|
|
265
|
+
head.push(options.componentImport || `import ${name} from './${name}.js'`)
|
|
266
|
+
|
|
267
|
+
const testName = options.testName || `${rec.component} session (copied from sygnal/devtools)`
|
|
268
|
+
const render = opts.length ? `renderComponent(${name}, {\n${opts.map(o => `${I}${o},`).join('\n')}\n })` : `renderComponent(${name})`
|
|
269
|
+
const code = [
|
|
270
|
+
...head,
|
|
271
|
+
'',
|
|
272
|
+
`it(${q(testName)}, async () => {`,
|
|
273
|
+
` const t = ${render}`,
|
|
274
|
+
' try {',
|
|
275
|
+
`${I}await t.ready()`,
|
|
276
|
+
...body,
|
|
277
|
+
`${I}await t.settle()`,
|
|
278
|
+
...assertion!,
|
|
279
|
+
' } finally {',
|
|
280
|
+
`${I}t.dispose()`,
|
|
281
|
+
' }',
|
|
282
|
+
'})',
|
|
283
|
+
'',
|
|
284
|
+
].join('\n')
|
|
285
|
+
return {code, complete: !incomplete.length, warnings, replayed}
|
|
286
|
+
}
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PLAN-6 E-1: the page side of the dev MCP endpoint (`sygnal({ mcp: true })` in sygnal/vite).
|
|
3
|
+
* Dev only: it is part of the 'sygnal/devtools' entry, and sygnal/vite's 'virtual:sygnal/mcp'
|
|
4
|
+
* module (served by the dev server, never in a build) calls installMcpBridge(import.meta.hot).
|
|
5
|
+
*
|
|
6
|
+
* The protocol, over Vite's HMR channel (custom events):
|
|
7
|
+
* page → server 'sygnal:mcp:hello' { url, title, focused, apps, update? } on load, focus,
|
|
8
|
+
* when shown, and (update: true) when the apps change
|
|
9
|
+
* server → page 'sygnal:mcp:request' { id, tool, args }
|
|
10
|
+
* page → server 'sygnal:mcp:response' { id, ok: true, result } | { id, ok: false, error }
|
|
11
|
+
* | { id, waiting: true } (a confirm dialog is open:
|
|
12
|
+
* the server waits longer)
|
|
13
|
+
*
|
|
14
|
+
* The tools read the page's apps through their runtime APIs, the DevTools bridge (diagnostics,
|
|
15
|
+
* action log, copy as test), the diagnostics core's inspect(), and the A-1 agent layer
|
|
16
|
+
* (agentTools, passed in by the virtual module from the app's own 'sygnal', so there is one core).
|
|
17
|
+
*
|
|
18
|
+
* G-638: every run() of the page is served. The bridge registers a dev hook layer
|
|
19
|
+
* (`__SYGNAL_DIAGNOSTICS__.layers`, read by each app at its start: no core bytes), so it sees each
|
|
20
|
+
* app's runtime API in start order; an HMR successor (disposed and started in the same tick)
|
|
21
|
+
* keeps its predecessor's place. `app` (an index, or the root component's name) picks one; the
|
|
22
|
+
* default is the first. An app started before the bridge was installed is still found through
|
|
23
|
+
* window.__SYGNAL_DEVTOOLS_APP__. The hello carries the apps ({ index, component }) and is sent
|
|
24
|
+
* again (`update: true`, not a focus) when they change.
|
|
25
|
+
*/
|
|
26
|
+
import {getDevTools} from './devtools'
|
|
27
|
+
import {getActions, getSession, preview} from './devtoolsActions'
|
|
28
|
+
import {sessionToTest} from './copyAsTest'
|
|
29
|
+
import {addLayer} from './devtoolsNext'
|
|
30
|
+
|
|
31
|
+
export const MCP_HELLO = 'sygnal:mcp:hello'
|
|
32
|
+
export const MCP_REQUEST = 'sygnal:mcp:request'
|
|
33
|
+
export const MCP_RESPONSE = 'sygnal:mcp:response'
|
|
34
|
+
|
|
35
|
+
export interface McpBridgeOptions {
|
|
36
|
+
/** agentTools from 'sygnal' (the app's core) */
|
|
37
|
+
agentTools?: (target: any, options?: any) => any
|
|
38
|
+
/**
|
|
39
|
+
* Consequential agent tools (A-1 `consequential: true`): 'page' asks in the page with
|
|
40
|
+
* window.confirm() (default), true runs them, false declines them.
|
|
41
|
+
*/
|
|
42
|
+
confirm?: boolean | 'page'
|
|
43
|
+
/** the app to serve, alone (default: every run() of the page) */
|
|
44
|
+
app?: () => any
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface Hot {
|
|
48
|
+
on(event: string, cb: (data: any) => void): void
|
|
49
|
+
send(event: string, data?: any): void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
class ToolError extends Error {}
|
|
53
|
+
|
|
54
|
+
const g: any = globalThis
|
|
55
|
+
|
|
56
|
+
/** JSON-safe copy (functions, DOM nodes, cycles, Maps, Sets described) */
|
|
57
|
+
const plain = (v: any) => (v === undefined ? null : preview(v))
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------- the page's apps (G-638)
|
|
60
|
+
|
|
61
|
+
/** runtime APIs of the apps started since the bridge was installed, in start order */
|
|
62
|
+
const started: any[] = []
|
|
63
|
+
let gone = -1
|
|
64
|
+
let tracking = false
|
|
65
|
+
const listeners = new Set<() => void>()
|
|
66
|
+
const changed = () => listeners.forEach(f => { try { f() } catch (_) {} })
|
|
67
|
+
const alive = (api: any) => {
|
|
68
|
+
try { const r = api?.root; return !!r && !r.disposed } catch (_) { return false }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** the dev hook layer: each app's runtime API at its start; its root's dispose ends it */
|
|
72
|
+
function appLayer(api: any) {
|
|
73
|
+
// an HMR swap (or any app started in the tick its predecessor ended) takes the same place
|
|
74
|
+
if (gone >= 0 && gone < started.length && !alive(started[gone])) started[gone] = api
|
|
75
|
+
else started.push(api)
|
|
76
|
+
gone = -1
|
|
77
|
+
changed()
|
|
78
|
+
return {
|
|
79
|
+
onDispose(iv: any) {
|
|
80
|
+
if (!iv?.isRoot) return
|
|
81
|
+
const i = started.indexOf(api)
|
|
82
|
+
if (i < 0) return
|
|
83
|
+
gone = i
|
|
84
|
+
queueMicrotask(() => {
|
|
85
|
+
if (gone === i) gone = -1
|
|
86
|
+
const j = started.indexOf(api)
|
|
87
|
+
if (j >= 0) started.splice(j, 1)
|
|
88
|
+
changed()
|
|
89
|
+
})
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function trackApps() {
|
|
95
|
+
if (tracking) return
|
|
96
|
+
tracking = true
|
|
97
|
+
addLayer(appLayer)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface AppRef { api: any; target: any }
|
|
101
|
+
|
|
102
|
+
/** the live apps of the page, the first app first */
|
|
103
|
+
export function pageApps(o: McpBridgeOptions = {}): AppRef[] {
|
|
104
|
+
if (o.app) {
|
|
105
|
+
const app = o.app()
|
|
106
|
+
return app?.__runtime && alive(app.__runtime) ? [{api: app.__runtime, target: app}] : []
|
|
107
|
+
}
|
|
108
|
+
// each runtime.api() is a new object: an app is known by its root instance (ids are page-unique)
|
|
109
|
+
const first = g.window?.__SYGNAL_DEVTOOLS_APP__
|
|
110
|
+
const firstRoot = first?.__runtime && alive(first.__runtime) ? first.__runtime.root.id : undefined
|
|
111
|
+
const ref = (api: any): AppRef => ({api, target: firstRoot !== undefined && api.root.id === firstRoot ? first : api})
|
|
112
|
+
const out = started.filter(alive).map(ref)
|
|
113
|
+
if (firstRoot !== undefined && !out.some(a => a.api.root.id === firstRoot)) out.unshift({api: first.__runtime, target: first})
|
|
114
|
+
return out
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const appSummary = (a: AppRef, index: number) => {
|
|
118
|
+
let component = '?'
|
|
119
|
+
try { component = a.api.root.name } catch (_) {}
|
|
120
|
+
return {index, component}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const appsLine = (apps: AppRef[]) => apps.map((a, i) => `${i} ${appSummary(a, i).component}`).join(', ')
|
|
124
|
+
|
|
125
|
+
/** the app `want` names (an index, or the root component's name); the first when omitted */
|
|
126
|
+
function pickApp(o: McpBridgeOptions, want: any): AppRef {
|
|
127
|
+
const apps = pageApps(o)
|
|
128
|
+
if (!apps.length) throw new ToolError('no Sygnal app is running on this page (run() has not been called, or the app was disposed)')
|
|
129
|
+
if (want === undefined || want === null || want === '') return apps[0]
|
|
130
|
+
if (typeof want == 'number' || /^\d+$/.test(String(want))) {
|
|
131
|
+
const a = apps[Number(want)]
|
|
132
|
+
if (!a) throw new ToolError(`no app ${want}; apps: ${appsLine(apps)}`)
|
|
133
|
+
return a
|
|
134
|
+
}
|
|
135
|
+
const named = apps.filter(a => { try { return a.api.root.name === want } catch (_) { return false } })
|
|
136
|
+
if (!named.length) throw new ToolError(`no app ${JSON.stringify(want)}; apps: ${appsLine(apps)}`)
|
|
137
|
+
if (named.length > 1) throw new ToolError(`${named.length} apps have the root ${want}; pass an index (apps: ${appsLine(apps)})`)
|
|
138
|
+
return named[0]
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The runtime API to use: `app`'s; without `app`, the first app, unless `component` (a name or
|
|
143
|
+
* an instance id; ids are unique on the page) is only in another one
|
|
144
|
+
*/
|
|
145
|
+
function runtimeFor(o: McpBridgeOptions, args: any): any {
|
|
146
|
+
const c = args.component
|
|
147
|
+
const first = pickApp(o, args.app).api
|
|
148
|
+
if ((args.app !== undefined && args.app !== null) || c === undefined || c === null || c === '' || find(first, c).length) return first
|
|
149
|
+
const owners = pageApps(o).filter(a => { try { return find(a.api, c).length > 0 } catch (_) { return false } })
|
|
150
|
+
return owners.length == 1 ? owners[0].api : first
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** every instance of the app, root first (depth-first) */
|
|
154
|
+
function allInstances(rt: any): any[] {
|
|
155
|
+
const out: any[] = []
|
|
156
|
+
const walk = (iv: any) => {
|
|
157
|
+
if (!iv) return
|
|
158
|
+
out.push(iv)
|
|
159
|
+
for (const c of iv.children?.() || []) walk(c)
|
|
160
|
+
}
|
|
161
|
+
walk(rt.root)
|
|
162
|
+
return out
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The instances `component` names: a component name, or an instance id (number or digits); root when omitted */
|
|
166
|
+
function find(rt: any, component: any): any[] {
|
|
167
|
+
if (component === undefined || component === null || component === '') return rt.root ? [rt.root] : []
|
|
168
|
+
const all = allInstances(rt)
|
|
169
|
+
if (typeof component == 'number' || /^\d+$/.test(String(component))) {
|
|
170
|
+
const iv = all.find(i => String(i.id) === String(component))
|
|
171
|
+
return iv ? [iv] : []
|
|
172
|
+
}
|
|
173
|
+
return all.filter(i => i.name === component)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function names(rt: any): string {
|
|
177
|
+
return [...new Set(allInstances(rt).map(i => i.name))].join(', ')
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function one(rt: any, component: any, verb: string): any {
|
|
181
|
+
const found = find(rt, component)
|
|
182
|
+
if (!found.length) throw new ToolError(`no component ${JSON.stringify(component)} is shown now; components: ${names(rt) || 'none'}`)
|
|
183
|
+
if (found.length > 1) {
|
|
184
|
+
throw new ToolError(`${found.length} instances of ${component} are shown (ids ${found.map(i => i.id).join(', ')}); pass one id as component to ${verb} one`)
|
|
185
|
+
}
|
|
186
|
+
return found[0]
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function at(value: any, path: any): any {
|
|
190
|
+
if (path === undefined || path === null || path === '') return value
|
|
191
|
+
const parts = Array.isArray(path) ? path.map(String) : String(path).split('.').filter(Boolean)
|
|
192
|
+
let v = value
|
|
193
|
+
for (const p of parts) {
|
|
194
|
+
if (v === null || v === undefined || typeof v != 'object') throw new ToolError(`state has no ${parts.join('.')} (stopped at ${p})`)
|
|
195
|
+
v = v instanceof Map ? v.get(p) : v[p]
|
|
196
|
+
}
|
|
197
|
+
return v
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const stateOf = (iv: any) => ({component: iv.name, id: iv.id, ...(iv.kind == 'item' ? {item: true} : {}), state: plain(iv.state)})
|
|
201
|
+
|
|
202
|
+
function actionNames(iv: any): string[] {
|
|
203
|
+
const def = iv.def || {}
|
|
204
|
+
const out = def.handlers instanceof Map ? [...def.handlers.keys()] : []
|
|
205
|
+
const b = def.behaviorActions
|
|
206
|
+
if (b && typeof b == 'object') out.push(...(b instanceof Map ? [...b.keys()] : Object.keys(b)))
|
|
207
|
+
return [...new Set(out.map(String))]
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const serializeAction = (a: any) => ({
|
|
211
|
+
seq: a.seq, type: a.type, data: preview(a.data), component: a.component, instance: a.instance, parent: a.parent,
|
|
212
|
+
sinks: [...a.sinks], cause: a.cause, at: a.at,
|
|
213
|
+
...('after' in a ? {before: plain(a.before), after: plain(a.after)} : {}),
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
/** The tool implementations; `id` is the request id (for the confirm notice). Exported for tests. */
|
|
217
|
+
export function pageTools(o: McpBridgeOptions, notifyWaiting: (id: any) => void = () => {}) {
|
|
218
|
+
const agents = new WeakMap<object, any>()
|
|
219
|
+
const agentsOf = (target: any) => {
|
|
220
|
+
if (typeof o.agentTools != 'function') throw new ToolError('the agent layer is not available (agentTools from sygnal/ai)')
|
|
221
|
+
let t = agents.get(target)
|
|
222
|
+
if (!t) agents.set(target, t = o.agentTools(target))
|
|
223
|
+
return t
|
|
224
|
+
}
|
|
225
|
+
const confirmFor = (id: any) => {
|
|
226
|
+
const c = o.confirm === undefined ? 'page' : o.confirm
|
|
227
|
+
if (c !== 'page') return !!c
|
|
228
|
+
return async (info: any) => {
|
|
229
|
+
if (typeof g.window?.confirm != 'function') return false
|
|
230
|
+
notifyWaiting(id)
|
|
231
|
+
// let the notice leave before the dialog blocks the page
|
|
232
|
+
await new Promise(r => setTimeout(r, 30))
|
|
233
|
+
const input = info.input === undefined ? '' : `\n\nInput: ${JSON.stringify(info.input)}`
|
|
234
|
+
return !!g.window.confirm(`An agent (MCP, sygnal dev server) wants to run "${info.tool}" on ${info.component}${info.label ? ` (${info.label})` : ''}: ${info.description}${input}\n\nAllow it?`)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const tools: Record<string, (args: any, id: any) => any> = {
|
|
239
|
+
apps() {
|
|
240
|
+
return {apps: pageApps(o).map((a, i) => {
|
|
241
|
+
let id, instances = 0
|
|
242
|
+
try { id = a.api.root.id; instances = allInstances(a.api).length } catch (_) {}
|
|
243
|
+
return {...appSummary(a, i), id, instances, ...(i === 0 ? {default: true} : {})}
|
|
244
|
+
})}
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
get_state(args) {
|
|
248
|
+
const rt = runtimeFor(o, args)
|
|
249
|
+
const found = find(rt, args.component)
|
|
250
|
+
if (!found.length) throw new ToolError(`no component ${JSON.stringify(args.component)} is shown now; components: ${names(rt) || 'none'}`)
|
|
251
|
+
const withPath = (iv: any) => {
|
|
252
|
+
const s = stateOf(iv)
|
|
253
|
+
if (args.path === undefined) return s
|
|
254
|
+
return {...s, path: args.path, state: plain(at(iv.state, args.path))}
|
|
255
|
+
}
|
|
256
|
+
if (found.length == 1) return withPath(found[0])
|
|
257
|
+
return {component: args.component, instances: found.map(withPath)}
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
async dispatch(args) {
|
|
261
|
+
if (typeof args.action != 'string' || !args.action) throw new ToolError('dispatch needs action: the action name, e.g. "ADD"')
|
|
262
|
+
const rt = runtimeFor(o, args)
|
|
263
|
+
const iv = one(rt, args.component, 'dispatch to')
|
|
264
|
+
const known = actionNames(iv)
|
|
265
|
+
if (!known.includes(args.action) && !args.action.includes('.')) {
|
|
266
|
+
throw new ToolError(`${iv.name} has no action ${args.action}; its actions: ${known.join(', ') || 'none'}`)
|
|
267
|
+
}
|
|
268
|
+
const before = iv.state
|
|
269
|
+
rt.dispatch(iv.id, args.action, args.data, 'agent')
|
|
270
|
+
await rt.flushed()
|
|
271
|
+
const after = iv.disposed ? undefined : iv.state
|
|
272
|
+
return {ok: true, component: iv.name, id: iv.id, action: args.action, changed: after !== before, ...(iv.disposed ? {disposed: true} : {state: plain(after)})}
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
component_tree(args) {
|
|
276
|
+
// every app's instances, unless `app` names one
|
|
277
|
+
const app = pickApp(o, args.app)
|
|
278
|
+
const inspect = g.__SYGNAL_DIAGNOSTICS__?.inspect || (getDevTools() as any).inspect
|
|
279
|
+
if (typeof inspect != 'function') {
|
|
280
|
+
throw new ToolError("component_tree needs the runtime diagnostics ('sygnal/diagnostics'), which sygnal({ diagnostics: 'off' }) leaves out")
|
|
281
|
+
}
|
|
282
|
+
const graph = inspect()
|
|
283
|
+
if (!graph || !Array.isArray(graph.components)) return graph
|
|
284
|
+
let keep = graph.components
|
|
285
|
+
if (args.app !== undefined && args.app !== null) {
|
|
286
|
+
const ids = new Set(allInstances(app.api).map((i: any) => String(i.id)))
|
|
287
|
+
keep = keep.filter((c: any) => ids.has(String(c.id)))
|
|
288
|
+
}
|
|
289
|
+
if (args.component) keep = keep.filter((c: any) => c.name === args.component || String(c.id) === String(args.component))
|
|
290
|
+
return keep === graph.components ? graph : {...graph, components: keep}
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
recent_actions(args) {
|
|
294
|
+
const limit = Math.max(1, Math.min(500, Number(args.limit) || 20))
|
|
295
|
+
const filter: any = {}
|
|
296
|
+
if (args.component) filter.component = String(args.component)
|
|
297
|
+
if (args.type) filter.type = String(args.type)
|
|
298
|
+
if (args.cause) filter.cause = args.cause
|
|
299
|
+
const all = getActions(Object.keys(filter).length ? filter : undefined)
|
|
300
|
+
return {total: all.length, actions: all.slice(-limit).map(serializeAction)}
|
|
301
|
+
},
|
|
302
|
+
|
|
303
|
+
get_diagnostics(args) {
|
|
304
|
+
let list: any[] = getDevTools().getDiagnostics() || []
|
|
305
|
+
if (args.code) list = list.filter(d => d.code === String(args.code).toUpperCase())
|
|
306
|
+
if (args.severity) list = list.filter(d => d.severity === args.severity)
|
|
307
|
+
const count = (s: string) => list.filter(d => d.severity === s).length
|
|
308
|
+
const limit = Math.max(1, Math.min(500, Number(args.limit) || 100))
|
|
309
|
+
return {
|
|
310
|
+
summary: {error: count('error'), warn: count('warn'), info: count('info')},
|
|
311
|
+
diagnostics: list.slice(-limit).map(d => ({
|
|
312
|
+
code: d.code, severity: d.severity, component: d.component, message: d.message,
|
|
313
|
+
...(d.fix ? {fix: d.fix} : {}), docsUrl: d.docsUrl, ...(d.data !== undefined ? {data: plain(d.data)} : {}),
|
|
314
|
+
})),
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
|
|
318
|
+
copy_as_test(args) {
|
|
319
|
+
// the default app's root by default (the DevTools default would be the newest root)
|
|
320
|
+
const target = String(one(runtimeFor(o, args), args.component, 'copy').id)
|
|
321
|
+
try {
|
|
322
|
+
const rec = getSession(target)
|
|
323
|
+
// a module path ('./App.jsx') or a whole import line
|
|
324
|
+
const ci = args.componentImport === undefined ? '' : String(args.componentImport)
|
|
325
|
+
const componentImport = !ci ? undefined : /^\s*import\s/.test(ci) ? ci : `import ${rec.component} from '${ci.replace(/'/g, "\\'")}'`
|
|
326
|
+
return sessionToTest(rec, componentImport ? {componentImport} : {})
|
|
327
|
+
} catch (e: any) {
|
|
328
|
+
throw new ToolError(String(e?.message || e))
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
async agent_tools(args, id) {
|
|
333
|
+
const t = agentsOf(pickApp(o, args.app).target)
|
|
334
|
+
if (args.call === undefined) return {tools: t.list(args.all ? {all: true} : undefined), context: plain(t.context())}
|
|
335
|
+
if (typeof args.call != 'string') throw new ToolError('call must be a tool name (see agent_tools without call)')
|
|
336
|
+
const result = await t.call(args.call, args.input, {confirm: confirmFor(id)})
|
|
337
|
+
return plain(result)
|
|
338
|
+
},
|
|
339
|
+
}
|
|
340
|
+
return tools
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
let installed: Hot | null = null
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Connect this page to the dev server's MCP endpoint (sygnal/vite with `mcp: true` calls it from
|
|
347
|
+
* 'virtual:sygnal/mcp'). Once per page; no-op without a hot channel.
|
|
348
|
+
*/
|
|
349
|
+
export function installMcpBridge(hot: Hot | undefined, options: McpBridgeOptions = {}): void {
|
|
350
|
+
if (!hot || installed === hot) return
|
|
351
|
+
installed = hot
|
|
352
|
+
if (!options.app) trackApps()
|
|
353
|
+
const tools = pageTools(options, (id) => hot.send(MCP_RESPONSE, {id, waiting: true}))
|
|
354
|
+
const hello = (update?: boolean) => {
|
|
355
|
+
const d = g.document
|
|
356
|
+
let apps: any[] = []
|
|
357
|
+
try { apps = pageApps(options).map(appSummary) } catch (_) {}
|
|
358
|
+
hot.send(MCP_HELLO, {url: String(g.location?.href || ''), title: String(d?.title || ''), focused: !!d?.hasFocus?.(), apps, ...(update ? {update: true} : {})})
|
|
359
|
+
}
|
|
360
|
+
// the apps changed: tell the server after the tick (when the new roots exist)
|
|
361
|
+
let queued = false
|
|
362
|
+
listeners.add(() => {
|
|
363
|
+
if (queued) return
|
|
364
|
+
queued = true
|
|
365
|
+
setTimeout(() => { queued = false; hello(true) }, 0)
|
|
366
|
+
})
|
|
367
|
+
hot.on(MCP_REQUEST, async (msg: any) => {
|
|
368
|
+
if (!msg || msg.id === undefined) return
|
|
369
|
+
const fn = Object.prototype.hasOwnProperty.call(tools, msg.tool) ? tools[msg.tool] : undefined
|
|
370
|
+
try {
|
|
371
|
+
if (!fn) throw new ToolError(`unknown page tool ${msg.tool}`)
|
|
372
|
+
const args = msg.args && typeof msg.args == 'object' && !Array.isArray(msg.args) ? msg.args : {}
|
|
373
|
+
const result = await fn(args, msg.id)
|
|
374
|
+
hot.send(MCP_RESPONSE, {id: msg.id, ok: true, result: result === undefined ? null : result})
|
|
375
|
+
} catch (e: any) {
|
|
376
|
+
hot.send(MCP_RESPONSE, {id: msg.id, ok: false, error: e instanceof ToolError ? e.message : `${msg.tool} failed: ${e?.message || e}`})
|
|
377
|
+
}
|
|
378
|
+
})
|
|
379
|
+
// the server answers this after a reconnect too (a restarted dev server)
|
|
380
|
+
hot.on('vite:ws:connect', () => hello())
|
|
381
|
+
hello()
|
|
382
|
+
if (typeof g.addEventListener == 'function') {
|
|
383
|
+
g.addEventListener('focus', () => hello())
|
|
384
|
+
g.document?.addEventListener?.('visibilitychange', () => { if (g.document.visibilityState == 'visible') hello() })
|
|
385
|
+
}
|
|
386
|
+
}
|