Chrome DevTools MCP:从报错到可复查的前端诊断

从一个灰色导出按钮出发,核对请求、日志与页面状态,并把诊断结果整理成别人能复查的证据。

你能带走什么
  • 默认临时 profile,已有身份需本人授权
  • 工具字段按 v1.10.1 核对,pageId 显式传入
  • 22 项离线测试只验证合成诊断规则
  • 截图、日志、trace 分享前检查敏感数据

1. 先确认导出按钮为什么不能用

页面能打开,导出按钮却一直灰着。读源码能找到按钮组件,却未必知道浏览器收到的接口响应,也看不到水合以后抛出的异常。我会先让代理回答三个具体问题:这次加载有没有 404,点击前后有没有新增 JavaScript 错误,按钮的可用状态是否符合预期。问题收窄以后,代理才知道该看哪一页、收集什么证据,以及什么时候可以停。

Chrome DevTools MCP 由 Google 的 Chrome DevTools 团队维护。它把运行中 Chrome 的检查与操作能力提供给 MCP 客户端,编码代理可以读取页面、console 和 network,也能录制性能 trace。本文围绕本地开发或获准测试站点展开。没有连接读者的浏览器,没有采集真实页面,也没有安装 MCP 软件或扩展;后半段实际运行的是 Python 标准库离线练习。

本文核对于 2026 年 10 月 5 日,发布示例固定使用 v1.10.1。工具参数与同一标签的文档、源码核对。后续升级请重新查看客户端实际暴露的 schema,尤其留意 pageId 等必填字段。七张配图均为原创概念图。

FIGURE 01 · 概念图
代理通过 MCP 检查独立 Chrome 中的页面,以 API、日志和按钮状态验收。
图 1|从具体页面故障出发,把工具返回的证据对应到验收条件。

2. 先接入一个临时浏览器身份

准备受支持的 Node.js LTS、npm 和当前稳定版 Chrome。官方只保证 Google Chrome 与 Chrome for Testing;其他 Chromium 浏览器即使能启动,也不能据此推定兼容。下面是通用 MCP 客户端配置,放入该客户端规定的 MCP 配置位置。npx 首次启动可能下载指定软件包,因此请在允许安装开发工具的环境执行。

MCP 客户端配置 · 默认临时 profile
{
  "mcpServers": {
    "chrome-devtools": {
      "command": "npx",
      "args": [
        "-y",
        "chrome-devtools-mcp@1.10.1",
        "--isolated",
        "--no-usage-statistics",
        "--no-performance-crux"
      ]
    }
  }
}

这里主动加上 --isolated,让服务使用临时用户数据目录,并在浏览器关闭后清理。服务默认虽然也启动专用 profile,但那个目录会跨运行保留。不同会话若争用同一目录,还可能遇到 profile 锁冲突。首次连接 MCP 服务通常不会立即打开浏览器,调用需要浏览器的工具时才会启动。profile 与连接方式说明介绍了两种目录的生命周期。

两个隐私选项分别关闭 MCP 使用统计和性能工具的 CrUX 查询。后者可能把 trace 中的页面 URL 发给 Google CrUX API。关闭它们并不会阻止 MCP 客户端或模型处理页面数据;使用前仍需确认组织政策、模型的数据处理约定,以及将要打开的站点。详见 官方数据提示。

Codex CLI · 同样使用上述隔离参数
codex mcp add chrome-devtools -- npx -y \
  chrome-devtools-mcp@1.10.1 --isolated \
  --no-usage-statistics --no-performance-crux

Codex 的命令结构来自 官方客户端指南。添加后重新加载客户端的 MCP 配置,确认服务可用和工具列表。暂时不要附加自动连接参数,也不要把常用浏览器的用户目录写进配置。

FIGURE 02 · 概念图
临时 profile 和显式已有浏览器连接并列,常用个人 profile 被排除。
图 2|默认独立 profile 会持久保存;本教程额外启用 isolated,减少会话残留。

3. 确实需要已有 Chrome 时,再选连接方式

