@evan_zhong/browser-attach 0.21.0 → 0.22.1

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 CHANGED
@@ -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 active, unsuspended tab in a non-minimized window, and 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.
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
 
@@ -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 { ensureHub, hubIsRunning, AttachClient } from '../src/client.mjs';
5
-
6
- try {
7
- const { values, positionals } = parseArgs({ allowPositionals: true, options: {
8
- config: { type: 'string', default: DEFAULT_CONFIG },
9
- 'extension-dir': { type: 'string' }, port: { type: 'string' },
10
- nosis: { type: 'boolean', default: false }, help: { type: 'boolean', short: 'h' },
11
- } });
12
- const action = positionals[0] || 'mcp', configPath = values.config;
13
- if (values.help) {
14
- console.log(`browser-attach [mcp|setup|hub|start|status|stop]\n setup [--extension-dir PATH] [--port 47831] [--nosis]\n --config PATH (default: ${DEFAULT_CONFIG})`);
15
- } else if (action === 'mcp') {
16
- const { runMcp } = await import('../src/mcp.mjs'); await runMcp(configPath);
17
- } else if (action === 'hub') {
18
- const { startHub } = await import('../src/hub.mjs');
19
- const hub = await startHub(await loadConfig(configPath));
20
- const stop = () => hub.close(); process.once('SIGINT', stop); process.once('SIGTERM', stop);
21
- } else if (action === 'setup') {
22
- const { setup, configureNosis } = await import('../src/setup.mjs');
23
- const result = await setup({ configPath, extensionDir: values['extension-dir'], port: values.port === undefined ? undefined : Number(values.port) });
24
- console.log(`Extension folder: ${result.extensionDir}\nExtension ID: ${result.config.extension_id}\nPairing: ${result.configPath}`);
25
- if (values.nosis) console.log(JSON.stringify(await configureNosis(undefined, { configPath: result.configPath })));
26
- 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.');
27
- } else if (action === 'start') { await ensureHub(configPath); console.log('Local hub started.'); }
28
- else if (action === 'status') {
29
- const config = await loadConfig(configPath);
30
- if (!await hubIsRunning(config)) console.log('Local hub is stopped.');
31
- else {
32
- const client = new AttachClient(config);
33
- try { console.log(JSON.stringify(await client.call('list_browsers'), null, 2)); } finally { await client.close(); }
34
- }
35
- } else if (action === 'stop') {
36
- const config = await loadConfig(configPath);
37
- const response = await fetch(`http://127.0.0.1:${config.port}/shutdown`, {
38
- method: 'POST', headers: { Authorization: `Bearer ${config.token}` }, signal: AbortSignal.timeout(5000),
39
- });
40
- if (!response.ok) throw new Error(`Hub refused shutdown (${response.status})`);
41
- console.log('Hub stopped; extension control released.');
42
- } else throw new Error(`Unknown command: ${action}`);
43
- } catch (error) { console.error(error.message); process.exitCode = 1; }
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; }
@@ -1,6 +1,6 @@
1
1
  # 架构与设计边界
2
2
 
3
- 当前实现为 **0.21.0**。本文说明 BrowserAttach 的组件、控制模型与设计取舍;工具参数见 [使用指南](USAGE.md)。
3
+ 当前实现为 **0.22.1**。本文说明 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` | 操作实现;传输层从不暴露原始 CDP |
25
+ | `extension/controller.js` | 所有权、标签队列、权限检查、生命周期协调与操作分派;传输层从不暴露原始 CDP |
25
26
  | `extension/connection.js` | 扩展连接、重连清理、升级维护与自行重载 |
26
27
  | `extension/sessions.js` | 扁平 debugger session 与 frame/OOPIF 归属 |
27
- | `extension/{click,input,actionability,scroll}.js` | 原生/DOM 输入、逐字按键、可操作性、容器滚动与有界查找 |
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/outcomes.js` | 临时 DOM 变动观察器与有界只读探测,不推断业务成功 |
32
- | `extension/fetch.js` | 单页读取的参数、隔离世界采集与每 owner 有界缓存分页;临时页生命周期复用 controller 的 attach、导航和标签队列 |
33
- | `extension/{snapshot,navigation,evaluate,diagnostics}.js` | 观察、导航、有界求值结果缓存与分页、日志缓冲 |
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 状态复用 diagnostics 的 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,不声称是服务器原文;空标题省略。
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
 
