无头 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 行就是最优解。