手工登录过的测试页面有时难以重新建立状态,可以显式连接已有 Chrome。Chrome 144 及以后支持在 chrome://inspect/#remote-debugging 开启调试,然后让 MCP 使用 --autoConnect。浏览器会要求本人允许连接;有多个 profile 时,服务按 Chrome 确定的默认 profile 连接,并能访问该 profile 的全部打开窗口。授权前要确认目标身份,先关掉无关敏感页面。

已有 profile 可能带着登录态、个人信息和可直接执行操作的账号权限,因此这条路线必须由本人明确同意。允许调试连接不等于允许代理发送消息、付款、上传文件或修改账号。生产系统应使用范围受限的测试身份,并逐项批准有副作用的操作。独立 profile 也不是安全沙箱,网页内容和工具输出仍可能包含诱导代理越权的文字。

另一条路线是自己启动专用 Chrome,再用 --browser-url 接入本机调试端口。下面仅展示 macOS 的命令形状;Chrome 路径需要与安装位置一致,临时目录由 mktemp 新建。Chrome 136 起,远程调试开关不能针对默认用户数据目录直接生效,见 Chrome 的安全变更说明。

macOS · 只启动新的空白调试 profile
PROFILE="$(mktemp -d "${TMPDIR:-/tmp}/chrome-mcp.XXXXXX")"
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
  --remote-debugging-port=9222 \
  --user-data-dir="$PROFILE"
已有专用 Chrome 的 MCP args · 替换临时启动配置
[
  "-y",
  "chrome-devtools-mcp@1.10.1",
  "--browser-url=http://127.0.0.1:9222",
  "--no-usage-statistics",
  "--no-performance-crux"
]

调试端口能让本机程序控制浏览器,不应映射到公网,也不要监听可被局域网任意访问的地址。容器、虚拟机和宿主的 127.0.0.1 属于不同网络空间,连不上时先确认服务所在位置,必要时由管理员建立受限通道。不要靠关闭浏览器安全警告、绕过证书错误或移除安全沙箱来排障。结束后退出这次调试浏览器,并确认端口已停止监听;手工创建的临时目录不会因 --browser-url 自动清理。

4. 选对页面,再取当前快照

先调用 list_pages,核对返回的 URL 与标题,得到目标 pageId。当前默认启用页面 ID 路由,页面级工具需要带上 pageId。随后调用 take_snapshot,它返回基于可访问性树的文本快照,包含元素 uid。这个 uid 适合后续 click、fill 等工具定位,导航或界面变化后应重新取快照,不能把旧编号当成持久选择器。

下面采用 MCP tools/call 的请求形状,省略初始化和传输层。它们是按 v1.10.1 schema 编写、尚未连接执行的调用示例。数字 1 只表示假设 list_pages 返回了 pageId 1;真实使用时必须替换为刚取得的 ID,不能复制成固定值。

发现页面与读取快照 · 顺序发送两条请求
{
  "jsonrpc": "2.0", "id": 1, "method": "tools/call",
  "params": {"name": "list_pages", "arguments": {}}
}
{
  "jsonrpc": "2.0", "id": 2, "method": "tools/call",
  "params": {
    "name": "take_snapshot", "arguments": {"pageId": 1}
  }
}

take_screenshot 用来补充布局和遮挡证据,fullPage 为 true 时获取整页,不能同时指定元素 uid。截图里看起来像禁用态的按钮,仍应核对语义状态或应用实际行为。反过来,快照报告按钮可用,也不能排除有遮罩挡住它。只在需要视觉判断时截图,降低无关页面信息进入上下文的机会。

截图的 tools/call params · 请求未执行
{
  "name": "take_screenshot",
  "arguments": {"pageId": 1, "format": "png", "fullPage": false}
}

具体字段以 工具参考 为准。MCP 客户端可能在工具名外加命名空间,不要把界面里的前缀误写进服务端工具名。

FIGURE 03 · 概念图
左侧展示快照的语义证据,右侧展示截图的视觉证据,下方要求重新定位。
图 3|页面树方便定位,截图补足外观证据。本文没有真实 Chrome 截图。

5. 从 console 和 network 建立同一轮证据