@@ -94,7 +103,7 @@ HTTP 状态复用 diagnostics 的 Network.responseReceived 观察,按主文档
94
103
  - **type 与 fill**:fill 替换文字,type 保留选区,逐字符复用原生按键与 keydown 回执;字符之间可延迟,不把 insertText 伪装成键盘序列。浏览器处理 keypress/input 及 preventDefault,非 ASCII 不模拟系统 IME。
95
104
  - **焦点输入与语义定位**:type 的 focused=true 与 ref/selector 互斥,复用 press_key 的 frame/开放 Shadow DOM 焦点解析,固定最初可编辑元素并逐字检查,不重新聚焦。selector_type 的 label/placeholder 在隔离世界按真实 HTML 关联/属性查询,role/name 每次读取所选 frame 的 AX 树并严格匹配,以 AX 树顺序支持 nth;统一返回原有节点结构,继续复用输入、命中核验及 batch 流程,无组件库适配器。
96
105
  - **只重试准备**:等待阶段可以反复轮询,但一旦派发就不再重试。超时分为"未派发"和"已派发、结果未知"两种错误文本。
97
- - **原生指针就绪检查**:准备前和派发前各做一次至多 500 ms 的有界检查;要求活动、未暂停的 tab 与未最小化的窗口。不满足时以 TAB_NOT_READY 明确报告当前指针输入未派发,并提示 tab_control(action=activate) → snapshot → click。click 不主动激活;仅显式 tab_control(action=activate) 在 focus_window=true 时请求窗口聚焦和 Page.bringToFront。
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。
98
107
  - **焦点信号边界**:OS 窗口失焦、遮挡或 document.visibilityState=hidden 不一定阻止 CDP 输入;document.hasFocus() 在 iframe 导航后也可能为 false。这些值作为 page_state 诊断,不独立拒绝点击。实际送达仍由隔离世界的可信事件回执校验;没有回执时不自动重放。
99
108
  - **送达预算**:原生 click/hover/press_key 的派发及可信回执另有 1250 ms 上限,清理最多 250 ms;系统菜单捕获输入时快速释放队列,仍报告结果未知并提示 Esc,不自动重放。
100
109
  - **hover 与 drag**:hover 复用点击准备与隔离校验,仅派发一次 mouseMoved,清理不移走指针。drag 显式区分 pointer 与 HTML5;后者从真实 dragstart 截获 DragData,再用 CDP 派发 dragover/drop,校验可信 dragstart/drop/dragend。目的节点在按住后滚入视口;所有步骤共用标签队列和总预算,失败时只取消/释放本次手势。
@@ -1,9 +1,39 @@
1
1
  # 验证报告
2
2
 
3
- 当前版本 **0.21.0**(2026-10-24)。浏览器验证使用本地 fixture 和临时 profile;测试链路在各项下说明。
3
+ 当前版本 **0.22.1**(2026-10-25)。浏览器验证使用本地 fixture 和临时 profile;测试链路在各项下说明。
4
4
 
5
5
  ## 当前版本
6
6
 
