From 20289561625a5bda9864175e489c138662d15549 Mon Sep 17 00:00:00 2001 From: arlo Date: Wed, 14 Oct 2026 15:45:35 +0800 Subject: [PATCH 1/2] feat(agent): add Vue DevTools agent package with MCP tools Add @vue/devtools-agent: MCP tools over the kit runtime for apps, component tree and state, inspectors and Pinia, router, value expansion, and guarded state edits, plus Devframe page/host adapters and the vue-devtools-agent stdio MCP CLI. Kit: paginated state reads, component search, and an opt-in requireEditable flag on the edit commands. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/release-commit.yml | 2 +- AGENTS.md | 4 + docs/.vitepress/config.ts | 1 + docs/guide/agents.md | 198 ++++++++ package.json | 3 +- packages/agent/LICENSE | 21 + packages/agent/README.md | 181 +++++++ packages/agent/package.json | 60 +++ packages/agent/src/cli.ts | 48 ++ packages/agent/src/devframe.ts | 2 + packages/agent/src/index.ts | 7 + packages/agent/src/mcp/host.ts | 16 + packages/agent/src/mcp/page.ts | 15 + packages/agent/src/mcp/schemas.ts | 203 ++++++++ packages/agent/src/mcp/tools.ts | 294 +++++++++++ packages/agent/src/node.ts | 1 + packages/agent/src/session.ts | 37 ++ packages/agent/src/types.ts | 18 + packages/agent/tests/component-search.test.ts | 58 +++ packages/agent/tests/handles.test.ts | 61 +++ packages/agent/tests/pagination.test.ts | 281 +++++++++++ packages/agent/tests/session.test.ts | 23 + packages/agent/tests/state-fields.test.ts | 205 ++++++++ packages/agent/tests/state-limits.test.ts | 116 +++++ packages/agent/tests/tools.test.ts | 146 ++++++ packages/agent/tsdown.config.ts | 9 + .../client/src/composables/devtools-state.ts | 2 +- packages/kit/src/codec/encode.ts | 27 +- packages/kit/src/protocol/messages.ts | 28 ++ packages/kit/src/protocol/requests.ts | 33 +- packages/kit/src/runtime/components.ts | 17 + .../kit/src/runtime/handlers/components.ts | 105 +++- .../kit/src/runtime/handlers/inspectors.ts | 93 +++- packages/kit/src/runtime/handlers/shared.ts | 16 + packages/kit/src/runtime/state/index.ts | 321 +++++++++++- packages/kit/src/runtime/state/pagination.ts | 152 ++++++ packages/vite/package.json | 3 +- packages/vite/src/client.ts | 76 ++- packages/vite/src/utils/dock/registration.ts | 15 +- pnpm-lock.yaml | 467 +++++++++++++++--- pnpm-workspace.yaml | 5 + tests/fixtures/vite-smoke/AgentFixtures.vue | 38 ++ tests/fixtures/vite-smoke/App.vue | 4 + tests/smoke/agent.test.ts | 265 ++++++++++ tests/unit/runtime/state-isolation.test.ts | 2 +- tests/unit/vite/client-lifecycle.test.ts | 70 ++- tests/unit/vite/dock-registration.test.ts | 47 +- vitest.config.ts | 4 + 48 files changed, 3683 insertions(+), 117 deletions(-) create mode 100644 docs/guide/agents.md create mode 100644 packages/agent/LICENSE create mode 100644 packages/agent/README.md create mode 100644 packages/agent/package.json create mode 100644 packages/agent/src/cli.ts create mode 100644 packages/agent/src/devframe.ts create mode 100644 packages/agent/src/index.ts create mode 100644 packages/agent/src/mcp/host.ts create mode 100644 packages/agent/src/mcp/page.ts create mode 100644 packages/agent/src/mcp/schemas.ts create mode 100644 packages/agent/src/mcp/tools.ts create mode 100644 packages/agent/src/node.ts create mode 100644 packages/agent/src/session.ts create mode 100644 packages/agent/src/types.ts create mode 100644 packages/agent/tests/component-search.test.ts create mode 100644 packages/agent/tests/handles.test.ts create mode 100644 packages/agent/tests/pagination.test.ts create mode 100644 packages/agent/tests/session.test.ts create mode 100644 packages/agent/tests/state-fields.test.ts create mode 100644 packages/agent/tests/state-limits.test.ts create mode 100644 packages/agent/tests/tools.test.ts create mode 100644 packages/agent/tsdown.config.ts create mode 100644 packages/kit/src/runtime/state/pagination.ts create mode 100644 tests/fixtures/vite-smoke/AgentFixtures.vue create mode 100644 tests/smoke/agent.test.ts diff --git a/.github/workflows/release-commit.yml b/.github/workflows/release-commit.yml index 591c2a13e..d0f168004 100644 --- a/.github/workflows/release-commit.yml +++ b/.github/workflows/release-commit.yml @@ -8,5 +8,5 @@ jobs: if: github.repository == 'vuejs/devtools' uses: sxzz/workflows/.github/workflows/release-commit.yml@v1 with: - packages: "'./packages/kit' './packages/devtools-api' './packages/vite'" + packages: "'./packages/kit' './packages/agent' './packages/devtools-api' './packages/vite'" compact: true diff --git a/AGENTS.md b/AGENTS.md index 8a3ca1c48..2b9afc98a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,7 @@ Vue DevTools v9 ships two hosts over one kit and one client. The Vite plugin is - **`@vue/devtools-kit` + `@vue/devtools-client`** — shared runtime and UI: component tree, state, timeline, Pinia, pages, reactivity graph, and the v6 plugin setup API (`setupDevToolsPlugin`, alias `setupDevtoolsPlugin`). - **`vite-plugin-vue-devtools`** — Vite host. Requires Vite 8.3.0+ and a project-level `@vitejs/devtools` (`devtools: { apply: 'serve' }` is recommended) next to `plugins: [vueDevTools()]`. This package does not register `DevTools()` and does not brand the shared Vite shell. Sibling repo: `../vite-devtools`. Read its [AGENTS.md](../vite-devtools/AGENTS.md) before changing dock, command, or open-in-editor behavior. +- **`@vue/devtools-agent`** — agent (MCP) tools over the kit's runtime requests: apps, component tree and state, inspectors and Pinia, router, value expansion, and guarded state edits. The Vite host registers them through Devframe (`./node` for the host help tool, `./devframe` for per-page tools); `vue-devtools-agent connect` exposes running Devframe instances over stdio MCP. The Chromium extension does not register agent tools. - **`@vue/devtools-chrome`** — Chromium MV3 host. Content backend calls `createDevtoolsKit({ clientName: 'chrome-extension' })` on the page and bridges the panel on channel `vue-devtools:chrome-extension`. It works on any page that runs Vue, including apps that never load Vite DevTools. `devframe` / `@devframes/hub` (docs: [devfra.me](https://devfra.me)) matter on the Vite path: docks, commands, terminals, messages. The extension does not use that hub. Features that only exist when several Vite tools share a UI (custom tabs, commands, split screen, the floating inspector button) belong in Vite DevTools. Vue-level `addCustomTab` / `addCustomCommand` stay removed on both hosts. Custom inspectors, component hooks, timeline layers, and plugin settings stay on the Vue plugin API. @@ -22,6 +23,7 @@ Monorepo (`pnpm` workspaces). ESM TypeScript. Library packages bundle with tsdow | `packages/devtools-api` | `@vue/devtools-api` | Public plugin API surface over the kit. | | `packages/client` | `@vue/devtools-client` | Vue 3 + UnoCSS UI shared by both hosts. Private. Vite plugin copies the build into `packages/vite/client`; the extension loads the same client in its panel. | | `packages/vite` | `vite-plugin-vue-devtools` | Vite host. Serves the static client and registers an iframe dock with `@vitejs/devtools`. | +| `packages/agent` | `@vue/devtools-agent` | Agent tools over the kit runtime, Devframe page/host adapters, and the `vue-devtools-agent` stdio MCP CLI. Used by the Vite host only. | | `packages/chrome` | `@vue/devtools-chrome` | Chromium MV3 host (background, content backend, DevTools panel). Private. Zip via `pnpm zip:chrome`. | Other top-level directories: @@ -39,6 +41,8 @@ flowchart TD client --> chrome["chrome extension"] kit --> plugin kit --> chrome + kit --> agent["@vue/devtools-agent"] + agent --> plugin plugin --> viteHost["@vitejs/devtools"] viteHost --> hub["@devframes/hub"] hub --> devframe diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index bf8df403c..6d88037d0 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -12,6 +12,7 @@ const GETTING_STARTED = [ const GUIDES = [ { text: 'Vite Plugin', link: '/guide/vite-plugin' }, + { text: 'Agents', link: '/guide/agents' }, { text: 'Browser Extension', link: '/guide/browser-extension' }, ] satisfies DefaultTheme.NavItemWithLink[] diff --git a/docs/guide/agents.md b/docs/guide/agents.md new file mode 100644 index 000000000..a422cf34e --- /dev/null +++ b/docs/guide/agents.md @@ -0,0 +1,198 @@ +# Agents + +Connect a coding agent to your running Vue app to inspect components, read Pinia stores, check routes, and edit individual state fields through MCP (Model Context Protocol). + +Agents use the same runtime as the Vue DevTools panel. Keep your app open in the browser; the panel can stay closed. This integration is available during development through the Vite plugin. The [Chromium extension](/guide/browser-extension) does not expose an agent connection. + +## Get started + +### 1. Enable Vue DevTools + +Set up the [Vite plugin](/guide/vite-plugin) with Vite 8.3 or later and `@vitejs/devtools` 0.7.6 or later. Start your dev server, open the app in a browser, and allow the Vite DevTools connection when prompted. + +Vue's tools register automatically when the host provides MCP access. Setting `devtools.mcp` to `false` disables that access. + +### 2. Configure your agent + +Add the connector to your agent client's MCP configuration. Use the global configuration if you want to connect to multiple projects; the file location depends on the client. + +```json +{ + "mcpServers": { + "vue-devtools": { + "command": "npx", + "args": ["-y", "@vue/devtools-agent", "connect"] + } + } +} +``` + +The connector discovers running projects automatically, so you do not need to configure a port for each one. + +### 3. Ask about your app + +Try a request such as: + +> Find the component that renders the user profile and inspect its current props and setup state. + +The agent finds the running project, identifies the browser page, and queries its Vue tools. If you have several projects or pages open, include the project name or page URL in your request. + +## Inspect the running app + +An agent starts with these calls: + +1. Call `devframe_connect_list-instances` and select the project by `rootDir`. +2. Find the page's Vue tools and call `list-apps` to get its URL and app IDs. +3. Call `component-tree` to find a component, then `component-state` to read its state. + +Page tools are called through `devframe_connect_call-tool`. Use the port and exact tool name returned by discovery. An options object is passed as `args.arg0`: + +```json +{ + "port": 5173, + "tool": "", + "args": { + "arg0": { + "appId": "", + "componentId": "" + } + } +} +``` + +Each browser document has its own tools. Use `list-apps` to identify the page before inspecting it. After a reload, discover its tools again: the previous tool namespace, component IDs, and value handles are no longer valid. + +### Available tools + +| Task | Tools | +| ------------------------------------------ | ----------------------------------------------------------------------------- | +| Identify apps and check runtime health | `list-apps`, `list-plugins`, `runtime-health` | +| Browse the component tree | `component-tree`, `component-children`, `cancel-component-children` | +| Read component state and render code | `component-state`, `component-value`, `expand-value`, `component-render-code` | +| Inspect Pinia stores and other plugin data | `list-inspectors`, `inspector-tree`, `inspector-state`, `inspector-value` | +| Check routes | `router-state`, `router-matches` | +| Edit a state field | `edit-component-state`, `edit-inspector-state` | + +Tools that target an app require an `appId`. Each tool declares an output schema. Successful calls return `{ page, capturedAt, result }`; failed calls use the MCP tool error channel. + +The component tree loads incrementally. Without a filter, `component-tree` returns the initially expanded nodes. With a filter, it searches the whole app by component name or source file and returns up to 100 matches. To walk the tree without a filter, use `component-children` and pass the returned cursor to read the next page. Cancel unfinished reads with `cancel-component-children`. If a cursor expires, restart `component-children` without it. + +Pinia stores and other plugin data are available through **Inspectors**. Call `list-inspectors`, find a node with `inspector-tree`, then read it with `inspector-state`. + +For routing, `page.url` tracks the browser's URL, while `router-state` reports Vue Router's current route and registered routes. Use `router-matches` to check which route records match a path without navigating. + +## Read a specific field + +Use `component-state` for an overview and `component-value` when you know the field path. A direct field read does not require a previous snapshot or value handle and can reach fields outside the current page. + +For example, to read `user.name` from a component's setup state, pass this options object to `component-value` as `args.arg0`: + +```json +{ + "appId": "", + "componentId": "", + "sectionId": "setup", + "path": ["user", "name"] +} +``` + +Get the section ID from `component-state`. Built-in sections include `props`, `data`, and `setup`. Use strings for array indexes, such as `["users", "0", "name"]`. + +For plugin state, use `inspector-value` with `appId`, `inspectorId`, `nodeId`, `sectionId`, and `path`. The path starts with the state entry's key. The plugin's state hook still runs, but only the requested value is encoded. + +Both tools return `found: false` if the target or path is missing. An existing field whose value is `null` or `undefined` returns `found: true`, with the value's type preserved by the Vue DevTools codec. + +## Edit a field + +Editing uses the same Kit commands and plugin hooks as the Vue DevTools panel: + +1. Read the field with `component-value` or `inspector-value` and check that `found` and `editable` are both `true`. +2. Call `edit-component-state` or `edit-inspector-state` with the same target and path, plus a JSON `value`. +3. Check `result.state` for the value read back after the edit. + +For example, pass the following options to `edit-component-state` to update `user.name`: + +```json +{ + "appId": "", + "componentId": "", + "sectionId": "setup", + "path": ["user", "name"], + "value": "Ada" +} +``` + +A `status: 1` response acknowledges the command; it does not guarantee that the requested value was retained. A plugin may transform the value or leave it unchanged, and a parent render may overwrite an edited prop. Check the returned state to see the actual result. + +These tools edit existing fields. They cannot delete or rename fields, navigate, or invoke arbitrary plugin actions. Component field tools work with built-in state sections; access custom plugin state through its Inspector. + +If an edit encounters a connection or readback error, read the field before retrying. The change may already have been applied. + +## Read all state with pagination + +`component-state` and `inspector-state` return the first page of fields, grouped by section. To read everything, follow `pagination.next` until it is `null`: + +```json +{ + "snapshotId": "state-page:1", + "pagination": { + "total": 126, + "current": 1, + "next": 2 + }, + "sections": [] +} +``` + +This example shows the pagination metadata; actual responses include the page's entries in `sections`. `total` counts fields across all sections, not pages. `current` and `next` are page numbers starting at 1. + +For the next call, keep the original query parameters, pass back `snapshotId`, and set `page` to the returned `next`. For example, the next `component-state` call uses: + +```json +{ + "appId": "", + "componentId": "", + "page": 2, + "snapshotId": "state-page:1" +} +``` + +Pages can contain different numbers of entries. Always follow `next` to determine whether more data is available. A section's `partial` flag means this page contains only part of that section; it does not mean the remaining fields are inaccessible. + +The same pagination parameters work with `expand-value`. For `component-value` and `inspector-value`, omit them for a compact preview, or pass `page: 1` to start paging through the selected value. Collections return a page of preview entries. Strings return text segments: `total` counts UTF-16 code units, and concatenating the segments reconstructs the full string. + +A batch keeps the original field order, but it is not an atomic snapshot of the application. Object properties can change as they are read. For `inspector-state`, the plugin hook runs when the batch starts. Component reactivity graphs, when available, appear on the first state page. Read pages in order; retrying the most recent page returns the same result. To start over, omit `snapshotId` and request page 1. Batches are retained temporarily; an expired ID returns instructions to restart. + +### Value handles + +A **value handle** is a temporary reference to an object or array. Pass it to `expand-value`, optionally with a property path, to read details omitted from a preview. A missing path produces an error; an existing `undefined` value is returned with its codec tag. + +Handles can expire when the same component or Inspector node is read again, including by the DevTools panel, or when the runtime releases retained values. Keeping the connection open does not keep handles valid. To recover, start a fresh state query and use its new handles, or read a known field path directly. After a page reload, rediscover the page tools as well. + +## Troubleshooting + +| Problem | What to do | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | +| Project not found | Check that the dev server is running, or specify its port as shown below. | +| Page tools missing | Open the app in a browser and allow the Vite DevTools connection. Rediscover tools after a reload. | +| Field returns `found: false` | Check the target IDs, section ID, and field path. | +| Field is read-only | Choose a field marked `editable: true`. | +| Value handle expired | Read the state again to get a new handle, or use a field path. | +| Response is too large | Read a narrower field path if a single entry is too large. | +| Edit result is uncertain | Read the current value before retrying. | + +To find a project by port: + +```sh +npx @vue/devtools-agent connect --port 5173 --base /__devtools/ +``` + +Repeat `--port` or pass a comma-separated list, such as `--port 5173,5174`, to probe several ports. + +## Connection and access + +Vite DevTools manages discovery, the MCP endpoint, and authentication. The default host accepts callers on the same machine. The connector sends a bearer token when `DEVFRAME_MCP_AUTH_TOKEN` is set. See [Devframe MCP](https://devfra.me/adapters/mcp) for host configuration. + +The Vue runtime connection opens on the first query and stays open until the page's tools are unregistered. Discovery alone does not open it. + +Connected agents can read application state, including any private data it contains. As in the Vue DevTools panel, reading state may invoke application getters and plugin hooks. diff --git a/package.json b/package.json index 37a64b26f..834084bfb 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "test:integration": "vitest run tests/integration", "test:performance": "vitest run --config vitest.performance.config.ts", "test:smoke:chrome": "pnpm --filter @vue/devtools-kit build && pnpm --filter @vue/devtools-client build && pnpm --filter @vue/devtools-chrome build && vitest run --config vitest.smoke.config.ts", - "test:smoke:vite": "pnpm --filter @vue/devtools-kit build && pnpm --filter @vue/devtools-client build && pnpm --filter vite-plugin-vue-devtools build && vitest run --config vitest.smoke.config.ts tests/smoke/vite-client-injection.test.ts", + "test:smoke:vite": "pnpm --filter @vue/devtools-kit build && pnpm --filter @vue/devtools-client build && pnpm --filter @vue/devtools-agent build && pnpm --filter vite-plugin-vue-devtools build && vitest run --config vitest.smoke.config.ts tests/smoke/vite-client-injection.test.ts tests/smoke/agent.test.ts", "test:unit": "vitest run tests/unit", "test:watch": "vitest", "typecheck": "vue-tsc --noEmit -p packages/client/tsconfig.json", @@ -50,6 +50,7 @@ }, "devDependencies": { "@clack/prompts": "catalog:", + "@modelcontextprotocol/client": "catalog:", "@types/chrome": "catalog:", "@types/node": "catalog:", "@vitejs/devtools": "catalog:", diff --git a/packages/agent/LICENSE b/packages/agent/LICENSE new file mode 100644 index 000000000..102207d37 --- /dev/null +++ b/packages/agent/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 webfansplz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/agent/README.md b/packages/agent/README.md new file mode 100644 index 000000000..3fbb6c1c7 --- /dev/null +++ b/packages/agent/README.md @@ -0,0 +1,181 @@ +# @vue/devtools-agent + +Let AI agents inspect a running Vue application through MCP: find components, read state, edit individual fields, inspect Pinia stores, and check routes. + +The Vite integration connects agents to the same runtime used by Vue DevTools. Open your app in a browser; the Vue DevTools panel can stay closed. Inspection tools are read-only. Hosts with a Kit command connection also expose explicit actions for editing component and Inspector fields. + +## Get started + +### 1. Enable Vue DevTools in your app + +Use `vite-plugin-vue-devtools` alongside `@vitejs/devtools` and Vite 8.3 or later. Add the following to your existing Vite configuration: + +```ts +import { defineConfig } from 'vite' +import vueDevTools from 'vite-plugin-vue-devtools' + +export default defineConfig({ + devtools: { apply: 'serve' }, + plugins: [vueDevTools()], +}) +``` + +Start the dev server, open the app in your browser, and allow the Vite DevTools connection when prompted. Vue's agent tools register automatically when the host exposes MCP. Setting `devtools.mcp` to `false` disables MCP access. + +### 2. Connect your agent + +Add this entry to your agent client's MCP configuration. Use its global configuration to make the connector available across projects; the configuration file location depends on the client. + +```json +{ + "mcpServers": { + "vue-devtools": { + "command": "npx", + "args": ["-y", "@vue/devtools-agent", "connect"] + } + } +} +``` + +The CLI uses Devframe's connector to discover running projects automatically. You do not need to configure a fixed port for each project. + +### 3. Inspect the running app + +An agent follows this sequence: + +1. Call `devframe_connect_list-instances` and select the project by its `rootDir`. +2. Find the page's Vue tools in that instance's tool list. Call `list-apps` to check the page URL and get an app ID. +3. Call `component-tree` to get component IDs, then `component-state` to inspect a component. Use `expand-value` when you need more detail about an object or array. + +Invoke page tools through `devframe_connect_call-tool`, using the port and exact tool name returned by discovery. Tools that take an options object expose it as `arg0`. For example: + +```json +{ + "port": 5173, + "tool": "", + "args": { + "arg0": { + "appId": "", + "componentId": "" + } + } +} +``` + +Each browser document has its own `vue-devtools:agent:` namespace. If several pages are open, use `list-apps` to identify the one you want. After a reload, discover its tools again before making further calls. + +## Available tools + +| Task | Tools | +| --------------------------------------- | ----------------------------------------------------------------------------- | +| Identify the app and check the runtime | `list-apps`, `list-plugins`, `runtime-health` | +| Explore components | `component-tree`, `component-children`, `cancel-component-children` | +| Inspect component state and render code | `component-state`, `component-value`, `expand-value`, `component-render-code` | +| Read Pinia stores and other plugin data | `list-inspectors`, `inspector-tree`, `inspector-state`, `inspector-value` | +| Inspect routing without navigating | `router-state`, `router-matches` | +| Edit an existing field | `edit-component-state`, `edit-inspector-state` | + +App-specific tools require an explicit `appId`. Every tool declares an output schema. Successful calls return `{ page, capturedAt, result }`; rejected calls use the MCP tool error channel. Field reads return `found: false` for a missing target or path. A present null or undefined value instead has `found: true` and a tagged codec value. The page URL follows browser navigation; use `router-state` for Vue Router's current route and registered routes. + +The component tree loads incrementally. Without a filter, `component-tree` returns the initially expanded nodes. With a filter, it searches the whole app by component name or source file and returns up to 100 matches. Use `component-children` to read more nodes, pass its cursor to continue, and cancel unfinished reads with `cancel-component-children`. + +## Read all state with pagination + +`component-state` and `inspector-state` return the first page of fields, grouped by section. To read everything, follow `pagination.next` until it is `null`: + +```json +{ + "snapshotId": "state-page:1", + "pagination": { + "total": 126, + "current": 1, + "next": 2 + }, + "sections": [] +} +``` + +This example shows the pagination metadata; actual responses include the page's entries in `sections`. `total` counts fields across all sections, not pages. `current` and `next` are page numbers starting at 1. + +For the next call, keep the original query parameters, pass back `snapshotId`, and set `page` to the returned `next`. For example, the next `component-state` call uses: + +```json +{ + "appId": "", + "componentId": "", + "page": 2, + "snapshotId": "state-page:1" +} +``` + +Pages can contain different numbers of entries. Always follow `next` to determine whether more data is available. A section's `partial` flag means this page contains only part of that section; it does not mean the remaining fields are inaccessible. + +The same pagination parameters work with `expand-value`. For `component-value` and `inspector-value`, omit them for a compact preview, or pass `page: 1` to start paging through the selected value. Collections return a page of preview entries. Strings return text segments: `total` counts UTF-16 code units, and concatenating the segments reconstructs the full string. + +A batch keeps the original field order, but it is not an atomic snapshot of the application. Object properties can change as they are read. For `inspector-state`, the plugin hook runs when the batch starts. Component reactivity graphs, when available, appear on the first state page. Read pages in order; retrying the most recent page returns the same result. To start over, omit `snapshotId` and request page 1. Batches are retained temporarily; an expired ID returns instructions to restart. + +### Value handles + +A **value handle** is a temporary reference to an object or array. Pass it to `expand-value`, optionally with a property path, to read details omitted from a preview. A missing path produces an error; an existing `undefined` value is returned with its codec tag. + +Handles can expire when the same component or Inspector node is read again, including by the DevTools panel, or when the runtime releases retained values. Keeping the connection open does not keep handles valid. To recover, start a fresh state query and use its new handles, or read a known field path directly. After a page reload, rediscover the page tools as well. + +## Read or edit a specific field + +For a known component field, call `component-value` with its `appId`, `componentId`, `sectionId`, and `path`. Built-in sections include `props`, `data`, and `setup`. Array indexes are strings. This reads the live value without requiring a previous handle: + +```json +{ + "appId": "app:0", + "componentId": "app:0:12", + "sectionId": "setup", + "path": ["user", "name"] +} +``` + +For plugin state such as Pinia, discover the Inspector and node first, then use `inspector-value` with `inspectorId`, `nodeId`, `sectionId`, and `path`. The path starts with the Inspector entry key. Kit still invokes the plugin's state hook, but encodes only the requested value. Both field tools return compact previews for objects and arrays. + +To change a field: + +1. Read it with `component-value` or `inspector-value`. Check `found` and `editable`. +2. Call `edit-component-state` or `edit-inspector-state` with the same target and a JSON `value`. +3. Inspect the returned `result.state`, which contains the value read after the command. `status: 1` acknowledges the command; a plugin may normalize the value or leave it unchanged. + +Editing uses the same Kit commands and plugin hooks as the panel. These tools change existing fields; they do not delete or rename fields, navigate, or invoke arbitrary plugin actions. Props may be overwritten by a parent render. Component field tools target built-in state sections; custom plugin state is accessed through its Inspector. + +| Situation | Next step | +| -------------------------------- | --------------------------------------------------------------------- | +| `found: false` | Check the target IDs and field path; rediscover tools after a reload | +| Read-only field | Choose a field marked `editable: true` | +| Expired handle | Read the original state again, or use a field path directly | +| Response exceeds the byte budget | Read a narrower field path if a single entry is too large | +| Edit transport or readback error | Read the current state before retrying; the edit may already have run | + +## Connection and access + +Vite DevTools manages the MCP endpoint, discovery, and authentication. The connector sends a bearer token only when `DEVFRAME_MCP_AUTH_TOKEN` is set. Vite DevTools accepts local callers by default; set that variable if your host configuration requires a token. + +If automatic discovery cannot find your project, probe its port explicitly: + +```sh +npx @vue/devtools-agent connect --port 5173 --base /__devtools/ +``` + +To probe several ports, repeat `--port` or pass a comma-separated list, such as `--port 5173,5174`. The base path points the probe to Vite DevTools' connection metadata. See [Devframe MCP](https://devfra.me/adapters/mcp) for endpoint and authentication configuration. + +Discovery alone does not open a Vue runtime connection. The Vite adapter connects on the first query and keeps that connection until the page's tool registration is disposed. Keeping it open does not guarantee that individual value handles remain valid. + +Connected agents can read application state, including private data stored in it. Reads use the same getters and plugin hooks as the Vue DevTools UI, so inspecting a value may execute application code. The Vite integration runs during development. + +## Integrate another host + +The tool definitions are independent of Vite. To expose them in another host, supply a Kit connection and a unique ID for each browser document. + +| Export | Entry point | Purpose | +| ------------------------------------------------ | ------------------------------ | ----------------------------------------------------------------------------------- | +| `createVueDevtoolsAgentTools(connection, page)` | `@vue/devtools-agent` | Create tool definitions for a page | +| `createVueDevtoolsAgentSession(connect)` | `@vue/devtools-agent` | Open a connection lazily and manage its lifetime | +| `registerVueDevtoolsAgentPage(connection, page)` | `@vue/devtools-agent/devframe` | Register page tools with Devframe | +| `registerVueDevtoolsAgentHost(ctx)` | `@vue/devtools-agent/node` | Register a help tool so the host's MCP endpoint is available before a page connects | + +The connection supplies Kit's typed `query` method; `command` enables editing actions and cursor cleanup. Hosts that supply only `query` do not register editing actions. Disposing a page registration removes its tools. The host remains responsible for disposing the connection. diff --git a/packages/agent/package.json b/packages/agent/package.json new file mode 100644 index 000000000..491a0b222 --- /dev/null +++ b/packages/agent/package.json @@ -0,0 +1,60 @@ +{ + "name": "@vue/devtools-agent", + "version": "9.0.0-beta.1", + "description": "Agent tools and host adapters for Vue DevTools", + "license": "MIT", + "author": "webfansplz", + "repository": { + "type": "git", + "url": "git+https://github.com/vuejs/devtools.git", + "directory": "packages/agent" + }, + "bin": { + "vue-devtools-agent": "./dist/cli.mjs" + }, + "files": [ + "dist", + "LICENSE", + "README.md" + ], + "type": "module", + "sideEffects": [ + "./dist/cli.mjs" + ], + "main": "./dist/index.mjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "./package.json": "./package.json", + "./devframe": { + "types": "./dist/devframe.d.mts", + "default": "./dist/devframe.mjs" + }, + "./node": { + "types": "./dist/node.d.mts", + "default": "./dist/node.mjs" + } + }, + "scripts": { + "build": "tsdown", + "dev": "tsdown --watch" + }, + "dependencies": { + "@devframes/agentic": "catalog:", + "@vue/devtools-kit": "workspace:*", + "cac": "catalog:", + "devframe": "catalog:", + "safe-stable-stringify": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "tsdown": "catalog:" + }, + "engines": { + "node": ">=22.12.0" + } +} diff --git a/packages/agent/src/cli.ts b/packages/agent/src/cli.ts new file mode 100644 index 000000000..33020601b --- /dev/null +++ b/packages/agent/src/cli.ts @@ -0,0 +1,48 @@ +#!/usr/bin/env node +import process from 'node:process' +import { cac } from 'cac' + +async function main() { + const cli = cac('vue-devtools-agent') + cli + .command('connect', 'Discover running Devframe projects and expose their tools over stdio MCP') + .option('--port ', 'Probe an additional port (repeatable or comma-separated)') + .option('--base ', 'Base path for explicit port probes', { default: '/' }) + .option('--instances-dir ', 'Override the Devframe instance registry directory') + .option('--timeout ', 'Probe timeout per instance in milliseconds', { default: '1000' }) + .action(async (options) => { + const ports = [options.port ?? []] + .flat() + .flatMap((value: string | number) => String(value).split(',')) + .filter((value) => value.trim() !== '') + .map(Number) + if (ports.some((port) => !Number.isInteger(port) || port < 1 || port > 65535)) { + throw new Error('--port must be an integer between 1 and 65535') + } + const timeoutMs = Number(options.timeout) + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new Error('--timeout must be a positive number of milliseconds') + } + const { startConnectServer } = await import('@devframes/agentic/connect') + await startConnectServer({ + ports, + base: options.base, + instancesDir: options.instancesDir, + timeoutMs, + authToken: process.env.DEVFRAME_MCP_AUTH_TOKEN, + }) + process.stdin.resume() + }) + cli.help() + cli.parse(process.argv, { run: false }) + if (!cli.matchedCommand) { + if (!cli.options.help) cli.outputHelp() + return + } + await cli.runMatchedCommand() +} + +void main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 +}) diff --git a/packages/agent/src/devframe.ts b/packages/agent/src/devframe.ts new file mode 100644 index 000000000..e1d9be26f --- /dev/null +++ b/packages/agent/src/devframe.ts @@ -0,0 +1,2 @@ +export { registerVueDevtoolsAgentPage } from './mcp/page' +export { createVueDevtoolsAgentSession, type VueDevtoolsAgentSession } from './session' diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts new file mode 100644 index 000000000..aeff8d342 --- /dev/null +++ b/packages/agent/src/index.ts @@ -0,0 +1,7 @@ +export { createVueDevtoolsAgentTools } from './mcp/tools' +export { createVueDevtoolsAgentSession, type VueDevtoolsAgentSession } from './session' +export type { + VueDevtoolsAgentConnection, + VueDevtoolsAgentPage, + VueDevtoolsAgentSnapshot, +} from './types' diff --git a/packages/agent/src/mcp/host.ts b/packages/agent/src/mcp/host.ts new file mode 100644 index 000000000..0784165fc --- /dev/null +++ b/packages/agent/src/mcp/host.ts @@ -0,0 +1,16 @@ +import type { DevframeNodeContext } from 'devframe/types' + +/** Prime the host agent surface so its automatic MCP route exists before the page connects. */ +export function registerVueDevtoolsAgentHost(ctx: Pick): () => void { + const handle = ctx.agent.registerTool({ + id: 'vue-devtools:agent:help', + description: + 'Explain how to discover Vue runtime tools. Call if Vue page tools are missing or to understand page selection.', + safety: 'read', + handler: () => ({ + instructions: + 'Open the application in a browser and allow the Vite DevTools connection. List tools again to discover page-specific Vue tools. Each page has a unique namespace; use list-apps to identify its URL and app IDs before querying. Reloading the page invalidates its old namespace, component IDs and value handles. Paging rules are in each paginated tool description.', + }), + }) + return () => handle.unregister() +} diff --git a/packages/agent/src/mcp/page.ts b/packages/agent/src/mcp/page.ts new file mode 100644 index 000000000..4876aaa04 --- /dev/null +++ b/packages/agent/src/mcp/page.ts @@ -0,0 +1,15 @@ +import { createPageScriptChannel } from 'devframe/in-page-channel' +import { createVueDevtoolsAgentTools } from './tools' +import type { VueDevtoolsAgentConnection, VueDevtoolsAgentPage } from '../types' + +/** Register inspection and optional editing tools in Devframe's browser registry, using a unique document namespace. */ +export function registerVueDevtoolsAgentPage( + connection: VueDevtoolsAgentConnection, + page: VueDevtoolsAgentPage, +): () => void { + const channel = createPageScriptChannel({ + name: `vue-devtools:agent:${page.id}`, + functions: createVueDevtoolsAgentTools(connection, page), + }) + return () => channel.close() +} diff --git a/packages/agent/src/mcp/schemas.ts b/packages/agent/src/mcp/schemas.ts new file mode 100644 index 000000000..098bf8043 --- /dev/null +++ b/packages/agent/src/mcp/schemas.ts @@ -0,0 +1,203 @@ +import { z } from 'zod' + +const jsonRecord = z.record(z.string(), z.json()) +// Preserve codec tags: a present undefined/null value is not a missing field. +const encodedValue: z.ZodType = z.lazy(() => + z.union([ + z.object({ kind: z.enum(['null', 'undefined']) }), + z.object({ kind: z.literal('boolean'), value: z.boolean() }), + z.object({ + kind: z.literal('number'), + value: z.union([z.number(), z.enum(['NaN', 'Infinity', '-Infinity'])]), + }), + z.object({ + kind: z.literal('string'), + value: z.string(), + truncated: z.boolean().optional(), + length: z.number().optional(), + }), + z.object({ kind: z.enum(['bigint', 'date', 'regexp']), value: z.string() }), + z.object({ kind: z.literal('symbol'), description: z.string() }), + z.object({ + kind: z.literal('function'), + name: z.string().optional(), + sourcePreview: z.string().optional(), + }), + z.object({ + kind: z.literal('array'), + length: z.number(), + preview: z.array(valueEntry), + handle: z.string().optional(), + }), + z.object({ + kind: z.literal('object'), + name: z.string(), + entries: z.number(), + preview: z.array(valueEntry), + handle: z.string().optional(), + }), + z.object({ + kind: z.enum(['map', 'set']), + size: z.number(), + preview: z.array(valueEntry), + handle: z.string().optional(), + }), + z.object({ kind: z.literal('component'), id: z.string().optional(), name: z.string() }), + z.object({ + kind: z.literal('dom'), + tag: z.string(), + id: z.string().optional(), + className: z.string().optional(), + }), + z.looseObject({ + kind: z.literal('custom'), + value: encodedValue, + handle: z.string().optional(), + readOnly: z.boolean().optional(), + }), + z.object({ + kind: z.literal('error'), + name: z.string(), + message: z.string(), + stack: z.string().optional(), + }), + z.object({ kind: z.literal('circular'), handle: z.string() }), + ]), +) +const valueEntry = z.object({ key: z.string(), value: encodedValue }) +const treeNode = z.looseObject({ + id: z.string(), + appId: z.string(), + name: z.string(), + parentId: z.string().optional(), + file: z.string().optional(), + childCount: z.number().optional(), + updatedAt: z.number(), +}) +const inspectorNode: z.ZodType = z.lazy(() => + z.looseObject({ id: z.string(), label: z.string(), children: z.array(inspectorNode).optional() }), +) +const route: z.ZodType = z.lazy(() => + z.object({ + path: z.string(), + name: z.string().optional(), + meta: jsonRecord.optional(), + children: z.array(route).optional(), + }), +) +const pagination = z.object({ + total: z.number().int().nonnegative(), + pageSize: z.number().int().positive(), + current: z.number().int().positive(), + next: z.number().int().positive().nullable(), +}) +const state = z.object({ + snapshotId: z.string(), + pagination, + componentId: z.string(), + version: z.number(), + sections: z.array( + z.object({ + id: z.string(), + label: z.string(), + partial: z.boolean().optional(), + entries: z.array( + valueEntry.extend({ + path: z.array(z.string()), + editable: z.boolean(), + meta: jsonRecord.optional(), + }), + ), + }), + ), + reactivityGraph: z + .object({ + nodes: z.array( + z.object({ id: z.string(), type: z.string(), label: z.string(), data: jsonRecord }), + ), + relationships: z.array(z.object({ id: z.string(), from: z.string(), to: z.string() })), + }) + .optional(), +}) +const stateValueSchema = z.discriminatedUnion('found', [ + z.object({ found: z.literal(false) }), + z.object({ + found: z.literal(true), + editable: z.boolean(), + value: encodedValue, + snapshotId: z.string().optional(), + pagination: pagination.optional(), + }), +]) +export const resultSchemas = { + apps: z.object({ + apps: z.array( + z.object({ + id: z.string(), + name: z.string(), + version: z.string().optional(), + componentCount: z.number(), + }), + ), + }), + plugins: z.object({ + plugins: z.array( + z.looseObject({ id: z.string(), settings: jsonRecord, settingValues: jsonRecord }), + ), + }), + health: z.object({ + status: z.literal('ready'), + performance: z.object({ + receivedEvents: z.number(), + emittedEvents: z.number(), + droppedEvents: z.number(), + coalescedEvents: z.number(), + bufferedEvents: z.number(), + bufferedBytes: z.number(), + collectionActive: z.boolean(), + attachedClients: z.number(), + }), + }), + tree: z.object({ + appId: z.string().optional(), + version: z.number().optional(), + nodes: z.array(treeNode), + }), + children: z.object({ nodes: z.array(treeNode), cursor: z.string().optional() }), + command: z.object({ status: z.union([z.literal(0), z.literal(1)]), error: z.json().optional() }), + state: state.nullable(), + inspectors: z.object({ + inspectors: z.array(z.looseObject({ id: z.string(), label: z.string() })), + }), + inspectorTree: z.object({ inspectorId: z.string(), rootNodes: z.array(inspectorNode) }), + renderCode: z.string().nullable(), + router: z.object({ + appId: z.string().optional(), + currentRoute: z + .looseObject({ + path: z.string().optional(), + fullPath: z.string().optional(), + matched: z.array(route).optional(), + }) + .optional(), + routes: z.array(route), + }), + matches: z.object({ appId: z.string().optional(), path: z.string(), routes: z.array(route) }), + expanded: z.object({ + handle: z.string(), + path: z.array(z.string()), + value: encodedValue, + snapshotId: z.string(), + pagination, + }), + stateValue: stateValueSchema, + edited: z.object({ status: z.literal(1), state: stateValueSchema }), +} + +export function snapshotSchema(result: z.ZodType) { + return z.object({ + page: z.object({ id: z.string(), url: z.string(), title: z.string() }), + capturedAt: z.number(), + result, + }) +} diff --git a/packages/agent/src/mcp/tools.ts b/packages/agent/src/mcp/tools.ts new file mode 100644 index 000000000..84d829438 --- /dev/null +++ b/packages/agent/src/mcp/tools.ts @@ -0,0 +1,294 @@ +import type { RuntimeCommandRequest, StateValueMessage } from '@vue/devtools-kit' +import { z } from 'zod' +import { resultSchemas, snapshotSchema } from './schemas' +import { configure } from 'safe-stable-stringify' +import type { + VueDevtoolsAgentConnection, + VueDevtoolsAgentPage, + VueDevtoolsAgentSnapshot, +} from '../types' + +const stringify = configure({ bigint: false, circularValue: '[Circular]', deterministic: false }) + +export function createVueDevtoolsAgentTools( + connection: VueDevtoolsAgentConnection, + page: VueDevtoolsAgentPage, +) { + const appId = z.string().min(1) + const target = z.strictObject({ appId }) + const component = target.extend({ componentId: z.string().min(1) }) + const inspector = target.extend({ inspectorId: z.string().min(1) }) + const pagination = { + page: z.number().int().min(1).default(1), + pageSize: z.number().int().min(1).max(500).default(50), + snapshotId: z.string().min(1).optional(), + } + const valuePagination = { + page: z.number().int().min(1).optional(), + pageSize: z.number().int().min(1).max(5000).optional(), + snapshotId: pagination.snapshotId, + } + // Inline so the protocol reaches agents on hosts that never register the help tool. + const pagingGuide = + ' Paging: start at page 1, then pass the returned snapshotId and pagination.next as page with the same target and pageSize until next is null. Pages may hold fewer entries to fit the response budget. Restart without snapshotId if it expires.' + const previewNote = ' Objects and arrays preview 30 entries.' + const handleNote = ' Handles expire when the same target is read again or the page reloads.' + + function tool( + description: string, + schema: S, + handler: (input: z.output) => Promise, + resultSchema: z.ZodType, + kind: 'query' | 'action' = 'query', + ) { + return { + type: kind, + jsonSerializable: true as const, + args: [schema] as [S], + returns: snapshotSchema(resultSchema), + agent: { description, safety: kind === 'query' ? ('read' as const) : ('action' as const) }, + async handler(input: z.input) { + const result = await handler(schema.parse(input)) + return toSnapshot(page, result) + }, + } + } + + function noArgTool( + description: string, + handler: () => Promise, + resultSchema: z.ZodType, + ) { + const definition = tool(description, z.strictObject({}), handler, resultSchema) + return { ...definition, args: [], handler: () => definition.handler({}) } + } + + const field = z.object({ + sectionId: z.string().min(1), + path: z + .array( + z + .string() + .refine( + (segment) => !['__proto__', 'prototype', 'constructor'].includes(segment), + 'Unsafe state path', + ), + ) + .min(1) + .max(20), + }) + const componentField = component.extend(field.shape) + const inspectorField = inspector.extend({ nodeId: z.string().min(1), ...field.shape }) + + async function editField(command: RuntimeCommandRequest, read: () => Promise) { + if (!connection.command) throw new Error('This host does not support state editing') + // requireEditable makes the runtime reject missing or read-only paths before any plugin hook runs. + const result = await connection.command({ + ...command, + payload: { ...command.payload, requireEditable: true }, + } as RuntimeCommandRequest) + if (result.status !== 1) { + const reason = stringify(result.error) ?? 'the runtime rejected the edit' + throw new Error( + reason.includes('not found') + ? 'State path was not found. Read the current component or inspector state and correct the path before editing.' + : reason.includes('read-only') + ? 'State field is read-only. Choose an editable field from the current state.' + : `State edit failed: ${reason}`, + ) + } + try { + return { status: 1 as const, state: await read() } + } catch (error) { + throw new Error( + `The edit command completed, but reading back the value failed. Inspect the state before retrying the edit. ${String(error)}`, + ) + } + } + + const listApps = noArgTool( + 'Identify this page and list its Vue apps. Call before inspecting an app. IDs and value handles belong to this document and expire on reload. page.url follows the document location; use router-state for the Vue route.', + () => connection.query({ type: 'apps:snapshot' }), + resultSchemas.apps, + ) + const listPlugins = noArgTool( + 'List Vue DevTools plugins registered on this page, including their settings.', + () => connection.query({ type: 'plugins:snapshot' }), + resultSchemas.plugins, + ) + const runtimeHealth = noArgTool( + 'Read whether the Vue runtime is collecting and a small performance snapshot. Use this when component data looks empty or stalled.', + () => connection.query({ type: 'runtime:health' }), + resultSchemas.health, + ) + + return { + 'list-apps': listApps, + 'list-plugins': listPlugins, + 'runtime-health': runtimeHealth, + 'component-tree': tool( + 'List the initially expanded Vue component tree and source files. With filter, search every component in the app by name or source file (up to 100 matches, each with its parentId) instead. The tree can be partial; use component-children for childCount and cursors. If a cursor expires, restart component-children for that component.', + target.extend({ filter: z.string().max(200).optional() }), + ({ appId, filter }) => + filter + ? connection.query({ type: 'components:search', appId, payload: { filter } }) + : connection.query({ type: 'components:treeSnapshot', appId, payload: {} }), + resultSchemas.tree, + ), + 'component-children': tool( + 'Read one page of a component’s children. Pass the returned cursor to read the next page in the same app and document. If a cursor expires, restart without it. Use cancel-component-children to drop a cursor you will not finish.', + component.extend({ cursor: z.string().min(1).optional() }), + ({ appId, ...payload }) => + connection.query({ type: 'components:treeChildren', appId, payload }), + resultSchemas.children, + ), + 'cancel-component-children': tool( + 'Drop a component-children cursor that will not be read again.', + target.extend({ cursor: z.string().min(1) }), + ({ appId, cursor }) => { + if (!connection.command) throw new Error('This host cannot cancel component expansion') + return connection.command({ + type: 'components:cancelTreeChildren', + appId, + payload: { cursor }, + }) + }, + resultSchemas.command, + ), + 'component-state': tool( + 'Read a page of component state, grouped by section. pagination.total counts top-level fields across all sections. Use expand-value or component-value for nested contents. A null result means the component was not found.' + + previewNote + + pagingGuide, + component.extend(pagination), + ({ appId, ...payload }) => + connection.query({ type: 'components:stateSnapshot', appId, payload }), + resultSchemas.state, + ), + 'list-inspectors': tool( + 'List plugin inspectors, including Pinia when registered. Use inspector-tree and inspector-state to inspect stores or other plugin data.', + target, + ({ appId }) => connection.query({ type: 'inspectors:list', appId }), + resultSchemas.inspectors, + ), + 'inspector-tree': tool( + 'Find nodes in a plugin inspector, such as Pinia stores. Use inspector IDs from list-inspectors. filter is interpreted by that plugin, not as a plain name search.', + inspector.extend({ filter: z.string().max(200).optional() }), + ({ appId, ...payload }) => + connection.query({ type: 'inspectors:treeSnapshot', appId, payload }), + resultSchemas.inspectorTree, + ), + 'inspector-state': tool( + 'Read a page of Inspector state, such as a Pinia store, grouped by section. pagination.total counts fields across all sections. Use node IDs from inspector-tree and inspector-value for a known path. The plugin state hook runs when starting a batch. A null result means no matching state was found.' + + previewNote + + pagingGuide, + inspector.extend({ nodeId: z.string().min(1), ...pagination }), + ({ appId, ...payload }) => + connection.query({ type: 'inspectors:stateSnapshot', appId, payload }), + resultSchemas.state, + ), + 'component-render-code': tool( + 'Read the render function source for one component, when the runtime can recover it.', + component, + ({ appId, componentId }) => + connection.query({ type: 'components:getRenderCode', appId, payload: { componentId } }), + resultSchemas.renderCode, + ), + 'router-state': tool( + 'Inspect the app’s current route and registered routes to diagnose navigation and matched components.', + target, + ({ appId }) => connection.query({ type: 'router:snapshot', appId }), + resultSchemas.router, + ), + 'router-matches': tool( + 'Match a path against the app router without navigating. Use router-state for the current route.', + target.extend({ path: z.string().min(1).max(2000) }), + ({ appId, path }) => + connection.query({ type: 'router:matchedRoutes', appId, payload: { path } }), + resultSchemas.matches, + ), + 'component-value': tool( + 'Read a known field in a component state section without a snapshot or handle, including fields outside the current page. Get sectionId from component-state (for example props, data, setup); path uses property names or array indexes as strings. found:false means the target or path is missing; a present null/undefined value has a codec tag. Pass page:1 to paginate (strings default to 5000 UTF-16 units, collections to 50 entries).' + + previewNote + + pagingGuide, + componentField.extend(valuePagination), + ({ appId, ...payload }) => + connection.query({ type: 'components:stateValue', appId, payload }), + resultSchemas.stateValue, + ), + 'inspector-value': tool( + 'Read one field through an existing plugin Inspector, including Pinia. Use inspector/node/section IDs from list-inspectors, inspector-tree and inspector-state. path starts with the state entry key, followed by nested keys. found:false means no matching target or path. The plugin getInspectorState hook still runs, but only the requested value is encoded. Pass page:1 to paginate (strings default to 5000 UTF-16 units, collections to 50 entries).' + + previewNote + + pagingGuide, + inspectorField.extend(valuePagination), + ({ appId, ...payload }) => + connection.query({ type: 'inspectors:stateValue', appId, payload }), + resultSchemas.stateValue, + ), + ...(connection.command + ? { + 'edit-component-state': tool( + 'Change one existing editable component field through the Kit edit command. First read component-value and check editable. Accepts JSON values only; no delete or rename. Props can be overwritten by the parent. Returns the actual value read after the command; status:1 is command acknowledgement, not proof that the requested value persisted. If transport/readback fails, read state before retrying; do not blindly repeat a mutation.', + componentField.extend({ value: z.json() }), + ({ appId, value, ...payload }) => + editField( + { type: 'components:editState', appId, payload: { ...payload, value } }, + () => connection.query({ type: 'components:stateValue', appId, payload }), + ), + resultSchemas.edited, + 'action', + ), + 'edit-inspector-state': tool( + 'Change one existing editable Inspector field through its plugin edit hook, including Pinia. First read inspector-value and check editable. Accepts a JSON value at the entry path; does not replace the entire store. Returns the actual value read after the command, which a plugin may transform or leave unchanged. If transport/readback fails, inspect state before retrying.', + inspectorField.extend({ value: z.json() }), + ({ appId, value, ...payload }) => + editField( + { type: 'inspectors:editState', appId, payload: { ...payload, value } }, + () => connection.query({ type: 'inspectors:stateValue', appId, payload }), + ), + resultSchemas.edited, + 'action', + ), + } + : {}), + 'expand-value': tool( + 'Expand an encoded value handle from a previous state or inspector query in this app and document. Read only the necessary path. If a handle expired, rerun the original query for a new one.' + + handleNote + + pagingGuide, + target.extend({ + handle: z.string().min(1), + path: z.array(z.string()).max(20).optional(), + ...pagination, + pageSize: z.number().int().min(1).max(5000).optional(), + }), + async ({ appId, ...payload }) => { + const result = await connection.query({ type: 'values:expand', appId, payload }) + if (result == null) { + throw new Error( + 'Value handle is expired or unknown. Rerun the original component-state or inspector-state query, then retry expand-value with the new handle. If the page reloaded, rediscover its tools first.', + ) + } + if ('found' in result && result.found === false) { + throw new Error( + 'Value path was not found. Read the current value and correct the path before expanding again.', + ) + } + return result + }, + resultSchemas.expanded, + ), + } +} + +function toSnapshot(page: VueDevtoolsAgentPage, result: unknown): VueDevtoolsAgentSnapshot { + return { + page: { id: page.id, url: page.url, title: page.title }, + capturedAt: Date.now(), + result: toJson(result) ?? null, + } +} + +// The codec output is JSON-safe; this pass only normalizes the few non-codec results +// (render code, router data, plugin settings) into plain JSON. +function toJson(value: unknown): unknown { + return JSON.parse(stringify(value) ?? 'null') +} diff --git a/packages/agent/src/node.ts b/packages/agent/src/node.ts new file mode 100644 index 000000000..51d5f1b3e --- /dev/null +++ b/packages/agent/src/node.ts @@ -0,0 +1 @@ +export { registerVueDevtoolsAgentHost } from './mcp/host' diff --git a/packages/agent/src/session.ts b/packages/agent/src/session.ts new file mode 100644 index 000000000..cd1298a64 --- /dev/null +++ b/packages/agent/src/session.ts @@ -0,0 +1,37 @@ +import type { RuntimeCommandRequest, RuntimeCommandResult } from '@vue/devtools-kit' +import type { VueDevtoolsAgentConnection } from './types' + +export interface VueDevtoolsAgentSession extends VueDevtoolsAgentConnection { + command(request: RuntimeCommandRequest): Promise + dispose(): void +} + +/** Connect on the first call and retain the connection until the host disposes it. */ +export function createVueDevtoolsAgentSession( + connect: () => VueDevtoolsAgentConnection & { dispose(): void }, +): VueDevtoolsAgentSession { + let connection: (VueDevtoolsAgentConnection & { dispose(): void }) | undefined + let disposed = false + + function getConnection() { + if (disposed) throw new Error('Vue DevTools agent session is disposed') + return (connection ??= connect()) + } + + return { + async query(request) { + return getConnection().query(request) + }, + async command(request) { + const current = getConnection() + if (!current.command) throw new Error('Vue DevTools agent session cannot send commands') + return current.command(request) + }, + dispose() { + disposed = true + const current = connection + connection = undefined + current?.dispose() + }, + } +} diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts new file mode 100644 index 000000000..dc65ed67d --- /dev/null +++ b/packages/agent/src/types.ts @@ -0,0 +1,18 @@ +import type { RuntimeRequests } from '@vue/devtools-kit' + +/** A host supplies a connection to its existing Vue runtime. `command` enables state editing and cursor cleanup. */ +export type VueDevtoolsAgentConnection = Pick & + Partial> + +export interface VueDevtoolsAgentPage { + /** Unique per document: component IDs and value handles expire on reload. */ + id: string + url: string + title: string +} + +export interface VueDevtoolsAgentSnapshot { + page: VueDevtoolsAgentPage + capturedAt: number + result: unknown +} diff --git a/packages/agent/tests/component-search.test.ts b/packages/agent/tests/component-search.test.ts new file mode 100644 index 000000000..d4af21ae7 --- /dev/null +++ b/packages/agent/tests/component-search.test.ts @@ -0,0 +1,58 @@ +// @vitest-environment happy-dom +import { afterEach, expect, it } from 'vitest' +import { createDevtoolsKit } from '../../kit/src/kit' +import type { DevtoolsKit } from '../../kit/src/kit' +import { createComponentTreeFixture } from '../../../tests/fixtures/runtime' + +let kit: DevtoolsKit | undefined +afterEach(async () => { + await kit?.dispose() + kit = undefined +}) + +async function setup() { + kit = createDevtoolsKit({ + target: { name: 'search' }, + hook: { install: false }, + budget: { components: { maxInitialDepth: 1 } }, + }) + kit.install() + const fixture = createComponentTreeFixture(30, { branching: 2 }) + kit.runtime.dispatch({ + type: 'app:init', + app: fixture.app, + time: 1, + version: '3.5.0', + vueTypes: {}, + }) + return kit +} + +it('finds deep components outside the initial tree by name', async () => { + const runtime = (await setup()).runtime + const initial = await runtime.query({ type: 'components:treeSnapshot', appId: 'app:0' }) + expect(initial.nodes.some((node) => node.name === 'FixtureComponent27')).toBe(false) + + const found = await runtime.query({ + type: 'components:search', + appId: 'app:0', + payload: { filter: 'FixtureComponent27' }, + }) + expect(found.nodes.map((node) => node.name)).toEqual(['FixtureComponent27']) +}) + +it('caps search results and matches source files case-insensitively', async () => { + const runtime = (await setup()).runtime + const byFile = await runtime.query({ + type: 'components:search', + appId: 'app:0', + payload: { filter: 'fixturecomponent2' }, + }) + expect(byFile.nodes.length).toBe(11) + const none = await runtime.query({ + type: 'components:search', + appId: 'app:0', + payload: { filter: 'no-such-thing' }, + }) + expect(none.nodes).toEqual([]) +}) diff --git a/packages/agent/tests/handles.test.ts b/packages/agent/tests/handles.test.ts new file mode 100644 index 000000000..6d13bb53b --- /dev/null +++ b/packages/agent/tests/handles.test.ts @@ -0,0 +1,61 @@ +// @vitest-environment happy-dom +import type { + ComponentTreeSnapshotMessage, + ComponentStateSnapshotMessage, +} from '../../kit/src/protocol' +import { it, expect } from 'vitest' +import { createDevtoolsRuntime } from '../../kit/src/runtime/runtime' +import { createComponentTreeFixture } from '../../../tests/fixtures/runtime' +import { createVueDevtoolsAgentTools } from '../src' + +it('reports expired handles after another reader takes a snapshot and recovers with a fresh handle', async () => { + const fixture = createComponentTreeFixture(1) + Object.assign(fixture.instances[0], { + props: { payload: { nested: { value: 42 }, absent: undefined } }, + }) + const runtime = createDevtoolsRuntime() + try { + runtime.dispatch({ + type: 'app:init', + app: fixture.app, + version: '3.5.0', + vueTypes: {}, + time: 1, + }) + const tools = createVueDevtoolsAgentTools(runtime, { id: 'p', url: '/', title: '' }) + const tree = (await tools['component-tree'].handler({ appId: 'app:0' })) + .result as ComponentTreeSnapshotMessage + const componentId = tree.nodes[0]!.id + const first = (await tools['component-state'].handler({ appId: 'app:0', componentId })) + .result as ComponentStateSnapshotMessage + const value = first.sections[0]!.entries[0]!.value + if (!('handle' in value) || !value.handle) throw new Error('Expected an object handle') + const handle = value.handle + expect((await tools['expand-value'].handler({ appId: 'app:0', handle })).result).not.toBeNull() + expect( + (await tools['expand-value'].handler({ appId: 'app:0', handle, path: ['absent'] })).result, + ).toMatchObject({ value: { kind: 'undefined' } }) + await expect( + tools['expand-value'].handler({ appId: 'app:0', handle, path: ['missing'] }), + ).rejects.toThrow('Value path was not found') + await runtime.query({ + type: 'components:stateSnapshot', + appId: 'app:0', + payload: { componentId }, + }) + await expect(tools['expand-value'].handler({ appId: 'app:0', handle })).rejects.toThrow( + 'Rerun the original component-state or inspector-state query', + ) + const fresh = (await tools['component-state'].handler({ appId: 'app:0', componentId })) + .result as ComponentStateSnapshotMessage + const freshValue = fresh.sections[0]!.entries[0]!.value + if (!('handle' in freshValue) || !freshValue.handle) + throw new Error('Expected a fresh object handle') + const freshHandle = freshValue.handle + expect( + (await tools['expand-value'].handler({ appId: 'app:0', handle: freshHandle })).result, + ).not.toBeNull() + } finally { + runtime.dispose() + } +}) diff --git a/packages/agent/tests/pagination.test.ts b/packages/agent/tests/pagination.test.ts new file mode 100644 index 000000000..baaa74a54 --- /dev/null +++ b/packages/agent/tests/pagination.test.ts @@ -0,0 +1,281 @@ +// @vitest-environment happy-dom +import { afterEach, expect, it, vi } from 'vitest' +import { createDevtoolsKit } from '../../kit/src/kit' +import type { DevtoolsKit } from '../../kit/src/kit' +import type { ComponentStateSnapshotMessage, ExpandedValueMessage } from '../../kit/src/protocol' +import { measureRuntimeMessageBytes } from '../../kit/src/rpc/event-buffer' +import { createComponentTreeFixture } from '../../../tests/fixtures/runtime' +import { createVueDevtoolsAgentTools } from '../src' + +let kit: DevtoolsKit +afterEach(async () => { + await kit?.dispose() + vi.restoreAllMocks() +}) +async function setup( + props: Record, + budget?: Parameters[0]['budget'], +) { + kit = createDevtoolsKit({ target: { name: 'pagination' }, hook: { install: false }, budget }) + kit.install() + const fixture = createComponentTreeFixture(1) + Object.assign(fixture.instances[0]!, { props }) + kit.runtime.dispatch({ + type: 'app:init', + app: fixture.app, + time: 1, + version: '3.5.0', + vueTypes: {}, + }) + const tree = await kit.runtime.query({ type: 'components:treeSnapshot', appId: 'app:0' }) + return { + fixture, + componentId: tree.nodes[0]!.id, + tools: createVueDevtoolsAgentTools(kit.runtime, { id: 'p', url: '/', title: '' }), + } +} + +it('walks every field across byte-limited pages without gaps, including plugin fields in an existing section', async () => { + const props = Object.fromEntries( + Array.from({ length: 501 }, (_, i) => [`field${i}`, '€'.repeat(10_000)]), + ) + const readFirst = vi.fn(() => '€'.repeat(10_000)) + Object.defineProperty(props, 'field0', { enumerable: true, get: readFirst }) + const { tools, componentId, fixture } = await setup(props) + await kit.plugins.register({ id: 'extra', app: fixture.app }, (api) => { + api.on.inspectComponent((payload) => { + ;(payload.instanceData as { state: unknown[] }).state.push({ + type: 'props', + key: 'extra', + value: 1, + }) + }) + }) + const target = { appId: 'app:0', componentId, pageSize: 500 } + const read = async (page: number, snapshotId?: string) => + (await tools['component-state'].handler({ ...target, page, snapshotId })) + .result as ComponentStateSnapshotMessage + let result = await read(1) + expect(readFirst).toHaveBeenCalledOnce() + const keys: string[] = [] + expect(result.pagination!.total).toBeGreaterThanOrEqual(502) + expect(result.sections[0]!.entries.length).toBeLessThan(500) + expect(await read(1, result.snapshotId)).toEqual(result) + for (;;) { + expect(measureRuntimeMessageBytes(result)).toBeLessThan(2 * 1024 * 1024) + expect( + tools['component-state'].returns.safeParse({ + page: { id: 'p', url: '/', title: '' }, + capturedAt: 1, + result, + }).success, + ).toBe(true) + keys.push( + ...result.sections + .filter((section) => section.id === 'props') + .flatMap((section) => section.entries.map((entry) => entry.key)), + ) + if (result.pagination!.next === null) break + result = await read(result.pagination!.next, result.snapshotId) + } + expect(keys).toEqual([...Object.keys(props), 'extra']) +}) + +it.each(['array', 'object', 'map', 'set'] as const)( + 'pages through nested %s values in order', + async (kind) => { + const values = Array.from({ length: 121 }, (_, i) => i) + const payload = + kind === 'array' + ? values + : kind === 'object' + ? Object.fromEntries(values.map((i) => [`key${i}`, i])) + : kind === 'map' + ? new Map(values.map((i) => [`key${i}`, i])) + : new Set(values) + const { tools, componentId } = await setup({ payload }) + const state = (await tools['component-state'].handler({ appId: 'app:0', componentId })) + .result as ComponentStateSnapshotMessage + const encoded = state.sections[0]!.entries[0]!.value + if (!('handle' in encoded) || !encoded.handle) throw new Error('Missing handle') + const target = { appId: 'app:0', handle: encoded.handle, pageSize: 50 } + let result = (await tools['expand-value'].handler(target)).result as ExpandedValueMessage + const actual: unknown[] = [] + for (;;) { + expect(result.pagination!.total).toBe(121) + if (!('preview' in result.value)) throw new Error('Missing preview') + actual.push( + ...result.value.preview.map((entry) => + entry.value.kind === 'number' ? entry.value.value : null, + ), + ) + if (!result.pagination!.next) break + result = ( + await tools['expand-value'].handler({ + ...target, + page: result.pagination!.next, + snapshotId: result.snapshotId, + }) + ).result as ExpandedValueMessage + } + expect(actual).toEqual(values) + }, +) + +it('pages Inspector state through one hook invocation per batch', async () => { + const { tools, fixture } = await setup({}) + const hook = vi.fn((payload) => { + payload.state = { store: Array.from({ length: 123 }, (_, i) => ({ key: String(i), value: i })) } + }) + await kit.plugins.register({ id: 'stores', app: fixture.app }, (api) => { + api.addInspector({ id: 'stores', label: 'Stores' }) + api.on.getInspectorState(hook) + }) + const target = { appId: 'app:0', inspectorId: 'stores', nodeId: 'counter', pageSize: 50 } + let result = (await tools['inspector-state'].handler(target)) + .result as ComponentStateSnapshotMessage + const keys: string[] = [] + for (;;) { + keys.push(...result.sections.flatMap((section) => section.entries.map((entry) => entry.key))) + if (!result.pagination!.next) break + result = ( + await tools['inspector-state'].handler({ + ...target, + page: result.pagination!.next, + snapshotId: result.snapshotId, + }) + ).result as ComponentStateSnapshotMessage + } + expect(keys).toEqual(Array.from({ length: 123 }, (_, i) => String(i))) + expect(hook).toHaveBeenCalledOnce() +}) + +it('rejects invalid continuation and expires retained batches with recovery instructions', async () => { + const { tools, componentId } = await setup( + Object.fromEntries(Array.from({ length: 110 }, (_, i) => [String(i), i])), + ) + const target = { appId: 'app:0', componentId, pageSize: 50 } + const first = (await tools['component-state'].handler(target)) + .result as ComponentStateSnapshotMessage + await expect(tools['component-state'].handler({ ...target, page: 2 })).rejects.toThrow( + 'snapshotId', + ) + await expect( + tools['component-state'].handler({ ...target, page: 3, snapshotId: first.snapshotId }), + ).rejects.toThrow('in order') + await expect( + tools['component-state'].handler({ + ...target, + page: 2, + pageSize: 20, + snapshotId: first.snapshotId, + }), + ).rejects.toThrow('parameters changed') + for (let i = 0; i < 8; i++) await tools['component-state'].handler(target) + await expect( + tools['component-state'].handler({ ...target, page: 2, snapshotId: first.snapshotId }), + ).rejects.toThrow('Restart at page 1') +}) + +it('reconstructs long strings by field path and keeps ordinary reads as previews', async () => { + const text = 'Vue € 🌍'.repeat(2000) + const { tools, componentId } = await setup({ text }) + const target = { appId: 'app:0', componentId, sectionId: 'props', path: ['text'] } + const preview = (await tools['component-value'].handler(target)).result as { + found: boolean + value: { truncated?: boolean } + pagination?: unknown + } + expect(preview.value.truncated).toBe(true) + expect(preview.pagination).toBeUndefined() + let result = (await tools['component-value'].handler({ ...target, page: 1, pageSize: 499 })) + .result as Extract + let actual = '' + for (;;) { + expect(result.pagination!.total).toBe(text.length) + if (result.value.kind !== 'string') throw new Error('Expected string segment') + actual += result.value.value + if (!result.pagination!.next) break + result = ( + await tools['component-value'].handler({ + ...target, + page: result.pagination!.next, + pageSize: 499, + snapshotId: result.snapshotId, + }) + ).result as typeof result + } + expect(actual).toBe(text) +}) + +it('retains the original object keys when the live collection changes between pages', async () => { + const payload: Record = { a: 1, b: 2, c: 3 } + const { tools, componentId } = await setup({ payload }) + const target = { appId: 'app:0', componentId, sectionId: 'props', path: ['payload'], pageSize: 1 } + const first = (await tools['component-value'].handler({ ...target, page: 1 })).result as Extract< + import('../../kit/src/protocol').StateValueMessage, + { found: true } + > + delete payload.a + payload.d = 4 + const second = ( + await tools['component-value'].handler({ ...target, page: 2, snapshotId: first.snapshotId }) + ).result as typeof first + const third = ( + await tools['component-value'].handler({ ...target, page: 3, snapshotId: first.snapshotId }) + ).result as typeof first + expect(second.value).toMatchObject({ preview: [{ key: 'b', value: { value: 2 } }] }) + expect(third.value).toMatchObject({ preview: [{ key: 'c', value: { value: 3 } }] }) + expect(third.pagination).toEqual({ total: 3, current: 3, pageSize: 1, next: null }) +}) + +it('reconstructs a long string exactly when the byte budget forces mid-string cuts', async () => { + const text = 'Vue € 🌍 '.repeat(4000) + const { tools, componentId } = await setup({ text }, { transport: { maxMessageBytes: 4 * 1024 } }) + const target = { appId: 'app:0', componentId, sectionId: 'props', path: ['text'] } + let result = (await tools['component-value'].handler({ ...target, page: 1, pageSize: 5000 })) + .result as Extract + let actual = '' + let pages = 0 + for (;;) { + pages++ + if (result.value.kind !== 'string') throw new Error('Expected string segment') + expect(measureRuntimeMessageBytes(result)).toBeLessThanOrEqual(4 * 1024) + actual += result.value.value + if (!result.pagination!.next) break + result = ( + await tools['component-value'].handler({ + ...target, + page: result.pagination!.next, + pageSize: 5000, + snapshotId: result.snapshotId, + }) + ).result as typeof result + } + expect(pages).toBeGreaterThan(1) + expect(actual).toBe(text) +}) + +it('expires a finished batch once its last page has been handed out', async () => { + const { tools, componentId } = await setup({ values: Array.from({ length: 4 }, (_, i) => i) }) + const target = { appId: 'app:0', componentId, sectionId: 'props', path: ['values'] } + const first = (await tools['component-value'].handler({ ...target, page: 1, pageSize: 2 })) + .result as { snapshotId: string; pagination: { next: number | null } } + const second = ( + await tools['component-value'].handler({ + ...target, + page: first.pagination.next!, + pageSize: 2, + snapshotId: first.snapshotId, + }) + ).result as { pagination: { next: number | null } } + expect(second.pagination.next).toBeNull() + await expect( + tools['component-value'].handler({ + ...target, + page: 3, + pageSize: 2, + snapshotId: first.snapshotId, + }), + ).rejects.toThrow(/complete|Read pages in order|expired/) +}) diff --git a/packages/agent/tests/session.test.ts b/packages/agent/tests/session.test.ts new file mode 100644 index 000000000..b2e8ec0d7 --- /dev/null +++ b/packages/agent/tests/session.test.ts @@ -0,0 +1,23 @@ +import { expect, it, vi } from 'vitest' +import { createVueDevtoolsAgentSession } from '../src/session' + +it('connects lazily, shares the connection and disposes it once', async () => { + const query = vi.fn().mockResolvedValue({ apps: [] }) + const command = vi.fn().mockResolvedValue({ status: 1 }) + const dispose = vi.fn() + const connect = vi.fn(() => ({ query, command, dispose })) + const session = createVueDevtoolsAgentSession(connect) + expect(connect).not.toHaveBeenCalled() + await Promise.all([ + session.query({ type: 'apps:snapshot' }), + session.query({ type: 'apps:snapshot' }), + session.command({ type: 'components:cancelTreeChildren', payload: { cursor: '1:1' } }), + ]) + expect(connect).toHaveBeenCalledOnce() + expect(command).toHaveBeenCalledOnce() + expect(dispose).not.toHaveBeenCalled() + session.dispose() + session.dispose() + expect(dispose).toHaveBeenCalledOnce() + await expect(session.query({ type: 'apps:snapshot' })).rejects.toThrow('disposed') +}) diff --git a/packages/agent/tests/state-fields.test.ts b/packages/agent/tests/state-fields.test.ts new file mode 100644 index 000000000..af696e09e --- /dev/null +++ b/packages/agent/tests/state-fields.test.ts @@ -0,0 +1,205 @@ +// @vitest-environment happy-dom +import { afterEach, expect, it, vi } from 'vitest' +import { computed, readonly, ref, shallowReadonly } from 'vue' +import { z } from 'zod' +import { createDevtoolsKit } from '../../kit/src/kit' +import type { DevtoolsKit } from '../../kit/src/kit' +import { createComponentTreeFixture } from '../../../tests/fixtures/runtime' +import { createVueDevtoolsAgentTools } from '../src' + +let kit: DevtoolsKit +const page = { id: 'test', url: '/', title: 'Fields' } +afterEach(async () => { + vi.restoreAllMocks() + await kit?.dispose() +}) + +async function setup() { + kit = createDevtoolsKit({ target: { name: 'fields' }, hook: { install: false } }) + kit.install() + const fixture = createComponentTreeFixture(1) + const props = Object.fromEntries(Array.from({ length: 601 }, (_, i) => [`field${i}`, i])) + Object.assign(props, { nil: null, absent: undefined }) + const unread = vi.fn(() => { + throw new Error('Do not read unrelated values') + }) + Object.defineProperty(props, 'unrelated', { enumerable: true, get: unread }) + Object.assign(fixture.instances[0]!, { + props, + setupState: { + count: ref(1), + locked: computed(() => 7), + nested: { locked: readonly({ count: 3 }) }, + }, + }) + kit.runtime.dispatch({ + type: 'app:init', + app: fixture.app, + time: 1, + version: '3.5.0', + vueTypes: {}, + }) + const tree = await kit.runtime.query({ type: 'components:treeSnapshot', appId: 'app:0' }) + return { + fixture, + unread, + componentId: tree.nodes[0]!.id, + tools: createVueDevtoolsAgentTools(kit.runtime, page), + } +} + +it('reads a field beyond 500, distinguishes missing/null/undefined and isolates app IDs', async () => { + const { tools, componentId, unread } = await setup() + const input = { appId: 'app:0', componentId, sectionId: 'props', path: ['field600'] } + const result = await tools['component-value'].handler(input) + expect(result.result).toEqual({ + found: true, + editable: true, + value: { kind: 'number', value: 600 }, + }) + expect(tools['component-value'].returns.safeParse(result).success).toBe(true) + expect(unread).not.toHaveBeenCalled() + for (const [key, kind] of [ + ['nil', 'null'], + ['absent', 'undefined'], + ]) { + expect( + (await tools['component-value'].handler({ ...input, path: [key!] })).result, + ).toMatchObject({ found: true, value: { kind } }) + } + expect((await tools['component-value'].handler({ ...input, path: ['missing'] })).result).toEqual({ + found: false, + }) + expect((await tools['component-value'].handler({ ...input, appId: 'wrong' })).result).toEqual({ + found: false, + }) +}) + +it('uses the existing component edit command, reads back refs and rejects readonly or unsafe fields', async () => { + const { tools, componentId } = await setup() + const input = { appId: 'app:0', componentId, sectionId: 'setup', path: ['count'], value: 9 } + const command = vi.spyOn(kit.runtime, 'command') + const result = await tools['edit-component-state']!.handler(input) + expect(command).toHaveBeenCalledWith({ + type: 'components:editState', + appId: 'app:0', + payload: { componentId, sectionId: 'setup', path: ['count'], value: 9, requireEditable: true }, + }) + expect(result.result).toMatchObject({ + status: 1, + state: { found: true, value: { kind: 'number', value: 9 } }, + }) + expect(tools['edit-component-state']!.returns.safeParse(result).success).toBe(true) + command.mockClear() + await expect( + tools['edit-component-state']!.handler({ ...input, path: ['locked'] }), + ).rejects.toThrow('read-only') + await expect( + tools['edit-component-state']!.handler({ ...input, path: ['nested', 'locked', 'count'] }), + ).rejects.toThrow('read-only') + await expect( + tools['edit-component-state']!.handler({ ...input, path: ['missing'] }), + ).rejects.toThrow('not found') + await expect( + tools['edit-component-state']!.handler({ ...input, path: ['__proto__', 'polluted'] }), + ).rejects.toThrow() + // The runtime rejects read-only and missing paths; the unsafe path never reaches it. + expect(command).toHaveBeenCalledTimes(3) + for (const call of command.mock.results) + await expect(call.value).resolves.toMatchObject({ status: 0 }) +}) + +it('treats nested props on a shallowReadonly props proxy as editable', async () => { + const { tools, fixture, componentId } = await setup() + const nested = { item: 1 } + const locked = readonly({ count: 3 }) + Object.assign(fixture.instances[0]!, { props: shallowReadonly({ nested, locked }) }) + const input = { appId: 'app:0', componentId, sectionId: 'props', path: ['nested', 'item'] } + expect((await tools['component-value'].handler(input)).result).toMatchObject({ + found: true, + editable: true, + value: { kind: 'number', value: 1 }, + }) + const edited = await tools['edit-component-state']!.handler({ ...input, value: 4 }) + expect(edited.result).toMatchObject({ + status: 1, + state: { found: true, editable: true, value: { kind: 'number', value: 4 } }, + }) + expect(nested.item).toBe(4) + await expect( + tools['edit-component-state']!.handler({ + ...input, + path: ['locked', 'count'], + value: 9, + }), + ).rejects.toThrow('read-only') + expect(locked.count).toBe(3) +}) + +it('reads and edits Inspector state via plugin hooks, returning the actual plugin result', async () => { + const { tools, fixture } = await setup() + const values = { count: 1, locked: 3 } + await kit.plugins.register({ id: 'fields', app: fixture.app }, (api) => { + api.addInspector({ id: 'fields', label: 'Fields' }) + api.on.getInspectorState((payload) => { + if (payload.inspectorId !== 'fields' || payload.nodeId !== 'node') return + payload.state = { + custom: [ + { key: 'value', value: values, editable: true }, + { key: 'readonly', value: 1, editable: false }, + ], + } + }) + api.on.editInspectorState((payload) => { + if (payload.inspectorId !== 'fields') return + // Simulate plugin-side normalization rather than echoing the requested value. + payload.set!({ value: values }, payload.path, Math.min(Number(payload.state.value), 10)) + }) + }) + const input = { + appId: 'app:0', + inspectorId: 'fields', + nodeId: 'node', + sectionId: 'custom', + path: ['value', 'count'], + } + expect((await tools['inspector-value'].handler(input)).result).toMatchObject({ + found: true, + value: { value: 1 }, + }) + const edited = await tools['edit-inspector-state']!.handler({ ...input, value: 99 }) + expect(edited.result).toMatchObject({ status: 1, state: { value: { value: 10 } } }) + expect(values.count).toBe(10) + expect(tools['edit-inspector-state']!.returns.safeParse(edited).success).toBe(true) + await expect( + tools['edit-inspector-state']!.handler({ ...input, path: ['readonly'], value: 5 }), + ).rejects.toThrow('read-only') + expect((await tools['inspector-value'].handler({ ...input, appId: 'wrong' })).result).toEqual({ + found: false, + }) +}) + +it('exports JSON output schemas and only advertises mutations on command-capable hosts', async () => { + const { tools, componentId } = await setup() + for (const tool of Object.values(tools)) { + expect(z.toJSONSchema(tool.returns).type).toBe('object') + } + expect(tools['edit-component-state']!.agent.safety).toBe('action') + expect(tools['edit-inspector-state']!.type).toBe('action') + const reads = createVueDevtoolsAgentTools({ query: kit.runtime.query.bind(kit.runtime) }, page) + expect(reads['edit-component-state']).toBeUndefined() + expect(reads['edit-inspector-state']).toBeUndefined() + const failure = vi + .spyOn(kit.runtime, 'command') + .mockResolvedValue({ status: 0, error: 'Rejected by plugin' }) + await expect( + tools['edit-component-state']!.handler({ + appId: 'app:0', + componentId, + sectionId: 'setup', + path: ['count'], + value: 4, + }), + ).rejects.toThrow('Rejected by plugin') + expect(failure).toHaveBeenCalledOnce() +}) diff --git a/packages/agent/tests/state-limits.test.ts b/packages/agent/tests/state-limits.test.ts new file mode 100644 index 000000000..147f2926c --- /dev/null +++ b/packages/agent/tests/state-limits.test.ts @@ -0,0 +1,116 @@ +import type { ComponentStateSnapshotMessage } from '../../kit/src/protocol' +import { expect, it, vi } from 'vitest' +import { createDevtoolsRuntime } from '../../kit/src/runtime/runtime' +import { measureRuntimeMessageBytes } from '../../kit/src/rpc/event-buffer' +import { createComponentTreeFixture } from '../../../tests/fixtures/runtime' +import { createVueDevtoolsAgentTools } from '../src' + +it.each(['array', 'object'] as const)( + 'reads 500 fields with 30-entry %s previews and expands omitted entries', + async (kind) => { + const fixture = createComponentTreeFixture(1) + const value = Array.from({ length: 500 }, (_, i) => i) + Object.assign(fixture.instances[0]!, { + props: Object.fromEntries( + Array.from({ length: 501 }, (_, i) => [ + `field${i}`, + kind === 'array' ? [...value] : Object.fromEntries(value.map((i) => [String(i), i])), + ]), + ), + }) + const runtime = createDevtoolsRuntime() + try { + runtime.dispatch({ + type: 'app:init', + app: fixture.app, + version: '3.5.0', + vueTypes: {}, + time: 1, + }) + const tree = await runtime.query({ type: 'components:treeSnapshot', appId: 'app:0' }) + const componentId = tree.nodes[0]!.id + const tools = createVueDevtoolsAgentTools(runtime, { id: 'limits', url: '/', title: '' }) + const defaults = (await tools['component-state'].handler({ appId: 'app:0', componentId })) + .result as ComponentStateSnapshotMessage + expect(defaults.sections[0]!.entries).toHaveLength(50) + const result = ( + await tools['component-state'].handler({ appId: 'app:0', componentId, pageSize: 500 }) + ).result as ComponentStateSnapshotMessage + const section = result.sections[0]! + expect(section.entries).toHaveLength(500) + expect(section.entries[499]!.key).toBe('field499') + expect(section.partial).toBe(true) + for (const entry of section.entries) { + if (!('preview' in entry.value)) throw new Error('Expected a preview') + expect(entry.value.preview).toHaveLength(30) + } + const encoded = section.entries[0]!.value + if (!('handle' in encoded) || !encoded.handle) throw new Error('Expected a handle') + const expanded = await tools['expand-value'].handler({ + appId: 'app:0', + handle: encoded.handle, + path: ['499'], + }) + expect(expanded.result).toMatchObject({ value: { kind: 'number', value: 499 } }) + expect(runtime.budget.transport.maxMessageBytes).toBe(2 * 1024 * 1024) + expect(measureRuntimeMessageBytes(result)).toBeLessThan( + runtime.budget.transport.maxMessageBytes, + ) + + // Existing UI callers that omit the preview option retain their current behavior. + const legacy = await runtime.query({ + type: 'components:stateSnapshot', + appId: 'app:0', + payload: { componentId, maxEntries: 50 }, + }) + const legacyValue = legacy!.sections[0]!.entries[0]!.value + if (!('preview' in legacyValue)) throw new Error('Expected a preview') + expect(legacyValue.preview).toHaveLength(50) + } finally { + runtime.dispose() + } + }, +) + +it('applies agent limits to plugin-added component state', async () => { + const fixture = createComponentTreeFixture(1) + const runtime = createDevtoolsRuntime() + const hook = vi.spyOn(runtime, 'callPluginHook').mockImplementation(async (name, payload) => { + if (name !== 'inspectComponent') return + const data = payload as { instanceData: { state: unknown[] } } + for (let i = 0; i < 501; i++) + data.instanceData.state.push({ + type: 'plugin', + key: String(i), + value: Array.from({ length: 100 }, (_, n) => n), + }) + }) + try { + runtime.dispatch({ + type: 'app:init', + app: fixture.app, + version: '3.5.0', + vueTypes: {}, + time: 1, + }) + const tree = await runtime.query({ type: 'components:treeSnapshot', appId: 'app:0' }) + const tools = createVueDevtoolsAgentTools(runtime, { id: 'plugin', url: '/', title: '' }) + const state = ( + await tools['component-state'].handler({ + appId: 'app:0', + componentId: tree.nodes[0]!.id, + pageSize: 500, + }) + ).result as ComponentStateSnapshotMessage + const section = state.sections.find((section) => section.id === 'plugin')! + expect(section.entries).toHaveLength(500) + expect(section.partial).toBe(true) + for (const entry of section.entries) { + if (!('preview' in entry.value)) throw new Error('Expected a preview') + expect(entry.value.preview).toHaveLength(30) + } + } finally { + hook.mockRestore() + runtime.dispose() + } +}) diff --git a/packages/agent/tests/tools.test.ts b/packages/agent/tests/tools.test.ts new file mode 100644 index 000000000..1c8dcdc48 --- /dev/null +++ b/packages/agent/tests/tools.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it, vi } from 'vitest' +import { createVueDevtoolsAgentTools } from '../src' + +const page = { id: 'document-1', url: 'http://localhost:5173/', title: 'App' } + +function setup(result?: unknown) { + const query = vi.fn().mockResolvedValue(result) + return { query, tools: createVueDevtoolsAgentTools({ query }, page) } +} + +describe('Vue agent tools', () => { + it.each([undefined, null])( + 'reports an unavailable handle (%s) with recovery instructions', + async (result) => { + const { tools } = setup(result) + await expect( + tools['expand-value'].handler({ appId: 'app', handle: 'missing' }), + ).rejects.toThrow( + 'Value handle is expired or unknown. Rerun the original component-state or inspector-state query', + ) + }, + ) + + it('reports a missing path separately from an expired handle', async () => { + const { tools } = setup({ found: false }) + await expect( + tools['expand-value'].handler({ appId: 'app', handle: 'value:1', path: ['missing'] }), + ).rejects.toThrow('Value path was not found') + }) + + it('preserves an encoded null value from a valid handle', async () => { + const result = { handle: 'value:1', path: ['optional'], value: { kind: 'null' } } + const { tools } = setup(result) + expect( + (await tools['expand-value'].handler({ appId: 'app', handle: 'value:1', path: ['optional'] })) + .result, + ).toEqual(result) + }) + + it('routes state queries to the explicit app and preserves encoded values', async () => { + const state = { sections: [{ entries: [{ key: 'secret', value: { type: 'undefined' } }] }] } + const { query, tools } = setup(state) + expect(await tools['component-state'].handler({ appId: 'app-2', componentId: '2:1' })).toEqual({ + page, + capturedAt: expect.any(Number), + result: state, + }) + expect(query).toHaveBeenCalledWith({ + type: 'components:stateSnapshot', + appId: 'app-2', + payload: { componentId: '2:1', page: 1, pageSize: 50 }, + }) + }) + + it('rejects missing app IDs, excessive reads and unknown arguments before querying', async () => { + const { query, tools } = setup() + await expect( + tools['component-state'].handler({ appId: 'app', componentId: '1', pageSize: 501 }), + ).rejects.toThrow() + await expect( + tools['router-state'].handler({ appId: 'a', command: 'components:editState' } as never), + ).rejects.toThrow() + expect(query).not.toHaveBeenCalled() + }) + + it('preserves pagination and converts missing results to JSON null', async () => { + const { query, tools } = setup() + expect( + (await tools['component-children'].handler({ appId: 'a', componentId: 'c', cursor: 'next' })) + .result, + ).toBeNull() + expect(query).toHaveBeenCalledWith({ + type: 'components:treeChildren', + appId: 'a', + payload: { componentId: 'c', cursor: 'next' }, + }) + }) + + it('exposes only read queries and distinct page identity', async () => { + const { tools } = setup({ apps: [] }) + expect( + Object.values(tools).every((tool) => tool.type === 'query' && tool.agent.safety === 'read'), + ).toBe(true) + expect((await tools['list-apps'].handler()).page.id).toBe(page.id) + }) + + it('routes a component tree filter to the app-wide search and passes matches through', async () => { + const matches = { nodes: [{ name: 'UserList', file: 'src/users/List.vue' }] } + const { query, tools } = setup(matches) + expect( + (await tools['component-tree'].handler({ appId: 'app', filter: 'LIST.vue' })).result, + ).toEqual(matches) + expect(query).toHaveBeenCalledWith({ + type: 'components:search', + appId: 'app', + payload: { filter: 'LIST.vue' }, + }) + }) + + it('routes the additional read queries and cancels an abandoned cursor', async () => { + const command = vi.fn().mockResolvedValue({ status: 1 }) + const query = vi.fn().mockResolvedValue({ ok: true }) + const tools = createVueDevtoolsAgentTools({ query, command }, page) + expect((await tools['list-plugins'].handler()).result).toEqual({ ok: true }) + expect((await tools['runtime-health'].handler()).result).toEqual({ ok: true }) + await tools['component-render-code'].handler({ appId: 'app', componentId: '2:1' }) + await tools['router-matches'].handler({ appId: 'app', path: '/users' }) + await tools['cancel-component-children'].handler({ appId: 'app', cursor: '4:1' }) + expect(query.mock.calls.map((call) => call[0].type)).toEqual([ + 'plugins:snapshot', + 'runtime:health', + 'components:getRenderCode', + 'router:matchedRoutes', + ]) + expect(command).toHaveBeenCalledWith({ + type: 'components:cancelTreeChildren', + appId: 'app', + payload: { cursor: '4:1' }, + }) + }) + + it('marks cycles and drops non-JSON values while preserving serializable data', async () => { + const cycle: Record = { name: 'App' } + cycle.self = cycle + const { tools } = setup({ + nodes: [cycle], + count: 1n, + callback: () => {}, + symbol: Symbol('value'), + values: [1, 1n, undefined, 2], + shared: cycle, + date: new Date('2026-09-30T00:00:00Z'), + }) + const snapshot = await tools['list-plugins'].handler() + expect(snapshot).toEqual({ + page, + capturedAt: expect.any(Number), + result: { + nodes: [{ name: 'App', self: '[Circular]' }], + values: [1, null, null, 2], + shared: { name: 'App', self: '[Circular]' }, + date: '2026-09-30T00:00:00.000Z', + }, + }) + }) +}) diff --git a/packages/agent/tsdown.config.ts b/packages/agent/tsdown.config.ts new file mode 100644 index 000000000..1a779f812 --- /dev/null +++ b/packages/agent/tsdown.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: ['src/index.ts', 'src/devframe.ts', 'src/node.ts', 'src/cli.ts'], + dts: true, + format: ['esm'], + sourcemap: true, + clean: true, +}) diff --git a/packages/client/src/composables/devtools-state.ts b/packages/client/src/composables/devtools-state.ts index d1e29e8d9..c73ec8eca 100644 --- a/packages/client/src/composables/devtools-state.ts +++ b/packages/client/src/composables/devtools-state.ts @@ -251,7 +251,7 @@ export function createDevtoolsState(options: DevtoolsStateOptions) { appId: selectedAppId.value, payload: { handle, maxEntries }, }) - if (!expanded) return + if (!expanded || !('value' in expanded)) return // Clearing a snapshot or selection also invalidates its in-flight expansions. if (version !== expandedValuesVersion || getEntryKey(entry) !== entryKey) return diff --git a/packages/kit/src/codec/encode.ts b/packages/kit/src/codec/encode.ts index fb6196708..a7ec19310 100644 --- a/packages/kit/src/codec/encode.ts +++ b/packages/kit/src/codec/encode.ts @@ -47,6 +47,9 @@ export type EncodedValue = | { kind: 'circular'; handle: ValueHandle } export interface ValueCodecOptions { + /** Offset within the root collection; nested previews always start at zero. */ + entryOffset?: number + entryKeys?: string[] maxDepth?: number maxEntries?: number maxStringLength?: number @@ -56,6 +59,8 @@ export interface ValueCodecOptions { } interface EncodeState { + entryKeys?: string[] + entryOffset: number depth: number seen: WeakMap handles: ValueHandleRegistry @@ -69,6 +74,8 @@ export function encodeValue(value: unknown, options: ValueCodecOptions = {}): En const handles = options.handles ?? new ValueHandleRegistry() return encode(value, { depth: 0, + entryOffset: options.entryOffset ?? 0, + entryKeys: options.entryKeys, seen: new WeakMap(), handles, handleScope: options.handleScope, @@ -147,14 +154,15 @@ function encodeUnsafe(value: unknown, state: EncodeState): EncodedValue { return { kind: 'error', name: value.name, message: value.message, stack: value.stack } if (Array.isArray(value)) { + const offset = state.entryOffset return { kind: 'array', length: value.length, preview: state.depth >= state.maxDepth ? [] - : value.slice(0, state.maxEntries).map((item, index) => ({ - key: String(index), + : value.slice(offset, offset + state.maxEntries).map((item, index) => ({ + key: String(index + offset), value: encode(item, nextState(state)), })), handle, @@ -169,8 +177,8 @@ function encodeUnsafe(value: unknown, state: EncodeState): EncodedValue { } function encodeObject(value: object, handle: ValueHandle, state: EncodeState): EncodedValue { - const keys = safeOwnEnumerableStringKeys(value) - const previewKeys = keys.slice(0, state.maxEntries) + const keys = state.entryKeys ?? safeOwnEnumerableStringKeys(value) + const previewKeys = keys.slice(state.entryOffset, state.entryOffset + state.maxEntries) return { kind: 'object', name: getObjectName(value), @@ -196,14 +204,12 @@ function encodeMap( try { size = value.size if (state.depth < state.maxDepth) { - let index = 0 for (const [key, item] of value) { - if (index >= state.maxEntries) break + if (preview.length >= state.maxEntries) break preview.push({ key: formatPreviewKey(key), value: encode(item, nextState(state)), }) - index++ } } } catch { @@ -222,10 +228,9 @@ function encodeSet(value: Set, handle: ValueHandle, state: EncodeState) for (const item of value) { if (index >= state.maxEntries) break preview.push({ - key: String(index), + key: String(index++), value: encode(item, nextState(state)), }) - index++ } } } catch { @@ -255,7 +260,7 @@ function encodeOwnProperty(target: object, key: string, state: EncodeState): Enc } } -function safeOwnEnumerableStringKeys(value: object): string[] { +export function safeOwnEnumerableStringKeys(value: object): string[] { try { const keys: string[] = [] for (const key of Reflect.ownKeys(value)) { @@ -370,6 +375,8 @@ function nextState(state: EncodeState): EncodeState { return { ...state, depth: state.depth + 1, + entryOffset: 0, + entryKeys: undefined, } } diff --git a/packages/kit/src/protocol/messages.ts b/packages/kit/src/protocol/messages.ts index 50c076319..af394f6ad 100644 --- a/packages/kit/src/protocol/messages.ts +++ b/packages/kit/src/protocol/messages.ts @@ -55,7 +55,22 @@ export interface ComponentTreeSnapshotMessage { nodes: ComponentTreeNodeSnapshot[] } +export interface StatePageOptions { + page?: number + pageSize?: number + snapshotId?: string +} + +export interface StatePagination { + total: number + pageSize: number + current: number + next: number | null +} + export interface ComponentStateSnapshotMessage { + snapshotId?: string + pagination?: StatePagination componentId: ComponentId version: number sections: ComponentStateSection[] @@ -111,6 +126,8 @@ export interface ReactivityGraphSnapshot { } export interface ExpandedValueMessage { + snapshotId?: string + pagination?: StatePagination handle: string path: string[] value: EncodedValue @@ -181,3 +198,14 @@ export interface RouterSnapshotMessage { currentRoute?: RouterRouteSnapshot routes: RouterRouteRecordSnapshot[] } + +/** A missing path is distinct from a present null or undefined value. */ +export type StateValueMessage = + | { found: false } + | { + found: true + value: EncodedValue + editable: boolean + snapshotId?: string + pagination?: StatePagination + } diff --git a/packages/kit/src/protocol/requests.ts b/packages/kit/src/protocol/requests.ts index 40aafa8a0..0a63be6ae 100644 --- a/packages/kit/src/protocol/requests.ts +++ b/packages/kit/src/protocol/requests.ts @@ -1,3 +1,4 @@ +import type { StatePageOptions } from './messages' import type { CustomInspectorOptions, TimelineEventOptions, @@ -8,6 +9,7 @@ import type { RuntimeInspectorInfo } from '../runtime/inspectors' import type { RuntimePerformanceSnapshot } from '../runtime/performance' import type { AppId, RuntimeCommand, RuntimeCommandResult, RuntimeQuery } from '../runtime/types' import type { + StateValueMessage, AppsSnapshotMessage, ComponentInspectionResultMessage, ComponentStateSnapshotMessage, @@ -30,6 +32,8 @@ interface StateEdit { value?: unknown remove?: boolean newKey?: string + /** Reject the edit unless the path exists and is editable, as reported by the stateValue query. */ + requireEditable?: boolean } /** Built-in request contracts. Extend these interfaces to type additional registered methods. */ @@ -45,14 +49,35 @@ export interface RuntimeQueryMap { payload: { filter?: string } | undefined result: ComponentTreeSnapshotMessage } + 'components:search': { + payload: { filter: string } + result: ComponentTreeSnapshotMessage + } 'components:treeChildren': { payload: { componentId: string; cursor?: string } result: ComponentTreePageMessage } 'components:stateSnapshot': { - payload: { componentId: string; maxEntries?: number } + payload: { + componentId: string + maxEntries?: number + maxPreviewEntries?: number + } & StatePageOptions result: ComponentStateSnapshotMessage | undefined } + 'components:stateValue': { + payload: { componentId: string; sectionId: string; path: string[] } & StatePageOptions + result: StateValueMessage + } + 'inspectors:stateValue': { + payload: { + inspectorId: string + nodeId: string + sectionId: string + path: string[] + } & StatePageOptions + result: StateValueMessage + } 'components:inspect': { payload: undefined; result: ComponentInspectionResultMessage | undefined } 'components:getBounds': { payload: ComponentTarget; result: ComponentBounds | undefined } 'components:getName': { payload: ComponentTarget; result: string | undefined } @@ -64,7 +89,7 @@ export interface RuntimeQueryMap { result: InspectorTreeSnapshotMessage } 'inspectors:stateSnapshot': { - payload: { inspectorId: string; nodeId: string } + payload: { inspectorId: string; nodeId: string } & StatePageOptions result: ComponentStateSnapshotMessage | undefined } 'router:snapshot': { payload: undefined; result: RouterSnapshotMessage } @@ -73,8 +98,8 @@ export interface RuntimeQueryMap { result: { appId?: string; path: string; routes: RouterRouteRecordSnapshot[] } } 'values:expand': { - payload: { handle: string; path?: string[]; maxEntries?: number } - result: ExpandedValueMessage | undefined + payload: { handle: string; path?: string[]; maxEntries?: number } & StatePageOptions + result: ExpandedValueMessage | { found: false } | undefined } 'values:storeAsGlobal': { payload: { handle: string } | { componentId: string; sectionId: string; path: string[] } diff --git a/packages/kit/src/runtime/components.ts b/packages/kit/src/runtime/components.ts index db14b4428..6dac074c0 100644 --- a/packages/kit/src/runtime/components.ts +++ b/packages/kit/src/runtime/components.ts @@ -109,6 +109,23 @@ export class ComponentTreeStore { } } + search(appId: AppId | undefined, needle: string, limit = 100): ComponentTreeSnapshot { + const app = appId ? this.registry.getApp(appId) : this.registry.listApps()[0] + if (!app) return { appId, version: 0, nodes: [] } + const query = needle.toLowerCase() + const nodes: ComponentSnapshot[] = [] + for (const record of this.registry.refreshComponentTree(app.id)) { + if (record.hidden) continue + const matches = [record.name, record.file].some( + (value) => typeof value === 'string' && value.toLowerCase().includes(query), + ) + if (!matches) continue + nodes.push(this.toSnapshot(record)) + if (nodes.length >= limit) break + } + return { appId: app.id, version: this.appVersions.get(app.id) ?? 0, nodes } + } + snapshot(appId?: AppId): ComponentTreeSnapshot { const app = appId ? this.registry.getApp(appId) : this.registry.listApps()[0] if (!app) return { appId, version: 0, nodes: [] } diff --git a/packages/kit/src/runtime/handlers/components.ts b/packages/kit/src/runtime/handlers/components.ts index 4de12de93..6ac28eb53 100644 --- a/packages/kit/src/runtime/handlers/components.ts +++ b/packages/kit/src/runtime/handlers/components.ts @@ -17,7 +17,14 @@ import type { DevtoolsRuntime } from '../runtime' import type { LegacyComponentStateEntry } from '../state' import { createLegacyStateSetter } from '../state/legacy-edit' import type { createComponentUpdateHighlight } from '../update-highlight' -import { readBoolean, readString, readStringArray } from './shared' +import { + editableError, + readBoolean, + readNumber, + readString, + readStringArray, + readStatePageOptions, +} from './shared' export function registerComponentHandlers( runtime: DevtoolsRuntime, @@ -48,6 +55,13 @@ export function registerComponentHandlers( return snapshot }) + runtime.registerQuery('components:search', async (query) => { + const filter = readString(query.payload, 'filter') ?? '' + const snapshot = runtime.components.search(query.appId, filter) + await applyVisitComponentTreeHooks(runtime, snapshot.nodes, filter) + return snapshot + }) + runtime.registerCommand('components:expandTreeNode', (command) => { const appId = command.appId const componentId = readString(command.payload, 'componentId') @@ -82,11 +96,52 @@ export function registerComponentHandlers( if (!componentId) return const record = runtime.registry.getComponent(componentId, query.appId) if (record) exposeComponentInstance(record) - const snapshot = runtime.componentState.snapshot(componentId, maxEntries) - if (record && snapshot) await applyInspectComponentHooks(runtime, record, snapshot) + const options = readStatePageOptions(query.payload) + if (options) { + if (!record) return + const initial = options.snapshotId + ? undefined + : runtime.componentState.snapshot(componentId, 50, 30)! + const legacy = initial ? await readInspectComponentEntries(runtime, record, initial) : [] + return runtime.componentState.statePage( + JSON.stringify(['component', query.appId, componentId]), + options, + componentId, + legacy, + initial, + ) + } + const maxPreviewEntries = readNumber(query.payload, 'maxPreviewEntries') + const snapshot = runtime.componentState.snapshot(componentId, maxEntries, maxPreviewEntries) + if (record && snapshot) + await applyInspectComponentHooks( + runtime, + record, + snapshot, + maxPreviewEntries === undefined ? undefined : { maxEntries, maxPreviewEntries }, + ) return snapshot }) + runtime.registerQuery('components:stateValue', (query) => { + const componentId = readString(query.payload, 'componentId') + const sectionId = readString(query.payload, 'sectionId') + const path = readStringArray(query.payload, 'path') + if ( + !componentId || + !sectionId || + !query.appId || + !runtime.registry.getComponent(componentId, query.appId) + ) + return { found: false } + return runtime.componentState.readStateValue( + componentId, + sectionId, + path, + readStatePageOptions(query.payload), + ) + }) + runtime.registerQuery('components:inspect', async () => { const result = await inspectComponentInPage(runtime) if (!result) return @@ -111,6 +166,16 @@ export function registerComponentHandlers( if (query.payload == null || typeof query.payload !== 'object') return const payload = query.payload as { handle?: unknown; path?: unknown; maxEntries?: unknown } if (typeof payload.handle !== 'string') return + const options = readStatePageOptions(query.payload) + if (options) { + const path = readStringArray(query.payload, 'path') + return runtime.componentState.expandPage( + JSON.stringify(['value', query.appId, payload.handle, path]), + options, + payload.handle, + path, + ) + } return runtime.componentState.expand( payload.handle, Array.isArray(payload.path) @@ -243,6 +308,13 @@ export function registerComponentHandlers( const record = runtime.registry.getComponent(componentId, command.appId) if (!record) return { status: 0, error: `Unknown component: ${componentId}` } + if (readBoolean(payload, 'requireEditable') === true) { + const error = editableError( + runtime.componentState.readStateValue(componentId, sectionId, path), + ) + if (error) return { status: 0, error } + } + const remove = payload != null && typeof payload === 'object' && @@ -423,26 +495,29 @@ async function applyInspectComponentHooks( runtime: DevtoolsRuntime, record: ComponentRecord, snapshot: ComponentStateSnapshotMessage, + limits?: { maxEntries?: number; maxPreviewEntries?: number }, ): Promise { + runtime.componentState.appendLegacyEntries( + snapshot, + await readInspectComponentEntries(runtime, record, snapshot), + limits, + ) +} + +async function readInspectComponentEntries( + runtime: DevtoolsRuntime, + record: ComponentRecord, + snapshot: ComponentStateSnapshotMessage, +): Promise { const legacyState = toLegacyStateEntries(snapshot) const originalLength = legacyState.length - const instanceData = { - id: record.id, - name: record.name, - file: record.file, - state: legacyState, - } - + const instanceData = { id: record.id, name: record.name, file: record.file, state: legacyState } await runtime.callPluginHook('inspectComponent', { app: runtime.registry.getApp(record.appId)?.app, componentInstance: record.instance, instanceData, }) - - runtime.componentState.appendLegacyEntries( - snapshot, - legacyState.slice(originalLength).filter(isLegacyStateEntry), - ) + return legacyState.slice(originalLength).filter(isLegacyStateEntry) } function toLegacyStateEntries( diff --git a/packages/kit/src/runtime/handlers/inspectors.ts b/packages/kit/src/runtime/handlers/inspectors.ts index 28681fb00..d1963f225 100644 --- a/packages/kit/src/runtime/handlers/inspectors.ts +++ b/packages/kit/src/runtime/handlers/inspectors.ts @@ -1,17 +1,24 @@ import type { CustomInspectorOptions } from '../../plugin' -import type { ComponentStateSnapshotMessage, InspectorsListMessage } from '../../protocol' +import type { + ComponentStateSnapshotMessage, + InspectorsListMessage, + StatePageOptions, + StateValueMessage, +} from '../../protocol' import type { DevtoolsRuntime } from '../runtime' import type { LegacyComponentStateEntry } from '../state' import { createLegacyStateSetter } from '../state/legacy-edit' import type { AppRef } from '../types' import { isRecord, + editableError, readBoolean, readNumber, readObject, readRecord, readString, readStringArray, + readStatePageOptions, readUnknownProperty, resolveAppRef, retiredAppError, @@ -62,6 +69,15 @@ export function registerInspectorHandlers(runtime: DevtoolsRuntime) { const inspector = runtime.inspectors.get(inspectorId, app) if (!inspector) return + const paging = readStatePageOptions(query.payload) + const pageTarget = JSON.stringify(['inspector', query.appId, inspectorId, nodeId]) + if (paging?.snapshotId) + return runtime.componentState.statePage( + pageTarget, + paging, + `inspector:${inspectorId}:${nodeId}`, + ) + inspector.selectedNodeId = nodeId const payload = { @@ -72,9 +88,41 @@ export function registerInspectorHandlers(runtime: DevtoolsRuntime) { } await runtime.callPluginHook('getInspectorState', payload) + if (paging) + return runtime.componentState.statePage( + pageTarget, + paging, + `inspector:${inspectorId}:${nodeId}`, + toInspectorLegacyStateEntries(inspectorId, nodeId, payload.state), + ) return createInspectorStateSnapshot(runtime, inspectorId, nodeId, payload.state) }) + runtime.registerQuery('inspectors:stateValue', async (query) => { + const inspectorId = readString(query.payload, 'inspectorId') + const nodeId = readString(query.payload, 'nodeId') + const sectionId = readString(query.payload, 'sectionId') + const path = readStringArray(query.payload, 'path') + if ( + !query.appId || + !runtime.registry.getApp(query.appId) || + !inspectorId || + !nodeId || + !sectionId || + !path.length + ) + return { found: false } + return readInspectorStateValue( + runtime, + query.appId, + inspectorId, + nodeId, + sectionId, + path, + readStatePageOptions(query.payload), + ) + }) + runtime.registerCommand('inspectors:add', (command) => { const payload = command.payload const options = readObject(payload, 'options') @@ -175,6 +223,22 @@ export function registerInspectorHandlers(runtime: DevtoolsRuntime) { const inspector = runtime.inspectors.get(inspectorId, app) if (!inspector) return { status: 0, error: `Unknown inspector: ${inspectorId}` } + if (readBoolean(payload, 'requireEditable') === true) { + const error = editableError( + command.appId + ? await readInspectorStateValue( + runtime, + command.appId, + inspectorId, + nodeId, + sectionId, + path, + ) + : { found: false as const }, + ) + if (error) return { status: 0, error } + } + const state = { value: readUnknownProperty(payload, 'value'), newKey: readString(payload, 'newKey') ?? null, @@ -326,3 +390,30 @@ function stringifyInspectorStateKey(key: unknown): string { return Object.prototype.toString.call(key) } } + +async function readInspectorStateValue( + runtime: DevtoolsRuntime, + appId: string, + inspectorId: string, + nodeId: string, + sectionId: string, + path: string[], + options?: StatePageOptions, +): Promise { + const app = resolveAppRef(runtime, appId) + if (!runtime.inspectors.get(inspectorId, app)) return { found: false } + const payload = { app, inspectorId, nodeId, state: {} } + await runtime.callPluginHook('getInspectorState', payload) + const entry = toInspectorLegacyStateEntries(inspectorId, nodeId, payload.state).find( + (entry) => entry.type === sectionId && entry.key === path[0], + ) + if (!entry) return { found: false } + return runtime.componentState.readStatePath( + { value: entry.value }, + ['value', ...path.slice(1)], + `state:inspector:${appId}:${inspectorId}:${nodeId}`, + entry.editable === true, + options, + JSON.stringify([appId, inspectorId, nodeId, sectionId, path]), + ) +} diff --git a/packages/kit/src/runtime/handlers/shared.ts b/packages/kit/src/runtime/handlers/shared.ts index f102369bd..271206abc 100644 --- a/packages/kit/src/runtime/handlers/shared.ts +++ b/packages/kit/src/runtime/handlers/shared.ts @@ -1,3 +1,4 @@ +import type { StatePageOptions, StateValueMessage } from '../../protocol' import type { DevtoolsRuntime } from '../runtime' import type { AppRef } from '../types' @@ -53,3 +54,18 @@ export function resolveAppRef( ): AppRef | undefined { return appId ? runtime.registry.getApp(appId)?.app : runtime.registry.listApps()[0]?.app } + +/** Explain why a stateValue read does not allow an edit, or return undefined when it does. */ +export function editableError(value: StateValueMessage): string | undefined { + if (!value.found) return 'State path was not found' + if (!value.editable) return 'State field is read-only' +} + +export function readStatePageOptions(payload: unknown): StatePageOptions | undefined { + const page = readNumber(payload, 'page') + const pageSize = readNumber(payload, 'pageSize') + const snapshotId = readString(payload, 'snapshotId') + return page === undefined && pageSize === undefined && snapshotId === undefined + ? undefined + : { page, pageSize, snapshotId } +} diff --git a/packages/kit/src/runtime/state/index.ts b/packages/kit/src/runtime/state/index.ts index f4d01f5a2..790247a3d 100644 --- a/packages/kit/src/runtime/state/index.ts +++ b/packages/kit/src/runtime/state/index.ts @@ -1,3 +1,5 @@ +import { StatePages } from './pagination' +import { safeOwnEnumerableStringKeys } from '../../codec/encode' import { ValueHandleRegistry, encodeValue } from '../../codec' import type { ComponentId, InstanceRef } from '../types' import { RuntimeRegistry } from '../registry' @@ -6,6 +8,8 @@ import type { ComponentStateSnapshotMessage, ExpandedValueMessage, StateEntry, + StateValueMessage, + StatePageOptions, ComponentStateSection, ReactivityGraphSnapshot, } from '../../protocol' @@ -17,7 +21,6 @@ import { resolveEntryEditable, } from './sections' import { - readPath, resolvePath, getEditableSectionTarget, setStateValue, @@ -43,6 +46,7 @@ export interface LegacyComponentStateEntry { const MAX_HANDLE_SCOPES = 8 export class ComponentStateCollector { + private pages: StatePages private handles: ValueHandleRegistry private handleScopes: string[] = [] private versions = new Map() @@ -54,6 +58,7 @@ export class ComponentStateCollector { private readonly budget: RuntimeBudget, ) { this.handles = new ValueHandleRegistry({ maxHandles: budget.state.maxHandles }) + this.pages = new StatePages(budget.transport.maxMessageBytes) } get handleCount(): number { @@ -91,6 +96,7 @@ export class ComponentStateCollector { snapshot( componentId: ComponentId, maxEntries = this.budget.state.maxEntries, + maxPreviewEntries = maxEntries, ): ComponentStateSnapshotMessage | undefined { const record = this.registry.getComponent(componentId) if (!record) return @@ -102,9 +108,10 @@ export class ComponentStateCollector { ? this.collectReactivityGraph(instance) : undefined const entries = normalizeMaxEntries(maxEntries, this.budget.state.maxEntries) + const previewEntries = normalizeMaxEntries(maxPreviewEntries, entries) const scope = this.beginStateScope(stateScopeKey(componentId)) const sections = createSectionSources(instance) - .map((section) => this.collectSection(section, entries, scope)) + .map((section) => this.collectSection(section, entries, scope, previewEntries)) .filter((section) => section.entries.length > 0) return { @@ -115,15 +122,193 @@ export class ComponentStateCollector { } } + statePage( + target: string, + options: StatePageOptions, + componentId: string, + legacy: LegacyComponentStateEntry[] = [], + initial?: ComponentStateSnapshotMessage, + ): ComponentStateSnapshotMessage { + return this.pages.read(target, options, () => { + const scope = initial + ? stateScopeKey(componentId) + : this.beginStateScope(stateScopeKey(componentId)) + const record = this.registry.getComponent(componentId) + const fields: { id: string; label: string; read: () => StateEntry }[] = [] + if (record) { + for (const source of createSectionSources(record.instance)) { + const cached = new Map( + initial?.sections + .find((section) => section.id === source.id) + ?.entries.map((entry) => [entry.key, entry]), + ) + for (const key of source.source ? safeOwnKeys(source.source) : []) { + if (!stringifyStateKey(key)) continue + fields.push({ + id: source.id, + label: source.label, + read: () => { + const entry = cached.get(stringifyStateKey(key)) + if (entry) { + cached.delete(entry.key) + return entry + } + return this.collectSection(source, 1, scope, 30, [key]).entries[0]! + }, + }) + } + } + } + for (const entry of legacy) { + const id = entry.type || 'data' + const key = stringifyStateKey(entry.key ?? '') + if (!key) continue + fields.push({ + id, + label: toSectionLabel(id), + read: () => this.encodePluginStateEntry(entry, id, key, scope, 30), + }) + } + const totals = new Map() + for (const field of fields) totals.set(field.id, (totals.get(field.id) ?? 0) + 1) + // Keep each section contiguous so byte trimming preserves the same field order. + const order = new Map([...totals.keys()].map((id, index) => [id, index])) + fields.sort((a, b) => order.get(a.id)! - order.get(b.id)!) + const version = this.versions.get(componentId) ?? 0 + const graph = initial + ? initial.reactivityGraph + : record && supportsReactivityGraphVueVersion(this.registry.getApp(record.appId)?.version) + ? this.collectReactivityGraph(record.instance) + : undefined + return { + total: fields.length, + read: (offset, limit) => { + const sections: ComponentStateSection[] = [] + for (const field of fields.slice(offset, offset + limit)) { + let section = sections.find((section) => section.id === field.id) + if (!section) { + section = { id: field.id, label: field.label, entries: [] } + sections.push(section) + } + section.entries.push(field.read()) + } + for (const section of sections) + section.partial = section.entries.length < totals.get(section.id)! + return { + componentId, + version, + sections, + ...(offset === 0 && graph ? { reactivityGraph: graph } : {}), + } + }, + } + }) as ComponentStateSnapshotMessage + } + + expandPage( + target: string, + options: StatePageOptions, + handle: string, + path: string[] = [], + ): ExpandedValueMessage | { found: false } | undefined { + const root = this.handles.get(handle) + if (!root) return + const resolved = path.length ? resolvePath(root, path) : { found: true, value: root } + if (!resolved.found) return { found: false } + return this.valuePage( + target, + options, + resolved.value, + this.handles.getScope(handle), + handle, + path, + ) + } + + private valuePage( + target: string, + options: StatePageOptions, + value: unknown, + scope?: string, + handle = '', + path: string[] = [], + ): ExpandedValueMessage { + const maxPageSize = typeof value === 'string' ? 5000 : 500 + const pageOptions = { + ...options, + pageSize: options.pageSize ?? (typeof value === 'string' ? 5000 : 50), + } + return this.pages.read( + target, + pageOptions, + () => { + // Snapshot Map/Set membership once so each page slices rather than re-walking the collection. + const mapEntries = value instanceof Map ? Array.from(value) : undefined + const setItems = value instanceof Set ? Array.from(value) : undefined + const objectKeys = + value && typeof value === 'object' && !Array.isArray(value) && !mapEntries && !setItems + ? safeOwnEnumerableStringKeys(value) + : undefined + const encodeOptions = { + handles: this.handles, + handleScope: scope, + maxDepth: 1, + maxStringLength: this.budget.state.maxStringLength, + } + const encode = (offset: number, limit: number) => { + if (typeof value === 'string') + return { kind: 'string' as const, value: value.slice(offset, offset + limit) } + if (mapEntries) + return encodeValue(new Map(mapEntries.slice(offset, offset + limit)), { + ...encodeOptions, + maxEntries: limit, + }) + if (setItems) { + const encoded = encodeValue(new Set(setItems.slice(offset, offset + limit)), { + ...encodeOptions, + maxEntries: limit, + }) + if (encoded.kind === 'set') + encoded.preview = encoded.preview.map((entry, index) => ({ + key: String(offset + index), + value: entry.value, + })) + return encoded + } + return encodeValue(value, { + entryKeys: objectKeys, + ...encodeOptions, + maxEntries: limit, + entryOffset: offset, + }) + } + const total = (() => { + if (typeof value === 'string') return value.length + if (Array.isArray(value)) return value.length + if (mapEntries || setItems) return (mapEntries ?? setItems)!.length + const summary = encode(0, 0) + return summary.kind === 'object' ? summary.entries : 1 + })() + return { total, read: (offset, limit) => ({ handle, path, value: encode(offset, limit) }) } + }, + maxPageSize, + ) as ExpandedValueMessage + } + expand( handle: string, path: string[] = [], maxEntries = this.budget.state.maxEntries, - ): ExpandedValueMessage | undefined { + ): ExpandedValueMessage | { found: false } | undefined { const root = this.handles.get(handle) if (!root) return - const value = path.length ? readPath(root, path) : root + let value: unknown = root + if (path.length) { + const resolved = resolvePath(root, path) + if (!resolved.found) return { found: false } + value = resolved.value + } const entries = normalizeMaxEntries(maxEntries, this.budget.state.maxEntries) return { handle, @@ -158,6 +343,71 @@ export class ComponentStateCollector { return resolvePath(source, path) } + readStateValue( + componentId: ComponentId, + sectionId: string, + path: string[], + options?: StatePageOptions, + ): StateValueMessage { + const record = this.registry.getComponent(componentId) + if (!record || !path.length) return { found: false } + const section = createSectionSources(record.instance).find( + (section) => section.id === sectionId, + ) + if (!section?.source) return { found: false } + const key = path[0]! + return this.readStatePath( + section.source, + path, + stateScopeKey(componentId), + resolveEntryEditable(section.editable, section.meta?.(key), key), + options, + JSON.stringify([componentId, sectionId, path]), + ) + } + + readStatePath( + source: object, + path: string[], + scope: string, + editable: boolean, + options?: StatePageOptions, + target = JSON.stringify([scope, path]), + ): StateValueMessage { + let value: unknown = source + // Resolve each segment once: getters can have side effects. Preserve readonly + // boundaries even when a nested value belongs to an editable section. + // The section root itself can be shallowReadonly (Vue props) while nested + // values stay writable, so the flag applies only after the first segment. + for (let depth = 0; depth <= path.length; depth++) { + if ( + depth > 0 && + value && + typeof value === 'object' && + readUnknownProperty(value, '__v_isReadonly') === true + ) + editable = false + if (depth === path.length) break + if (value == null || (typeof value !== 'object' && typeof value !== 'function')) + return { found: false } + const resolved = resolvePath(value, [path[depth]!]) + if (!resolved.found) return { found: false } + value = resolved.value + } + if (!options?.snapshotId) this.beginStateScope(scope) + if (options) { + const result = this.valuePage(target, options, value, scope) + return { + found: true, + editable, + value: result.value, + pagination: result.pagination, + snapshotId: result.snapshotId, + } + } + return { found: true, editable, value: this.encodeStateValue(value, scope, 30) } + } + /** `triggerRef` the live computed for this state path. */ recompute(componentId: ComponentId, sectionId: string, path: string[]): boolean { const record = this.registry.getComponent(componentId) @@ -209,8 +459,15 @@ export class ComponentStateCollector { appendLegacyEntries( snapshot: ComponentStateSnapshotMessage, entries: LegacyComponentStateEntry[], + limits?: { maxEntries?: number; maxPreviewEntries?: number }, ): void { const scope = stateScopeKey(snapshot.componentId) + const maxEntries = limits + ? normalizeMaxEntries( + limits.maxEntries ?? this.budget.state.maxEntries, + this.budget.state.maxEntries, + ) + : Infinity for (const entry of entries) { const sectionId = entry.type || 'data' const key = stringifyStateKey(entry.key ?? '') @@ -226,17 +483,13 @@ export class ComponentStateCollector { snapshot.sections.push(section) } - section.entries.push({ - key, - path: [sectionId, key], - value: this.encodeStateValue(entry.value, scope), - editable: entry.editable === true, - meta: { - ...entry.meta, - ...(entry.objectType ? { stateTypeName: entry.objectType } : {}), - ...(entry.raw ? { raw: entry.raw } : {}), - }, - }) + if (section.entries.length >= maxEntries) { + section.partial = true + continue + } + section.entries.push( + this.encodePluginStateEntry(entry, sectionId, key, scope, limits?.maxPreviewEntries), + ) } } @@ -260,6 +513,7 @@ export class ComponentStateCollector { } clear(): void { + this.pages.clear() this.handles.clear() this.handleScopes = [] this.versions.clear() @@ -267,12 +521,36 @@ export class ComponentStateCollector { this.reactivityGraphIdSeed = 0 } - private encodeStateValue(value: unknown, scope?: string): StateEntry['value'] { + private encodePluginStateEntry( + entry: LegacyComponentStateEntry, + sectionId: string, + key: string, + scope: string, + maxPreviewEntries?: number, + ): StateEntry { + return { + key, + path: [sectionId, key], + value: this.encodeStateValue(entry.value, scope, maxPreviewEntries), + editable: entry.editable === true, + meta: { + ...entry.meta, + ...(entry.objectType ? { stateTypeName: entry.objectType } : {}), + ...(entry.raw ? { raw: entry.raw } : {}), + }, + } + } + + private encodeStateValue( + value: unknown, + scope?: string, + maxPreviewEntries = this.budget.state.maxEntries, + ): StateEntry['value'] { return encodeValue(value, { handles: this.handles, handleScope: scope, maxDepth: this.budget.state.lazyChildren ? 1 : this.budget.state.maxDepth, - maxEntries: this.budget.state.maxEntries, + maxEntries: normalizeMaxEntries(maxPreviewEntries, this.budget.state.maxEntries), maxStringLength: this.budget.state.maxStringLength, }) } @@ -281,8 +559,11 @@ export class ComponentStateCollector { source: SectionSource, maxEntries: number, scope: string, + maxPreviewEntries: number, + selectedKeys?: PropertyKey[], ): ComponentStateSection { - const keys = source.source ? safeOwnKeys(source.source).slice(0, maxEntries) : [] + const keys = + selectedKeys ?? (source.source ? safeOwnKeys(source.source).slice(0, maxEntries) : []) const entries: StateEntry[] = [] for (const key of keys) { @@ -299,7 +580,7 @@ export class ComponentStateCollector { handles: this.handles, handleScope: scope, maxDepth: this.budget.state.lazyChildren ? 1 : this.budget.state.maxDepth, - maxEntries, + maxEntries: maxPreviewEntries, maxStringLength: this.budget.state.maxStringLength, }) } catch (error) { @@ -324,7 +605,7 @@ export class ComponentStateCollector { id: source.id, label: source.label, entries, - partial: !!source.source && safeOwnKeys(source.source).length > keys.length, + partial: !selectedKeys && !!source.source && safeOwnKeys(source.source).length > keys.length, } } diff --git a/packages/kit/src/runtime/state/pagination.ts b/packages/kit/src/runtime/state/pagination.ts new file mode 100644 index 000000000..e861e12c4 --- /dev/null +++ b/packages/kit/src/runtime/state/pagination.ts @@ -0,0 +1,152 @@ +import type { + ComponentStateSnapshotMessage, + ExpandedValueMessage, + StatePageOptions, +} from '../../protocol' +import { measureRuntimeMessageBytes } from '../../rpc/event-buffer' + +type Result = ComponentStateSnapshotMessage | ExpandedValueMessage +interface PageSource { + total: number + read: (offset: number, limit: number) => Result +} +interface Session extends PageSource { + target: string + pageSize: number + offset: number + // Items the byte budget last allowed per page; avoids encoding a full pageSize that gets trimmed. + fit?: number + last?: { page: number; result: Result } +} + +/** Keeps actual page boundaries when the byte budget ends a page early. */ +export class StatePages { + private sessions = new Map() + private seed = 0 + + constructor(private readonly maxBytes: number) {} + + read( + target: string, + options: StatePageOptions, + create: () => PageSource, + maxPageSize = 500, + ): Result { + const page = options.page ?? 1 + const pageSize = options.pageSize ?? 50 + if ( + !Number.isSafeInteger(page) || + page < 1 || + !Number.isSafeInteger(pageSize) || + pageSize < 1 || + pageSize > maxPageSize + ) + throw new Error(`Use page >= 1 and pageSize between 1 and ${maxPageSize}.`) + if (!options.snapshotId && page !== 1) + throw new Error('Start at page 1, then pass its snapshotId with pagination.next.') + const id = options.snapshotId ?? `state-page:${++this.seed}` + let session = this.sessions.get(id) + if (!options.snapshotId) { + session = { ...create(), target, pageSize, offset: 0 } + this.sessions.set(id, session) + while (this.sessions.size > 8) { + const oldest = this.sessions.keys().next().value! + this.sessions.delete(oldest) + } + } + if (!session || session.target !== target || session.pageSize !== pageSize) + throw new Error( + 'State pagination expired or query parameters changed. Restart at page 1 without snapshotId.', + ) + if (session.last?.page === page) return session.last.result + const offset = session.offset + if (page !== (session.last?.page ?? 0) + 1 || (page > 1 && offset >= session.total)) + throw new Error( + 'Read pages in order using pagination.next. Restart at page 1 without snapshotId to reread earlier pages.', + ) + const requested = Math.min(pageSize, session.fit ?? pageSize) + const result = session.read(offset, requested) + const items = + 'sections' in result + ? result.sections.flatMap((section) => section.entries) + : 'preview' in result.value + ? result.value.preview + : [result.value] + let count = + 'value' in result && result.value.kind === 'string' ? result.value.value.length : items.length + const update = () => { + result.snapshotId = id + result.pagination = { + total: session.total, + pageSize, + current: page, + next: offset + count < session.total ? page + 1 : null, + } + } + update() + // Leave room for the RPC envelope and host metadata; never advance past omitted entries. + const limit = this.maxBytes * 0.9 + let bytes = measureRuntimeMessageBytes(result) + if ('value' in result && result.value.kind === 'string' && bytes > limit) { + const text = result.value.value + let low = 1 + let high = text.length + while (low < high) { + const mid = (low + high + 1) >> 1 + result.value.value = text.slice(0, mid) + if (measureRuntimeMessageBytes(result) <= limit) low = mid + else high = mid - 1 + } + if (low > 1 && isHighSurrogate(text.charCodeAt(low - 1))) low-- + result.value.value = text.slice(0, low) + count = low + update() + bytes = measureRuntimeMessageBytes(result) + } + while (bytes > limit && count > 1) { + if ('sections' in result) { + const section = [...result.sections] + .reverse() + .find((section) => section.entries.length > 0)! + bytes -= measureRuntimeMessageBytes(section.entries.pop()) + section.partial = true + } else if ('preview' in result.value) + bytes -= measureRuntimeMessageBytes(result.value.preview.pop()) + count-- + update() + } + if (measureRuntimeMessageBytes(result) > limit) + throw new Error( + 'A single state entry exceeds the response budget. Read a narrower field path or expand its value separately.', + ) + if (count === 0 && offset < session.total) + throw new Error('State changed during pagination. Restart at page 1 without snapshotId.') + session.offset = offset + count + session.fit = count < requested ? Math.max(1, count) : Math.min(pageSize, requested * 2) + session.last = { page, result } + if (session.offset >= session.total) this.sessions.set(id, finished(session)) + return result + } + + clear() { + this.sessions.clear() + } +} + +/** Drops the live collection and field readers once every page has been handed out. */ +function finished(session: Session): Session { + return { + target: session.target, + pageSize: session.pageSize, + total: session.total, + offset: session.offset, + last: session.last, + read: () => { + throw new Error('State pagination is complete. Restart at page 1 without snapshotId.') + }, + } +} + +function isHighSurrogate(code: number): boolean { + return code >= 0xd800 && code <= 0xdbff +} diff --git a/packages/vite/package.json b/packages/vite/package.json index f03cc89b1..8f07b0a84 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -47,6 +47,7 @@ }, "dependencies": { "@vitejs/devtools-kit": "catalog:", + "@vue/devtools-agent": "workspace:*", "@vue/devtools-kit": "workspace:*" }, "devDependencies": { @@ -55,7 +56,7 @@ "vite": "catalog:" }, "peerDependencies": { - "@vitejs/devtools": "^0.7.5", + "@vitejs/devtools": "^0.7.6", "vite": "^8.3.0" }, "engines": { diff --git a/packages/vite/src/client.ts b/packages/vite/src/client.ts index dbccb627b..95c5e7fe8 100644 --- a/packages/vite/src/client.ts +++ b/packages/vite/src/client.ts @@ -1,6 +1,11 @@ import type { DevToolsClientContext, DockClientScriptContext } from '@vitejs/devtools-kit/client' +import type { VueDevtoolsAgentPage } from '@vue/devtools-agent' import type { DevtoolsHookTarget, DevtoolsKit } from '@vue/devtools-kit' -import { startIframeDevtoolsRpcHost, createDevtoolsKit } from '@vue/devtools-kit' +import { + startIframeDevtoolsRpcHost, + createDevtoolsKit, + connectDevtoolsIframeClient, +} from '@vue/devtools-kit' import { CLIENT_RUNTIME_KEY } from './constants' import { createVueDevToolsDockBinding, type VueDevToolsDockBinding } from './utils/dock/binding' @@ -8,12 +13,19 @@ export interface InstallVueDevToolsOptions { enabled: boolean } +interface VueDevToolsAgentSetup { + kit: DevtoolsKit + rpc: DevToolsClientContext['rpc'] +} + interface VueDevToolsClientState { kit?: DevtoolsKit context?: DevToolsClientContext | DockClientScriptContext bindDock?: typeof setupVueDevToolsDockController dockBinding?: VueDevToolsDockBinding disposeRpcHost?: () => void + agentSetup?: VueDevToolsAgentSetup + disposeAgent?: () => void } interface VueDevToolsTarget extends DevtoolsHookTarget { @@ -64,10 +76,25 @@ export function setupVueDevToolsDockController( if (!state.context || !state.kit) return state.dockBinding?.dispose() state.dockBinding = createVueDevToolsDockBinding(state.context, state.kit.runtime) + const rpc = state.context.rpc + if (!rpc?.connectionMeta.mcp) { + disposeAgent(state) + return + } + if (state.agentSetup?.kit === state.kit && state.agentSetup.rpc === rpc) return + disposeAgent(state) + const setup = { kit: state.kit, rpc } + state.agentSetup = setup + void setupAgent(state, setup).catch((error) => { + if (state.agentSetup !== setup) return + disposeAgent(state) + console.error('[vue-devtools] Failed to register agent tools', error) + }) } export function disposeVueDevTools(): void { const state = getClientState() + disposeAgent(state) state.dockBinding?.dispose() state.disposeRpcHost?.() void state.kit?.dispose() @@ -85,3 +112,50 @@ function getTarget(): typeof globalThis & VueDevToolsTarget { return (typeof window !== 'undefined' ? window : globalThis) as typeof globalThis & VueDevToolsTarget } + +function disposeAgent(state: VueDevToolsClientState): void { + state.disposeAgent?.() + delete state.disposeAgent + delete state.agentSetup +} + +async function setupAgent( + state: VueDevToolsClientState, + setup: VueDevToolsAgentSetup, +): Promise { + const { createVueDevtoolsAgentSession, registerVueDevtoolsAgentPage } = + await import('@vue/devtools-agent/devframe') + if ( + state.kit !== setup.kit || + state.agentSetup !== setup || + !setup.rpc.connectionMeta.mcp || + typeof window === 'undefined' + ) + return + const connection = createVueDevtoolsAgentSession(() => + connectDevtoolsIframeClient({ targetWindow: window }), + ) + const page: VueDevtoolsAgentPage = { + id: Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => + byte.toString(16).padStart(2, '0'), + ).join(''), + get url() { + return location.href + }, + get title() { + return document.title + }, + } + let disposeTools = () => {} + state.disposeAgent = () => { + disposeTools() + connection.dispose() + } + try { + disposeTools = registerVueDevtoolsAgentPage(connection, page) + } catch (error) { + state.disposeAgent() + delete state.disposeAgent + throw error + } +} diff --git a/packages/vite/src/utils/dock/registration.ts b/packages/vite/src/utils/dock/registration.ts index 37978349d..c09a2d79b 100644 --- a/packages/vite/src/utils/dock/registration.ts +++ b/packages/vite/src/utils/dock/registration.ts @@ -4,20 +4,32 @@ import { clientDist } from '../../dirs' export function createVueDevToolsDockRegistrationPlugin(): PluginWithDevTools { let base = '/' + let mcpEnabled = true + let disposeAgentHost: (() => void) | undefined return { name: 'vue-devtools:dock-registration', apply: 'serve', configResolved(config) { base = config.base + mcpEnabled = !!config.devtools && config.devtools.config.mcp !== false if (!config.devtools) { config.logger.warn( "[vite-plugin-vue-devtools] Vue DevTools requires Vite DevTools. Install @vitejs/devtools and set devtools: { apply: 'serve' } in your Vite config.", ) } }, + closeBundle() { + disposeAgentHost?.() + disposeAgentHost = undefined + }, devtools: { - setup(ctx) { + async setup(ctx) { + if (mcpEnabled) { + const { registerVueDevtoolsAgentHost } = await import('@vue/devtools-agent/node') + disposeAgentHost?.() + disposeAgentHost = registerVueDevtoolsAgentHost(ctx) + } const clientUrl = getClientBasePath(base) ctx.views.hostStatic(clientUrl, clientDist) @@ -31,6 +43,7 @@ export function createVueDevToolsDockRegistrationPlugin(): PluginWithDevTools { frameId: DOCK_ENTRY_ID, clientScript: { importFrom: getClientDockModulePath(base), + ...(mcpEnabled ? { eager: true } : {}), }, }) }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bdfedde5f..1a76bb5d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -170,6 +170,9 @@ catalogs: '@clack/prompts': specifier: ^1.8.1 version: 1.8.1 + '@devframes/agentic': + specifier: ^1.1.0 + version: 1.1.0 '@iconify-json/carbon': specifier: ^1.2.28 version: 1.2.28 @@ -182,6 +185,9 @@ catalogs: '@iconify-json/ri': specifier: ^1.2.10 version: 1.2.10 + '@modelcontextprotocol/client': + specifier: ^2.2.0 + version: 2.2.0 '@types/chrome': specifier: ^0.3.0 version: 0.3.0 @@ -218,6 +224,9 @@ catalogs: bumpp: specifier: ^12.3.0 version: 12.3.0 + cac: + specifier: ^7.0.0 + version: 7.0.0 colorjs.io: specifier: ^0.7.1 version: 0.7.1 @@ -257,6 +266,9 @@ catalogs: playwright: specifier: ^1.63.0 version: 1.63.0 + safe-stable-stringify: + specifier: ^2.5.0 + version: 2.5.0 simple-git-hooks: specifier: ^2.14.0 version: 2.14.0 @@ -296,6 +308,9 @@ catalogs: vue-virtual-scroller: specifier: ^3.0.5 version: 3.0.5 + zod: + specifier: ^4.6.5 + version: 4.6.5 overrides: '@devframes/hub': ^1.1.0 @@ -316,6 +331,9 @@ importers: '@clack/prompts': specifier: 'catalog:' version: 1.8.1 + '@modelcontextprotocol/client': + specifier: 'catalog:' + version: 2.2.0 '@types/chrome': specifier: 'catalog:' version: 0.3.0 @@ -399,7 +417,7 @@ importers: version: 15.0.0(vue@3.5.43(typescript@6.0.3)) unocss: specifier: 'catalog:' - version: 66.10.5(@devframes/agentic@1.0.0)(cac@7.0.0)(rolldown@1.2.8)(rollup@4.63.0)(srvx@1.0.5)(vite@8.3.1) + version: 66.10.5(@devframes/agentic@1.1.0)(cac@7.0.0)(rolldown@1.2.8)(rollup@4.63.0)(srvx@1.0.5)(vite@8.3.1) unplugin-vue-components: specifier: ^32.1.0 version: 32.1.0(esbuild@0.28.2)(rolldown@1.2.8)(rollup@4.63.0)(vite@8.3.1)(vue@3.5.43(typescript@6.0.3)) @@ -413,6 +431,31 @@ importers: specifier: 'catalog:' version: 3.5.43(typescript@6.0.3) + packages/agent: + dependencies: + '@devframes/agentic': + specifier: 'catalog:' + version: 1.1.0(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0) + '@vue/devtools-kit': + specifier: workspace:* + version: link:../kit + cac: + specifier: 'catalog:' + version: 7.0.0 + devframe: + specifier: ^1.1.0 + version: 1.1.0(@devframes/agentic@1.1.0)(cac@7.0.0)(srvx@1.0.5) + safe-stable-stringify: + specifier: 'catalog:' + version: 2.5.0 + zod: + specifier: 'catalog:' + version: 4.6.5 + devDependencies: + tsdown: + specifier: 'catalog:' + version: 0.23.0(@vitejs/devtools@0.7.6)(@volar/typescript@2.4.28(typescript@6.0.3))(@vue/language-core@3.3.11)(publint@0.3.21)(typescript@6.0.3)(vue-tsc@3.3.11(typescript@6.0.3)) + packages/chrome: dependencies: '@vue/devtools-client': @@ -448,7 +491,7 @@ importers: version: 6.0.3 unocss: specifier: 'catalog:' - version: 66.10.5(@devframes/agentic@1.0.0)(cac@7.0.0)(rolldown@1.2.8)(rollup@4.63.0)(srvx@1.0.5)(vite@8.3.1) + version: 66.10.5(@devframes/agentic@1.1.0)(cac@7.0.0)(rolldown@1.2.8)(rollup@4.63.0)(srvx@1.0.5)(vite@8.3.1) vite: specifier: ^8.3.1 version: 8.3.1(@types/node@26.6.3)(@vitejs/devtools@0.7.6)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0) @@ -460,7 +503,7 @@ importers: dependencies: '@vitejs/devtools-kit': specifier: ^0.7.6 - version: 0.7.6(@devframes/agentic@1.0.0)(cac@7.0.0)(crossws@0.4.12(srvx@1.0.4))(srvx@1.0.4)(vite@8.3.1) + version: 0.7.6(@devframes/agentic@1.1.0)(cac@7.0.0)(crossws@0.4.12(srvx@1.0.4))(srvx@1.0.4)(vite@8.3.1) '@vue/devtools-kit': specifier: workspace:* version: link:../kit @@ -518,7 +561,7 @@ importers: version: 3.0.8 '@vitejs/devtools': specifier: ^0.7.6 - version: 0.7.6(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0))(crossws@0.4.12(srvx@1.0.4))(srvx@1.0.4)(vite@8.3.1) + version: 0.7.6(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0))(crossws@0.4.12(srvx@1.0.4))(srvx@1.0.4)(vite@8.3.1) '@vitejs/plugin-vue': specifier: 'catalog:' version: 6.0.9(vite@8.3.1)(vue@3.5.43(typescript@6.0.3)) @@ -527,7 +570,7 @@ importers: version: 6.0.3 unocss: specifier: 'catalog:' - version: 66.10.5(@devframes/agentic@1.0.0)(cac@7.0.0)(rolldown@1.2.5)(rollup@4.63.0)(srvx@1.0.4)(vite@8.3.1) + version: 66.10.5(@devframes/agentic@1.1.0)(cac@7.0.0)(rolldown@1.2.5)(rollup@4.63.0)(srvx@1.0.4)(vite@8.3.1) vite: specifier: ^8.3.1 version: 8.3.1(@types/node@26.6.3)(@vitejs/devtools@0.7.6)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0) @@ -546,7 +589,7 @@ importers: dependencies: devframe: specifier: ^1.1.0 - version: 1.1.0(@devframes/agentic@1.0.0)(cac@7.0.0)(srvx@1.0.4) + version: 1.1.0(@devframes/agentic@1.1.0)(cac@7.0.0)(srvx@1.0.4) devDependencies: tsdown: specifier: 'catalog:' @@ -556,10 +599,13 @@ importers: dependencies: '@vitejs/devtools': specifier: ^0.7.6 - version: 0.7.6(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0))(crossws@0.4.12(srvx@1.0.4))(srvx@1.0.4)(vite@8.3.1) + version: 0.7.6(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0))(crossws@0.4.12(srvx@1.0.4))(srvx@1.0.4)(vite@8.3.1) '@vitejs/devtools-kit': specifier: ^0.7.6 - version: 0.7.6(@devframes/agentic@1.0.0)(cac@7.0.0)(crossws@0.4.12(srvx@1.0.4))(srvx@1.0.4)(vite@8.3.1) + version: 0.7.6(@devframes/agentic@1.1.0)(cac@7.0.0)(crossws@0.4.12(srvx@1.0.4))(srvx@1.0.4)(vite@8.3.1) + '@vue/devtools-agent': + specifier: workspace:* + version: link:../agent '@vue/devtools-kit': specifier: workspace:* version: link:../kit @@ -578,7 +624,7 @@ importers: dependencies: '@antfu/design': specifier: 'catalog:' - version: 0.5.3(@antfu/utils@9.3.0)(@tanstack/vue-virtual@3.13.39(vue@3.5.43(typescript@6.0.3)))(@unocss/core@66.10.5)(colorjs.io@0.7.1)(playwright@1.63.0)(reka-ui@2.10.4(vue@3.5.43(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.5.43(typescript@6.0.3)))(unocss@66.10.5(@devframes/agentic@1.0.0)(cac@7.0.0)(rolldown@1.2.8)(rollup@4.63.0)(srvx@1.0.4)(vite@8.3.1))(vue@3.5.43(typescript@6.0.3)) + version: 0.5.3(@antfu/utils@9.3.0)(@tanstack/vue-virtual@3.13.39(vue@3.5.43(typescript@6.0.3)))(@unocss/core@66.10.5)(colorjs.io@0.7.1)(playwright@1.63.0)(reka-ui@2.10.4(vue@3.5.43(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.5.43(typescript@6.0.3)))(unocss@66.10.5(@devframes/agentic@1.1.0)(cac@7.0.0)(rolldown@1.2.8)(rollup@4.63.0)(srvx@1.0.4)(vite@8.3.1))(vue@3.5.43(typescript@6.0.3)) '@unocss/reset': specifier: 'catalog:' version: 66.10.5 @@ -594,7 +640,7 @@ importers: devDependencies: '@vitejs/devtools': specifier: ^0.7.6 - version: 0.7.6(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0))(crossws@0.4.12(srvx@1.0.4))(srvx@1.0.4)(vite@8.3.1) + version: 0.7.6(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0))(crossws@0.4.12(srvx@1.0.4))(srvx@1.0.4)(vite@8.3.1) '@vitejs/plugin-vue': specifier: 'catalog:' version: 6.0.9(vite@8.3.1)(vue@3.5.43(typescript@6.0.3)) @@ -603,7 +649,7 @@ importers: version: 6.0.3 unocss: specifier: 'catalog:' - version: 66.10.5(@devframes/agentic@1.0.0)(cac@7.0.0)(rolldown@1.2.8)(rollup@4.63.0)(srvx@1.0.4)(vite@8.3.1) + version: 66.10.5(@devframes/agentic@1.1.0)(cac@7.0.0)(rolldown@1.2.8)(rollup@4.63.0)(srvx@1.0.4)(vite@8.3.1) vite: specifier: ^8.3.1 version: 8.3.1(@types/node@26.6.3)(@vitejs/devtools@0.7.6)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0) @@ -615,7 +661,7 @@ importers: dependencies: '@antfu/design': specifier: 'catalog:' - version: 0.5.3(@antfu/utils@9.3.0)(@tanstack/vue-virtual@3.13.39(vue@3.6.0-rc.5(typescript@6.0.3)))(@unocss/core@66.10.5)(colorjs.io@0.7.1)(playwright@1.63.0)(reka-ui@2.10.5(vue@3.6.0-rc.5(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.6.0-rc.5(typescript@6.0.3)))(unocss@66.10.5(@devframes/agentic@1.0.0)(cac@7.0.0)(rolldown@1.2.5)(rollup@4.63.0)(srvx@1.0.4)(vite@8.3.1))(vue@3.6.0-rc.5(typescript@6.0.3)) + version: 0.5.3(@antfu/utils@9.3.0)(@tanstack/vue-virtual@3.13.39(vue@3.6.0-rc.5(typescript@6.0.3)))(@unocss/core@66.10.5)(colorjs.io@0.7.1)(playwright@1.63.0)(reka-ui@2.10.5(vue@3.6.0-rc.5(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.6.0-rc.5(typescript@6.0.3)))(unocss@66.10.5(@devframes/agentic@1.1.0)(cac@7.0.0)(rolldown@1.2.5)(rollup@4.63.0)(srvx@1.0.4)(vite@8.3.1))(vue@3.6.0-rc.5(typescript@6.0.3)) '@antfu/utils': specifier: ^9.3.0 version: 9.3.0 @@ -643,7 +689,7 @@ importers: devDependencies: '@vitejs/devtools': specifier: ^0.7.6 - version: 0.7.6(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0))(crossws@0.4.12(srvx@1.0.4))(srvx@1.0.4)(vite@8.3.1) + version: 0.7.6(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0))(crossws@0.4.12(srvx@1.0.4))(srvx@1.0.4)(vite@8.3.1) '@vitejs/plugin-vue': specifier: 'catalog:' version: 6.0.9(vite@8.3.1)(vue@3.6.0-rc.5(typescript@6.0.3)) @@ -652,7 +698,7 @@ importers: version: 6.0.3 unocss: specifier: 'catalog:' - version: 66.10.5(@devframes/agentic@1.0.0)(cac@7.0.0)(rolldown@1.2.5)(rollup@4.63.0)(srvx@1.0.4)(vite@8.3.1) + version: 66.10.5(@devframes/agentic@1.1.0)(cac@7.0.0)(rolldown@1.2.5)(rollup@4.63.0)(srvx@1.0.4)(vite@8.3.1) vite: specifier: ^8.3.1 version: 8.3.1(@types/node@26.6.3)(@vitejs/devtools@0.7.6)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0) @@ -764,6 +810,11 @@ packages: peerDependencies: devframe: ^1.1.0 + '@devframes/agentic@1.1.0': + resolution: {integrity: sha512-YLD+yCMiNuUnf1EslRWx0j+kwY8+ByX8RjKghC1ElqX/SrkMGw21K1u8+sd0REdX3uHy22nxL+X/tFaEjk0oLg==} + peerDependencies: + devframe: ^1.1.0 + '@devframes/hub-ui@1.0.0': resolution: {integrity: sha512-uSmkN4MBoudNgzOSgwGh8/NyBhdUN35Ye1M1eokqB+ETR+dMoj5SJDusvbIN/kbpZue2zCcy7920SOhrrt2yLw==} peerDependencies: @@ -1115,18 +1166,26 @@ packages: peerDependencies: zod: ^4.0.0 - '@modelcontextprotocol/client@2.0.0': - resolution: {integrity: sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==} + '@modelcontextprotocol/client@2.2.0': + resolution: {integrity: sha512-LxCou/CSYQ6dwEnjhLZY0KnEuc8V4UJ3IEQCl/uR2yHobSQIEdJKUlKLRYRF5i5FqRGHy1eK7une3aAWDHLtig==} engines: {node: '>=20'} '@modelcontextprotocol/core@2.0.0': resolution: {integrity: sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==} engines: {node: '>=20'} + '@modelcontextprotocol/core@2.2.0': + resolution: {integrity: sha512-iLhmprRmWI8EcosOA3wVvww22z02NkgqhV4fBH6f/odQBsi7xnJc0HGmi21yWO+/iKw2yzqVE127Zhna5/+JXw==} + engines: {node: '>=20'} + '@modelcontextprotocol/server@2.0.0': resolution: {integrity: sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==} engines: {node: '>=20'} + '@modelcontextprotocol/server@2.2.0': + resolution: {integrity: sha512-qFnltjus6Gk8Lx6J1w2RXmr3YPN22MNT6qD6zjAkaiWIlFIFxWClz0rXHNuJF/KSUyn5YGhkSvXkWDbQHVLFEQ==} + engines: {node: '>=20'} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} engines: {node: ^22.20 || ^24.12 || >=25} @@ -2599,133 +2658,157 @@ packages: resolution: {integrity: sha512-Oc2KInVkPfUjEB4PeV6X0NvIoyYTzDe/WmqFrTwBqKs6YKnz4A7XwWQvJTb7M0rLz9a6WgpUUiiS5QKaCi4O/g==} cpu: [arm64] os: [android] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-darwin-arm64@0.10.2': resolution: {integrity: sha512-3H7eNPIHJndUJXZ4QUGBPq1zC6RGcUZfm7bymJes+/N7wTVWUn1bxZ9JcozYHpkWQNm9f23G8YWbaHD9aYH72A==} cpu: [arm64] os: [darwin] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-darwin-x64@0.10.2': resolution: {integrity: sha512-AJOUpR2s3LF9AWiiub/Uyc/UoXNTWcq8hK3cVI6fUSXtwh34dG21J6hRuNlO6JVJFTo4o3ScVvZOrh/YFfUEAA==} cpu: [x64] os: [darwin] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-freebsd-x64@0.10.2': resolution: {integrity: sha512-ms7DcZu87u5CiK/wMwvpKAvAmtaqKjCQIXNweMLypG6qbuiaOMQf1jpbB5+j46xBcgCovo8TQMcv0bCQLKIL9w==} cpu: [x64] os: [freebsd] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-linux-arm-gnu@0.10.2': resolution: {integrity: sha512-xJDpYAsKV5+eaiBhTYl05fvT6sst4PsCeuIFMRu9b0/WCSrNQPfCDtAQ5/MS6xNpsdujdZEPR+gmfWk3ZG5i3A==} cpu: [arm] os: [linux] libc: [glibc] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-linux-arm-musl@0.10.2': resolution: {integrity: sha512-qfTkgd7AEx61l4K9VtXWCGTxc/fmXJ4ZheDz3i+/AAJUtg4sa0bPrc0VBxPggB/rayR8Z3+JfrBItuyanBJJ9Q==} cpu: [arm] os: [linux] libc: [musl] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-linux-arm64-gnu@0.10.2': resolution: {integrity: sha512-4e6Mifm/4UdjtU3D8mATIrvgP+xEiH8xtQOjd0zpd72XKWT7ug3sdyhq2utkkZFP6BvYp7SgZrI+aR4VeIOHdw==} cpu: [arm64] os: [linux] libc: [glibc] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-linux-arm64-musl@0.10.2': resolution: {integrity: sha512-RdsJrUfDYFVV3JOmWFUWwSu31fa1APn12xDeIKxgl/YWxrabwtxsDBNjF2561c7tmcbiewdCUvngqRs8AyGVLA==} cpu: [arm64] os: [linux] libc: [musl] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-linux-x64-gnu@0.10.2': resolution: {integrity: sha512-mVzWimEPPreaPyXIUvxsHoJzvO7ckZ5j0LgQFHz04zQIRwt9A9T2hA7K5/jYjJKkMxWG/U2VjhGNwVhtyU+uqg==} cpu: [x64] os: [linux] libc: [glibc] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-linux-x64-musl@0.10.2': resolution: {integrity: sha512-Y77fyISurmr2mvO1yeq3u+x/WJbACgPR4w+lzEVx30ViCxZzIGrZPRN1yEdZ9xUNPNsIO5thBHIdRNG+UGUG+Q==} cpu: [x64] os: [linux] libc: [musl] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-win32-arm64@0.10.2': resolution: {integrity: sha512-1+tGLyG0u5YYy0lev4ck7/0sd8xJ9SZde0dLut7qLDPZV5WVTV8x6OxNXUne/PGuHZyO1vK+txPQzzHOyXHGSw==} cpu: [arm64] os: [win32] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-win32-x64@0.10.2': resolution: {integrity: sha512-8a2SExRohRbwC0MY4VpOF0/RSckrJ2ASZqAor5/RYSJJaeadR93n10gZzcKT6pY9ukiSOIZCRKVRD2tH73T0qA==} cpu: [x64] os: [win32] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-parser/binding-android-arm64@0.10.2': resolution: {integrity: sha512-2VPBU9fRGRAQ2xPAvghnec3oou5Nrxm2Bezhf+13UMspAxQSkF/32r+ySKXSwIPA5/RivumDJDxAxc301Sgicw==} cpu: [arm64] os: [android] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-darwin-arm64@0.10.2': resolution: {integrity: sha512-LD+PMZE51tCYTOss5HBkm3/AE39MvcMDBfWJx7A4yDcjfNbAQDnHZKtzSOuqrswx+TQHY0ws5xn6+fWOwtmfBA==} cpu: [arm64] os: [darwin] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-darwin-x64@0.10.2': resolution: {integrity: sha512-mmZ8cND+AoIIMRERyMinlg5ApHxP23B9jH2B5wT7T+dliPa9rubLxneB/SUjFwyUjGaFnB5G7t4YvpfbO5zbkQ==} cpu: [x64] os: [darwin] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-freebsd-x64@0.10.2': resolution: {integrity: sha512-gVIjaaIddbRfAhHlC8N809wQWml7mxfSVnzaLtzGXObTeFTPAg/YVnQXt1UOhPM1ah5eG/8Y0RUlNpB4GVe7eQ==} cpu: [x64] os: [freebsd] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-linux-arm-gnu@0.10.2': resolution: {integrity: sha512-q/XPPQQAPdlw05aPj30ygBhekmQryGOwxVraBgApjKK8yY1kNQgqq6XCYLF1WHSee+lhxclrTO7W0/bt7dR1GA==} cpu: [arm] os: [linux] libc: [glibc] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-linux-arm-musl@0.10.2': resolution: {integrity: sha512-A+Cb0I1hFF4wilTQpWs79k1aNnj4B2tkrHx3zsuUNF9BtVY2zPZ4yeQEM/zjXrS/qJlNMZVK9NrWJjwdpnTrfg==} cpu: [arm] os: [linux] libc: [musl] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-linux-arm64-gnu@0.10.2': resolution: {integrity: sha512-aGNSzqIqqphFwAdIFwVvKIyXD1Iy3CxrEJVIZAT87Ecyi4vCmmDD2v2l9h+Gd3/wSy3JZlOI792LJbKAjyy9rQ==} cpu: [arm64] os: [linux] libc: [glibc] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-linux-arm64-musl@0.10.2': resolution: {integrity: sha512-Ddl1sF0rtuCXyHGSOV6dcmdx+ETv1iD+IVFqQuOjzkJZWclxJxdBWX6ZJMRtTiGqGUTbqLEKiTXxpR/Bvqk+2A==} cpu: [arm64] os: [linux] libc: [musl] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-linux-x64-gnu@0.10.2': resolution: {integrity: sha512-/nlcpR6IF5U+0m5L9wvAIXeQa2DT+BXuvqKDsTcOTlcc0B4dHNyqE5hTXsS4hAyimpy2ZK8A1zMNF5hTrjg2cg==} cpu: [x64] os: [linux] libc: [glibc] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-linux-x64-musl@0.10.2': resolution: {integrity: sha512-GX80dxTQD/M/OryyuxJcFzFENzry3cDFPvCFTyWogNWQH3fSHfMrNfpwpl1YzMos1eV9NygK5GSDG7VArQlb4w==} cpu: [x64] os: [linux] libc: [musl] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-win32-arm64@0.10.2': resolution: {integrity: sha512-agePQBV4VHewiGU0ACSjscZ/hJd283f9JfAF19Gf1rI5+wy5tTgx4GS36i0b3xim15AQ4RCpDRosEvhLZ4zAOw==} cpu: [arm64] os: [win32] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-win32-x64@0.10.2': resolution: {integrity: sha512-s8//CMpgL5+y1lvDCdyh1rwGI5+ytDJywFJRe1vnhI7n0j+caxshNucurikYLLVeQ2SYFex6aPrzgQxkabBQRA==} cpu: [x64] os: [win32] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-toolchain/types@0.10.2': resolution: {integrity: sha512-sSeo4SSSToiS+sSD+bwn/s94EEcaLJ7tG5LCp8gFYC1G5VzxzX7fqB6m9RU1yMD8KTpu6zdU/I1NlQf8hVBJ9Q==} @@ -3722,6 +3805,10 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + scule@1.3.0: resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} @@ -4319,10 +4406,10 @@ packages: snapshots: - '@antfu/design@0.5.3(@antfu/utils@9.3.0)(@tanstack/vue-virtual@3.13.39(vue@3.5.43(typescript@6.0.3)))(@unocss/core@66.10.5)(colorjs.io@0.7.1)(playwright@1.63.0)(reka-ui@2.10.4(vue@3.5.43(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.5.43(typescript@6.0.3)))(unocss@66.10.5(@devframes/agentic@1.0.0)(cac@7.0.0)(rolldown@1.2.8)(rollup@4.63.0)(srvx@1.0.4)(vite@8.3.1))(vue@3.5.43(typescript@6.0.3))': + '@antfu/design@0.5.3(@antfu/utils@9.3.0)(@tanstack/vue-virtual@3.13.39(vue@3.5.43(typescript@6.0.3)))(@unocss/core@66.10.5)(colorjs.io@0.7.1)(playwright@1.63.0)(reka-ui@2.10.4(vue@3.5.43(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.5.43(typescript@6.0.3)))(unocss@66.10.5(@devframes/agentic@1.1.0)(cac@7.0.0)(rolldown@1.2.8)(rollup@4.63.0)(srvx@1.0.4)(vite@8.3.1))(vue@3.5.43(typescript@6.0.3))': dependencies: '@vueuse/core': 15.0.0(vue@3.5.43(typescript@6.0.3)) - unocss: 66.10.5(@devframes/agentic@1.0.0)(cac@7.0.0)(rolldown@1.2.8)(rollup@4.63.0)(srvx@1.0.4)(vite@8.3.1) + unocss: 66.10.5(@devframes/agentic@1.1.0)(cac@7.0.0)(rolldown@1.2.8)(rollup@4.63.0)(srvx@1.0.4)(vite@8.3.1) vue: 3.5.43(typescript@6.0.3) optionalDependencies: '@antfu/utils': 9.3.0 @@ -4333,10 +4420,10 @@ snapshots: reka-ui: 2.10.4(vue@3.5.43(typescript@6.0.3)) splitpanes: 4.1.2(vue@3.5.43(typescript@6.0.3)) - '@antfu/design@0.5.3(@antfu/utils@9.3.0)(@tanstack/vue-virtual@3.13.39(vue@3.6.0-rc.5(typescript@6.0.3)))(@unocss/core@66.10.5)(colorjs.io@0.7.1)(playwright@1.63.0)(reka-ui@2.10.5(vue@3.6.0-rc.5(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.6.0-rc.5(typescript@6.0.3)))(unocss@66.10.5(@devframes/agentic@1.0.0)(cac@7.0.0)(rolldown@1.2.5)(rollup@4.63.0)(srvx@1.0.4)(vite@8.3.1))(vue@3.6.0-rc.5(typescript@6.0.3))': + '@antfu/design@0.5.3(@antfu/utils@9.3.0)(@tanstack/vue-virtual@3.13.39(vue@3.6.0-rc.5(typescript@6.0.3)))(@unocss/core@66.10.5)(colorjs.io@0.7.1)(playwright@1.63.0)(reka-ui@2.10.5(vue@3.6.0-rc.5(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.6.0-rc.5(typescript@6.0.3)))(unocss@66.10.5(@devframes/agentic@1.1.0)(cac@7.0.0)(rolldown@1.2.5)(rollup@4.63.0)(srvx@1.0.4)(vite@8.3.1))(vue@3.6.0-rc.5(typescript@6.0.3))': dependencies: '@vueuse/core': 15.0.0(vue@3.6.0-rc.5(typescript@6.0.3)) - unocss: 66.10.5(@devframes/agentic@1.0.0)(cac@7.0.0)(rolldown@1.2.5)(rollup@4.63.0)(srvx@1.0.4)(vite@8.3.1) + unocss: 66.10.5(@devframes/agentic@1.1.0)(cac@7.0.0)(rolldown@1.2.5)(rollup@4.63.0)(srvx@1.0.4)(vite@8.3.1) vue: 3.6.0-rc.5(typescript@6.0.3) optionalDependencies: '@antfu/utils': 9.3.0 @@ -4408,7 +4495,7 @@ snapshots: '@devframes/agentic@1.0.0(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0)': dependencies: - '@modelcontextprotocol/client': 2.0.0 + '@modelcontextprotocol/client': 2.2.0 '@modelcontextprotocol/server': 2.0.0 devframe: 1.1.0(@devframes/agentic@1.0.0)(cac@7.0.0)(srvx@1.0.4) h3: 2.0.1-rc.32(crossws@0.4.12(srvx@1.0.4)) @@ -4419,9 +4506,9 @@ snapshots: '@devframes/agentic@1.0.0(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0)': dependencies: - '@modelcontextprotocol/client': 2.0.0 + '@modelcontextprotocol/client': 2.2.0 '@modelcontextprotocol/server': 2.0.0 - devframe: 1.1.0(@devframes/agentic@1.0.0)(cac@7.0.0)(srvx@1.0.4) + devframe: 1.1.0(@devframes/agentic@1.0.0)(cac@7.0.0)(srvx@1.0.5) h3: 2.0.1-rc.32(crossws@0.4.12(srvx@1.0.5)) pathe: 2.0.3 transitivePeerDependencies: @@ -4429,6 +4516,29 @@ snapshots: - ocache optional: true + '@devframes/agentic@1.1.0(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0)': + dependencies: + '@modelcontextprotocol/client': 2.2.0 + '@modelcontextprotocol/server': 2.2.0 + devframe: 1.1.0(@devframes/agentic@1.1.0)(cac@7.0.0)(srvx@1.0.4) + h3: 2.0.1-rc.32(crossws@0.4.12(srvx@1.0.4)) + pathe: 2.0.3 + transitivePeerDependencies: + - crossws + - ocache + optional: true + + '@devframes/agentic@1.1.0(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0)': + dependencies: + '@modelcontextprotocol/client': 2.2.0 + '@modelcontextprotocol/server': 2.2.0 + devframe: 1.1.0(@devframes/agentic@1.1.0)(cac@7.0.0)(srvx@1.0.5) + h3: 2.0.1-rc.32(crossws@0.4.12(srvx@1.0.5)) + pathe: 2.0.3 + transitivePeerDependencies: + - crossws + - ocache + '@devframes/hub-ui@1.0.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0))(devframe@1.1.0)': dependencies: '@devframes/hub': 1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0) @@ -4436,6 +4546,21 @@ snapshots: optionalDependencies: '@devframes/json-render': 1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0) + '@devframes/hub-ui@1.0.0(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0))(devframe@1.1.0)': + dependencies: + '@devframes/hub': 1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0) + devframe: 1.1.0(@devframes/agentic@1.1.0)(cac@7.0.0)(srvx@1.0.4) + optionalDependencies: + '@devframes/json-render': 1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0) + + '@devframes/hub-ui@1.0.0(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0))(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0))(devframe@1.1.0))(devframe@1.1.0)': + dependencies: + '@devframes/hub': 1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0) + devframe: 1.1.0(@devframes/agentic@1.0.0)(cac@7.0.0)(srvx@1.0.5) + optionalDependencies: + '@devframes/json-render': 1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0))(devframe@1.1.0) + optional: true + '@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0)': dependencies: destr: 2.0.5 @@ -4453,13 +4578,42 @@ snapshots: '@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0)': dependencies: destr: 2.0.5 - devframe: 1.1.0(@devframes/agentic@1.0.0)(cac@7.0.0)(srvx@1.0.4) + devframe: 1.1.0(@devframes/agentic@1.0.0)(cac@7.0.0)(srvx@1.0.5) h3: 2.0.1-rc.32(crossws@0.4.12(srvx@1.0.5)) pathe: 2.0.3 tinyexec: 1.3.1 zigpty: 0.2.1 optionalDependencies: - '@devframes/agentic': 1.0.0(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0) + '@devframes/agentic': 1.0.0(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0) + transitivePeerDependencies: + - crossws + - ocache + optional: true + + '@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0)': + dependencies: + destr: 2.0.5 + devframe: 1.1.0(@devframes/agentic@1.1.0)(cac@7.0.0)(srvx@1.0.4) + h3: 2.0.1-rc.32(crossws@0.4.12(srvx@1.0.4)) + pathe: 2.0.3 + tinyexec: 1.3.1 + zigpty: 0.2.1 + optionalDependencies: + '@devframes/agentic': 1.1.0(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0) + transitivePeerDependencies: + - crossws + - ocache + + '@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0)': + dependencies: + destr: 2.0.5 + devframe: 1.1.0(@devframes/agentic@1.1.0)(cac@7.0.0)(srvx@1.0.5) + h3: 2.0.1-rc.32(crossws@0.4.12(srvx@1.0.5)) + pathe: 2.0.3 + tinyexec: 1.3.1 + zigpty: 0.2.1 + optionalDependencies: + '@devframes/agentic': 1.1.0(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0) transitivePeerDependencies: - crossws - ocache @@ -4470,6 +4624,17 @@ snapshots: '@devframes/hub': 1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0) devframe: 1.1.0(@devframes/agentic@1.0.0)(cac@7.0.0)(srvx@1.0.4) + '@devframes/json-render-ui@1.0.0(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0)': + optionalDependencies: + '@devframes/hub': 1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0) + devframe: 1.1.0(@devframes/agentic@1.1.0)(cac@7.0.0)(srvx@1.0.4) + + '@devframes/json-render-ui@1.0.0(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0))(devframe@1.1.0)': + optionalDependencies: + '@devframes/hub': 1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0) + devframe: 1.1.0(@devframes/agentic@1.0.0)(cac@7.0.0)(srvx@1.0.5) + optional: true + '@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0)': dependencies: '@json-render/core': 0.21.0(zod@4.6.5) @@ -4478,6 +4643,23 @@ snapshots: optionalDependencies: '@devframes/hub': 1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0) + '@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0)': + dependencies: + '@json-render/core': 0.21.0(zod@4.6.5) + devframe: 1.1.0(@devframes/agentic@1.1.0)(cac@7.0.0)(srvx@1.0.4) + zod: 4.6.5 + optionalDependencies: + '@devframes/hub': 1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0) + + '@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0))(devframe@1.1.0)': + dependencies: + '@json-render/core': 0.21.0(zod@4.6.5) + devframe: 1.1.0(@devframes/agentic@1.0.0)(cac@7.0.0)(srvx@1.0.5) + zod: 4.6.5 + optionalDependencies: + '@devframes/hub': 1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0) + optional: true + '@devframes/plugin-inspect@1.1.0(devframe@1.1.0)(vite@8.3.1)': dependencies: cac: 7.0.0 @@ -4494,6 +4676,25 @@ snapshots: optionalDependencies: vite: 8.3.1(@types/node@26.6.3)(@vitejs/devtools@0.7.6)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0) + '@devframes/plugin-messages@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0)(vite@8.3.1)': + dependencies: + '@devframes/hub': 1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0) + '@devframes/service-open': 1.1.0(devframe@1.1.0) + cac: 7.0.0 + devframe: 1.1.0(@devframes/agentic@1.1.0)(cac@7.0.0)(srvx@1.0.4) + optionalDependencies: + vite: 8.3.1(@types/node@26.6.3)(@vitejs/devtools@0.7.6)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0) + + '@devframes/plugin-messages@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0))(devframe@1.1.0)(vite@8.3.1)': + dependencies: + '@devframes/hub': 1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0) + '@devframes/service-open': 1.1.0(devframe@1.1.0) + cac: 7.0.0 + devframe: 1.1.0(@devframes/agentic@1.0.0)(cac@7.0.0)(srvx@1.0.5) + optionalDependencies: + vite: 8.3.1(@types/node@26.6.3)(@vitejs/devtools@0.7.6)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0) + optional: true + '@devframes/plugin-terminals@1.1.0(@devframes/hub-ui@1.0.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0))(devframe@1.1.0))(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0)(vite@8.3.1)': dependencies: '@devframes/vite': 1.1.0(@devframes/hub-ui@1.0.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0))(devframe@1.1.0))(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0)(vite@8.3.1) @@ -4506,6 +4707,31 @@ snapshots: - '@devframes/hub' - '@devframes/hub-ui' + '@devframes/plugin-terminals@1.1.0(@devframes/hub-ui@1.0.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0))(devframe@1.1.0))(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0)(vite@8.3.1)': + dependencies: + '@devframes/vite': 1.1.0(@devframes/hub-ui@1.0.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0))(devframe@1.1.0))(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0)(vite@8.3.1) + cac: 7.0.0 + devframe: 1.1.0(@devframes/agentic@1.1.0)(cac@7.0.0)(srvx@1.0.4) + zigpty: 0.2.1 + optionalDependencies: + vite: 8.3.1(@types/node@26.6.3)(@vitejs/devtools@0.7.6)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0) + transitivePeerDependencies: + - '@devframes/hub' + - '@devframes/hub-ui' + + '@devframes/plugin-terminals@1.1.0(@devframes/hub-ui@1.0.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0))(devframe@1.1.0))(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0))(devframe@1.1.0)(vite@8.3.1)': + dependencies: + '@devframes/vite': 1.1.0(@devframes/hub-ui@1.0.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0))(devframe@1.1.0))(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0))(devframe@1.1.0)(vite@8.3.1) + cac: 7.0.0 + devframe: 1.1.0(@devframes/agentic@1.0.0)(cac@7.0.0)(srvx@1.0.5) + zigpty: 0.2.1 + optionalDependencies: + vite: 8.3.1(@types/node@26.6.3)(@vitejs/devtools@0.7.6)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0) + transitivePeerDependencies: + - '@devframes/hub' + - '@devframes/hub-ui' + optional: true + '@devframes/service-open@1.1.0(devframe@1.1.0)': dependencies: devframe: 1.1.0(@devframes/agentic@1.0.0)(cac@7.0.0)(srvx@1.0.4) @@ -4520,6 +4746,25 @@ snapshots: '@devframes/hub-ui': 1.0.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0))(devframe@1.1.0) vite: 8.3.1(@types/node@26.6.3)(@vitejs/devtools@0.7.6)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0) + '@devframes/vite@1.1.0(@devframes/hub-ui@1.0.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0))(devframe@1.1.0))(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0)(vite@8.3.1)': + dependencies: + devframe: 1.1.0(@devframes/agentic@1.1.0)(cac@7.0.0)(srvx@1.0.4) + pathe: 2.0.3 + optionalDependencies: + '@devframes/hub': 1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0) + '@devframes/hub-ui': 1.0.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0))(devframe@1.1.0) + vite: 8.3.1(@types/node@26.6.3)(@vitejs/devtools@0.7.6)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0) + + '@devframes/vite@1.1.0(@devframes/hub-ui@1.0.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0))(devframe@1.1.0))(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0))(devframe@1.1.0)(vite@8.3.1)': + dependencies: + devframe: 1.1.0(@devframes/agentic@1.0.0)(cac@7.0.0)(srvx@1.0.5) + pathe: 2.0.3 + optionalDependencies: + '@devframes/hub': 1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0) + '@devframes/hub-ui': 1.0.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0))(devframe@1.1.0) + vite: 8.3.1(@types/node@26.6.3)(@vitejs/devtools@0.7.6)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0) + optional: true + '@docsearch/css@4.7.0': {} '@docsearch/js@4.7.0': {} @@ -4725,9 +4970,9 @@ snapshots: dependencies: zod: 4.6.5 - '@modelcontextprotocol/client@2.0.0': + '@modelcontextprotocol/client@2.2.0': dependencies: - '@modelcontextprotocol/core': 2.0.0 + '@modelcontextprotocol/core': 2.2.0 cross-spawn: 7.0.6 eventsource: 3.0.7 eventsource-parser: 3.1.1 @@ -4739,11 +4984,20 @@ snapshots: dependencies: zod: 4.6.5 + '@modelcontextprotocol/core@2.2.0': + dependencies: + zod: 4.6.5 + '@modelcontextprotocol/server@2.0.0': dependencies: '@modelcontextprotocol/core': 2.0.0 zod: 4.6.5 + '@modelcontextprotocol/server@2.2.0': + dependencies: + '@modelcontextprotocol/core': 2.2.0 + zod: 4.6.5 + '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true @@ -4809,7 +5063,8 @@ snapshots: '@oxc-project/types@0.146.0': optional: true - '@oxc-project/types@0.149.0': {} + '@oxc-project/types@0.149.0': + optional: true '@oxc-project/types@0.150.0': {} @@ -5352,12 +5607,12 @@ snapshots: dependencies: '@unocss/core': 66.10.5 - '@unocss/inspector@66.10.5(@devframes/agentic@1.0.0)(cac@7.0.0)(srvx@1.0.4)': + '@unocss/inspector@66.10.5(@devframes/agentic@1.1.0)(cac@7.0.0)(srvx@1.0.4)': dependencies: '@unocss/config': 66.10.5 '@unocss/core': 66.10.5 '@unocss/rule-utils': 66.10.5 - devframe: 1.1.0(@devframes/agentic@1.0.0)(cac@7.0.0)(srvx@1.0.4) + devframe: 1.1.0(@devframes/agentic@1.1.0)(cac@7.0.0)(srvx@1.0.4) tinyglobby: 0.2.17 unplugin-utils: 0.3.2 transitivePeerDependencies: @@ -5366,12 +5621,12 @@ snapshots: - ocache - srvx - '@unocss/inspector@66.10.5(@devframes/agentic@1.0.0)(cac@7.0.0)(srvx@1.0.5)': + '@unocss/inspector@66.10.5(@devframes/agentic@1.1.0)(cac@7.0.0)(srvx@1.0.5)': dependencies: '@unocss/config': 66.10.5 '@unocss/core': 66.10.5 '@unocss/rule-utils': 66.10.5 - devframe: 1.1.0(@devframes/agentic@1.0.0)(cac@7.0.0)(srvx@1.0.5) + devframe: 1.1.0(@devframes/agentic@1.1.0)(cac@7.0.0)(srvx@1.0.5) tinyglobby: 0.2.17 unplugin-utils: 0.3.2 transitivePeerDependencies: @@ -5487,12 +5742,12 @@ snapshots: dependencies: '@unocss/core': 66.10.5 - '@unocss/vite@66.10.5(@devframes/agentic@1.0.0)(cac@7.0.0)(srvx@1.0.4)(vite@8.3.1)': + '@unocss/vite@66.10.5(@devframes/agentic@1.1.0)(cac@7.0.0)(srvx@1.0.4)(vite@8.3.1)': dependencies: '@jridgewell/remapping': 2.3.5 '@unocss/config': 66.10.5 '@unocss/core': 66.10.5 - '@unocss/inspector': 66.10.5(@devframes/agentic@1.0.0)(cac@7.0.0)(srvx@1.0.4) + '@unocss/inspector': 66.10.5(@devframes/agentic@1.1.0)(cac@7.0.0)(srvx@1.0.4) chokidar: 5.0.0 magic-string: 1.4.1 pathe: 2.0.3 @@ -5505,12 +5760,12 @@ snapshots: - ocache - srvx - '@unocss/vite@66.10.5(@devframes/agentic@1.0.0)(cac@7.0.0)(srvx@1.0.5)(vite@8.3.1)': + '@unocss/vite@66.10.5(@devframes/agentic@1.1.0)(cac@7.0.0)(srvx@1.0.5)(vite@8.3.1)': dependencies: '@jridgewell/remapping': 2.3.5 '@unocss/config': 66.10.5 '@unocss/core': 66.10.5 - '@unocss/inspector': 66.10.5(@devframes/agentic@1.0.0)(cac@7.0.0)(srvx@1.0.5) + '@unocss/inspector': 66.10.5(@devframes/agentic@1.1.0)(cac@7.0.0)(srvx@1.0.5) chokidar: 5.0.0 magic-string: 1.4.1 pathe: 2.0.3 @@ -5542,7 +5797,7 @@ snapshots: '@vitejs/devtools-kit@0.7.6(@devframes/agentic@1.0.0)(cac@7.0.0)(crossws@0.4.12(srvx@1.0.5))(srvx@1.0.5)(vite@8.3.1)': dependencies: '@devframes/hub': 1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0) - '@devframes/json-render': 1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0) + '@devframes/json-render': 1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0))(devframe@1.1.0) devframe: 1.1.0(@devframes/agentic@1.0.0)(cac@7.0.0)(srvx@1.0.5) local-pkg: 1.2.1 nostics: 1.3.0 @@ -5556,6 +5811,22 @@ snapshots: - srvx optional: true + '@vitejs/devtools-kit@0.7.6(@devframes/agentic@1.1.0)(cac@7.0.0)(crossws@0.4.12(srvx@1.0.4))(srvx@1.0.4)(vite@8.3.1)': + dependencies: + '@devframes/hub': 1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0) + '@devframes/json-render': 1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0) + devframe: 1.1.0(@devframes/agentic@1.1.0)(cac@7.0.0)(srvx@1.0.4) + local-pkg: 1.2.1 + nostics: 1.3.0 + package-manager-detector: 1.8.0 + vite: 8.3.1(@types/node@26.6.3)(@vitejs/devtools@0.7.6)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0) + transitivePeerDependencies: + - '@devframes/agentic' + - cac + - crossws + - ocache + - srvx + '@vitejs/devtools@0.7.6(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0))(crossws@0.4.12(srvx@1.0.4))(srvx@1.0.4)(vite@8.3.1)': dependencies: '@devframes/agentic': 1.0.0(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0) @@ -5609,6 +5880,60 @@ snapshots: - crossws - ocache - srvx + + '@vitejs/devtools@0.7.6(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0))(crossws@0.4.12(srvx@1.0.4))(srvx@1.0.4)(vite@8.3.1)': + dependencies: + '@devframes/agentic': 1.0.0(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0) + '@devframes/hub': 1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0) + '@devframes/hub-ui': 1.0.0(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0))(devframe@1.1.0) + '@devframes/json-render-ui': 1.0.0(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0) + '@devframes/plugin-inspect': 1.1.0(devframe@1.1.0)(vite@8.3.1) + '@devframes/plugin-messages': 1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0)(vite@8.3.1) + '@devframes/plugin-terminals': 1.1.0(@devframes/hub-ui@1.0.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0))(devframe@1.1.0))(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0)(vite@8.3.1) + '@vitejs/devtools-kit': 0.7.6(@devframes/agentic@1.0.0)(cac@7.0.0)(crossws@0.4.12(srvx@1.0.4))(srvx@1.0.4)(vite@8.3.1) + cac: 7.0.0 + devframe: 1.1.0(@devframes/agentic@1.0.0)(cac@7.0.0)(srvx@1.0.4) + h3: 2.0.1-rc.32(crossws@0.4.12(srvx@1.0.4)) + local-pkg: 1.2.1 + nostics: 1.3.0 + obug: 3.0.0 + pathe: 2.0.3 + vite: 8.3.1(@types/node@26.6.3)(@vitejs/devtools@0.7.6)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0) + transitivePeerDependencies: + - '@devframes/json-render' + - '@devframes/plugin-inspect--assets' + - '@devframes/plugin-messages--assets' + - '@devframes/plugin-terminals--assets' + - crossws + - ocache + - srvx + + '@vitejs/devtools@0.7.6(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0))(devframe@1.1.0))(crossws@0.4.12(srvx@1.0.5))(srvx@1.0.5)(vite@8.3.1)': + dependencies: + '@devframes/agentic': 1.0.0(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0) + '@devframes/hub': 1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0) + '@devframes/hub-ui': 1.0.0(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0))(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0))(devframe@1.1.0))(devframe@1.1.0) + '@devframes/json-render-ui': 1.0.0(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0))(devframe@1.1.0) + '@devframes/plugin-inspect': 1.1.0(devframe@1.1.0)(vite@8.3.1) + '@devframes/plugin-messages': 1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0))(devframe@1.1.0)(vite@8.3.1) + '@devframes/plugin-terminals': 1.1.0(@devframes/hub-ui@1.0.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0))(devframe@1.1.0))(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0))(devframe@1.1.0)(vite@8.3.1) + '@vitejs/devtools-kit': 0.7.6(@devframes/agentic@1.0.0)(cac@7.0.0)(crossws@0.4.12(srvx@1.0.5))(srvx@1.0.5)(vite@8.3.1) + cac: 7.0.0 + devframe: 1.1.0(@devframes/agentic@1.0.0)(cac@7.0.0)(srvx@1.0.5) + h3: 2.0.1-rc.32(crossws@0.4.12(srvx@1.0.5)) + local-pkg: 1.2.1 + nostics: 1.3.0 + obug: 3.0.0 + pathe: 2.0.3 + vite: 8.3.1(@types/node@26.6.3)(@vitejs/devtools@0.7.6)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0) + transitivePeerDependencies: + - '@devframes/json-render' + - '@devframes/plugin-inspect--assets' + - '@devframes/plugin-messages--assets' + - '@devframes/plugin-terminals--assets' + - crossws + - ocache + - srvx optional: true '@vitejs/plugin-vue@6.0.8(vite@8.3.1)(vue@3.5.43(typescript@6.0.3))': @@ -6487,7 +6812,34 @@ snapshots: nostics: 1.3.0 pathe: 2.0.3 optionalDependencies: - '@devframes/agentic': 1.0.0(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0) + '@devframes/agentic': 1.0.0(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0) + cac: 7.0.0 + transitivePeerDependencies: + - ocache + - srvx + optional: true + + devframe@1.1.0(@devframes/agentic@1.1.0)(cac@7.0.0)(srvx@1.0.4): + dependencies: + crossws: 0.4.12(srvx@1.0.4) + h3: 2.0.1-rc.32(crossws@0.4.12(srvx@1.0.4)) + nostics: 1.3.0 + pathe: 2.0.3 + optionalDependencies: + '@devframes/agentic': 1.1.0(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0) + cac: 7.0.0 + transitivePeerDependencies: + - ocache + - srvx + + devframe@1.1.0(@devframes/agentic@1.1.0)(cac@7.0.0)(srvx@1.0.5): + dependencies: + crossws: 0.4.12(srvx@1.0.5) + h3: 2.0.1-rc.32(crossws@0.4.12(srvx@1.0.5)) + nostics: 1.3.0 + pathe: 2.0.3 + optionalDependencies: + '@devframes/agentic': 1.1.0(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0) cac: 7.0.0 transitivePeerDependencies: - ocache @@ -7177,12 +7529,12 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 - rolldown-plugin-dts@0.28.6(@volar/typescript@2.4.28(typescript@6.0.3))(@vue/language-core@3.3.11)(rolldown@1.2.8)(typescript@6.0.3)(vue-tsc@3.3.11(typescript@6.0.3)): + rolldown-plugin-dts@0.28.6(@volar/typescript@2.4.28(typescript@6.0.3))(@vue/language-core@3.3.11)(rolldown@1.2.11)(typescript@6.0.3)(vue-tsc@3.3.11(typescript@6.0.3)): dependencies: dts-resolver: 3.0.0 get-tsconfig: 5.0.0-beta.6 obug: 3.0.0 - rolldown: 1.2.8 + rolldown: 1.2.11 yuku-ast: 0.10.2 yuku-codegen: 0.10.2 yuku-parser: 0.10.2 @@ -7257,6 +7609,7 @@ snapshots: '@rolldown/binding-openharmony-arm64': 1.2.8 '@rolldown/binding-win32-arm64-msvc': 1.2.8 '@rolldown/binding-win32-x64-msvc': 1.2.8 + optional: true rollup@4.63.0: dependencies: @@ -7302,6 +7655,8 @@ snapshots: safe-buffer@5.2.1: {} + safe-stable-stringify@2.5.0: {} + scule@1.3.0: {} semver@7.8.5: {} @@ -7464,15 +7819,15 @@ snapshots: import-without-cache: 0.4.0 obug: 2.1.4 picomatch: 4.0.7 - rolldown: 1.2.8 - rolldown-plugin-dts: 0.28.6(@volar/typescript@2.4.28(typescript@6.0.3))(@vue/language-core@3.3.11)(rolldown@1.2.8)(typescript@6.0.3)(vue-tsc@3.3.11(typescript@6.0.3)) + rolldown: 1.2.11 + rolldown-plugin-dts: 0.28.6(@volar/typescript@2.4.28(typescript@6.0.3))(@vue/language-core@3.3.11)(rolldown@1.2.11)(typescript@6.0.3)(vue-tsc@3.3.11(typescript@6.0.3)) tinyexec: 1.3.1 tinyglobby: 0.2.17 tree-kill: 1.2.2 unconfig-core: 7.5.0 verkit: 0.4.1 optionalDependencies: - '@vitejs/devtools': 0.7.6(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0))(crossws@0.4.12(srvx@1.0.5))(srvx@1.0.5)(vite@8.3.1) + '@vitejs/devtools': 0.7.6(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0))(devframe@1.1.0))(crossws@0.4.12(srvx@1.0.5))(srvx@1.0.5)(vite@8.3.1) publint: 0.3.21 typescript: 6.0.3 transitivePeerDependencies: @@ -7491,15 +7846,15 @@ snapshots: import-without-cache: 0.4.0 obug: 2.1.4 picomatch: 4.0.7 - rolldown: 1.2.8 - rolldown-plugin-dts: 0.28.6(@volar/typescript@2.4.28(typescript@6.0.3))(@vue/language-core@3.3.11)(rolldown@1.2.8)(typescript@6.0.3)(vue-tsc@3.3.11(typescript@6.0.3)) + rolldown: 1.2.11 + rolldown-plugin-dts: 0.28.6(@volar/typescript@2.4.28(typescript@6.0.3))(@vue/language-core@3.3.11)(rolldown@1.2.11)(typescript@6.0.3)(vue-tsc@3.3.11(typescript@6.0.3)) tinyexec: 1.3.1 tinyglobby: 0.2.17 tree-kill: 1.2.2 unconfig-core: 7.5.0 verkit: 0.4.1 optionalDependencies: - '@vitejs/devtools': 0.7.6(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0))(crossws@0.4.12(srvx@1.0.4))(srvx@1.0.4)(vite@8.3.1) + '@vitejs/devtools': 0.7.6(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0))(crossws@0.4.12(srvx@1.0.4))(srvx@1.0.4)(vite@8.3.1) typescript: 6.0.3 transitivePeerDependencies: - '@typescript/native-preview' @@ -7517,15 +7872,15 @@ snapshots: import-without-cache: 0.4.0 obug: 2.1.4 picomatch: 4.0.7 - rolldown: 1.2.8 - rolldown-plugin-dts: 0.28.6(@volar/typescript@2.4.28(typescript@6.0.3))(@vue/language-core@3.3.11)(rolldown@1.2.8)(typescript@6.0.3)(vue-tsc@3.3.11(typescript@6.0.3)) + rolldown: 1.2.11 + rolldown-plugin-dts: 0.28.6(@volar/typescript@2.4.28(typescript@6.0.3))(@vue/language-core@3.3.11)(rolldown@1.2.11)(typescript@6.0.3)(vue-tsc@3.3.11(typescript@6.0.3)) tinyexec: 1.3.1 tinyglobby: 0.2.17 tree-kill: 1.2.2 unconfig-core: 7.5.0 verkit: 0.4.1 optionalDependencies: - '@vitejs/devtools': 0.7.6(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0))(crossws@0.4.12(srvx@1.0.4))(srvx@1.0.4)(vite@8.3.1) + '@vitejs/devtools': 0.7.6(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0))(crossws@0.4.12(srvx@1.0.4))(srvx@1.0.4)(vite@8.3.1) publint: 0.3.21 typescript: 6.0.3 transitivePeerDependencies: @@ -7581,7 +7936,7 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - unocss@66.10.5(@devframes/agentic@1.0.0)(cac@7.0.0)(rolldown@1.2.5)(rollup@4.63.0)(srvx@1.0.4)(vite@8.3.1): + unocss@66.10.5(@devframes/agentic@1.1.0)(cac@7.0.0)(rolldown@1.2.5)(rollup@4.63.0)(srvx@1.0.4)(vite@8.3.1): dependencies: '@unocss/cli': 66.10.5 '@unocss/core': 66.10.5 @@ -7600,7 +7955,7 @@ snapshots: '@unocss/transformer-compile-class': 66.10.5 '@unocss/transformer-directives': 66.10.5 '@unocss/transformer-variant-group': 66.10.5 - '@unocss/vite': 66.10.5(@devframes/agentic@1.0.0)(cac@7.0.0)(srvx@1.0.4)(vite@8.3.1) + '@unocss/vite': 66.10.5(@devframes/agentic@1.1.0)(cac@7.0.0)(srvx@1.0.4)(vite@8.3.1) transitivePeerDependencies: - '@devframes/agentic' - cac @@ -7610,7 +7965,7 @@ snapshots: - srvx - vite - unocss@66.10.5(@devframes/agentic@1.0.0)(cac@7.0.0)(rolldown@1.2.8)(rollup@4.63.0)(srvx@1.0.4)(vite@8.3.1): + unocss@66.10.5(@devframes/agentic@1.1.0)(cac@7.0.0)(rolldown@1.2.8)(rollup@4.63.0)(srvx@1.0.4)(vite@8.3.1): dependencies: '@unocss/cli': 66.10.5 '@unocss/core': 66.10.5 @@ -7629,7 +7984,7 @@ snapshots: '@unocss/transformer-compile-class': 66.10.5 '@unocss/transformer-directives': 66.10.5 '@unocss/transformer-variant-group': 66.10.5 - '@unocss/vite': 66.10.5(@devframes/agentic@1.0.0)(cac@7.0.0)(srvx@1.0.4)(vite@8.3.1) + '@unocss/vite': 66.10.5(@devframes/agentic@1.1.0)(cac@7.0.0)(srvx@1.0.4)(vite@8.3.1) transitivePeerDependencies: - '@devframes/agentic' - cac @@ -7639,7 +7994,7 @@ snapshots: - srvx - vite - unocss@66.10.5(@devframes/agentic@1.0.0)(cac@7.0.0)(rolldown@1.2.8)(rollup@4.63.0)(srvx@1.0.5)(vite@8.3.1): + unocss@66.10.5(@devframes/agentic@1.1.0)(cac@7.0.0)(rolldown@1.2.8)(rollup@4.63.0)(srvx@1.0.5)(vite@8.3.1): dependencies: '@unocss/cli': 66.10.5 '@unocss/core': 66.10.5 @@ -7658,7 +8013,7 @@ snapshots: '@unocss/transformer-compile-class': 66.10.5 '@unocss/transformer-directives': 66.10.5 '@unocss/transformer-variant-group': 66.10.5 - '@unocss/vite': 66.10.5(@devframes/agentic@1.0.0)(cac@7.0.0)(srvx@1.0.5)(vite@8.3.1) + '@unocss/vite': 66.10.5(@devframes/agentic@1.1.0)(cac@7.0.0)(srvx@1.0.5)(vite@8.3.1) transitivePeerDependencies: - '@devframes/agentic' - cac @@ -7815,7 +8170,7 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 26.6.3 - '@vitejs/devtools': 0.7.6(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.0.0)(crossws@0.4.12(srvx@1.0.4))(devframe@1.1.0))(devframe@1.1.0))(crossws@0.4.12(srvx@1.0.4))(srvx@1.0.4)(vite@8.3.1) + '@vitejs/devtools': 0.7.6(@devframes/json-render@1.1.0(@devframes/hub@1.1.0(@devframes/agentic@1.1.0)(crossws@0.4.12(srvx@1.0.5))(devframe@1.1.0))(devframe@1.1.0))(crossws@0.4.12(srvx@1.0.5))(srvx@1.0.5)(vite@8.3.1) esbuild: 0.28.2 fsevents: 2.3.3 jiti: 2.6.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ae09d2dfa..25953c079 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,11 @@ packages: - docs catalog: + safe-stable-stringify: ^2.5.0 + '@modelcontextprotocol/client': ^2.2.0 + '@devframes/agentic': ^1.1.0 + cac: ^7.0.0 + zod: ^4.6.5 colorjs.io: ^0.7.1 '@antfu/design': ^0.5.3 '@iconify-json/carbon': ^1.2.28 diff --git a/tests/fixtures/vite-smoke/AgentFixtures.vue b/tests/fixtures/vite-smoke/AgentFixtures.vue new file mode 100644 index 000000000..bff9d05f8 --- /dev/null +++ b/tests/fixtures/vite-smoke/AgentFixtures.vue @@ -0,0 +1,38 @@ + + + diff --git a/tests/fixtures/vite-smoke/App.vue b/tests/fixtures/vite-smoke/App.vue index 8ef789518..91363e1da 100644 --- a/tests/fixtures/vite-smoke/App.vue +++ b/tests/fixtures/vite-smoke/App.vue @@ -1,6 +1,9 @@ diff --git a/tests/smoke/agent.test.ts b/tests/smoke/agent.test.ts new file mode 100644 index 000000000..1cc4dbfb6 --- /dev/null +++ b/tests/smoke/agent.test.ts @@ -0,0 +1,265 @@ +import { createVueDevtoolsAgentTools } from '../../packages/agent/src' +import { Client, type CallToolResult } from '@modelcontextprotocol/client' +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio' +import vue from '@vitejs/plugin-vue' +import { chromium, type Browser } from 'playwright' +import { resolve } from 'node:path' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { afterAll, beforeAll, expect, it, onTestFinished } from 'vitest' +import { createServer } from 'vite' +import vueDevtools from '../../packages/vite/src' + +const fixtureRoot = resolve('tests/fixtures/vite-smoke') +let browser: Browser + +beforeAll(async () => { + browser = await chromium.launch({ + headless: true, + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, + }) +}) + +afterAll(async () => { + await browser?.close() +}) + +it('discovers Vue tools and reads component state through the CLI connector', async () => { + const { origin, page } = await startApp() + const errors: string[] = [] + page.on('pageerror', (error) => errors.push(error.message)) + await page.goto(`${origin}/?agent`) + + const connector = new Client({ name: 'vue-agent-smoke', version: '1' }) + onTestFinished(() => connector.close()) + await connector.connect( + new StdioClientTransport({ + command: process.execPath, + args: [resolve('packages/agent/dist/cli.mjs'), 'connect'], + }), + ) + + interface Instance { + origin: string + rootDir: string + port: number + mcp?: { tools: { name: string }[] } + } + async function discover() { + const result = await connector.callTool({ name: 'devframe_connect_list-instances' }) + return readResult<{ instances: Instance[] }>(result).instances.find( + (instance) => instance.origin === origin, + ) + } + await expect + .poll(async () => + (await discover())?.mcp?.tools.some((tool) => tool.name.endsWith('list-apps')), + ) + .toBe(true) + const instance = (await discover())! + expect(instance.rootDir).toBe(fixtureRoot) + + async function callResult(suffix: string, args: Record = {}) { + const tool = instance.mcp!.tools.find((tool) => tool.name.endsWith(`_${suffix}`)) + expect(tool, `Missing Vue tool: ${suffix}`).toBeDefined() + const result = await connector.callTool({ + name: 'devframe_connect_call-tool', + arguments: { port: instance.port, tool: tool!.name, args }, + }) + return readResult(result) + } + + const contracts = createVueDevtoolsAgentTools( + { + query: async () => { + throw new Error('schema only') + }, + command: async () => ({ status: 1 }), + }, + { id: '', url: '', title: '' }, + ) + async function call(suffix: string, args: Record = {}) { + const result = readResult<{ page: { url: string }; result: T }>(await callResult(suffix, args)) + const contract = contracts[suffix as keyof typeof contracts]! + expect(contract.returns.safeParse(result).success, `Output contract for ${suffix}`).toBe(true) + return result + } + + const apps = await call<{ apps: { id: string }[] }>('list-apps') + expect(apps.page.url).toBe(`${origin}/?agent`) + expect(apps.result.apps.length).toBeGreaterThan(0) + const appId = apps.result.apps[0].id + const tree = await call<{ nodes: { id: string; name: string }[] }>('component-tree', { + arg0: { appId }, + }) + expect(tree.result.nodes.length).toBeGreaterThan(0) + const state = await call<{ sections: unknown[] }>('component-state', { + arg0: { appId, componentId: tree.result.nodes[0].id }, + }) + expect(state.result.sections.length).toBeGreaterThan(0) + const componentId = tree.result.nodes.find((node) => node.name === 'AgentArrays')!.id + type State = { + snapshotId: string + pagination: { total: number; pageSize: number; current: number; next: number | null } + sections: { + entries: { key: string; value: { handle: string; preview: unknown[] } }[] + partial?: boolean + }[] + } + const defaults = await call('component-state', { arg0: { appId, componentId } }) + expect(defaults.result.sections[0]!.entries).toHaveLength(50) + const bounded = await call('component-state', { + arg0: { appId, componentId, pageSize: 500 }, + }) + const section = bounded.result.sections[0]! + expect(section.entries).toHaveLength(500) + expect(section.partial).toBe(true) + for (const entry of section.entries) expect(entry.value.preview).toHaveLength(30) + const handle = section.entries[0]!.value.handle + const expanded = await call<{ value: { kind: string; value: number } }>('expand-value', { + arg0: { appId, handle, path: ['499'] }, + }) + expect(expanded.result.value).toEqual({ kind: 'number', value: 499 }) + + const fresh = await call('component-state', { arg0: { appId, componentId } }) + const expired = await callResult('expand-value', { arg0: { appId, handle } }) + expect(expired.isError).toBe(true) + expect(JSON.stringify(expired)).toContain( + 'Rerun the original component-state or inspector-state query', + ) + const recovered = await call<{ value: { kind: string; value: number } }>('expand-value', { + arg0: { appId, handle: fresh.result.sections[0]!.entries[0]!.value.handle, path: ['499'] }, + }) + expect(recovered.result.value).toEqual({ kind: 'number', value: 499 }) + + const oversizedId = tree.result.nodes.find((node) => node.name === 'AgentOversized')!.id + let oversized = await call('component-state', { + arg0: { appId, componentId: oversizedId, pageSize: 500 }, + }) + expect(oversized.result.pagination.next).toBe(2) + const allKeys: string[] = [] + for (;;) { + allKeys.push( + ...oversized.result.sections.flatMap((section) => section.entries.map((entry) => entry.key)), + ) + if (!oversized.result.pagination.next) break + oversized = await call('component-state', { + arg0: { + appId, + componentId: oversizedId, + pageSize: 500, + page: oversized.result.pagination.next, + snapshotId: oversized.result.snapshotId, + }, + }) + } + expect(allKeys).toEqual(Array.from({ length: 500 }, (_, i) => `field${i}`)) + const field = { appId, componentId, sectionId: 'setup', path: ['field500', '499'] } + const direct = await call<{ found: boolean; value: { value: number } }>('component-value', { + arg0: field, + }) + expect(direct.result).toMatchObject({ found: true, value: { kind: 'number', value: 499 } }) + expect( + (await call<{ found: boolean }>('component-value', { arg0: { ...field, path: ['missing'] } })) + .result.found, + ).toBe(false) + + const editableId = tree.result.nodes.find((node) => node.name === 'AgentEditable')!.id + const editInput = { + appId, + componentId: editableId, + sectionId: 'setup', + path: ['count'], + value: 8, + } + const changed = await call<{ state: { value: { value: number } } }>('edit-component-state', { + arg0: editInput, + }) + expect(changed.result.state.value.value).toBe(8) + await expect.poll(() => page.locator('#agent-count').textContent()).toBe('8') + const locked = await callResult('edit-component-state', { + arg0: { ...editInput, path: ['doubled'] }, + }) + expect(locked.isError).toBe(true) + expect(JSON.stringify(locked)).toContain('read-only') + + const inspectors = await call<{ inspectors: { id: string }[] }>('list-inspectors', { + arg0: { appId }, + }) + const inspectorId = inspectors.result.inspectors.find((inspector) => + inspector.id.includes('pinia'), + )!.id + const stores = await call<{ rootNodes: { id: string }[] }>('inspector-tree', { + arg0: { appId, inspectorId }, + }) + const nodeId = stores.result.rootNodes.find((node) => node.id === 'counter')!.id + const storeState = await call<{ sections: { id: string; entries: { key: string }[] }[] }>( + 'inspector-state', + { arg0: { appId, inspectorId, nodeId } }, + ) + const sectionId = storeState.result.sections.find((section) => + section.entries.some((entry) => entry.key === 'count'), + )!.id + const storeField = { appId, inspectorId, nodeId, sectionId, path: ['count'] } + expect( + (await call<{ found: boolean }>('inspector-value', { arg0: storeField })).result.found, + ).toBe(true) + const storeEdit = await call<{ state: { value: { value: number } } }>('edit-inspector-state', { + arg0: { ...storeField, value: 12 }, + }) + expect(storeEdit.result.state.value.value).toBe(12) + await expect.poll(() => page.locator('#count').textContent()).toBe('12') + await call('list-plugins') + await call('runtime-health') + await call('router-state', { arg0: { appId } }) + await call('router-matches', { arg0: { appId, path: '/' } }) + await call('component-render-code', { arg0: { appId, componentId: editableId } }) + expect(errors).toEqual([]) +}) + +it('disables the MCP endpoint when the host sets mcp: false', async () => { + const { origin, page } = await startApp(false) + await page.goto(origin) + const metadata = await (await fetch(`${origin}/__devtools/__connection.json`)).json() + expect(metadata.mcp).toBeUndefined() + const response = await fetch(`${origin}/__devtools/__mcp`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + origin, + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }), + }) + expect(response.ok).toBe(false) +}) + +async function startApp(mcp?: boolean) { + const cacheDir = await mkdtemp(resolve(tmpdir(), 'vue-devtools-agent-smoke-')) + onTestFinished(() => rm(cacheDir, { recursive: true, force: true })) + const server = await createServer({ + cacheDir, + configFile: false, + // Pinia disables DevTools under NODE_ENV=test; the served app represents development. + define: { 'process.env.NODE_ENV': JSON.stringify('development') }, + root: fixtureRoot, + logLevel: 'silent', + devtools: { clientAuth: false, builtinDevTools: false, ...(mcp === undefined ? {} : { mcp }) }, + plugins: [vue(), vueDevtools()], + optimizeDeps: { exclude: ['@vue/devtools-api', 'pinia'] }, + resolve: { alias: { '@vue/devtools-api': resolve('packages/devtools-api/src/index.ts') } }, + server: { host: '127.0.0.1', port: 0 }, + }) + onTestFinished(() => server.close()) + await server.listen() + const page = await browser.newPage() + onTestFinished(() => page.close()) + return { origin: server.resolvedUrls!.local[0].replace(/\/$/, ''), page } +} + +function readResult(result: CallToolResult): T { + expect(result.isError, JSON.stringify(result)).not.toBe(true) + const content = result.content[0] + if (content?.type !== 'text') throw new Error('Expected an MCP text result') + return JSON.parse(content.text) as T +} diff --git a/tests/unit/runtime/state-isolation.test.ts b/tests/unit/runtime/state-isolation.test.ts index 3370dd691..652a46a74 100644 --- a/tests/unit/runtime/state-isolation.test.ts +++ b/tests/unit/runtime/state-isolation.test.ts @@ -75,7 +75,7 @@ describe('component state isolation (P0.4)', () => { payload: { handle }, type: 'values:expand', }) - expect(expanded?.value.kind).toBe('object') + expect(expanded && 'value' in expanded && expanded.value.kind).toBe('object') }) it('stores the real value as a stable $tempN global via its handle', async () => { diff --git a/tests/unit/vite/client-lifecycle.test.ts b/tests/unit/vite/client-lifecycle.test.ts index ced4ab715..b0dfc3684 100644 --- a/tests/unit/vite/client-lifecycle.test.ts +++ b/tests/unit/vite/client-lifecycle.test.ts @@ -1,6 +1,13 @@ // @vitest-environment happy-dom -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' + +const agent = vi.hoisted(() => { + const register = vi.fn((_connection: unknown, _page: { url: string; title: string }) => vi.fn()) + const sessionDispose = vi.fn() + const createSession = vi.fn(() => ({ query: vi.fn(), dispose: sessionDispose })) + return { register, sessionDispose, createSession } +}) const mocks = vi.hoisted(() => { const state = { @@ -44,6 +51,11 @@ vi.mock('@vitejs/devtools-kit/client', () => ({ getDevToolsClientContext: () => undefined, })) +vi.mock('@vue/devtools-agent/devframe', () => ({ + createVueDevtoolsAgentSession: agent.createSession, + registerVueDevtoolsAgentPage: agent.register, +})) + import { disposeVueDevTools, installVueDevTools, @@ -56,6 +68,10 @@ describe('Vite client inspector lifecycle', () => { disposeVueDevTools() delete (window as unknown as Record)[CLIENT_RUNTIME_KEY] mocks.command.mockClear() + agent.register.mockReset() + agent.register.mockImplementation(() => vi.fn()) + agent.sessionDispose.mockClear() + agent.createSession.mockClear() }) it.each(['runtime-first', 'dock-first'])( @@ -114,6 +130,55 @@ describe('Vite client inspector lifecycle', () => { expect(mocks.command).toHaveBeenCalledWith({ type: 'components:cancelInspect' }) }) + it('registers agent tools once while MCP is advertised and follows the document location', async () => { + const initialUrl = location.href + const initialTitle = document.title + onTestFinished(() => { + history.replaceState({}, '', initialUrl) + document.title = initialTitle + }) + installVueDevTools({ enabled: true }) + const dock = createDockContext({ mcp: true }) + setupVueDevToolsDockController(dock.context) + await vi.waitFor(() => expect(agent.register).toHaveBeenCalledOnce()) + setupVueDevToolsDockController(dock.context) + expect(agent.createSession).toHaveBeenCalledOnce() + expect(agent.createSession).toHaveBeenCalledWith(expect.any(Function)) + expect(Object.hasOwn(history, 'pushState')).toBe(false) + expect(Object.hasOwn(history, 'replaceState')).toBe(false) + const page = agent.register.mock.calls[0][1] + expect(page.url).toBe(location.href) + history.pushState({}, '', '/settings?tab=1#main') + expect(page.url).toBe(new URL('/settings?tab=1#main', location.origin).href) + document.title = 'Next' + expect(JSON.parse(JSON.stringify(page))).toEqual({ + id: expect.any(String), + url: new URL('/settings?tab=1#main', location.origin).href, + title: 'Next', + }) + + const closed = createDockContext() + setupVueDevToolsDockController(closed.context) + expect(agent.register.mock.results[0].value).toHaveBeenCalledOnce() + expect(agent.sessionDispose).toHaveBeenCalledOnce() + }) + + it('drops a failed agent registration and tries again on the next setup', async () => { + installVueDevTools({ enabled: true }) + agent.register.mockImplementationOnce(() => { + throw new Error('register failed') + }) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + setupVueDevToolsDockController(createDockContext({ mcp: true }).context) + await vi.waitFor(() => expect(error).toHaveBeenCalled()) + expect(agent.sessionDispose).toHaveBeenCalledOnce() + error.mockRestore() + + setupVueDevToolsDockController(createDockContext({ mcp: true }).context) + await vi.waitFor(() => expect(agent.register).toHaveBeenCalledTimes(2)) + expect(agent.createSession).toHaveBeenCalledTimes(2) + }) + it('replaces and disposes dock deactivation listeners', () => { installVueDevTools({ enabled: true }) const first = createDockContext() @@ -130,7 +195,7 @@ describe('Vite client inspector lifecycle', () => { }) }) -function createDockContext() { +function createDockContext(options?: { mcp?: boolean }) { let onDeactivate = () => {} const unsubscribe = vi.fn() const switchEntry = vi.fn(async (id: string | null) => { @@ -153,6 +218,7 @@ function createDockContext() { panel: { session: { open: true }, }, + ...(options?.mcp ? { rpc: { connectionMeta: { mcp: { path: '__mcp' } } } } : {}), } as unknown as Parameters[0] return { diff --git a/tests/unit/vite/dock-registration.test.ts b/tests/unit/vite/dock-registration.test.ts index 8578b54d3..0b2ddf7a9 100644 --- a/tests/unit/vite/dock-registration.test.ts +++ b/tests/unit/vite/dock-registration.test.ts @@ -15,9 +15,10 @@ describe('Vue DevTools dock registration', () => { await callHook(plugin.configResolved, { base, - devtools: { enabled: true, apply: 'all' }, + devtools: { enabled: true, apply: 'all', config: {} }, } as ResolvedConfig) await plugin.devtools?.setup({ + agent: { registerTool: vi.fn(() => ({ unregister: vi.fn() })) }, docks: { register }, views: { hostStatic }, } as never) @@ -29,7 +30,7 @@ describe('Vue DevTools dock registration', () => { ) expect(register).toHaveBeenCalledWith({ category: 'framework', - clientScript: { importFrom: clientScriptUrl }, + clientScript: { importFrom: clientScriptUrl, eager: true }, icon: 'logos:vue', id: 'vue-devtools', frameId: 'vue-devtools', @@ -39,6 +40,48 @@ describe('Vue DevTools dock registration', () => { }) }) + it('registers the default agent surface and cleans it up with the Vite plugin', async () => { + const plugin = createVueDevToolsDockRegistrationPlugin() as Plugin + const unregister = vi.fn() + const registerTool = vi.fn(() => ({ unregister })) + const register = vi.fn() + await plugin.devtools?.setup({ + agent: { registerTool }, + docks: { register }, + views: { hostStatic: vi.fn() }, + } as never) + expect(registerTool).toHaveBeenCalledWith(expect.objectContaining({ safety: 'read' })) + expect(register).toHaveBeenCalledWith( + expect.objectContaining({ + clientScript: { importFrom: '/__devtools__/dock-client.js', eager: true }, + }), + ) + const close = plugin.closeBundle + if (typeof close !== 'function') throw new TypeError('Expected closeBundle hook') + await close.call({} as never) + expect(unregister).toHaveBeenCalledOnce() + expect(vueDevtools({ enabled: false })).toEqual([]) + }) + + it('does not register an agent surface or eagerly load the script when the host disables MCP', async () => { + const plugin = createVueDevToolsDockRegistrationPlugin() as Plugin + await callHook(plugin.configResolved, { + base: '/', + devtools: { config: { mcp: false } }, + } as ResolvedConfig) + const registerTool = vi.fn() + const register = vi.fn() + await plugin.devtools?.setup({ + agent: { registerTool }, + docks: { register }, + views: { hostStatic: vi.fn() }, + } as never) + expect(registerTool).not.toHaveBeenCalled() + expect(register.mock.calls[0][0].clientScript).toEqual({ + importFrom: '/__devtools__/dock-client.js', + }) + }) + it.each([undefined, false, { enabled: false }, { apply: 'build' as const }])( 'warns without starting a DevTools host when devtools is %j', async (devtools) => { diff --git a/vitest.config.ts b/vitest.config.ts index f31fc06fa..8aa69477e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -18,6 +18,10 @@ export default defineConfig({ find: '@vue/devtools-kit', replacement: fromRoot('./packages/kit/src/index.ts'), }, + { + find: /^@vue\/devtools-agent\/devframe$/, + replacement: fromRoot('./packages/agent/src/devframe.ts'), + }, { find: '@components', replacement: fromRoot('./packages/client/src/components'), From 7e588acf458705678725d45835711eb975b57ba0 Mon Sep 17 00:00:00 2001 From: arlo Date: Thu, 15 Oct 2026 10:14:57 +0800 Subject: [PATCH 2/2] chore: update --- packages/agent/tests/pagination.test.ts | 80 +++++++++++++++++++ packages/agent/tests/state-fields.test.ts | 41 +++++++++- .../kit/src/runtime/handlers/inspectors.ts | 2 +- packages/kit/src/runtime/state/index.ts | 41 ++++++---- packages/kit/src/runtime/state/instance.ts | 4 +- packages/kit/src/runtime/state/sections.ts | 71 +++++++++------- tests/smoke/vite-client-injection.test.ts | 4 + vitest.config.ts | 4 + 8 files changed, 199 insertions(+), 48 deletions(-) diff --git a/packages/agent/tests/pagination.test.ts b/packages/agent/tests/pagination.test.ts index baaa74a54..c0dbd96a8 100644 --- a/packages/agent/tests/pagination.test.ts +++ b/packages/agent/tests/pagination.test.ts @@ -1,5 +1,6 @@ // @vitest-environment happy-dom import { afterEach, expect, it, vi } from 'vitest' +import { computed } from 'vue' import { createDevtoolsKit } from '../../kit/src/kit' import type { DevtoolsKit } from '../../kit/src/kit' import type { ComponentStateSnapshotMessage, ExpandedValueMessage } from '../../kit/src/protocol' @@ -98,11 +99,21 @@ it.each(['array', 'object', 'map', 'set'] as const)( .result as ComponentStateSnapshotMessage const encoded = state.sections[0]!.entries[0]!.value if (!('handle' in encoded) || !encoded.handle) throw new Error('Missing handle') + const handleCount = kit.runtime.componentState.handleCount const target = { appId: 'app:0', handle: encoded.handle, pageSize: 50 } let result = (await tools['expand-value'].handler(target)).result as ExpandedValueMessage const actual: unknown[] = [] for (;;) { expect(result.pagination!.total).toBe(121) + if (result.value.kind === 'map' || result.value.kind === 'set') { + expect(kit.runtime.componentState.handleCount).toBe(handleCount) + expect(result.value.size).toBe(121) + expect(result.value.handle).toBe(encoded.handle) + expect(kit.runtime.componentState.getHandleValue(result.value.handle!)).toBe(payload) + const key = result.value.preview[0]!.key + const expanded = kit.runtime.componentState.expand(result.value.handle!, [key]) + expect(expanded).toMatchObject({ value: result.value.preview[0]!.value }) + } if (!('preview' in result.value)) throw new Error('Missing preview') actual.push( ...result.value.preview.map((entry) => @@ -279,3 +290,72 @@ it('expires a finished batch once its last page has been handed out', async () = }), ).rejects.toThrow(/complete|Read pages in order|expired/) }) + +it.each(['array', 'object', 'string'] as const)( + 'paginates cached setup computed %s values', + async (kind) => { + const values = Array.from({ length: 121 }, (_, i) => i) + const payload = + kind === 'array' + ? values + : kind === 'object' + ? Object.fromEntries(values.map((i) => [`key${i}`, i])) + : 'x'.repeat(6000) + const getter = vi.fn(() => payload) + const cached = computed(getter) + void cached.value + const { tools, fixture, componentId } = await setup({}) + Object.assign(fixture.instances[0]!, { setupState: { cached } }) + const target = { + appId: 'app:0', + componentId, + sectionId: 'setup', + path: ['cached'], + pageSize: kind === 'string' ? 5000 : 50, + } + let result = (await tools['component-value'].handler({ ...target, page: 1 })).result as Extract< + import('../../kit/src/protocol').StateValueMessage, + { found: true } + > + const actual: unknown[] = [] + let text = '' + for (;;) { + expect(result.pagination!.total).toBe(kind === 'string' ? 6000 : 121) + if (result.value.kind === 'string') text += result.value.value + else if ('preview' in result.value) + actual.push( + ...result.value.preview.map((entry) => + entry.value.kind === 'number' ? entry.value.value : null, + ), + ) + if (!result.pagination!.next) break + result = ( + await tools['component-value'].handler({ + ...target, + page: result.pagination!.next, + snapshotId: result.snapshotId, + }) + ).result as typeof result + } + expect(kind === 'string' ? text : actual).toEqual(kind === 'string' ? payload : values) + expect(getter).toHaveBeenCalledOnce() + }, +) + +it('does not evaluate an unaccessed computed during pagination', async () => { + const getter = vi.fn(() => [1, 2, 3]) + const { tools, fixture, componentId } = await setup({}) + Object.assign(fixture.instances[0]!, { setupState: { untouched: computed(getter) } }) + const result = await tools['component-value'].handler({ + appId: 'app:0', + componentId, + sectionId: 'setup', + path: ['untouched'], + page: 1, + }) + expect(result.result).toMatchObject({ + found: true, + value: { kind: 'custom', type: 'computed-not-accessed' }, + }) + expect(getter).not.toHaveBeenCalled() +}) diff --git a/packages/agent/tests/state-fields.test.ts b/packages/agent/tests/state-fields.test.ts index af696e09e..801690150 100644 --- a/packages/agent/tests/state-fields.test.ts +++ b/packages/agent/tests/state-fields.test.ts @@ -1,6 +1,6 @@ // @vitest-environment happy-dom import { afterEach, expect, it, vi } from 'vitest' -import { computed, readonly, ref, shallowReadonly } from 'vue' +import { computed, customRef, readonly, ref, shallowReadonly } from 'vue' import { z } from 'zod' import { createDevtoolsKit } from '../../kit/src/kit' import type { DevtoolsKit } from '../../kit/src/kit' @@ -170,6 +170,10 @@ it('reads and edits Inspector state via plugin hooks, returning the actual plugi const edited = await tools['edit-inspector-state']!.handler({ ...input, value: 99 }) expect(edited.result).toMatchObject({ status: 1, state: { value: { value: 10 } } }) expect(values.count).toBe(10) + await expect( + tools['edit-inspector-state']!.handler({ ...input, appId: 'wrong', value: 2 }), + ).rejects.toThrow('not found') + expect(values.count).toBe(10) expect(tools['edit-inspector-state']!.returns.safeParse(edited).success).toBe(true) await expect( tools['edit-inspector-state']!.handler({ ...input, path: ['readonly'], value: 5 }), @@ -203,3 +207,38 @@ it('exports JSON output schemas and only advertises mutations on command-capable ).rejects.toThrow('Rejected by plugin') expect(failure).toHaveBeenCalledOnce() }) + +it('reads and edits a requested binding without accessing unrelated setup or data getters', async () => { + const { tools, fixture, componentId } = await setup() + const unread = vi.fn(() => { + throw new Error('Unrelated state was accessed') + }) + const data = { count: 1 } + Object.defineProperty(data, 'broken', { enumerable: true, get: unread }) + Object.assign(fixture.instances[0]!, { + data, + setupState: { + count: ref(1), + broken: customRef(() => ({ get: unread, set() {} })), + }, + }) + for (const sectionId of ['props', 'data', 'setup']) { + const input = { + appId: 'app:0', + componentId, + sectionId, + path: [sectionId === 'props' ? 'field600' : 'count'], + } + expect((await tools['component-value'].handler(input)).result).toMatchObject({ + found: true, + editable: true, + }) + expect( + (await tools['edit-component-state']!.handler({ ...input, value: 8 })).result, + ).toMatchObject({ + status: 1, + state: { value: { kind: 'number', value: 8 } }, + }) + } + expect(unread).not.toHaveBeenCalled() +}) diff --git a/packages/kit/src/runtime/handlers/inspectors.ts b/packages/kit/src/runtime/handlers/inspectors.ts index d1963f225..40077f1d3 100644 --- a/packages/kit/src/runtime/handlers/inspectors.ts +++ b/packages/kit/src/runtime/handlers/inspectors.ts @@ -401,7 +401,7 @@ async function readInspectorStateValue( options?: StatePageOptions, ): Promise { const app = resolveAppRef(runtime, appId) - if (!runtime.inspectors.get(inspectorId, app)) return { found: false } + if (!app || !runtime.inspectors.get(inspectorId, app)) return { found: false } const payload = { app, inspectorId, nodeId, state: {} } await runtime.callPluginHook('getInspectorState', payload) const entry = toInspectorLegacyStateEntries(inspectorId, nodeId, payload.state).find( diff --git a/packages/kit/src/runtime/state/index.ts b/packages/kit/src/runtime/state/index.ts index 790247a3d..254024218 100644 --- a/packages/kit/src/runtime/state/index.ts +++ b/packages/kit/src/runtime/state/index.ts @@ -1,6 +1,12 @@ import { StatePages } from './pagination' import { safeOwnEnumerableStringKeys } from '../../codec/encode' -import { ValueHandleRegistry, encodeValue } from '../../codec' +import { + ValueHandleRegistry, + encodeValue, + isComputedRef, + isUnaccessedComputed, + readCachedComputedValue, +} from '../../codec' import type { ComponentId, InstanceRef } from '../types' import { RuntimeRegistry } from '../registry' import type { RuntimeBudget } from '../budget' @@ -233,6 +239,7 @@ export class ComponentStateCollector { handle = '', path: string[] = [], ): ExpandedValueMessage { + if (isComputedRef(value) && !isUnaccessedComputed(value)) value = readCachedComputedValue(value) const maxPageSize = typeof value === 'string' ? 5000 : 500 const pageOptions = { ...options, @@ -258,21 +265,21 @@ export class ComponentStateCollector { const encode = (offset: number, limit: number) => { if (typeof value === 'string') return { kind: 'string' as const, value: value.slice(offset, offset + limit) } - if (mapEntries) - return encodeValue(new Map(mapEntries.slice(offset, offset + limit)), { - ...encodeOptions, - maxEntries: limit, - }) - if (setItems) { - const encoded = encodeValue(new Set(setItems.slice(offset, offset + limit)), { - ...encodeOptions, - maxEntries: limit, - }) - if (encoded.kind === 'set') - encoded.preview = encoded.preview.map((entry, index) => ({ - key: String(offset + index), - value: entry.value, - })) + if (mapEntries || setItems) { + const slice = mapEntries + ? new Map(mapEntries.slice(offset, offset + limit)) + : new Set(setItems!.slice(offset, offset + limit)) + const encoded = encodeValue(slice, { ...encodeOptions, maxEntries: limit }) + if (encoded.kind === 'map' || encoded.kind === 'set') { + if (encoded.handle) this.handles.delete(encoded.handle) + encoded.handle = this.handles.getOrCreate(value as object, scope) + encoded.size = (mapEntries ?? setItems)!.length + if (encoded.kind === 'set') + encoded.preview = encoded.preview.map((entry, index) => ({ + key: String(offset + index), + value: entry.value, + })) + } return encoded } return encodeValue(value, { @@ -351,7 +358,7 @@ export class ComponentStateCollector { ): StateValueMessage { const record = this.registry.getComponent(componentId) if (!record || !path.length) return { found: false } - const section = createSectionSources(record.instance).find( + const section = createSectionSources(record.instance, sectionId, path[0]).find( (section) => section.id === sectionId, ) if (!section?.source) return { found: false } diff --git a/packages/kit/src/runtime/state/instance.ts b/packages/kit/src/runtime/state/instance.ts index 9124f91d8..c44d9dea0 100644 --- a/packages/kit/src/runtime/state/instance.ts +++ b/packages/kit/src/runtime/state/instance.ts @@ -80,13 +80,15 @@ export function isUserSetupKey(key: string): boolean { */ export function collectSetupBindings( record: Record, + selectedKey?: string, ): Record | undefined { const bindings: Record = {} const props = readObject(record, 'props') for (const source of setupBindingSources(record)) { if (!source) continue - for (const key of Object.keys(source)) { + for (const key of selectedKey === undefined ? Object.keys(source) : [selectedKey]) { + if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue if (key in bindings || !isUserSetupKey(key) || (props && hasOwn(props, key))) continue bindings[key] = readObjectValue(source, key) } diff --git a/packages/kit/src/runtime/state/sections.ts b/packages/kit/src/runtime/state/sections.ts index a28e8c3f5..5dd8d423c 100644 --- a/packages/kit/src/runtime/state/sections.ts +++ b/packages/kit/src/runtime/state/sections.ts @@ -27,92 +27,105 @@ export interface SectionSource { meta?: (key: string) => Record | undefined } -export function createSectionSources(instance: InstanceRef): SectionSource[] { +export function createSectionSources( + instance: InstanceRef, + sectionId?: string, + key?: string, +): SectionSource[] { const record = instance as Record const type = readObject(record, 'type') const mergedType = resolveMergedOptions(instance) const proxy = readObject(record, 'proxy') const computed = readObject(mergedType, 'computed') - const rawSetupState = collectSetupBindings(record) + const rawSetupState = + !sectionId || sectionId === 'setup' || sectionId === 'setup-other' + ? collectSetupBindings(record, key) + : undefined const setupState = createSetupStateReaders(rawSetupState) - return [ - { + const sections: Record SectionSource> = { + props: () => ({ id: 'props', label: 'Props', source: readObject(record, 'props'), editable: true, meta: createPropMetaReader(type), - }, - { + }), + data: () => ({ id: 'data', label: 'Data', - source: createDataReader(record, mergedType), + source: createDataReader(record, mergedType, key), editable: true, - }, - { + }), + setup: () => ({ id: 'setup', label: 'Setup', source: setupState.state, editable: true, meta: createSetupStateMetaReader(rawSetupState), - }, - { + }), + 'setup-other': () => ({ id: 'setup-other', label: 'Setup (other)', source: setupState.other, editable: false, meta: createSetupStateMetaReader(rawSetupState), - }, - { + }), + computed: () => ({ id: 'computed', label: 'Computed', source: computed ? createComputedReader(computed, proxy, record) : undefined, editable: true, meta: createComputedMetaReader(computed), - }, - { + }), + attrs: () => ({ id: 'attrs', label: 'Attrs', source: readObject(record, 'attrs'), editable: false, - }, - { + }), + provide: () => ({ id: 'provide', label: 'Provided', source: readObject(record, 'provides'), editable: false, - }, - { + }), + inject: () => ({ id: 'inject', label: 'Injected', source: createInjectReader(record, mergedType), editable: false, - }, - { + }), + refs: () => ({ id: 'refs', label: 'Refs', source: readObject(record, 'refs'), editable: false, - }, - { + }), + listeners: () => ({ id: 'listeners', label: 'Listeners', source: createListenerReader(readObject(readObject(record, 'vnode'), 'props'), type), editable: false, - }, - ] + }), + } + return sectionId + ? Object.hasOwn(sections, sectionId) + ? [sections[sectionId]!()] + : [] + : Object.values(sections).map((create) => create()) } function createDataReader( record: Record, type: object | undefined, + key?: string, ): object | undefined { const props = readObject(type, 'props') const computed = readObject(type, 'computed') const vuex = readObject(type, 'vuex') const getters = readObject(vuex, 'getters') - const data = mergeObjects(readObject(record, 'data'), readObject(record, 'renderContext')) + const data = mergeObjects([readObject(record, 'data'), readObject(record, 'renderContext')], key) if (!data) return const result: Record = {} @@ -346,13 +359,15 @@ function readPropType(definition: object): string { return 'any' } -function mergeObjects(...objects: Array): object | undefined { +function mergeObjects(objects: Array, key?: string): object | undefined { const result: Record = {} let hasValue = false for (const object of objects) { if (!object) continue - Object.assign(result, object) + if (key === undefined) Object.assign(result, object) + else if (Object.prototype.propertyIsEnumerable.call(object, key)) + result[key] = readObjectValue(object, key) hasValue = true } diff --git a/tests/smoke/vite-client-injection.test.ts b/tests/smoke/vite-client-injection.test.ts index 8f8a1ea0e..c3d362018 100644 --- a/tests/smoke/vite-client-injection.test.ts +++ b/tests/smoke/vite-client-injection.test.ts @@ -57,6 +57,10 @@ describe('Vite client injection', () => { let server: ViteDevServer | undefined let middlewareServer: HttpServer | undefined const page = await browser.newPage() + // External font stylesheets can block module execution while offline. + await page.route('https://fonts.googleapis.com/**', (route) => + route.fulfill({ body: '', contentType: 'text/css' }), + ) const cacheDir = await mkdtemp(resolve(tmpdir(), 'vue-devtools-vite-smoke-')) try { diff --git a/vitest.config.ts b/vitest.config.ts index 8aa69477e..5026d44c0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -22,6 +22,10 @@ export default defineConfig({ find: /^@vue\/devtools-agent\/devframe$/, replacement: fromRoot('./packages/agent/src/devframe.ts'), }, + { + find: /^@vue\/devtools-agent\/node$/, + replacement: fromRoot('./packages/agent/src/node.ts'), + }, { find: '@components', replacement: fromRoot('./packages/client/src/components'),