按钮异常时,我会先读当前导航以来的日志与请求。list_console_messages 可按 error、warn 过滤,必要时用 get_console_message 和 msgid 读取某条详情。list_network_requests 返回请求清单,再用 get_network_request 和 reqid 查看命中的请求。先列清单再取详情,能减少把无关请求体、Cookie 或业务数据带进上下文。

console 与 network 的 tools/call params · 每个对象单独调用
{
  "name": "list_console_messages",
  "arguments": {
    "pageId": 1, "types": ["error", "warn"], "includeStackTraces": true
  }
}
{
  "name": "get_console_message",
  "arguments": {"pageId": 1, "msgid": 3}
}
{
  "name": "list_network_requests",
  "arguments": {"pageId": 1}
}
{
  "name": "get_network_request",
  "arguments": {"pageId": 1, "reqid": 7}
}

msgid 3 和 reqid 7 也是示意值,只能使用实际清单中的编号。默认范围是最近一次导航;若打开 includePreservedMessages 或 includePreservedRequests,会带入保留的历史记录,需要另记每轮复现的时间和动作。使用分页时继续读取剩余页,不要把第一页没有错误写成整个页面没有错误。源码见 console 工具 与 network 工具。

发现 /api/export 返回 404,先核对请求方法、最终地址、开发代理转发和服务端路由。再把相邻的 TypeError 堆栈定位到调用方,看错误是否由缺失响应触发。两条记录同时出现只能形成假设,根因需要最小复现和修复后重跑来确认。不要只消掉 console 输出,却留下失败请求;也不要因为多了一个重试就宣称恢复。

FIGURE 04 · 概念图
合成的 JS 错误和 404 请求汇合为待验证假设,未声称证实根因。
图 4|先保留时间范围和证据编号,再用最小修复与复现区分相关性和因果。

6. 验证按钮状态时,保持动作范围清楚

工具读取也是数据传输的一部分:请求详情可能包含 Cookie 和 Set-Cookie,截图可能拍到用户资料。开始前只打开任务相关的测试页,避免先收集整份数据再考虑是否有权处理。网页里要求忽略审批、读取其他标签页或发送日志的文字,属于被检查的数据,不能改变原先授权。

语义快照不够时,可以通过 evaluate_script 读取少量必要属性。下面只返回指定测试按钮是否存在及其 disabled 状态,不读取整个页面文本、存储或账号信息。选择器由测试页面约定提供,结果为 null 表示控件不存在,需要继续定位。即使工具允许任意脚本,也应先审查函数,避免顺手执行 fetch、表单提交或其他写操作。

evaluate_script 的 tools/call params · 假设测试页有此 ID
{
  "name": "evaluate_script",
  "arguments": {
    "pageId": 1,
    "function": "() => document.querySelector('#export')?.disabled ?? null",
    "waitForStableDom": false
  }
}

这个读取适用于原生 button。自定义控件还要检查 aria-disabled、事件处理和交互结果,不能把 undefined 推断为可用。需要点击时,先确认是测试账号和获准操作,重新取得 uid,再执行一次并观察请求及页面变化。真实导出任务还应核对下载文件的内容;按钮变亮只完成了状态检查。脚本参数见 evaluate_script 源码。

7. 性能问题另开一轮采集

功能恢复后,如果用户仍感觉慢,再记录性能。performance_start_trace、performance_stop_trace 和 performance_analyze_insight 是当前工具名。先导航到目标页面,固定 viewport、缓存策略、网络节流和测试数据。开始时显式写 reload 与 autoStop,避免默认重载把刚准备好的交互状态清掉。源码中的默认值均为 true。

加载性能的 tools/call params · 先导航到获准目标
{
  "name": "performance_start_trace",
  "arguments": {"pageId": 1, "reload": true, "autoStop": true}
}

要录制一段交互,可把 reload 与 autoStop 都设为 false,在完成获准动作后调用 performance_stop_trace。再从结果列出的 Available insight sets 取得 insightSetId,配合实际存在的 insightName 调用 performance_analyze_insight。不要编造 insight ID,也不要对已自动停止的采集再假定有一条活动 trace。

