From b840559b0fe08f65ccf4083401b9c92ac8ceadf0 Mon Sep 17 00:00:00 2001 From: soul-cat <626630250@qq.com> Date: Fri, 27 Feb 2026 17:29:10 +0800 Subject: [PATCH 1/2] feat: add 13 JavaScript Debugger tools via CDP Debugger domain Add full debugger capabilities: enable/disable, breakpoints (set/remove/list), pause state inspection, stepping (over/into/out), call frame evaluation, script source retrieval, and script listing. New file: src/tools/debugger.ts Modified: McpPage (per-page debugger state + CDP event listeners), McpContext (13 debugger method implementations), ToolDefinition (Context interface extension), tools.ts (tool registration), types.ts (debugger interfaces) --- src/McpContext.ts | 131 ++++++++++++++++ src/McpPage.ts | 87 ++++++++++- src/tools/ToolDefinition.ts | 33 +++- src/tools/debugger.ts | 291 ++++++++++++++++++++++++++++++++++++ src/tools/tools.ts | 2 + src/types.ts | 44 ++++++ 6 files changed, 586 insertions(+), 2 deletions(-) create mode 100644 src/tools/debugger.ts diff --git a/src/McpContext.ts b/src/McpContext.ts index 09a339db0..19b577a17 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -1126,4 +1126,135 @@ export class McpContext implements Context { getExtension(id: string): InstalledExtension | undefined { return this.#extensionRegistry.getById(id); } + + // ── Debugger methods ── + async enableDebugger(targetPage?: Page): Promise { + const page = targetPage ?? this.getSelectedPage(); + const mcpPage = this.#getMcpPage(page); + await mcpPage.enableDebugger(); + } + + async disableDebugger(targetPage?: Page): Promise { + const page = targetPage ?? this.getSelectedPage(); + const mcpPage = this.#getMcpPage(page); + await mcpPage.disableDebugger(); + } + + isDebuggerEnabled(targetPage?: Page): boolean { + const page = targetPage ?? this.getSelectedPage(); + const mcpPage = this.#mcpPages.get(page); + return mcpPage?.debuggerState.enabled ?? false; + } + getDebuggerPausedState(targetPage?: Page) { + const page = targetPage ?? this.getSelectedPage(); + const mcpPage = this.#getMcpPage(page); + return mcpPage.debuggerState.paused; + } + getDebuggerScripts(targetPage?: Page) { + const page = targetPage ?? this.getSelectedPage(); + const mcpPage = this.#getMcpPage(page); + return [...mcpPage.debuggerState.scripts.values()]; + } + getBreakpoints(targetPage?: Page) { + const page = targetPage ?? this.getSelectedPage(); + const mcpPage = this.#getMcpPage(page); + return [...mcpPage.debuggerState.breakpoints.values()]; + } + async setBreakpoint( + targetPage: Page | undefined, + url: string, + lineNumber: number, + columnNumber?: number, + condition?: string, + ) { + const page = targetPage ?? this.getSelectedPage(); + const mcpPage = this.#getMcpPage(page); + const session = mcpPage.getCdpSession(); + const result = await session.send('Debugger.setBreakpointByUrl', { + urlRegex: url.replace(/[.*+?^$()|[\]\\]/g, '\\$&'), + lineNumber, + columnNumber, + condition, + }); + const info = { + breakpointId: result.breakpointId, + url, + lineNumber, + columnNumber, + condition, + locations: (result.locations ?? []).map(loc => ({ + scriptId: loc.scriptId, + lineNumber: loc.lineNumber, + columnNumber: loc.columnNumber ?? 0, + })), + }; + mcpPage.debuggerState.breakpoints.set(info.breakpointId, info); + return info; + } + async removeBreakpoint( + targetPage: Page | undefined, + breakpointId: string, + ): Promise { + const page = targetPage ?? this.getSelectedPage(); + const mcpPage = this.#getMcpPage(page); + const session = mcpPage.getCdpSession(); + await session.send('Debugger.removeBreakpoint', {breakpointId}); + mcpPage.debuggerState.breakpoints.delete(breakpointId); + } + async resumeDebugger(targetPage?: Page): Promise { + const page = targetPage ?? this.getSelectedPage(); + const mcpPage = this.#getMcpPage(page); + const session = mcpPage.getCdpSession(); + await session.send('Debugger.resume'); + } + async stepOver(targetPage?: Page): Promise { + const page = targetPage ?? this.getSelectedPage(); + const mcpPage = this.#getMcpPage(page); + const session = mcpPage.getCdpSession(); + await session.send('Debugger.stepOver'); + } + async stepInto(targetPage?: Page): Promise { + const page = targetPage ?? this.getSelectedPage(); + const mcpPage = this.#getMcpPage(page); + const session = mcpPage.getCdpSession(); + await session.send('Debugger.stepInto'); + } + async stepOut(targetPage?: Page): Promise { + const page = targetPage ?? this.getSelectedPage(); + const mcpPage = this.#getMcpPage(page); + const session = mcpPage.getCdpSession(); + await session.send('Debugger.stepOut'); + } + async evaluateOnCallFrame( + targetPage: Page | undefined, + callFrameId: string, + expression: string, + ): Promise { + const page = targetPage ?? this.getSelectedPage(); + const mcpPage = this.#getMcpPage(page); + const session = mcpPage.getCdpSession(); + const result = await session.send( + 'Debugger.evaluateOnCallFrame', + {callFrameId, expression, returnByValue: true}, + ); + const remoteObj = result.result; + if (remoteObj.type === 'undefined') return 'undefined'; + if (remoteObj.value !== undefined) { + return JSON.stringify(remoteObj.value); + } + return remoteObj.description ?? String(remoteObj.type); + } + async getScriptSource( + targetPage: Page | undefined, + scriptId: string, + ): Promise { + const page = targetPage ?? this.getSelectedPage(); + const mcpPage = this.#getMcpPage(page); + const session = mcpPage.getCdpSession(); + const result = await session.send( + 'Debugger.getScriptSource', + {scriptId}, + ); + return result.scriptSource; + } } diff --git a/src/McpPage.ts b/src/McpPage.ts index 51a30448f..223a3d9ce 100644 --- a/src/McpPage.ts +++ b/src/McpPage.ts @@ -4,8 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type {Dialog, Page, Viewport} from './third_party/index.js'; +import type {CDPSession, Dialog, Page, Protocol, Viewport} from './third_party/index.js'; import type { + DebuggerBreakpointInfo, + DebuggerPausedState, + DebuggerScriptInfo, + DebuggerState, EmulationSettings, GeolocationOptions, TextSnapshot, @@ -34,6 +38,86 @@ export class McpPage { isolatedContextName?: string; devToolsPage?: Page; + // Debugger + debuggerState: DebuggerState = { + enabled: false, + paused: null, + breakpoints: new Map(), + scripts: new Map(), + }; + #cdpSession: CDPSession | null = null; + + async enableDebugger(): Promise { + if (this.debuggerState.enabled) return; + // @ts-expect-error internal Puppeteer API + const session = this.page._client() as CDPSession; + this.#cdpSession = session; + session.on('Debugger.paused', this.#onDebuggerPaused); + session.on('Debugger.resumed', this.#onDebuggerResumed); + session.on('Debugger.scriptParsed', this.#onScriptParsed); + await session.send('Debugger.enable'); + this.debuggerState.enabled = true; + } + + async disableDebugger(): Promise { + if (!this.debuggerState.enabled || !this.#cdpSession) return; + this.#cdpSession.off('Debugger.paused', this.#onDebuggerPaused); + this.#cdpSession.off('Debugger.resumed', this.#onDebuggerResumed); + this.#cdpSession.off('Debugger.scriptParsed', this.#onScriptParsed); + await this.#cdpSession.send('Debugger.disable'); + this.debuggerState = { + enabled: false, + paused: null, + breakpoints: new Map(), + scripts: new Map(), + }; + this.#cdpSession = null; + } + + getCdpSession(): CDPSession { + if (!this.#cdpSession) { + throw new Error('Debugger is not enabled. Call debugger_enable first.'); + } + return this.#cdpSession; + } + #onDebuggerPaused = (params: Protocol.Debugger.PausedEvent): void => { + const callFrames = params.callFrames.map(frame => ({ + callFrameId: frame.callFrameId, + functionName: frame.functionName, + url: frame.url ?? '', + lineNumber: frame.location.lineNumber ?? 0, + columnNumber: frame.location.columnNumber ?? 0, + scopeChain: frame.scopeChain.map(scope => ({ + type: scope.type, + name: scope.name, + objectId: scope.object.objectId, + })), + })); + this.debuggerState.paused = { + callFrames, + reason: params.reason ?? 'unknown', + hitBreakpoints: params.hitBreakpoints, + }; + }; + #onDebuggerResumed = (): void => { + this.debuggerState.paused = null; + }; + + #onScriptParsed = (params: Protocol.Debugger.ScriptParsedEvent): void => { + const scriptId = params.scriptId; + const url = params.url ?? ''; + if (!url) return; + this.debuggerState.scripts.set(scriptId, { + scriptId, + url, + startLine: params.startLine ?? 0, + startColumn: params.startColumn ?? 0, + endLine: params.endLine ?? 0, + endColumn: params.endColumn ?? 0, + sourceMapURL: params.sourceMapURL, + }); + }; + // Dialog #dialog?: Dialog; #dialogHandler: (dialog: Dialog) => void; @@ -81,5 +165,6 @@ export class McpPage { dispose(): void { this.page.off('dialog', this.#dialogHandler); + void this.disableDebugger().catch(() => {}); } } diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 3f441ee94..ee1800377 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -14,7 +14,13 @@ import type { Viewport, } from '../third_party/index.js'; import type {InsightName, TraceResult} from '../trace-processing/parse.js'; -import type {TextSnapshotNode, GeolocationOptions} from '../types.js'; +import type { + TextSnapshotNode, + GeolocationOptions, + DebuggerPausedState, + DebuggerBreakpointInfo, + DebuggerScriptInfo, +} from '../types.js'; import type {InstalledExtension} from '../utils/ExtensionRegistry.js'; import type {PaginationOptions} from '../utils/types.js'; @@ -189,6 +195,31 @@ export type Context = Readonly<{ uninstallExtension(id: string): Promise; listExtensions(): InstalledExtension[]; getExtension(id: string): InstalledExtension | undefined; + // Debugger + enableDebugger(page: Page): Promise; + disableDebugger(page: Page): Promise; + isDebuggerEnabled(page: Page): boolean; + getDebuggerPausedState(page: Page): DebuggerPausedState | null; + setBreakpoint( + page: Page, + url: string, + lineNumber: number, + columnNumber?: number, + condition?: string, + ): Promise; + removeBreakpoint(page: Page, breakpointId: string): Promise; + getBreakpoints(page: Page): DebuggerBreakpointInfo[]; + resumeDebugger(page: Page): Promise; + stepOver(page: Page): Promise; + stepInto(page: Page): Promise; + stepOut(page: Page): Promise; + evaluateOnCallFrame( + page: Page, + callFrameId: string, + expression: string, + ): Promise; + getScriptSource(page: Page, scriptId: string): Promise; + getDebuggerScripts(page: Page): DebuggerScriptInfo[]; }>; export function defineTool( diff --git a/src/tools/debugger.ts b/src/tools/debugger.ts new file mode 100644 index 000000000..dc48a9484 --- /dev/null +++ b/src/tools/debugger.ts @@ -0,0 +1,291 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {zod} from '../third_party/index.js'; + +import {ToolCategory} from './categories.js'; +import {definePageTool} from './ToolDefinition.js'; + +export const debuggerEnable = definePageTool({ + name: 'debugger_enable', + description: + 'Enable the JavaScript debugger for the current page. Must be called before setting breakpoints or using other debugger tools.', + annotations: { + category: ToolCategory.DEBUGGING, + readOnlyHint: false, + }, + schema: {}, + handler: async (request, response, context) => { + await context.enableDebugger(request.page); + response.appendResponseLine('Debugger enabled for the current page.'); + }, +}); + +export const debuggerDisable = definePageTool({ + name: 'debugger_disable', + description: + 'Disable the JavaScript debugger for the current page. Removes all breakpoints and resumes execution if paused.', + annotations: { + category: ToolCategory.DEBUGGING, + readOnlyHint: false, + }, + schema: {}, + handler: async (request, response, context) => { + await context.disableDebugger(request.page); + response.appendResponseLine('Debugger disabled.'); + }, +}); + +export const setBreakpoint = definePageTool({ + name: 'set_breakpoint', + description: + 'Set a breakpoint at a specific URL and line number. The debugger must be enabled first.', + annotations: { + category: ToolCategory.DEBUGGING, + readOnlyHint: false, + }, + schema: { + url: zod.string().describe('The URL of the script to set the breakpoint in.'), + lineNumber: zod + .number() + .int() + .describe('0-based line number to set the breakpoint at.'), + columnNumber: zod + .number() + .int() + .optional() + .describe('0-based column number to set the breakpoint at.'), + condition: zod + .string() + .optional() + .describe('Expression that must evaluate to true for the breakpoint to pause.'), + }, + handler: async (request, response, context) => { + const {url, lineNumber, columnNumber, condition} = request.params; + const info = await context.setBreakpoint( + request.page, + url, + lineNumber, + columnNumber, + condition, + ); + response.appendResponseLine(`Breakpoint set: ${info.breakpointId}`); + if (info.locations.length > 0) { + response.appendResponseLine('Resolved locations:'); + for (const loc of info.locations) { + response.appendResponseLine( + ` scriptId=${loc.scriptId} line=${loc.lineNumber} col=${loc.columnNumber}`, + ); + } + } + }, +}); + +export const removeBreakpoint = definePageTool({ + name: 'remove_breakpoint', + description: 'Remove a breakpoint by its ID.', + annotations: { + category: ToolCategory.DEBUGGING, + readOnlyHint: false, + }, + schema: { + breakpointId: zod.string().describe('The ID of the breakpoint to remove.'), + }, + handler: async (request, response, context) => { + await context.removeBreakpoint(request.page, request.params.breakpointId); + response.appendResponseLine('Breakpoint removed.'); + }, +}); +export const listBreakpoints = definePageTool({ + name: 'list_breakpoints', + description: 'List all active breakpoints for the current page.', + annotations: { + category: ToolCategory.DEBUGGING, + readOnlyHint: true, + }, + schema: {}, + handler: async (request, response, context) => { + const breakpoints = context.getBreakpoints(request.page); + if (breakpoints.length === 0) { + response.appendResponseLine('No active breakpoints.'); + return; + } + response.appendResponseLine(`Active breakpoints (${breakpoints.length}):`); + for (const bp of breakpoints) { + response.appendResponseLine( + ` ${bp.breakpointId}: ${bp.url}:${bp.lineNumber}${bp.condition ? ` (condition: ${bp.condition})` : ''}`, + ); + } + }, +}); +export const getPausedState = definePageTool({ + name: 'get_paused_state', + description: + 'Get the current debugger paused state including call frames, pause reason, and hit breakpoints.', + annotations: { + category: ToolCategory.DEBUGGING, + readOnlyHint: true, + }, + schema: {}, + handler: async (request, response, context) => { + const state = context.getDebuggerPausedState(request.page); + if (!state) { + response.appendResponseLine('Debugger is not paused.'); + return; + } + response.appendResponseLine(`Paused. Reason: ${state.reason}`); + if (state.hitBreakpoints?.length) { + response.appendResponseLine( + `Hit breakpoints: ${state.hitBreakpoints.join(', ')}`, + ); + } + response.appendResponseLine(`Call frames (${state.callFrames.length}):`); + for (const frame of state.callFrames) { + response.appendResponseLine( + ` [${frame.callFrameId}] ${frame.functionName || '(anonymous)'} at ${frame.url}:${frame.lineNumber}:${frame.columnNumber}`, + ); + for (const scope of frame.scopeChain) { + response.appendResponseLine( + ` scope: ${scope.type}${scope.name ? ` (${scope.name})` : ''}`, + ); + } + } + }, +}); +export const debuggerResume = definePageTool({ + name: 'debugger_resume', + description: 'Resume execution after being paused at a breakpoint.', + annotations: { + category: ToolCategory.DEBUGGING, + readOnlyHint: false, + }, + schema: {}, + handler: async (request, response, context) => { + await context.resumeDebugger(request.page); + response.appendResponseLine('Execution resumed.'); + }, +}); +export const debuggerStepOver = definePageTool({ + name: 'debugger_step_over', + description: 'Step over the current statement while paused.', + annotations: { + category: ToolCategory.DEBUGGING, + readOnlyHint: false, + }, + schema: {}, + handler: async (request, response, context) => { + await context.stepOver(request.page); + response.appendResponseLine('Stepped over.'); + }, +}); +export const debuggerStepInto = definePageTool({ + name: 'debugger_step_into', + description: 'Step into the next function call while paused.', + annotations: { + category: ToolCategory.DEBUGGING, + readOnlyHint: false, + }, + schema: {}, + handler: async (request, response, context) => { + await context.stepInto(request.page); + response.appendResponseLine('Stepped into.'); + }, +}); +export const debuggerStepOut = definePageTool({ + name: 'debugger_step_out', + description: 'Step out of the current function while paused.', + annotations: { + category: ToolCategory.DEBUGGING, + readOnlyHint: false, + }, + schema: {}, + handler: async (request, response, context) => { + await context.stepOut(request.page); + response.appendResponseLine('Stepped out.'); + }, +}); +export const evaluateOnCallFrame = definePageTool({ + name: 'evaluate_on_call_frame', + description: + 'Evaluate an expression in the context of a specific call frame while paused at a breakpoint.', + annotations: { + category: ToolCategory.DEBUGGING, + readOnlyHint: true, + }, + schema: { + callFrameId: zod + .string() + .describe('The ID of the call frame from get_paused_state.'), + expression: zod + .string() + .describe('JavaScript expression to evaluate.'), + }, + handler: async (request, response, context) => { + const {callFrameId, expression} = request.params; + const result = await context.evaluateOnCallFrame( + request.page, + callFrameId, + expression, + ); + response.appendResponseLine('Result:'); + response.appendResponseLine('```json'); + response.appendResponseLine(result); + response.appendResponseLine('```'); + }, +}); +export const getScriptSource = definePageTool({ + name: 'get_script_source', + description: + 'Get the source code of a script by its ID. Use list_scripts to find script IDs.', + annotations: { + category: ToolCategory.DEBUGGING, + readOnlyHint: true, + }, + schema: { + scriptId: zod.string().describe('The script ID to get source for.'), + }, + handler: async (request, response, context) => { + const source = await context.getScriptSource( + request.page, + request.params.scriptId, + ); + response.appendResponseLine('```javascript'); + response.appendResponseLine(source); + response.appendResponseLine('```'); + }, +}); +export const listScripts = definePageTool({ + name: 'list_scripts', + description: + 'List all scripts loaded in the current page. Useful for finding script IDs for breakpoints.', + annotations: { + category: ToolCategory.DEBUGGING, + readOnlyHint: true, + }, + schema: { + filter: zod + .string() + .optional() + .describe('Optional substring to filter scripts by URL.'), + }, + handler: async (request, response, context) => { + let scripts = context.getDebuggerScripts(request.page); + const {filter} = request.params; + if (filter) { + scripts = scripts.filter(s => s.url.includes(filter)); + } + if (scripts.length === 0) { + response.appendResponseLine('No scripts found.'); + return; + } + response.appendResponseLine(`Scripts (${scripts.length}):`); + for (const s of scripts) { + response.appendResponseLine( + ` [${s.scriptId}] ${s.url}`, + ); + } + }, +}); \ No newline at end of file diff --git a/src/tools/tools.ts b/src/tools/tools.ts index 27d20cedc..6efa1459b 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -7,6 +7,7 @@ import type {ParsedArguments} from '../cli.js'; import * as consoleTools from './console.js'; +import * as debuggerTools from './debugger.js'; import * as emulationTools from './emulation.js'; import * as extensionTools from './extensions.js'; import * as inputTools from './input.js'; @@ -27,6 +28,7 @@ export const createTools = (args: ParsedArguments) => { ? Object.values(slimTools) : [ ...Object.values(consoleTools), + ...Object.values(debuggerTools), ...Object.values(emulationTools), ...Object.values(extensionTools), ...Object.values(inputTools), diff --git a/src/types.ts b/src/types.ts index 69dddd2a9..da75ffbf4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -37,3 +37,47 @@ export interface EmulationSettings { colorScheme?: 'dark' | 'light' | null; viewport?: Viewport | null; } + +// Debugger types +export interface DebuggerBreakpointInfo { + breakpointId: string; + url: string; + lineNumber: number; + columnNumber?: number; + condition?: string; + locations: Array<{scriptId: string; lineNumber: number; columnNumber: number}>; +} + +export interface DebuggerPausedState { + callFrames: Array<{ + callFrameId: string; + functionName: string; + url: string; + lineNumber: number; + columnNumber: number; + scopeChain: Array<{ + type: string; + name?: string; + objectId?: string; + }>; + }>; + reason: string; + hitBreakpoints?: string[]; +} + +export interface DebuggerScriptInfo { + scriptId: string; + url: string; + startLine: number; + startColumn: number; + endLine: number; + endColumn: number; + sourceMapURL?: string; +} + +export interface DebuggerState { + enabled: boolean; + paused: DebuggerPausedState | null; + breakpoints: Map; + scripts: Map; +} From ed95625a27b909542c99f72f8a8aa8f3ea4956b2 Mon Sep 17 00:00:00 2001 From: soul-cat <626630250@qq.com> Date: Fri, 27 Feb 2026 18:12:45 +0800 Subject: [PATCH 2/2] docs: add setup guides, test script, and MCP launcher - setup-from-scratch.md: complete from-zero config guide with troubleshooting - opencode-setup-guide.md: reference doc for existing users - run-mcp.sh: wrapper script to launch MCP server from repo root - test-debugger.mjs: comprehensive debugger functional test (14 cases) --- docs/opencode-setup-guide.md | 196 +++++++++++++++++++++++ docs/setup-from-scratch.md | 247 +++++++++++++++++++++++++++++ run-mcp.sh | 3 + test-debugger.mjs | 296 +++++++++++++++++++++++++++++++++++ 4 files changed, 742 insertions(+) create mode 100644 docs/opencode-setup-guide.md create mode 100644 docs/setup-from-scratch.md create mode 100755 run-mcp.sh create mode 100644 test-debugger.mjs diff --git a/docs/opencode-setup-guide.md b/docs/opencode-setup-guide.md new file mode 100644 index 000000000..27bc3ff70 --- /dev/null +++ b/docs/opencode-setup-guide.md @@ -0,0 +1,196 @@ +# OpenCode Chrome Debugger 配置指南 + +本文档说明如何在一个全新的 OpenCode 实例中配置 Chrome JavaScript 断点调试能力。 + +## 前置条件 + +- Node.js >= 20 +- Google Chrome 浏览器 +- OpenCode + oh-my-opencode 插件 +- Git + +## 架构概览 + +``` +OpenCode (AI) + └─ skill: chrome-automation + └─ mcp.json → chrome-devtools MCP server (本地 fork) + └─ CDP (Chrome DevTools Protocol) + └─ Chrome (--remote-debugging-port=9222) +``` + +AI 通过 `skill_mcp(mcp_name="chrome-devtools", tool_name="...")` 调用 MCP 工具, +MCP server 通过 CDP 协议与 Chrome 通信,实现断点调试。 + +## 第一步:克隆 Fork 仓库 + +```bash +git clone https://github.com/soul-cat/chrome-devtools-mcp.git ~/chrome-devtools-mcp +cd ~/chrome-devtools-mcp +git checkout feat/debugger-tools +npm install +``` + +## 第二步:构建项目 + +```bash +# 编译 TypeScript +npx tsc + +# 运行 post-build(生成 mock 文件) +# Node >= 22 用: +node --experimental-strip-types scripts/post-build.ts +# Node 20 用: +npx tsx scripts/post-build.ts +``` + +验证构建成功: +```bash +ls build/src/tools/debugger.js # 应该存在 +``` +## 第三步:创建启动脚本 +在仓库根目录创建 `run-mcp.sh`: +```bash +#!/bin/bash +cd ~/chrome-devtools-mcp +exec node build/src/index.js "$@" +``` +```bash +chmod +x ~/chrome-devtools-mcp/run-mcp.sh +``` +**为什么需要 wrapper 脚本?** MCP server 必须从仓库根目录启动,否则 `node_modules` 无法正确解析。 + +## 第四步:配置 OpenCode Skill + +### 4.1 创建 skill 目录 +```bash +mkdir -p ~/.claude/skills/chrome-automation +``` + +### 4.2 创建 `mcp.json` +在 skill 目录下创建 `~/.claude/skills/chrome-automation/mcp.json`: +```json +{ + "chrome-devtools": { + "command": "/Users/你的用户名/chrome-devtools-mcp/run-mcp.sh", + "args": ["--browser-url=http://127.0.0.1:9222"] + } +} +``` +> **注意**:`command` 路径必须是绝对路径,替换为你的实际用户名。 +### 4.3 创建 `SKILL.md` +在 skill 目录下创建 `~/.claude/skills/chrome-automation/SKILL.md`: +```markdown +--- +name: chrome-automation +description: 此skill用于启动Chrome浏览器并建立MCP连接,支持浏览器自动化和JavaScript断点调试。 +version: 2.0.0 +--- +# Chrome 自动化 + JavaScript 调试 + +**重要**:任何浏览器相关操作前,必须先执行此流程! + +## 启动 Chrome +```bash +/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222 --user-data-dir="/tmp/chrome-debug-profile" & +``` + +## 验证端口 +```bash +curl -s http://127.0.0.1:9222/json/version +``` + +## 可用的 Debugger 工具(13个) +所有工具通过 `skill_mcp(mcp_name="chrome-devtools", tool_name="...")` 调用。 + +| 工具名 | 说明 | 参数 | +|--------|------|------| +| debugger_enable | 启用调试器(必须先调用) | 无 | +| debugger_disable | 禁用调试器 | 无 | +| set_breakpoint | 设置断点 | url, lineNumber, columnNumber?, condition? | +| remove_breakpoint | 移除断点 | breakpointId | +| list_breakpoints | 列出所有断点 | 无 | +| debugger_resume | 恢复执行 | 无 | +| debugger_step_over | 单步跳过 | 无 | +| debugger_step_into | 单步进入 | 无 | +| debugger_step_out | 单步跳出 | 无 | +| get_paused_state | 获取暂停状态 | 无 | +| evaluate_on_call_frame | 在断点处求值 | callFrameId, expression | +| list_scripts | 列出页面脚本 | filter? | +| get_script_source | 获取脚本源码 | scriptId | +``` + +## 第五步:验证 + +### 5.1 启动 Chrome +```bash +/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222 --user-data-dir="/tmp/chrome-debug-profile" & +``` + +### 5.2 重启 OpenCode +修改 skill 文件后必须重启 OpenCode 才能生效。 + +### 5.3 测试 MCP 连接 +在 OpenCode 中执行: +``` +skill_mcp(mcp_name="chrome-devtools", tool_name="debugger_enable") +``` +预期返回:`Debugger enabled for the current page.` + +## 故障排查 + +### MCP server not found +- 确认 `mcp.json` 在 `~/.claude/skills/chrome-automation/` 目录下 +- 确认 JSON 格式正确,`command` 是绝对路径 +- 重启 OpenCode + +### Connection closed +- 确认 Chrome 已启动且端口 9222 可访问:`curl -s http://127.0.0.1:9222/json/version` +- 确认 `run-mcp.sh` 可执行:`chmod +x run-mcp.sh` +- 手动测试 MCP server 启动:`./run-mcp.sh --browser-url=http://127.0.0.1:9222` +### ERR_MODULE_NOT_FOUND +- 确认已运行 `npm install` +- 确认已运行 post-build 脚本(生成 `build/node_modules/` 下的 mock 文件) +- 确认 `run-mcp.sh` 中的 `cd` 路径指向仓库根目录 + +## 关键机制说明 + +### 为什么不能直接用 `npx chrome-devtools-mcp@latest`? +npm 官方包不包含 debugger 工具。必须使用我们的 fork(`feat/debugger-tools` 分支)。 + +### Skill MCP 加载机制 +oh-my-opencode 的 `skill_mcp` 工具只能访问 **skill 内嵌的 MCP**,不能访问 `opencode.json` 中配置的 MCP。 +Skill 内嵌 MCP 有两种方式: +1. skill 目录下放 `mcp.json` 文件(推荐) +2. SKILL.md frontmatter 中添加 `mcp:` 字段 +### mcp.json 格式 +支持两种格式: +```json +// 格式1:直接定义(推荐) +{ + "server-name": { + "command": "/path/to/executable", + "args": ["--arg1", "--arg2"] + } +} +// 格式2:mcpServers 包装 +{ + "mcpServers": { + "server-name": { + "command": "/path/to/executable", + "args": ["--arg1"] + } + } +} +``` +## 文件结构总览 +``` +~/.claude/skills/chrome-automation/ +├── SKILL.md # AI 读取的指南(包含工具列表和调用方式) +└── mcp.json # MCP server 配置(指向本地 fork) + +~/chrome-devtools-mcp/ # fork 仓库 +├── run-mcp.sh # MCP 启动脚本 +├── src/tools/debugger.ts # 13个 debugger 工具实现 +└── build/ # 编译输出 +``` \ No newline at end of file diff --git a/docs/setup-from-scratch.md b/docs/setup-from-scratch.md new file mode 100644 index 000000000..5e609c979 --- /dev/null +++ b/docs/setup-from-scratch.md @@ -0,0 +1,247 @@ +# OpenCode Chrome JavaScript 调试能力配置指南 + +> 让 AI 在 OpenCode 中拥有 Chrome 断点调试能力:设置断点、单步执行、检查变量、查看调用栈。 + +## 它能做什么? + +配置完成后,AI 可以通过 13 个调试工具对 Chrome 中运行的 JavaScript 进行断点调试: + +| 能力 | 工具 | +|------|------| +| 启用/禁用调试器 | `debugger_enable`, `debugger_disable` | +| 断点管理 | `set_breakpoint`, `remove_breakpoint`, `list_breakpoints` | +| 执行控制 | `debugger_resume`, `debugger_step_over`, `debugger_step_into`, `debugger_step_out` | +| 状态检查 | `get_paused_state`, `evaluate_on_call_frame` | +| 脚本查看 | `list_scripts`, `get_script_source` | + + +## 前置条件 + +- Node.js >= 20 +- Google Chrome 浏览器 +- [OpenCode](https://github.com/opencode-ai/opencode) + [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) 插件 +- Git + +## 原理 + +``` +OpenCode (AI) + │ + │ skill_mcp(mcp_name="chrome-devtools", tool_name="debugger_enable") + │ + ▼ +chrome-automation skill (SKILL.md + mcp.json) + │ + │ 启动 MCP server 进程 + │ + ▼ +chrome-devtools-mcp (本地 fork,含 debugger 工具) + │ + │ Chrome DevTools Protocol (CDP) + │ + ▼ +Chrome (--remote-debugging-port=9222) +``` + +关键点:npm 官方的 `chrome-devtools-mcp` 包不含调试工具,必须用我们的 fork。 +--- +## 配置步骤 +### 1. 克隆并构建 fork 仓库 +```bash +git clone https://github.com/soul-cat/chrome-devtools-mcp.git ~/chrome-devtools-mcp +cd ~/chrome-devtools-mcp +git checkout feat/debugger-tools +npm install +``` +构建: +```bash +npx tsc +# Node >= 22: +node --experimental-strip-types scripts/post-build.ts +# Node 20: +npx tsx scripts/post-build.ts +``` +验证: +```bash +ls build/src/tools/debugger.js # 应存在 +``` +### 2. 创建启动脚本 +创建 `~/chrome-devtools-mcp/run-mcp.sh`: +```bash +#!/bin/bash +cd ~/chrome-devtools-mcp +exec node build/src/index.js "$@" +``` +```bash +chmod +x ~/chrome-devtools-mcp/run-mcp.sh +``` +> MCP server 必须从仓库根目录启动,否则 node_modules 无法解析。 + +### 3. 配置 OpenCode Skill + +oh-my-opencode 通过 skill 目录下的 `mcp.json` 自动发现并加载 MCP server。 + +#### 3.1 创建 skill 目录 +```bash +mkdir -p ~/.claude/skills/chrome-automation +``` + +#### 3.2 创建 mcp.json +```bash +cat > ~/.claude/skills/chrome-automation/mcp.json << 'EOF' +{ + "chrome-devtools": { + "command": "$HOME/chrome-devtools-mcp/run-mcp.sh", + "args": ["--browser-url=http://127.0.0.1:9222"] + } +} +EOF +``` + +> **注意**:`command` 中的路径必须是绝对路径,`$HOME` 需替换为实际路径(如 `/Users/yourname/chrome-devtools-mcp/run-mcp.sh`)。 + +#### 3.3 创建 SKILL.md + +SKILL.md 告诉 AI 这个 skill 的用途和可用工具: + +```bash +cat > ~/.claude/skills/chrome-automation/SKILL.md << 'SKILLEOF' +--- +name: chrome-automation +description: 此skill用于启动Chrome浏览器并建立MCP连接,支持浏览器自动化和JavaScript断点调试。当用户要求"启动浏览器"、"打开Chrome"、"断点"、"调试JS"、"debug"等操作时使用。 +version: 2.0.0 +--- + +# Chrome 自动化 + JavaScript 调试 + +**重要**:任何浏览器相关操作前,必须先启动 Chrome! + +## 第一步:启动 Chrome + +**macOS:** +```bash +/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222 --user-data-dir="/tmp/chrome-debug-profile" & +``` + +## 第二步:验证端口 + +```bash +curl -s http://127.0.0.1:9222/json/version +``` + +## 第三步:使用 MCP 工具 + +所有工具通过 `skill_mcp(mcp_name="chrome-devtools", tool_name="...")` 调用。 + +## 可用的 Debugger 工具(13个) + +| 工具名 | 说明 | +|--------|------| +| `debugger_enable` | 启用调试器(必须先调用) | +| `debugger_disable` | 禁用调试器,清除所有断点 | +| `set_breakpoint` | 设置断点(参数:url, lineNumber) | +| `remove_breakpoint` | 移除断点(参数:breakpointId) | +| `list_breakpoints` | 列出所有活跃断点 | +| `debugger_resume` | 恢复执行 | +| `debugger_step_over` | 单步跳过 | +| `debugger_step_into` | 单步进入 | +| `debugger_step_out` | 单步跳出 | +| `get_paused_state` | 获取暂停状态(调用栈、命中断点) | +| `evaluate_on_call_frame` | 在断点处求值(参数:callFrameId, expression) | +| `list_scripts` | 列出页面脚本 | +| `get_script_source` | 获取脚本源码(参数:scriptId) | +SKILLEOF +``` + +### 4. 启动 Chrome + +MCP server 通过 CDP 协议连接 Chrome,需要 Chrome 以远程调试模式启动。 + +**macOS:** +```bash +/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222 --user-data-dir="/tmp/chrome-debug-profile" & +``` + +**Linux:** +```bash +google-chrome --remote-debugging-port=9222 --user-data-dir="/tmp/chrome-debug-profile" & +``` + +**Windows:** +```bash +start "" "C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222 --user-data-dir="%TEMP%\chrome-debug-profile" +``` + +验证 Chrome 已就绪: +```bash +curl -s http://127.0.0.1:9222/json/version +# 应返回含 "Browser" 和 "webSocketDebuggerUrl" 的 JSON +``` +### 5. 验证配置 +**重启 OpenCode**,然后在对话中测试: +``` +# 测试 1:启用调试器 +skill_mcp(mcp_name="chrome-devtools", tool_name="debugger_enable") +# 期望输出:"Debugger enabled for the current page." + +# 测试 2:列出脚本 +skill_mcp(mcp_name="chrome-devtools", tool_name="list_scripts") +# 期望输出:页面加载的脚本列表 + +# 测试 3:列出断点 +skill_mcp(mcp_name="chrome-devtools", tool_name="list_breakpoints") +# 期望输出:"No active breakpoints." + +# 测试 4:禁用调试器 +skill_mcp(mcp_name="chrome-devtools", tool_name="debugger_disable") +# 期望输出:"Debugger disabled." +``` +如果 4 个测试都通过,配置完成 ✅ + +--- +## 故障排除 + +### 问题 1:`skill_mcp` 找不到 `chrome-devtools` +**症状**:`MCP server 'chrome-devtools' not found` +**原因**:`mcp.json` 不在 skill 目录下,或 OpenCode 未重启。 +**解决**: +1. 确认文件存在:`ls ~/.claude/skills/chrome-automation/mcp.json` +2. 确认 JSON 格式正确(无尾逗号) +3. 重启 OpenCode +### 问题 2:MCP server 启动崩溃 `ERR_MODULE_NOT_FOUND` +**症状**:`Cannot find module './node_modules/...'` +**原因**:构建后缺少 mock 文件。 +**解决**: +```bash +cd ~/chrome-devtools-mcp +# Node >= 22: +node --experimental-strip-types scripts/post-build.ts +# Node 20: +npx tsx scripts/post-build.ts +``` +### 问题 3:`Connection refused` 连接 Chrome 失败 +**症状**:`connect ECONNREFUSED 127.0.0.1:9222` +**原因**:Chrome 未以远程调试模式启动。 +**解决**: +1. 关闭所有 Chrome 实例 +2. 用上面 Step 4 的命令重新启动 +3. 验证:`curl -s http://127.0.0.1:9222/json/version` +### 问题 4:断点设置后代码不暂停 +**症状**:`set_breakpoint` 成功但 `get_paused_state` 显示未暂停。 +**原因**:断点所在行的代码未被执行。 +**解决**: +1. 确认断点设置在会被执行的代码行上 +2. 在 Chrome 中触发相应操作(如刷新页面、点击按钮) +3. 再次调用 `get_paused_state` 检查 +### 问题 5:`opencode.json` 中配置的 MCP 无法通过 `skill_mcp` 调用 +**症状**:在 `opencode.json` 的 `mcpServers` 中配置了 server,但 `skill_mcp` 找不到。 +**原因**:`skill_mcp` 只能访问 skill 目录下 `mcp.json` 定义的 MCP,不能访问 `opencode.json` 配置级别的 MCP。 +**解决**:将 MCP 配置放在 `~/.claude/skills//mcp.json` 中。 +--- +## 核心机制说明 +### 为什么不能用 npm 官方包? +npm 上的 `@anthropic-ai/chrome-devtools-mcp` 只包含浏览器自动化工具(导航、点击、截图等),不包含 JavaScript 调试工具。我们的 fork 通过 CDP 的 `Debugger` domain 添加了 13 个调试工具。 +### skill_mcp vs opencode.json MCP +- `opencode.json` 中的 `mcpServers`:由 OpenCode 直接管理,AI 可以直接调用工具名 +- `skill mcp.json`:由 oh-my-opencode 插件加载,AI 通过 `skill_mcp()` 调用 +- 两者不互通,`skill_mcp` 只能访问 skill 目录下的 MCP \ No newline at end of file diff --git a/run-mcp.sh b/run-mcp.sh new file mode 100755 index 000000000..0c3be7f38 --- /dev/null +++ b/run-mcp.sh @@ -0,0 +1,3 @@ +#!/bin/bash +cd /Users/cool/chrome-devtools-mcp +exec node build/src/index.js "$@" diff --git a/test-debugger.mjs b/test-debugger.mjs new file mode 100644 index 000000000..3d185faf3 --- /dev/null +++ b/test-debugger.mjs @@ -0,0 +1,296 @@ +/** + * Functional test for chrome-devtools-mcp debugger tools. + * Tests CDP Debugger domain commands that our MCP tools wrap. + * + * Key: Uses TWO CDP sessions - one for debugger, one for triggering actions. + * This avoids the deadlock where Runtime.evaluate blocks on the same session + * that has the debugger paused. + */ +import puppeteer from 'puppeteer-core'; + +const CHROME_URL = 'http://127.0.0.1:9222'; +let browser, page, dbgSession, actionSession; +let passed = 0, failed = 0; + +const debuggerState = { + enabled: false, + paused: null, + breakpoints: new Map(), + scripts: new Map(), +}; + +function log(msg) { console.log(` ${msg}`); } +function pass(name) { passed++; console.log(`✅ ${name}`); } +function fail(name, err) { failed++; console.log(`❌ ${name}: ${err}`); } + +function waitForPause(timeout = 3000) { + return new Promise((resolve, reject) => { + if (debuggerState.paused) return resolve(debuggerState.paused); + const start = Date.now(); + const check = setInterval(() => { + if (debuggerState.paused) { clearInterval(check); resolve(debuggerState.paused); } + else if (Date.now() - start > timeout) { clearInterval(check); reject(new Error('Timeout waiting for pause')); } + }, 50); + }); +} + +function waitForResume(timeout = 3000) { + return new Promise((resolve, reject) => { + if (!debuggerState.paused) return resolve(); + const start = Date.now(); + const check = setInterval(() => { + if (!debuggerState.paused) { clearInterval(check); resolve(); } + else if (Date.now() - start > timeout) { clearInterval(check); reject(new Error('Timeout waiting for resume')); } + }, 50); + }); +} + +async function setup() { + console.log('\n🔧 Connecting to Chrome...'); + browser = await puppeteer.connect({ browserURL: CHROME_URL }); + page = await browser.newPage(); + // Two separate CDP sessions to avoid deadlock + dbgSession = await page.createCDPSession(); + actionSession = await page.createCDPSession(); + console.log('🔧 Connected (2 CDP sessions).\n'); +} + +async function teardown() { + try { await dbgSession.send('Debugger.disable'); } catch {} + try { await dbgSession.detach(); } catch {} + try { await actionSession.detach(); } catch {} + try { await page.close(); } catch {} + browser.disconnect(); +} + +// ─── Test 1: debugger_enable + list_scripts ─── +async function testEnableAndListScripts() { + console.log('── Test 1: debugger_enable + list_scripts ──'); + + dbgSession.on('Debugger.scriptParsed', (params) => { + const url = params.url ?? ''; + if (!url) return; + debuggerState.scripts.set(params.scriptId, { + scriptId: params.scriptId, url, + startLine: params.startLine ?? 0, startColumn: params.startColumn ?? 0, + endLine: params.endLine ?? 0, endColumn: params.endColumn ?? 0, + }); + }); + + dbgSession.on('Debugger.paused', (params) => { + debuggerState.paused = { + callFrames: params.callFrames.map(f => ({ + callFrameId: f.callFrameId, + functionName: f.functionName, + url: f.url ?? '', + lineNumber: f.location.lineNumber ?? 0, + columnNumber: f.location.columnNumber ?? 0, + scopeChain: f.scopeChain.map(s => ({ type: s.type, name: s.name, objectId: s.object.objectId })), + })), + reason: params.reason ?? 'unknown', + hitBreakpoints: params.hitBreakpoints, + }; + }); + + dbgSession.on('Debugger.resumed', () => { debuggerState.paused = null; }); + + try { + await dbgSession.send('Debugger.enable'); + debuggerState.enabled = true; + pass('debugger_enable'); + } catch (e) { + fail('debugger_enable', e.message); + return; + } + + await page.goto('data:text/html,'); + await new Promise(r => setTimeout(r, 500)); + + const scripts = [...debuggerState.scripts.values()]; + if (scripts.length > 0) { + pass(`list_scripts (${scripts.length} scripts)`); + for (const s of scripts.slice(0, 3)) log(`[${s.scriptId}] ${s.url.substring(0, 80)}`); + } else { + fail('list_scripts', 'No scripts found'); + } +} + +// ─── Test 2: set_breakpoint + trigger + get_paused_state ─── +async function testBreakpointAndPause() { + console.log('\n── Test 2: set_breakpoint + trigger + get_paused_state ──'); + + const testHTML = ` + + +`; + + debuggerState.scripts.clear(); + await page.goto(`data:text/html,${encodeURIComponent(testHTML)}`); + await new Promise(r => setTimeout(r, 500)); + + // Set breakpoint at line 3 (let sum = a + b) + const pageUrl = page.url(); + try { + const result = await dbgSession.send('Debugger.setBreakpointByUrl', { + lineNumber: 3, url: pageUrl, + }); + const bpId = result.breakpointId; + debuggerState.breakpoints.set(bpId, { breakpointId: bpId, url: pageUrl, lineNumber: 3, locations: result.locations }); + pass(`set_breakpoint (id: ${bpId.substring(0, 40)}...)`); + for (const loc of result.locations) { + log(`Resolved: scriptId=${loc.scriptId} line=${loc.lineNumber} col=${loc.columnNumber}`); + } + } catch (e) { + fail('set_breakpoint', e.message); + return; + } + + // Trigger via SEPARATE session (avoids deadlock!) + log('Triggering breakpoint via action session...'); + actionSession.send('Runtime.evaluate', { + expression: 'document.getElementById("btn").click()', + }).catch(() => {}); // Fire-and-forget + + try { + await waitForPause(3000); + pass('get_paused_state (paused!)'); + log(`Reason: ${debuggerState.paused.reason}`); + for (const f of debuggerState.paused.callFrames) { + log(` [${f.callFrameId}] ${f.functionName || '(anonymous)'} @ line ${f.lineNumber}:${f.columnNumber}`); + } + } catch (e) { + fail('get_paused_state', e.message); + } +} + +// ─── Test 3: evaluate_on_call_frame ─── +async function testEvaluateOnCallFrame() { + console.log('\n── Test 3: evaluate_on_call_frame ──'); + if (!debuggerState.paused) { fail('evaluate_on_call_frame', 'Not paused'); return; } + + const frameId = debuggerState.paused.callFrames[0].callFrameId; + + for (const [expr, expected] of [['a', 3], ['b', 7], ['a + b', 10]]) { + try { + const r = await dbgSession.send('Debugger.evaluateOnCallFrame', { callFrameId: frameId, expression: expr }); + if (r.result.value === expected) pass(`eval "${expr}" = ${r.result.value}`); + else fail(`eval "${expr}"`, `Expected ${expected}, got ${r.result.value}`); + } catch (e) { + fail(`eval "${expr}"`, e.message); + } + } +} + +// ─── Test 4: step_over / step_into / resume ─── +async function testStepping() { + console.log('\n── Test 4: step_over / step_into / resume ──'); + if (!debuggerState.paused) { fail('stepping', 'Not paused'); return; } + + // step_over + try { + const prevLine = debuggerState.paused.callFrames[0].lineNumber; + debuggerState.paused = null; + await dbgSession.send('Debugger.stepOver'); + await waitForPause(3000); + const newLine = debuggerState.paused.callFrames[0].lineNumber; + pass(`step_over (line ${prevLine} → ${newLine})`); + } catch (e) { fail('step_over', e.message); } + + // step_over again + try { + const prevLine = debuggerState.paused.callFrames[0].lineNumber; + debuggerState.paused = null; + await dbgSession.send('Debugger.stepOver'); + await waitForPause(3000); + const newLine = debuggerState.paused.callFrames[0].lineNumber; + pass(`step_over #2 (line ${prevLine} → ${newLine})`); + } catch (e) { fail('step_over #2', e.message); } + + // resume + try { + debuggerState.paused = null; + await dbgSession.send('Debugger.resume'); + await waitForResume(3000); + pass('resume (execution continued)'); + } catch (e) { fail('resume', e.message); } +} + +// ─── Test 5: remove_breakpoint + debugger_disable ─── +async function testCleanup() { + console.log('\n── Test 5: remove_breakpoint + debugger_disable ──'); + + if (debuggerState.paused) { + await dbgSession.send('Debugger.resume').catch(() => {}); + await new Promise(r => setTimeout(r, 300)); + } + + for (const bpId of [...debuggerState.breakpoints.keys()]) { + try { + await dbgSession.send('Debugger.removeBreakpoint', { breakpointId: bpId }); + debuggerState.breakpoints.delete(bpId); + pass(`remove_breakpoint`); + } catch (e) { fail(`remove_breakpoint`, e.message); } + } + + if (debuggerState.breakpoints.size === 0) pass('list_breakpoints (empty)'); + else fail('list_breakpoints', `Still has ${debuggerState.breakpoints.size}`); + + try { + await dbgSession.send('Debugger.disable'); + debuggerState.enabled = false; + pass('debugger_disable'); + } catch (e) { fail('debugger_disable', e.message); } +} + +// ─── Test 6: get_script_source ─── +async function testGetScriptSource() { + console.log('\n── Test 6: get_script_source ──'); + + debuggerState.scripts.clear(); + await dbgSession.send('Debugger.enable'); + + await page.goto('data:text/html,'); + await new Promise(r => setTimeout(r, 500)); + + const scripts = [...debuggerState.scripts.values()]; + if (scripts.length === 0) { fail('get_script_source', 'No scripts'); return; } + + try { + const r = await dbgSession.send('Debugger.getScriptSource', { scriptId: scripts[0].scriptId }); + if (r.scriptSource) { + pass(`get_script_source (${r.scriptSource.length} chars)`); + log(`Preview: ${r.scriptSource.substring(0, 60)}`); + } else fail('get_script_source', 'Empty source'); + } catch (e) { fail('get_script_source', e.message); } + + await dbgSession.send('Debugger.disable').catch(() => {}); +} + +// ─── Main ─── +async function main() { + try { + await setup(); + await testEnableAndListScripts(); + await testBreakpointAndPause(); + await testEvaluateOnCallFrame(); + await testStepping(); + await testCleanup(); + await testGetScriptSource(); + } catch (e) { + console.error('\n💥 Unexpected error:', e); + } finally { + await teardown(); + } + console.log(`\n${'═'.repeat(40)}`); + console.log(`Results: ${passed} passed, ${failed} failed`); + console.log(`${'═'.repeat(40)}\n`); + process.exit(failed > 0 ? 1 : 0); +} + +main();