返回博客

Playwright CDPSession 类型定义是 CDP 协议的子集,运行正常却 tsc 报错

写浏览器自动化脚本时接触到了 Playwright 的 CDPSession,用来调一些高级 CDP 命令——screencast 推流、拦截文件选择弹窗、IME 文本输入。代码在浏览器里跑得好好的,截帧数据也能拿到,但 tsc --noEmit 一跑就炸:

error TS2345: Argument of type '"Page.startScreencast"' is not assignable
  to parameter of type 'keyof CommandParameters'.
error TS2345: Argument of type '"Page.handleFileChooser"' is not assignable
  to parameter of type 'keyof CommandParameters'.

反直觉:运行时完全正常,只有编译器报错

现象

以下这批 CDP 命令,在 Chromium 里都能正确响应,但全部触发 TS2345:

| 命令 | 用途 | |------|------| | Page.startScreencast / Page.stopScreencast | viewport JPEG 推流 | | Page.screencastFrameAck | 帧确认 | | Page.setInterceptFileChooserDialog | 拦截 <input type=file> | | Page.handleFileChooser | 给被拦截的 chooser 回填路径 | | Emulation.setDeviceMetricsOverride | 动态修改 viewport / DPR | | Input.insertText | IME 路径写文字(中文输入法等) |

cdp.on('Page.screencastFrame', ...) 也一样,事件名同样不在 typed 列表里。

根因

node_modules/playwright/types/protocol.d.tsCDPSession.send 的签名是:

send<T extends keyof CommandParameters>(
  method: T,
  params?: CommandParameters[T]
): Promise<CommandReturnValues[T]>

CommandParameters 是一个精选清单——Playwright 团队只把他们自己内部用到的 CDP 命令加进去了,并不是 Chrome DevTools Protocol 的全集。这个设计让 IDE 有自动补全,但代价是大量运行时有效的命令在类型层面"不存在"。

坑:CDPSession 的类型是 Playwright 的内部子集,不是完整 CDP 协议

Chrome DevTools Protocol 官方文档里有几百个命令,但 Playwright 的 CommandParameters 只收录了其中一部分。缺失的命令在 Chromium 里能正常执行,却会让 TypeScript 编译失败,让人以为是 API 不支持,实际上只是类型没有覆盖。

正解

方案 A(推荐):在函数顶部做一次 AnyCdp 转型

import type { CDPSession } from 'playwright'
 
type AnyCdp = {
  send: (method: string, params?: any) => Promise<any>
  on: (event: string, cb: (e: any) => void) => void
}
 
export async function setupScreencast(cdp: CDPSession, onFrame: (data: string) => void) {
  const c = cdp as unknown as AnyCdp          // 只转型一次
 
  await c.send('Page.startScreencast', {
    format: 'jpeg',
    quality: 70,
    maxWidth: 1280,
    maxHeight: 720,
    everyNthFrame: 2,
  })
 
  c.on('Page.screencastFrame', (frame) => {
    onFrame(frame.data)
    c.send('Page.screencastFrameAck', { sessionId: frame.sessionId }).catch(() => {})
  })
}

要点:

  • as unknown as AnyCdp——双层 cast,绕过 TypeScript 对不兼容类型的检查
  • 只在函数入口 cast 一次赋给局部变量 c,后续所有 send/on 都用 c,不要在每个调用处分别 cast
  • 对外暴露的函数参数仍是 CDPSession,调用方不受影响
  • 返回值是 Promise<any>,需要特定字段时再按实际 CDP 文档做局部类型断言
正解:函数顶部一次性 as unknown as,对外接口保持 CDPSession 类型

封装成独立函数,入参仍是 CDPSession,函数内部 const c = cdp as unknown as AnyCdp 做一次转型,之后所有 CDP 调用都走 c。既消灭了 TS2345,又把逃逸范围控制到最小,lint 也不会抱怨。

方案 B:每处单独 as any(不推荐)

await (cdp.send as any)('Page.startScreencast', { ... })

每个 call site 都要写,散在各处,eslint 容易告警,IDE 跳转也丢失。

方案 C:扩展 Commands 接口(重量级)

// types/cdp-extras.d.ts
declare module 'playwright' {
  interface Commands {
    'Page.startScreencast': { params: { format: 'jpeg' | 'png'; quality?: number } }
    // ...
  }
}

能拿回完整类型提示,但要手抄 CDP protocol.json,升级 Playwright 时可能需要维护。只有项目大量直击底层 CDP 时才值得。

额外的运行时坑

  1. screencast 在页面导航后会断:主框架 framenavigated 之后 Page.startScreencast 的订阅会失效,需要重新 send 开启。Playwright 不会自动重启推流。

  2. Page.handleFileChooser 必须配合 setInterceptFileChooserDialog: enabled=true:如果没有先开拦截,handleFileChooser 静默无效,不报错,路径也填不进去。

  3. Input.insertText 走 IME 路径:不会触发 keydown/keyup 事件。需要按键事件的场景仍要用 Input.dispatchKeyEvent

  4. Emulation.setDeviceMetricsOverride 是动态修改 viewport 的唯一途径:通过 browser.newContext({ viewport }) 只能在创建时设置,已开 context 想改尺寸只能走 CDP。

验证

npx tsc --noEmit
# 应输出 0 errors

一句话外卖

Playwright 的 CDPSession.send 类型是内部精选子集,不是 CDP 全集——编译报错不代表命令无效,用 as unknown as 一次性转型就够,不要怀疑协议本身。