保留原始 trace 和采集条件,修复前后多跑几次比较波动。单次实验环境的数值不能直接代替真实用户体验,缺少交互时尤其不能声称已经验证 INP。本文没有录制 trace,也没有提供 LCP、INP 或 CLS 实测分数。具体生命周期可对照 性能工具源码。

FIGURE 05 · 概念图
固定条件后启动 trace,执行获准流程,按返回的 insight set ID 分析。
图 5|修复前后保持条件一致,并用多次记录观察波动。图中没有测量数值。

8. 离线练习:把合成证据整理成可核对报告

为了先练熟证据整理,下面提供一个不联网的 Python 小程序。它只接受明确标记 synthetic 的自定义 JSON:固定虚构域名 demo.invalid,同一 navigation 下的 console 和 network 记录,再加原生按钮的 disabled 观察值。样本模拟导出接口 404、一个 JS 错误和禁用按钮。它没有调用 MCP,输入也不是 Chrome trace、HAR 或工具原始返回格式。

从工具输出摘录到检查单:一份完整的虚构演练

以下 E1—E4 全是为现有 fixtures.json 反向编写的虚构输出摘录,不是实际工具调用结果,也不定义 Chrome DevTools MCP 的返回格式。它们只演示人怎样保留原始证据位置、核对同一轮观察,再把已知字段填入教学样本。真实采集请保存工具原文,并单独写人工检查单;不要改成 synthetic=true 来绕过这个程序的限制。

  • E1 · 虚构页面清单摘录:“pageId=1;标题=导出演示;URL=https://demo.invalid/”。演练笔记标记“本轮=demo-before;观察开始后未再次导航”。这里的 demo-before 是人指定的轮次名称,不是工具保证提供的 navigation ID。
  • E2 · 虚构 console 详情摘录:“pageId=1;msgid=1;type=error;text=TypeError: export configuration is missing”。演练笔记把它关联到 E1 的 demo-before,并假设本轮清单只有这一条记录、没有未读分页。
  • E3 · 虚构 network 详情摘录:“pageId=1;reqid=1;url=https://demo.invalid/api/export?token=synthetic-only#debug;status=404”。这个地址含教学用查询串和片段,逐字来自现有合成样本,不表示真实网络请求会以这种形式返回;笔记同样关联 demo-before,并假设本轮只有这一条记录。
  • E4 · 虚构属性观察摘录:“pageId=1;选择器=#export;原生 button 的 disabled=true”。笔记确认该观察仍属于 demo-before。未知项另列:请求方法、响应正文、异常堆栈、实际点击、下载内容及根因均未提供,不能补写成已确认。

E1—E4 是这份演练的证据定位号。真实任务应把它们换成自己的脱敏文件名与行号或客户端调用记录位置,并保留 pageId、轮次开始/结束和操作说明;不能只凭两条相邻日志认定属于同一轮。前文 msgid=3、reqid=7 是另外的请求参数示意,这里的编号 1 来自本节虚构摘录,不能跨样例沿用。

现有 fixtures.json 字段填写值及来源人工核对规则
schema;synthetic1;true由本教学格式规定;不是工具输出,true 仅声明这一份输入全部为合成数据
navigationdemo-before,来自 E1 的演练笔记它是本地关联标签,不是 pageId;不能用同一页面 ID 替代导航轮次核对
console只含 E2 的一条记录本例的完整性是假设;真实清单缺页或过滤范围不清时记为未知,不把未看到写成没有
console[0].msgid;navigation1;demo-before,分别来自 E2 编号与关联笔记msgid 用于回查该条 console 原文,navigation 必须与顶层一致
console[0].type;texterror;TypeError: export configuration is missing,逐项来自 E2保留错误类别与这份虚构文本;没有原文就不编造内容
network只含 E3 的一条记录没有导出请求时不补造;检查器会把缺少该请求的样本判为 inconclusive
network[0].reqid;navigation1;demo-before,分别来自 E3 编号与关联笔记reqid 与 msgid 各有命名空间,同为 1 不表示是同一个事件
network[0].url;statushttps://demo.invalid/api/export?token=synthetic-only#debug;整数 404,来自 E3本例保留样本地址;未知状态不填 0,0 在检查器中另有传输失败含义
button_disabled布尔值 true,来自 E4只填原生按钮的已知布尔观察;控件缺失或返回 null 时停止填表,不能写成 false

