@evan_zhong/browser-attach 0.20.0 → 0.22.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/README.md +2 -2
- package/bin/browser-attach.mjs +44 -43
- package/docs/ARCHITECTURE.md +22 -11
- package/docs/TEST-REPORT.md +30 -1
- package/docs/USAGE.md +24 -4
- package/extension/actionability.js +96 -72
- package/extension/click.js +141 -375
- package/extension/controller.js +430 -968
- package/extension/diagnostics.js +167 -241
- package/extension/dialogs.js +73 -0
- package/extension/drag.js +195 -195
- package/extension/evaluate.js +133 -133
- package/extension/fetch-page.js +131 -0
- package/extension/input.js +342 -339
- package/extension/log-data.js +29 -0
- package/extension/manifest.json +1 -1
- package/extension/navigation.js +74 -74
- package/extension/network.js +79 -0
- package/extension/observation.js +232 -0
- package/extension/outcomes.js +71 -15
- package/extension/page-url.js +8 -0
- package/extension/pointer.js +243 -0
- package/extension/screenshot.js +64 -0
- package/extension/scroll.js +142 -42
- package/extension/upload.js +57 -0
- package/extension/wait.js +117 -93
- package/package.json +1 -1
- package/scripts/check-package.mjs +1 -0
- package/src/client.mjs +50 -135
- package/src/config.mjs +1 -1
- package/src/hub-runtime.mjs +87 -0
- package/src/mcp.mjs +216 -208
- package/docs/DESIGN-REVIEW-0.15.1.md +0 -27
package/README.md
CHANGED
|
@@ -94,7 +94,7 @@ Claiming and ordinary input never activate a tab or window; call `browser_tab_co
|
|
|
94
94
|
| `browser_open_tab` / `browser_tab_control` | Create a tab; explicit `release/activate/close` of a claimed tab |
|
|
95
95
|
| `browser_fetch` | Read one HTTP(S) URL in a temporary background tab using the profile's sign-in; `text`/`html`/`json`, optional HTTP status and cursor continuation |
|
|
96
96
|
| `browser_snapshot` / `browser_read_text` | AX snapshot, literal name/value query with actionable refs, and page text |
|
|
97
|
-
| `browser_wait` | Read-only wait for
|
|
97
|
+
| `browser_wait` | Read-only wait for presence/absence, visibility, actionability, rendered text, exact field value, checked/selected/expanded state, URL or network idle |
|
|
98
98
|
| `browser_click` / `browser_hover` / `browser_drag` / `browser_select_option` / `browser_type` / `browser_fill` / `browser_press_key` / `browser_scroll` | Native/DOM input, pointer hover and drag, option selection, character typing and scrolling |
|
|
99
99
|
| `browser_batch` | Run 1–20 click/fill/type/press_key/hover/scroll/select_option/wait steps in one queue; stops on the first failure and returns partial results |
|
|
100
100
|
| `browser_navigate` | HTTP(S)/file navigation, back/forward and load waiting; local destinations require file access |
|
|
@@ -139,7 +139,7 @@ Logs are collected from claim time, survive navigation and are cleared on releas
|
|
|
139
139
|
|
|
140
140
|
The extension holds debugger permission and client actions can use existing site sign-in state; only connect clients you trust. Web page content must not be treated as user authorization. The pairing key and the generated `config.generated.js` must not be committed or published; the packaging check rejects packages that contain that file.
|
|
141
141
|
|
|
142
|
-
Chrome / Edge HTTP(S) pages and existing ordinary `file://` tabs with extension file access enabled are supported. File access is checked during discovery, claim, operations and navigation, including history destinations. `open_tab` and `fetch` still accept HTTP(S) only. Built-in PDF viewer AX/content and screenshots are browser-dependent and are not guaranteed; browser internal pages and arbitrary extension pages remain outside scope. Download management, network interception and Firefox / WebKit are not supported. Cross-process iframes use recursive session routing covering snapshots, input, uploads and logs; clicks in perspective-transformed iframes are refused explicitly. Native pointer input requires an
|
|
142
|
+
Chrome / Edge HTTP(S) pages and existing ordinary `file://` tabs with extension file access enabled are supported. File access is checked during discovery, claim, operations and navigation, including history destinations. `open_tab` and `fetch` still accept HTTP(S) only. Built-in PDF viewer AX/content and screenshots are browser-dependent and are not guaranteed; browser internal pages and arbitrary extension pages remain outside scope. Download management, network interception and Firefox / WebKit are not supported. Cross-process iframes use recursive session routing covering snapshots, input, uploads and logs; clicks in perspective-transformed iframes are refused explicitly. Native pointer input requires an unsuspended tab in a non-minimized window. An unmodified left single click may target an inactive tab with trusted delivery verification; hover, drag and other click variants still require an active tab. Focus and document visibility are diagnostic only; when the tab is not ready it returns without dispatch and suggests tab_control(action=activate) instead of stealing focus. Background screenshots may time out because the browser pauses rendering; native menus and tab drags may also suppress input.
|
|
143
143
|
|
|
144
144
|
## Local development
|
|
145
145
|
|
package/bin/browser-attach.mjs
CHANGED
|
@@ -1,43 +1,44 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { parseArgs } from 'node:util';
|
|
3
|
-
import { DEFAULT_CONFIG, loadConfig } from '../src/config.mjs';
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
else if (action === '
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { DEFAULT_CONFIG, loadConfig } from '../src/config.mjs';
|
|
4
|
+
import { AttachClient } from '../src/client.mjs';
|
|
5
|
+
import { ensureHub, hubIsRunning } from '../src/hub-runtime.mjs';
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
const { values, positionals } = parseArgs({ allowPositionals: true, options: {
|
|
9
|
+
config: { type: 'string', default: DEFAULT_CONFIG },
|
|
10
|
+
'extension-dir': { type: 'string' }, port: { type: 'string' },
|
|
11
|
+
nosis: { type: 'boolean', default: false }, help: { type: 'boolean', short: 'h' },
|
|
12
|
+
} });
|
|
13
|
+
const action = positionals[0] || 'mcp', configPath = values.config;
|
|
14
|
+
if (values.help) {
|
|
15
|
+
console.log(`browser-attach [mcp|setup|hub|start|status|stop]\n setup [--extension-dir PATH] [--port 47831] [--nosis]\n --config PATH (default: ${DEFAULT_CONFIG})`);
|
|
16
|
+
} else if (action === 'mcp') {
|
|
17
|
+
const { runMcp } = await import('../src/mcp.mjs'); await runMcp(configPath);
|
|
18
|
+
} else if (action === 'hub') {
|
|
19
|
+
const { startHub } = await import('../src/hub.mjs');
|
|
20
|
+
const hub = await startHub(await loadConfig(configPath));
|
|
21
|
+
const stop = () => hub.close(); process.once('SIGINT', stop); process.once('SIGTERM', stop);
|
|
22
|
+
} else if (action === 'setup') {
|
|
23
|
+
const { setup, configureNosis } = await import('../src/setup.mjs');
|
|
24
|
+
const result = await setup({ configPath, extensionDir: values['extension-dir'], port: values.port === undefined ? undefined : Number(values.port) });
|
|
25
|
+
console.log(`Extension folder: ${result.extensionDir}\nExtension ID: ${result.config.extension_id}\nPairing: ${result.configPath}`);
|
|
26
|
+
if (values.nosis) console.log(JSON.stringify(await configureNosis(undefined, { configPath: result.configPath })));
|
|
27
|
+
await ensureHub(result.configPath); console.log('Local hub ready. Load the extension in each Chrome/Edge profile on first use; subsequent upgrades reload it automatically.');
|
|
28
|
+
} else if (action === 'start') { await ensureHub(configPath); console.log('Local hub started.'); }
|
|
29
|
+
else if (action === 'status') {
|
|
30
|
+
const config = await loadConfig(configPath);
|
|
31
|
+
if (!await hubIsRunning(config)) console.log('Local hub is stopped.');
|
|
32
|
+
else {
|
|
33
|
+
const client = new AttachClient(config);
|
|
34
|
+
try { console.log(JSON.stringify(await client.call('list_browsers'), null, 2)); } finally { await client.close(); }
|
|
35
|
+
}
|
|
36
|
+
} else if (action === 'stop') {
|
|
37
|
+
const config = await loadConfig(configPath);
|
|
38
|
+
const response = await fetch(`http://127.0.0.1:${config.port}/shutdown`, {
|
|
39
|
+
method: 'POST', headers: { Authorization: `Bearer ${config.token}` }, signal: AbortSignal.timeout(5000),
|
|
40
|
+
});
|
|
41
|
+
if (!response.ok) throw new Error(`Hub refused shutdown (${response.status})`);
|
|
42
|
+
console.log('Hub stopped; extension control released.');
|
|
43
|
+
} else throw new Error(`Unknown command: ${action}`);
|
|
44
|
+
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 架构与设计边界
|
|
2
2
|
|
|
3
|
-
当前实现为 **0.
|
|
3
|
+
当前实现为 **0.22.0**。本文说明 BrowserAttach 的组件、控制模型与设计取舍;工具参数见 [使用指南](USAGE.md)。
|
|
4
4
|
|
|
5
5
|
## 组件与数据流
|
|
6
6
|
|
|
@@ -19,23 +19,32 @@ MCP 客户端
|
|
|
19
19
|
| `src/config.mjs` | 数据目录、配对文件校验、版本常量 |
|
|
20
20
|
| `src/setup.mjs` | 生成配对密钥、暂存并替换稳定扩展目录、写 Nosis 配置 |
|
|
21
21
|
| `src/hub.mjs` | 浏览器与会话注册、独占 claim、每标签串行队列、超时 |
|
|
22
|
-
| `src/client.mjs` | MCP 到 Hub 的 WebSocket
|
|
22
|
+
| `src/client.mjs` | MCP 到 Hub 的 WebSocket 请求、回执与断连处理 |
|
|
23
|
+
| `src/hub-runtime.mjs` | Hub 启动、升级协调与扩展就绪检查 |
|
|
23
24
|
| `src/mcp.mjs` | 23 个工具的 schema、结果与图片封装 |
|
|
24
|
-
| `extension/controller.js` |
|
|
25
|
+
| `extension/controller.js` | 所有权、标签队列、权限检查、生命周期协调与操作分派;传输层从不暴露原始 CDP |
|
|
25
26
|
| `extension/connection.js` | 扩展连接、重连清理、升级维护与自行重载 |
|
|
26
27
|
| `extension/sessions.js` | 扁平 debugger session 与 frame/OOPIF 归属 |
|
|
27
|
-
| `extension/
|
|
28
|
+
| `extension/pointer.js` | 共享的原生指针就绪检查、落点准备、命中核验及 frame 坐标转换 |
|
|
29
|
+
| `extension/{click,input,actionability,scroll}.js` | 原生/DOM 输入与回执、逐字按键、可操作性、容器滚动与有界查找 |
|
|
28
30
|
| `extension/{drag,wait}.js` | 复用鼠标准备和严格定位的拖拽、只读条件等待;网络空闲复用被动请求表 |
|
|
29
31
|
| `extension/batch.js` | 有限输入序列的参数预检;执行复用 controller 的单步流程与标签队列 |
|
|
30
32
|
| `extension/motion.js` | 无状态的可选落点/延迟/拖拽参数校验、区间采样及拖拽插值;不维护动作历史或改变焦点 |
|
|
31
|
-
| `extension/
|
|
32
|
-
| `extension/
|
|
33
|
-
| `extension/
|
|
33
|
+
| `extension/dialogs.js` | 当前对话框、嵌套操作策略、自动处理及被模态阻塞的 CDP 请求 |
|
|
34
|
+
| `extension/outcomes.js` | 动作前临时 DOM 观察器、未知结果后的有界只读探测与证据封装,不推断业务成功 |
|
|
35
|
+
| `extension/fetch.js`、`fetch-page.js` | 单页读取参数与缓存分页、临时页创建/导航/采集/关闭;复用 controller 的 attach 和标签队列 |
|
|
36
|
+
| `extension/observation.js`、`snapshot.js` | 文档/frame 观察版本、AX 采集、ref 身份与失效历史;AX 渲染和快照分页 |
|
|
37
|
+
| `extension/network.js` | 一份被动请求与文档响应状态,供 fetch、网络空闲等待及诊断日志使用 |
|
|
38
|
+
| `extension/diagnostics.js`、`log-data.js` | 日志缓冲与分页、可选正文捕获、截断及脱敏;不拥有网络空闲或文档状态 |
|
|
39
|
+
| `extension/{navigation,evaluate}.js` | 导航等待、有界求值结果缓存与分页 |
|
|
40
|
+
| `extension/{screenshot,upload}.js` | 截图裁剪与采集、文件选择器状态和文件赋值 |
|
|
34
41
|
| `extension/{locator,text}.js` | 严格 CSS/XPath/HTML label/placeholder/AX role-name 定位、每次解析的 frame URL/title/iframe 路径、DOM/字段/支持的编辑器模型读取 |
|
|
35
42
|
| `extension/visuals.js`、`click-visuals.js` | 无标题红色原生标签组、临时点击反馈 |
|
|
36
43
|
|
|
37
44
|
Hub 按需后台启动(`ensureHub`),多客户端共享;只监听 loopback。
|
|
38
45
|
|
|
46
|
+
Hub 与扩展各自保留控制权及串行边界;controller 向具体模块传入所需能力,不共享可变状态总表。sessions 管 debugger session 的归属;observation 管文档/frame 的观察有效性。CDP 事件先更新同一份 network 状态,再将关联结果交给 diagnostics 记录,日志淘汰或读取不改变 fetch 与 wait 使用的网络事实。对话框策略和动作结果观察各自清理自己的状态;未知结果探测使用内部结构化标记,不依赖英文错误文案匹配。
|
|
47
|
+
|
|
39
48
|
MCP 层将 `discover(kind=browsers/tabs)` 映射为内部 `list_browsers/list_tabs`,将 `tab_control(action=release/activate/close)` 映射为内部 `release_tab/activate_tab/close_tab`;分支参数先校验,再复用 Hub 和扩展的发现、所有权及串行执行流程。
|
|
40
49
|
|
|
41
50
|
## MCP 描述分层
|
|
@@ -65,7 +74,7 @@ claim 与普通输入都不激活标签或窗口,也不移动系统鼠标;
|
|
|
65
74
|
|
|
66
75
|
正文按 owner 保留至多 4 份各最多 1000000 字符的不可变采集,cursor 定位采集而非 URL;LRU 淘汰最久未读取的未完成采集,读完、owner 或扩展断连清除。一次读完和失败的请求不淘汰已有缓存。分页不执行脚本、不导航,临时标签关闭不会删除缓存。JSON 在采集上限内整份返回,不按 max_chars 拆分。导航等待跟随同一标签后续主文档 loader,完成时保留实际满足条件的文档身份;采集期间文档变化最多只读重采 3 次,状态码始终匹配本次采集的 frame/loader。控制权变化立即失败,断连后的迟到结果不能复活缓存。格式、采集上限与分页语义见使用指南。
|
|
67
76
|
|
|
68
|
-
HTTP 状态复用
|
|
77
|
+
HTTP 状态复用 network.js 的 Network.responseReceived 观察,按主文档 frame/loader 精确匹配;额外保留 requestId、响应 URL、MIME、空体证据和加载错误码,只从匹配状态的 ExtraInfo 补充 Content-Length,防止重定向 hop、跨 session 请求和缓存 304 混淆。独立于可淘汰的日志正文保留至多 500 项文档状态元数据,断连/detach 清理。不新增 webRequest 或 host 权限,不重复请求页面。未匹配时 status/ok 返回 null;ok 仅表示 HTTP 2xx。ERR_HTTP_RESPONSE_CODE_FAILURE 若有已匹配的 HTTP 响应,返回状态及已确认的空体;不能证明正文为空时标记 unavailable,不采集浏览器错误页为目标正文。其他传输错误仍失败。HTML 标记 dom=rendered,保留观察到的 DOM,不声称是服务器原文;空标题省略。
|
|
69
78
|
|
|
70
79
|
## 观察模型
|
|
71
80
|
|
|
@@ -74,6 +83,7 @@ HTTP 状态复用 diagnostics 的 Network.responseReceived 观察,按主文档
|
|
|
74
83
|
- **文本查询与精简输出**:`snapshot(query=...)` 复用同一次 AX 渲染,按当前视图中节点的完整名称(含合并正文)、value 或字段 context 做不区分大小写的字面子串筛选,只输出匹配行和对应 frame 标题;不搜索 role/ref 或任意 DOM 属性,不新增索引缓存。新查询仍采集新观察并使旧 refs 失效,query 与分页绑定。默认省略 viewport、snapshot_id 和重复规则说明,`verbose=true` 返回前两项;frame 覆盖、视图、预算和游标语义保留。
|
|
75
84
|
- **字段上下文**:表单控件可附带 context 并参与 query,保留至多 160 字符的前一兄弟分支 AX 文本;只在四层以内 generic/none/group 祖先、单控件容器中查找,每次容器/文本遍历分别限制 64/32 节点。不将其伪装为 accessible name,不用于 label 定位或 ref 身份验证;interactive/text=false 也保留提示。
|
|
76
85
|
- **ref 解析**:ref 保存 `backendNodeId`、所属 frame/session 和观察时的 AX role/name;动作时重新 `DOM.resolveNode` 并用局部 AX 查询核对语义身份。输入与求值不清空 refs,只使快照分页失效;原节点仍连接时可复用,节点替换、role/name 改变、文档或 frame 变化时安全失败,不按 role/name 猜测替代节点。
|
|
86
|
+
- **ref 失效诊断**:controller 每标签保留最近至多 1000 个已清理 ref 的 cause/frame_id,不保留 DOM 对象、字段值或替代定位信息。新观察、文档/frame 变化及对话框清理记录各自原因;解析时单独报告节点脱离、session 或 AX 身份变化。释放、断连、关闭和 detach 清空历史,淘汰后返回 unknown_ref。
|
|
77
87
|
- **双预算**:`max_nodes` 限制节点数,`max_chars` 限制每行序列化后的累计字符数;两者独立,任一耗尽都会返回 `next_cursor`。
|
|
78
88
|
- **续读**:续读沿用同一份 `state`(`entries`、`refs`、`nextOffset`),所以 refs 在分页过程中保持有效。单个节点放不进预算时直接报错要求提高预算,避免静默截断尾部。
|
|
79
89
|
- **frame 失效**:主文档导航或文档更新清空整份观察;子 frame 导航、移除或 session detach 只使该子树失效,并把受影响 frame 写入 `omitted_frames`。
|
|
@@ -93,12 +103,13 @@ HTTP 状态复用 diagnostics 的 Network.responseReceived 观察,按主文档
|
|
|
93
103
|
- **type 与 fill**:fill 替换文字,type 保留选区,逐字符复用原生按键与 keydown 回执;字符之间可延迟,不把 insertText 伪装成键盘序列。浏览器处理 keypress/input 及 preventDefault,非 ASCII 不模拟系统 IME。
|
|
94
104
|
- **焦点输入与语义定位**:type 的 focused=true 与 ref/selector 互斥,复用 press_key 的 frame/开放 Shadow DOM 焦点解析,固定最初可编辑元素并逐字检查,不重新聚焦。selector_type 的 label/placeholder 在隔离世界按真实 HTML 关联/属性查询,role/name 每次读取所选 frame 的 AX 树并严格匹配,以 AX 树顺序支持 nth;统一返回原有节点结构,继续复用输入、命中核验及 batch 流程,无组件库适配器。
|
|
95
105
|
- **只重试准备**:等待阶段可以反复轮询,但一旦派发就不再重试。超时分为"未派发"和"已派发、结果未知"两种错误文本。
|
|
96
|
-
- **原生指针就绪检查**:准备前和派发前各做一次至多 500 ms
|
|
106
|
+
- **原生指针就绪检查**:准备前和派发前各做一次至多 500 ms 的有界检查;要求未冻结/丢弃的 tab 与未最小化的窗口。无修饰键的左键单击可在非活动标签尝试执行;hover、drag、其他按钮、双击及带修饰键的点击仍要求活动标签。检查失败以 TAB_NOT_READY 明确报告当前指针输入未派发,并提示 tab_control(action=activate) → snapshot → 所请求动作。click/hover 成功结果的 page_state.tab_active 记录派发前检查观察到的活动状态。普通操作不主动激活;仅显式 tab_control(action=activate) 在 focus_window=true 时请求窗口聚焦和 Page.bringToFront。
|
|
97
107
|
- **焦点信号边界**:OS 窗口失焦、遮挡或 document.visibilityState=hidden 不一定阻止 CDP 输入;document.hasFocus() 在 iframe 导航后也可能为 false。这些值作为 page_state 诊断,不独立拒绝点击。实际送达仍由隔离世界的可信事件回执校验;没有回执时不自动重放。
|
|
98
108
|
- **送达预算**:原生 click/hover/press_key 的派发及可信回执另有 1250 ms 上限,清理最多 250 ms;系统菜单捕获输入时快速释放队列,仍报告结果未知并提示 Esc,不自动重放。
|
|
99
109
|
- **hover 与 drag**:hover 复用点击准备与隔离校验,仅派发一次 mouseMoved,清理不移走指针。drag 显式区分 pointer 与 HTML5;后者从真实 dragstart 截获 DragData,再用 CDP 派发 dragover/drop,校验可信 dragstart/drop/dragend。目的节点在按住后滚入视口;所有步骤共用标签队列和总预算,失败时只取消/释放本次手势。
|
|
100
110
|
- **可选输入参数**:显式 offset 在元素 content quad 上插值后沿用命中与 OOPIF 边界检查;settle_ms 在 click/drag 首次派发前等待并复查就绪。drag trajectory 只替换既有插值段,采用法向控制点、平滑进度及随机间隔权重;type 的 delay_ms 可逐间隔独立区间采样。所有等待共用总预算,超时后没有继续派发的链;省略参数时不使用随机数,沿用既有确定性路径。不新增 tab 级历史、冷却、自动激活或重放路径。
|
|
101
|
-
- **条件等待**:wait 复用严格 selector
|
|
111
|
+
- **条件等待**:wait 复用严格 selector、可操作性和命中检查,支持出现、无匹配、可见、可操作、字段值精确相等、checked/selected/expanded、文本/URL 子串及网络空闲。控件优先读取明确的原生属性,否则读取对应 ARIA 属性;缺失/无效状态保持 unavailable,不当作 false。字段值不回显;文本使用 innerText,比较在 500 字符预览截取前完成。每次只读轮询重新解析 selector,可在节点替换时重读,歧义立即失败;AX 无匹配不证明 DOM 移除。单步与 batch 共享条件 schema 和参数校验,等待超时不改变前序动作的派发事实。
|
|
112
|
+
- **可操作性诊断**:区分 display_none、visibility_hidden、zero_size、disabled、inert、read_only 和 unstable_geometry;无可见落点单独返回 no_visible_click_point。失败时最多采集 8 层 composed 祖先的样式、滚动方向和 transition 属性/时长,成功路径保留原有几何就绪语义;不以 opacity=0 拒绝输入,不自动 hover、移除遮挡或等待推断出的组件状态。
|
|
102
113
|
- **原生下拉**:select_option 不要求可见或稳定几何,先只读核对节点、禁用/inert 祖先与唯一选项,再在一次派发中复查并用 selectedIndex setter 和合成 input/change 设置,核验保留结果并返回 events_trusted=false。单步和 batch 共用实现。native click 的 select 命中(含 UA shadow 内部节点和关联 label)在派发前拒绝。
|
|
103
114
|
- **命中提示**:普通命中保留实际 hit node,沿父元素、Shadow host、slot 查找最近的 HTML/ARIA 可点击候选,返回 clickable_target;hint=false 表示未识别候选,不代表无法响应事件。CSSPseudoElement 先解析为事件所属的 originating element,提示标记 pseudo_element=true,并使用该元素安装事件校验。语义候选不会被用于替换点击目标。
|
|
104
115
|
- **遮挡失败诊断**:当前 frame 的遮挡直接复用已读取的 hit_target;祖先 frame 遮挡额外用至多 200 ms 读取命中描述,诊断节点消失时保留原遮挡错误与 backend node ID。错误包含命中点和受阻 frame。根据共享预算的派发状态区分“未派发”和“结果未知”,避免 drag 按住后准备目的节点时误报零输入。
|
|
@@ -108,7 +119,7 @@ HTTP 状态复用 diagnostics 的 Network.responseReceived 观察,按主文档
|
|
|
108
119
|
- **命中对象**:伪元素先解析 originating Element;ShadowRoot 通过其 elementFromPoint 重新取得实际命中 Element,同进程子 frame 先把 session 坐标映射到所属文档视口。不将 ShadowRoot 直接当 Element 或无条件改成 host。后续身份、遮挡和事件回执检查保持一致;命中描述附带 frame-local rect 和样式诊断。
|
|
109
120
|
- **DOM 模式**:`mode: "dom"` 是显式的、受控的降级路径,用于原生菜单或标签拖动暂时捕获输入时操作普通 HTML 控件。它调用 `HTMLElement.click()` 或原生 value setter,事件非可信、无用户激活,且绝不会在原生失败后自动启用。
|
|
110
121
|
- **对话框策略**:ask 仍为默认;单操作/单步骤的显式 accept/dismiss 在事件回调中解除 JS 模态阻塞,保留消息与处理结果,最多自动处理 10 个。beforeunload dismiss 快速取消本次导航等待,实际主文档变化仍会停止 batch。
|
|
111
|
-
- **未知结果探测**:在可能写入的操作准备期安装目标 frame 的临时 MutationObserver;观察用的 frame 解析只尝试一次,不等待不存在的 frame 消耗动作准备预算。仅在不确定失败后做一次有界只读探测,回传对话框、导航、DOM 变动;读取最多 300 ms、清理最多 100 ms。
|
|
122
|
+
- **未知结果探测**:在可能写入的操作准备期安装目标 frame 的临时 MutationObserver;观察用的 frame 解析只尝试一次,不等待不存在的 frame 消耗动作准备预算。仅在不确定失败后做一次有界只读探测,回传对话框、导航、DOM 变动;读取最多 300 ms、清理最多 100 ms。observations.tab/dom 分别提供 available 及错误;任意正面变化证据保留 observable_effects,只有标签与 DOM 均可读且未见变化才返回 no_observable_effects,其他情况为 observation_unavailable。不可用 DOM 结果为 null;任何 assessment 都不证明业务成功或没有派发,不自动重放。扩展、Hub、客户端用 errors.js 保留 error 文本及 input_dispatched、post_action、diagnostics;MCP 工具执行失败返回 isError 与 JSON,batch 失败采用相同证据字段。
|
|
112
123
|
- **元素截图/求值 ref**:复用既有截图 clip/元数据与 ref 注册/失效模型,分别增加元素 border box 截图和显式单 DOM Element 结果;不暴露 Runtime objectId。JSON 求值自动调用函数一次并等待 Promise,缓存续读不执行脚本。
|
|
113
124
|
- **求值预算提示**:JSON 仍按顶层完整项分页;已有输出而下一项超出预算时,在同一响应提供 next_item 的 key/index、required_chars 和是否超过最大页预算。提示只读取缓存,不展开或改写嵌套值,也不提供任意脚本的执行进度。
|
|
114
125
|
- **历史导航**:在已有 navigate 中选择 URL 或 back/forward,按 history entry 与主 frame 生命周期等待;同文档/BFCache 恢复不等新的加载事件,不用 evaluate 重放导航。
|
package/docs/TEST-REPORT.md
CHANGED
|
@@ -1,9 +1,38 @@
|
|
|
1
1
|
# 验证报告
|
|
2
2
|
|
|
3
|
-
当前版本 **0.
|
|
3
|
+
当前版本 **0.22.0**(2026-10-24)。浏览器验证使用本地 fixture 和临时 profile;测试链路在各项下说明。
|
|
4
4
|
|
|
5
5
|
## 当前版本
|
|
6
6
|
|
|
7
|
+
- `npm test`:243 项通过。新增非活动标签的单次左键单击(ref / selector / viewport / document 坐标)仍取得可信回执且不激活标签;hover、拖拽、其他按键、双击与带修饰键的点击仍在派发前以 `TAB_NOT_READY` 拒绝;派发前的就绪检查允许左键单击期间的标签切换,仍拒绝视图冻结或目标丢弃,`page_state` 带 `tab_active`。
|
|
8
|
+
- Chromium 156、Edge 155 headless:`interaction-smoke --regressions` 各 35 个场景通过。后台 ref / selector / viewport / document / offset 各 3 次(每浏览器 15 次,合计 30 次)均 `delivery_verified` 且标签激活与窗口焦点状态不变;连续两次后台 batch、后台回执丢失(1 次效果、不重放)、后台遮挡(未派发)、受限动作组、settle 期间切换标签等场景通过。
|
|
9
|
+
- Chromium 156 `interaction-smoke` 默认场景 17 项通过,覆盖前台、后台 ref 与坐标、遮挡窗口、缩放、hover 偏移、闭合 Shadow DOM 与导航。
|
|
10
|
+
- Chromium 156、Edge 155 完整 stdio MCP `live-smoke`:各 255 次工具调用、27 项检查通过;后台 ref / selector / 坐标点击均在 `tab_active=false` 下取得可信送达,显式激活、DOM 模式与命中提示的既有行为保持。
|
|
11
|
+
- `npm run test:package`:仓库外安装与 stdio MCP 通过,23 个工具、60 个发布文件,无配对密钥;未增加工具、参数、依赖或扩展权限。次版本反映点击就绪语义的变化。
|
|
12
|
+
- 本机部署:Hub 从工作区重启为 0.22.0 / ready;已配对 Chrome、Edge 均上报 0.22.0;稳定扩展目录 `F:\.nosis\browser-attach\extension` 的 42 个文件逐一核对与工作区一致。
|
|
13
|
+
- 真实既有 profile 探测:从未激活的后台页面上,Edge 取得可信回执并产生 1 次点击;Chrome 未取得回执,返回结果未知、`input_dispatched=true` 且未重放(0 次效果)。验证日志为本地忽略目录 `test-results/` 中的 `*-0220*` 文件。
|
|
14
|
+
- 当前边界:非活动标签只允许单次左键单击,送达仍以可信回执为准;无回执时报告结果未知,不自动激活、不重放、不切换 DOM 模式。hover、拖拽与其他点击变体继续要求活动标签。
|
|
15
|
+
|
|
16
|
+
## 0.21.1 验证
|
|
17
|
+
|
|
18
|
+
- `npm test`:240 项通过。原 controller 测试按观察、指针、键盘输入、对话框、截图/上传、滚动、求值和 fetch 分组,共用原有 fixture;保留控制权、队列、权限和清理的集成测试。新增不依赖日志的网络状态、错误文案无关的结构化未知结果、观察启动前失败仍恢复对话框策略,以及 select 异常跨 CDP 边界后保留结果未知证据的回归。
|
|
19
|
+
- Chromium 156、Edge 155 完整 stdio MCP 集成:各 254 次工具调用通过,覆盖快照/ref、输入、上传、截图、导航、对话框、fetch、日志、嵌套 OOPIF 和 12000 个语义节点分页;Edge `interaction-smoke --primitives --headless` 22 个场景通过。
|
|
20
|
+
- Chromium 文件页面集成:50 次工具调用、5 组检查通过,覆盖实时文件权限、中文/空格/# 路径、主文档与 iframe 观察、原生输入、HTTP↔file 导航和控制权清理。
|
|
21
|
+
- Chromium 真实升级验证通过:同一临时 profile 内两个并发启动者完成 Hub 与扩展升级至 0.21.1,确认自动 runtime 重载及升级后页面操作。旧协议版本由当前代码修改版本号构造,不代表真实 0.16 扩展具备自动升级能力。
|
|
22
|
+
- `npm run test:package`:仓库外安装和 stdio MCP 通过,60 个发布文件、23 个工具,无配对密钥。未增加工具、参数、依赖或扩展权限;补丁版本用于内部职责重构与清理修复。
|
|
23
|
+
- 本机部署:Hub 已停止并从工作区重启,健康接口为 0.21.1 / ready;已配对 Chrome、Edge 均自动重载并实际重连为 0.21.1。稳定扩展目录 `F:\.nosis\browser-attach\extension` 的 42 个非生成文件已核对:manifest 按 JSON 内容一致,其余文件字节一致。新 stdio 连接返回 0.21.1 和 23 个工具。
|
|
24
|
+
- 验证日志与本机同步校验在本地忽略目录 `test-results/refactor-0.21.1/`;已有 MCP 客户端需重新连接以载入新版进程。
|
|
25
|
+
|
|
26
|
+
## 0.21.0 验证
|
|
27
|
+
|
|
28
|
+
- `npm test`:236 项通过。新增可操作性原因、有限祖先样式/滚动/transition 证据、零透明度放行、slot 继承 inert、无可见落点且保留既有派发事实、ref 历史上限及释放清理、身份解析期间文档变化、动作后观察不可用/未见变化/正面证据分型,以及明确状态等待的参数、严格匹配、节点替换、缺失 ARIA 状态和字段值不回显验证。
|
|
29
|
+
- Chromium、Edge headless:`interaction-smoke --primitives` 各 22 个场景通过。新增扩展 → Hub 的真实 DOM 验证:一次 DOM 点击后在 batch 等待替换后的字段值、原生 checked/selected、AX role 定位的 aria-expanded/aria-selected、CSS/role 无匹配;原生 mixed、隐藏 DOM 不等于 CSS 无匹配、缺失状态不等于 false、等待超时后写次数仍为 1,以及旧 ref 的 snapshot_replaced 诊断。原有原生输入、hover、拖拽及滚动场景继续通过。
|
|
30
|
+
- `npm run test:package`:包外安装与 stdio MCP 通过,23 个工具、51 个发布文件,无配对密钥;未增加依赖或扩展权限。MCP 测试同时覆盖新 wait 条件在单步和 batch 中的 schema、布尔 false/mixed 传递与参数预检。
|
|
31
|
+
- 本机部署:Hub 已停止并从工作区重启,健康接口为 0.21.0 / ready;Chrome、Edge 均自动重载并上报 0.21.0。稳定扩展目录 `F:\.nosis\browser-attach\extension` 的 manifest 为 0.21.0,其余 33 个非生成源码/资源文件 SHA-256 与工作区一致。新 stdio 连接返回 0.21.0、23 个工具和新增 wait 条件;已有 MCP 客户端需重新连接以刷新 schema。
|
|
32
|
+
- 当前边界:状态等待只确认指定的页面状态,不推断业务成功;ARIA 缺失/无效时继续报告 unavailable。role 无匹配不证明 DOM 已移除;不可用 frame 不作为元素消失证据。ref 原因历史每标签限 1000 条;不自动重绑定、补发或切换输入模式。
|
|
33
|
+
|
|
34
|
+
## 0.20.0 验证
|
|
35
|
+
|
|
7
36
|
- `node --test tests/*.test.mjs`:226 项通过。新增 file:// 发现与接管的实时授权、普通操作与 batch 中途停权、HTTP→file 导航派发前检查、文件导航后的 ref/分页失效、停权后可靠释放及 detach 失败保留控制权、历史目标权限检查和迟到权限回执不派发导航。
|
|
8
37
|
- Chromium 156、Edge 155 headless:`node scripts/live-smoke.mjs --executable <浏览器路径> --files` 各通过 5 组集成检查。复用扩展 → Hub → stdio MCP 链路,验证中文/空格/# 文件路径、真实扩展设置中的文件权限开关、发现后独占接管且不激活、主文档与本地 iframe AX、PNG 截图、ref 原生 fill/type/按键/click 与 trusted 事件、DOM batch、文件 URL/前进/后退及 HTTP↔file 导航、旧 ref/cursor 失效、释放/连接断开/关闭后的控制权清理。测试脚本只在临时 profile 的扩展管理页切换权限;权限变更触发扩展重载后重新发现浏览器连接。
|
|
9
38
|
- Chromium 156 原有 HTTP(S) `live-smoke`:254 次工具调用通过,包含 fetch、正文查询与分页、输入、对话框、上传、OOPIF、日志及大 AX 树回归。
|
package/docs/USAGE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# BrowserAttach 使用指南
|
|
2
2
|
|
|
3
|
-
适用于 **0.
|
|
3
|
+
适用于 **0.22.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
|
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
| `browser_type` | 在当前选区逐字符输入 |
|
|
45
45
|
| `browser_press_key` | 向当前焦点发送单键或组合键 |
|
|
46
46
|
| `browser_select_option` | 设置一个 HTML select 选项 |
|
|
47
|
-
| `browser_wait` |
|
|
47
|
+
| `browser_wait` | 只读等待元素、字段值、控件状态、文本、URL 或网络空闲 |
|
|
48
48
|
| `browser_scroll` | 滚动容器或有界查找虚拟列表项 |
|
|
49
49
|
| `browser_batch` | 在同一标签队列中执行有限步骤 |
|
|
50
50
|
| `browser_navigate` | URL 导航、后退或前进 |
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
|
|
76
76
|
超时、回执丢失或派发中断可能发生在动作生效之后。先用快照、截图或日志观察,再决定下一步;不要自动重放或换模式补发。导航和异步 JavaScript 也可能在超时后继续。
|
|
77
77
|
|
|
78
|
-
不确定错误可能附带结构化 `post_action`:delivery=unknown、dialog/dialogs、navigation_observed、frame_navigation_observed、current_url、frame_id、dom_changed。assessment=observable_effects
|
|
78
|
+
不确定错误可能附带结构化 `post_action`:delivery=unknown、dialog/dialogs、navigation_observed、frame_navigation_observed、current_url、frame_id、dom_changed。observations.tab/dom 分别报告 available 和不可用时的 error;dom_changed=null 表示 DOM 观察不可用。assessment=observable_effects 表示已有变化证据,即使部分观察不可用也保留;只有标签与 DOM 观察均可用且未发现变化时才返回 no_observable_effects,否则为 observation_unavailable。三者都不证明业务成功或没有派发。DOM 观察未必覆盖字段属性、其他 frame 或服务端变化。`input_dispatched=true` 表示已进入派发阶段,不证明事件送达或脚本完成;false 表示尚未派发,字段缺失则没有确定证据。
|
|
79
79
|
|
|
80
80
|
`TAB_NOT_READY` 明确表示本次指针输入尚未派发。显式激活或恢复窗口后,重新 snapshot 再执行动作。检查通过后仍可能遇到用户切换标签或浏览器菜单捕获输入,最终以事件回执和错误中的派发状态为准。
|
|
81
81
|
|
|
@@ -150,6 +150,8 @@ snapshot 返回 AX 语义树,每行 `[token:n] role "name" 状态`;`[token:n
|
|
|
150
150
|
|
|
151
151
|
ref 对应原节点及其可访问身份;节点被替换、脱离文档或 role/name 变化时应重新观察,不会自动匹配同名替代节点。
|
|
152
152
|
|
|
153
|
+
ref 错误的 diagnostics.reason=stale_ref,cause 区分 snapshot_replaced、document_navigation/document_updated、same_document_navigation、frame_changed、frame_session_changed、element_detached 和 identity_changed 等原因。每标签至多保留最近 1000 个已清理 ref 的失效原因,释放或断连清空;超出范围或未由本次接管创建的 ref 返回 unknown_ref,不推断其来源。
|
|
154
|
+
|
|
153
155
|
表单控件可附带 `context="性别"` 等邻近文本,在 interactive 或 text=false 下也保留,并参与 query。context 来自有限祖先范围内、仅含一个控件的容器中前一兄弟节点的 AX 文本;只是观察提示,不代表真实标签关联,不改写 AX name,也不参与 ref 身份校验。复杂布局可能没有 context。
|
|
154
156
|
|
|
155
157
|
### browser_read_text
|
|
@@ -199,7 +201,11 @@ frame.url/title 精确匹配子 frame,path 是逐层且每层唯一的 CSS ifr
|
|
|
199
201
|
|
|
200
202
|
click/hover/fill/type 的输入准备会检查可见、启用、非 inert 和几何稳定;fill 还要求可编辑且非只读。select_option 不要求可见几何。timeout_ms 默认 5000,范围 100–30000,覆盖准备、派发和校验;click/hover/press_key 的派发及回执另有 1250 ms 上限,清理最多 250 ms,长 timeout 主要用于等待目标。
|
|
201
203
|
|
|
202
|
-
|
|
204
|
+
准备失败的 diagnostics.reason 区分 display_none、visibility_hidden、zero_size、disabled、inert、read_only、unstable_geometry 和 no_visible_click_point;遮挡仍使用 obstructed/frame_obstructed。不可操作时附当前矩形及至多 8 层 composed 祖先(含目标)的 display、visibility、opacity、pointer-events、overflow、可滚动方向及 transition 属性/时长,ancestors_truncated 表示更深信息未采集。这些是当前样式证据,不推断 hover 触发条件或动画是否即将结束;opacity=0 不单独拒绝输入。
|
|
205
|
+
|
|
206
|
+
原生 click/hover/drag 要求未冻结/丢弃的标签与未最小化窗口,准备前及派发前各检查一次,单次最多 500 ms。无修饰键的左键单击(button=left、click_count=1、modifiers=[],均为默认值)可在非活动标签尝试执行,支持 ref、selector 及坐标目标;hover、drag、其他按钮、双击及带修饰键的点击仍要求活动标签。普通点击不会自动激活标签,后台事件送达和网站业务成功仍需分别核验,不能据此假设依赖悬停展开的流程也可后台执行。
|
|
207
|
+
|
|
208
|
+
窗口失焦、document.visibilityState=hidden 或 document.hasFocus()=false 不独立触发拒绝;Click/Hover 成功结果的 page_state 提供这些诊断信号,以及派发前检查观察到的 tab_active。就绪检查通过不保证后台点击每次送达;实际送达仍需可信事件回执,缺失时报告结果未知,不自动重放或切换 DOM 模式。就绪失败的处理见[结果未知时](#结果未知时)。
|
|
203
209
|
|
|
204
210
|
native 与 dom 由调用者显式选择。DOM click/fill 使用非可信事件且没有用户激活,不能满足依赖 isTrusted、pointer 事件或用户激活的功能。
|
|
205
211
|
|
|
@@ -261,17 +267,31 @@ pointer 用于鼠标驱动控件,核验 pointerdown/move/up;html5 用真实
|
|
|
261
267
|
| condition | 等待内容 |
|
|
262
268
|
|---|---|
|
|
263
269
|
| `attached` / `visible` | 元素出现 / 非隐藏且有尺寸;visible 不要求启用 |
|
|
270
|
+
| `absent` | 严格 selector 不再匹配;隐藏的 DOM 元素仍可能匹配 CSS/XPath,role 查询无匹配不证明 DOM 节点已移除 |
|
|
264
271
|
| `actionable` | 可见、启用、非 inert、几何稳定且命中无阻挡;不自动滚动,须已在视口内 |
|
|
265
272
|
| `text` | 渲染文本 innerText 含 expected,或与完整旧值 previous 不同;二者选一 |
|
|
273
|
+
| `value` | input/textarea/select 的 value 或 contenteditable 的 innerText 与字符串 expected 精确相等;不回显字段值 |
|
|
274
|
+
| `checked` | checkbox/radio 的原生状态,否则读取 aria-checked;expected 为 true/false,或字符串 mixed |
|
|
275
|
+
| `selected` | option.selected,否则读取 aria-selected;expected 为 true/false |
|
|
276
|
+
| `expanded` | details.open,否则读取 aria-expanded;expected 为 true/false |
|
|
266
277
|
| `url` | 主 frame URL 含 expected |
|
|
267
278
|
| `network_idle` | 已观察的请求 inflight=0,且连续 idle_ms 无活动 |
|
|
268
279
|
|
|
269
280
|
元素条件必须提供严格 selector,可带 frame/nth;全页渲染文字使用 `selector:"body"`。url/network_idle 不使用元素目标。工具只读等待,超时附带条件及最后状态。
|
|
270
281
|
|
|
282
|
+
控件状态不要求可见或启用。缺失或无效的 ARIA 状态报告 state=unavailable,不按 false 处理;checkbox.indeterminate 对应 mixed。每次轮询重新解析 selector,节点在重渲染中被替换可继续等待,歧义立即报错;frame 不可用不能证明元素消失。等待只验证明确条件,不证明保存或服务端业务完成,超时不使前序动作变得可安全重放。单步和 batch 使用相同的条件与校验。
|
|
283
|
+
|
|
271
284
|
```json
|
|
272
285
|
{"tab_handle":"...","selector":"#toast","condition":"text","expected":"保存成功","timeout_ms":5000}
|
|
273
286
|
```
|
|
274
287
|
|
|
288
|
+
```json
|
|
289
|
+
{"tab_handle":"...","steps":[
|
|
290
|
+
{"action":"click","selector":"#open-picker"},
|
|
291
|
+
{"action":"wait","selector":"#open-picker","condition":"expanded","expected":true}
|
|
292
|
+
]}
|
|
293
|
+
```
|
|
294
|
+
|
|
275
295
|
network_idle 的 idle_ms 默认 500,范围 100–10000。它只覆盖接管后观察到的页面/iframe 请求,不包含独立 worker 或 WebSocket 消息;长轮询会阻止满足。请求跟踪溢出时 tracking_overflow=true,不能报告已确认空闲。业务就绪优先等待具体元素或标志。
|
|
276
296
|
|
|
277
297
|
### browser_fill
|
|
@@ -1,72 +1,96 @@
|
|
|
1
|
-
// One deadline covers preparation, waiting, dispatch and verification. Only
|
|
2
|
-
// preparation is retried; once input is sent its outcome must be observed.
|
|
3
|
-
export function deadline(timeout_ms = 5000, phase = 'input', parentEnd = Infinity) {
|
|
4
|
-
if (!Number.isInteger(timeout_ms) || timeout_ms < 100 || timeout_ms > 30000) throw new Error('timeout_ms must be 100..30000');
|
|
5
|
-
const end = Math.min(Date.now() + timeout_ms, parentEnd);
|
|
6
|
-
let dispatched = false, waiting, diagnostics;
|
|
7
|
-
function error() {
|
|
8
|
-
const result = timeoutError();
|
|
9
|
-
if (diagnostics) result.diagnostics = diagnostics;
|
|
10
|
-
return result;
|
|
11
|
-
}
|
|
12
|
-
function timeoutError() {
|
|
13
|
-
if (phase === 'evaluation') return Object.assign(new Error(dispatched ? 'JavaScript evaluation timed out. Page effects may already have occurred. Observe before continuing. Do not replay automatically.' : 'Evaluation preparation timed out; no expression was executed.'), { input_dispatched: dispatched });
|
|
14
|
-
if (phase === 'fetch') return new Error(`Fetch did not finish within ${timeout_ms} ms; the temporary tab will be closed. Do not automatically replay the request.`);
|
|
15
|
-
if (phase === 'wait') return Object.assign(new Error(`Condition was not satisfied within ${timeout_ms} ms`), { input_dispatched: false });
|
|
16
|
-
if (phase === 'observation') return new Error(`Navigation observation timed out within ${timeout_ms} ms; navigation already succeeded. Take a new snapshot.`);
|
|
17
|
-
const result = new Error(dispatched
|
|
18
|
-
? `Input did not finish with verified delivery within ${timeout_ms} ms. Outcome is unknown. Browser UI may be capturing input; press Esc to dismiss a native menu. Observe before retrying; never automatically replay or fall back.`
|
|
19
|
-
: `Target did not become actionable within ${timeout_ms} ms${waiting ? `; waiting for ${waiting}` : ''}; no input was dispatched.`);
|
|
20
|
-
result.input_dispatched = dispatched;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (bounds?.unsupported) throw new Error(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
1
|
+
// One deadline covers preparation, waiting, dispatch and verification. Only
|
|
2
|
+
// preparation is retried; once input is sent its outcome must be observed.
|
|
3
|
+
export function deadline(timeout_ms = 5000, phase = 'input', parentEnd = Infinity) {
|
|
4
|
+
if (!Number.isInteger(timeout_ms) || timeout_ms < 100 || timeout_ms > 30000) throw new Error('timeout_ms must be 100..30000');
|
|
5
|
+
const end = Math.min(Date.now() + timeout_ms, parentEnd);
|
|
6
|
+
let dispatched = false, waiting, diagnostics;
|
|
7
|
+
function error() {
|
|
8
|
+
const result = timeoutError();
|
|
9
|
+
if (diagnostics) result.diagnostics = diagnostics;
|
|
10
|
+
return result;
|
|
11
|
+
}
|
|
12
|
+
function timeoutError() {
|
|
13
|
+
if (phase === 'evaluation') return Object.assign(new Error(dispatched ? 'JavaScript evaluation timed out. Page effects may already have occurred. Observe before continuing. Do not replay automatically.' : 'Evaluation preparation timed out; no expression was executed.'), { input_dispatched: dispatched, outcome_unknown: dispatched });
|
|
14
|
+
if (phase === 'fetch') return new Error(`Fetch did not finish within ${timeout_ms} ms; the temporary tab will be closed. Do not automatically replay the request.`);
|
|
15
|
+
if (phase === 'wait') return Object.assign(new Error(`Condition was not satisfied within ${timeout_ms} ms`), { input_dispatched: false });
|
|
16
|
+
if (phase === 'observation') return Object.assign(new Error(`Navigation observation timed out within ${timeout_ms} ms; navigation already succeeded. Take a new snapshot.`), { outcome_unknown: true });
|
|
17
|
+
const result = new Error(dispatched
|
|
18
|
+
? `Input did not finish with verified delivery within ${timeout_ms} ms. Outcome is unknown. Browser UI may be capturing input; press Esc to dismiss a native menu. Observe before retrying; never automatically replay or fall back.`
|
|
19
|
+
: `Target did not become actionable within ${timeout_ms} ms${waiting ? `; waiting for ${waiting}` : ''}; no input was dispatched.`);
|
|
20
|
+
result.input_dispatched = dispatched;
|
|
21
|
+
result.outcome_unknown = dispatched;
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
async function run(operation) {
|
|
25
|
+
const remaining = end - Date.now();
|
|
26
|
+
if (remaining <= 0) throw error();
|
|
27
|
+
let timer;
|
|
28
|
+
try { return await Promise.race([Promise.resolve().then(operation), new Promise((resolve, reject) => {
|
|
29
|
+
timer = setTimeout(() => reject(error()), remaining);
|
|
30
|
+
})]); } finally { clearTimeout(timer); }
|
|
31
|
+
}
|
|
32
|
+
return { run, has_dispatched: () => dispatched, child(ms, childPhase = phase) { return deadline(ms, childPhase, end); }, describe(detail, state) { waiting = detail; diagnostics = state; }, dispatch() { if (Date.now() >= end) throw error(); dispatched = true; }, remaining: () => Math.max(0, end - Date.now()) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function waitForTarget(cdp, value, target, node, budget, { editable = false } = {}) {
|
|
36
|
+
let previous;
|
|
37
|
+
while (true) {
|
|
38
|
+
const bounds = value(await budget.run(() => cdp(target, 'Runtime.callFunctionOn', {
|
|
39
|
+
objectId: node.objectId, functionDeclaration: inspectTarget.toString(), arguments: [{ value: editable }], returnByValue: true,
|
|
40
|
+
})));
|
|
41
|
+
if (bounds?.detached || bounds?.unsupported) throw Object.assign(new Error(`${bounds.detached
|
|
42
|
+
? 'Element is detached; take a new snapshot.'
|
|
43
|
+
: 'Target is not a supported editable field.'} ${budget.has_dispatched() ? 'Input was already dispatched; outcome is unknown. Do not replay automatically.' : 'No input was dispatched.'}`), { outcome_unknown: true,
|
|
44
|
+
input_dispatched: budget.has_dispatched(), diagnostics: { reason: bounds.detached ? 'element_detached' : 'unsupported_editable', frame_id: node.frameId },
|
|
45
|
+
});
|
|
46
|
+
if (bounds?.ready && previous && JSON.stringify(bounds) === JSON.stringify(previous)) return;
|
|
47
|
+
budget.describe?.(`visible, enabled, stable target; current=${JSON.stringify(bounds)}`, { reason: bounds?.ready ? 'unstable_geometry' : bounds?.reason || 'not_actionable', frame_id: node.frameId, target: bounds });
|
|
48
|
+
previous = bounds?.ready ? bounds : null;
|
|
49
|
+
await budget.run(() => new Promise(resolve => setTimeout(resolve, 20)));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function inspectTarget(editable) {
|
|
54
|
+
let element = this?.nodeType === 3 ? this.parentElement : this;
|
|
55
|
+
if (!element?.isConnected) return { detached: true };
|
|
56
|
+
const tag = element.tagName?.toLowerCase();
|
|
57
|
+
if (editable && ((!['input', 'textarea'].includes(tag) && !element.isContentEditable) ||
|
|
58
|
+
(tag === 'input' && !['text', 'search', 'email', 'password', 'tel', 'url', ''].includes(element.type)))) return { unsupported: true };
|
|
59
|
+
const next = node => node.assignedSlot || node.parentElement || node.getRootNode().host;
|
|
60
|
+
const rect = element.getBoundingClientRect(), win = element.ownerDocument.defaultView, style = win.getComputedStyle(element);
|
|
61
|
+
function failure(reason, blocker) {
|
|
62
|
+
const ancestors = [];
|
|
63
|
+
let parent = element;
|
|
64
|
+
for (; parent && ancestors.length < 8; parent = next(parent)) {
|
|
65
|
+
const css = win.getComputedStyle(parent);
|
|
66
|
+
ancestors.push({ tag: parent.tagName?.toLowerCase(), id: parent.id || null,
|
|
67
|
+
display: css.display, visibility: css.visibility, opacity: css.opacity, pointer_events: css.pointerEvents,
|
|
68
|
+
overflow_x: css.overflowX, overflow_y: css.overflowY,
|
|
69
|
+
transition_property: css.transitionProperty, transition_duration: css.transitionDuration,
|
|
70
|
+
scrollable_x: /auto|scroll|overlay/.test(css.overflowX) && parent.scrollWidth > parent.clientWidth,
|
|
71
|
+
scrollable_y: /auto|scroll|overlay/.test(css.overflowY) && parent.scrollHeight > parent.clientHeight });
|
|
72
|
+
}
|
|
73
|
+
if (reason === 'zero_size' && ancestors.some(node => node.display === 'none')) reason = 'display_none';
|
|
74
|
+
return { ready: false, reason, rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
75
|
+
...(blocker ? { blocker: { tag: blocker.tagName?.toLowerCase(), id: blocker.id || null } } : {}),
|
|
76
|
+
ancestors, ancestors_truncated: !!parent };
|
|
77
|
+
}
|
|
78
|
+
// Inert and disabled state can be inherited through a slot or shadow host.
|
|
79
|
+
for (let parent = element; parent; parent = next(parent)) {
|
|
80
|
+
if (parent.matches(':disabled') || parent.hasAttribute('inert')) return failure(parent.matches(':disabled') ? 'disabled' : 'inert', parent);
|
|
81
|
+
}
|
|
82
|
+
if (style.display === 'none') return failure('display_none');
|
|
83
|
+
if (style.visibility !== 'visible') return failure('visibility_hidden');
|
|
84
|
+
if (rect.width <= 0 || rect.height <= 0) return failure('zero_size');
|
|
85
|
+
if (editable && element.readOnly) return failure('read_only');
|
|
86
|
+
return { ready: true,
|
|
87
|
+
x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// A lost native receipt must free the queue promptly even with a long preparation budget.
|
|
91
|
+
export async function delivery(operation, ms = 1250) {
|
|
92
|
+
let timer;
|
|
93
|
+
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.'), { outcome_unknown: true })), ms);
|
|
95
|
+
})]); } finally { clearTimeout(timer); }
|
|
96
|
+
}
|