7
+ - `npm test`:246 项通过。新增隔离世界选择器诊断经 CDP 异常对象读取、单步/read_text/wait/batch 传递及 frame 路径底层原因保留;诊断读取失败保留原异常并清理 wrapper。补充准备/派发超时分类、派发后不沿用准备诊断、fill 值不匹配保留未知结果且不回显字段值。后续新增的 click 目标变化原因断言另通过 15 项指针回归。
8
+ - 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 树回归。
9
+ - Edge 首次与 Chromium 并行执行时,原有遮挡检查在 500 ms 准备预算内超时,未派发输入;未修改该检查预算,独立复跑完整脚本通过。
10
+ - `npm run test:package`:包外安装与 stdio MCP 通过,23 个工具、60 个发布文件,无配对密钥。未增加工具、参数、依赖或扩展权限,版本按诊断修复递增至 0.22.1。
11
+ - 本机部署:协调升级因现有 MCP 连接拒绝后,使用项目 stop/start 重启;Hub 为 0.22.1 / ready,已配对 Chrome、Edge 均为 0.22.1。稳定扩展目录 `F:\.nosis\browser-attach\extension` 的 42 个文件已核对:manifest 版本一致,其余非生成文件 SHA-256 一致。
12
+ - 当前边界:原因码不推断业务成功或服务端拒绝,也不授权重放;文档/上下文销毁导致异常诊断无法读取时保留原错误,diagnostics 可能缺失。已有 MCP 客户端需重连,重新发现和接管标签。
13
+
14
+ ## 0.22.0 验证
15
+
16
+ - `npm test`:243 项通过。新增非活动标签的单次左键单击(ref / selector / viewport / document 坐标)仍取得可信回执且不激活标签;hover、拖拽、其他按键、双击与带修饰键的点击仍在派发前以 `TAB_NOT_READY` 拒绝;派发前的就绪检查允许左键单击期间的标签切换,仍拒绝视图冻结或目标丢弃,`page_state` 带 `tab_active`。
17
+ - Chromium 156、Edge 155 headless:`interaction-smoke --regressions` 各 35 个场景通过。后台 ref / selector / viewport / document / offset 各 3 次(每浏览器 15 次,合计 30 次)均 `delivery_verified` 且标签激活与窗口焦点状态不变;连续两次后台 batch、后台回执丢失(1 次效果、不重放)、后台遮挡(未派发)、受限动作组、settle 期间切换标签等场景通过。
18
+ - Chromium 156 `interaction-smoke` 默认场景 17 项通过,覆盖前台、后台 ref 与坐标、遮挡窗口、缩放、hover 偏移、闭合 Shadow DOM 与导航。
19
+ - Chromium 156、Edge 155 完整 stdio MCP `live-smoke`:各 255 次工具调用、27 项检查通过;后台 ref / selector / 坐标点击均在 `tab_active=false` 下取得可信送达,显式激活、DOM 模式与命中提示的既有行为保持。
20
+ - `npm run test:package`:仓库外安装与 stdio MCP 通过,23 个工具、60 个发布文件,无配对密钥;未增加工具、参数、依赖或扩展权限。次版本反映点击就绪语义的变化。
21
+ - 本机部署:Hub 从工作区重启为 0.22.0 / ready;已配对 Chrome、Edge 均上报 0.22.0;稳定扩展目录 `F:\.nosis\browser-attach\extension` 的 42 个文件逐一核对与工作区一致。
22
+ - 真实既有 profile 探测:从未激活的后台页面上,Edge 取得可信回执并产生 1 次点击;Chrome 未取得回执,返回结果未知、`input_dispatched=true` 且未重放(0 次效果)。验证日志为本地忽略目录 `test-results/` 中的 `*-0220*` 文件。
23
+ - 当前边界:非活动标签只允许单次左键单击,送达仍以可信回执为准;无回执时报告结果未知,不自动激活、不重放、不切换 DOM 模式。hover、拖拽与其他点击变体继续要求活动标签。
24
+
25
+ ## 0.21.1 验证
26
+
27
+ - `npm test`:240 项通过。原 controller 测试按观察、指针、键盘输入、对话框、截图/上传、滚动、求值和 fetch 分组,共用原有 fixture;保留控制权、队列、权限和清理的集成测试。新增不依赖日志的网络状态、错误文案无关的结构化未知结果、观察启动前失败仍恢复对话框策略,以及 select 异常跨 CDP 边界后保留结果未知证据的回归。
28
+ - Chromium 156、Edge 155 完整 stdio MCP 集成:各 254 次工具调用通过,覆盖快照/ref、输入、上传、截图、导航、对话框、fetch、日志、嵌套 OOPIF 和 12000 个语义节点分页;Edge `interaction-smoke --primitives --headless` 22 个场景通过。
29
+ - Chromium 文件页面集成:50 次工具调用、5 组检查通过,覆盖实时文件权限、中文/空格/# 路径、主文档与 iframe 观察、原生输入、HTTP↔file 导航和控制权清理。
30
+ - Chromium 真实升级验证通过:同一临时 profile 内两个并发启动者完成 Hub 与扩展升级至 0.21.1,确认自动 runtime 重载及升级后页面操作。旧协议版本由当前代码修改版本号构造,不代表真实 0.16 扩展具备自动升级能力。
31
+ - `npm run test:package`:仓库外安装和 stdio MCP 通过,60 个发布文件、23 个工具,无配对密钥。未增加工具、参数、依赖或扩展权限;补丁版本用于内部职责重构与清理修复。
32
+ - 本机部署:Hub 已停止并从工作区重启,健康接口为 0.21.1 / ready;已配对 Chrome、Edge 均自动重载并实际重连为 0.21.1。稳定扩展目录 `F:\.nosis\browser-attach\extension` 的 42 个非生成文件已核对:manifest 按 JSON 内容一致,其余文件字节一致。新 stdio 连接返回 0.21.1 和 23 个工具。
33
+ - 验证日志与本机同步校验在本地忽略目录 `test-results/refactor-0.21.1/`;已有 MCP 客户端需重新连接以载入新版进程。
34
+
35
+ ## 0.21.0 验证
36
+
7
37
  - `npm test`:236 项通过。新增可操作性原因、有限祖先样式/滚动/transition 证据、零透明度放行、slot 继承 inert、无可见落点且保留既有派发事实、ref 历史上限及释放清理、身份解析期间文档变化、动作后观察不可用/未见变化/正面证据分型,以及明确状态等待的参数、严格匹配、节点替换、缺失 ARIA 状态和字段值不回显验证。