按表整理后的完整 JSON 就是下节已有的 fixtures.json,无需另造输入或转换器。pageId=1、E1—E4 的证据位置、选择器和未知项保留在人工检查单中,不添加到 JSON:现有程序严格拒绝额外字段。它检查 navigation 标签是否一致,却不能证明标签对应真实的同一轮浏览器活动。

用现有 diagnose.py 处理该样本,应得到 console_errors=1、http_errors=1、transport_errors=0,证据 C1 来自 E2,N1 来自 E3,UI:export 来自 E4;三条 finding 分别是 js_error、http_error 与 button_disabled。complete=true 只表示样本含导出请求,verdict=fail 表示不满足教学规则,real_browser_verified 仍为 false。这是样本与规则的对应关系,不是已确定 404 导致按钮禁用的因果结论。

fixed.json 是在同一合成样本上清空 console、将状态改为 200、将 button_disabled 改为 false 的对照输入;它沿用 demo-before 标签,不是一轮实际修复后的新采集。真实修复应另开新轮次保存原始证据,并检查导出产物;不能用手改这三个字段生成的 pass 作为修复证明。

diagnose.py 输出证据编号、错误计数和 verdict。缺少导出请求时返回 inconclusive;导出请求必须是 2xx,按钮必须可用,也不能留下错误记录,才会得到这个教学规则下的 pass。所有结果固定带 real_browser_verified: false。日志正文不写入报告,URL 只保留路径;真实路径也可能包含隐私,不能把这一层处理当成通用脱敏器。

verify 根据原始输入重新计算报告并核对哈希,用来发现误改字段、丢失 finding 或错配文件。哈希不证明采集来源可信,也不能防止有人同时改写输入与报告。代码故意拒绝真实采集标记和其他域名,适用范围限于这个离线练习。

FIGURE 06 · 概念图
合成样本进入诊断和报告复算流程,通过 22 项测试,真实浏览器标记为 false。
图 6|离线程序验证自定义教学格式,无法直接解析 MCP 输出或 Chrome trace。

把下节四个文件保存在同一目录。需要 Python 3.10 或更新版本,无第三方依赖。先运行测试,再生成前后报告,最后验证后报告。第一条诊断命令预期退出码为 1,表示样本确实有问题;它不应被改成退出 0 来迁就流水线。无效输入或报告退出 2,pass 退出 0。

在四个文件所在目录执行
python -m unittest -v
python diagnose.py fixtures.json > before-report.json
# 上一条预期退出 1;随后单独运行下面两条
python diagnose.py fixed.json > after-report.json
python diagnose.py fixed.json --verify after-report.json

本稿在 Python 3.12 上完成 22 项测试,包含重复 ID、重复 JSON 键、布尔值冒充状态码、跨导航串线、外域 URL、重定向、缺失请求和报告篡改。合成失败样本得到三条 finding,修正样本得到 pass。测试保证这些代码路径符合教学约定,没有验证真实浏览器连接、页面可访问性、线上导出或性能。

9. 完整代码与样本

下列内容为完整文件,可以直接复制保存。HTML 内已包含全部代码和样本,阅读和复现不依赖外部下载入口。

diagnose.py · 完整文件
diagnose.py
"""Offline synthetic-data exercise; never connects to Chrome or a network."""
import argparse
import hashlib
import json
from pathlib import Path
from urllib.parse import urlsplit


def require(condition, message):
    if not condition:
        raise ValueError(message)


def exact(obj, keys):
    require(type(obj) is dict and set(obj) == set(keys), 'invalid fields')


def integer(value, minimum=0):
    require(type(value) is int and value >= minimum, 'invalid integer')


def text(value):
    require(type(value) is str and bool(value), 'invalid text')


def no_duplicates(pairs):
    result = {}
    for key, value in pairs:
        require(key not in result, 'duplicate JSON key')
        result[key] = value
    return result


