@evan_zhong/browser-attach 0.22.0 → 0.23.0
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.
- package/docs/ARCHITECTURE.md +3 -1
- package/docs/TEST-REPORT.md +18 -1
- package/docs/USAGE.md +46 -5
- package/extension/actionability.js +5 -2
- package/extension/click.js +3 -1
- package/extension/errors.js +18 -0
- package/extension/fields.js +130 -0
- package/extension/input.js +3 -1
- package/extension/locator.js +43 -22
- package/extension/manifest.json +1 -1
- package/extension/observation.js +17 -3
- package/extension/snapshot.js +38 -23
- package/extension/text.js +4 -2
- package/extension/wait.js +4 -2
- package/package.json +1 -1
- package/scripts/check-package.mjs +1 -1
- package/src/config.mjs +1 -1
- package/src/mcp.mjs +4 -3
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 架构与设计边界
|
|
2
2
|
|
|
3
|
-
当前实现为 **0.
|
|
3
|
+
当前实现为 **0.23.0**。本文说明 BrowserAttach 的组件、控制模型与设计取舍;工具参数见 [使用指南](USAGE.md)。
|
|
4
4
|
|
|
5
5
|
## 组件与数据流
|
|
6
6
|
|
|
@@ -34,6 +34,7 @@ MCP 客户端
|
|
|
34
34
|
| `extension/outcomes.js` | 动作前临时 DOM 观察器、未知结果后的有界只读探测与证据封装,不推断业务成功 |
|
|
35
35
|
| `extension/fetch.js`、`fetch-page.js` | 单页读取参数与缓存分页、临时页创建/导航/采集/关闭;复用 controller 的 attach 和标签队列 |
|
|
36
36
|
| `extension/observation.js`、`snapshot.js` | 文档/frame 观察版本、AX 采集、ref 身份与失效历史;AX 渲染和快照分页 |
|
|
37
|
+
| `extension/fields.js` | 每次观察按 debugger session 采集 DOMSnapshot,按 frame/backend 节点身份关联 AX,提取结构化字段事实和原生文件选择状态 |
|
|
37
38
|
| `extension/network.js` | 一份被动请求与文档响应状态,供 fetch、网络空闲等待及诊断日志使用 |
|
|
38
39
|
| `extension/diagnostics.js`、`log-data.js` | 日志缓冲与分页、可选正文捕获、截断及脱敏;不拥有网络空闲或文档状态 |
|
|
39
40
|
| `extension/{navigation,evaluate}.js` | 导航等待、有界求值结果缓存与分页 |
|
|
@@ -82,6 +83,7 @@ HTTP 状态复用 network.js 的 Network.responseReceived 观察,按主文档
|
|
|
82
83
|
- **交互视图**:可选 `profile=interactive` 在同一渲染器中仅保留 AX 控件角色、可聚焦/可编辑节点及其祖先链和控件内文本,不以 backendNodeId/ref 是否存在判断交互性。独立文本和合并正文可隐藏或截取预览,控件名称、值和 ref 的原始 AX 身份保留;默认 `full` 行为不变。筛选仍读取完整 AX 树,只降低输出与分页成本;视图参数绑定分页观察。
|
|
83
84
|
- **文本查询与精简输出**:`snapshot(query=...)` 复用同一次 AX 渲染,按当前视图中节点的完整名称(含合并正文)、value 或字段 context 做不区分大小写的字面子串筛选,只输出匹配行和对应 frame 标题;不搜索 role/ref 或任意 DOM 属性,不新增索引缓存。新查询仍采集新观察并使旧 refs 失效,query 与分页绑定。默认省略 viewport、snapshot_id 和重复规则说明,`verbose=true` 返回前两项;frame 覆盖、视图、预算和游标语义保留。
|
|
84
85
|
- **字段上下文**:表单控件可附带 context 并参与 query,保留至多 160 字符的前一兄弟分支 AX 文本;只在四层以内 generic/none/group 祖先、单控件容器中查找,每次容器/文本遍历分别限制 64/32 节点。不将其伪装为 accessible name,不用于 label 定位或 ref 身份验证;interactive/text=false 也保留提示。
|
|
86
|
+
- **结构化字段**:profile=fields 复用相同观察状态、ref 注册、上下文提取、双预算分页与失效模型,以 fields 数组输出 DOM 与 AX 事实。每个 debugger session 在本次观察内至多一次 DOMSnapshot.captureSnapshot;按文档 frameId 与 backendNodeId 关联,不用选择器或名称推断身份,不逐字段读取普通 DOM 属性。DOMSnapshot 同时保留闭合 Shadow DOM、原生实时值与选项状态;只对文件 input 在隔离世界读取 FileList 的数量/名称,并释放 object group。默认限于 AX 暴露的原生字段与 AX 字段角色;include_hidden 显式加入 AX 缺失的原生字段,其 ref 可空。密码值不进入字段结果及查询。名称来自 AX,labels 来自 HTML label 名称来源,context 保持提示语义;原生/AX 值不合并成业务提交值。会话内 DOM 捕获只存在于本次采集,续读使用既有 entries,不增加持久 DOM 缓存。AX/DOM/FileList 与跨 frame 读取不是原子事务。
|
|
85
87
|
- **ref 解析**:ref 保存 `backendNodeId`、所属 frame/session 和观察时的 AX role/name;动作时重新 `DOM.resolveNode` 并用局部 AX 查询核对语义身份。输入与求值不清空 refs,只使快照分页失效;原节点仍连接时可复用,节点替换、role/name 改变、文档或 frame 变化时安全失败,不按 role/name 猜测替代节点。
|
|
86
88
|
- **ref 失效诊断**:controller 每标签保留最近至多 1000 个已清理 ref 的 cause/frame_id,不保留 DOM 对象、字段值或替代定位信息。新观察、文档/frame 变化及对话框清理记录各自原因;解析时单独报告节点脱离、session 或 AX 身份变化。释放、断连、关闭和 detach 清空历史,淘汰后返回 unknown_ref。
|
|
87
89
|
- **双预算**:`max_nodes` 限制节点数,`max_chars` 限制每行序列化后的累计字符数;两者独立,任一耗尽都会返回 `next_cursor`。
|
package/docs/TEST-REPORT.md
CHANGED
|
@@ -1,9 +1,26 @@
|
|
|
1
1
|
# 验证报告
|
|
2
2
|
|
|
3
|
-
当前版本 **0.
|
|
3
|
+
当前版本 **0.23.0**(2026-10-25)。浏览器验证使用本地 fixture 和临时 profile;测试链路在各项下说明。
|
|
4
4
|
|
|
5
5
|
## 当前版本
|
|
6
6
|
|
|
7
|
+
- `npm test`:253 项通过。新增结构化字段保留重复标签、真实 HTML label 来源与附近 context、DOM/AX 值区分、原生多选与 mixed 状态、隐藏字段显式读取、密码值省略、节点身份缺失不按名称合并、完整行双预算分页、session 级 DOM 捕获复用与文件选择只读采集。覆盖输入后 cursor 失效且 ref 保留、新观察替换旧 ref、子 frame 变化只移除子树、文档捕获中导航不恢复旧引用。
|
|
8
|
+
- Chromium 156、Edge 155 headless 完整 stdio MCP `live-smoke`:各 281 次调用、31 项检查通过。真实 DOMSnapshot 与 AX 关联验证重复 HTML 标签、TEXTS28 字段名查询、格式化/只读值、多选原始值/标签、checkbox mixed、隐藏输入、密码省略、闭合 Shadow DOM、同源 iframe、OOPIF、文件选择数量/中文文件名、分页与字段 ref 操作;单纯观察不派发 input。保留输入、截图、上传、对话框、日志与 12000 节点 AX 分页回归。
|
|
9
|
+
- `npm run test:package`:包外安装与 stdio MCP 通过,23 个工具、61 个发布文件,无配对密钥;新增 fields 模块纳入包边界检查。没有新工具、依赖或扩展权限,观察能力扩展升至 0.23.0。
|
|
10
|
+
- 本机部署:协调升级因已有 MCP 连接拒绝后,按 stop/start 重启;Hub 为 0.23.0 / ready,Chrome、Edge 扩展均为 0.23.0。稳定扩展目录 `F:\.nosis\browser-attach\extension` 的 43 个文件已核对:manifest 版本一致,其余非生成源码/资源 SHA-256 一致。
|
|
11
|
+
- 当前边界:跨 frame、AX/DOM/FileList 分步采集不具备事务或并发写入归属;自定义组件内部模型、最终提交值和服务端附件状态仍需任务相关检查。隐藏原生字段可能无 AX/ref,未关联 DOM 身份的 AX 行省略值;缺失或失败的 frame 保留 omitted_frames。已有 MCP 客户端需重连刷新字段视图参数并重新发现/接管。
|
|
12
|
+
|
|
13
|
+
## 0.22.1 验证
|
|
14
|
+
|
|
15
|
+
- `npm test`:246 项通过。新增隔离世界选择器诊断经 CDP 异常对象读取、单步/read_text/wait/batch 传递及 frame 路径底层原因保留;诊断读取失败保留原异常并清理 wrapper。补充准备/派发超时分类、派发后不沿用准备诊断、fill 值不匹配保留未知结果且不回显字段值。后续新增的 click 目标变化原因断言另通过 15 项指针回归。
|
|
16
|
+
- Chromium 156、Edge 155 headless 完整 stdio MCP `live-smoke`:各 267 次工具调用、29 项检查通过。新增真实 CSS/XPath 语法错误、CSS/AX role 歧义、缺失 selector、read_text/wait/batch 错误原因经过扩展 → Hub → MCP 的检查;原生 fill 同步格式化返回 value_not_retained、input_dispatched=true 及 post_action,真实 input 事件仅 1 次。保留上传、对话框、OOPIF、日志、后台输入与大 AX 树回归。
|
|
17
|
+
- Edge 首次与 Chromium 并行执行时,原有遮挡检查在 500 ms 准备预算内超时,未派发输入;未修改该检查预算,独立复跑完整脚本通过。
|
|
18
|
+
- `npm run test:package`:包外安装与 stdio MCP 通过,23 个工具、60 个发布文件,无配对密钥。未增加工具、参数、依赖或扩展权限,版本按诊断修复递增至 0.22.1。
|
|
19
|
+
- 本机部署:协调升级因现有 MCP 连接拒绝后,使用项目 stop/start 重启;Hub 为 0.22.1 / ready,已配对 Chrome、Edge 均为 0.22.1。稳定扩展目录 `F:\.nosis\browser-attach\extension` 的 42 个文件已核对:manifest 版本一致,其余非生成文件 SHA-256 一致。
|
|
20
|
+
- 当前边界:原因码不推断业务成功或服务端拒绝,也不授权重放;文档/上下文销毁导致异常诊断无法读取时保留原错误,diagnostics 可能缺失。已有 MCP 客户端需重连,重新发现和接管标签。
|
|
21
|
+
|
|
22
|
+
## 0.22.0 验证
|
|
23
|
+
|
|
7
24
|
- `npm test`:243 项通过。新增非活动标签的单次左键单击(ref / selector / viewport / document 坐标)仍取得可信回执且不激活标签;hover、拖拽、其他按键、双击与带修饰键的点击仍在派发前以 `TAB_NOT_READY` 拒绝;派发前的就绪检查允许左键单击期间的标签切换,仍拒绝视图冻结或目标丢弃,`page_state` 带 `tab_active`。
|
|
8
25
|
- Chromium 156、Edge 155 headless:`interaction-smoke --regressions` 各 35 个场景通过。后台 ref / selector / viewport / document / offset 各 3 次(每浏览器 15 次,合计 30 次)均 `delivery_verified` 且标签激活与窗口焦点状态不变;连续两次后台 batch、后台回执丢失(1 次效果、不重放)、后台遮挡(未派发)、受限动作组、settle 期间切换标签等场景通过。
|
|
9
26
|
- Chromium 156 `interaction-smoke` 默认场景 17 项通过,覆盖前台、后台 ref 与坐标、遮挡窗口、缩放、hover 偏移、闭合 Shadow DOM 与导航。
|
package/docs/USAGE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# BrowserAttach 使用指南
|
|
2
2
|
|
|
3
|
-
适用于 **0.
|
|
3
|
+
适用于 **0.23.0**:Node.js 22+、Chrome / Edge 125+、23 个 MCP 工具。安装、配对与升级见 [README](../README.md),宿主配置见 [Agent 集成](AGENT-INTEGRATION.md),内部机制见 [架构说明](ARCHITECTURE.md)。
|
|
4
4
|
|
|
5
5
|
本文供按需查阅,不会自动通过 MCP 发送给 agent。MCP 初始化 `instructions` 提供通用规则,工具 description 与参数 schema 提供调用说明;宿主应把 instructions 纳入模型上下文。
|
|
6
6
|
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
| `browser_open_tab` | 在指定配置中打开 HTTP(S) 标签 |
|
|
35
35
|
| `browser_claim_tab` | 独占接管已发现的标签 |
|
|
36
36
|
| `browser_tab_control` | 释放、激活或关闭标签 |
|
|
37
|
-
| `browser_snapshot` | AX
|
|
37
|
+
| `browser_snapshot` | AX 语义树或结构化字段数组、元素 ref、查询与 frame 覆盖 |
|
|
38
38
|
| `browser_read_text` | 正文、字段值或支持的编辑器模型 |
|
|
39
39
|
| `browser_fetch` | 使用登录态在临时后台标签读取一个 URL |
|
|
40
40
|
| `browser_click` | 原生点击或显式 DOM 点击 |
|
|
@@ -71,6 +71,20 @@
|
|
|
71
71
|
|
|
72
72
|
这些核验不代替业务检查。下拉过滤文字不等于选项已提交,字段值不等于框架状态已保存;按任务观察选中项、成功提示或业务接口。
|
|
73
73
|
|
|
74
|
+
定位与输入失败可按 `diagnostics.reason` 区分,无须解析英文错误文案:
|
|
75
|
+
|
|
76
|
+
| reason | 含义 |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `selector_not_found` | 准备预算内未找到 selector/nth 目标,或直接读取时目标不存在 |
|
|
79
|
+
| `selector_ambiguous` | CSS/XPath/label/placeholder/AX role 匹配不唯一;附有界候选与匹配总数 |
|
|
80
|
+
| `selector_invalid` / `selector_not_element` | 选择器解析失败 / 结果不是元素 |
|
|
81
|
+
| `value_not_retained` | fill 后实际文本与请求不一致;不回显请求值或实际值,不推断是格式化还是输入被吞 |
|
|
82
|
+
| `delivery_unverified` | 可信输入事件回执未在回执预算内完成 |
|
|
83
|
+
| `target_changed` | click/hover 派发期间的目标核验被拒绝,可能已有前序输入送达 |
|
|
84
|
+
| `preparation_timeout` / `input_timeout` | 无更具体准备原因的超时 / 已进入派发阶段后操作超时 |
|
|
85
|
+
|
|
86
|
+
已有 `stale_ref`、`frame_locator`、可操作性及遮挡原因保持各自语义。frame 路径查询失败仍以 `frame_locator` 为 reason,可在 cause 和 selector_diagnostics 中保留底层选择器原因。定位辅助脚本抛出的诊断通过 CDP 单独读取;文档已销毁或无法读取时保留原错误,诊断可能缺失。派发后超时不会沿用准备阶段的定位/可操作性原因。上述原因不是重放许可;结合 `input_dispatched` 与 `post_action` 判断下一步,不推断服务端业务拒绝。
|
|
87
|
+
|
|
74
88
|
### 结果未知时
|
|
75
89
|
|
|
76
90
|
超时、回执丢失或派发中断可能发生在动作生效之后。先用快照、截图或日志观察,再决定下一步;不要自动重放或换模式补发。导航和异步 JavaScript 也可能在超时后继续。
|
|
@@ -124,20 +138,21 @@ snapshot 返回 AX 语义树,每行 `[token:n] role "name" 状态`;`[token:n
|
|
|
124
138
|
|
|
125
139
|
| 参数 | 用途与默认值 |
|
|
126
140
|
|---|---|
|
|
127
|
-
| `profile` | `full`;interactive
|
|
141
|
+
| `profile` | `full`;interactive 保留控件、可聚焦/编辑节点及祖先链;fields 返回结构化字段数组,见下文 |
|
|
142
|
+
| `include_hidden` | 仅 fields,默认 false;true 额外读取未暴露到 AX 的原生字段,如隐藏 input |
|
|
128
143
|
| `text` | true;false 隐去独立正文,保留控件名称和值 |
|
|
129
144
|
| `text_limit` | 正文名称预览上限 0–10000;interactive 默认 80,full 默认不截断;控件名称和值保留完整 |
|
|
130
145
|
| `query` | 1–2000 字符的名称/value/context 字面子串,不区分大小写;零命中正常返回 |
|
|
131
146
|
| `max_nodes` / `max_chars` | 600 / 24000;范围分别为 1–3000 / 1–2000000,两个预算独立 |
|
|
132
147
|
| `verbose` | false;true 额外返回 viewport 和 snapshot_id |
|
|
133
148
|
|
|
134
|
-
查询在当前视图中匹配完整名称/value,再截取正文预览;不搜索 role、ref 或未暴露给 AX 的属性。阅读查询保留 full,interactive 会先筛掉正文。视图和查询减少返回量,不减少完整 AX 树采集。
|
|
149
|
+
full/interactive 查询在当前视图中匹配完整名称/value/context,再截取正文预览;不搜索 role、ref 或未暴露给 AX 的属性。阅读查询保留 full,interactive 会先筛掉正文。视图和查询减少返回量,不减少完整 AX 树采集。
|
|
135
150
|
|
|
136
151
|
```json
|
|
137
152
|
{"tab_handle":"...","query":"保存","max_nodes":20}
|
|
138
153
|
```
|
|
139
154
|
|
|
140
|
-
`next_cursor` 继续同一份观察,保留 refs。续读可省略 profile/text/text_limit/query
|
|
155
|
+
`next_cursor` 继续同一份观察,保留 refs。续读可省略 profile/text/text_limit/query/include_hidden;显式提供时须与原观察一致。单个节点或字段放不进预算会报出所需大小。主 frame、同进程 iframe 和跨进程 iframe(OOPIF)均可覆盖;未能读取或期间变化的 frame 见 omitted_frames。
|
|
141
156
|
|
|
142
157
|
| 事件 | ref / 快照分页的影响 |
|
|
143
158
|
|---|---|
|
|
@@ -154,6 +169,32 @@ ref 错误的 diagnostics.reason=stale_ref,cause 区分 snapshot_replaced、do
|
|
|
154
169
|
|
|
155
170
|
表单控件可附带 `context="性别"` 等邻近文本,在 interactive 或 text=false 下也保留,并参与 query。context 来自有限祖先范围内、仅含一个控件的容器中前一兄弟节点的 AX 文本;只是观察提示,不代表真实标签关联,不改写 AX name,也不参与 ref 身份校验。复杂布局可能没有 context。
|
|
156
171
|
|
|
172
|
+
### 结构化字段观察
|
|
173
|
+
|
|
174
|
+
```json
|
|
175
|
+
{"tab_handle":"...","profile":"fields"}
|
|
176
|
+
{"tab_handle":"...","profile":"fields","query":"TEXTS28"}
|
|
177
|
+
{"tab_handle":"...","profile":"fields","include_hidden":true,"query":"rawFee"}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
fields 视图用 `fields` 数组替代 snapshot 文本,保留重复标签和字段名,避免以标签作为键覆盖数据。`node_count/total_node_count` 与 max_nodes 计数字段,max_chars 仍按完整序列化项计算。`frames` 列出本次观察的 frame_id/URL,读取失败及变化见 omitted_frames。每行示例:
|
|
181
|
+
|
|
182
|
+
```json
|
|
183
|
+
{
|
|
184
|
+
"ref":"token:1", "frame_id":"frame-id",
|
|
185
|
+
"labels":["签证费"], "context":null,
|
|
186
|
+
"ax":{"role":"textbox","name":"签证费","value":"1,234.00","readonly":true},
|
|
187
|
+
"dom":{"tag":"input","id":"fee","name":"TEXTS28","type":"text","value":"1,234.00","value_source":"input_value"}
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
- `ax` 是浏览器提供的名称、角色、值及 disabled/readonly/required/invalid/checked/selected/expanded/multiselectable 状态;缺失属性不推断为 false。`labels` 来自浏览器 AX 名称的真实 HTML label 来源,context 则是现有有界附近文本,两者不改写 AX name。
|
|
192
|
+
- `dom.id/name/type/placeholder` 来自原生 DOM 快照属性;input 缺少 type 时记录 text。`dom.value` 的 value_source 区分 input_value、textarea_value、selected_option、text_content。select 附 multiple 和 selected_options 的值/标签;checkbox/radio 附原生 checked,AX checked=mixed 另行保留。contenteditable/output 的值是捕获的 textContent,不声称为编辑器完整模型。
|
|
193
|
+
- 默认包括暴露到 AX 的原生 input/textarea/select/output/contenteditable,以及 AX 字段角色的自定义控件;不把普通按钮当字段。文件 input 即使 AX 角色是 button 也识别为字段。include_hidden=true 才额外包括未暴露到 AX 的原生字段;此类行 ax/ref 可为 null,只提供可观察的 DOM 事实。密码字段省略 DOM 与 AX 值,返回 value_redacted=true。
|
|
194
|
+
- 文件 input 的 `dom.files={available,count,names}` 只报告隔离世界读取时的当前本地选择,不读取文件内容,不代表服务端附件数量或接受结果;读取失败为 available=false。上传后组件清空 input 时 count 可为 0。
|
|
195
|
+
- query 在 AX 名称/值、labels/context、DOM id/name/placeholder/value 和选中选项值/标签上作字面子串匹配。text/text_limit 只控制正文,不截断字段值。分页续读不重新采集;要核对当前值应发起新的字段观察。
|
|
196
|
+
- DOM 与 AX 按所属 frame/session 的 backend 节点身份关联,支持闭合 Shadow DOM 和 OOPIF。两次捕获之间节点被替换时不按 id/name 猜测新节点,只有 AX 事实的行 dom=null;无法判断原生字段类型时也省略 AX value。各 frame 与文件列表分步读取,不提供跨来源原子快照、业务提交值、组件内部模型或并发写入归属判断。读取最终保存结果仍需任务相关的页面或接口证据。
|
|
197
|
+
|
|
157
198
|
### browser_read_text
|
|
158
199
|
|
|
159
200
|
`browser_read_text({tab_handle})` 默认读取主 frame 的可见正文。可用 selector 限定范围,或用 frame/frame_id 选择子 frame。
|
|
@@ -6,7 +6,7 @@ export function deadline(timeout_ms = 5000, phase = 'input', parentEnd = Infinit
|
|
|
6
6
|
let dispatched = false, waiting, diagnostics;
|
|
7
7
|
function error() {
|
|
8
8
|
const result = timeoutError();
|
|
9
|
-
if (diagnostics) result.diagnostics = diagnostics;
|
|
9
|
+
if (diagnostics && !dispatched) result.diagnostics = diagnostics;
|
|
10
10
|
return result;
|
|
11
11
|
}
|
|
12
12
|
function timeoutError() {
|
|
@@ -19,6 +19,7 @@ export function deadline(timeout_ms = 5000, phase = 'input', parentEnd = Infinit
|
|
|
19
19
|
: `Target did not become actionable within ${timeout_ms} ms${waiting ? `; waiting for ${waiting}` : ''}; no input was dispatched.`);
|
|
20
20
|
result.input_dispatched = dispatched;
|
|
21
21
|
result.outcome_unknown = dispatched;
|
|
22
|
+
result.diagnostics = { reason: dispatched ? 'input_timeout' : 'preparation_timeout' };
|
|
22
23
|
return result;
|
|
23
24
|
}
|
|
24
25
|
async function run(operation) {
|
|
@@ -91,6 +92,8 @@ export function inspectTarget(editable) {
|
|
|
91
92
|
export async function delivery(operation, ms = 1250) {
|
|
92
93
|
let timer;
|
|
93
94
|
try { return await Promise.race([operation, new Promise((resolve, reject) => {
|
|
94
|
-
timer = setTimeout(() => reject(Object.assign(new Error('Native input has no verified receipt. Outcome is unknown. Browser UI may be capturing input; press Esc to dismiss a native menu, then observe. Never automatically replay or fall back.'), {
|
|
95
|
+
timer = setTimeout(() => reject(Object.assign(new Error('Native input has no verified receipt. Outcome is unknown. Browser UI may be capturing input; press Esc to dismiss a native menu, then observe. Never automatically replay or fall back.'), {
|
|
96
|
+
outcome_unknown: true, diagnostics: { reason: 'delivery_unverified' },
|
|
97
|
+
})), ms);
|
|
95
98
|
})]); } finally { clearTimeout(timer); }
|
|
96
99
|
}
|
package/extension/click.js
CHANGED
|
@@ -14,7 +14,9 @@ export function createClick(cdp, value, { prepare, assertReady }) {
|
|
|
14
14
|
try {
|
|
15
15
|
const receipt = JSON.parse(params.payload);
|
|
16
16
|
if (receipt.matched === true) operation.resolve();
|
|
17
|
-
else operation.reject(Object.assign(new Error('Click target changed or became covered during mouse dispatch. Further incorrect target input was blocked; outcome is unknown. Observe again before retrying.'), {
|
|
17
|
+
else operation.reject(Object.assign(new Error('Click target changed or became covered during mouse dispatch. Further incorrect target input was blocked; outcome is unknown. Observe again before retrying.'), {
|
|
18
|
+
outcome_unknown: true, diagnostics: { reason: 'target_changed' },
|
|
19
|
+
}));
|
|
18
20
|
} catch (error) { operation.reject(error); }
|
|
19
21
|
}
|
|
20
22
|
function cancel(tabId) { pending.get(tabId)?.reject(Object.assign(new Error('Tab detached during click; outcome is unknown. Observe before retrying.'), { outcome_unknown: true })); }
|
package/extension/errors.js
CHANGED
|
@@ -11,3 +11,21 @@ export function errorFromData(data) {
|
|
|
11
11
|
for (const key of fields) if (data[key] !== undefined) error[key] = data[key];
|
|
12
12
|
return error;
|
|
13
13
|
}
|
|
14
|
+
|
|
15
|
+
// CDP exception descriptions omit custom properties on errors thrown by our
|
|
16
|
+
// isolated-world helpers. Read their diagnostics only on the failure path.
|
|
17
|
+
export async function checkedValue(result, send, value) {
|
|
18
|
+
if (!result.exceptionDetails) return value(result);
|
|
19
|
+
let diagnostics;
|
|
20
|
+
const objectId = result.exceptionDetails.exception?.objectId;
|
|
21
|
+
if (objectId) {
|
|
22
|
+
try {
|
|
23
|
+
const details = await send('Runtime.callFunctionOn', { objectId, returnByValue: true,
|
|
24
|
+
functionDeclaration: 'function() { return Object.getOwnPropertyDescriptor(this, "diagnostics")?.value; }' });
|
|
25
|
+
if (!details.exceptionDetails) diagnostics = details.result?.value;
|
|
26
|
+
} catch {}
|
|
27
|
+
finally { await send('Runtime.releaseObject', { objectId }).catch(() => {}); }
|
|
28
|
+
}
|
|
29
|
+
try { return value(result); }
|
|
30
|
+
catch (error) { if (typeof diagnostics?.reason === 'string') error.diagnostics = diagnostics; throw error; }
|
|
31
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { axContext, FIELD_ROLES, registerAXRef } from './snapshot.js';
|
|
2
|
+
|
|
3
|
+
const INPUT_BUTTONS = new Set(['button', 'submit', 'reset', 'image']);
|
|
4
|
+
const AX_STATES = new Set(['disabled', 'readonly', 'required', 'invalid', 'checked', 'selected', 'expanded', 'multiselectable']);
|
|
5
|
+
|
|
6
|
+
// One native DOM capture per debugger session, joined by frame and backend id.
|
|
7
|
+
// No DOM query, component getter or per-field resolve is needed for ordinary fields.
|
|
8
|
+
export function createFieldReader(send, value) {
|
|
9
|
+
const captures = new Map();
|
|
10
|
+
return async (target, frameId, nodes, token, refs, options) => {
|
|
11
|
+
const key = target.sessionId ?? '';
|
|
12
|
+
if (!captures.has(key)) captures.set(key, await send(target, 'DOMSnapshot.captureSnapshot', { computedStyles: [] }));
|
|
13
|
+
const capture = captures.get(key);
|
|
14
|
+
const document = capture.documents.find(doc => capture.strings[doc.frameId] === frameId);
|
|
15
|
+
if (!document) throw new Error('Frame document is unavailable in the DOM field capture');
|
|
16
|
+
const entries = renderFields(nodes, document.nodes, capture.strings, token, refs, frameId, target.sessionId, options);
|
|
17
|
+
const files = entries.filter(entry => entry.field.dom?.tag === 'input' && entry.field.dom.type === 'file');
|
|
18
|
+
if (files.length) {
|
|
19
|
+
const group = 'browser-attach-fields';
|
|
20
|
+
try {
|
|
21
|
+
const { executionContextId } = await send(target, 'Page.createIsolatedWorld', { frameId, worldName: group });
|
|
22
|
+
for (const entry of files) {
|
|
23
|
+
try {
|
|
24
|
+
const { object } = await send(target, 'DOM.resolveNode', { backendNodeId: entry.backendNodeId, executionContextId, objectGroup: group });
|
|
25
|
+
if (!object?.objectId) throw new Error('File input detached');
|
|
26
|
+
entry.field.dom.files = value(await send(target, 'Runtime.callFunctionOn', { objectId: object.objectId,
|
|
27
|
+
functionDeclaration: readFileSelection.toString(), returnByValue: true }));
|
|
28
|
+
} catch (error) { entry.field.dom.files = { available: false, error: error.message }; }
|
|
29
|
+
}
|
|
30
|
+
} finally { await send(target, 'Runtime.releaseObjectGroup', { objectGroup: group }).catch(() => {}); }
|
|
31
|
+
}
|
|
32
|
+
return entries;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readFileSelection() {
|
|
37
|
+
if (!this.isConnected || this.tagName !== 'INPUT' || this.type !== 'file') return { available: false, error: 'File input changed during observation' };
|
|
38
|
+
return { available: true, count: this.files.length, names: Array.from(this.files, file => file.name) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function renderFields(axNodes, domNodes, strings, token, refs, frameId, sessionId, options) {
|
|
42
|
+
const { fieldContext } = axContext(axNodes);
|
|
43
|
+
const axByBackend = new Map();
|
|
44
|
+
for (const node of axNodes) if (node.backendDOMNodeId && (!node.ignored || !axByBackend.has(node.backendDOMNodeId))) axByBackend.set(node.backendDOMNodeId, node);
|
|
45
|
+
const rare = data => new Map((data?.index || []).map((index, n) => [index, data.value?.[n]]));
|
|
46
|
+
const inputValues = rare(domNodes.inputValue), textValues = rare(domNodes.textValue);
|
|
47
|
+
const checked = new Set(domNodes.inputChecked?.index || []), selected = new Set(domNodes.optionSelected?.index || []);
|
|
48
|
+
const children = new Map();
|
|
49
|
+
for (let i = 0; i < domNodes.parentIndex.length; i++) {
|
|
50
|
+
const parent = domNodes.parentIndex[i];
|
|
51
|
+
if (!children.has(parent)) children.set(parent, []);
|
|
52
|
+
children.get(parent).push(i);
|
|
53
|
+
}
|
|
54
|
+
const attributes = index => {
|
|
55
|
+
const list = domNodes.attributes[index] || [], result = new Map();
|
|
56
|
+
for (let i = 0; i < list.length; i += 2) result.set(strings[list[i]], strings[list[i + 1]]);
|
|
57
|
+
return result;
|
|
58
|
+
};
|
|
59
|
+
function descendants(index, visit) {
|
|
60
|
+
const pending = [index];
|
|
61
|
+
while (pending.length) {
|
|
62
|
+
const current = pending.pop(); visit(current);
|
|
63
|
+
const list = children.get(current) || [];
|
|
64
|
+
for (let i = list.length - 1; i >= 0; i--) pending.push(list[i]);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function textContent(index) {
|
|
68
|
+
const parts = [];
|
|
69
|
+
descendants(index, current => { if (domNodes.nodeType[current] === 3) parts.push(strings[domNodes.nodeValue[current]] || ''); });
|
|
70
|
+
return parts.join('');
|
|
71
|
+
}
|
|
72
|
+
const entries = [], seen = new Set(), query = options.query?.toLowerCase();
|
|
73
|
+
function add(node, dom, backendNodeId) {
|
|
74
|
+
const protectedValue = dom?.tag === 'input' && dom.type === 'password';
|
|
75
|
+
const ax = node && !node.ignored ? { role: node.role?.value, name: String(node.name?.value || '') } : null;
|
|
76
|
+
if (ax) {
|
|
77
|
+
if (dom && node.value?.value != null && !protectedValue) ax.value = node.value.value;
|
|
78
|
+
for (const property of node.properties || []) if (AX_STATES.has(property.name)) ax[property.name] = property.value?.value;
|
|
79
|
+
}
|
|
80
|
+
const labels = [];
|
|
81
|
+
for (const source of node?.name?.sources || []) {
|
|
82
|
+
if (!['label', 'labelfor', 'labelwrapped'].includes(source.nativeSource) || source.invalid) continue;
|
|
83
|
+
const related = source.nativeSourceValue?.relatedNodes ?? source.value?.relatedNodes ?? [];
|
|
84
|
+
const texts = related.map(item => item.text).filter(text => typeof text === 'string');
|
|
85
|
+
if (!texts.length && typeof source.value?.value === 'string') texts.push(source.value.value);
|
|
86
|
+
for (const text of texts) { const label = text.replace(/\s+/g, ' ').trim(); if (label && !labels.includes(label)) labels.push(label); }
|
|
87
|
+
}
|
|
88
|
+
const context = ax ? fieldContext(node) : null;
|
|
89
|
+
const field = { ref: null, frame_id: frameId, labels, context, ax, dom };
|
|
90
|
+
const terms = [ax?.name, ax?.value, context, ...labels, dom?.id, dom?.name, dom?.placeholder, dom?.value,
|
|
91
|
+
...(dom?.selected_options || []).flatMap(option => [option.value, option.label])];
|
|
92
|
+
if (query != null && !terms.some(term => term != null && String(term).toLowerCase().includes(query))) return;
|
|
93
|
+
if (ax) field.ref = registerAXRef(node, token, refs, frameId, sessionId);
|
|
94
|
+
entries.push({ node: true, frameId, backendNodeId, field });
|
|
95
|
+
}
|
|
96
|
+
for (let index = 0; index < domNodes.backendNodeId.length; index++) {
|
|
97
|
+
if (domNodes.nodeType[index] !== 1) continue;
|
|
98
|
+
const backendNodeId = domNodes.backendNodeId[index], node = axByBackend.get(backendNodeId);
|
|
99
|
+
const attrs = attributes(index), tag = strings[domNodes.nodeName[index]].toLowerCase();
|
|
100
|
+
const type = tag === 'input' ? (attrs.get('type') || 'text').toLowerCase() : null;
|
|
101
|
+
const editable = attrs.has('contenteditable') && ['', 'true', 'plaintext-only'].includes(attrs.get('contenteditable').toLowerCase());
|
|
102
|
+
const native = tag === 'input' && !INPUT_BUTTONS.has(type) || ['textarea', 'select', 'output'].includes(tag) || editable;
|
|
103
|
+
if (!native && (!node || node.ignored || !FIELD_ROLES.has(node.role?.value))) continue;
|
|
104
|
+
if ((!node || node.ignored) && !options.include_hidden) continue;
|
|
105
|
+
seen.add(backendNodeId);
|
|
106
|
+
const dom = { tag, id: attrs.get('id') ?? null, name: attrs.get('name') ?? null };
|
|
107
|
+
if (type != null) dom.type = type;
|
|
108
|
+
if (attrs.has('placeholder')) dom.placeholder = attrs.get('placeholder');
|
|
109
|
+
if (type === 'password') dom.value_redacted = true;
|
|
110
|
+
else if (tag === 'input' && inputValues.has(index)) { dom.value = strings[inputValues.get(index)]; dom.value_source = 'input_value'; }
|
|
111
|
+
else if (tag === 'textarea' && textValues.has(index)) { dom.value = strings[textValues.get(index)]; dom.value_source = 'textarea_value'; }
|
|
112
|
+
else if (tag === 'select') {
|
|
113
|
+
dom.multiple = attrs.has('multiple'); dom.selected_options = [];
|
|
114
|
+
descendants(index, current => {
|
|
115
|
+
if (strings[domNodes.nodeName[current]] !== 'OPTION' || !selected.has(current)) return;
|
|
116
|
+
const optionAttrs = attributes(current), text = textContent(current).replace(/[\t\n\f\r ]+/g, ' ').replace(/^ | $/g, '');
|
|
117
|
+
dom.selected_options.push({ value: optionAttrs.get('value') ?? text, label: optionAttrs.get('label') ?? text });
|
|
118
|
+
});
|
|
119
|
+
dom.value = dom.selected_options[0]?.value ?? ''; dom.value_source = 'selected_option';
|
|
120
|
+
} else if (editable || tag === 'output') { dom.value = textContent(index); dom.value_source = 'text_content'; }
|
|
121
|
+
if (['checkbox', 'radio'].includes(type)) dom.checked = checked.has(index);
|
|
122
|
+
add(node, dom, backendNodeId);
|
|
123
|
+
}
|
|
124
|
+
// A DOM replacement between the AX and DOM captures must not be joined to
|
|
125
|
+
// a new element just because its name/id looks the same.
|
|
126
|
+
for (const node of axNodes) if (!node.ignored && node.backendDOMNodeId && !seen.has(node.backendDOMNodeId) && FIELD_ROLES.has(node.role?.value)) {
|
|
127
|
+
add(node, null, node.backendDOMNodeId);
|
|
128
|
+
}
|
|
129
|
+
return entries;
|
|
130
|
+
}
|
package/extension/input.js
CHANGED
|
@@ -127,7 +127,9 @@ export function createInput(cdp, value, sessions) {
|
|
|
127
127
|
if (mode === 'native') await send('Input.insertText', { text: args.text });
|
|
128
128
|
const matches = value(await send('Runtime.callFunctionOn', { objectId: node.objectId, returnByValue: true,
|
|
129
129
|
functionDeclaration: verifyFill.toString(), arguments: [{ value: args.text }] }));
|
|
130
|
-
if (matches !== true) throw Object.assign(new Error(`Fill did not retain the requested text. Input may have been swallowed, focus may have moved, or the page may have reformatted it. Outcome is unknown. ${BUSY}`), {
|
|
130
|
+
if (matches !== true) throw Object.assign(new Error(`Fill did not retain the requested text. Input may have been swallowed, focus may have moved, or the page may have reformatted it. Outcome is unknown. ${BUSY}`), {
|
|
131
|
+
outcome_unknown: true, diagnostics: { reason: 'value_not_retained', frame_id: node.frameId },
|
|
132
|
+
});
|
|
131
133
|
return { filled: true, method: mode, value_verified: true, ...(mode === 'dom' ? { events_trusted: false } : {}), application_result: 'not_checked', observe_again: true };
|
|
132
134
|
}
|
|
133
135
|
|
package/extension/locator.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { checkedValue } from './errors.js';
|
|
2
|
+
|
|
1
3
|
// Selectors are reusable queries, never identities inferred from old AX text.
|
|
2
4
|
export function targetOptions(args, { coordinates = false, focused = false } = {}) {
|
|
3
5
|
const hasRef = args.ref != null, hasSelector = args.selector != null;
|
|
@@ -19,19 +21,26 @@ export function targetOptions(args, { coordinates = false, focused = false } = {
|
|
|
19
21
|
|
|
20
22
|
export function queryElement(selector, type = 'css', nth, waitForMatch = false) {
|
|
21
23
|
if (nth != null && (!Number.isSafeInteger(nth) || nth < 0)) throw new Error('nth must be a nonnegative safe integer (zero-based)');
|
|
24
|
+
const failure = (message, reason, detail = {}) => Object.assign(new Error(message), {
|
|
25
|
+
diagnostics: { reason, selector_type: type, ...detail },
|
|
26
|
+
});
|
|
22
27
|
let element, count, at;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
Array.from(
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
28
|
+
try {
|
|
29
|
+
if (type === 'xpath') {
|
|
30
|
+
const result = document.evaluate(selector, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
|
|
31
|
+
count = result.snapshotLength; at = index => result.snapshotItem(index);
|
|
32
|
+
} else {
|
|
33
|
+
const normalize = text => text.replace(/\s+/g, ' ').trim();
|
|
34
|
+
const elements = type === 'label'
|
|
35
|
+
? Array.from(document.querySelectorAll('input,textarea,select,button,meter,output,progress')).filter(element =>
|
|
36
|
+
Array.from(element.labels || []).some(label => normalize(label.textContent || '') === normalize(selector)))
|
|
37
|
+
: type === 'placeholder'
|
|
38
|
+
? Array.from(document.querySelectorAll('[placeholder]')).filter(element => element.getAttribute('placeholder') === selector)
|
|
39
|
+
: document.querySelectorAll(selector);
|
|
40
|
+
count = elements.length; at = index => elements[index];
|
|
41
|
+
}
|
|
42
|
+
} catch (error) {
|
|
43
|
+
throw failure(error.message, 'selector_invalid');
|
|
35
44
|
}
|
|
36
45
|
if (waitForMatch && (count === 0 || (nth != null && nth >= count))) return null;
|
|
37
46
|
if (nth == null && count !== 1) {
|
|
@@ -43,11 +52,12 @@ export function queryElement(selector, type = 'css', nth, waitForMatch = false)
|
|
|
43
52
|
disabled: node.matches(':disabled') || !!node.closest('[inert], [aria-disabled="true"]'),
|
|
44
53
|
visible: rect.width > 0 && rect.height > 0 && style.visibility === 'visible' };
|
|
45
54
|
});
|
|
46
|
-
throw
|
|
55
|
+
throw failure(`Selector must match exactly one element; matched ${count}. Scope the selector or provide an explicit zero-based nth. Candidates: ${JSON.stringify(candidates)}${count > candidates.length ? ` (${count - candidates.length} more omitted)` : ''}`,
|
|
56
|
+
count ? 'selector_ambiguous' : 'selector_not_found', { matched: count, candidates, omitted_candidates: Math.max(0, count - candidates.length) });
|
|
47
57
|
}
|
|
48
|
-
if (nth != null && nth >= count) throw
|
|
58
|
+
if (nth != null && nth >= count) throw failure(`Selector nth=${nth} is out of range; matched ${count}`, 'selector_not_found', { matched: count, nth });
|
|
49
59
|
element = at(nth ?? 0);
|
|
50
|
-
if (element.nodeType !== 1) throw
|
|
60
|
+
if (element.nodeType !== 1) throw failure('Selector must resolve to an element', 'selector_not_element');
|
|
51
61
|
return element;
|
|
52
62
|
}
|
|
53
63
|
|
|
@@ -66,22 +76,27 @@ export async function resolveSelector(cdp, value, sessions, tabId, args, budget,
|
|
|
66
76
|
} else {
|
|
67
77
|
const result = await send('Runtime.evaluate', { contextId: context.executionContextId, objectGroup: 'browser-attach',
|
|
68
78
|
expression: `(${queryElement.toString()})(${JSON.stringify(args.selector)},${JSON.stringify(args.selector_type ?? 'css')},${args.nth ?? 'undefined'},true)` });
|
|
69
|
-
|
|
79
|
+
await checkedValue(result, send, value);
|
|
70
80
|
if (result.result?.subtype !== 'null') {
|
|
71
|
-
if (!result.result?.objectId) throw new Error('Selector did not resolve to an element')
|
|
81
|
+
if (!result.result?.objectId) throw Object.assign(new Error('Selector did not resolve to an element'), {
|
|
82
|
+
diagnostics: { reason: 'selector_not_element', frame_id: frameId },
|
|
83
|
+
});
|
|
72
84
|
const { node } = await send('DOM.describeNode', { objectId: result.result.objectId });
|
|
73
85
|
backendNodeId = node.backendNodeId;
|
|
74
86
|
}
|
|
75
87
|
}
|
|
76
88
|
if (backendNodeId != null) break;
|
|
77
89
|
if (!wait) return null;
|
|
78
|
-
budget.describe?.(`selector ${JSON.stringify(args.selector)}${args.name == null ? '' : ` name=${JSON.stringify(args.name)}`}${args.nth == null ? '' : ` nth=${args.nth}`} to appear; current=no matching element
|
|
90
|
+
budget.describe?.(`selector ${JSON.stringify(args.selector)}${args.name == null ? '' : ` name=${JSON.stringify(args.name)}`}${args.nth == null ? '' : ` nth=${args.nth}`} to appear; current=no matching element`,
|
|
91
|
+
{ reason: 'selector_not_found', selector_type: args.selector_type ?? 'css', frame_id: frameId, ...(args.nth == null ? {} : { nth: args.nth }) });
|
|
79
92
|
await budget.run(() => new Promise(resolve => setTimeout(resolve, 20)));
|
|
80
93
|
}
|
|
81
94
|
// Hit-test refs resolve in the default world too. CDP forbids passing object
|
|
82
95
|
// arguments between worlds, even when both wrappers refer to the same node.
|
|
83
96
|
const resolved = await send('DOM.resolveNode', { backendNodeId, objectGroup: 'browser-attach' });
|
|
84
|
-
if (!resolved.object?.objectId) throw new Error('Selector target detached before resolution; no input was dispatched')
|
|
97
|
+
if (!resolved.object?.objectId) throw Object.assign(new Error('Selector target detached before resolution; no input was dispatched'), {
|
|
98
|
+
diagnostics: { reason: 'element_detached', frame_id: frameId },
|
|
99
|
+
});
|
|
85
100
|
return { target, frameId, objectId: resolved.object.objectId, backendNodeId };
|
|
86
101
|
}
|
|
87
102
|
|
|
@@ -98,7 +113,13 @@ export function matchRole(nodes, args) {
|
|
|
98
113
|
for (let i = (node.childIds?.length || 0) - 1; i >= 0; i--) pending.push(byId.get(node.childIds[i]));
|
|
99
114
|
}
|
|
100
115
|
const candidates = [...matches.values()];
|
|
101
|
-
if (args.nth == null && candidates.length > 1)
|
|
116
|
+
if (args.nth == null && candidates.length > 1) {
|
|
117
|
+
const preview = candidates.slice(0, 10).map((node, nth) => ({ nth, role: node.role?.value, name: String(node.name?.value || '').slice(0, 120) }));
|
|
118
|
+
throw Object.assign(new Error(`Role selector must match exactly one element; matched ${candidates.length}. Provide name or an explicit zero-based nth. Candidates: ${JSON.stringify(preview)}`), {
|
|
119
|
+
diagnostics: { reason: 'selector_ambiguous', selector_type: 'role', matched: candidates.length,
|
|
120
|
+
candidates: preview, omitted_candidates: Math.max(0, candidates.length - preview.length) },
|
|
121
|
+
});
|
|
122
|
+
}
|
|
102
123
|
return candidates[args.nth ?? 0] || null;
|
|
103
124
|
}
|
|
104
125
|
|
|
@@ -116,7 +137,7 @@ async function resolveFrameOnce(cdp, value, sessions, tabId, args, budget) {
|
|
|
116
137
|
const frames = await budget.run(() => sessions.frames(tabId));
|
|
117
138
|
const failure = (error, scope = {}) => {
|
|
118
139
|
const candidates = frames.slice(0, 10).map(frame => ({ frame_id: frame.id, parent_frame_id: frame.parentId ?? null, url: frame.url }));
|
|
119
|
-
error.diagnostics = { reason: 'frame_locator', ...scope, candidates, omitted_candidates: Math.max(0, frames.length - candidates.length) };
|
|
140
|
+
error.diagnostics = { reason: 'frame_locator', ...(error.diagnostics ? { cause: error.diagnostics.reason, selector_diagnostics: error.diagnostics } : {}), ...scope, candidates, omitted_candidates: Math.max(0, frames.length - candidates.length) };
|
|
120
141
|
error.message += `; frame scope=${JSON.stringify(scope)}; current frames=${JSON.stringify(candidates)}${frames.length > candidates.length ? ` (${frames.length - candidates.length} more omitted)` : ''}`;
|
|
121
142
|
return error;
|
|
122
143
|
};
|
|
@@ -134,7 +155,7 @@ async function resolveFrameOnce(cdp, value, sessions, tabId, args, budget) {
|
|
|
134
155
|
const send = (method, params) => budget.run(() => cdp(target, method, params));
|
|
135
156
|
const { executionContextId } = await send('Page.createIsolatedWorld', { frameId, worldName: 'browser-attach-locator' });
|
|
136
157
|
const result = await send('Runtime.evaluate', { contextId: executionContextId, expression: `(${queryElement.toString()})(${JSON.stringify(selector)},"css")`, objectGroup: 'browser-attach-frame' });
|
|
137
|
-
|
|
158
|
+
await checkedValue(result, send, value);
|
|
138
159
|
try {
|
|
139
160
|
const { node } = await send('DOM.describeNode', { objectId: result.result.objectId });
|
|
140
161
|
if (!['IFRAME', 'FRAME'].includes(node.nodeName)) throw new Error('frame.path must resolve to iframe/frame elements');
|
package/extension/manifest.json
CHANGED
package/extension/observation.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { renderAX, snapshotOptions, snapshotPage } from './snapshot.js';
|
|
2
|
+
import { createFieldReader } from './fields.js';
|
|
2
3
|
|
|
3
4
|
// Observation identity is separate from debugger session ownership.
|
|
4
5
|
export function createObservations(cdp, value, sessions, viewport, currentClaim, changed) {
|
|
@@ -130,6 +131,7 @@ export function createObservations(cdp, value, sessions, viewport, currentClaim,
|
|
|
130
131
|
const knownFrames = new Set(frames.map(frame => frame.id));
|
|
131
132
|
const token = crypto.randomUUID().slice(0, 8);
|
|
132
133
|
const refs = new Map(), entries = [], omittedFrames = [];
|
|
134
|
+
const readFields = options.profile === 'fields' ? createFieldReader(send, value) : null;
|
|
133
135
|
for (const frame of frames) {
|
|
134
136
|
let nodes;
|
|
135
137
|
let target;
|
|
@@ -156,15 +158,27 @@ export function createObservations(cdp, value, sessions, viewport, currentClaim,
|
|
|
156
158
|
const url = srcIndex >= 0 ? new URL(attributes[srcIndex + 1], frame.url).href : 'about:blank';
|
|
157
159
|
knownFrames.add(childId); frames.push({ id: childId, url });
|
|
158
160
|
}
|
|
159
|
-
|
|
160
|
-
if (
|
|
161
|
+
let rendered;
|
|
162
|
+
if (readFields) {
|
|
163
|
+
try { rendered = await readFields(target, frame.id, nodes, token, refs, options); }
|
|
164
|
+
catch (error) {
|
|
165
|
+
if (frame.id === mainId) throw error;
|
|
166
|
+
for (const [ref, node] of refs) if (node.frameId === frame.id) refs.delete(ref);
|
|
167
|
+
omittedFrames.push({ frame_id: frame.id, url: frame.url, reason: error.message });
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
rendered = renderAX(nodes, token, refs, frame.id, target.sessionId, options);
|
|
172
|
+
if (rendered.length || options.query == null) entries.push({ node: false, frameId: frame.id, line: `Frame ${frame.id} ${frame.url}` });
|
|
173
|
+
}
|
|
161
174
|
for (const entry of rendered) entries.push(entry);
|
|
162
175
|
}
|
|
163
176
|
const metadata = value(await send(tabId, 'Runtime.evaluate', {
|
|
164
177
|
expression: '({title: document.title, url: location.href})', returnByValue: true,
|
|
165
178
|
}));
|
|
166
179
|
const state = { id: token, options, refs, entries, invalidFrames: new Set(), totalNodes: entries.filter(entry => entry.node).length,
|
|
167
|
-
metadata: { title: metadata.title, url: metadata.url, viewport: await (budget ? budget.run(() => viewport(tabId)) : viewport(tabId)), omitted_frames: omittedFrames
|
|
180
|
+
metadata: { title: metadata.title, url: metadata.url, viewport: await (budget ? budget.run(() => viewport(tabId)) : viewport(tabId)), omitted_frames: omittedFrames,
|
|
181
|
+
...(readFields ? { frames: frames.map(frame => ({ frame_id: frame.id, url: frame.url })) } : {}) } };
|
|
168
182
|
if (documentVersion !== documents.get(tabId)) throw new Error('Document changed while reading the snapshot. Take a new browser_snapshot.');
|
|
169
183
|
for (const frame of frames) if (versions.get(frame.id) !== frameStates.get(tabId)?.versions.get(frame.id)) {
|
|
170
184
|
omitFrame(tabId, state, frame.id, 'Frame changed while reading the snapshot; take a new snapshot to observe it');
|
package/extension/snapshot.js
CHANGED
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
// Render semantic AX nodes once; page through the same observation without losing refs.
|
|
2
|
-
export function snapshotOptions({ profile = 'full', text = true, text_limit, query } = {}) {
|
|
3
|
-
if (!['full', 'interactive'].includes(profile)) throw new Error('profile must be full or
|
|
2
|
+
export function snapshotOptions({ profile = 'full', text = true, text_limit, query, include_hidden } = {}) {
|
|
3
|
+
if (!['full', 'interactive', 'fields'].includes(profile)) throw new Error('profile must be full, interactive or fields');
|
|
4
4
|
if (typeof text !== 'boolean') throw new Error('text must be a boolean');
|
|
5
5
|
if (text_limit != null && (!Number.isInteger(text_limit) || text_limit < 0 || text_limit > 10000)) throw new Error('text_limit must be 0..10000');
|
|
6
6
|
if (query != null && (typeof query !== 'string' || !query.trim() || query.length > 2000)) throw new Error('query must contain 1..2000 characters and not be blank');
|
|
7
|
-
|
|
7
|
+
if (include_hidden != null && (profile !== 'fields' || typeof include_hidden !== 'boolean')) throw new Error('include_hidden is a boolean for profile=fields only');
|
|
8
|
+
return { profile, text, text_limit: text_limit ?? (profile === 'interactive' ? 80 : null), ...(query != null ? { query } : {}),
|
|
9
|
+
...(profile === 'fields' ? { include_hidden: include_hidden ?? false } : {}) };
|
|
8
10
|
}
|
|
9
11
|
|
|
10
|
-
export
|
|
12
|
+
export const FIELD_ROLES = new Set(['checkbox', 'combobox', 'listbox', 'radio', 'searchbox', 'slider', 'spinbutton', 'switch', 'textbox']);
|
|
13
|
+
const CONTROLS = new Set(['button', 'checkbox', 'combobox', 'DisclosureTriangle', 'link', 'listbox', 'menuitem',
|
|
14
|
+
'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'scrollbar', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab', 'textbox', 'treeitem']);
|
|
15
|
+
|
|
16
|
+
export function axContext(nodes) {
|
|
11
17
|
const byId = new Map(nodes.map(node => [node.nodeId, node]));
|
|
12
|
-
const visited = new Set();
|
|
13
|
-
const entries = [];
|
|
14
|
-
const query = options.query?.toLowerCase();
|
|
15
|
-
const textRoles = new Set(['StaticText', 'InlineTextBox', 'ListMarker']);
|
|
16
|
-
const controls = new Set(['button', 'checkbox', 'combobox', 'DisclosureTriangle', 'link', 'listbox', 'menuitem',
|
|
17
|
-
'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'scrollbar', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab', 'textbox', 'treeitem']);
|
|
18
|
-
const fields = new Set(['checkbox', 'combobox', 'radio', 'searchbox', 'slider', 'spinbutton', 'switch', 'textbox']);
|
|
19
18
|
const parents = new Map();
|
|
20
19
|
for (const node of nodes) for (const child of node.childIds || []) parents.set(child, node.nodeId);
|
|
21
20
|
function contextText(node) {
|
|
@@ -53,7 +52,7 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
|
|
|
53
52
|
while (pending.length && ++visited <= 64 && count < 2) {
|
|
54
53
|
const current = pending.pop();
|
|
55
54
|
if (!current) return null;
|
|
56
|
-
if (!current.ignored &&
|
|
55
|
+
if (!current.ignored && CONTROLS.has(current.role?.value)) count++;
|
|
57
56
|
if (visited + pending.length + (current.childIds?.length || 0) > 64) return null;
|
|
58
57
|
for (const id of current.childIds || []) pending.push(byId.get(id));
|
|
59
58
|
}
|
|
@@ -67,6 +66,21 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
|
|
|
67
66
|
}
|
|
68
67
|
return null;
|
|
69
68
|
}
|
|
69
|
+
return { byId, parents, fieldContext };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function registerAXRef(node, token, refs, frameId, sessionId) {
|
|
73
|
+
if (!node.backendDOMNodeId || node.ignored) return null;
|
|
74
|
+
const ref = `${token}:${refs.size + 1}`;
|
|
75
|
+
refs.set(ref, { backendNodeId: node.backendDOMNodeId, frameId, sessionId, role: node.role?.value, name: String(node.name?.value || '') });
|
|
76
|
+
return ref;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function renderAX(nodes, token, refs, frameId, sessionId, options = snapshotOptions()) {
|
|
80
|
+
const { byId, parents, fieldContext } = axContext(nodes);
|
|
81
|
+
const visited = new Set(), entries = [];
|
|
82
|
+
const query = options.query?.toLowerCase();
|
|
83
|
+
const textRoles = new Set(['StaticText', 'InlineTextBox', 'ListMarker']);
|
|
70
84
|
let kept;
|
|
71
85
|
if (options.profile === 'interactive') {
|
|
72
86
|
kept = new Set();
|
|
@@ -82,7 +96,7 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
|
|
|
82
96
|
}
|
|
83
97
|
}
|
|
84
98
|
for (const node of nodes) {
|
|
85
|
-
if (node.ignored || !node.backendDOMNodeId || !(
|
|
99
|
+
if (node.ignored || !node.backendDOMNodeId || !(CONTROLS.has(node.role?.value) ||
|
|
86
100
|
node.properties?.some(prop => prop.name === 'focusable' && prop.value?.value === true ||
|
|
87
101
|
prop.name === 'editable' && prop.value?.value && prop.value.value !== 'false'))) continue;
|
|
88
102
|
for (const child of node.childIds || []) keepText(byId.get(child));
|
|
@@ -128,7 +142,7 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
|
|
|
128
142
|
['disabled', 'focused', 'expanded', 'checked', 'selected', 'required', 'invalid', 'level'].includes(prop.name)
|
|
129
143
|
).map(prop => `${prop.name}=${JSON.stringify(prop.value?.value)}`);
|
|
130
144
|
if (node.value?.value != null) states.push(`value=${JSON.stringify(node.value.value)}`);
|
|
131
|
-
const context =
|
|
145
|
+
const context = FIELD_ROLES.has(role) && !node.ignored ? fieldContext(node) : null;
|
|
132
146
|
if (context && context !== name) states.push(`context=${JSON.stringify(context)}`);
|
|
133
147
|
const attributes = states.join(' ');
|
|
134
148
|
const skip = node.ignored || (!options.text && textRoles.has(role)) || (['none', 'generic'].includes(role) && !name && !attributes) ||
|
|
@@ -141,8 +155,7 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
|
|
|
141
155
|
let nextDepth = depth;
|
|
142
156
|
if (!skip) {
|
|
143
157
|
if (query == null || identityName.toLowerCase().includes(query) || String(node.value?.value ?? '').toLowerCase().includes(query) || context?.toLowerCase().includes(query)) {
|
|
144
|
-
const ref = node
|
|
145
|
-
if (ref) refs.set(ref, { backendNodeId: node.backendDOMNodeId, frameId, sessionId, role: node.role?.value, name: String(node.name?.value || '') });
|
|
158
|
+
const ref = registerAXRef(node, token, refs, frameId, sessionId);
|
|
146
159
|
entries.push({ node: true, frameId, line: `${' '.repeat(Math.min(depth, 24))}${ref ? `[${ref}] ` : ''}${role} ${JSON.stringify(name)}${attributes ? ' ' + attributes : ''}` });
|
|
147
160
|
}
|
|
148
161
|
nextDepth++;
|
|
@@ -164,28 +177,30 @@ export function snapshotPage(state, { max_nodes = 600, max_chars = 24000, cursor
|
|
|
164
177
|
let offset = 0;
|
|
165
178
|
if (cursor != null) {
|
|
166
179
|
if (cursor !== state.nextCursor) throw new Error('Snapshot cursor is stale or unknown. Take a new browser_snapshot.');
|
|
167
|
-
for (const key of ['profile', 'text', 'text_limit', 'query']) if (view[key] != null && view[key] !== state.options[key]) {
|
|
168
|
-
throw new Error('Snapshot cursor uses the original profile/text/text_limit/query; take a new snapshot to change the view.');
|
|
180
|
+
for (const key of ['profile', 'text', 'text_limit', 'query', 'include_hidden']) if (view[key] != null && view[key] !== state.options[key]) {
|
|
181
|
+
throw new Error('Snapshot cursor uses the original profile/text/text_limit/query/include_hidden; take a new snapshot to change the view.');
|
|
169
182
|
}
|
|
170
183
|
offset = state.nextOffset;
|
|
171
184
|
}
|
|
172
|
-
const lines = [];
|
|
185
|
+
const lines = [], fields = [];
|
|
186
|
+
const structured = state.options.profile === 'fields';
|
|
173
187
|
let count = 0, chars = 0, index = offset, reason = null;
|
|
174
188
|
for (; index < state.entries.length; index++) {
|
|
175
189
|
const entry = state.entries[index];
|
|
176
190
|
if (state.invalidFrames.has(entry.frameId)) continue;
|
|
177
|
-
const size = JSON.stringify(entry.line).length + 2;
|
|
191
|
+
const size = JSON.stringify(structured ? entry.field : entry.line).length + 2;
|
|
178
192
|
if (entry.node && count >= max_nodes) { reason = 'node_limit'; break; }
|
|
179
193
|
if (chars + size > max_chars) {
|
|
180
|
-
if (!lines.length) throw new Error(`A snapshot line requires max_chars >= ${size}. Increase max_chars to continue without losing text.`);
|
|
194
|
+
if (!lines.length && !fields.length) throw new Error(`A snapshot ${structured ? 'field' : 'line'} requires max_chars >= ${size}. Increase max_chars to continue without losing text.`);
|
|
181
195
|
reason = 'text_budget'; break;
|
|
182
196
|
}
|
|
183
|
-
|
|
197
|
+
if (structured) fields.push(entry.field); else lines.push(entry.line);
|
|
198
|
+
chars += size; count += entry.node ? 1 : 0;
|
|
184
199
|
}
|
|
185
200
|
const truncated = index < state.entries.length;
|
|
186
201
|
state.nextOffset = index;
|
|
187
202
|
state.nextCursor = truncated ? `${state.id}@${index}` : null;
|
|
188
203
|
const { viewport, ...metadata } = state.metadata;
|
|
189
|
-
return { ...metadata, ...state.options, ...(verbose ? { viewport, snapshot_id: state.id } : {}), snapshot: lines.join('\n'), node_count: count,
|
|
204
|
+
return { ...metadata, ...state.options, ...(verbose ? { viewport, snapshot_id: state.id } : {}), ...(structured ? { fields } : { snapshot: lines.join('\n') }), node_count: count,
|
|
190
205
|
total_node_count: state.totalNodes, truncated, truncation_reason: reason, next_cursor: state.nextCursor };
|
|
191
206
|
}
|
package/extension/text.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { queryElement, targetOptions } from './locator.js';
|
|
2
|
+
import { checkedValue } from './errors.js';
|
|
2
3
|
|
|
3
4
|
export async function readText(cdp, value, tabId, args) {
|
|
4
5
|
const max = args.max_chars ?? 20000, offset = args.offset ?? 0, mode = args.mode ?? 'visible';
|
|
@@ -12,8 +13,9 @@ export async function readText(cdp, value, tabId, args) {
|
|
|
12
13
|
const options = { selector: args.selector, selector_type: args.selector_type, nth: args.nth, mode, offset, max_chars: max };
|
|
13
14
|
if (args.node) return value(await cdp(tabId, 'Runtime.callFunctionOn', { objectId: args.node.objectId, returnByValue: true,
|
|
14
15
|
functionDeclaration: `function(args) { return (${extractText.toString()})(args, () => this); }`, arguments: [{ value: options }] }));
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
const send = (method, params) => cdp(tabId, method, params);
|
|
17
|
+
return await checkedValue(await send('Runtime.evaluate', { returnByValue: true, ...(args.context_id != null ? { contextId: args.context_id } : {}),
|
|
18
|
+
expression: `(${extractText.toString()})(${JSON.stringify(options)},${queryElement.toString()})` }), send, value);
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export function extractText(args, query) {
|
package/extension/wait.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { deadline, inspectTarget } from './actionability.js';
|
|
2
2
|
import { queryElement, targetOptions, resolveSelector, resolveFrame } from './locator.js';
|
|
3
|
+
import { checkedValue } from './errors.js';
|
|
3
4
|
|
|
4
5
|
export const WAIT_CONDITIONS = ['attached', 'absent', 'visible', 'actionable', 'text', 'value', 'checked', 'selected', 'expanded', 'url', 'network_idle'];
|
|
5
6
|
|
|
@@ -46,8 +47,9 @@ export async function waitFor(cdp, value, sessions, network, pointer, tabId, arg
|
|
|
46
47
|
}
|
|
47
48
|
} else {
|
|
48
49
|
const { executionContextId } = await budget.run(() => cdp(target, 'Page.createIsolatedWorld', { frameId, worldName: 'browser-attach-locator' }));
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
const send = (method, params) => budget.run(() => cdp(target, method, params));
|
|
51
|
+
current = await checkedValue(await send('Runtime.evaluate', { contextId: executionContextId, returnByValue: true,
|
|
52
|
+
expression: `(${inspectWait.toString()})((${queryElement.toString()}),(${inspectTarget.toString()}),${JSON.stringify(args)})` }), send, value);
|
|
51
53
|
}
|
|
52
54
|
const stable = current?.ready && previous?.ready && JSON.stringify(current) === JSON.stringify(previous);
|
|
53
55
|
if (current?.satisfied && (args.condition !== 'actionable' || stable)) {
|
package/package.json
CHANGED
|
@@ -15,7 +15,7 @@ for (const path of names) {
|
|
|
15
15
|
if (/config\.generated\.js|(^|\/)(\.local|\.venv|test-results|node_modules)\//.test(path)) throw new Error(`Private file included in package: ${path}`);
|
|
16
16
|
}
|
|
17
17
|
for (const path of ['extension/connection.js', 'extension/version.js', 'extension/errors.js', 'extension/page-url.js']) if (!names.includes(path)) throw new Error(`Package is missing ${path}`);
|
|
18
|
-
for (const path of ['src/hub-runtime.mjs', 'extension/observation.js', 'extension/pointer.js', 'extension/dialogs.js', 'extension/network.js', 'extension/log-data.js', 'extension/fetch-page.js', 'extension/screenshot.js', 'extension/upload.js']) if (!names.includes(path)) throw new Error(`Package is missing ${path}`);
|
|
18
|
+
for (const path of ['src/hub-runtime.mjs', 'extension/observation.js', 'extension/fields.js', 'extension/pointer.js', 'extension/dialogs.js', 'extension/network.js', 'extension/log-data.js', 'extension/fetch-page.js', 'extension/screenshot.js', 'extension/upload.js']) if (!names.includes(path)) throw new Error(`Package is missing ${path}`);
|
|
19
19
|
for (const path of ['bin/browser-attach.mjs', 'src/mcp.mjs', 'src/hub.mjs', 'extension/manifest.json', 'extension/evaluate.js', 'extension/fetch.js', 'extension/locator.js', 'extension/batch.js', 'extension/wait.js', 'extension/outcomes.js', 'extension/scroll.js', 'extension/drag.js', 'extension/text.js', 'extension/diagnostics.js', 'extension/sessions.js', 'extension/actionability.js', 'extension/limits.js', 'extension/icons/BrowserAttach.png']) {
|
|
20
20
|
if (!names.includes(path)) throw new Error(`Package is missing ${path}`);
|
|
21
21
|
}
|
package/src/config.mjs
CHANGED
|
@@ -15,7 +15,7 @@ export const DATA_DIR = configuredHome
|
|
|
15
15
|
: process.platform === 'win32' ? join(homedir(), '.browser-attach')
|
|
16
16
|
: join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'browser-attach');
|
|
17
17
|
export const DEFAULT_CONFIG = join(DATA_DIR, 'config.json');
|
|
18
|
-
export const VERSION = '0.
|
|
18
|
+
export const VERSION = '0.23.0';
|
|
19
19
|
|
|
20
20
|
export async function loadConfig(path = DEFAULT_CONFIG) {
|
|
21
21
|
let config;
|
package/src/mcp.mjs
CHANGED
|
@@ -103,13 +103,14 @@ export function createMcp(client) {
|
|
|
103
103
|
if (args.focus_window != null) throw new Error('focus_window is only valid for action=activate');
|
|
104
104
|
return { tab_handle: args.tab_handle };
|
|
105
105
|
});
|
|
106
|
-
tool('snapshot', 'Read an AX tree with refs and
|
|
106
|
+
tool('snapshot', 'Read an AX tree, or profile=fields for structured DOM/AX field facts with refs, HTML labels, nearby context and native values. Field rows distinguish DOM values from AX values, not submitted data or application success. A new observation replaces refs; next_cursor continues the captured view.', {
|
|
107
107
|
...handle, max_nodes: integerRange('max_nodes', 1, 3000).default(600),
|
|
108
108
|
max_chars: integerRange('max_chars', 1, 2000000).default(24000), cursor: z.string().optional(),
|
|
109
|
-
profile: z.enum(['full', 'interactive']).optional().describe('Default full; interactive keeps controls
|
|
109
|
+
profile: z.enum(['full', 'interactive', 'fields']).optional().describe('Default full; interactive keeps controls and ancestors; fields returns a fields array instead of AX text.'),
|
|
110
|
+
include_hidden: z.boolean().optional().describe('fields only, default false: also include native fields omitted from AX, such as hidden inputs. These may have ref=null. Password values are omitted.'),
|
|
110
111
|
text: z.boolean().optional().describe('Default true; false hides prose, preserving control names and values.'),
|
|
111
112
|
text_limit: integerRange('text_limit', 0, 10000).optional().describe('Prose preview length, marked with …; control names/values stay complete. Default 80 for interactive, unlimited for full.'),
|
|
112
|
-
query: z.string().min(1).max(2000).optional().describe('Case-insensitive literal substring of
|
|
113
|
+
query: z.string().min(1).max(2000).optional().describe('Case-insensitive literal substring of names, values or nearby context. fields also searches HTML labels, DOM id/name/placeholder and selected options. Zero matches is not an error.'),
|
|
113
114
|
verbose: z.boolean().default(false).describe('Include viewport and snapshot_id.'),
|
|
114
115
|
});
|
|
115
116
|
tool('read_text', 'Read rendered page/frame text, optionally scoped to a selector. mode=value reads a field; mode=editor reads a Monaco/CodeMirror 5 model. complete_source reports completeness; next_offset continues live text, which may change.', {
|