@epoint-playwright/cdp-recorder 0.0.0-stage → 2.0.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +199 -2
  2. package/dist/cdp/client.d.ts +46 -0
  3. package/dist/cdp/client.js +137 -0
  4. package/dist/cdp/dom.d.ts +40 -0
  5. package/dist/cdp/dom.js +112 -0
  6. package/dist/cli/args.d.ts +5 -0
  7. package/dist/cli/args.js +12 -0
  8. package/dist/cli/doctor.d.ts +2 -0
  9. package/dist/cli/doctor.js +67 -0
  10. package/dist/cli/index.d.ts +11 -0
  11. package/dist/cli/index.js +36 -0
  12. package/dist/cli/probe/act.d.ts +8 -0
  13. package/dist/cli/probe/act.js +347 -0
  14. package/dist/cli/probe/facts.d.ts +15 -0
  15. package/dist/cli/probe/facts.js +104 -0
  16. package/dist/cli/probe/frames.d.ts +40 -0
  17. package/dist/cli/probe/frames.js +102 -0
  18. package/dist/cli/probe/index.d.ts +2 -0
  19. package/dist/cli/probe/index.js +193 -0
  20. package/dist/cli/probe/required.d.ts +23 -0
  21. package/dist/cli/probe/required.js +53 -0
  22. package/dist/cli/probe/snapshot.d.ts +70 -0
  23. package/dist/cli/probe/snapshot.js +287 -0
  24. package/dist/cli/record.d.ts +2 -0
  25. package/dist/cli/record.js +135 -0
  26. package/dist/flows/form.d.ts +61 -0
  27. package/dist/flows/form.js +223 -0
  28. package/dist/flows/grid.d.ts +6 -0
  29. package/dist/flows/grid.js +24 -0
  30. package/dist/flows/menu.d.ts +23 -0
  31. package/dist/flows/menu.js +228 -0
  32. package/dist/flows/session.d.ts +65 -0
  33. package/dist/flows/session.js +169 -0
  34. package/dist/flows/steps.d.ts +16 -0
  35. package/dist/flows/steps.js +36 -0
  36. package/dist/index.d.ts +18 -0
  37. package/dist/index.js +16 -0
  38. package/dist/miniui/apply.d.ts +20 -0
  39. package/dist/miniui/apply.js +109 -0
  40. package/dist/miniui/context.d.ts +27 -0
  41. package/dist/miniui/context.js +78 -0
  42. package/dist/miniui/controls.d.ts +57 -0
  43. package/dist/miniui/controls.js +186 -0
  44. package/dist/miniui/date-picker.d.ts +18 -0
  45. package/dist/miniui/date-picker.js +121 -0
  46. package/dist/miniui/dialog.d.ts +23 -0
  47. package/dist/miniui/dialog.js +107 -0
  48. package/dist/miniui/dom-expr.d.ts +7 -0
  49. package/dist/miniui/dom-expr.js +12 -0
  50. package/dist/miniui/index.d.ts +24 -0
  51. package/dist/miniui/index.js +18 -0
  52. package/dist/miniui/ops-input.d.ts +47 -0
  53. package/dist/miniui/ops-input.js +305 -0
  54. package/dist/miniui/ops-pick.d.ts +19 -0
  55. package/dist/miniui/ops-pick.js +98 -0
  56. package/dist/miniui/pointer.d.ts +32 -0
  57. package/dist/miniui/pointer.js +150 -0
  58. package/dist/miniui/read.d.ts +40 -0
  59. package/dist/miniui/read.js +129 -0
  60. package/dist/miniui/tree/index.d.ts +16 -0
  61. package/dist/miniui/tree/index.js +15 -0
  62. package/dist/miniui/tree/model.d.ts +99 -0
  63. package/dist/miniui/tree/model.js +105 -0
  64. package/dist/miniui/tree/ops.d.ts +33 -0
  65. package/dist/miniui/tree/ops.js +99 -0
  66. package/dist/miniui/tree/rows.d.ts +31 -0
  67. package/dist/miniui/tree/rows.js +152 -0
  68. package/dist/miniui/tree/select.d.ts +38 -0
  69. package/dist/miniui/tree/select.js +163 -0
  70. package/dist/miniui/wait.d.ts +72 -0
  71. package/dist/miniui/wait.js +169 -0
  72. package/dist/recorder/constraints.d.ts +34 -0
  73. package/dist/recorder/constraints.js +72 -0
  74. package/dist/recorder/failure-snapshot.d.ts +20 -0
  75. package/dist/recorder/failure-snapshot.js +81 -0
  76. package/dist/recorder/postprocess.d.ts +13 -0
  77. package/dist/recorder/postprocess.js +207 -0
  78. package/dist/recorder/recorder.d.ts +108 -0
  79. package/dist/recorder/recorder.js +417 -0
  80. package/docs/ARCHITECTURE.md +105 -0
  81. package/docs/BACKLOG.md +46 -0
  82. package/docs/CONSTRAINTS.md +63 -0
  83. package/docs/TROUBLESHOOTING.md +211 -0
  84. package/package.json +39 -3
  85. package/recording-assets/recording-handlers.js +373 -0
  86. package/recording-assets/recording-locator.js +126 -0
  87. package/recording-assets/recording-manager.js +76 -0
  88. package/recording-assets/recording-record.js +475 -0