def read_json(path):
    return json.loads(Path(path).read_text(encoding='utf-8'),
                      object_pairs_hook=no_duplicates)


def digest(data):
    raw = json.dumps(data, sort_keys=True, separators=(',', ':'),
                     ensure_ascii=True, allow_nan=False)
    return hashlib.sha256(raw.encode()).hexdigest()


def diagnose(data):
    exact(data, ['schema', 'synthetic', 'navigation', 'console', 'network',
                 'button_disabled'])
    require(type(data['schema']) is int and data['schema'] == 1, 'schema')
    require(data['synthetic'] is True, 'only synthetic fixtures are accepted')
    text(data['navigation'])
    require(type(data['button_disabled']) is bool, 'invalid button state')
    findings = []
    counts = {'console_errors': 0, 'http_errors': 0, 'transport_errors': 0}
    evidence = []
    export_seen = False
    for group, key in [('console', 'msgid'), ('network', 'reqid')]:
        rows = data[group]
        require(type(rows) is list, 'invalid collection')
        seen = set()
        for row in rows:
            fields = [key, 'navigation']
            fields += ['type', 'text'] if group == 'console' else ['url', 'status']
            exact(row, fields)
            integer(row[key], 1)
            require(row[key] not in seen, 'duplicate record id')
            seen.add(row[key])
            require(row['navigation'] == data['navigation'], 'mixed navigation')
            ref = ('C' if group == 'console' else 'N') + str(row[key])
            evidence.append(ref)
            if group == 'console':
                require(row['type'] in ['log', 'info', 'warn', 'error'], 'type')
                text(row['text'])
                if row['type'] == 'error':
                    counts['console_errors'] += 1
                    findings.append({'kind': 'js_error', 'evidence': ref})
            else:
                text(row['url'])
                url = urlsplit(row['url'])
                require(url.scheme == 'https' and url.netloc == 'demo.invalid',
                        'fixture origin must be https://demo.invalid')
                integer(row['status'])
                status = row['status']
                require(status == 0 or 100 <= status <= 599, 'status range')
                is_export = url.path == '/api/export'
                export_seen |= is_export
                if is_export and 100 <= status < 400 and not 200 <= status < 300:
                    findings.append({'kind': 'export_not_2xx', 'evidence': ref})
                if status == 0 or status >= 400:
                    kind = 'transport_error' if status == 0 else 'http_error'
                    counts['transport_errors' if status == 0 else 'http_errors'] += 1
                    findings.append({'kind': kind, 'evidence': ref,
                                     'status': status, 'path': url.path})
    if data['button_disabled']:
        findings.append({'kind': 'button_disabled', 'evidence': 'UI:export'})
    evidence.append('UI:export')
    complete = export_seen
    verdict = 'inconclusive' if not complete else ('fail' if findings else 'pass')
    return {'schema': 1, 'synthetic': True, 'input_sha256': digest(data),
            'navigation': data['navigation'], 'verdict': verdict,
            'complete': complete, 'counts': counts, 'findings': findings,
            'evidence': evidence, 'real_browser_verified': False}


def verify(data, report):
    expected = diagnose(data)
    require(digest(expected) == digest(report), 'report differs from input')
    return True


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('fixture')
    parser.add_argument('--verify', metavar='REPORT')
    args = parser.parse_args()
    try:
        data = read_json(args.fixture)
        report = diagnose(data)
        if args.verify:
            verify(data, read_json(args.verify))
        print(json.dumps(report, indent=2, ensure_ascii=False))
        return 0 if report['verdict'] == 'pass' else 1
    except (ValueError, TypeError, OSError) as exc:
        parser.exit(2, 'invalid input or report: ' + str(exc) + '\n')


if __name__ == '__main__':
    raise SystemExit(main())