8
38
  - 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、拖拽及滚动场景继续通过。
9
39
  - `npm run test:package`:包外安装与 stdio MCP 通过,23 个工具、51 个发布文件,无配对密钥;未增加依赖或扩展权限。MCP 测试同时覆盖新 wait 条件在单步和 batch 中的 schema、布尔 false/mixed 传递与参数预检。
package/docs/USAGE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # BrowserAttach 使用指南
2
2
 
3
- 适用于 **0.21.0**:Node.js 22+、Chrome / Edge 125+、23 个 MCP 工具。安装、配对与升级见 [README](../README.md),宿主配置见 [Agent 集成](AGENT-INTEGRATION.md),内部机制见 [架构说明](ARCHITECTURE.md)。
3
+ 适用于 **0.22.1**: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
 
@@ -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 也可能在超时后继续。
@@ -203,7 +217,9 @@ click/hover/fill/type 的输入准备会检查可见、启用、非 inert 和几
203
217
 
204
218
  准备失败的 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
219
 
206
- 原生 click/hover/drag 要求活动、未冻结/丢弃的标签与未最小化窗口,准备前及派发前各检查一次,单次最多 500 ms。窗口失焦、document.visibilityState=hidden 或 document.hasFocus()=false 不独立触发拒绝;Click/Hover 成功结果的 page_state 提供这些诊断信号,实际送达仍需回执。就绪失败的处理见[结果未知时](#结果未知时)。
220
+ 原生 click/hover/drag 要求未冻结/丢弃的标签与未最小化窗口,准备前及派发前各检查一次,单次最多 500 ms。无修饰键的左键单击(button=left、click_count=1、modifiers=[],均为默认值)可在非活动标签尝试执行,支持 ref、selector 及坐标目标;hover、drag、其他按钮、双击及带修饰键的点击仍要求活动标签。普通点击不会自动激活标签,后台事件送达和网站业务成功仍需分别核验,不能据此假设依赖悬停展开的流程也可后台执行。
221
+
222
+ 窗口失焦、document.visibilityState=hidden 或 document.hasFocus()=false 不独立触发拒绝;Click/Hover 成功结果的 page_state 提供这些诊断信号,以及派发前检查观察到的 tab_active。就绪检查通过不保证后台点击每次送达;实际送达仍需可信事件回执,缺失时报告结果未知,不自动重放或切换 DOM 模式。就绪失败的处理见[结果未知时](#结果未知时)。
207
223
 
208
224
  native 与 dom 由调用者显式选择。DOM click/fill 使用非可信事件且没有用户激活,不能满足依赖 isTrusted、pointer 事件或用户激活的功能。
209
225
 
@@ -1,95 +1,99 @@
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
- return result;
22
- }
23
- async function run(operation) {
24
- const remaining = end - Date.now();
25
- if (remaining <= 0) throw error();
26
- let timer;
27
- try { return await Promise.race([Promise.resolve().then(operation), new Promise((resolve, reject) => {
28
- timer = setTimeout(() => reject(error()), remaining);
29
- })]); } finally { clearTimeout(timer); }
30
- }
31
- 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()) };
32
- }
33
-
34
- export async function waitForTarget(cdp, value, target, node, budget, { editable = false } = {}) {
35
- let previous;
36
- while (true) {
37
- const bounds = value(await budget.run(() => cdp(target, 'Runtime.callFunctionOn', {
38
- objectId: node.objectId, functionDeclaration: inspectTarget.toString(), arguments: [{ value: editable }], returnByValue: true,
39
- })));
40
- if (bounds?.detached || bounds?.unsupported) throw Object.assign(new Error(`${bounds.detached
41
- ? 'Element is detached; take a new snapshot.'
42
- : '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.'}`), {
43
- input_dispatched: budget.has_dispatched(), diagnostics: { reason: bounds.detached ? 'element_detached' : 'unsupported_editable', frame_id: node.frameId },
44
- });
45
- if (bounds?.ready && previous && JSON.stringify(bounds) === JSON.stringify(previous)) return;
46
- 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 });
47
- previous = bounds?.ready ? bounds : null;
48
- await budget.run(() => new Promise(resolve => setTimeout(resolve, 20)));
49
- }
50
- }
51
-
52
- export function inspectTarget(editable) {
53
- let element = this?.nodeType === 3 ? this.parentElement : this;
54
- if (!element?.isConnected) return { detached: true };
55
- const tag = element.tagName?.toLowerCase();
56
- if (editable && ((!['input', 'textarea'].includes(tag) && !element.isContentEditable) ||
57
- (tag === 'input' && !['text', 'search', 'email', 'password', 'tel', 'url', ''].includes(element.type)))) return { unsupported: true };
58
- const next = node => node.assignedSlot || node.parentElement || node.getRootNode().host;
59
- const rect = element.getBoundingClientRect(), win = element.ownerDocument.defaultView, style = win.getComputedStyle(element);
60
- function failure(reason, blocker) {
61
- const ancestors = [];
62
- let parent = element;
63
- for (; parent && ancestors.length < 8; parent = next(parent)) {
64
- const css = win.getComputedStyle(parent);
65
- ancestors.push({ tag: parent.tagName?.toLowerCase(), id: parent.id || null,
66
- display: css.display, visibility: css.visibility, opacity: css.opacity, pointer_events: css.pointerEvents,
67
- overflow_x: css.overflowX, overflow_y: css.overflowY,
68
- transition_property: css.transitionProperty, transition_duration: css.transitionDuration,
69
- scrollable_x: /auto|scroll|overlay/.test(css.overflowX) && parent.scrollWidth > parent.clientWidth,
70
- scrollable_y: /auto|scroll|overlay/.test(css.overflowY) && parent.scrollHeight > parent.clientHeight });
71
- }
72
- if (reason === 'zero_size' && ancestors.some(node => node.display === 'none')) reason = 'display_none';
73
- return { ready: false, reason, rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
74
- ...(blocker ? { blocker: { tag: blocker.tagName?.toLowerCase(), id: blocker.id || null } } : {}),
75
- ancestors, ancestors_truncated: !!parent };
76
- }
77
- // Inert and disabled state can be inherited through a slot or shadow host.
78
- for (let parent = element; parent; parent = next(parent)) {
79
- if (parent.matches(':disabled') || parent.hasAttribute('inert')) return failure(parent.matches(':disabled') ? 'disabled' : 'inert', parent);
80
- }
81
- if (style.display === 'none') return failure('display_none');
82
- if (style.visibility !== 'visible') return failure('visibility_hidden');
83
- if (rect.width <= 0 || rect.height <= 0) return failure('zero_size');
84
- if (editable && element.readOnly) return failure('read_only');
85
- return { ready: true,
86
- x: rect.x, y: rect.y, width: rect.width, height: rect.height };
87
- }
88
-
89
- // A lost native receipt must free the queue promptly even with a long preparation budget.
90
- export async function delivery(operation, ms = 1250) {
91
- let timer;
92
- try { return await Promise.race([operation, new Promise((resolve, reject) => {
93
- timer = setTimeout(() => reject(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.')), ms);
94
- })]); } finally { clearTimeout(timer); }
95
- }
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 && !dispatched) 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
+ result.diagnostics = { reason: dispatched ? 'input_timeout' : 'preparation_timeout' };
23
+ return result;
24
+ }
25
+ async function run(operation) {
26
+ const remaining = end - Date.now();
27
+ if (remaining <= 0) throw error();
28
+ let timer;
29
+ try { return await Promise.race([Promise.resolve().then(operation), new Promise((resolve, reject) => {
30
+ timer = setTimeout(() => reject(error()), remaining);
31
+ })]); } finally { clearTimeout(timer); }
32
+ }
33
+ 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()) };
34
+ }
35
+
36
+ export async function waitForTarget(cdp, value, target, node, budget, { editable = false } = {}) {
37
+ let previous;
38
+ while (true) {
39
+ const bounds = value(await budget.run(() => cdp(target, 'Runtime.callFunctionOn', {
40
+ objectId: node.objectId, functionDeclaration: inspectTarget.toString(), arguments: [{ value: editable }], returnByValue: true,
41
+ })));
42
+ if (bounds?.detached || bounds?.unsupported) throw Object.assign(new Error(`${bounds.detached
43
+ ? 'Element is detached; take a new snapshot.'
44
+ : '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,
45
+ input_dispatched: budget.has_dispatched(), diagnostics: { reason: bounds.detached ? 'element_detached' : 'unsupported_editable', frame_id: node.frameId },
46
+ });
47
+ if (bounds?.ready && previous && JSON.stringify(bounds) === JSON.stringify(previous)) return;
48
+ 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 });
49
+ previous = bounds?.ready ? bounds : null;
50
+ await budget.run(() => new Promise(resolve => setTimeout(resolve, 20)));
51
+ }
52
+ }
53
+
54
+ export function inspectTarget(editable) {
55
+ let element = this?.nodeType === 3 ? this.parentElement : this;
56
+ if (!element?.isConnected) return { detached: true };
57
+ const tag = element.tagName?.toLowerCase();
58
+ if (editable && ((!['input', 'textarea'].includes(tag) && !element.isContentEditable) ||
59
+ (tag === 'input' && !['text', 'search', 'email', 'password', 'tel', 'url', ''].includes(element.type)))) return { unsupported: true };
60
+ const next = node => node.assignedSlot || node.parentElement || node.getRootNode().host;
61
+ const rect = element.getBoundingClientRect(), win = element.ownerDocument.defaultView, style = win.getComputedStyle(element);
62
+ function failure(reason, blocker) {
63
+ const ancestors = [];
64
+ let parent = element;
65
+ for (; parent && ancestors.length < 8; parent = next(parent)) {
66
+ const css = win.getComputedStyle(parent);
67
+ ancestors.push({ tag: parent.tagName?.toLowerCase(), id: parent.id || null,
68
+ display: css.display, visibility: css.visibility, opacity: css.opacity, pointer_events: css.pointerEvents,
69
+ overflow_x: css.overflowX, overflow_y: css.overflowY,
70
+ transition_property: css.transitionProperty, transition_duration: css.transitionDuration,
71
+ scrollable_x: /auto|scroll|overlay/.test(css.overflowX) && parent.scrollWidth > parent.clientWidth,
72
+ scrollable_y: /auto|scroll|overlay/.test(css.overflowY) && parent.scrollHeight > parent.clientHeight });
73
+ }
74
+ if (reason === 'zero_size' && ancestors.some(node => node.display === 'none')) reason = 'display_none';
75
+ return { ready: false, reason, rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
76
+ ...(blocker ? { blocker: { tag: blocker.tagName?.toLowerCase(), id: blocker.id || null } } : {}),
77
+ ancestors, ancestors_truncated: !!parent };
78
+ }
79
+ // Inert and disabled state can be inherited through a slot or shadow host.
80
+ for (let parent = element; parent; parent = next(parent)) {
81
+ if (parent.matches(':disabled') || parent.hasAttribute('inert')) return failure(parent.matches(':disabled') ? 'disabled' : 'inert', parent);
82
+ }
83
+ if (style.display === 'none') return failure('display_none');
84
+ if (style.visibility !== 'visible') return failure('visibility_hidden');
85
+ if (rect.width <= 0 || rect.height <= 0) return failure('zero_size');
86
+ if (editable && element.readOnly) return failure('read_only');
87
+ return { ready: true,
88
+ x: rect.x, y: rect.y, width: rect.width, height: rect.height };
89
+ }
90
+
91
+ // A lost native receipt must free the queue promptly even with a long preparation budget.
92
+ export async function delivery(operation, ms = 1250) {
93
+ let timer;
94
+ try { return await Promise.race([operation, new Promise((resolve, reject) => {
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);
98
+ })]); } finally { clearTimeout(timer); }
99
+ }