无头 Chrome 的 --screenshot 能看页面,但验证交互(点了按钮之后发生什么)需要真正的驱动能力。Playwright/Puppeteer 固然好,但如果你只是想快速点一下、读个状态,Node 24 内置的 WebSocket + Chrome 自带的 CDP 就够了——30 行,零依赖。这个脚本在本博客的 bug 排查里立过大功,这里完整交出。
启动与连接
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
--headless=new --disable-gpu \
--remote-debugging-port=9222 --user-data-dir=/tmp/chrome-test \
about:blank &
完整脚本(可直接保存运行)
// cdp-test.js — 用法: node cdp-test.js https://your-page
const http = require('http');
const get = (url) => new Promise((res, rej) =>
http.get(url, r => { let d = ''; r.on('data', c => d += c);
r.on('end', () => res(JSON.parse(d))); }).on('error', rej));
(async () => {
// 1. 找到调试目标页,建立 WebSocket
const targets = await get('http://127.0.0.1:9222/json/list');
const ws = new WebSocket(targets.find(t => t.type === 'page').webSocketDebuggerUrl);
let id = 0; const pending = {};
const send = (method, params = {}) => new Promise(res => {
const i = ++id; pending[i] = res;
ws.send(JSON.stringify({ id: i, method, params }));
});
ws.onmessage = e => {
const m = JSON.parse(e.data);
if (m.id && pending[m.id]) pending[m.id](m.result);
};
await new Promise(r => ws.onopen = r);
// 2. 打开被测页面
await send('Page.navigate', { url: process.argv[2] });
await new Promise(r => setTimeout(r, 3000)); // 等渲染
// 3. 在页面里执行任意 JS(注意 awaitPromise 才能拿到 async 结果)
const result = await send('Runtime.evaluate', {
returnByValue: true, awaitPromise: true,
expression: `(async () => {
const d = document.querySelector('details.menu-item-has-children');
d.querySelector('summary')
.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await new Promise(r => setTimeout(r, 300));
const rect = d.querySelector('.submenu').getBoundingClientRect();
const hit = document.elementFromPoint(
rect.x + rect.width / 2, rect.y + 40); // 命中测试
return { open: d.open, w: Math.round(rect.width),
hit: hit ? hit.className : null };
})()`
});
console.log(JSON.stringify(result.result.value));
// 4. (可选)截图存证
const shot = await send('Page.captureScreenshot', { format: 'png' });
require('fs').writeFileSync('/tmp/cdp-shot.png',
Buffer.from(shot.data, 'base64'));
process.exit(0);
})();
三个关键细节(都是踩过的)
1. awaitPromise: true。 传了 async IIFE 却拿到空对象 {}?因为默认不等待 Promise。这个参数是 CDP 新手第一大坑。
2. 合成点击用 dispatchEvent,不要 el.click()。 el.click() 不冒泡也不触发部分监听器;new MouseEvent('click', {bubbles: true, cancelable: true}) 更接近真实用户。想完全拟真还可以用 Input.dispatchMouseEvent(CDP 的输入事件,连坐标都真实)。
3. 断言"真的看得见"用命中测试,不要只看 rect。 getBoundingClientRect() 有值不代表没被裁剪/遮挡——document.elementFromPoint(x, y) 返回坐标处最顶层元素,返回的是你的目标才算真的可见。本博客"下拉菜单被 overflow 裁剪"的 bug,最后就是靠命中测试实锤的。
模拟手机:一行切换视口
await send('Emulation.setDeviceMetricsOverride',
{ width: 390, height: 844, deviceScaleFactor: 2, mobile: true });
之后的所有截图和断言都在手机视口下进行。大量"桌面正常、手机异常"的 bug,只需要这一行就能复现。
它适合什么、不适合什么
适合:一次性验证、CI 里的冒烟断言、bug 复现脚本——快、零依赖、随手改。 不适合:成体系的 E2E 套件(选择器等待、重试、报告,这些 Playwright 做得更好)。
判断标准:如果测试脚本本身超过 100 行,该换 Puppeteer/Playwright 了;如果只是"点一下看看",这 30 行就是最优解。