package/README.md CHANGED
@@ -1,3 +1,200 @@
1
- # Temporary Holding Version
1
+ # CDP 录制
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 评审当前未提交增量请看 [BMAD 方案与验收](../../_bmad-output/planning-artifacts/sprint-change-proposal-2026-10-26.md);本轮历史检查记录见 [BMAD 证据附件](../../_bmad-output/planning-artifacts/prds/prd-epoint-playwright-2026-10-26/evidence/validation.json)。
4
+
5
+ 用 Chrome DevTools Protocol 驱动真实浏览器,把页面操作录成 JSON,再交给 `codegen-cli` 生成 Playwright 页面对象。
6
+
7
+ ## 为什么这么做
8
+
9
+ 原来的录制靠 Chrome 扩展(`packages/web-capture-ext`),要手动装扩展、点录制按钮。换成 CDP 后,脚本直接驱动浏览器,录制可以完全自动跑。
10
+
11
+ 关键是**没有重写采集逻辑**:注入的仍是那套 `recording-*.js`(现随本包发布,`web-capture-ext` 构建时复用同一份),只是把 `chrome.runtime.sendMessage` 桥接到 CDP 的 `Runtime.addBinding`。产出的 JSON 和扩展录制结构一致,codegen 不需要任何改动。
12
+
13
+ ## 在脚手架项目中使用
14
+
15
+ `epoint-create` 生成的项目已把本包和 `codegen-cli` 装为 devDependencies,命令在**项目根**执行:
16
+
17
+ ```bash
18
+ npx epoint-cdp-recorder probe --doctor
19
+ npx epoint-cdp-recorder record --flow flows/xxx.mts --out recordings --verbose
20
+ npx epoint-codegen recordings/action-*.json pages/XxxPage.ts "步骤名称" --stdout
21
+ ```
22
+
23
+ - `--out` 缺省为 `recordings/`,`probe --facts` 缺省写到 `flows/`。
24
+ - 页面内录制脚本随包发布(`recording-assets/`),不需要额外装扩展。
25
+ - 本包依赖 Node 的全局 WebSocket / fetch,需要 **Node ≥ 22**。
26
+
27
+ ## 怎么跑
28
+
29
+ Chrome 启动时加调试端口,**窗口放前台**(页面在后台时 `visibilityState === 'hidden'`,CDP 派发的鼠标事件不触发页面监听器,表现是"点了完全没反应也不报错"):
30
+
31
+ ```bash
32
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
33
+ --remote-debugging-port=9222 \
34
+ --user-data-dir=/tmp/chrome-cdp-profile \
35
+ --no-first-run "about:blank"
36
+ ```
37
+
38
+ ```bash
39
+ # 探索:先看清页面结构,再写流程脚本
40
+ pnpm --filter @epoint-playwright/cdp-recorder probe
41
+ pnpm --filter @epoint-playwright/cdp-recorder probe --frame purchasereqplan_workflow
42
+ pnpm --filter @epoint-playwright/cdp-recorder probe --windows
43
+
44
+ # 录制(凭据走环境变量)
45
+ EPOINT_ENTRY=xxx EPOINT_PASS=xxx EPOINT_STRICT_MENU=1 pnpm --filter @epoint-playwright/cdp-recorder record \
46
+ --flow ../../examples/e2e/flows/purchase-initiation.mts --out /tmp/rec --verbose
47
+
48
+ # EPOINT_STRICT_MENU=1:菜单真实点击失败时直接失败,不用 DOM click 兜底。
49
+ # DOM click 是合成事件(isTrusted=false),录制脚本会忽略,录出来的 JSON 会缺菜单步骤。
50
+ # 续跑被上次失败留下的弹窗挡住时加 --cleanup(录制启动前关掉遗留弹窗;续跑点在弹窗内时不要加)。
51
+
52
+ # 生成页面对象
53
+ node packages/codegen-cli/codegen-cli.mjs /tmp/rec/action-*.json \
54
+ examples/e2e/pages/PurchaseInitiationPage.ts "采购立项" --stdout
55
+ ```
56
+
57
+ `probe` 的几种模式:
58
+
59
+ | 命令 | 看什么 |
60
+ |---|---|
61
+ | `probe` | iframe 树 + 可见弹窗 + 控件类型统计(默认概览) |
62
+ | `probe --frames` | 只列 iframe(含嵌套层级、可见性、跨域标记) |
63
+ | `probe --windows` | 所有 `mini-window`(递归下钻,含标题和 iframe src) |
64
+ | `probe --frame <src>` | 该 frame 的全部 MiniUI 控件(id / type / 必填 / 可见 / 值) |
65
+ | `probe --popups --frame <src>` | 当前可见浮层里的树/列表节点 |
66
+ | `probe --tree <控件id> --frame <src>` | 树控件的数据结构(含层级树形图) |
67
+ | `probe --snapshot [--out <json>] [--shot <png>]` | 一次采集整页模型:frame 链、MiniUI 控件+选项、原生控件、必填空缺、弹窗、消息框 |
68
+ | `probe --find <关键词>` | 跨 frame 找控件/原生元素/文本,包含隐藏/已关闭弹窗残留的 DOM |
69
+ | `probe --required [--frame <src>]` | 列出页面真正会拦的必填项(含无 id 控件)和哪些为空,保存前先跑 |
70
+ | `probe --act <意图> --target <目标>` | 统一动作接口。目标:`id:x` / `label:中文名` / `text:按钮文字` / `css:选择器` / `window:标题`;意图:`set` / `click` / `pick` / `click-menu` / `wait-control` / `wait-settled`。横切 flag:`--guard-required`、`--expect-message`、`--expect-window`、`--force`、`--no-confirm`、`--act-timeout <ms>`(默认 120s,卡死直接退出码 124) |
71
+ | `probe --diff <before.json> <after.json>` | 对比两次快照,只看新增/消失/变化的 frame、控件、必填项、弹窗 |
72
+ | `probe --facts <flow> [--from <json>]` | 把整页模型落成 `<flow>.probe.json` + `<flow>.facts.md`(已有 facts 默认不覆盖,`--force` 覆盖) |
73
+ | `probe --check <probe.json> [--update]` | 对比当前页面与缓存模型,只报结构变化;`--update` 刷新缓存 |
74
+ | `probe --doctor` | 开工前体检:CDP 可达、`visibilityState`、定时器是否被节流、有无其它录制进程、当前页面。正常退出码 0,有问题退出码 2 |
75
+
76
+ `--frame` 支持嵌套链:`purchasereqplan_workflow>pplineitem_workflow`。只写一段时会递归搜索,不用先知道它嵌在哪一层。
77
+
78
+ 探索新页面时优先用「一次快照 + 差分」:`probe --snapshot --out /tmp/s1.json` 建立基线,做完一个动作再拍一张,`probe --diff /tmp/s1.json /tmp/s2.json` 只看新增的 frame、控件、必填项和弹窗。字段不在当前 frame 时用 `probe --find <关键词>`,它会连隐藏弹窗残留的 DOM 一起搜,并给出完整的 frame 链和所在弹窗标题。
79
+
80
+ ## 加新控件:四步闭环
81
+
82
+ 控件是**表驱动**的,加一个控件 = 加一行数据。
83
+
84
+ **动手前先看 `packages/epoint-playwright-web/src/MiniDriver.ts` 有没有同控件的现成实现**——
85
+ 选择器和交互顺序照搬,别重新发明(控件与方法对应关系见 `cdp-control-dev` skill)。
86
+
87
+ 1. `probe --frame xxx` 看清楚控件的运行时 `type` 和内部结构
88
+ 2. 在 [`src/miniui/controls.ts`](src/miniui/controls.ts) 的 `CONTROL_SPECS` 加一行
89
+ 3. `pnpm --filter @epoint-playwright/cdp-recorder typecheck` —— 类型检查;`pnpm --filter @epoint-playwright/cdp-recorder test` —— 注册表结构 + 覆盖对齐
90
+ 4. 录一条真实流程验证,通过后把 `verified` 从 `false` 改成 `true`(只跑通部分分支就写 `'partial'` 并在 `notes` 里说明)
91
+
92
+ 八种交互策略(`op`)已实现,绝大多数控件直接复用:
93
+
94
+ | op | 交互 | 典型控件 |
95
+ |---|---|---|
96
+ | `type` | 点内部输入框 → 全选 → 真实输入 → 失焦提交 | textbox / textarea / buttonedit |
97
+ | `clickItem` | 在控件内按文本匹配子项并点击 | radiobuttonlist / checkboxlist |
98
+ | `toggle` | 读状态 → 需要时点图标 | checkbox 开关 |
99
+ | `pickList` | 点 trigger → 等浮层 → 按文本点列表项 | combobox |
100
+ | `pickTree` | 点 trigger → 等浮层 → 逐级展开并点末节点 | treeselect |
101
+ | `pickDate` | 点 trigger → 等 laydate 弹层 → 选「现在」或指定日期 | datepicker |
102
+ | `checkTree` | 逐级展开树路径 → 勾选末端复选框 →(可选)点确认按钮 | tabstreeselect / 复选树 |
103
+ | `typeRichText` | 点富文本编辑区 body → 全选 → 真实输入 → 失焦 | webeditor(UEditor/eWebEditor) |
104
+ | `clickText` | 按文字点按钮 | button |
105
+
106
+ ## 当前支持情况
107
+
108
+ `test/coverage.test.ts` 会直接读 codegen 的 `MAPPING_RULES_TS` 做集合比对,**跑测试就能看到"还有哪些控件没试"**,不需要记:
109
+
110
+ ```bash
111
+ pnpm --filter @epoint-playwright/cdp-recorder test 2>&1 | grep 'coverage]'
112
+ ```
113
+
114
+ 当前状态(20 个控件,12 个完整 + 5 个部分):
115
+
116
+ | 已实现 | 说明 |
117
+ |---|---|
118
+ | `mini-textbox` / `mini-textarea` | 完整 |
119
+ | `mini-combobox` | 完整 |
120
+ | `mini-radiobuttonlist` | 完整 |
121
+ | `mini-checkbox` | 完整 |
122
+ | `mini-checkboxlist` | 完整:多选按「对齐集合」逐项勾选/取消(`DEBUG+WARN`) |
123
+ | `mini-spinner` | **部分**:输入路径已真机验证;上下箭头未验证 |
124
+ | `mini-button` / `mini-button-text` | 完整 |
125
+ | `mini-contenteditable` | 原生可编辑区(工作流「签署意见」),完整 |
126
+ | `mini-buttonedit-form` | 表单 searchbox,只填值不点 trigger |
127
+ | `mini-datepicker` | 完整 |
128
+ | `mini-treeselect` | 完整:单选和复选(多叶子 `checkPaths`)均已真机验证 |
129
+ | `mini-tabstreeselect` | **部分**:复选叶子路径 `checkTreePath` + 可选「确认选择」;树网格行 `tr.mini-grid-row` 已适配;多 tab 切换未覆盖 |
130
+ | `mini-filtertree` | **部分**:内嵌筛选树(树网格行),跳过 trigger 直接按控件作用域选节点;单层/两层已验证,更深分支未覆盖 |
131
+ | `mini-webuploader` | **部分**:`DOM.setFileInputFiles` 设置文件并触发 change,录制生成 `miniUploadFile`;「开始上传」用合成点击 |
132
+ | `mini-webeditor` | **部分**:UEditor 已真机录放(编辑区 iframe 的 blur 由父窗口代理采集,值随 blur 落盘,回放 `inputMiniWebEditor(value, label)`);eWebEditor 变体未真机验证 |
133
+
134
+ 待实现:`mini-daterangepicker`、`mini-buttonedit-search`、`mini-datagrid-cell`。
135
+
136
+ ## 写流程脚本的规矩
137
+
138
+ 流程脚本放在 `examples/e2e/flows/*.mts`(`.mts` 永远是 ESM,绕开 examples 包的 `type: commonjs` 歧义)。
139
+
140
+ ```ts
141
+ import { session, menu, ui, waitFor, type CdpClient, type CdpRecorder } from '@epoint-playwright/cdp-recorder';
142
+
143
+ export const name = '采购立项-登录到保存';
144
+
145
+ export async function run(cdp: CdpClient, recorder: CdpRecorder): Promise<void> {
146
+ await session.login(cdp); // 凭据走环境变量
147
+ await menu.clickIdeaMenu(cdp, ['采购立项管理', '采购立项']);
148
+ await ui.clickButton(cdp, 'purchaseproject_list', '新增采购项目');
149
+ await ui.inputText(cdp, 'purchaseproject_workflow', 'projectname', value, 'buttonedit');
150
+ await ui.selectTree(cdp, 'purchaseproject_workflow', 'xiaqucode', '北京市', '110000');
151
+ await ui.expectMessageBox(cdp, '保存成功');
152
+ }
153
+ ```
154
+
155
+ 三条硬规矩:
156
+
157
+ 1. **每个动作只做一次**。别写"滚动 + 命中检测 + 反复点"的试探循环,那会把一次操作录成四五个 click,污染录制。
158
+ 2. **不要按 Tab 失焦**。Tab 会把焦点跳到下一个控件,之后点别处时它空着 blur,录出一条假操作。用 `el.blur()`(原语内部已处理)。
159
+ 3. **等状态,不写固定 sleep**。`ui.waitIdle` / `ui.waitFrame` / `ui.waitPopup` 已经封装了 `div.page-loading`、`div.mini-mask-background`、浮层可见性判断;子窗口关闭后父窗口会重渲染工具栏,用 `ui.waitSettled` 等 DOM 真正安静再点。
160
+
161
+ ## 树操作 API(分层)
162
+
163
+ 树的 DOM 选择器、作用域回退和重试只有一份实现,按四层组织在 `src/miniui/tree/`:
164
+
165
+ | 层 | 用什么 | 什么时候用 |
166
+ |---|---|---|
167
+ | 行模型(纯函数) | `planTreeAction` / `isCheckSettled` / `parseTreePath` / `childRows` | 写策略或做纯逻辑测试,不碰浏览器 |
168
+ | 读行 | `readTreeRows(cdp, f, { scopeId })` / `readTreeRow(cdp, f, 节点名, scopeId?)` | 先看当前有哪些节点、各是什么状态 |
169
+ | 单步动作 | `waitTreeRow` / `expandTreeRow` / `clickTreeRow` / `checkTreeRow` | 只展开/点/勾一个节点,选择逻辑自己写 |
170
+ | 高层选择 | `selectTree` / `pickTreeNode` / `checkTreePath` / `selectFirstTreeLeaf` | 目标明确(按路径选)或没有目标(选第一个可选叶子) |
171
+
172
+ 行模型一次读出:`text / visible / parent / collapsed / hasExpander / checked / hasCheckbox / radio / leaf`。
173
+ 作用域:**给了控件 id 就只在「控件 → 可见浮层」里找**(treeselect 的节点在浮层、内嵌树在控件里),
174
+ 不退回整页 —— 退回整页会在浮层没打开时选中同页另一棵树的同名节点,把错误节点写进录制。不给 id 时才退到整页。
175
+ 旧名(`openTreePopup` / `checkTreeNode`)保留,迁移期脚本不用改。
176
+
177
+ **新页面怎么写**:`ensureTreeOpen` 打开容器(内嵌树自动跳过)→ `readTreeRows` 看当前节点 → 组合
178
+ `expandTreeRow` + `clickTreeRow` / `checkTreeRow`。不要往 `tree/` 里加页面专用函数;
179
+ 只有出现新的 DOM 变体(行结构、展开图标、勾选框选择器不同)才改 `tree/rows.ts`,只改那一处。
180
+
181
+ ## 目录
182
+
183
+ ```
184
+ packages/cdp-recorder/
185
+ src/cdp/ CDP 客户端 + DOM 域定位
186
+ src/miniui/ 控件原语(context / wait / pointer / read / controls / dialog)
187
+ ops-input / ops-pick / date-picker / tree/ / apply(index.ts 统一出口)
188
+ src/miniui/tree/ 树操作(model 行模型 / rows 读行 / ops 单步动作 / select 高层选择)
189
+ src/recorder/ 录制器 + 后处理 + 约束校验
190
+ src/flows/session.ts 登录 / 配置 / stamp / waitControl(对外门面没有:按模块各自导出)
191
+ src/flows/menu.ts 菜单导航
192
+ src/flows/form.ts 表单填写 / 必填项 / 按钮+消息框
193
+ src/flows/grid.ts 表格行读取与等待
194
+ src/flows/steps.ts 断点续跑(runSteps)
195
+ src/cli/index.ts 只做分发;record.ts / doctor.ts / probe/*.ts 是各命令实现
196
+ test/ 注册表 / 覆盖 / 后处理 / 约束
197
+ docs/ ARCHITECTURE / TROUBLESHOOTING / CONSTRAINTS / BACKLOG
198
+ ```
199
+
200
+ 更多细节:[ARCHITECTURE.md](docs/ARCHITECTURE.md)(录制器原理)、[TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md)(踩坑清单)、[CONSTRAINTS.md](docs/CONSTRAINTS.md)(录制硬约束)、[BACKLOG.md](docs/BACKLOG.md)(待办与已知缺口)。
@@ -0,0 +1,46 @@
1
+ /**
2
+ * 极简 CDP 客户端。
3
+ *
4
+ * 只做三件事:连上已开的 Chrome、拿到页面 target、在页面/iframe 里执行 JS。
5
+ * 不封装业务,业务在 recorder/ 与 flows/ 里。
6
+ *
7
+ * 依赖全局 fetch / WebSocket(Node 22+ 稳定),刻意零 npm 依赖:
8
+ * 这个工具开发时直接 `node src/cli/index.ts` 跑,发布时编译到 dist/;运行时零 npm 依赖:
9
+ */
10
+ export interface CdpMessage {
11
+ id?: number;
12
+ method?: string;
13
+ params?: Record<string, unknown>;
14
+ sessionId?: string;
15
+ result?: Record<string, unknown>;
16
+ error?: unknown;
17
+ }
18
+ export interface CdpClient {
19
+ ws: WebSocket;
20
+ page: {
21
+ targetId: string;
22
+ url: string;
23
+ };
24
+ sessionId: string;
25
+ send: (method: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>>;
26
+ on: (fn: (msg: CdpMessage) => void) => void;
27
+ close: () => void;
28
+ }
29
+ interface ConnectOptions {
30
+ /** 页面 URL 必须包含的片段;不传则取第一个 page target */
31
+ urlContains?: string;
32
+ }
33
+ export declare function connect(cdpUrl?: string, opts?: ConnectOptions): Promise<CdpClient>;
34
+ /** 在指定执行上下文里求值,返回 JSON 值。 */
35
+ export declare function evaluate(cdp: CdpClient, expression: string, contextId?: number): Promise<unknown>;
36
+ /** 在顶层页面执行。 */
37
+ export declare function evaluateTop(cdp: CdpClient, expression: string): Promise<unknown>;
38
+ /** 发一次真实鼠标点击(CSS 像素,相对顶层视口)。 */
39
+ export declare function clickAt(cdp: CdpClient, x: number, y: number): Promise<void>;
40
+ /** 输入文本(走真实输入通道,触发 input/change)。 */
41
+ export declare function typeText(cdp: CdpClient, text: string): Promise<void>;
42
+ /** 按下并释放一个键,用于 Enter/Tab。 */
43
+ export declare function pressKey(cdp: CdpClient, key: string): Promise<void>;
44
+ /** 轮询等待条件成立。 */
45
+ export declare function waitFor(cdp: CdpClient, expression: string, timeoutMs?: number, intervalMs?: number): Promise<true>;
46
+ export {};
@@ -0,0 +1,137 @@
1
+ /**
2
+ * 极简 CDP 客户端。
3
+ *
4
+ * 只做三件事:连上已开的 Chrome、拿到页面 target、在页面/iframe 里执行 JS。
5
+ * 不封装业务,业务在 recorder/ 与 flows/ 里。
6
+ *
7
+ * 依赖全局 fetch / WebSocket(Node 22+ 稳定),刻意零 npm 依赖:
8
+ * 这个工具开发时直接 `node src/cli/index.ts` 跑,发布时编译到 dist/;运行时零 npm 依赖:
9
+ */
10
+ export async function connect(cdpUrl = 'http://127.0.0.1:9222', opts = {}) {
11
+ const version = (await (await fetch(`${cdpUrl}/json/version`)).json());
12
+ const ws = new WebSocket(version.webSocketDebuggerUrl);
13
+ await new Promise((resolve, reject) => {
14
+ ws.onopen = () => resolve();
15
+ ws.onerror = () => reject(new Error('CDP WebSocket 连接失败'));
16
+ });
17
+ let seq = 0;
18
+ const pending = new Map();
19
+ const listeners = [];
20
+ let closed = false;
21
+ /** 立即失败所有在途请求:socket 关掉后它们不会再收到响应,只能等 30s 超时。 */
22
+ const failPending = (reason) => {
23
+ for (const [id, p] of pending) {
24
+ clearTimeout(p.timer);
25
+ p.reject(new Error(reason));
26
+ pending.delete(id);
27
+ }
28
+ };
29
+ ws.onclose = () => {
30
+ closed = true;
31
+ failPending('CDP 连接已关闭');
32
+ };
33
+ ws.onmessage = (event) => {
34
+ const msg = JSON.parse(String(event.data));
35
+ if (msg.id && pending.has(msg.id)) {
36
+ const p = pending.get(msg.id);
37
+ pending.delete(msg.id);
38
+ // 必须清掉超时定时器:不清的话每次调用都挂一个 30s 的 timer,
39
+ // 最后一个命令跑完后 Node 还要空等 30 秒才退出。
40
+ clearTimeout(p.timer);
41
+ if (msg.error)
42
+ p.reject(new Error(JSON.stringify(msg.error)));
43
+ else
44
+ p.resolve(msg.result ?? {});
45
+ return;
46
+ }
47
+ if (msg.method)
48
+ listeners.forEach((fn) => fn(msg));
49
+ };
50
+ const send = (method, params = {}) => {
51
+ if (closed)
52
+ return Promise.reject(new Error(`CDP 已关闭: ${method}`));
53
+ const id = ++seq;
54
+ return new Promise((resolve, reject) => {
55
+ const timer = setTimeout(() => {
56
+ if (pending.has(id)) {
57
+ pending.delete(id);
58
+ reject(new Error(`CDP timeout: ${method}`));
59
+ }
60
+ }, 30000);
61
+ pending.set(id, { resolve, reject, timer });
62
+ // 首次 Target.* 调用还没有 sessionId,带上空串会被 CDP 拒绝。
63
+ ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
64
+ });
65
+ };
66
+ // sessionId 在下面拿到后回填;这里用闭包变量保证 send 里能读到最新值。
67
+ let sessionId = '';
68
+ const targets = (await send('Target.getTargets'));
69
+ const infos = targets.targetInfos;
70
+ // 指定了 urlContains 就按它挑;没指定时优先选有实际页面内容的 tab,
71
+ // 跳过 about:blank / devtools 之类的空白 target。
72
+ const pages = infos.filter((t) => t.type === 'page');
73
+ const page = opts.urlContains
74
+ ? (pages.find((t) => t.url.includes(opts.urlContains)) ?? pages[0])
75
+ : (pages.find((t) => /^https?:/i.test(t.url)) ?? pages[0]);
76
+ if (!page)
77
+ throw new Error('没有找到页面 target');
78
+ const attached = (await send('Target.attachToTarget', { targetId: page.targetId, flatten: true }));
79
+ sessionId = attached.sessionId;
80
+ return {
81
+ ws,
82
+ page,
83
+ sessionId,
84
+ send,
85
+ on: (fn) => listeners.push(fn),
86
+ close: () => {
87
+ closed = true;
88
+ ws.close();
89
+ failPending('CDP 连接已关闭');
90
+ },
91
+ };
92
+ }
93
+ /** 在指定执行上下文里求值,返回 JSON 值。 */
94
+ export async function evaluate(cdp, expression, contextId) {
95
+ const result = (await cdp.send('Runtime.evaluate', {
96
+ expression,
97
+ ...(contextId ? { contextId } : {}),
98
+ awaitPromise: true,
99
+ returnByValue: true,
100
+ userGesture: true,
101
+ }));
102
+ if (result.exceptionDetails) {
103
+ throw new Error(result.exceptionDetails.exception?.description ?? JSON.stringify(result.exceptionDetails));
104
+ }
105
+ return result.result?.value;
106
+ }
107
+ /** 在顶层页面执行。 */
108
+ export async function evaluateTop(cdp, expression) {
109
+ return evaluate(cdp, expression);
110
+ }
111
+ /** 发一次真实鼠标点击(CSS 像素,相对顶层视口)。 */
112
+ export async function clickAt(cdp, x, y) {
113
+ await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });
114
+ await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 });
115
+ await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
116
+ }
117
+ /** 输入文本(走真实输入通道,触发 input/change)。 */
118
+ export async function typeText(cdp, text) {
119
+ await cdp.send('Input.insertText', { text });
120
+ }
121
+ /** 按下并释放一个键,用于 Enter/Tab。 */
122
+ export async function pressKey(cdp, key) {
123
+ const code = key === 'Enter' ? 'Enter' : key === 'Tab' ? 'Tab' : key;
124
+ await cdp.send('Input.dispatchKeyEvent', { type: 'rawKeyDown', key, code });
125
+ await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key, code });
126
+ }
127
+ /** 轮询等待条件成立。 */
128
+ export async function waitFor(cdp, expression, timeoutMs = 15000, intervalMs = 250) {
129
+ const start = Date.now();
130
+ while (Date.now() - start < timeoutMs) {
131
+ const ok = await evaluateTop(cdp, expression).catch(() => false);
132
+ if (ok)
133
+ return true;
134
+ await new Promise((r) => setTimeout(r, intervalMs));
135
+ }
136
+ throw new Error(`waitFor 超时: ${expression}`);
137
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * 用 CDP 的 DOM 域定位元素,代替页面里算坐标。
3
+ *
4
+ * 为什么不用 `getBoundingClientRect() + frameElement 偏移`:
5
+ * - 多层 iframe 要手工累加偏移,差一层就点飞;
6
+ * - 滚动位置要自己算,滚动容器可能是 iframe 内部的某个 div;
7
+ * - 页面缩放/滚动条会让坐标偏。
8
+ *
9
+ * CDP 的做法:
10
+ * - `DOM.describeNode` 拿 backendNodeId(页面 JS 只负责"找哪个元素",不算位置);
11
+ * - `DOM.scrollIntoViewIfNeeded` 由浏览器自己滚;
12
+ * - `DOM.getBoxModel` 返回主框架坐标系下的四边形,直接取中心。
13
+ */
14
+ import type { CdpClient } from './client.ts';
15
+ export interface Point {
16
+ x: number;
17
+ y: number;
18
+ w: number;
19
+ h: number;
20
+ backendNodeId?: number;
21
+ }
22
+ /** 确保 DOM 域已启用。 */
23
+ export declare function enableDom(cdp: CdpClient): Promise<void>;
24
+ /**
25
+ * 把一个页面内表达式求值得到的元素转成 backendNodeId。
26
+ * expression 必须返回 Element 或 null。
27
+ */
28
+ export declare function backendNodeIdOf(cdp: CdpClient, expression: string): Promise<number | null>;
29
+ /**
30
+ * 让浏览器把节点滚进可视区并返回中心坐标(主框架坐标系,CSS 像素)。
31
+ * 节点不可见(尺寸为 0)时返回 null。
32
+ */
33
+ export declare function centerOfNode(cdp: CdpClient, backendNodeId: number): Promise<Point | null>;
34
+ /** 一次完成:表达式 → backendNodeId → 中心坐标。 */
35
+ export declare function locate(cdp: CdpClient, expression: string): Promise<Point | null>;
36
+ /** 带诊断的 locate:返回失败原因,便于定位是哪一层出问题。 */
37
+ export declare function locateVerbose(cdp: CdpClient, expression: string): Promise<Point | {
38
+ x?: undefined;
39
+ reason: string;
40
+ }>;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * 用 CDP 的 DOM 域定位元素,代替页面里算坐标。
3
+ *
4
+ * 为什么不用 `getBoundingClientRect() + frameElement 偏移`:
5
+ * - 多层 iframe 要手工累加偏移,差一层就点飞;
6
+ * - 滚动位置要自己算,滚动容器可能是 iframe 内部的某个 div;
7
+ * - 页面缩放/滚动条会让坐标偏。
8
+ *
9
+ * CDP 的做法:
10
+ * - `DOM.describeNode` 拿 backendNodeId(页面 JS 只负责"找哪个元素",不算位置);
11
+ * - `DOM.scrollIntoViewIfNeeded` 由浏览器自己滚;
12
+ * - `DOM.getBoxModel` 返回主框架坐标系下的四边形,直接取中心。
13
+ */
14
+ /** 确保 DOM 域已启用。 */
15
+ export async function enableDom(cdp) {
16
+ await cdp.send('DOM.enable').catch(() => { });
17
+ }
18
+ /**
19
+ * 把一个页面内表达式求值得到的元素转成 backendNodeId。
20
+ * expression 必须返回 Element 或 null。
21
+ */
22
+ export async function backendNodeIdOf(cdp, expression) {
23
+ const res = (await cdp
24
+ .send('Runtime.evaluate', { expression, returnByValue: false, userGesture: true })
25
+ .catch(() => null));
26
+ if (res?.exceptionDetails)
27
+ return null;
28
+ const objectId = res?.result?.objectId;
29
+ if (!objectId)
30
+ return null;
31
+ const described = (await cdp.send('DOM.describeNode', { objectId }).catch(() => null));
32
+ return described?.node?.backendNodeId ?? null;
33
+ }
34
+ /**
35
+ * 让浏览器把节点滚进可视区并返回中心坐标(主框架坐标系,CSS 像素)。
36
+ * 节点不可见(尺寸为 0)时返回 null。
37
+ */
38
+ export async function centerOfNode(cdp, backendNodeId) {
39
+ await cdp.send('DOM.scrollIntoViewIfNeeded', { backendNodeId }).catch(() => { });
40
+ const { model } = (await cdp.send('DOM.getBoxModel', { backendNodeId }).catch(() => ({ model: null })));
41
+ if (!model)
42
+ return null;
43
+ // content quad = [x1,y1, x2,y2, x3,y3, x4,y4],四个角
44
+ const q = model.content;
45
+ const width = Math.hypot(q[2] - q[0], q[3] - q[1]);
46
+ const height = Math.hypot(q[4] - q[2], q[5] - q[3]);
47
+ if (width === 0 || height === 0)
48
+ return null;
49
+ return {
50
+ x: (q[0] + q[2] + q[4] + q[6]) / 4,
51
+ y: (q[1] + q[3] + q[5] + q[7]) / 4,
52
+ w: width,
53
+ h: height,
54
+ };
55
+ }
56
+ /** 一次完成:表达式 → backendNodeId → 中心坐标。 */
57
+ export async function locate(cdp, expression) {
58
+ const id = await backendNodeIdOf(cdp, expression);
59
+ if (id == null)
60
+ return null;
61
+ const center = await centerOfNode(cdp, id);
62
+ if (!center)
63
+ return null;
64
+ return { ...center, backendNodeId: id };
65
+ }
66
+ /** 带诊断的 locate:返回失败原因,便于定位是哪一层出问题。 */
67
+ export async function locateVerbose(cdp, expression) {
68
+ const res = (await cdp
69
+ .send('Runtime.evaluate', { expression, returnByValue: false, userGesture: true })
70
+ .catch((e) => ({ err: e.message })));
71
+ if (!res || res.err)
72
+ return { reason: 'evaluate-failed: ' + (res?.err ?? '') };
73
+ if (res.exceptionDetails) {
74
+ return {
75
+ reason: 'evaluate-exception: ' +
76
+ String(res.exceptionDetails.exception?.description ?? res.exceptionDetails.text ?? '').slice(0, 160),
77
+ };
78
+ }
79
+ if (!res.result)
80
+ return { reason: 'no-result' };
81
+ if (!res.result.objectId) {
82
+ return {
83
+ reason: 'element-not-found (结果是 ' +
84
+ res.result.type +
85
+ ': ' +
86
+ String(res.result.value ?? res.result.description ?? '').slice(0, 60) +
87
+ ')',
88
+ };
89
+ }
90
+ const described = (await cdp.send('DOM.describeNode', { objectId: res.result.objectId }).catch((e) => ({
91
+ err: e.message,
92
+ })));
93
+ const backendNodeId = described?.node?.backendNodeId;
94
+ if (!backendNodeId)
95
+ return { reason: 'describe-failed: ' + (described?.err ?? 'no backendNodeId') };
96
+ await cdp.send('DOM.scrollIntoViewIfNeeded', { backendNodeId }).catch(() => { });
97
+ const boxed = (await cdp.send('DOM.getBoxModel', { backendNodeId }).catch((e) => ({ err: e.message })));
98
+ if (!boxed?.model)
99
+ return { reason: 'boxmodel-failed: ' + (boxed?.err ?? 'no model') };
100
+ const q = boxed.model.content;
101
+ const width = Math.hypot(q[2] - q[0], q[3] - q[1]);
102
+ const height = Math.hypot(q[4] - q[2], q[5] - q[3]);
103
+ if (width === 0 || height === 0)
104
+ return { reason: 'zero-size' };
105
+ return {
106
+ x: (q[0] + q[2] + q[4] + q[6]) / 4,
107
+ y: (q[1] + q[3] + q[5] + q[7]) / 4,
108
+ w: width,
109
+ h: height,
110
+ backendNodeId,
111
+ };
112
+ }
@@ -0,0 +1,5 @@
1
+ /** 命令行参数解析:手工解析,零依赖(内部工具,不需要 commander)。 */
2
+ export declare function arg(name: string, fallback?: string): string | undefined;
3
+ export declare const hasFlag: (name: string) => boolean;
4
+ export declare const subcommand: string;
5
+ export declare function stamp(): string;
@@ -0,0 +1,12 @@
1
+ /** 命令行参数解析:手工解析,零依赖(内部工具,不需要 commander)。 */
2
+ export function arg(name, fallback) {
3
+ const i = process.argv.indexOf(`--${name}`);
4
+ return i > -1 ? process.argv[i + 1] : fallback;
5
+ }
6
+ export const hasFlag = (name) => process.argv.includes(`--${name}`);
7
+ export const subcommand = process.argv[2] ?? '';
8
+ export function stamp() {
9
+ const d = new Date();
10
+ const pad = (n) => String(n).padStart(2, '0');
11
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`;
12
+ }
@@ -0,0 +1,2 @@
1
+ /** probe --doctor:开工前环境体检(CDP / 可见性 / 定时器节流 / 其它录制进程)。 */
2
+ export declare function runDoctor(): Promise<void>;
@@ -0,0 +1,67 @@
1
+ /** probe --doctor:开工前环境体检(CDP / 可见性 / 定时器节流 / 其它录制进程)。 */
2
+ import { execSync } from 'node:child_process';
3
+ import { connect, evaluateTop } from "../cdp/client.js";
4
+ import { arg } from "./args.js";
5
+ export async function runDoctor() {
6
+ const cdpUrl = arg('cdp', 'http://127.0.0.1:9222');
7
+ const urlContains = arg('url-contains', process.env.EPOINT_URL_CONTAINS ?? '');
8
+ const lines = [];
9
+ let ok = true;
10
+ let others = '';
11
+ try {
12
+ others = execSync('pgrep -fl "cdp-recorder.*record" || true', { encoding: 'utf8' }).trim();
13
+ }
14
+ catch {
15
+ others = '';
16
+ }
17
+ if (others) {
18
+ ok = false;
19
+ lines.push(`✗ 有其他录制进程(会互相点对方的弹窗):\n${others}`);
20
+ }
21
+ else {
22
+ lines.push('✓ 没有其它录制进程');
23
+ }
24
+ try {
25
+ const res = await fetch(`${cdpUrl}/json/version`);
26
+ const data = (await res.json());
27
+ lines.push(`✓ CDP 可达 ${cdpUrl}(${data.Browser ?? '未知版本'})`);
28
+ }
29
+ catch (e) {
30
+ lines.push(`✗ CDP 不可达 ${cdpUrl}:${e.message}`);
31
+ console.log(lines.join('\n'));
32
+ process.exit(1);
33
+ }
34
+ const cdp = await connect(cdpUrl, { urlContains });
35
+ try {
36
+ const raw = await evaluateTop(cdp, `(() => JSON.stringify({ vis: document.visibilityState, focus: document.hasFocus(), url: location.href, frames: document.querySelectorAll('iframe').length }))()`);
37
+ const info = JSON.parse(String(raw));
38
+ if (info.vis === 'visible') {
39
+ lines.push('✓ visibilityState=visible');
40
+ }
41
+ else {
42
+ ok = false;
43
+ lines.push('✗ visibilityState=hidden(窗口被遮挡/锁屏)。CDP 点击仍然有效,但后台定时器会被节流:' +
44
+ '懒加载变 5~6s、保存后父窗口重渲染 2~3s,容易点空。' +
45
+ 'Chrome 启动时加 --disable-background-timer-throttling --disable-renderer-backgrounding ' +
46
+ '--disable-backgrounding-occluded-windows --disable-features=CalculateNativeWinOcclusion 可解决。');
47
+ }
48
+ // 50ms × 500ms 正常应触发约 10 次;被节流时只有 1~2 次。
49
+ const ticks = Number(await evaluateTop(cdp, `new Promise((done) => { let n = 0; const id = setInterval(() => n++, 50); setTimeout(() => { clearInterval(id); done(n); }, 500); })`));
50
+ if (ticks >= 7) {
51
+ lines.push(`✓ 定时器未节流(50ms×500ms 触发 ${ticks} 次)`);
52
+ }
53
+ else {
54
+ ok = false;
55
+ lines.push(`✗ 定时器被节流(50ms×500ms 只触发 ${ticks} 次)`);
56
+ }
57
+ lines.push(`• 当前页面:${info.url}`);
58
+ lines.push(`• iframe 数:${info.frames}(--url-contains=${urlContains || '(空)'})`);
59
+ lines.push(`• 页面焦点:${info.focus ? '有' : '无'}`);
60
+ }
61
+ finally {
62
+ cdp.close();
63
+ }
64
+ console.log(lines.join('\n'));
65
+ if (!ok)
66
+ process.exit(2);
67
+ }
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CDP 录制工具入口。
4
+ *
5
+ * epoint-cdp-recorder record --flow <path.mts> [--out <dir>] [--verbose]
6
+ * epoint-cdp-recorder probe [--frames|--windows|--popups|--snapshot|--find|--required|--act|--doctor]
7
+ *
8
+ * 这里只做分发;各命令实现在 record.ts / probe/*.ts。
9
+ * 前置:Chrome 已用 --remote-debugging-port=9222 启动,窗口在前台,且已登录目标系统。
10
+ */
11
+ export type { Recording } from '../recorder/recorder.ts';