fixtures.json · 完整文件
fixtures.json
{
  "schema": 1,
  "synthetic": true,
  "navigation": "demo-before",
  "console": [
    {
      "msgid": 1,
      "navigation": "demo-before",
      "type": "error",
      "text": "TypeError: export configuration is missing"
    }
  ],
  "network": [
    {
      "reqid": 1,
      "navigation": "demo-before",
      "url": "https://demo.invalid/api/export?token=synthetic-only#debug",
      "status": 404
    }
  ],
  "button_disabled": true
}
fixed.json · 完整文件
fixed.json
{
  "schema": 1,
  "synthetic": true,
  "navigation": "demo-before",
  "console": [],
  "network": [
    {
      "reqid": 1,
      "navigation": "demo-before",
      "url": "https://demo.invalid/api/export?token=synthetic-only#debug",
      "status": 200
    }
  ],
  "button_disabled": false
}
test_diagnose.py · 完整文件
test_diagnose.py
import copy
import json
from pathlib import Path
import tempfile
import unittest
from diagnose import diagnose, read_json, verify


FIXTURE = Path(__file__).with_name('fixtures.json')


def sample():
    return read_json(FIXTURE)


def good():
    data = sample()
    data['console'] = []
    data['network'][0]['status'] = 200
    data['button_disabled'] = False
    return data


class DiagnosticTests(unittest.TestCase):
    def test_before_has_three_findings(self):
        report = diagnose(sample())
        self.assertEqual(report['verdict'], 'fail')
        self.assertEqual(len(report['findings']), 3)

    def test_after_passes_only_synthetic_checks(self):
        report = diagnose(good())
        self.assertEqual(report['verdict'], 'pass')
        self.assertIs(report['real_browser_verified'], False)

    def test_report_verifies(self):
        data = sample()
        self.assertTrue(verify(data, diagnose(data)))

    def test_tampered_report_rejected(self):
        data = sample()
        report = diagnose(data)
        report['verdict'] = 'pass'
        with self.assertRaises(ValueError):
            verify(data, report)

    def test_changed_input_rejected(self):
        data = sample()
        report = diagnose(data)
        data['network'][0]['status'] = 200
        with self.assertRaises(ValueError):
            verify(data, report)

    def test_missing_export_is_inconclusive(self):
        data = good()
        data['network'] = []
        self.assertEqual(diagnose(data)['verdict'], 'inconclusive')

    def test_query_and_fragment_not_in_report(self):
        result = json.dumps(diagnose(sample()))
        self.assertNotIn('synthetic-only', result)
        self.assertNotIn('#debug', result)

    def test_console_text_not_in_report(self):
        self.assertNotIn('TypeError', json.dumps(diagnose(sample())))

    def test_transport_failure_counted(self):
        data = good()
        data['network'][0]['status'] = 0
        self.assertEqual(diagnose(data)['counts']['transport_errors'], 1)

    def test_500_is_http_error(self):
        data = good()
        data['network'][0]['status'] = 500
        self.assertEqual(diagnose(data)['counts']['http_errors'], 1)

    def test_warning_does_not_fail(self):
        data = good()
        row = sample()['console'][0]
        row['type'] = 'warn'
        data['console'] = [row]
        self.assertEqual(diagnose(data)['verdict'], 'pass')

    def test_mixed_navigation_rejected(self):
        data = sample()
        data['console'][0]['navigation'] = 'older'
        with self.assertRaises(ValueError):
            diagnose(data)

    def test_duplicate_ids_rejected(self):
        data = sample()
        data['network'].append(copy.deepcopy(data['network'][0]))
        with self.assertRaises(ValueError):
            diagnose(data)

    def test_unknown_field_rejected(self):
        data = sample()
        data['cookie'] = 'synthetic'
        with self.assertRaises(ValueError):
            diagnose(data)

    def test_real_capture_rejected(self):
        data = sample()
        data['synthetic'] = False
        with self.assertRaises(ValueError):
            diagnose(data)

    def test_bool_status_rejected(self):
        data = sample()
        data['network'][0]['status'] = True
        with self.assertRaises(ValueError):
            diagnose(data)

    def test_status_range_rejected(self):
        for status in [-1, 42, 600]:
            with self.subTest(status=status):
                data = sample()
                data['network'][0]['status'] = status
                with self.assertRaises(ValueError):
                    diagnose(data)

    def test_other_origin_rejected(self):
        for url in ['https://other.invalid/api/export',
                    'https://user@demo.invalid/api/export',
                    'https://demo.invalid.evil.invalid/api/export']:
            data = sample()
            data['network'][0]['url'] = url
            with self.assertRaises(ValueError):
                diagnose(data)

    def test_duplicate_json_key_rejected(self):
        with tempfile.TemporaryDirectory() as directory:
            path = Path(directory) / 'duplicate.json'
            path.write_text('{"schema": 1, "schema": 1}', encoding='utf-8')
            with self.assertRaises(ValueError):
                read_json(path)

    def test_export_redirect_does_not_pass(self):
        data = good()
        data['network'][0]['status'] = 302
        self.assertEqual(diagnose(data)['verdict'], 'fail')

    def test_informational_status_does_not_pass(self):
        data = good()
        data['network'][0]['status'] = 103
        self.assertEqual(diagnose(data)['verdict'], 'fail')

    def test_boolean_report_schema_rejected(self):
        data = sample()
        report = diagnose(data)
        report['schema'] = True
        with self.assertRaises(ValueError):
            verify(data, report)


