diff --git a/apps/web/tests/inspector-late-enable.e2e.ts b/apps/web/tests/inspector-late-enable.e2e.ts new file mode 100644 index 0000000000..9a7cda3eee --- /dev/null +++ b/apps/web/tests/inspector-late-enable.e2e.ts @@ -0,0 +1,56 @@ +/** Enabling the optional Inspector bundle on a running page mounts the bottom DevTools without a reload. */ +import { fileURLToPath } from 'node:url' +import { chromium, type Browser } from 'playwright' +import { expect, it, onTestFailed, onTestFinished } from 'vitest' +import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' +import { PLUGIN_TOGGLE_SETTLE_MS, saveFailureShot } from './support.ts' + +const bundle = fileURLToPath(new URL('../../../packages/experimental/inspector-profile', import.meta.url)) +const BOOTSTRAP_PATH = '/api/experimental-inspector/bootstrap' + +it('opens bottom DevTools after the Developer Tools bundle is enabled on an already loaded page', 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: bundle, enabled: false }], hmr: true }, + extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), + }) + resources.scaffold = scaffold + // Only a running HMR service applies the bundle switch to this page without a restart. + expect(scaffold.ctx.get('hmr')).toBeDefined() + // Full Chromium matches the other Inspector browser scenarios. + const browser = await chromium.launch({ executablePath: chromium.executablePath() }) + resources.browser = browser + const page = await browser.newPage({ locale: 'en-US', viewport: { width: 1440, height: 900 } }) + const tripwire = watchConsole(page) + onTestFailed(() => saveFailureShot(page, 'web-e2e-inspector-late-enable')) + const bootstraps: number[] = [] + page.on('response', (response) => { + if (new URL(response.url()).pathname === BOOTSTRAP_PATH) bootstraps.push(response.status()) + }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.evaluate(() => { Reflect.set(window, '__inspectorLateEnableMarker', 'same-document') }) + + await page.getByRole('navigation', { name: 'Global panels' }).getByRole('button', { name: 'Plugins', exact: true }).click() + const plugins = page.locator('[data-plugin-panel]') + const toggle = plugins.getByRole('switch', { name: 'Enable Developer Tools', exact: true }) + await toggle.waitFor() + expect(await toggle.getAttribute('aria-checked')).toBe('false') + const panel = page.locator('[data-inspector-panel]') + expect(await panel.count()).toBe(0) + + await toggle.click() + await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('true') + await panel.waitFor({ state: 'attached', timeout: 20_000 }).catch((error: unknown) => { + throw new Error(`Bottom panel did not mount; ${BOOTSTRAP_PATH} responses: ${JSON.stringify(bootstraps)}`, { cause: error }) + }) + expect(await panel.isVisible()).toBe(false) + await page.keyboard.press('ControlOrMeta+Shift+Period') + await panel.waitFor({ state: 'visible' }) + await page.frameLocator('iframe[data-inspector-devtools]').getByRole('tab', { name: 'Console', exact: true }).waitFor() + expect(await page.evaluate((): unknown => Reflect.get(window, '__inspectorLateEnableMarker'))).toBe('same-document') + expect(new Set(bootstraps)).toEqual(new Set([200])) + expect(tripwire.pageErrors).toEqual([]) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 174956d1b9..ff56fc9485 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -85,6 +85,7 @@ "tests/worktree-opt-in.e2e.ts", "tests/session-inspector.e2e.ts", "tests/shared-inspector.e2e.ts", + "tests/inspector-late-enable.e2e.ts", "tests/devtools-frontend.e2e.ts", "tests/inspector-panels.e2e.ts", "tests/inspector-source-build.e2e.ts", diff --git a/packages/experimental/inspector/README.i18n.yaml b/packages/experimental/inspector/README.i18n.yaml index 6c6b7c70d9..e5f1706ff4 100644 --- a/packages/experimental/inspector/README.i18n.yaml +++ b/packages/experimental/inspector/README.i18n.yaml @@ -18,8 +18,8 @@ en: 6d290aabc543f878 zh: 7ad283b159ae2c29 /deepseek-ai-dsh-experimental-inspector/runtime-layout: - en: 30dad7b7fe3b3465 - zh: 2bda395981802f59 + en: 13f495af2fec307d + zh: ec4bebf24a87ec54 /deepseek-ai-dsh-experimental-inspector/configuration: en: d094842893700446 zh: 7a7f880ba795948f diff --git a/packages/experimental/inspector/README.md b/packages/experimental/inspector/README.md index a110302dd3..9856c718e5 100644 --- a/packages/experimental/inspector/README.md +++ b/packages/experimental/inspector/README.md @@ -48,7 +48,7 @@ The frontend uses browser runtime adapters and excludes Node-only transports at ## Runtime layout -The Worker never accesses live Cordis objects: the shared Host/Client collector projects them into validated snapshots before transport. Cordis owns plugin composition, `ctx.inspector` registration, bootstrap injection, and disposal. A late-loading Client fetches its bootstrap through an authenticated Host route. Bootstrap transport and source-setup failures warn and retain connection-reset recovery for both injected and fetched startup; invalid bootstrap data or service-registration failures reject startup. Source replacement joins old service-consumer cleanup before claiming another source, and unloading an active plugin cancels its pending bootstrap refresh. +The Worker never accesses live Cordis objects: the shared Host/Client collector projects them into validated snapshots before transport. Cordis owns plugin composition, `ctx.inspector` registration, bootstrap injection, and disposal. A late-loading Client fetches its bootstrap through an authenticated Host route, which the Host registers before it starts the Worker: a request waits for Worker readiness and then receives the bootstrap, or `503` when startup fails; an aborted request stops waiting. When the platform cannot run the Worker and reports Node's `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM`, such as the browser preview's Worker host, the Host logs one warning and the plugin still starts with only that route, which answers `503`; it provides no `ctx.inspector`, DevTools routes, bootstrap injection, or `devtools://` URL. Any other startup error still rejects the plugin. Bootstrap transport and source-setup failures warn and retain connection-reset recovery for both injected and fetched startup; invalid bootstrap data or service-registration failures reject startup. Source replacement joins old service-consumer cleanup before claiming another source, and unloading an active plugin cancels its pending bootstrap refresh. The Host plugin starts the Worker and connects a dedicated `MessagePort`. The Client uses injected or fetched browser-source parameters to open an authenticated WebSocket directly to the Worker; it receives no DevTools URL. Chrome DevTools connects to the Worker's CDP WebSocket. A private `node:inspector.Session` per DevTools connection attaches from the Worker to the Host main thread, so Host debugging remains available while Host JavaScript is paused. diff --git a/packages/experimental/inspector/README.zh.md b/packages/experimental/inspector/README.zh.md index 619a0be665..66b25ec2cc 100644 --- a/packages/experimental/inspector/README.zh.md +++ b/packages/experimental/inspector/README.zh.md @@ -48,7 +48,7 @@ Host 在相对于应用根目录的 `inspector/devtools/devtools_app.html` 路 ## 运行时布局 -Worker 不访问实时 Cordis 对象;共享 Host/Client collector 会在传输前把它们投影成已验证快照。Cordis 负责插件组合、注册 `ctx.inspector`、注入 bootstrap 和 dispose(资源释放)。后加载的 Client 通过已鉴权的 Host 路由获取 bootstrap。初始注入与主动获取两条路径中,bootstrap 传输或 source 建立失败均记录警告并保留连接重置后的重试;bootstrap 数据非法或服务注册失败则使启动失败。替换 source 会等待旧服务消费者清理完成,再申请新 source;卸载已激活插件会取消其未完成的 bootstrap 刷新。 +Worker 不访问实时 Cordis 对象;共享 Host/Client collector 会在传输前把它们投影成已验证快照。Cordis 负责插件组合、注册 `ctx.inspector`、注入 bootstrap 和 dispose(资源释放)。后加载的 Client 通过已鉴权的 Host 路由获取 bootstrap。Host 在启动 Worker 之前注册该路由,请求会等到 Worker 就绪后返回 bootstrap,启动失败时返回 `503`;请求中止时立即停止等待。当前平台无法运行 Worker 并报告 Node 的 `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM` 时(例如浏览器预览的 Worker host),Host 记录一条警告,插件仍照常启动,只保留该路由并返回 `503`,不提供 `ctx.inspector`、DevTools 路由、bootstrap 注入或 `devtools://` URL;其他任何启动错误仍使插件启动失败。初始注入与主动获取两条路径中,bootstrap 传输或 source 建立失败均记录警告并保留连接重置后的重试;bootstrap 数据非法或服务注册失败则使启动失败。替换 source 会等待旧服务消费者清理完成,再申请新 source;卸载已激活插件会取消其未完成的 bootstrap 刷新。 Host 插件启动 Worker 并连接专用 `MessagePort`。Client 使用注入或主动获取的浏览器 source 参数,直接向 Worker 打开带鉴权的 WebSocket,不接收 DevTools URL。Chrome DevTools 连接 Worker 的 CDP WebSocket。每条 DevTools 连接在 Worker 中独占一个连接 Host 主线程的 `node:inspector.Session`,因此 Host JavaScript 暂停时仍可调试 Host。 diff --git a/packages/experimental/inspector/src/host/plugin.ts b/packages/experimental/inspector/src/host/plugin.ts index 23306c4cf4..6294edc2ed 100644 --- a/packages/experimental/inspector/src/host/plugin.ts +++ b/packages/experimental/inspector/src/host/plugin.ts @@ -4,12 +4,11 @@ import type { Context } from '@deepseek-ai/cordis' import { fileURLToPath } from 'node:url' import { connect } from 'node:net' import type { Duplex } from 'node:stream' -import serveStatic from 'serve-static' import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-client-connection' import type {} from '@deepseek-ai/dsh-cmdline' -import open, { apps } from 'open' -import { resolveInspectorOptions, startInspector, type InspectorOptions } from './bridge/controller.ts' +import { resolveInspectorOptions, startInspector, type InspectorHandle, type InspectorOptions } from './bridge/controller.ts' +import type { InspectorClientBootstrap } from '../shared/bridge/messages/control.ts' import { createInspectorService } from '../shared/service.ts' import { publishCordisTree } from './inspection/cordis.ts' import { INSPECTOR_BOOTSTRAP_PATH, INSPECTOR_CLIENT_QUERY, readInspectorClientSelection } from '../shared/web.ts' @@ -17,6 +16,9 @@ import { disposeInspectorResources } from '../shared/dispose.ts' const DEVTOOLS_PATH = '/inspector/devtools' +/** Node's standard code for a feature the running platform cannot provide, such as `worker_threads` in a browser Worker host. */ +const FEATURE_UNAVAILABLE_ON_PLATFORM = 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM' + export { resolveInspectorOptions, startInspector } from './bridge/controller.ts' export type { InspectorEndpoint, InspectorHandle, InspectorOptions, InspectorSpec } from './bridge/controller.ts' export type { CordisRuntimeTreeReader } from '../shared/cordis/reader.ts' @@ -45,14 +47,36 @@ export interface HostPluginConfig extends Omit { await ctx.effect(async () => { const spec = resolveInspectorOptions(config) - const handle = await startInspector(spec) + const ready = Promise.withResolvers() const disposers: Array<() => unknown> = [] - const dispose = () => disposeInspectorResources(disposers, () => handle.close(), 'experimental-inspector: disposal failed') + let worker: InspectorHandle | undefined + const dispose = () => disposeInspectorResources(disposers, () => worker?.close(), 'experimental-inspector: disposal failed') try { + disposers.push(ctx.connection.fetch.register({ + path: INSPECTOR_BOOTSTRAP_PATH, methods: ['GET'], requestBody: 'buffered', + fetch: request => bootstrapResponse(ready.promise, request.signal), + })) + const handle = await startInspector(spec).catch((error: unknown) => { + if (!isUnavailableOnPlatform(error)) throw error + ctx.logger.warn('experimental-inspector: NodeJS Inspector is unavailable on this platform; Host inspection stays disabled', error) + return undefined + }) + if (handle === undefined) { + ready.resolve(undefined) + return dispose + } + worker = handle + // Node-only frontend and launcher packages load after the Worker starts, so a platform without it never loads them. + const { default: serveStatic } = await import('serve-static') disposers.push(publishCordisTree(ctx, handle.source, { maxNodes: spec.maxCordisNodes, maxBytes: spec.maxSourceFrameBytes - 4_096, @@ -61,12 +85,6 @@ export async function apply(ctx: Context, config: HostPluginConfig): Promise { table.push({ kind: 'global', name: '__DSH_INSPECTOR__', value: handle.endpoint.client }) })) - disposers.push(ctx.connection.fetch.register({ - path: INSPECTOR_BOOTSTRAP_PATH, methods: ['GET'], requestBody: 'buffered', - fetch: () => Promise.resolve(Response.json(handle.endpoint.client, { - headers: { 'cache-control': 'no-store' }, - })), - })) const assets = serveStatic(fileURLToPath(new URL('./lib/devtools/', import.meta.resolve('@deepseek-ai/dsh-experimental-inspector/package.json'))), { index: false, redirect: false, fallthrough: true, @@ -148,12 +166,15 @@ export async function apply(ctx: Context, config: HostPluginConfig): Promise { ctx.logger.error('experimental-inspector: Chrome could not open DevTools', error) }) } } catch (error) { + ready.resolve(undefined) await dispose().catch((cleanupError: unknown) => { ctx.logger.error('experimental-inspector: initialization rollback failed', cleanupError) }) @@ -162,3 +183,32 @@ export async function apply(ctx: Context, config: HostPluginConfig): Promise, signal: AbortSignal): Promise { + const headers = { 'cache-control': 'no-store' } + const abandoned = Promise.withResolvers() + const abandon = () => { abandoned.resolve(undefined) } + if (signal.aborted) abandon() + else signal.addEventListener('abort', abandon, { once: true }) + try { + const client = await Promise.race([ready, abandoned.promise]) + return client === undefined ? new Response(null, { status: 503, headers }) : Response.json(client, { headers }) + } finally { + signal.removeEventListener('abort', abandon) + } +} diff --git a/packages/experimental/inspector/tests/bootstrap-route.host.spec.ts b/packages/experimental/inspector/tests/bootstrap-route.host.spec.ts new file mode 100644 index 0000000000..1a70b1917b --- /dev/null +++ b/packages/experimental/inspector/tests/bootstrap-route.host.spec.ts @@ -0,0 +1,179 @@ +/** Host bootstrap route availability while the Inspector Worker is still starting. */ + +import { Context, FiberState } from '@deepseek-ai/cordis' +import type { Message } from '@deepseek-ai/cordis' +import type { IndexInjection, WebServer } 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 { afterEach, describe, expect, it, vi } from 'vitest' +import { apply, Config, inject, name } from '../src/index.ts' +import { startInspector, type InspectorHandle, type InspectorOptions } from '../src/host/bridge/controller.ts' +import { INSPECTOR_BOOTSTRAP_PATH } from '../src/shared/web.ts' + +vi.mock('open', () => ({ default: vi.fn(async () => undefined), apps: { chrome: 'chrome' } })) + +vi.mock('../src/host/bridge/controller.ts', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, startInspector: vi.fn(actual.startInspector) } +}) + +/** Startup failures the fixture can inject; only the platform code selects the unavailable state. */ +const FAILURES = { + fail: () => new Error('fixture Worker startup failed'), + unavailable: () => Object.assign(new Error('web-preview: node:worker_threads.MessageChannel is not available in the worker host'), { + code: 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM', + }), + 'other-code': () => Object.assign(new Error('fixture Worker port is occupied'), { code: 'EADDRINUSE' }), + 'message-only': () => new Error('fixture ERR_FEATURE_UNAVAILABLE_ON_PLATFORM: worker host is not available'), +} satisfies Record Error> + +const UNAVAILABLE_WARNING = 'experimental-inspector: NodeJS Inspector is unavailable on this platform; Host inspection stays disabled' + +describe('experimental Inspector Host bootstrap route', () => { + let context: Context | undefined + const gates: Array> = [] + + afterEach(async () => { + // A failed assertion can leave startup gated; disposal waits for the pending effect. + for (const gate of gates.splice(0)) gate.resolve(undefined) + await context?.fiber.dispose() + context = undefined + vi.restoreAllMocks() + vi.clearAllMocks() + }) + + /** Hold Worker startup until the returned gate opens. */ + function gateStartup(outcome: 'start' | keyof typeof FAILURES): PromiseWithResolvers { + const gate = Promise.withResolvers() + gates.push(gate) + vi.mocked(startInspector).mockImplementationOnce(async (options?: InspectorOptions): Promise => { + await gate.promise + if (outcome !== 'start') throw FAILURES[outcome]() + const actual = await vi.importActual('../src/host/bridge/controller.ts') + return actual.startInspector(options) + }) + return gate + } + + function mount(args: readonly string[] = []) { + context = new Context() + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined) + const register = vi.fn(() => () => {}) + const registerUpgrade = vi.fn(() => () => {}) + context.provide('webServer', { register, registerUpgrade } as Pick as WebServer) + context.provide('cmdlineArgs', { get: () => args }) + const connection = new HostConnectionService(context, [], {} as BrowserAuth) + const api = connection.createSharedFetchHandler('/api') + const messages: Message[] = [] + context.logger.exporter({ levels: { default: 3 }, export: (message) => { messages.push(message) } }) + const fiber = context.plugin({ name, inject: [...inject], Config, apply }, { port: 0, captureFetch: false }) + const bootstrap = (signal?: AbortSignal) => api.fetch( + new Request(`http://localhost${INSPECTOR_BOOTSTRAP_PATH}`, signal === undefined ? {} : { signal }), + connection.browserPeer, + ) + const injected = () => { + const rows: IndexInjection[] = [] + context!.emit('webserver/index-inject', rows) + return rows.find(row => row.kind === 'global' && row.name === '__DSH_INSPECTOR__') + } + return { fiber, bootstrap, injected, register, registerUpgrade, messages, log } + } + + it('accepts bootstrap requests before the Worker is ready and answers once it is', async () => { + const gate = gateStartup('start') + const { fiber, bootstrap, injected, register, registerUpgrade } = mount() + await vi.waitFor(() => { expect(startInspector).toHaveBeenCalledOnce() }) + + const early = bootstrap() + expect(await settledWithin(early, 50)).toBe(false) + expect(injected()).toBeUndefined() + expect(register).not.toHaveBeenCalled() + expect(registerUpgrade).not.toHaveBeenCalled() + + gate.resolve(undefined) + await fiber.await() + const response = await early + expect(response.status).toBe(200) + expect(response.headers.get('cache-control')).toBe('no-store') + const value = injected() + expect(value).toMatchObject({ kind: 'global', name: '__DSH_INSPECTOR__' }) + expect(await response.json()).toEqual(value?.kind === 'global' ? value.value : undefined) + expect(register).toHaveBeenCalledWith(expect.objectContaining({ path: '/inspector/devtools' })) + expect(registerUpgrade).toHaveBeenCalledWith(expect.objectContaining({ path: '/inspector/devtools/cdp' })) + }) + + for (const outcome of ['fail', 'other-code', 'message-only'] as const) { + it(`answers waiting bootstrap requests with 503 and removes the route when Worker startup fails (${outcome})`, async () => { + const gate = gateStartup(outcome) + const { fiber, bootstrap, injected, messages } = mount() + await vi.waitFor(() => { expect(startInspector).toHaveBeenCalledOnce() }) + + const early = bootstrap() + expect(await settledWithin(early, 50)).toBe(false) + gate.resolve(undefined) + await expect(fiber.await()).rejects.toThrow(FAILURES[outcome]().message) + expect(fiber.state).toBe(FiberState.FAILED) + const response = await early + expect(response.status).toBe(503) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(injected()).toBeUndefined() + expect((await bootstrap()).status).toBe(404) + expect(messages.filter(message => message.args[0] === UNAVAILABLE_WARNING)).toEqual([]) + }) + } + + it('starts without the NodeJS Inspector when the platform cannot run its Worker', async () => { + const gate = gateStartup('unavailable') + const { fiber, bootstrap, injected, register, registerUpgrade, messages, log } = mount(['--inspect']) + await vi.waitFor(() => { expect(startInspector).toHaveBeenCalledOnce() }) + + const early = bootstrap() + expect(await settledWithin(early, 50)).toBe(false) + gate.resolve(undefined) + await fiber.await() + expect(fiber.state).toBe(FiberState.ACTIVE) + for (const response of [await early, await bootstrap()]) { + expect(response.status).toBe(503) + expect(response.headers.get('cache-control')).toBe('no-store') + } + const warnings = messages.filter(message => message.type === 'warn') + expect(warnings).toHaveLength(1) + expect(warnings[0]!.args[0]).toBe(UNAVAILABLE_WARNING) + expect(warnings[0]!.args[1]).toMatchObject({ code: 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM' }) + expect(messages.filter(message => message.type === 'error')).toEqual([]) + expect(register).not.toHaveBeenCalled() + expect(registerUpgrade).not.toHaveBeenCalled() + expect(injected()).toBeUndefined() + expect(context!.get('inspector')).toBeUndefined() + expect(log).not.toHaveBeenCalledWith(expect.stringMatching(/^dsh inspector:/u)) + expect(open).not.toHaveBeenCalled() + + await fiber.dispose() + expect((await bootstrap()).status).toBe(404) + expect(messages.filter(message => message.type === 'error')).toEqual([]) + }) + + it('stops waiting when a bootstrap request is aborted before the Worker is ready', async () => { + const gate = gateStartup('start') + const { fiber, bootstrap } = mount() + await vi.waitFor(() => { expect(startInspector).toHaveBeenCalledOnce() }) + + const abort = new AbortController() + const abandoned = bootstrap(abort.signal) + expect(await settledWithin(abandoned, 50)).toBe(false) + abort.abort() + expect((await abandoned).status).toBe(503) + expect((await bootstrap(AbortSignal.abort())).status).toBe(503) + + gate.resolve(undefined) + await fiber.await() + expect((await bootstrap()).status).toBe(200) + }) +}) + +/** Report whether a promise settles before a real-time deadline. */ +async function settledWithin(promise: Promise, ms: number): Promise { + const timeout = new Promise((resolve) => { setTimeout(() => { resolve(false) }, ms) }) + return Promise.race([promise.then(() => true, () => true), timeout]) +} diff --git a/packages/experimental/inspector/tests/plugin-routes.host.spec.ts b/packages/experimental/inspector/tests/plugin-routes.host.spec.ts new file mode 100644 index 0000000000..d37d1cbfc2 --- /dev/null +++ b/packages/experimental/inspector/tests/plugin-routes.host.spec.ts @@ -0,0 +1,286 @@ +/** Host DevTools frontend and CDP upgrade routes registered by the Inspector plugin. */ + +import { randomBytes } from 'node:crypto' +import type { IncomingMessage, ServerResponse } from 'node:http' +import { createConnection, createServer, type AddressInfo, type Socket } from 'node:net' +import { Context } from '@deepseek-ai/cordis' +import type { Message } from '@deepseek-ai/cordis' +import type { WebRoute, 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 { afterEach, describe, expect, it, vi } from 'vitest' +import { apply, Config, inject, name } from '../src/index.ts' +import { startInspector, type InspectorHandle } from '../src/host/bridge/controller.ts' + +type AssetMiddleware = (req: IncomingMessage, res: ServerResponse, next: (error?: unknown) => void) => void + +const assets = vi.hoisted(() => ({ + options: undefined as { setHeaders?: (res: ServerResponse) => void } | undefined, + middleware: vi.fn(), +})) + +vi.mock('serve-static', () => ({ + default: (_root: string, options: { setHeaders?: (res: ServerResponse) => void }) => { + assets.options = options + return assets.middleware + }, +})) + +vi.mock('../src/host/bridge/controller.ts', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, startInspector: vi.fn(actual.startInspector) } +}) + +interface RecordedResponse { + status?: number + headers: Record + ended: boolean + destroyed: boolean +} + +describe('experimental Inspector Host DevTools routes', () => { + let context: Context | undefined + + afterEach(async () => { + await context?.fiber.dispose() + context = undefined + assets.middleware.mockReset() + vi.restoreAllMocks() + vi.clearAllMocks() + }) + + async function mount(options: { indexAllowed?: () => boolean; before?: (ctx: Context) => void } = {}) { + context = new Context() + vi.spyOn(console, 'log').mockImplementation(() => undefined) + const routes: WebRoute[] = [] + const upgrades: WebUpgradeRoute[] = [] + const webServer: Pick = { + register: (route) => { routes.push(route); return () => {} }, + registerUpgrade: (route) => { upgrades.push(route); return () => {} }, + } + context.provide('webServer', webServer as WebServer) + const auth: Pick = { + isAuthenticated: () => true, + authenticateApi: () => undefined, + authorizeIndex: (_request, response) => { + if (options.indexAllowed?.() !== false) return true + response.writeHead(401) + response.end() + return false + }, + } + new HostConnectionService(context, [], auth as BrowserAuth) + const messages: Message[] = [] + context.logger.exporter({ levels: { default: 3 }, export: (message) => { messages.push(message) } }) + options.before?.(context) + const fiber = context.plugin({ name, inject: [...inject], Config, apply }, { port: 0, captureFetch: false }) + return { fiber, routes, upgrades, messages } + } + + it('answers frontend requests by method, index authorization, redirect, and asset outcome', async () => { + let indexAllowed = true + const { fiber, routes, messages } = await mount({ indexAllowed: () => indexAllowed }) + await fiber.await() + const route = routes.find(row => row.path === '/inspector/devtools') + if (route === undefined) throw new Error('DevTools route was not registered') + const call = async (method: string | undefined, url: string | undefined, headersSent = false) => { + const recorded: RecordedResponse = { headers: {}, ended: false, destroyed: false } + const res = { + get headersSent() { return headersSent }, + setHeader(key: string, value: string) { recorded.headers[key.toLowerCase()] = value }, + writeHead(status: number, headers: Record = {}) { + recorded.status = status + for (const [key, value] of Object.entries(headers)) recorded.headers[key.toLowerCase()] = value + return this + }, + end() { recorded.ended = true; return this }, + destroy() { recorded.destroyed = true; return this }, + } + const req = { method, url, headers: {} } + await route.handler(req as IncomingMessage, res as ServerResponse) + return { recorded, req } + } + + expect((await call('POST', '/inspector/devtools/devtools_app.html')).recorded).toMatchObject({ status: 405, headers: { allow: 'GET, HEAD' }, ended: true }) + indexAllowed = false + expect((await call('GET', '/inspector/devtools/devtools_app.html')).recorded.status).toBe(401) + indexAllowed = true + expect(assets.middleware).not.toHaveBeenCalled() + expect((await call('GET', '/inspector/devtools?panel=console')).recorded).toMatchObject({ + status: 302, headers: { location: 'devtools/devtools_app.html?panel=console' }, + }) + expect((await call('HEAD', '/inspector/devtools/')).recorded).toMatchObject({ status: 302, headers: { location: 'devtools_app.html' } }) + + assets.middleware.mockImplementationOnce((_req, res) => { + assets.options?.setHeaders?.(res) + res.writeHead(200).end() + }) + const served = await call('GET', '/inspector/devtools/devtools_app.html?ws=1') + expect(served.req.url).toBe('/devtools_app.html?ws=1') + expect(served.recorded).toMatchObject({ status: 200, headers: { 'x-content-type-options': 'nosniff' } }) + + assets.middleware.mockImplementationOnce((_req, _res, next) => { next() }) + const missing = await call('GET', undefined) + expect(missing.req.url).toBe('') + expect(missing.recorded).toMatchObject({ status: 404, ended: true }) + + const failure = new Error('fixture asset read failed') + assets.middleware.mockImplementation((_req, _res, next) => { next(failure) }) + expect((await call(undefined, '/inspector/devtools/main.js')).recorded).toMatchObject({ status: 405 }) + expect((await call('GET', '/inspector/devtools/main.js')).recorded).toMatchObject({ status: 500, ended: true, destroyed: false }) + const streaming = (await call('GET', '/inspector/devtools/main.js', true)).recorded + expect(streaming.status).toBeUndefined() + expect(streaming.destroyed).toBe(true) + expect(messages.filter(message => message.args[0] === 'experimental-inspector: frontend asset request failed') + .map((message): unknown => message.args[1])).toEqual([failure, failure]) + }) + + it('proxies authenticated CDP upgrades with their WebSocket headers and early bytes to the Worker', async () => { + const { fiber, upgrades } = await mount() + await fiber.await() + const upgrade = upgrades.find(row => row.path === '/inspector/devtools/cdp') + if (upgrade === undefined) throw new Error('CDP upgrade route was not registered') + + const selected = await socketPair() + const reply = readUpgrade(selected.client) + await upgrade.handler(upgradeRequest('/inspector/devtools/cdp?clientSourceId=client-fixture', { + 'sec-websocket-protocol': ['dsh-fixture', 'dsh-other'], + 'sec-websocket-extensions': undefined, + 'user-agent': 'fixture', + }), selected.server, clientTextFrame(JSON.stringify({ id: 1, method: 'DSHInspector.getSources' }))) + const answered = await reply + expect(answered.status).toMatch(/^HTTP\/1\.1 101 /u) + expect(answered.headers.toLowerCase()).toContain('sec-websocket-protocol: dsh-fixture') + expect(answered.message.id).toBe(1) + expect(answered.message.result).toHaveProperty('sources') + + const unselected = await socketPair() + const later = readUpgrade(unselected.client) + await upgrade.handler(upgradeRequest(undefined, {}), unselected.server, Buffer.alloc(0)) + await vi.waitFor(() => { expect(unselected.received().toString('latin1')).toMatch(/^HTTP\/1\.1 101 /u) }) + unselected.client.write(clientTextFrame(JSON.stringify({ id: 1, method: 'DSHInspector.getSources' }))) + expect((await later).message).toMatchObject({ id: 1 }) + + const reset = await socketPair() + await upgrade.handler(upgradeRequest(undefined, {}), reset.server, Buffer.alloc(0)) + reset.server.destroy(new Error('fixture browser reset')) + await reset.closed + + // Disposal releases every still-proxied socket before the Worker closes. + await fiber.dispose() + await Promise.all([selected.closed, unselected.closed]) + }) + + it('closes the browser socket when the Worker endpoint refuses the proxied connection', async () => { + const refused = await closedPort() + vi.mocked(startInspector).mockImplementationOnce(async (options) => { + const actual = await vi.importActual('../src/host/bridge/controller.ts') + const handle = await actual.startInspector(options) + return redirectedHandle(handle, `ws://127.0.0.1:${String(refused)}/devtools/page/refused`) + }) + const { fiber, upgrades } = await mount() + await fiber.await() + const upgrade = upgrades.find(row => row.path === '/inspector/devtools/cdp') + if (upgrade === undefined) throw new Error('CDP upgrade route was not registered') + const pair = await socketPair() + await upgrade.handler(upgradeRequest(undefined, {}), pair.server, Buffer.alloc(0)) + await pair.closed + expect(pair.received().length).toBe(0) + }) + + it('logs a rollback failure and still rejects initialization with the registration error', async () => { + const closeFailure = new Error('fixture Worker close failed') + vi.mocked(startInspector).mockImplementationOnce(async (options) => { + const actual = await vi.importActual('../src/host/bridge/controller.ts') + const handle = await actual.startInspector(options) + return { endpoint: handle.endpoint, source: handle.source, close: async () => { await handle.close(); throw closeFailure } } + }) + const { fiber, messages } = await mount({ + before: (ctx) => { + ctx.provide('inspector', { + publish: () => undefined, + cordis: { getTree: () => Promise.reject(new Error('unused test service')) }, + }) + }, + }) + await expect(fiber.await()).rejects.toThrow('service "inspector" has been registered') + const rollback = messages.find(message => message.args[0] === 'experimental-inspector: initialization rollback failed') + expect(rollback?.type).toBe('error') + expect(rollback?.args[1]).toMatchObject({ errors: [closeFailure] }) + }) +}) + +function upgradeRequest(url: string | undefined, headers: Record): IncomingMessage { + return { + url, + headers: { + host: '127.0.0.1', origin: 'http://127.0.0.1', connection: 'Upgrade', upgrade: 'websocket', + 'sec-websocket-key': randomBytes(16).toString('base64'), 'sec-websocket-version': '13', ...headers, + }, + } as IncomingMessage +} + +/** Encode one masked client-to-server WebSocket text frame. */ +function clientTextFrame(text: string): Buffer { + const payload = Buffer.from(text) + if (payload.length >= 126) throw new Error('fixture frame must stay below 126 bytes') + const mask = randomBytes(4) + return Buffer.concat([Buffer.from([0x81, 0x80 | payload.length]), mask, payload.map((byte, index) => byte ^ mask[index % 4]!)]) +} + +/** Read the upgrade response and the first server text frame that answers request id 1. */ +async function readUpgrade(socket: Socket): Promise<{ status: string; headers: string; message: Record }> { + let buffer = Buffer.alloc(0) + return new Promise((resolve, reject) => { + socket.on('data', (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]) + const end = buffer.indexOf('\r\n\r\n') + if (end < 0) return + const head = buffer.subarray(0, end).toString('latin1') + let offset = end + 4 + while (offset + 2 <= buffer.length) { + const short = buffer[offset + 1]! & 0x7f + const lengthBytes = short === 126 ? 2 : short === 127 ? 8 : 0 + if (offset + 2 + lengthBytes > buffer.length) return + const length = short === 126 ? buffer.readUInt16BE(offset + 2) : short === 127 ? Number(buffer.readBigUInt64BE(offset + 2)) : short + const start = offset + 2 + lengthBytes + if (start + length > buffer.length) return + const message = JSON.parse(buffer.subarray(start, start + length).toString('utf8')) as Record + if (message.id === 1) { + const [status = '', ...headers] = head.split('\r\n') + resolve({ status, headers: headers.join('\n'), message }) + return + } + offset = start + length + } + }) + socket.once('close', () => { reject(new Error(`socket closed before a CDP reply: ${buffer.toString('latin1')}`)) }) + }) +} + +/** Open one connected TCP pair: `server` stands for the browser socket the Web server hands to the upgrade route. */ +async function socketPair(): Promise<{ client: Socket; server: Socket; closed: Promise; received: () => Buffer }> { + const listener = createServer() + await new Promise((resolve) => { listener.listen(0, '127.0.0.1', resolve) }) + const accepted = new Promise((resolve) => { listener.once('connection', resolve) }) + const client = createConnection((listener.address() as AddressInfo).port, '127.0.0.1') + const server = await accepted + listener.close() + let received = Buffer.alloc(0) + client.on('data', (chunk: Buffer) => { received = Buffer.concat([received, chunk]) }) + client.on('error', (error: unknown) => { void error /* The proxied peer may reset the fixture connection; `closed` observes the outcome. */ }) + const closed = new Promise((resolve) => { client.once('close', () => { resolve() }) }) + return { client, server, closed, received: () => received } +} + +async function closedPort(): Promise { + const listener = createServer() + await new Promise((resolve) => { listener.listen(0, '127.0.0.1', resolve) }) + const { port } = listener.address() as AddressInfo + await new Promise((resolve) => { listener.close(() => { resolve() }) }) + return port +} + +function redirectedHandle(handle: InspectorHandle, webSocketDebuggerUrl: string): InspectorHandle { + return { endpoint: { ...handle.endpoint, webSocketDebuggerUrl }, source: handle.source, close: () => handle.close() } +} diff --git a/tsconfig.host.json b/tsconfig.host.json index 692b5c6d04..1815f14578 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -77,6 +77,7 @@ "apps/web/tests/worktree-opt-in.e2e.ts", "apps/web/tests/session-inspector.e2e.ts", "apps/web/tests/shared-inspector.e2e.ts", + "apps/web/tests/inspector-late-enable.e2e.ts", "apps/web/tests/layout-bottom.e2e.ts", "apps/web/tests/devtools-frontend.e2e.ts", "apps/web/tests/inspector-panels.e2e.ts", diff --git a/vitest.config.ts b/vitest.config.ts index 711733c10e..9c717c0ced 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -306,7 +306,6 @@ export default defineConfig({ // Keep already-complete Inspector modules under the per-file gate and // enumerate the remaining direct-test debt instead of exempting src/**. // TODO(inspector): close these branch gaps and remove the entries. - 'packages/experimental/inspector/src/host/plugin.ts', 'packages/experimental/inspector/src/shared/bridge/{control-codec,rpc}.ts', 'packages/experimental/inspector/src/shared/bridge/messages/observation.ts', 'packages/experimental/inspector/src/shared/bridge/messages/query/codec.ts',