diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml index e6f2fb8718..242b95ac9f 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml @@ -9,11 +9,11 @@ en: 2ba6c43b20a3d347 zh: 5647179ddaa38ad9 /agent-note-ship-experimental-capabilities-as-optional-bundles/decision: - en: 1c5a43b3ce2d3056 - zh: b59f549a12cf67d1 + en: 0d6c40e376fb6e48 + zh: b0cf2ded4e395942 /agent-note-ship-experimental-capabilities-as-optional-bundles/alternatives-considered: en: a65d84004e48b3ef zh: 17221f08e7a8a89b /agent-note-ship-experimental-capabilities-as-optional-bundles/consequences: - en: a5c9699904fa72f1 - zh: 80763881150593e2 + en: d690cf4d49c6af79 + zh: 1c7a3077c35ae5b8 diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md index 9c3de87f91..70bc46df69 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md +++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md @@ -10,9 +10,9 @@ The Web plugin page offered two optional bundles, Agent Teams and voice input. A ## Decision -`OPTIONAL_BUNDLES` lists Agent Teams, voice input, and Auto review. Every optional bundle declares `icon` and exports `./locale/*.json` with `meta.title` and `meta.description`, so the Official group renders a localized title, description, and image; `verify-default-product-isolation` rejects an optional bundle without them. The Inspector's `cordis.patch.yml` names the package instead of a built-artifact path, so the same file is the bundle patch and the `--patch` overlay for a built Web launch. +`OPTIONAL_BUNDLES` lists Agent Teams, voice input, Auto review, and the Inspector profile. Every optional bundle declares `icon` and exports `./locale/*.json` with `meta.title` and `meta.description`, so the Official group renders a localized title, description, and image; `verify-default-product-isolation` rejects an optional bundle without them. The Inspector profile owns the package-named bundle patch. The standalone Inspector's `cordis.patch.yml` mounts its local built entry as an explicit repository overlay; [Session Inspector](../feature/2026-09-24-session-inspector.md) owns the inspection behavior and activation choices. -An optional bundle is a runtime dependency of the installation, so its dependency graph is downloaded by every `dsh` install. The list therefore admits only packages whose graph is already in the installation's closure; Auto review adds nothing. The Inspector stays available through explicit installation and does not appear in the default plugin list. The browser-use and computer-use providers stay explicit compositions: Playwright MCP, Chrome DevTools MCP, and the native Cua Driver runtime binaries add about 85 MB and 21 packages to every install whether or not the bundle is enabled, and a shipped Cua Driver MCP switch would offer a capability whose executable the installation does not carry. Those provider packages keep locale display metadata for their component rows. Two more packages stay out for other reasons: `ptc-runtime-python` replaces the PTC runtime, and `workflow-ptc` rejects a non-TypeScript runtime at load while the Web presets carry `workflow-ptc` rows a bundle patch cannot reach; `browser-use-stagehand-native` requires a native model name and API key at schema validation with no configuration form on the page. +An optional bundle is a runtime dependency of the installation, so its dependency graph is downloaded by every `dsh` install. The list weighs the added installation cost: Auto review reuses the installation's closure, while the Inspector profile adds inspection packages, `serve-static`/`open`/`ws`, and 535 mirrored DevTools resources (about 11.95 MiB uncompressed), without another browser binary. The Inspector profile appears in the default plugin list but remains disabled until selected. The browser-use and computer-use providers stay explicit compositions: Playwright MCP, Chrome DevTools MCP, and the native Cua Driver runtime binaries add about 85 MB and 21 packages to every install whether or not the bundle is enabled, and a shipped Cua Driver MCP switch would offer a capability whose executable the installation does not carry. Those provider packages keep locale display metadata for their component rows. Two more packages stay out for other reasons: `ptc-runtime-python` replaces the PTC runtime, and `workflow-ptc` rejects a non-TypeScript runtime at load while the Web presets carry `workflow-ptc` rows a bundle patch cannot reach; `browser-use-stagehand-native` requires a native model name and API key at schema validation with no configuration form on the page. ## Alternatives considered @@ -22,4 +22,4 @@ An optional bundle is a runtime dependency of the installation, so its dependenc ## Consequences -The Official group carries three optional-bundle entries, each tagged experimental by its experimental name. The runtime dependency closure of the installation is unchanged. A browser-use or computer-use provider still needs its Service Definition and the provider in a profile patch or composition. +The Official group carries four optional-bundle entries, each tagged experimental by its experimental name. Their runtime dependencies are installed even while the bundles are disabled. A browser-use or computer-use provider still needs its Service Definition and the provider in a profile patch or composition. diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md index 3198b409c7..d5a9b6aea9 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md @@ -10,9 +10,9 @@ Web 插件页只提供两个可选 bundle:Agent Teams 与语音输入。Auto r ## 决策 -`OPTIONAL_BUNDLES` 列出 Agent Teams、语音输入与 Auto review。每个可选 bundle 都声明 `icon` 并导出带 `meta.title` 与 `meta.description` 的 `./locale/*.json`,官方分组因此能渲染本地化标题、描述和图片;`verify-default-product-isolation` 会拒绝缺少这些声明的可选 bundle。Inspector 的 `cordis.patch.yml` 直接写包名而非构建产物路径,所以同一文件既是 bundle patch,也是已构建 Web 启动的 `--patch` overlay。 +`OPTIONAL_BUNDLES` 列出 Agent Teams、语音输入、Auto review 与 Inspector profile。每个可选 bundle 都声明 `icon` 并导出带 `meta.title` 与 `meta.description` 的 `./locale/*.json`,官方分组因此能渲染本地化标题、描述和图片;`verify-default-product-isolation` 会拒绝缺少这些声明的可选 bundle。Inspector profile 管理按包名挂载的 bundle patch。独立 Inspector 的 `cordis.patch.yml` 通过本地构建入口提供显式仓库 overlay;[Session Inspector](../feature/2026-09-24-session-inspector.zh.md) 管理检查行为与启用选择。 -可选 bundle 是安装的运行时依赖,其依赖图会随每次 `dsh` 安装一起下载。因此名单只接纳依赖图已在安装闭包内的包;Auto review 不增加任何依赖。Inspector 保留显式安装方式,不在默认插件列表中显示。浏览器操作与电脑操作提供方仍保持显式组合:Playwright MCP、Chrome DevTools MCP 与原生 Cua Driver 运行时二进制会让每次安装多出约 85 MB、21 个包,无论 bundle 是否启用;而随附的 Cua Driver MCP 开关会提供一个安装本身并不携带其可执行文件的能力。这些提供方包保留 locale 显示元数据,供组件行使用。另有两个包因其他原因不纳入:`ptc-runtime-python` 会替换 PTC 运行时,而 `workflow-ptc` 在加载时拒绝非 TypeScript 运行时,且 Web preset 内含 bundle patch 无法触及的 `workflow-ptc` 行;`browser-use-stagehand-native` 在 schema 校验时就要求原生模型名称与 API 密钥,而插件页没有对应的配置表单。 +可选 bundle 是安装的运行时依赖,其依赖图会随每次 `dsh` 安装一起下载。名单权衡新增安装成本:Auto review 复用安装闭包,Inspector profile 增加检查包、`serve-static`/`open`/`ws` 和 535 个镜像 DevTools 资源(未压缩约 11.95 MiB),但不携带另一个浏览器二进制。Inspector profile 在默认插件列表显示,选中前保持关闭。浏览器操作与电脑操作提供方仍保持显式组合:Playwright MCP、Chrome DevTools MCP 与原生 Cua Driver 运行时二进制会让每次安装多出约 85 MB、21 个包,无论 bundle 是否启用;而随附的 Cua Driver MCP 开关会提供一个安装本身并不携带其可执行文件的能力。这些提供方包保留 locale 显示元数据,供组件行使用。另有两个包因其他原因不纳入:`ptc-runtime-python` 会替换 PTC 运行时,而 `workflow-ptc` 在加载时拒绝非 TypeScript 运行时,且 Web preset 内含 bundle patch 无法触及的 `workflow-ptc` 行;`browser-use-stagehand-native` 在 schema 校验时就要求原生模型名称与 API 密钥,而插件页没有对应的配置表单。 ## 考虑过的替代方案 @@ -22,4 +22,4 @@ Web 插件页只提供两个可选 bundle:Agent Teams 与语音输入。Auto r ## 后果 -官方分组包含三个可选 bundle 条目,每项都因实验包名而带实验性标签。安装的运行时依赖闭包保持不变。浏览器操作或电脑操作提供方仍需在 profile patch 或组合中同时挂载其 Service Definition 与提供方。 +官方分组包含四个可选 bundle 条目,每项都因实验包名而带实验性标签。即使组合包关闭,其运行时依赖也会安装。浏览器操作或电脑操作提供方仍需在 profile patch 或组合中同时挂载其 Service Definition 与提供方。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index b863bd7258..ac0cb99d37 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -121,6 +121,7 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`readable-stream`](https://github.com/nodejs/readable-stream) | MIT | | [`resolve.exports`](https://github.com/lukeed/resolve.exports) | MIT | | [`semver`](https://github.com/npm/node-semver) | ISC | +| [`serve-static`](https://github.com/expressjs/serve-static) | MIT | | [`sharp`](https://github.com/lovell/sharp) | Apache-2.0 | | [`sherpa-onnx-node`](https://github.com/csukuangfj/sherpa-onnx) | Apache-2.0 | | [`shiki`](https://github.com/shikijs/shiki) | MIT | @@ -147,6 +148,11 @@ pnpm applies local patches to the following packages at install time, so shipped - `exceljs@4.4.0` — [`patches/exceljs@4.4.0.patch`](patches/exceljs@4.4.0.patch) - `node-pty@1.2.0-beta.15` — [`patches/node-pty@1.2.0-beta.15.patch`](patches/node-pty@1.2.0-beta.15.patch) +## Chrome DevTools frontend + +The optional experimental Inspector distributes a local copy of [Chrome 150.0.7871.186's frontend](https://chrome-devtools-frontend.appspot.com/serve_rev/@ec97cf3bbeea2cb623fbf97c4e3f22f5acb4d568/devtools_app.html). The Chromium [BSD-3-Clause license](packages/experimental/inspector/assets/devtools/LICENSE) is included with the frontend. Copied JavaScript retains its embedded license comments; the resource downloader does not collect separate upstream third-party license files. The Chromium root license does not replace those dependencies' licenses. + + ## Official Claude Code platform payloads The project owner authorizes distribution of every version of the official `@anthropic-ai/claude-agent-sdk` package and the official Claude Code CLI/platform payloads that each version declares through `optionalDependencies`. This identity-scoped authorization does not classify their declared terms as permissive and does not cover any unrelated runtime package; version, declared-license, and payload-set changes still require the ordinary dependency, lockfile, compatibility, terms, and notices review. @@ -205,6 +211,7 @@ External packages **directly declared** for development, tests, types, or toolin | [`@types/react-dom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/readable-stream`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/semver`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/serve-static`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/spdx-expression-parse`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/turndown`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/use-sync-external-store`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | diff --git a/apps/cli/package.json b/apps/cli/package.json index 81494aea52..e98a7c41ce 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -39,6 +39,7 @@ "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:*", "@deepseek-ai/dsh-cordis-client-runner": "workspace:*", "@deepseek-ai/dsh-experimental-agent-team-profile": "workspace:*", + "@deepseek-ai/dsh-experimental-inspector-profile": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-goal": "workspace:*", "@deepseek-ai/dsh-goal-round-driver": "workspace:*", diff --git a/apps/web/tests/devtools-frontend.e2e.ts b/apps/web/tests/devtools-frontend.e2e.ts new file mode 100644 index 0000000000..0ca535ab4e --- /dev/null +++ b/apps/web/tests/devtools-frontend.e2e.ts @@ -0,0 +1,118 @@ +/** Prebuilt Inspector frontend assets through the authenticated Web Host subpath. */ +import { fileURLToPath } from 'node:url' +import { mkdir } from 'node:fs/promises' +import { chromium, type Browser } from 'playwright' +import { expect, it, onTestFinished } from 'vitest' +import { launchWebScaffold, type WebScaffold } from './scaffold.ts' + +it('serves the trimmed DevTools frontend under its own path without rebuilding DevTools', async () => { + const resources: { scaffold?: WebScaffold; browser?: Browser } = {} + onTestFinished(async () => { + try { await resources.browser?.close() } finally { await resources.scaffold?.close() } + }) + const scaffold = await launchWebScaffold({ + profile: { packages: [{ + dir: fileURLToPath(new URL('../../../packages/experimental/inspector-profile', import.meta.url)), enabled: true, + }], hmr: false }, + extraOverlayPath: [ + fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), + fileURLToPath(new URL('./fixtures/shared-inspector.patch.yml', import.meta.url)), + ], + }) + resources.scaffold = scaffold + const browser = await chromium.launch() + resources.browser = browser + const page = await browser.newPage({ locale: 'zh-CN' }) + const root = new URL('inspector/devtools/', scaffold.authenticatedUrl).href + expect((await page.request.get(`${root}devtools_app.html`)).status()).toBe(401) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + + const redirect = await page.request.get(`${root.slice(0, -1)}?probe=retained`, { maxRedirects: 0 }) + expect(redirect.status()).toBe(302) + expect(redirect.headers()['location']).toBe('devtools/devtools_app.html?probe=retained') + const html = await page.request.get(`${root}devtools_app.html`) + expect(html.status()).toBe(200) + expect(html.headers()['content-type']).toContain('text/html') + expect(html.headers()['x-content-type-options']).toBe('nosniff') + const markup = await html.text() + const importMap = markup.match(/', + new URL('devtools_app.html', root))).toEqual(['main.js', 'tokens.css']) + expect(paths('@import "./more.css"; body { background:url(../Images/icon.svg); src:url(data:font/woff2;base64,AAAA) }', + new URL('styles/main.css', root))).toEqual(['Images/icon.svg', 'styles/more.css']) +}) + +it('discovers assets in emitted CSS modules but skips their diagnostic source URL', () => { + expect(paths('const cssContent = "body { background:url(../../Images/icon.svg) }"; import.meta.resolve("./widget.css");', + new URL('ui/example/widget.css.js', root))).toEqual(['Images/icon.svg']) +}) + +it('rejects module dependencies outside the pinned revision', () => { + expect(() => paths('import "../../../other-revision/module.js"')).toThrow('escapes the pinned revision') + expect(() => paths('import "https://other.example/module.js"')).toThrow('escapes the pinned revision') +}) + +it('enumerates every locale supplied to the upstream I18n constructor before any pruning', () => { + expect(paths('const available = ["en-US", "zh", "fr"]; const i18n = new Library.I18n.I18n(available, "en-US");', + new URL('core/i18n/i18n.js', root))).toEqual([ + 'core/i18n/locales/en-US.json', 'core/i18n/locales/fr.json', 'core/i18n/locales/zh.json', + ]) +}) + +it('records bare and variable imports instead of inventing relative CDN paths for them', () => { + const result = findDevtoolsDependencies('import("puppeteer"); import("node:fs"); import(workerEntry);', file, root) + expect(result.urls).toEqual([]) + expect(result.externalModules).toEqual(['puppeteer', 'node:fs']) + expect(result.dynamicImports).toEqual(['import(workerEntry)']) +}) + +it('follows the Lighthouse locale table with its upstream source-catalog exclusion', () => { + expect(paths('const locales = ["en-US.json", "en-US.ctc.json", "fr.json"].filter(name => !name.endsWith(".ctc.json"));', + new URL('third_party/lighthouse/lighthouse-dt-bundle.js', root))).toEqual([ + 'third_party/lighthouse/locales/en-US.json', 'third_party/lighthouse/locales/fr.json', + ]) +}) diff --git a/packages/experimental/inspector/tests/devtools-locales.host.spec.ts b/packages/experimental/inspector/tests/devtools-locales.host.spec.ts new file mode 100644 index 0000000000..897dc3a58d --- /dev/null +++ b/packages/experimental/inspector/tests/devtools-locales.host.spec.ts @@ -0,0 +1,22 @@ +/** Locale pruning changes the upstream menu source and dependency graph together. */ +import { expect, it } from 'vitest' +import { configureDevtoolsLocales } from '../scripts/build-devtools.ts' +import { findDevtoolsDependencies } from '../scripts/download-devtools.ts' + +it('keeps Chinese and English in both the I18n instance and its resource dependencies', () => { + const source = 'const locales=["fr","en-US","zh","de"]; const bundled=["en-US","zh"]; new Lib.I18n.I18n(locales,"en-US");' + const result = configureDevtoolsLocales(source) + expect(result).toBe('const locales=["en-US","zh"]; const bundled=["en-US","zh"]; new Lib.I18n.I18n(locales,"en-US");') + const root = new URL('https://cdn.example/serve_rev/@revision/') + expect(findDevtoolsDependencies(result, new URL('core/i18n/i18n.js', root), root).urls.map(url => url.href)).toEqual([ + new URL('core/i18n/locales/en-US.json', root).href, + new URL('core/i18n/locales/zh.json', root).href, + ]) +}) + +it('rejects an upstream locale declaration that no longer matches the distribution', () => { + expect(() => configureDevtoolsLocales('const locales=["en-US"]; new Lib.I18n.I18n(locales,"en-US");')) + .toThrow('every distributed locale') + expect(() => configureDevtoolsLocales('new Lib.I18n.I18n(loadLocales(),"en-US");')) + .toThrow('one supported-locale array') +}) diff --git a/packages/experimental/inspector/tests/devtools-selection.host.spec.ts b/packages/experimental/inspector/tests/devtools-selection.host.spec.ts new file mode 100644 index 0000000000..215224bb20 --- /dev/null +++ b/packages/experimental/inspector/tests/devtools-selection.host.spec.ts @@ -0,0 +1,44 @@ +/** Per-connection Client selection in the frontend bootstrap and upgrade URL. */ +import { readFileSync } from 'node:fs' +import { runInNewContext } from 'node:vm' +import { expect, it } from 'vitest' +import { INSPECTOR_CLIENT_QUERY, readInspectorClientSelection } from '../src/shared/web.ts' + +const bootstrap = readFileSync(new URL('../assets/devtools/connect.js', import.meta.url), 'utf8') + +it('keeps an absent selector global and decodes one selected Client', () => { + expect(readInspectorClientSelection(new URL('http://inspector/cdp'))).toBeUndefined() + const url = new URL('http://inspector/cdp') + url.searchParams.set(INSPECTOR_CLIENT_QUERY, 'client/page?x=1&y=2') + expect(readInspectorClientSelection(url)).toBe('client/page?x=1&y=2') +}) + +it.each(['clientSourceId=', 'clientSourceId=a&clientSourceId=b', `clientSourceId=${'x'.repeat(257)}`])( + 'rejects malformed selections instead of opening a global connection: %s', (query) => { + expect(() => readInspectorClientSelection(new URL(`http://inspector/cdp?${query}`))).toThrow() + }, +) + +it.each(['http', 'https'])('preserves the embedding Client selection and deployment prefix over %s', (protocol) => { + const location = new URL(`${protocol}://inspector/app/inspector/devtools/devtools_app.html?disableLocaleInfoBar=true`) + location.searchParams.set(INSPECTOR_CLIENT_QUERY, 'client/page?x=1&y=2') + let result: URL | undefined + const preferences = new Map() + runInNewContext(bootstrap, { URL, window: { + location, localStorage: { setItem: (key: string, value: string) => preferences.set(key, value) }, + history: { state: null, replaceState: (_state: null, _title: string, url: URL) => { result = url } }, + } }) + const selected = new URL(`${protocol === 'https' ? 'wss' : 'ws'}://${result!.searchParams.get(protocol === 'https' ? 'wss' : 'ws')}`) + expect(selected.pathname).toBe('/app/inspector/devtools/cdp') + expect(readInspectorClientSelection(selected)).toBe('client/page?x=1&y=2') + expect(preferences.get('disable-locale-info-bar')).toBe('true') +}) + +it('keeps an explicit debugging endpoint unchanged', () => { + const location = new URL('http://inspector/devtools_app.html?ws=remote:9230/devtools/page/target&clientSourceId=client-page') + let result: URL | undefined + runInNewContext(bootstrap, { URL, window: { + location, history: { state: null, replaceState: (_state: null, _title: string, url: URL) => { result = url } }, + } }) + expect(result!.searchParams.get('ws')).toBe('remote:9230/devtools/page/target') +}) diff --git a/packages/experimental/inspector/tests/dispose.host.spec.ts b/packages/experimental/inspector/tests/dispose.host.spec.ts new file mode 100644 index 0000000000..0584bee935 --- /dev/null +++ b/packages/experimental/inspector/tests/dispose.host.spec.ts @@ -0,0 +1,27 @@ +/** Host-run checks for ordered async Inspector cleanup. */ + +import { expect, it, vi } from 'vitest' +import { disposeInspectorResources } from '../src/shared/dispose.ts' + +it('waits for each registration before releasing its predecessor and source', async () => { + const released = Promise.withResolvers() + const order: string[] = [] + const disposed = disposeInspectorResources([ + () => { order.push('first') }, + async () => { order.push('second'); await released.promise }, + ], () => { order.push('source') }, 'failed') + try { + expect(order).toEqual(['second']) + } finally { released.resolve(undefined) } + await disposed + expect(order).toEqual(['second', 'first', 'source']) +}) + +it('collects both registration and transport failures after attempting every action', async () => { + const earlier = new Error('registration') + const later = new Error('transport') + const retained = vi.fn() + const disposed = disposeInspectorResources([retained, async () => { throw earlier }], () => { throw later }, 'cleanup failed') + await expect(disposed).rejects.toMatchObject({ message: 'cleanup failed', errors: [earlier, later] }) + expect(retained).toHaveBeenCalledOnce() +}) diff --git a/packages/experimental/inspector/tests/integration.host.spec.ts b/packages/experimental/inspector/tests/integration.host.spec.ts index 40f518359b..383c4e364f 100644 --- a/packages/experimental/inspector/tests/integration.host.spec.ts +++ b/packages/experimental/inspector/tests/integration.host.spec.ts @@ -81,6 +81,86 @@ describe('experimental Inspector real Worker', () => { server = undefined }) + it('limits an embedded connection to its Client while direct connections retain every Client', async (test) => { + inspector = await startInspector({ port: 0, captureFetch: false, clientReconnectBaseMs: 10, clientReconnectMaxMs: 20 }) + const sourceCatalog = (name: string) => ({ + sourceText: `globalThis.clientName = ${JSON.stringify(name)}`, + sourceMap: JSON.stringify({ version: 3, sources: [`${name}.ts`], mappings: 'AAAA' }), + sourceUrl: `http://client.test/${name}.js`, sourceMapUrl: `http://client.test/${name}.js.map`, + }) + client = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Embedded', sourceCatalog: sourceCatalog('embedded') }) + cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl) + let sourceId: string | undefined + await vi.waitFor(async () => { + const sources = recordArray((await cdp!.call('DSHInspector.getSources')).result?.sources) + const selected = sources.find(source => source.label === 'Embedded') + expect(typeof selected?.sourceId).toBe('string') + sourceId = String(selected!.sourceId) + }) + const endpoint = new URL(inspector.endpoint.webSocketDebuggerUrl) + endpoint.searchParams.set('clientSourceId', sourceId!) + secondCdp = await TestCdpClient.connect(endpoint.href) + await Promise.all([cdp.call('Runtime.enable'), secondCdp.call('Runtime.enable')]) + await Promise.all([cdp.call('Debugger.enable'), secondCdp.call('Debugger.enable')]) + await secondCdp.call('DSHInspector.enable') + const other = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Other', sourceCatalog: sourceCatalog('other') }) + test.onTestFinished(() => other.close()) + await vi.waitFor(() => { + expect(runtimeContexts(cdp!).map(context => context.name).sort()).toEqual(['Client — Embedded', 'Client — Other', 'Host']) + expect(runtimeContexts(secondCdp!).map(context => context.name).sort()).toEqual(['Client — Embedded', 'Host']) + expect(cdp!.events.some(event => event.method === 'Debugger.scriptParsed' && event.params?.url === 'http://client.test/other.js')).toBe(true) + }) + expect(secondCdp.events.some(event => event.method === 'Debugger.scriptParsed' && event.params?.url === 'http://client.test/other.js')).toBe(false) + const otherContext = runtimeContexts(cdp).find(context => context.name === 'Client — Other')! + const selectedContext = runtimeContexts(secondCdp).find(context => context.name === 'Client — Embedded')! + expect((await secondCdp.call('Runtime.evaluate', { expression: '1', contextId: otherContext.id })).error).toBeDefined() + await other.log('other-value', 'other-console') + await client.log('own-value', 'own-console') + await vi.waitFor(() => { + expect(consoleEvent(cdp!, Number(otherContext.id), 'other-console')).toBeDefined() + expect(consoleEvent(secondCdp!, Number(selectedContext.id), 'own-console')).toBeDefined() + }) + expect(consoleEvent(secondCdp, Number(otherContext.id), 'other-console')).toBeUndefined() + const filteredSources = recordArray((await secondCdp.call('DSHInspector.getSources')).result?.sources) + expect(filteredSources.filter(source => source.kind === 'client').map(source => source.label)).toEqual(['Embedded']) + for (const event of secondCdp.events.filter(event => event.method === 'DSHInspector.sourcesChanged')) { + expect(recordArray(event.params?.sources).filter(source => source.kind === 'client').map(source => source.label)).toEqual(['Embedded']) + } + const scopedTree = asRecord((await secondCdp.call('DSHInspector.getCordisTree')).result?.tree) + const fullTree = asRecord((await cdp.call('DSHInspector.getCordisTree')).result?.tree) + expect(recordArray(scopedTree.clients).map(realm => asRecord(realm.source).label)).toEqual(['Embedded']) + expect(recordArray(fullTree.clients)).toHaveLength(2) + const root = asRecord((await secondCdp.call('DOM.getDocument', { depth: 3 })).result?.root) + const clients = recordArray(root.children).find(node => node.localName === 'clients')! + expect(recordArray(clients.children)).toHaveLength(1) + await client.disconnect() + await vi.waitFor(() => { + expect(secondCdp!.events.some(event => event.method === 'Runtime.executionContextDestroyed' + && event.params?.executionContextId === selectedContext.id)).toBe(true) + const reconnected = runtimeContexts(secondCdp!).filter(context => context.name === 'Client — Embedded') + expect(reconnected).toHaveLength(2) + expect(reconnected[1]?.id).not.toBe(selectedContext.id) + }) + expect(runtimeContexts(secondCdp).some(context => context.name === 'Client — Other')).toBe(false) + expect(recordArray((await cdp.call('DSHInspector.getSources')).result?.sources).filter(source => source.kind === 'client')).toHaveLength(2) + endpoint.searchParams.set('clientSourceId', 'absent-client') + const absent = await TestCdpClient.connect(endpoint.href) + test.onTestFinished(() => absent.close()) + await absent.call('Runtime.enable') + expect(runtimeContexts(absent).map(context => context.name)).toEqual(['Host']) + expect(recordArray((await absent.call('DSHInspector.getSources')).result?.sources).every(source => source.kind === 'host')).toBe(true) + }) + + it('rejects malformed Client selectors before admitting a CDP connection', async () => { + inspector = await startInspector({ port: 0, captureFetch: false }) + for (const query of ['clientSourceId=', 'clientSourceId=a&clientSourceId=b']) { + await expect(TestCdpClient.connect(`${inspector.endpoint.webSocketDebuggerUrl}?${query}`)) + .rejects.toThrow('400') + } + cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl) + expect((await cdp.call('Runtime.enable')).error).toBeUndefined() + }) + it('switches between Host and Client contexts and routes Client RemoteObjects', async () => { inspector = await startInspector({ port: 0, captureFetch: false, clientReconnectBaseMs: 10, clientReconnectMaxMs: 20 }) cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl) @@ -100,7 +180,7 @@ describe('experimental Inspector real Worker', () => { let clientContextId: number | undefined let clientUniqueContextId: string | undefined await vi.waitFor(() => { - expect(runtimeContexts(cdp!).some(context => context.name === 'Host')).toBe(true) + expect(runtimeContexts(cdp!).map(context => context.name).sort()).toEqual(['Client — Test Client', 'Host']) const clientContext = cdp!.events .filter(event => event.method === 'Runtime.executionContextCreated') .map(event => event.params?.context as Record | undefined) @@ -271,7 +351,7 @@ describe('experimental Inspector real Worker', () => { })).result?.result).toMatchObject({ type: 'number', value: 42 }) }) - it('preserves native Host execution-context selectors', async () => { + it('advertises only the default Host context and preserves its numeric and unique selectors', async () => { inspector = await startInspector({ port: 0, captureFetch: false }) cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl) await cdp.call('Runtime.enable') @@ -281,30 +361,31 @@ describe('experimental Inspector real Worker', () => { let contextId: number | undefined let uniqueContextId: string | undefined await vi.waitFor(() => { - const created = runtimeContexts(cdp!).find(candidate => candidate.name === 'Inspector VM Context') + const created = runtimeContexts(cdp!).find(candidate => candidate.name === 'Host') contextId = created?.id as number | undefined uniqueContextId = created?.uniqueId as string | undefined expect(contextId).toBeTypeOf('number') expect(uniqueContextId).toBeTypeOf('string') }) const evaluated = await cdp.call('Runtime.evaluate', { - expression: 'globalThis.vmMarker', + expression: 'process.pid', contextId, returnByValue: true, }) - expect(evaluated.result?.result).toMatchObject({ type: 'string', value: 'selected-vm' }) + expect(evaluated.result?.result).toMatchObject({ type: 'number', value: process.pid }) expect((await cdp.call('Runtime.evaluate', { - expression: 'globalThis.vmMarker', + expression: 'process.pid', uniqueContextId, returnByValue: true, - })).result?.result).toMatchObject({ type: 'string', value: 'selected-vm' }) + })).result?.result).toMatchObject({ type: 'number', value: process.pid }) expect((await cdp.call('Runtime.callFunctionOn', { executionContextId: contextId, - functionDeclaration: 'function () { return globalThis.vmMarker }', + functionDeclaration: 'function () { return process.pid }', returnByValue: true, - })).result?.result).toMatchObject({ type: 'string', value: 'selected-vm' }) + })).result?.result).toMatchObject({ type: 'number', value: process.pid }) expect((await cdp.call('Runtime.globalLexicalScopeNames', { executionContextId: contextId })).result?.names) - .toContain('vmLexicalMarker') + .not.toContain('vmLexicalMarker') + expect(runtimeContexts(cdp).map(candidate => candidate.name)).toEqual(['Host']) }) it('uses the same Runtime value model for Host and Client realms', async () => { diff --git a/packages/experimental/inspector/tests/layout.host.spec.ts b/packages/experimental/inspector/tests/layout.host.spec.ts index 39b8cf7bf4..2ef6c4be16 100644 --- a/packages/experimental/inspector/tests/layout.host.spec.ts +++ b/packages/experimental/inspector/tests/layout.host.spec.ts @@ -10,8 +10,11 @@ const packageRoot = fileURLToPath(new URL('../', import.meta.url)) const testsRoot = fileURLToPath(new URL('./', import.meta.url)) describe('Inspector execution layout', () => { - it('keeps Client and Host implementation paths mirrored', async () => { - expect(await sourceFiles('client')).toEqual(await sourceFiles('host')) + it('keeps Client and Host adapters mirrored apart from Client dictionaries and bottom controls', async () => { + const clientOnly = ['bridge/locales.ts', 'bottom/locales.ts', 'bottom/keyboard.ts'] + const client = await sourceFiles('client') + expect(clientOnly.every(file => client.includes(file))).toBe(true) + expect(client.filter(file => !clientOnly.includes(file))).toEqual(await sourceFiles('host')) }) it('keeps Worker Client and Host backend paths mirrored', async () => { diff --git a/packages/experimental/inspector/tests/loader-composition.host.spec.ts b/packages/experimental/inspector/tests/loader-composition.host.spec.ts index 387482d6e0..462a5b146a 100644 --- a/packages/experimental/inspector/tests/loader-composition.host.spec.ts +++ b/packages/experimental/inspector/tests/loader-composition.host.spec.ts @@ -7,10 +7,16 @@ import { fileURLToPath, pathToFileURL } from 'node:url' import { Context } from '@deepseek-ai/cordis' import Include from '@deepseek-ai/cordis-plugin-include' import Loader from '@deepseek-ai/cordis-plugin-loader' -import { loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' import WebServer from '@deepseek-ai/dsh-host-webserver' +import { HostConnectionService } from '@deepseek-ai/dsh-client-connection' +import { composeEntries, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' +import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts' +import open from 'open' import { afterEach, describe, expect, it, vi } from 'vitest' import * as Inspector from '../src/index.ts' +import { INSPECTOR_BOOTSTRAP_PATH } from '../src/shared/web.ts' + +vi.mock('open', () => ({ default: vi.fn(async () => undefined), apps: { chrome: 'chrome' } })) let root: string | undefined let context: Context | undefined @@ -20,27 +26,24 @@ afterEach(async () => { context = undefined if (root !== undefined) await rm(root, { recursive: true, force: true }) root = undefined + vi.clearAllMocks() }) describe('experimental Inspector through a real Loader composition', () => { - it('mounts the bundle patch over a Web server row and releases its endpoint', async () => { + it.each([false, true])('starts inspection with --inspect=%s and opens Chrome only when requested', async (flag) => { root = await mkdtemp(join(tmpdir(), 'dsh-inspector-loader-')) const configPath = join(root, 'cordis.yml') - await writeFile(configPath, [ - "- name: '@deepseek-ai/dsh-host-webserver'", - ' config:', - " host: '127.0.0.1'", - ' port: 0', - '', - ].join('\n')) - // The shipped bundle patch inserts the row by package name under a stable id; a later - // profile patch configures that id, as the Plugins page and a `--patch` overlay do. - const bundlePatches = loadOverlayPatches('test', fileURLToPath(new URL('../cordis.patch.yml', import.meta.url))) - expect(bundlePatches.flatMap(patch => patch.insert ?? [])).toEqual([ - { id: 'experimental-inspector', name: '@deepseek-ai/dsh-experimental-inspector' }, - ]) + const entries = composeEntries([loadOverlayPatches('dsh', fileURLToPath( + new URL('../../inspector-profile/cordis.patch.yml', import.meta.url), + ))]) + const inspector = entries.find(entry => entry.id === 'experimental-inspector')! + await writeFile(configPath, JSON.stringify([ + { name: '@deepseek-ai/dsh-host-webserver', config: { host: '127.0.0.1', port: 0 } }, + { name: 'fixture:connection' }, + ])) context = new Context() + context.provide('cmdlineArgs', { get: () => flag ? ['--inspect'] : [] }) context.baseUrl = pathToFileURL(root).href + '/' await context.plugin(Loader) expect('default' in Inspector).toBe(false) @@ -54,6 +57,7 @@ describe('experimental Inspector through a real Loader composition', () => { context.loader.builtins.include = Include const modules = new Map([ ['@deepseek-ai/dsh-host-webserver', WebServer], + ['fixture:connection', (ctx: Context) => { new HostConnectionService(ctx, [], {} as BrowserAuth) }], ['@deepseek-ai/dsh-experimental-inspector', Inspector], ]) context.loader.internal = { @@ -67,7 +71,7 @@ describe('experimental Inspector through a real Loader composition', () => { name: 'cordis:include', config: { path: pathToFileURL(configPath).href, - patches: [...bundlePatches, { id: 'experimental-inspector', config: { port: 0, captureFetch: false } }], + patches: [{ insert: [inspector] }, { id: 'experimental-inspector', config: { port: 0, captureFetch: false } }], }, }) await context.loader.await() @@ -75,13 +79,18 @@ describe('experimental Inspector through a real Loader composition', () => { expect([...context.loader.entries()] .filter(entry => entry.fiber === undefined && !entry.disabled)) .toEqual([]) + const inspectorEntry = [...context.loader.entries()] + .find(entry => entry.options.name === '@deepseek-ai/dsh-experimental-inspector') + expect(inspectorEntry?.disabled).toBe(false) + expect(open).toHaveBeenCalledTimes(flag ? 1 : 0) + const api = (context.connection as HostConnectionService).createSharedFetchHandler('/api') + expect(inspectorEntry?.fiber).toBeDefined() await vi.waitFor(async () => { expect((await context!.inspector.cordis.getTree()).host?.source.kind).toBe('host') }) + const bootstrap = await api.fetch(new Request(`http://localhost${INSPECTOR_BOOTSTRAP_PATH}`)) + expect(await bootstrap.json()).toHaveProperty('endpoint', expect.stringContaining('/ingest')) - const inspectorEntry = [...context.loader.entries()] - .find(entry => entry.options.name === '@deepseek-ai/dsh-experimental-inspector') - expect(inspectorEntry?.fiber).toBeDefined() await inspectorEntry!.fiber!.dispose() expect(context.get('inspector')).toBeUndefined() }) diff --git a/packages/experimental/inspector/tests/plugin.client.spec.ts b/packages/experimental/inspector/tests/plugin.client.spec.ts index 7d518e976f..f5160e1cc7 100644 --- a/packages/experimental/inspector/tests/plugin.client.spec.ts +++ b/packages/experimental/inspector/tests/plugin.client.spec.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { apply } from '../src/client/index.ts' import { ClientRealmSource } from '../src/client/inspection/realm.ts' import type { InspectorClientBootstrap } from '../src/shared/bridge/messages/control.ts' +import { INSPECTOR_BOOTSTRAP_ROUTE } from '../src/shared/web.ts' class FakeWebSocket extends EventTarget { static readonly CONNECTING = 0 @@ -68,6 +69,7 @@ describe('experimental Inspector Client plugin', () => { afterEach(() => { vi.restoreAllMocks() + vi.unstubAllGlobals() FakeWebSocket.sockets.length = 0 globalThis.WebSocket = nativeWebSocket globalThis.fetch = nativeFetch @@ -76,6 +78,34 @@ describe('experimental Inspector Client plugin', () => { Reflect.deleteProperty(globalThis, '__DSH_BOOT__') }) + it('removes the source on pagehide and reconnects once when the cached page resumes', async () => { + vi.stubGlobal('WebSocket', FakeWebSocket) + globalThis.__DSH_INSPECTOR__ = bootstrap + const ctx = new Context() + try { + await ctx.plugin({ apply }).await() + const first = FakeWebSocket.sockets[0]! + first.open() + const opened = JSON.parse(first.sent[0]!) as { source: { sourceId: string; generation: string } } + first.receive({ v: 0, t: 'source/accepted', sourceId: opened.source.sourceId, generation: opened.source.generation }) + window.dispatchEvent(new Event('visibilitychange')) + expect(first.readyState).toBe(FakeWebSocket.OPEN) + window.dispatchEvent(new Event('pagehide')) + expect(first.readyState).toBe(FakeWebSocket.CLOSED) + expect(first.sent.map(value => JSON.parse(value) as { t: string }).at(-1)?.t).toBe('source/close') + window.dispatchEvent(new Event('pageshow')) + window.dispatchEvent(new Event('pageshow')) + expect(FakeWebSocket.sockets).toHaveLength(2) + const second = FakeWebSocket.sockets[1]! + second.open() + const resumed = JSON.parse(second.sent[0]!) as { source: { sourceId: string; generation: string } } + expect(resumed.source.sourceId).toBe(opened.source.sourceId) + expect(resumed.source.generation).not.toBe(opened.source.generation) + } finally { await ctx.fiber.dispose() } + window.dispatchEvent(new Event('pageshow')) + expect(FakeWebSocket.sockets).toHaveLength(2) + }) + it('provides ctx.inspector and sends observations after the Worker accepts the source', async () => { globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket globalThis.__DSH_INSPECTOR__ = bootstrap @@ -366,11 +396,11 @@ describe('experimental Inspector Client plugin', () => { rev: 'graph', entries: [{ id: '@deepseek-ai/dsh-experimental-inspector', - url: 'plugins/@deepseek-ai/dsh-experimental-inspector/client.js?rev=bundle-rev', + url: 'plugins/??@deepseek-ai/dsh-experimental-inspector/client.js&rev=bundle-rev', rev: 'bundle-rev', }], }) - const source = 'const clientBundleMarker = "你好"\n' + const source = 'const clientBundleMarker = "你好"\n//# sourceMappingURL=??@deepseek-ai/dsh-experimental-inspector/client.js.map&rev=bundle-rev\n' const sourceMap = '{"version":3,"sources":["client/index.ts"]}' globalThis.fetch = vi.fn(async (input: string | URL | Request) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url @@ -409,9 +439,9 @@ describe('experimental Inspector Client plugin', () => { outcome?: { result?: { scripts?: Array<{ scriptKey: string; url: string; sourceMapUrl: string }> } } }).find(frame => frame.requestId === 'source-request-1') const script = response?.outcome?.result?.scripts?.[0] - expect(script?.url).toContain('/plugins/@deepseek-ai/dsh-experimental-inspector/client.js?rev=bundle-rev') + expect(script?.url).toContain('/plugins/??@deepseek-ai/dsh-experimental-inspector/client.js&rev=bundle-rev') expect(script?.sourceMapUrl) - .toContain('/plugins/@deepseek-ai/dsh-experimental-inspector/client.js.map?rev=bundle-rev') + .toContain('/plugins/??@deepseek-ai/dsh-experimental-inspector/client.js.map&rev=bundle-rev') scriptKey = script?.scriptKey }) socket.receive({ @@ -436,17 +466,77 @@ describe('experimental Inspector Client plugin', () => { await fiber.dispose() }) - it('fails loud when the Host did not inject a bootstrap', async () => { - globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket + it('connects without index injection and reuses the source after a connection reset', async () => { + vi.stubGlobal('WebSocket', FakeWebSocket) + const fetch = vi.fn(async (url: string) => { + expect(url).toBe(INSPECTOR_BOOTSTRAP_ROUTE) + expect(new URL(url, 'https://host.example/tools/dsh/').pathname) + .toBe('/tools/dsh/api/experimental-inspector/bootstrap') + return Response.json(bootstrap) + }) + vi.stubGlobal('fetch', fetch) const ctx = new Context() - const fiber = ctx.plugin({ apply }) - await expect(fiber).rejects.toThrow('Host bootstrap is missing; reload the page after enabling the Inspector') - await fiber.dispose() + try { + const fiber = ctx.plugin({ apply }) + await fiber.await() + expect(ctx.get('inspector')).toBeDefined() + expect(FakeWebSocket.sockets).toHaveLength(1) + ctx.emit('connection/reset') + await vi.waitFor(() => { expect(fetch).toHaveBeenCalledTimes(2) }) + expect(FakeWebSocket.sockets).toHaveLength(1) + await fiber.dispose() + expect(FakeWebSocket.sockets[0]!.readyState).toBe(FakeWebSocket.CLOSED) + } finally { await ctx.fiber.dispose() } }) - it('closes the Client source when a later plugin registration fails', async () => { - globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket + it('retries a missing bootstrap and replaces a source after the Host generation changes', async () => { + vi.stubGlobal('WebSocket', FakeWebSocket) + let response = new Response(null, { status: 503 }) + vi.stubGlobal('fetch', vi.fn(async () => response)) + const ctx = new Context() + try { + await ctx.plugin({ apply }).await() + expect(ctx.get('inspector')).toBeUndefined() + response = Response.json(bootstrap) + ctx.emit('connection/reset') + await vi.waitFor(() => { expect(FakeWebSocket.sockets).toHaveLength(1) }) + const first = FakeWebSocket.sockets[0]! + response = Response.json({ ...bootstrap, protocol: 'dsh-inspector-v0-new-worker' }) + ctx.emit('connection/reset') + await vi.waitFor(() => { expect(FakeWebSocket.sockets).toHaveLength(2) }) + expect(first.readyState).toBe(FakeWebSocket.CLOSED) + expect(FakeWebSocket.sockets[1]!.protocol).toBe('dsh-inspector-v0-new-worker') + } finally { await ctx.fiber.dispose() } + }) + + it('does not install a late bootstrap after disposal begins', async () => { + vi.stubGlobal('WebSocket', FakeWebSocket) globalThis.__DSH_INSPECTOR__ = bootstrap + let respond!: (response: Response) => void + const pending = new Promise((resolve) => { respond = resolve }) + const fetch = vi.fn(() => pending) + vi.stubGlobal('fetch', fetch) + const ctx = new Context() + try { + const fiber = ctx.plugin({ apply }) + await fiber.await() + ctx.emit('connection/reset') + await vi.waitFor(() => { expect(fetch).toHaveBeenCalledOnce() }) + const disposed = fiber.dispose() + respond(Response.json({ ...bootstrap, protocol: 'dsh-inspector-v0-late-worker' })) + await disposed + expect(FakeWebSocket.sockets).toHaveLength(1) + expect(FakeWebSocket.sockets[0]!.readyState).toBe(FakeWebSocket.CLOSED) + } finally { + respond(Response.json(bootstrap)) + await ctx.fiber.dispose() + } + }) + + it.each([true, false])('closes the Client source when registration fails (injected=%s)', async (injected) => { + globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket + if (injected) globalThis.__DSH_INSPECTOR__ = bootstrap + else vi.stubGlobal('fetch', vi.fn(async () => Response.json(bootstrap))) const ctx = new Context() ctx.provide('inspector', { publish: () => undefined, @@ -459,4 +549,105 @@ describe('experimental Inspector Client plugin', () => { expect(FakeWebSocket.sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED) await fiber.dispose() }) + + it.each([true, false])('retains reconnect recovery after transport setup fails (injected=%s)', async (injected) => { + vi.stubGlobal('WebSocket', FakeWebSocket) + vi.stubGlobal('fetch', vi.fn(async () => Response.json(bootstrap))) + if (injected) globalThis.__DSH_INSPECTOR__ = bootstrap + vi.spyOn(ClientRealmSource, 'claim').mockRejectedValueOnce(new Error('identity temporarily unavailable')) + const ctx = new Context() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + try { + await ctx.plugin({ apply }).await() + expect(warn).toHaveBeenCalledOnce() + expect(ctx.get('inspector')).toBeUndefined() + ctx.emit('connection/reset') + await vi.waitFor(() => { expect(ctx.get('inspector')).toBeDefined() }) + expect(FakeWebSocket.sockets).toHaveLength(1) + } finally { await ctx.fiber.dispose() } + expect(FakeWebSocket.sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED) + expect(ctx.get('inspector')).toBeUndefined() + }) + + it.each([true, false])('rejects invalid bootstrap data during startup (injected=%s)', async (injected) => { + vi.stubGlobal('WebSocket', FakeWebSocket) + if (injected) globalThis.__DSH_INSPECTOR__ = null + else vi.stubGlobal('fetch', vi.fn(async () => Response.json(null))) + const ctx = new Context() + try { + await expect(ctx.plugin({ apply }).await()).rejects.toThrow() + expect(FakeWebSocket.sockets).toHaveLength(0) + expect(ctx.get('inspector')).toBeUndefined() + } finally { await ctx.fiber.dispose() } + }) + + it('retains recovery after a bootstrap network failure', async () => { + vi.stubGlobal('WebSocket', FakeWebSocket) + vi.stubGlobal('fetch', vi.fn().mockRejectedValueOnce(new TypeError('network unavailable')).mockResolvedValue(Response.json(bootstrap))) + const ctx = new Context() + try { + await ctx.plugin({ apply }).await() + expect(ctx.get('inspector')).toBeUndefined() + ctx.emit('connection/reset') + await vi.waitFor(() => { expect(ctx.get('inspector')).toBeDefined() }) + } finally { await ctx.fiber.dispose() } + }) + + it('settles an aborted bootstrap refresh without warning after disposal', async () => { + vi.stubGlobal('WebSocket', FakeWebSocket) + globalThis.__DSH_INSPECTOR__ = bootstrap + const pending = Promise.withResolvers() + const aborted = Promise.withResolvers() + const fetch = vi.fn((_url: string, init: RequestInit) => { + init.signal?.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true }) + return pending.promise + }) + vi.stubGlobal('fetch', fetch) + const ctx = new Context() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const fiber = ctx.plugin({ apply }) + try { + await fiber.await() + ctx.emit('connection/reset') + await vi.waitFor(() => { expect(fetch).toHaveBeenCalledOnce() }) + const disposed = fiber.dispose() + await aborted.promise + pending.reject(new Error('aborted request')) + await disposed + expect(warn).not.toHaveBeenCalled() + } finally { + pending.resolve(Response.json(bootstrap)) + await ctx.fiber.dispose() + } + }) + + it('joins service-consumer cleanup before claiming a replacement source', async () => { + vi.stubGlobal('WebSocket', FakeWebSocket) + globalThis.__DSH_INSPECTOR__ = bootstrap + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ ...bootstrap, protocol: 'replacement-worker' }))) + const claimed = vi.spyOn(ClientRealmSource, 'claim') + const started = Promise.withResolvers() + const released = Promise.withResolvers() + const ctx = new Context() + try { + await ctx.plugin({ apply }).await() + await ctx.plugin({ inject: ['inspector'], apply(child) { + child.effect(() => async () => { + started.resolve(undefined) + await released.promise + }) + } }).await() + ctx.emit('connection/reset') + await started.promise + expect(claimed).toHaveBeenCalledTimes(1) + expect(ctx.get('inspector')).toBeUndefined() + released.resolve(undefined) + await vi.waitFor(() => { expect(FakeWebSocket.sockets).toHaveLength(2) }) + expect(FakeWebSocket.sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED) + expect(ctx.get('inspector')).toBeDefined() + } finally { + released.resolve(undefined) + await ctx.fiber.dispose() + } + }) }) diff --git a/packages/experimental/inspector/tests/plugin.host.spec.ts b/packages/experimental/inspector/tests/plugin.host.spec.ts index 905c655d96..0fe950bfed 100644 --- a/packages/experimental/inspector/tests/plugin.host.spec.ts +++ b/packages/experimental/inspector/tests/plugin.host.spec.ts @@ -1,11 +1,18 @@ -import { createServer } from 'node:http' +import { createServer, type IncomingMessage } from 'node:http' +import { PassThrough } from 'node:stream' import type { AddressInfo } from 'node:net' import { Context } from '@deepseek-ai/cordis' -import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver' +import type { IndexInjection, WebServer, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' +import { HostConnectionService } from '@deepseek-ai/dsh-client-connection' +import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts' +import open from 'open' import WebSocket, { type RawData } from 'ws' import { afterEach, describe, expect, it, vi } from 'vitest' import { apply, Config, inject, name, startInspector } from '../src/index.ts' import { isPlainObject } from '../src/shared/json.ts' +import { INSPECTOR_BOOTSTRAP_PATH } from '../src/shared/web.ts' + +vi.mock('open', () => ({ default: vi.fn(async () => undefined), apps: { chrome: 'chrome' } })) interface CdpResponse { readonly id: number @@ -19,12 +26,21 @@ describe('experimental Inspector Host plugin', () => { await context?.fiber.dispose() context = undefined vi.restoreAllMocks() + vi.clearAllMocks() }) it('starts the Worker, provides ctx.inspector, injects Client bootstrap, and disposes', async () => { context = new Context() const log = vi.spyOn(console, 'log').mockImplementation(() => undefined) - context.provide('webServer', {} as WebServer) + const releaseRoute = vi.fn() + const registerRoute = vi.fn(() => releaseRoute) + const releaseUpgrade = vi.fn() + const registerUpgrade = vi.fn<(_route: WebUpgradeRoute) => () => void>(() => releaseUpgrade) + const routes: Pick = { register: registerRoute, registerUpgrade } + let authenticated = false + const auth: Pick = { isAuthenticated: () => authenticated } + context.provide('webServer', routes as WebServer) + const connection = new HostConnectionService(context, [], auth as BrowserAuth) const fiber = context.plugin( { name, inject: [...inject], Config, apply }, { port: 0, captureFetch: false }, @@ -37,6 +53,22 @@ describe('experimental Inspector Host plugin', () => { expect(bootstrap).toMatchObject({ kind: 'global', name: '__DSH_INSPECTOR__' }) expect(log).toHaveBeenCalledWith(expect.stringMatching(/^dsh inspector: devtools:\/\//u)) expect(context.inspector).toBeDefined() + expect(registerRoute).toHaveBeenCalledWith(expect.objectContaining({ kind: 'prefix', path: '/inspector/devtools' })) + expect(registerUpgrade).toHaveBeenCalledWith(expect.objectContaining({ path: '/inspector/devtools/cdp' })) + const upgrade = registerUpgrade.mock.calls[0]![0] + for (const [origin, status] of [['http://127.0.0.1', 401], ['http://untrusted.invalid', 403]] as const) { + const socket = new PassThrough() + await upgrade.handler({ headers: { host: '127.0.0.1', origin } } as IncomingMessage, socket, Buffer.alloc(0)) + expect(String(socket.read())).toContain(`HTTP/1.1 ${status}`) + socket.destroy() + } + authenticated = true + for (const query of ['clientSourceId=', 'clientSourceId=a&clientSourceId=b']) { + const socket = new PassThrough() + await upgrade.handler({ url: `/inspector/devtools/cdp?${query}`, headers: { host: '127.0.0.1', origin: 'http://127.0.0.1' } } as IncomingMessage, socket, Buffer.alloc(0)) + expect(String(socket.read())).toContain('HTTP/1.1 400') + socket.destroy() + } await vi.waitFor(async () => { const tree = await context!.inspector.cordis.getTree() expect(tree.host?.source.kind).toBe('host') @@ -46,6 +78,11 @@ describe('experimental Inspector Host plugin', () => { context.inspector.publish('host/plugin-probe', { ready: true }) const value = bootstrap?.kind === 'global' ? bootstrap.value : undefined + const api = connection.createSharedFetchHandler('/api') + const fetched = await api.fetch(new Request(`http://localhost${INSPECTOR_BOOTSTRAP_PATH}`)) + expect(fetched.headers.get('cache-control')).toBe('no-store') + expect(await fetched.json()).toEqual(value) + expect(open).not.toHaveBeenCalled() const endpoint = value as { endpoint: string; protocol: string } const authority = new URL(endpoint.endpoint) const targets: unknown = await fetch(`http://${authority.host}/json`).then(response => response.json()) @@ -72,16 +109,21 @@ describe('experimental Inspector Host plugin', () => { await new Promise((resolve) => { socket.once('close', () => { resolve() }) }) await fiber.dispose() + expect(releaseRoute).toHaveBeenCalledOnce() + expect(releaseUpgrade).toHaveBeenCalledOnce() expect(rows).toHaveLength(1) const afterDispose: IndexInjection[] = [] context.emit('webserver/index-inject', afterDispose) expect(afterDispose).toEqual([]) + expect((await api.fetch(new Request(`http://localhost${INSPECTOR_BOOTSTRAP_PATH}`))).status).toBe(404) }) it('closes the started Worker when a later plugin registration fails', async () => { const port = await availablePort() context = new Context() - context.provide('webServer', {} as WebServer) + const routes: Pick = { register: () => () => {}, registerUpgrade: () => () => {} } + context.provide('webServer', routes as WebServer) + new HostConnectionService(context, [], {} as BrowserAuth) context.provide('inspector', { publish: () => undefined, cordis: { getTree: () => Promise.reject(new Error('unused test service')) }, @@ -98,6 +140,26 @@ describe('experimental Inspector Host plugin', () => { await replacement.close() }) + it('keeps inspection available when --inspect cannot open Chrome', async () => { + context = new Context() + const routes: Pick = { register: () => () => {}, registerUpgrade: () => () => {} } + context.provide('webServer', routes as WebServer) + context.provide('cmdlineArgs', { get: () => ['--inspect'] }) + const connection = new HostConnectionService(context, [], {} as BrowserAuth) + vi.mocked(open).mockRejectedValueOnce(new Error('Chrome unavailable')) + const fiber = context.plugin({ name, inject: [...inject], Config, apply }, { port: 0, captureFetch: false }) + await fiber.await() + expect(context.get('inspector')).toBeDefined() + expect(open).toHaveBeenCalledOnce() + expect(open).toHaveBeenCalledWith(expect.stringMatching(/^devtools:\/\//u), { + app: { name: 'chrome' }, wait: false, + }) + const api = connection.createSharedFetchHandler('/api') + const response = await api.fetch(new Request(`http://localhost${INSPECTOR_BOOTSTRAP_PATH}`)) + expect(response.status).toBe(200) + expect(await response.json()).toHaveProperty('endpoint', expect.stringContaining('/ingest')) + }) + it('closes the Worker when fetch capture installation fails', async () => { const port = await availablePort() const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'fetch') diff --git a/packages/experimental/inspector/tests/prune-lighthouse.host.spec.ts b/packages/experimental/inspector/tests/prune-lighthouse.host.spec.ts new file mode 100644 index 0000000000..1bc38d51b3 --- /dev/null +++ b/packages/experimental/inspector/tests/prune-lighthouse.host.spec.ts @@ -0,0 +1,145 @@ +/** Pure Lighthouse pruning fixtures retain adjacent panels, AI capabilities, and image registrations. */ +import ts from 'typescript' +import { describe, expect, it } from 'vitest' +import { pruneLighthouse } from '../scripts/prune-lighthouse.ts' + +const entryPath = 'entrypoints/devtools_app/devtools_app.js' +const panelPath = 'panels/ai_assistance/ai_assistance.js' +const modelPath = 'models/ai_assistance/ai_assistance.js' +const entry = ` +import * as Lighthouse from "../../panels/lighthouse/lighthouse.js"; +import * as Views from "../../ui/legacy/legacy.js"; +const strings = { title: "Lighthouse" }, messages = Locale.registerUIStrings("panels/lighthouse/lighthouse-meta.ts", strings), cached = undefined; +async function loadLighthouse() { return cached || await import("../../panels/lighthouse/lighthouse.js"); } +Views.registerViewExtension({ id: "elements", title: "Elements" }); +Views.registerViewExtension({ id: "lighthouse", async loadView() { return (await loadLighthouse()).LighthousePanel; } }); +Core.registerRevealer({ contextTypes() { return [Lighthouse.LighthousePanel.ActiveLighthouseReport]; }, async loadRevealer() { return (await loadLighthouse()).ReportRevealer; } }); +Views.registerViewExtension({ id: "timeline", title: "Performance" }); +` + +const panel = ` +import * as Reports from "../lighthouse/lighthouse.js"; +import * as Rendering from "../lighthouse/lighthouse.js"; +import * as AI from "../../models/ai_assistance/ai_assistance.js"; +function reportContext(report) { return report ? new AI.AccessibilityAgent.AccessibilityContext(report.report) : null; } +async function renderReport(widget) { return Rendering.LighthouseReportRenderer.renderLighthouseScores(widget.report); } +async function renderWidget(widget) { let rendered = null; switch (widget.name) { +case "LIGHTHOUSE_REPORT": rendered = await renderReport(widget); break; +case "OTHER": rendered = "other-widget"; break; +} return rendered; } +function icon(context) { return context instanceof AI.AccessibilityAgent.AccessibilityContext ? "Lighthouse" : "other-icon"; } +class Panel { + #report = null; + #onReport = event => { this.#report = reportContext(event.data); }; + async #record(params) { return await Reports.LighthousePanel.executeLighthouseRecording(params); } + wasShown() { before(), this.#report = reportContext(Context.flavor(Reports.LighthousePanel.ActiveLighthouseReport)), Context.addFlavorChangeListener(Reports.LighthousePanel.ActiveLighthouseReport, this.#onReport), after(); } + willHide() { before(), Context.removeFlavorChangeListener(Reports.LighthousePanel.ActiveLighthouseReport, this.#onReport), after(); } + first() { return new AI.AiConversation.AiConversation({ lighthouseRecording: this.#record.bind(this), keep: "first" }); } + second() { return new AI.AiConversation.AiConversation({ keep: "second", lighthouseRecording: this.#record.bind(this), other: true }); } + third() { return new AI.AiConversation.AiConversation({ keep: "third", lighthouseRecording: this.#record.bind(this) }); } + context(type) { switch (type) { case "accessibility": return this.#report; case "styling": return "styling-context"; } } + setContext = value => value instanceof AI.AccessibilityAgent.AccessibilityContext ? this.#report = value : useOtherContext(value); + view() { let elements = Views.isViewVisible("elements"), lighthouse = Views.isViewVisible("lighthouse"), storage = Views.isViewVisible("resources"); + return elements ? "styling" : lighthouse && config.devToolsAiAssistanceAccessibilityAgent?.enabled ? "accessibility" : storage ? "storage" : "none"; + } +} +class Unrelated { #report = "unrelated"; read() { return this.#report; } } +` + +const model = ` +class SelectionAgent { constructor() { + this.declareFunction("listNetworkRequests", {}), this.declareFunction("runLighthouseAudits", {}), this.declareFunction("inspectDom", {}); +} } +class AccessibilityAgent { configure() { + this.declareFunction("executeJavaScript", {}), this.declareFunction("runAccessibilityAudits", {}), this.declareFunction("getLighthouseAudits", {}), this.declareFunction("getStyles", {}); +} } +` + +function imports(source: string): string[] { + const file = ts.createSourceFile('fixture.js', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS) + const result: string[] = [] + const visit = (node: ts.Node): void => { + if (ts.isImportDeclaration(node) && ts.isStringLiteralLike(node.moduleSpecifier)) result.push(node.moduleSpecifier.text) + if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword + && node.arguments[0] !== undefined && ts.isStringLiteralLike(node.arguments[0])) result.push(node.arguments[0].text) + ts.forEachChild(node, visit) + } + visit(file) + return result +} + +describe('pruneLighthouse', () => { + it('removes the panel, revealer, lazy loader and private metadata while retaining adjacent panels', () => { + const result = pruneLighthouse(entryPath, entry) + expect(imports(result)).toEqual(['../../ui/legacy/legacy.js']) + expect(result).toContain('Views.registerViewExtension({ id: "elements", title: "Elements" });') + expect(result).toContain('Views.registerViewExtension({ id: "timeline", title: "Performance" });') + expect(result).not.toMatch(/Lighthouse|loadLighthouse|registerRevealer|registerUIStrings|cached/) + }) + + it('removes only report context, recording and rendering from the AI panel', () => { + const result = pruneLighthouse(panelPath, panel) + expect(imports(result)).toEqual(['../../models/ai_assistance/ai_assistance.js']) + expect(result).not.toMatch(/Reports|Rendering|renderReport|reportContext|lighthouseRecording|#record|#onReport/) + expect(result).toContain('wasShown() { before(), after(); }') + expect(result).toContain('willHide() { before(), after(); }') + expect(result).toContain('case"LIGHTHOUSE_REPORT":break;') + expect(result).toContain('case "OTHER": rendered = "other-widget"; break;') + expect(result).toContain('case"accessibility":return null;') + expect(result).toContain('case "styling": return "styling-context";') + expect(result).toContain('setContext = value => useOtherContext(value);') + expect(result).toContain('return "other-icon";') + expect(result).toContain('{ keep: "first" }') + expect(result).toContain('{ keep: "second", other: true }') + expect(result).toContain('{ keep: "third" }') + expect(result).toContain('storage ? "storage" : "none"') + expect(result).toContain('class Unrelated { #report = "unrelated"; read() { return this.#report; } }') + }) + + it('removes Lighthouse model tool declarations without dropping other agent tools', () => { + const result = pruneLighthouse(modelPath, model) + expect(result).not.toMatch(/runLighthouseAudits|runAccessibilityAudits|getLighthouseAudits/) + for (const tool of ['listNetworkRequests', 'inspectDom', 'executeJavaScript', 'getStyles']) { + expect(result).toContain(`this.declareFunction("${tool}", {})`) + } + expect(result).toContain('class SelectionAgent') + expect(result).toContain('class AccessibilityAgent') + }) + + it.each(['./lighthouse_logo.svg', 'lighthouse_logo.svg'])('replaces only the %s image call and preserves the comma chain', (url) => { + const source = `style.setProperty("--before", "before.svg"),style.setProperty("--image-file-lighthouse_logo", new URL(new URL("${url}", import.meta.url).href)),style.setProperty("--after", "after.svg");` + expect(pruneLighthouse('Images/Images.js', source)) + .toBe('style.setProperty("--before", "before.svg"),void 0,style.setProperty("--after", "after.svg");') + }) + + it('keeps unowned modules and i18n strings byte-identical', () => { + const source = '{"showLighthouse":"Show Lighthouse","language":"all locales retained"}\n' + expect(pruneLighthouse('core/i18n/locales/en-US.json', source)).toBe(source) + expect(pruneLighthouse('panels/elements/elements.js', 'const Lighthouse = "unrelated";')).toBe('const Lighthouse = "unrelated";') + expect(pruneLighthouse(entryPath.replaceAll('/', '\\'), entry)).toBe(pruneLighthouse(entryPath, entry)) + }) + + it('rejects missing or duplicate pinned contributions', () => { + expect(() => pruneLighthouse(entryPath, entry.replace('id: "lighthouse"', 'id: "renamed"'))).toThrow('expected 1 panel registrations, found 0') + expect(() => pruneLighthouse(modelPath, model.replace('"runLighthouseAudits"', '"renamed"'))).toThrow('expected 1 runLighthouseAudits, found 0') + const logo = 'style.setProperty("--image-file-lighthouse_logo", new URL("./lighthouse_logo.svg", import.meta.url));' + expect(() => pruneLighthouse('Images/Images.js', logo + logo)).toThrow('expected 1 Lighthouse logo registrations, found 2') + expect(() => pruneLighthouse(panelPath, panel.replace('async #record', 'async record'))).toThrow('private Lighthouse recording method') + }) + + it('rejects extra references instead of removing their unrelated consumers', () => { + expect(() => pruneLighthouse(entryPath, entry + '\nuseElsewhere(Lighthouse);')).toThrow('removed binding Lighthouse remains referenced') + expect(() => pruneLighthouse(entryPath, entry + '\nuseElsewhere(loadLighthouse);')).toThrow('removed binding loadLighthouse remains referenced') + expect(() => pruneLighthouse(panelPath, panel.replace('first() {', 'extra() { return this.#report; } first() {'))) + .toThrow('removed private member #report remains referenced') + expect(() => pruneLighthouse(panelPath, panel + '\nimport("../lighthouse/lighthouse.js");')) + .toThrow('Lighthouse import remains after pruning') + }) + + it('rejects unexpected import forms, syntax, and effect-bearing tool expressions', () => { + expect(() => pruneLighthouse(entryPath, entry.replace('import * as Lighthouse', 'import Lighthouse'))).toThrow('Lighthouse import must use a namespace') + expect(() => pruneLighthouse(entryPath, 'function {')).toThrow('pruneLighthouse:') + expect(() => pruneLighthouse(modelPath, model.replace('this.declareFunction("runLighthouseAudits", {})', 'wrap(this.declareFunction("runLighthouseAudits", {}))'))) + .toThrow('runLighthouseAudits is not an independent expression') + }) +}) diff --git a/packages/experimental/inspector/tests/workspace-prefetch.host.spec.ts b/packages/experimental/inspector/tests/workspace-prefetch.host.spec.ts new file mode 100644 index 0000000000..2f925f7406 --- /dev/null +++ b/packages/experimental/inspector/tests/workspace-prefetch.host.spec.ts @@ -0,0 +1,31 @@ +/** DevTools prefetch belongs to contributor setup, not published package installation. */ +import { readFileSync } from 'node:fs' +import { expect, it } from 'vitest' + +interface Manifest { + scripts?: Record + files?: string[] +} + +function manifest(path: string): Manifest { + return JSON.parse(readFileSync(new URL(path, import.meta.url), 'utf8')) as Manifest +} + +it('prefetches DevTools from the root workspace after installing its existing Git hooks', () => { + const root = manifest('../../../../package.json') + expect(root.scripts?.postinstall).toBe('node scripts/install-lefthook.mjs && pnpm run prefetch:devtools') + expect(root.scripts?.['prefetch:devtools']).toBe('tsx packages/experimental/inspector/scripts/download-devtools.ts') +}) + +it.each(['../../../../apps/cli/package.json', '../../inspector-profile/package.json', '../package.json'])( + 'keeps download lifecycle hooks out of the published package %s', (path) => { + const { scripts = {} } = manifest(path) + for (const hook of ['preinstall', 'install', 'postinstall', 'prepare']) expect(scripts[hook]).toBeUndefined() + }, +) + +it('publishes the built frontend without its download tooling or cache', () => { + const { files } = manifest('../package.json') + expect(files).toContain('lib/devtools/**') + expect(files?.some(path => /^(?:scripts|\.cache)(?:\/|$)/u.test(path))).toBe(false) +}) diff --git a/packages/experimental/inspector/tsconfig.client.json b/packages/experimental/inspector/tsconfig.client.json index b24fdcb91f..3f0511fed2 100644 --- a/packages/experimental/inspector/tsconfig.client.json +++ b/packages/experimental/inspector/tsconfig.client.json @@ -6,7 +6,13 @@ "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" }, "files": [ + "src/css-modules.d.ts", + "src/client/bottom/page.tsx", + "src/client/bottom/resize.tsx", + "src/client/bottom/locales.ts", + "src/client/bottom/keyboard.ts", "src/client/bridge/controller.ts", + "src/client/bridge/locales.ts", "src/client/bridge/dispatcher.ts", "src/client/bridge/lifecycle.ts", "src/client/bridge/publisher.ts", @@ -76,15 +82,25 @@ "src/shared/cordis/projector.ts", "src/shared/cordis/reader.ts", "src/shared/cordis/snapshot.ts", + "src/shared/dispose.ts", "src/shared/identity.ts", "src/shared/index.ts", "src/shared/json.ts", "src/shared/network/event-source.ts", "src/shared/network/observation.ts", "src/shared/service.ts", - "src/shared/validation.ts" + "src/shared/validation.ts", + "src/shared/web.ts" ], "references": [ + { "path": "../../client/ui-renderer" }, + { "path": "../../client/ui-layout" }, + { "path": "../../client/shortcuts/tsconfig.client.json" }, + { "path": "../../client/store" }, + { "path": "../../client/ui-slots" }, + { "path": "../../client/ui-primitives" }, + { "path": "../../client/locale/tsconfig.client.json" }, + { "path": "../../client/connection/tsconfig.client.json" }, { "path": "../../util/brand" }, diff --git a/packages/experimental/inspector/tsconfig.host.json b/packages/experimental/inspector/tsconfig.host.json index f96992b4aa..3a1272922a 100644 --- a/packages/experimental/inspector/tsconfig.host.json +++ b/packages/experimental/inspector/tsconfig.host.json @@ -77,6 +77,7 @@ "src/shared/cordis/projector.ts", "src/shared/cordis/reader.ts", "src/shared/cordis/snapshot.ts", + "src/shared/dispose.ts", "src/shared/identity.ts", "src/shared/index.ts", "src/shared/json.ts", @@ -84,6 +85,7 @@ "src/shared/network/observation.ts", "src/shared/service.ts", "src/shared/validation.ts", + "src/shared/web.ts", "src/worker/bridge/endpoint.ts", "src/worker/bridge/hub.ts", "src/worker/bridge/runtime-rpc.ts", @@ -134,6 +136,10 @@ "src/worker/server.ts" ], "references": [ + { "path": "../../client/connection/tsconfig.host.json" }, + { + "path": "../../boot/cmdline" + }, { "path": "../../util/brand" }, diff --git a/packages/experimental/inspector/tsdown.config.ts b/packages/experimental/inspector/tsdown.config.ts index 53917a1ea2..95b3266d38 100644 --- a/packages/experimental/inspector/tsdown.config.ts +++ b/packages/experimental/inspector/tsdown.config.ts @@ -1,6 +1,7 @@ import type { UserConfig } from 'tsdown' import { clientBundle } from '../../client/tsdown.client.ts' import { profileWorkerBanner } from '../../tsdown.worker.ts' +import { buildDevtools } from './scripts/build-devtools.ts' const worker: UserConfig = { entry: { worker: 'lib/types/worker/entry.js' }, @@ -22,5 +23,13 @@ const worker: UserConfig = { export default clientBundle( '@deepseek-ai/dsh-experimental-inspector', ['lib/types/index.js'], - { hostPhase: true, companions: [worker] }, + { + hostPhase: true, + companions: [worker], + lib: { + hooks: { + 'build:done': async () => { await buildDevtools() }, + }, + }, + }, ) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 34a751030e..bfd0e0b6f7 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -2781,6 +2781,35 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.trigger\', () => ctx.slots.register(\n { name: \'settings.trigger\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', source: 'packages/client/ui-settings/src/client/contract/slots.ts:27', }, + { + key: 'shell.bottom', + kind: 'single', + scope: 'root', + summary: 'Full-width bottom content below all three columns.', + doc: 'Full-width bottom content below all three columns. Its rendered height\nreduces the columns\' available height; empty content reserves no space.\nThe occupant owns its height, visibility, and controls. It receives no\nSession binding and remains mounted across main-panel navigation.', + registerOptions: [], + ownerProps: [], + ownerPropsReferences: [], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useWorkspaces: SnapshotSelectorHook', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'root\' (client-ui-layout), so it exists while that entry is mounted', + occupants: [ + 'experimental-inspector InspectorPage', + ], + replaceRisk: 'shadows-shipped-ui', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'shell.bottom\', () => ctx.slots.register(\n { name: \'shell.bottom\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-layout/src/client/index.ts:94', + }, { key: 'shell.leading', kind: 'single', @@ -2808,7 +2837,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'shell.leading\', () => ctx.slots.register(\n { name: \'shell.leading\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-layout/src/client/index.ts:112', + source: 'packages/client/ui-layout/src/client/index.ts:119', }, { key: 'shell.overlay', @@ -2865,7 +2894,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'shell.overlay\', () => ctx.slots.register(\n { name: \'shell.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-layout/src/client/index.ts:98', + source: 'packages/client/ui-layout/src/client/index.ts:105', }, { key: 'shell.quota-notice', @@ -3185,6 +3214,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'client-ui-sidebar-right GuideBody', 'client-ui-sidebar-terminal LazyTerminalBody', 'client-ui-subagent SidebarChatTab', + 'experimental-session-inspector SessionInspectorView', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.pane.tab\', () => ctx.slots.register(\n { name: \'sidebar.right.pane.tab\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', diff --git a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md index 9910286ca0..ca87b357aa 100644 --- a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md +++ b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md @@ -190,6 +190,7 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo | `@deepseek-ai/dsh-experimental-computer-use-cua-driver-native` | no | Experimental computer-use provider embedding the Cua Driver native npm SDK | | `@deepseek-ai/dsh-experimental-inspector` | yes | Experimental cross-realm CDP hub for Host debugging and Client Runtime inspection | | `@deepseek-ai/dsh-experimental-ptc-runtime-python` | yes | CPython subprocess implementation of the DeepSeek Harness PTC execution seam | +| `@deepseek-ai/dsh-experimental-session-inspector` | no | Experimental virtualized Session log and live Chat group/node inspectors | | `@deepseek-ai/dsh-experimental-speech-to-text` | yes | Experimental speech recognition with independently selectable providers | | `@deepseek-ai/dsh-experimental-speech-to-text-sensevoice` | yes | Local SenseVoice ONNX transcription with a managed sherpa-onnx process | | `@deepseek-ai/dsh-experimental-tool-agent-team` | yes | Scoped model-facing Agent Teams tools over ctx.agentTeams | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b5c2234db..247444ca1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -247,6 +247,9 @@ importers: '@deepseek-ai/dsh-experimental-auto-review': specifier: workspace:* version: link:../../packages/experimental/auto-review + '@deepseek-ai/dsh-experimental-inspector-profile': + specifier: workspace:* + version: link:../../packages/experimental/inspector-profile '@deepseek-ai/dsh-experimental-voice-input-bundle': specifier: workspace:* version: link:../../packages/experimental/voice-input-bundle @@ -7921,6 +7924,12 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + open: + specifier: ^11.0.0 + version: 11.0.4 + serve-static: + specifier: ^2.2.1 + version: 2.2.1(supports-color@9.4.0) ws: specifier: ^8.21.0 version: 8.21.0 @@ -7934,19 +7943,80 @@ importers: '@deepseek-ai/cordis-plugin-loader': specifier: workspace:~ version: link:../../../vendor/loader + '@deepseek-ai/dsh-client-connection': + specifier: workspace:* + version: link:../../client/connection + '@deepseek-ai/dsh-client-locale': + specifier: workspace:* + version: link:../../client/locale + '@deepseek-ai/dsh-client-shortcuts': + specifier: workspace:* + version: link:../../client/shortcuts + '@deepseek-ai/dsh-client-store': + specifier: workspace:* + version: link:../../client/store + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:* + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-layout': + specifier: workspace:* + version: link:../../client/ui-layout + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:* + version: link:../../client/ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:* + version: link:../../client/ui-renderer + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:* + version: link:../../client/ui-slots + '@deepseek-ai/dsh-cmdline': + specifier: workspace:* + version: link:../../boot/cmdline '@deepseek-ai/dsh-host-webserver': specifier: workspace:* version: link:../../host/webserver + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + '@types/serve-static': + specifier: ^2.2.0 + version: 2.2.0 '@types/ws': specifier: ^8.18.1 version: 8.18.1 playwright: specifier: ^1.49.0 version: 1.61.1 + react: + specifier: ^18.2.0 + version: 18.3.1 tsx: specifier: ^4.19.2 version: 4.22.4 + packages/experimental/inspector-profile: + dependencies: + '@deepseek-ai/dsh-experimental-inspector': + specifier: workspace:* + version: link:../inspector + '@deepseek-ai/dsh-experimental-session-inspector': + specifier: workspace:* + version: link:../session-inspector + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:~ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:~ + version: link:../../../vendor/include + js-yaml: + specifier: ^4.2.0 + version: 4.3.1 + packages/experimental/ptc-runtime-python: dependencies: '@deepseek-ai/schemastery': @@ -7966,6 +8036,69 @@ importers: specifier: workspace:* version: link:../../util/values + packages/experimental/session-inspector: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:~ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:* + version: link:../../api/session-controller + '@deepseek-ai/dsh-client-locale': + specifier: workspace:* + version: link:../../client/locale + '@deepseek-ai/dsh-client-store': + specifier: workspace:* + version: link:../../client/store + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:* + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-chat': + specifier: workspace:* + version: link:../../client/ui-chat + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:* + version: link:../../client/ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:* + version: link:../../client/ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:* + version: link:../../client/ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:* + version: link:../../client/ui-session + '@deepseek-ai/dsh-client-ui-sidebar-right': + specifier: workspace:* + version: link:../../client/ui-sidebar-right + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:* + version: link:../../client/ui-slots + '@deepseek-ai/dsh-llm': + specifier: workspace:* + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:* + version: link:../../core/session + '@tanstack/react-virtual': + specifier: ^3.14.9 + version: 3.14.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + '@types/react-dom': + specifier: ~18.3.0 + version: 18.3.7(@types/react@18.3.31) + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + packages/experimental/speech-to-text: dependencies: '@deepseek-ai/dsh-brand': @@ -26462,8 +26595,8 @@ snapshots: qs: 6.15.3 range-parser: 1.3.0 router: 2.2.0 - send: 1.2.1 - serve-static: 2.2.1 + send: 1.2.1(supports-color@9.4.0) + serve-static: 2.2.1(supports-color@9.4.0) statuses: 2.0.2 type-is: 2.1.0 vary: 1.1.2 @@ -28722,7 +28855,7 @@ snapshots: semver@7.8.5: {} - send@1.2.1: + send@1.2.1(supports-color@9.4.0): dependencies: debug: 4.4.3(supports-color@9.4.0) encodeurl: 2.0.0 @@ -28743,12 +28876,12 @@ snapshots: type-fest: 0.13.1 optional: true - serve-static@2.2.1: + serve-static@2.2.1(supports-color@9.4.0): dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 1.2.1 + send: 1.2.1(supports-color@9.4.0) transitivePeerDependencies: - supports-color diff --git a/scripts/check-workspace-constraints.spec.ts b/scripts/check-workspace-constraints.spec.ts index 3307aa3956..c225c1a59e 100644 --- a/scripts/check-workspace-constraints.spec.ts +++ b/scripts/check-workspace-constraints.spec.ts @@ -388,6 +388,16 @@ it('requires the local speech worker and locked runtime in the published payload } }) +it('requires the Inspector Worker, Client chunks, and mirrored DevTools resources in the published payload', () => { + const dir = 'packages/experimental/inspector' + const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest'] + expect(checkWorkspaceManifest({ dir, manifest })).toEqual([]) + for (const omitted of ['lib/client.*.js', 'lib/worker.js', 'lib/devtools/**']) { + expect(checkWorkspaceManifest({ dir, manifest: { ...manifest, files: manifest.files!.filter(file => file !== omitted) } })) + .toEqual([expect.stringContaining('package.json files must be')]) + } +}) + it('requires the standalone shortcut protocol and rejects unrelated runtime files', () => { const dir = 'packages/client/shortcuts' const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest'] diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 13deaa24d9..02068b242f 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -188,8 +188,8 @@ const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-experimental-speech-to-text-sensevoice': ['runtime/assets.json'], // The isolated Node bootstrap is a separately launched bundle. '@deepseek-ai/dsh-ptc-runtime-node': ['lib/process.js'], - // The Host entry starts its sibling Worker by URL rather than a package export. - '@deepseek-ai/dsh-experimental-inspector': ['lib/worker.js'], + // The Inspector owns a Worker and a mirrored frontend outside package export paths. + '@deepseek-ai/dsh-experimental-inspector': ['lib/client.*.js', 'lib/worker.js', 'lib/devtools/**'], // Creator's composition guidance travels with the declaration package. '@deepseek-ai/dsh-agent-preset': ['skills'], // The Web Host mounts the default-off settings owner independently of each diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index 6167190367..e872aac220 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -15,6 +15,7 @@ import { parse as parseToml, type TomlTableWithoutBigInt, type TomlValueWithoutB import parseSpdx from 'spdx-expression-parse' import primaryRuntimeLock from './primary-runtime/lock.json' with { type: 'json' } import { browserBundledExternals } from './browser-bundled-externals.ts' +import { DEVTOOLS_CDN, DEVTOOLS_CHROMIUM_VERSION } from '../packages/experimental/inspector/scripts/download-devtools.ts' const root = resolve(import.meta.dirname, '..') const OUT = 'THIRD_PARTY_NOTICES.md' @@ -793,6 +794,11 @@ ${renderNpmTable(runtimeDeps)} pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification: ${patchedLines.join('\n')} + +## Chrome DevTools frontend + +The optional experimental Inspector distributes a local copy of [Chrome ${DEVTOOLS_CHROMIUM_VERSION}'s frontend](${DEVTOOLS_CDN.href}devtools_app.html). The Chromium [BSD-3-Clause license](packages/experimental/inspector/assets/devtools/LICENSE) is included with the frontend. Copied JavaScript retains its embedded license comments; the resource downloader does not collect separate upstream third-party license files. The Chromium root license does not replace those dependencies' licenses. + ${renderClaudeDistribution(claudeDistribution)} ${kitRuntime ? ` ## LibreOffice conversion kit diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index d43d1be214..c8940cfd06 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -1180,7 +1180,7 @@ }, "packages/experimental/inspector/tests/plugin.client.spec.ts": { "148509a0193d8d3c16a606a15f5145770f454c2ebc70dd0ccaa51fc1a0f43e99": 1, - "2066ad746457f15b6737d62124c4ec81f2140fc3fa425187eac8e3b1c3b6ab0b": 9 + "2066ad746457f15b6737d62124c4ec81f2140fc3fa425187eac8e3b1c3b6ab0b": 8 }, "packages/experimental/ptc-runtime-python/src/index.ts": { "b121cee14635b00f7891217013e4682493cf96f676710be60722fdf8b862aea3": 1 diff --git a/scripts/release/families.spec.ts b/scripts/release/families.spec.ts index a54d7c3fb2..0cbba9d3c8 100644 --- a/scripts/release/families.spec.ts +++ b/scripts/release/families.spec.ts @@ -60,8 +60,10 @@ describe('release families', () => { '@deepseek-ai/dsh-experimental-client-ui-voice-input', '@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp', '@deepseek-ai/dsh-experimental-computer-use-cua-driver-native', + '@deepseek-ai/dsh-experimental-inspector-profile', '@deepseek-ai/dsh-experimental-inspector', '@deepseek-ai/dsh-experimental-ptc-runtime-python', + '@deepseek-ai/dsh-experimental-session-inspector', '@deepseek-ai/dsh-experimental-speech-to-text-sensevoice', '@deepseek-ai/dsh-experimental-speech-to-text', '@deepseek-ai/dsh-experimental-tool-agent-team', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 735aaa2fc2..4db748696f 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -100,6 +100,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/experimental/webworker-runtime': { kind: 'none', reason: 'Browser-side host runtime and Node-compatibility layer; the plugins it boots own every model-facing registration.' }, 'packages/experimental/webworker-packer': { kind: 'none', reason: 'Build-time image writer; its output reaches a model only through the tree the worker then boots.' }, 'packages/experimental/inspector': { kind: 'none', reason: 'Developer diagnostics transport; it observes runtime activity without changing model requests.' }, + 'packages/experimental/inspector-profile': { kind: 'none', reason: 'The bundle mounts developer inspection plugins without contributing model context.' }, + 'packages/experimental/session-inspector': { kind: 'none', reason: 'The conversation view entries add no model-facing input.' }, 'packages/client/ui-slots': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-attachment': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-primitives': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, diff --git a/snapshots/web/session-inspector/array-details.expected.md b/snapshots/web/session-inspector/array-details.expected.md new file mode 100644 index 0000000000..f047571639 --- /dev/null +++ b/snapshots/web/session-inspector/array-details.expected.md @@ -0,0 +1,9 @@ +- group: + - text: "blocks: Array(2)" + - list: + - listitem: + - group: "Other properties: Object" + - listitem: + - group: "0: Object" + - listitem: + - group: "1: Object" diff --git a/snapshots/web/session-inspector/groups.expected.md b/snapshots/web/session-inspector/groups.expected.md new file mode 100644 index 0000000000..4565e7b623 --- /dev/null +++ b/snapshots/web/session-inspector/groups.expected.md @@ -0,0 +1,11 @@ +- system-prompt +- user +- context +- context +- turn-process +- group +- assistant-step +- tool-call +- assistant-step +- assistant-step +- turn-tail diff --git a/snapshots/web/session-inspector/log.expected.md b/snapshots/web/session-inspector/log.expected.md new file mode 100644 index 0000000000..6f82049736 --- /dev/null +++ b/snapshots/web/session-inspector/log.expected.md @@ -0,0 +1,53 @@ +- table "Raw Log": + - rowgroup: + - row "Index Type Filter types T/S Time (UTC) Data": + - columnheader "Index" + - columnheader "Type Filter types": + - text: Type + - button "Filter types" [pressed] + - columnheader "T/S" + - columnheader "Time (UTC)" + - columnheader "Data" + - rowgroup: + - row "4 Collapse children turn/start 1 {{time}} 1": + - cell "4" + - cell "Collapse children turn/start": + - button "Collapse children" [expanded]: ▾ + - button "turn/start" + - cell "1" + - cell "{{time}}" + - cell "1": + - button "1" + - 'row "6 Collapse children step/start 1/1 {{time}} turn: 1 · step: 1"': + - cell "6" + - cell "Collapse children step/start": + - button "Collapse children" [expanded]: ▾ + - button "step/start" + - cell "1/1" + - cell "{{time}}" + - 'cell "turn: 1 · step: 1"': + - 'button "turn: 1 · step: 1"' + - 'row "8 user/message {{time}} data: content: [1] · source: {…} · role: user · id: {{uuid}} · surfaceOp: append"': + - cell "8" + - cell "user/message": + - button "user/message" + - cell + - cell "{{time}}" + - 'cell "data: content: [1] · source: {…} · role: user · id: {{uuid}} · surfaceOp: append"': + - 'button "data: content: [1] · source: {…} · role: user · id: {{uuid}} · surfaceOp: append"' + - 'row "9 user/message {{time}} data: content: [1] · source: {…} · role: user · id: {{uuid}} · surfaceOp: append"': + - cell "9" + - cell "user/message": + - button "user/message" + - cell + - cell "{{time}}" + - 'cell "data: content: [1] · source: {…} · role: user · id: {{uuid}} · surfaceOp: append"': + - 'button "data: content: [1] · source: {…} · role: user · id: {{uuid}} · surfaceOp: append"' + - 'row "10 user/message {{time}} data: content: [1] · source: {…} · role: user · id: {{uuid}} · surfaceOp: append"': + - cell "10" + - cell "user/message": + - button "user/message" + - cell + - cell "{{time}}" + - 'cell "data: content: [1] · source: {…} · role: user · id: {{uuid}} · surfaceOp: append"': + - 'button "data: content: [1] · source: {…} · role: user · id: {{uuid}} · surfaceOp: append"' diff --git a/snapshots/web/session-inspector/navigation.expected.md b/snapshots/web/session-inspector/navigation.expected.md new file mode 100644 index 0000000000..1bef36afd5 --- /dev/null +++ b/snapshots/web/session-inspector/navigation.expected.md @@ -0,0 +1,5 @@ +- selected: tool/call +- unavailable: tool-call +- highlighted: assistant-step/reasoning +- visible: true +- bounds match highlighted node: true diff --git a/snapshots/web/session-inspector/snapshot.yml b/snapshots/web/session-inspector/snapshot.yml new file mode 100644 index 0000000000..6ebb822ab1 --- /dev/null +++ b/snapshots/web/session-inspector/snapshot.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: session-inspector +profile: web +composition: web-default +recording: authored +header: + class: web-default +session: + source: ../fresh-round-trip/session.v4.jsonl diff --git a/tsconfig.base.json b/tsconfig.base.json index c1c658ba1e..b5f649378a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -323,6 +323,9 @@ "@deepseek-ai/dsh-experimental-webworker-runtime": ["./packages/experimental/webworker-runtime/src"], "@deepseek-ai/dsh-experimental-webworker-packer": ["./packages/experimental/webworker-packer/src"], "@deepseek-ai/dsh-experimental-inspector": ["./packages/experimental/inspector/src"], + "@deepseek-ai/dsh-experimental-inspector-profile": ["./packages/experimental/inspector-profile/src"], + "@deepseek-ai/dsh-experimental-session-inspector": ["./packages/experimental/session-inspector/src"], + "@deepseek-ai/dsh-experimental-session-inspector/client": ["./packages/experimental/session-inspector/src/client/index.ts"], "@deepseek-ai/dsh-experimental-inspector/client": ["./packages/experimental/inspector/src/client/index.ts"], "@deepseek-ai/dsh-experimental-ptc-runtime-python": ["./packages/experimental/ptc-runtime-python/src"], "@deepseek-ai/dsh-util-crypto": ["./packages/util/crypto/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 738ed0c5b4..97f4f1b2a6 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -74,6 +74,7 @@ { "path": "./packages/client/ui-primitives" }, { "path": "./packages/client/modules" }, { "path": "./packages/experimental/inspector/tsconfig.client.json" }, + { "path": "./packages/experimental/session-inspector" }, { "path": "./packages/client/hmr" }, { "path": "./packages/client/connection/tsconfig.client.json" }, { "path": "./packages/client/file-upload/tsconfig.client.json" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index ee2d3eaa98..31586f034a 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -63,6 +63,11 @@ "apps/web/tests/plugin-config.e2e.ts", "apps/web/tests/input-plugin-extensions.e2e.ts", "apps/web/tests/plugin-manager.e2e.ts", + "apps/web/tests/session-inspector.e2e.ts", + "apps/web/tests/shared-inspector.e2e.ts", + "apps/web/tests/layout-bottom.e2e.ts", + "apps/web/tests/devtools-frontend.e2e.ts", + "apps/web/tests/inspector-panels.e2e.ts", "apps/web/tests/plugin-install-cancel.e2e.ts", "apps/web/tests/plugin-install-approve.e2e.ts", "apps/web/tests/plugin-install-registry.e2e.ts", @@ -179,6 +184,7 @@ "apps/desktop-host/tests/**/*.ts", "benchmarks/**/*.ts", "packages/*/*/tests/**/*.ts", + "packages/experimental/inspector/scripts/**/*.ts", "scripts/**/*.ts", "website/**/*.ts", "website/.vitepress/**/*.ts" @@ -440,6 +446,7 @@ { "path": "./packages/jobs/tool-jobs" }, { "path": "./packages/experimental/agent-team" }, { "path": "./packages/experimental/agent-team-profile" }, + { "path": "./packages/experimental/inspector-profile" }, { "path": "./packages/experimental/tool-agent-team" }, { "path": "./packages/workflow/workflow" }, { "path": "./packages/workflow/workflow-ptc" },