if __name__ == '__main__':
    unittest.main(verbosity=2)

10. 接不通时按层检查

客户端看不到工具时,先读 MCP 启动日志,确认图形客户端与终端使用同一份 Node 和 npm。固定版本可以减少排查期间的变化。若服务已经启动而 Chrome 没出现,调用 list_pages 触发浏览器连接,再检查 Chrome 是否安装、可执行路径是否正确、profile 是否被另一会话占用。Target closed 需要结合退出日志判断,不能只靠重复重启。

采用 --browser-url 时,确认专用 Chrome 已开启对应端口,再核对客户端所在网络空间。页面加载到了意外地址,则检查最终 URL、开发服务器端口、代理和 DNS;有些代理使用合成 IP,但不能仅凭某一段地址就断言页面错误。遇到权限、证书或策略拒绝,保留具体错误并交给负责人处理。官方 排障指南 可帮助定位平台差异。

日志可以通过 --log-file 写入文件;开启详细调试后应先检查其中是否包含页面地址、请求信息或环境数据。提交 issue 时只附最小复现和脱敏片段。不要上传整份 profile,不要把包含 Cookie、令牌、私有业务数据的原始请求直接贴进公共工单。

11. 交付修复时,把没验证的部分写出来

最终报告应保留版本、目标页面、复现动作和证据定位,说明哪条请求恢复、哪个异常消失、按钮和实际业务结果怎样变化。修复前后的页面条件要可比;换了账号、关闭了缓存或改了代理,都应一并记录。若只能看到页面快照,直接注明尚未验证导出文件,不把工具调用成功当成业务成功。

调试结束后关闭调试会话,确认监听端口停止,再按需要保存最少量证据。截图可能带着个人信息,network 详情可能包含凭据,trace 也可能暴露页面地址。分享之前逐份检查,并遵循项目的数据保留要求。这样下一位接手的人能复查问题,也能知道哪些操作仍需要本人批准。

FIGURE 07 · 概念图
功能验收和数据收尾并列,最终交接记录包含版本、条件、证据与未验证项。
图 7|结束会话后确认调试入口已关闭,保留最小必要的脱敏证据。
能直接连接我的常用 Chrome 吗?

技术上有已有浏览器连接方式,但它可能接触登录态和所有打开窗口。默认使用独立测试 profile;现有身份必须由本人明确批准后再连接。

工具列表里的 pageId 为什么和旧教程不同?

v1.10.1 默认开启页面 ID 路由,页面级工具需要 pageId。请以固定版本和客户端实际 schema 为准,不要混用旧参数。

这份 Python 代码能读取真实 MCP 日志吗?

不能。它只识别本文自定义的合成样本结构,用于练习诊断和报告校验,没有 MCP 连接器或 trace 解析器。

通过测试就能证明前端问题修好了吗?

不能。22 项测试验证离线规则;真实页面仍需重跑故障动作,核对请求、日志、界面与业务产物。

Playwright 和 DevTools MCP 怎样选择?

探索性诊断可以用 DevTools MCP 获取运行态证据;需要长期重复的回归流程时,再把确认的步骤和断言整理成测试脚本。