@evan_zhong/browser-attach 0.17.2 → 0.19.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 CHANGED
@@ -8,6 +8,8 @@
8
8
  <strong>English</strong> · <a href="README.zh.md">简体中文</a>
9
9
  </p>
10
10
 
11
+ [![M8ven Score](https://m8ven.ai/badge/mcp/evannzhongg-browserattach-kisqie?v=ad2528ebe8a0fa35e3cf933630840bac)](https://m8ven.ai/mcp/evannzhongg-browserattach-kisqie?s=readme)
12
+
11
13
  Drive the Chrome / Edge tabs you already have open and already signed in from an MCP client. BrowserAttach connects to the browser through a locally paired extension, keeps your existing sign-in state, and needs neither a remote debugging port nor a separate automation browser.
12
14
 
13
15
  [npm](https://www.npmjs.com/package/@evan_zhong/browser-attach) · [Usage guide](docs/USAGE.md) · [Architecture](docs/ARCHITECTURE.md) · [Agent integration](docs/AGENT-INTEGRATION.md) · [Test report](docs/TEST-REPORT.md) · [Issues](https://github.com/EvannZhongg/BrowserAttach/issues) · [Contributing](CONTRIBUTING.md)
@@ -15,7 +17,7 @@ Drive the Chrome / Edge tabs you already have open and already signed in from an
15
17
  ## What it does
16
18
 
17
19
  - Discover multiple browser profiles, pick one precisely, claim a tab exclusively, and release it when done.
18
- - Read the accessibility tree, query it by literal name/value, inspect field values and supported editor models; click, replace-fill and type character by character through current snapshot refs or strict CSS/XPath locators.
20
+ - Read the accessibility tree, query names/values/nearby field context, inspect field values and supported editor models; click, fill and type through refs or strict CSS/XPath, HTML label, placeholder and AX role/name locators. Type can also target the currently focused field explicitly.
19
21
  - Native pointer hover, stepped drags and conditional waits; select HTML options without opening a native popup.
20
22
  - Navigate and go back/forward, open or close tabs, handle JavaScript dialogs, upload local files, and capture the whole page or a single element.
21
23
  - Evaluate JavaScript expressions in the main page; read console, page-error and network lifecycle logs captured after claiming; opt in to bounded, redacted request/response details when XHR/fetch needs verification.
package/README.zh.md CHANGED
@@ -15,7 +15,7 @@
15
15
  ## 能做什么
16
16
 
17
17
  - 发现多个浏览器配置,精确选择并独占接管标签页,完成后释放。
18
- - 读取可访问性树,按字面名称或 value 查询并取得可操作引用,检查字段值和支持的编辑器模型,通过当前快照引用或严格 CSS/XPath 定位点击、替换填写和逐字输入。
18
+ - 读取可访问性树,按名称、value 或邻近字段 context 查询并取得可操作引用,检查字段值和编辑器模型;通过 ref 或严格 CSS/XPath、HTML label、placeholder、AX role/name 定位点击和填写,也可显式向当前聚焦字段逐字输入。
19
19
  - 原生指针悬停、分步拖拽、条件等待;无需打开系统弹层即可选择 HTML 下拉选项。
20
20
  - 导航与历史前进/后退、打开或关闭标签页,处理 JavaScript 对话框,上传本机文件并截取整页或单个元素。
21
21
  - 在主页面执行 JavaScript 表达式,读取接管后的控制台、页面异常和网络生命周期日志;需要校验 XHR/fetch 时显式开启有界、脱敏的请求/响应详情。
@@ -1,6 +1,6 @@
1
1
  # 架构与设计边界
2
2
 
3
- 当前实现为 **0.17.2**。本文说明 BrowserAttach 的组件、控制模型与设计取舍;工具参数见 [使用指南](USAGE.md)。
3
+ 当前实现为 **0.19.0**。本文说明 BrowserAttach 的组件、控制模型与设计取舍;工具参数见 [使用指南](USAGE.md)。
4
4
 
5
5
  ## 组件与数据流
6
6
 
@@ -31,7 +31,7 @@ MCP 客户端
31
31
  | `extension/outcomes.js` | 临时 DOM 变动观察器与有界只读探测,不推断业务成功 |
32
32
  | `extension/fetch.js` | 单页读取的参数、隔离世界采集与每 owner 有界缓存分页;临时页生命周期复用 controller 的 attach、导航和标签队列 |
33
33
  | `extension/{snapshot,navigation,evaluate,diagnostics}.js` | 观察、导航、有界求值结果缓存与分页、日志缓冲 |
34
- | `extension/{locator,text}.js` | 严格 CSS/XPath 定位、每次解析的 frame URL/title/iframe 路径、DOM/字段/支持的编辑器模型读取 |
34
+ | `extension/{locator,text}.js` | 严格 CSS/XPath/HTML label/placeholder/AX role-name 定位、每次解析的 frame URL/title/iframe 路径、DOM/字段/支持的编辑器模型读取 |
35
35
  | `extension/visuals.js`、`click-visuals.js` | 无标题红色原生标签组、临时点击反馈 |
36
36
 
37
37
  Hub 按需后台启动(`ensureHub`),多客户端共享;只监听 loopback。
@@ -69,7 +69,8 @@ HTTP 状态复用 diagnostics 的 Network.responseReceived 观察,按主文档
69
69
 
70
70
  - **AX 渲染**:从 `Accessibility.getFullAXTree` 读取,移除 `InlineTextBox`,合并纯文本列表项与段落名称,丢弃 `generic` / `none` 且无名称无状态的节点,只保留少量状态属性和浏览器提供的 value、invalid。树遍历、交互视图筛选和正文合并使用迭代,跨 frame 逐项合并结果,避免深树递归和大数组参数展开溢出;分页前仍采集并渲染完整 AX 树,max_nodes 不限制采集成本。每行 `[token:n] role "name"`,`token` 是本次观察的随机前缀;这些字段不证明应用已保存。
71
71
  - **交互视图**:可选 `profile=interactive` 在同一渲染器中仅保留 AX 控件角色、可聚焦/可编辑节点及其祖先链和控件内文本,不以 backendNodeId/ref 是否存在判断交互性。独立文本和合并正文可隐藏或截取预览,控件名称、值和 ref 的原始 AX 身份保留;默认 `full` 行为不变。筛选仍读取完整 AX 树,只降低输出与分页成本;视图参数绑定分页观察。
72
- - **文本查询与精简输出**:`snapshot(query=...)` 复用同一次 AX 渲染,按当前视图中节点的完整名称(含合并正文)或 value 做不区分大小写的字面子串筛选,只输出匹配行和对应 frame 标题;不搜索 role/ref 或任意 DOM 属性,不新增索引缓存。新查询仍采集新观察并使旧 refs 失效,query 与分页绑定。默认省略 viewport、snapshot_id 和重复规则说明,`verbose=true` 返回前两项;frame 覆盖、视图、预算和游标语义保留。
72
+ - **文本查询与精简输出**:`snapshot(query=...)` 复用同一次 AX 渲染,按当前视图中节点的完整名称(含合并正文)、value 或字段 context 做不区分大小写的字面子串筛选,只输出匹配行和对应 frame 标题;不搜索 role/ref 或任意 DOM 属性,不新增索引缓存。新查询仍采集新观察并使旧 refs 失效,query 与分页绑定。默认省略 viewport、snapshot_id 和重复规则说明,`verbose=true` 返回前两项;frame 覆盖、视图、预算和游标语义保留。
73
+ - **字段上下文**:表单控件可附带 context 并参与 query,保留至多 160 字符的前一兄弟分支 AX 文本;只在四层以内 generic/none/group 祖先、单控件容器中查找,每次容器/文本遍历分别限制 64/32 节点。不将其伪装为 accessible name,不用于 label 定位或 ref 身份验证;interactive/text=false 也保留提示。
73
74
  - **ref 解析**:ref 保存 `backendNodeId`、所属 frame/session 和观察时的 AX role/name;动作时重新 `DOM.resolveNode` 并用局部 AX 查询核对语义身份。输入与求值不清空 refs,只使快照分页失效;原节点仍连接时可复用,节点替换、role/name 改变、文档或 frame 变化时安全失败,不按 role/name 猜测替代节点。
74
75
  - **双预算**:`max_nodes` 限制节点数,`max_chars` 限制每行序列化后的累计字符数;两者独立,任一耗尽都会返回 `next_cursor`。
75
76
  - **续读**:续读沿用同一份 `state`(`entries`、`refs`、`nextOffset`),所以 refs 在分页过程中保持有效。单个节点放不进预算时直接报错要求提高预算,避免静默截断尾部。
@@ -88,6 +89,7 @@ HTTP 状态复用 diagnostics 的 Network.responseReceived 观察,按主文档
88
89
  ```
89
90
 
90
91
  - **type 与 fill**:fill 替换文字,type 保留选区,逐字符复用原生按键与 keydown 回执;字符之间可延迟,不把 insertText 伪装成键盘序列。浏览器处理 keypress/input 及 preventDefault,非 ASCII 不模拟系统 IME。
92
+ - **焦点输入与语义定位**:type 的 focused=true 与 ref/selector 互斥,复用 press_key 的 frame/开放 Shadow DOM 焦点解析,固定最初可编辑元素并逐字检查,不重新聚焦。selector_type 的 label/placeholder 在隔离世界按真实 HTML 关联/属性查询,role/name 每次读取所选 frame 的 AX 树并严格匹配,以 AX 树顺序支持 nth;统一返回原有节点结构,继续复用输入、命中核验及 batch 流程,无组件库适配器。
91
93
  - **只重试准备**:等待阶段可以反复轮询,但一旦派发就不再重试。超时分为"未派发"和"已派发、结果未知"两种错误文本。
92
94
  - **原生指针就绪检查**:准备前和派发前各做一次至多 500 ms 的有界检查;要求活动、未暂停的 tab 与未最小化的窗口。不满足时以 TAB_NOT_READY 明确报告当前指针输入未派发,并提示 tab_control(action=activate) → snapshot → click。click 不主动激活;仅显式 tab_control(action=activate) 在 focus_window=true 时请求窗口聚焦和 Page.bringToFront。
93
95
  - **焦点信号边界**:OS 窗口失焦、遮挡或 document.visibilityState=hidden 不一定阻止 CDP 输入;document.hasFocus() 在 iframe 导航后也可能为 false。这些值作为 page_state 诊断,不独立拒绝点击。实际送达仍由隔离世界的可信事件回执校验;没有回执时不自动重放。
@@ -95,16 +97,18 @@ HTTP 状态复用 diagnostics 的 Network.responseReceived 观察,按主文档
95
97
  - **hover 与 drag**:hover 复用点击准备与隔离校验,仅派发一次 mouseMoved,清理不移走指针。drag 显式区分 pointer 与 HTML5;后者从真实 dragstart 截获 DragData,再用 CDP 派发 dragover/drop,校验可信 dragstart/drop/dragend。目的节点在按住后滚入视口;所有步骤共用标签队列和总预算,失败时只取消/释放本次手势。
96
98
  - **可选输入参数**:显式 offset 在元素 content quad 上插值后沿用命中与 OOPIF 边界检查;settle_ms 在 click/drag 首次派发前等待并复查就绪。drag trajectory 只替换既有插值段,采用法向控制点、平滑进度及随机间隔权重;type 的 delay_ms 可逐间隔独立区间采样。所有等待共用总预算,超时后没有继续派发的链;省略参数时不使用随机数,沿用既有确定性路径。不新增 tab 级历史、冷却、自动激活或重放路径。
97
99
  - **条件等待**:wait 复用严格 selector、可操作性和命中检查,支持出现、可见、可操作、文本/URL 子串及网络空闲;文本使用 innerText,expected 子串匹配和 previous 完整旧值比较均在 500 字符结果预览截取前完成。只读轮询,错误附带条件和最后状态。
98
- - **原生下拉**:select_option 直接用 selectedIndex setter 和合成 input/change 设置一个 HTML 选项,核验保留结果并返回 events_trusted=false。native click 的 select 命中(含 UA shadow 内部节点和关联 label)在派发前拒绝。
100
+ - **原生下拉**:select_option 不要求可见或稳定几何,先只读核对节点、禁用/inert 祖先与唯一选项,再在一次派发中复查并用 selectedIndex setter 和合成 input/change 设置,核验保留结果并返回 events_trusted=false。单步和 batch 共用实现。native click 的 select 命中(含 UA shadow 内部节点和关联 label)在派发前拒绝。
99
101
  - **命中提示**:普通命中保留实际 hit node,沿父元素、Shadow host、slot 查找最近的 HTML/ARIA 可点击候选,返回 clickable_target;hint=false 表示未识别候选,不代表无法响应事件。CSSPseudoElement 先解析为事件所属的 originating element,提示标记 pseudo_element=true,并使用该元素安装事件校验。语义候选不会被用于替换点击目标。
100
102
  - **遮挡失败诊断**:当前 frame 的遮挡直接复用已读取的 hit_target;祖先 frame 遮挡额外用至多 200 ms 读取命中描述,诊断节点消失时保留原遮挡错误与 backend node ID。错误包含命中点和受阻 frame。根据共享预算的派发状态区分“未派发”和“结果未知”,避免 drag 按住后准备目的节点时误报零输入。
101
- - **送达不等于业务完成**:`target_verified` / `delivery_verified` / `value_verified` 分别证明可信事件命中、keydown 送达、字段文本写入;点击、按键、fill 和 batch 返回 `application_result: "not_checked"`。fill 不证明 React 状态、选项提交、blur 校验或保存完成。
102
- - **有限多步输入**:batch 在现有标签队列内执行 1–20 步 click/fill/type/press_key/hover/scroll/select_option/wait,共享至多 30 秒预算,每步可另设更短超时;不增加后台任务、循环或脚本引擎;batch 本身只接受每步重新解析的 selector。所有参数先验证,selector 每步重解析,尚未出现的目标只在准备阶段等待;歧义立即报错。首个失败、主文档变化或接管结束后停止,返回此前结果与失败索引;不回滚、不重放、不自动激活或切换输入模式。start_at 显式跳过已完成前缀,返回 failed_step、next_unattempted_step、retry_failed_step_safe;没有后台作业或自动选择续跑点。
103
+ - **送达不等于业务完成**:原生 click/hover 的 delivery_verified 表示可信事件送达;target_verified=true 只用于显式元素目标,纯坐标为 null,target_verification 标明 element/coordinate_hit 和准备时的 same_node/composed_descendant。hit_relation 按 composed 祖先遍历处理 slot/ShadowRoot,派发后仍由隔离世界事件路径核验。value_verified 表示字段文本写入;点击、按键、fill 和 batch 返回 `application_result: "not_checked"`。fill 不证明 React 状态、选项提交、blur 校验或保存完成。
104
+ - **有限多步输入**:batch 在现有标签队列内执行 1–20 步 click/fill/type/press_key/hover/scroll/select_option/wait,共享至多 30 秒预算,每步可另设更短超时;不增加后台任务、循环或脚本引擎;元素目标使用每步重新解析的 selector,type 也接受 focused=true。所有参数先验证,selector 每步重解析,尚未出现的目标只在准备阶段等待;歧义立即报错。首个失败、主文档变化或接管结束后停止,返回此前结果与失败索引;不回滚、不重放、不自动激活或切换输入模式。start_at 显式跳过已完成前缀,返回 failed_step、next_unattempted_step、retry_failed_step_safe;没有后台作业或自动选择续跑点。
103
105
  - **OOPIF 坐标**:点击从顶层派发。逐层用 `DOM.getBoxModel` 把坐标换算到祖先文档,并用 `DOM.getNodeForLocation` 确认该位置确实落在 frame owner 上,防止祖先页面的遮挡被跳过。
106
+ - **命中对象**:伪元素先解析 originating Element;ShadowRoot 通过其 elementFromPoint 重新取得实际命中 Element,同进程子 frame 先把 session 坐标映射到所属文档视口。不将 ShadowRoot 直接当 Element 或无条件改成 host。后续身份、遮挡和事件回执检查保持一致;命中描述附带 frame-local rect 和样式诊断。
104
107
  - **DOM 模式**:`mode: "dom"` 是显式的、受控的降级路径,用于原生菜单或标签拖动暂时捕获输入时操作普通 HTML 控件。它调用 `HTMLElement.click()` 或原生 value setter,事件非可信、无用户激活,且绝不会在原生失败后自动启用。
105
108
  - **对话框策略**:ask 仍为默认;单操作/单步骤的显式 accept/dismiss 在事件回调中解除 JS 模态阻塞,保留消息与处理结果,最多自动处理 10 个。beforeunload dismiss 快速取消本次导航等待,实际主文档变化仍会停止 batch。
106
- - **未知结果探测**:在可能写入的操作准备期安装目标 frame 的临时 MutationObserver,仅在不确定失败后做一次有界只读探测,回传对话框、导航、DOM 变动;读取最多 300 ms、清理最多 100 ms。assessment 为 observable_effects 或 no_observable_effects;观察不到变化不等于没有派发,不将证据升级成业务成功,不自动重放。
109
+ - **未知结果探测**:在可能写入的操作准备期安装目标 frame 的临时 MutationObserver;观察用的 frame 解析只尝试一次,不等待不存在的 frame 消耗动作准备预算。仅在不确定失败后做一次有界只读探测,回传对话框、导航、DOM 变动;读取最多 300 ms、清理最多 100 ms。assessment 为 observable_effects 或 no_observable_effects;观察不到变化不等于没有派发,不将证据升级成业务成功,不自动重放。扩展、Hub、客户端用 errors.js 保留 error 文本及 input_dispatched、post_action、diagnostics;MCP 工具执行失败返回 isError 与 JSON,batch 失败采用相同证据字段。
107
110
  - **元素截图/求值 ref**:复用既有截图 clip/元数据与 ref 注册/失效模型,分别增加元素 border box 截图和显式单 DOM Element 结果;不暴露 Runtime objectId。JSON 求值自动调用函数一次并等待 Promise,缓存续读不执行脚本。
111
+ - **求值预算提示**:JSON 仍按顶层完整项分页;已有输出而下一项超出预算时,在同一响应提供 next_item 的 key/index、required_chars 和是否超过最大页预算。提示只读取缓存,不展开或改写嵌套值,也不提供任意脚本的执行进度。
108
112
  - **历史导航**:在已有 navigate 中选择 URL 或 back/forward,按 history entry 与主 frame 生命周期等待;同文档/BFCache 恢复不等新的加载事件,不用 evaluate 重放导航。
109
113
  - **导航后观察**:导航成功后的快照另有 10 秒预算,文档变化或执行上下文销毁等瞬时错误最多只重读一次;权限错误和预算耗尽不重试。观察失败保留导航结果并附 observation_error,不重新派发导航。
110
114
  - **不做通用 CDP 透传**:`browser_evaluate` 是页面表达式 API,不是 CDP 通道;没有暴露任意 domain/method 的工具。
@@ -131,7 +135,7 @@ HTTP 状态复用 diagnostics 的 Network.responseReceived 观察,按主文档
131
135
 
132
136
  | 能力 | 决策 | 原因 |
133
137
  |---|---|---|
134
- | 定位方式 | AX ref + CSS/XPath selector,可显式 nth | ref 保持观察身份,selector 每次重解析;省略 nth 仍严格要求唯一;索引是调用者明确选择,不猜测同名目标 |
138
+ | 定位方式 | AX ref + CSS/XPath/label/placeholder/role-name selector,可显式 nth | ref 保持观察身份,selector 每次重解析;省略 nth 仍严格要求唯一;context 仅作观察辅助,不猜测标签关系 |
135
139
  | 动作重试 | 只重试派发前的准备 | 网页操作可能提交真实数据,重复派发不可接受 |
136
140
  | DOM 模式 | 需要显式 `mode: "dom"` | 非可信事件、无用户激活,不能当作原生输入的透明替代 |
137
141
  | hover / drag | 原生指针工具,复用 click 准备 | 纯悬停和可信拖拽无法由 click 或页面 evaluate 等价表达 |
@@ -1,14 +1,34 @@
1
1
  # 验证报告
2
2
 
3
- 当前版本 **0.17.2**(2026-10-23)。浏览器验证使用本地 fixture 和临时 profile;测试链路在各项下说明。
3
+ 当前版本 **0.19.0**(2026-10-23)。浏览器验证使用本地 fixture 和临时 profile;测试链路在各项下说明。
4
4
 
5
5
  ## 当前版本
6
6
 
7
- - `npm test`:204 项通过。新增渲染文本的 expected/previous 比较与预览截取、1.5 万层深树的 full/interactive 遍历和正文合并、15 万节点观察的分页回归。用修改前的实现分别复现深树递归和宽树数组参数展开的 `Maximum call stack size exceeded`。
7
+ - `npm test`:219 项通过。覆盖 ShadowRoot 命中归一化、同进程子 frame 的局部命中坐标、slot/ShadowRoot 祖先关系、归一化后仍拒绝遮挡、frame 候选诊断及超时保留、select 的只读准备和继承 inert、文本等待缺 selector 提示,以及扩展 → Hub → 客户端 → stdio MCP 的结构化错误证据传递。保留原有输入、导航、所有权、清理和升级回归。
8
+ - Chromium 156、Edge 155 headless:`--issues` 各 10 个场景、`--primitives` 各 21 个场景、`--extended` 各 13 个场景通过。新增场景验证闭合 Shadow DOM 的真实 trusted 点击与 userActivation、实际 ShadowRoot 上的命中归一化、遮挡节点 rect/样式诊断、纯坐标验证范围、隐藏 select 的单步/batch 一致性和禁用/inert 拒绝、frame 定位候选、同进程与 OOPIF 的嵌套滚动/缩放/高度变化、元素截图、上传后 reload 及显式补填、求值超时后已观察到导航的 post_action。
9
+ - Chromium、Edge 完整 stdio MCP 集成:各 254 次调用通过,覆盖实际扩展与 Hub 链路、嵌套 OOPIF、输入、上传、导航、日志、截图和 12000 个语义节点分页。测试退出前经 CDP 正常关闭所创建的临时浏览器,避免 Windows Edge 重启实际主进程后仅结束 launcher 留下 profile 锁;Edge 完整重跑后正常退出。
10
+ - `npm run test:package`:仓库外安装与 stdio MCP 通过,23 个工具、51 个发布文件,不含配对密钥;无新增依赖和扩展权限。新增结构化结果及隐藏 select 能力,版本递增到 0.19.0。
11
+ - 本机部署:Hub 已从工作区重启,健康接口返回 0.19.0 / ready;原 Chrome、Edge 两个 profile 均已自动重载并上报 0.19.0。稳定扩展目录 `F:\.nosis\browser-attach\extension` 的 manifest 为 0.19.0,其余 32 个非生成源码/资源文件 SHA-256 与工作区一致。已有 MCP 客户端需重新连接以载入新版工具描述。
12
+ - 复现结论:带嵌套滚动、缩放和 iframe 高度变化的同进程/OOPIF 点击及后续坐标点击均送达目标,未复现所述约 88px 偏差;不能据此排除原站其他布局组合。上传触发 reload 后清空已填字段已复现,显式等待、观察后再 batch 补填成功,上传事件仅一次。顶层视口外元素截图保留真实内容且不改变滚动;overflow 祖先裁剪下的目标截图为空白,已人工查看 PNG,文档明确保留此裁剪语义。
13
+ - 当前范围:未在原 iCIMS 页面或真实验证码上复测;不新增 diagnose、跨导航字段重放引擎或截图默认自动滚动。诊断只报告当前可观测事实,不解释任意应用业务禁用原因,不把字段保留、可信事件或用户激活视为业务成功。
14
+
15
+ ## 0.18.0 验证
16
+
17
+ - `npm test`:213 项通过。新增真实 HTML label/placeholder 匹配、AX role/name 唯一性与树顺序、每次重新解析、focused 参数互斥、失焦停止、batch 预校验、快照 context 查询与 ref 身份、嵌套求值预算首屏提示回归。最后的定位错误文案调整另通过 12 项定位/原语定向测试。
18
+ - Chromium 156、Edge 155 headless:输入集成各 21 个场景、扩展集成各 13 个场景通过。覆盖关联标签填写、placeholder 和 AX role 字段读取、坐标聚焦后输入、延迟打开的自定义 option、闭合 Shadow DOM role 点击、开放 Shadow DOM 焦点输入、同进程及跨进程 iframe 的 role/focused 输入、字段切换焦点后停止,以及缓存续读不重放表达式。测试通过扩展和 Hub 操作页面;CDP 仅用于临时 fixture 设置/断言及关闭测试浏览器。
19
+ - `npm run test:package`:包外安装与 stdio MCP 通过,23 个工具、50 个发布文件,不含配对密钥;工具能力扩展,未新增工具、依赖或扩展权限。
20
+ - 本机部署:Hub 已从工作区重启,健康接口返回 0.18.0 / ready;Chrome、Edge 均已自动重载并上报 0.18.0。稳定扩展目录 `F:\.nosis\browser-attach\extension` 的 manifest 为 0.18.0,其余 31 个非生成源码/资源文件 SHA-256 与工作区一致。原有 MCP 客户端需要重新连接以刷新 schema。
21
+ - 当前边界:label 仅使用真实 HTML 关联,不推断兄弟 div;context 是有界观察提示,不改写 AX name。role 每次采集所选 frame 的完整 AX 树。evaluate 仍按完整嵌套项分页,10 秒默认、最多 30 秒,未加入任意脚本检查点或截图布局 token。
22
+ - 额外测试观察:接管后动态插入再移除跨源 iframe 的组合场景中,后续 network_idle 曾以 inflight=1 超时,原因未在本次定位改动中解决;跨进程输入另在接管前已加载 iframe 的 fixture 中验证通过。本次不包含网络跟踪修复。
23
+
24
+ ## 0.17.2 验证
25
+
26
+ - `npm test`:207 项通过。新增滚动最深匹配、隐藏中间节点、多个分支歧义和候选上限回归;10000 个匹配只做 9999 次包含关系检查,每个文字命中候选只读一次几何信息。保留渲染文本的 expected/previous 比较与预览截取、1.5 万层深树的 full/interactive 遍历和正文合并、15 万节点观察的分页回归。用修改前的实现分别复现深树递归和宽树数组参数展开的 `Maximum call stack size exceeded`。
27
+ - 同版本滚动优化:Chromium 156 与 Edge 155 headless 输入集成各 20 个场景通过,包含真实 DOM 的隐藏中间节点、最深匹配 ref、兄弟分支歧义、selector 消歧及原有延迟加载滚动。保留 10000 候选上限、文字与可见性条件;筛选只比较文档顺序中的相邻匹配,不再两两比较,不代表选择器、子树文字和布局读取的总成本也是线性的。
8
28
  - Chromium 156 与 Edge 155 headless 完整 stdio MCP 集成:各 254 次调用通过。真实 DOM 验证 script/style/隐藏子节点字面量不会提前满足 text wait,expected 与 previous 均等待渲染文字变化;1.2 万个显式语义节点在 max_nodes=1 下完成采集和续页,分别约 15.3 秒、16.6 秒。
9
29
  - 剩余限制:Chromium 上 12 万个显式 group 节点的真实快照调用仍触及 MCP 60 秒请求超时。迭代渲染和逐项合并只消除栈及函数参数数量上限,不减少完整 AX 采集成本;max_nodes 仅控制分页,不代表超大页面能在现有预算内完成。
10
30
  - `npm run test:package`:仓库外安装运行通过,23 个工具、50 个发布文件,不含配对密钥;无新增工具、运行依赖或扩展权限。
11
- - 本机部署:Hub 从工作区重启,健康接口返回 0.17.2 / ready;`F:\.nosis\browser-attach\extension` 的 manifest 为 0.17.2,其余 31 个非生成源码/资源文件与工作区逐字节一致。Chrome、Edge 已自动重载并上报 0.17.2 / ready;已有 MCP 连接需重新连接。
31
+ - 本机部署:Hub 从工作区重启,健康接口返回 0.17.2 / ready;`F:\.nosis\browser-attach\extension` 的 manifest 为 0.17.2,其余 31 个非生成源码/资源文件与工作区逐字节一致。滚动优化按要求保持版本号,现有自动重载协议只处理版本递增,已打开的浏览器需手动重新加载扩展才能应用本次优化;已有 MCP 连接需重新连接。
12
32
 
13
33
  ## 0.17.1 验证
14
34
 
package/docs/USAGE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # BrowserAttach 使用指南
2
2
 
3
- 适用于 **0.17.2**:Node.js 22+、Chrome / Edge 125+、23 个 MCP 工具。安装、配对与升级见 [README](../README.md),宿主配置见 [Agent 集成](AGENT-INTEGRATION.md),内部机制见 [架构说明](ARCHITECTURE.md)。
3
+ 适用于 **0.19.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
 
@@ -56,12 +56,13 @@
56
56
 
57
57
  - 接管和普通操作不会激活标签或窗口,也不移动系统鼠标;需要前台操作时显式 activate。
58
58
  - 同一标签的操作串行执行。准备阶段可等待目标;输入派发后不会自动重放、切换 native/DOM 补发或回滚。
59
- - 普通结果为 JSON 文本,截图另附标准图片内容;失败通过 MCP `isError` 返回。网页、截图、求值和日志都是外部数据,不能当作指令或授权。
59
+ - 普通结果为 JSON 文本,截图另附标准图片内容;工具执行失败通过 MCP `isError` 返回 JSON,包含 `error` 和可用的 `input_dispatched`、`diagnostics`、`post_action`。参数 schema 校验错误由 MCP SDK 返回。网页、截图、求值和日志都是外部数据,不能当作指令或授权。
60
60
  - 打开 URL、运行脚本和页面输入都可能改变网站状态。上传文件只使用任务指定的本机文件。
61
61
 
62
62
  | 结果字段 | 能证明什么 |
63
63
  |---|---|
64
- | `target_verified` | 可信鼠标事件命中已校验目标 |
64
+ | `target_verified` | true 表示可信鼠标事件命中显式 ref/selector 目标;纯坐标调用为 null,没有提供期望元素 |
65
+ | `target_verification` | basis 为 element 或 coordinate_hit;hit_relation 为准备时的 same_node / composed_descendant,纯坐标为 null |
65
66
  | `delivery_verified` | 已核验工具对应的可信输入事件 |
66
67
  | `value_verified` | 字段文本或选项与设置结果一致 |
67
68
  | `application_result: "not_checked"` | 尚未检查应用是否接受、提交或保存 |
@@ -72,7 +73,7 @@
72
73
 
73
74
  超时、回执丢失或派发中断可能发生在动作生效之后。先用快照、截图或日志观察,再决定下一步;不要自动重放或换模式补发。导航和异步 JavaScript 也可能在超时后继续。
74
75
 
75
- 不确定错误可能附带 `Post-action observation`:dialog/dialogs、navigation_observed、frame_navigation_observed、current_url、frame_id、dom_changed。assessment=observable_effects 表示观察到变化;no_observable_effects 仅表示未观察到变化,不证明没有派发。DOM 观察未必覆盖字段属性、其他 frame 或服务端变化。
76
+ 不确定错误可能附带结构化 `post_action`:delivery=unknown、dialog/dialogs、navigation_observed、frame_navigation_observed、current_url、frame_id、dom_changed。assessment=observable_effects 表示观察到变化;no_observable_effects 仅表示未观察到变化,不证明没有派发。DOM 观察未必覆盖字段属性、其他 frame 或服务端变化。`input_dispatched=true` 表示已进入派发阶段,不证明事件送达或脚本完成;false 表示尚未派发,字段缺失则没有确定证据。
76
77
 
77
78
  `TAB_NOT_READY` 明确表示本次指针输入尚未派发。显式激活或恢复窗口后,重新 snapshot 再执行动作。检查通过后仍可能遇到用户切换标签或浏览器菜单捕获输入,最终以事件回执和错误中的派发状态为准。
78
79
 
@@ -124,7 +125,7 @@ snapshot 返回 AX 语义树,每行 `[token:n] role "name" 状态`;`[token:n
124
125
  | `profile` | `full`;interactive 只保留控件、可聚焦/编辑节点及祖先链,自定义控件可能被省略 |
125
126
  | `text` | true;false 隐去独立正文,保留控件名称和值 |
126
127
  | `text_limit` | 正文名称预览上限 0–10000;interactive 默认 80,full 默认不截断;控件名称和值保留完整 |
127
- | `query` | 1–2000 字符的名称/value 字面子串,不区分大小写;零命中正常返回 |
128
+ | `query` | 1–2000 字符的名称/value/context 字面子串,不区分大小写;零命中正常返回 |
128
129
  | `max_nodes` / `max_chars` | 600 / 24000;范围分别为 1–3000 / 1–2000000,两个预算独立 |
129
130
  | `verbose` | false;true 额外返回 viewport 和 snapshot_id |
130
131
 
@@ -147,6 +148,8 @@ snapshot 返回 AX 语义树,每行 `[token:n] role "name" 状态`;`[token:n
147
148
 
148
149
  ref 对应原节点及其可访问身份;节点被替换、脱离文档或 role/name 变化时应重新观察,不会自动匹配同名替代节点。
149
150
 
151
+ 表单控件可附带 `context="性别"` 等邻近文本,在 interactive 或 text=false 下也保留,并参与 query。context 来自有限祖先范围内、仅含一个控件的容器中前一兄弟节点的 AX 文本;只是观察提示,不代表真实标签关联,不改写 AX name,也不参与 ref 身份校验。复杂布局可能没有 context。
152
+
150
153
  ### browser_read_text
151
154
 
152
155
  `browser_read_text({tab_handle})` 默认读取主 frame 的可见正文。可用 selector 限定范围,或用 frame/frame_id 选择子 frame。
@@ -162,11 +165,25 @@ ref 对应原节点及其可访问身份;节点被替换、脱离文档或 rol
162
165
 
163
166
  next_offset 是下一次实时读取位置,内容变化可造成重复或遗漏;需要一致结果时用 evaluate 一次捕获再分页。DOM 正文不包含未渲染的虚拟行或所有 Shadow DOM,complete_source=false 表示覆盖边界;字段值及支持的编辑器模型为 true。
164
167
 
165
- ### 可复用的 frame 寻址
168
+ ### 可复用的元素与 frame 寻址
169
+
170
+ selector 每次重新解析,省略 nth 时必须唯一;歧义立即报错并附最多 10 个候选。输入、点击、拖拽、等待、读取和 batch 共用以下定位方式:
171
+
172
+ | selector_type | selector 的含义 |
173
+ |---|---|
174
+ | `css`(默认)/ `xpath` | CSS / XPath 查询 |
175
+ | `label` | 关联 HTML label 的文本;折叠空白后精确匹配,支持 for 和包裹式 label |
176
+ | `placeholder` | placeholder 属性的完整精确值 |
177
+ | `role` | 浏览器 AX role,可另传 `name` 精确匹配 accessible name;包括浏览器提供的隐式角色 |
178
+
179
+ `name` 仅适用于 role。`nth` 从 0 开始,按 DOM 顺序选择,role 则按 AX 树顺序;准备阶段可等待目标出现。重复匹配应缩小范围或显式 nth,不按可见性猜选。label 不推断兄弟 div 的标签关系,ARIA 命名可通过 role/name 定位。
166
180
 
167
- selector 每次重新解析,默认 CSS,可用 `selector_type:"xpath"`。省略 nth 时必须唯一;歧义立即报错并附最多 10 个候选。`nth` 从 0 开始,按当前文档顺序选择;未出现的目标可在准备预算内等待。行顺序改变后应重新确认目标。
181
+ ```js
182
+ browser_fill({tab_handle, selector_type:"label", selector:"工作职责", text:"填写内容"})
183
+ browser_click({tab_handle, selector_type:"role", selector:"option", name:"2026"})
184
+ ```
168
185
 
169
- CSS/XPath 不穿透 Shadow DOM,优先用 AX ref。selector、read_text、evaluate 可用 frame 或 frame_id 指定范围,两者互斥:
186
+ CSS/XPath/label/placeholder 不穿透 Shadow DOM;可用 AX role 或 ref。role 每次读取所选 frame 的完整 AX 树,不保证比简单 CSS 查询便宜。selector、read_text、evaluate 可用 frame 或 frame_id 指定范围,两者互斥:
170
187
 
171
188
  ```json
172
189
  {"tab_handle":"...","selector":"input","frame":{"path":["iframe[title='Outer']","iframe[title='Inner']"]},"text":"填写内容"}
@@ -174,9 +191,11 @@ CSS/XPath 不穿透 Shadow DOM,优先用 AX ref。selector、read_text、evalu
174
191
 
175
192
  frame.url/title 精确匹配子 frame,path 是逐层且每层唯一的 CSS iframe 路径,可再用 url/title 校验最终 frame。路径每次重解析,歧义报错;ref 已携带 frame/session,不能另传 frame。
176
193
 
194
+ 例如 iframe 的 id 为 `icims_content_iframe` 时,path 应写为 `["#icims_content_iframe"]`。定位失败的 diagnostics 包含失败步骤和至多 10 个当前 frame 的 id、父 id、URL 及省略数量;快照也会列出 frame id/URL。frame_id 每次按当前 frame tree 校验,不是 ref;iframe 重建或导航后的 ID 应重新观察,不猜测替代目标。
195
+
177
196
  ## 输入:可操作性与两种模式
178
197
 
179
- 输入准备会检查可见、启用、非 inert 和几何稳定;fill 还要求可编辑且非只读。timeout_ms 默认 5000,范围 100–30000,覆盖准备、派发和校验;click/hover/press_key 的派发及回执另有 1250 ms 上限,清理最多 250 ms,长 timeout 主要用于等待目标。
198
+ click/hover/fill/type 的输入准备会检查可见、启用、非 inert 和几何稳定;fill 还要求可编辑且非只读。select_option 不要求可见几何。timeout_ms 默认 5000,范围 100–30000,覆盖准备、派发和校验;click/hover/press_key 的派发及回执另有 1250 ms 上限,清理最多 250 ms,长 timeout 主要用于等待目标。
180
199
 
181
200
  原生 click/hover/drag 要求活动、未冻结/丢弃的标签与未最小化窗口,准备前及派发前各检查一次,单次最多 500 ms。窗口失焦、document.visibilityState=hidden 或 document.hasFocus()=false 不独立触发拒绝;Click/Hover 成功结果的 page_state 提供这些诊断信号,实际送达仍需回执。就绪失败的处理见[结果未知时](#结果未知时)。
182
201
 
@@ -194,9 +213,9 @@ native 与 dom 由调用者显式选择。DOM click/fill 使用非可信事件
194
213
  | `offset` | 原生元素内的 {x_ratio,y_ratio},各为 0–1;默认可见区域中心 |
195
214
  | `settle_ms` | 派发前等待:0–1000 整数或有序 [min,max] 区间,默认 0 |
196
215
 
197
- 原生模式核验对应的 click/dblclick/auxclick/contextmenu,成功返回 target_verified=true。目标被遮挡或派发中变化时失败;错误附带 hit_target、命中点及受阻 frame,前序输入可能已经送达。
216
+ 原生模式核验对应的 click/dblclick/auxclick/contextmenu,成功返回 delivery_verified=true、events_trusted=true。显式元素目标返回 target_verified=true;坐标调用返回 target_verified=null,仅证明事件送达该坐标命中的节点。目标被遮挡或派发中变化时失败;遮挡错误的 diagnostics 附带 hit_target、命中点及受阻 frame,前序输入可能已经送达。
198
217
 
199
- hit_target 描述实际命中的 tag/text/role/disabled,并给出 clickable_hint 和最近的 clickable_target 候选。候选仅是启发式提示,不改变实际目标;false 不表示无法响应点击。伪元素命中标记 pseudo_element=true。返回 clicked 始终为顶层视口 CSS 坐标,跨进程 iframe 仍检查祖先遮挡;透视变换 frame 不支持。
218
+ hit_target 描述实际命中的 tag/id/text/role/disabled,以及所属文档视口 CSS 坐标的 rect、position、z_index、visibility;z_index 不代表全局遮挡顺序。clickable_hint 和最近的 clickable_target 仅是启发式提示,不改变实际目标;false 不表示无法响应点击。伪元素命中标记 pseudo_element=true。返回 clicked 始终为顶层视口 CSS 坐标,跨进程 iframe 仍检查祖先遮挡;透视变换 frame 不支持。
200
219
 
201
220
  DOM 模式调用一次 HTMLElement.click(),返回 method=dom、events_trusted=false。HTML select 的原生点击会在派发前拒绝,改用 select_option;系统弹层已打开时先按 Esc。
202
221
 
@@ -227,7 +246,7 @@ pointer 用于鼠标驱动控件,核验 pointerdown/move/up;html5 用真实
227
246
 
228
247
  ### browser_select_option
229
248
 
230
- 目标必须为 HTML select;label/value 精确匹配,index 从 0 开始,三者选一。重复 label/value 时用 index 消歧;禁用 select/option/optgroup 被拒绝。multiple 控件也只保留所选的一个选项。
249
+ 目标必须为 HTML select,允许隐藏或零尺寸;label/value 精确匹配,index 从 0 开始,三者选一。重复 label/value 时用 index 消歧;禁用 select/option/optgroup、继承 inert 的目标被拒绝,含 Shadow DOM host 和 slot 祖先。multiple 控件也只保留所选的一个选项。单步与 batch 使用同一实现。
231
250
 
232
251
  ```json
233
252
  {"tab_handle":"...","selector":"select[name=city]","label":"上海"}
@@ -245,7 +264,7 @@ pointer 用于鼠标驱动控件,核验 pointerdown/move/up;html5 用真实
245
264
  | `url` | 主 frame URL 含 expected |
246
265
  | `network_idle` | 已观察的请求 inflight=0,且连续 idle_ms 无活动 |
247
266
 
248
- 元素条件接受严格 selector 和 frame/nth;工具只读等待,超时附带条件及最后状态。
267
+ 元素条件必须提供严格 selector,可带 frame/nth;全页渲染文字使用 `selector:"body"`。url/network_idle 不使用元素目标。工具只读等待,超时附带条件及最后状态。
249
268
 
250
269
  ```json
251
270
  {"tab_handle":"...","selector":"#toast","condition":"text","expected":"保存成功","timeout_ms":5000}
@@ -263,15 +282,21 @@ network_idle 的 idle_ms 默认 500,范围 100–10000。它只覆盖接管后
263
282
 
264
283
  保留文本与选区,聚焦后逐个 Unicode 字符发送可信键盘输入。页面 preventDefault 可阻止输入;非 ASCII 不模拟系统 IME。换行、Tab 等控制键使用 press_key。
265
284
 
285
+ ref、selector、`focused:true` 三选一。focused 固定操作开始时已聚焦的可编辑元素,沿可用 iframe 和开放 Shadow DOM 查找,不主动聚焦或清空内容;原元素失焦或脱离文档后停止,不向新焦点继续输入。focused 不接受 frame/nth/selector_type/name,目标范围由当前焦点确定。
286
+
266
287
  ```json
267
288
  {"tab_handle":"...","selector":"input[role=combobox]","text":"上海","delay_ms":[40,180]}
268
289
  ```
269
290
 
291
+ ```json
292
+ {"tab_handle":"...","focused":true,"text":"上海"}
293
+ ```
294
+
270
295
  delay_ms 为 0–1000 整数(默认 0)或有序区间,每两个字符间独立抽样,末字符后不等待;实际耗时还含协议开销。text 最多 10000 个 UTF-16 单位,总预算最多 30 秒。失焦或节点替换时停止,失败报告已核验字符数,当前字符仍可能送达;delivery_verified 只核验 keydown。
271
296
 
272
297
  ### browser_batch
273
298
 
274
- 一次执行 1–20 步,支持 click/fill/type/press_key/hover/scroll/select_option/wait。元素动作使用 selector(可带 selector_type/frame/nth),每步重解析,不接受 ref 或坐标;scroll/wait 可按各自接口不带元素目标。
299
+ 一次执行 1–20 步,支持 click/fill/type/press_key/hover/scroll/select_option/wait。元素动作使用 selector(可带 selector_type/name/frame/nth),每步重解析,不接受 ref 或坐标;type 也可使用 focused=true,scroll/wait 可按各自接口不带元素目标。
275
300
 
276
301
  ```json
277
302
  {"tab_handle":"...","steps":[
@@ -341,6 +366,8 @@ beforeunload 仅在浏览器实际弹出时可处理。accept 继续导航,dis
341
366
 
342
367
  browser_screenshot 默认截图视口,full_page=true 截整页,或用 ref/selector 截取元素;元素目标与 full_page 互斥。元素截图是当前 border box 的外接矩形,保留页面遮挡,不是抠图。
343
368
 
369
+ 元素截图不自动滚动,可直接采集顶层视口外已渲染的区域;祖先 overflow 裁剪仍保留,因此有渲染 box 不代表截图里能看到该元素。需要滚入视口时先显式滚动,再重新截图并使用新映射。
370
+
344
371
  返回 PNG 与 capture:scope、viewport、css_width/css_height、image_origin_css、image_to_viewport_css。图片像素换算为视口 CSS 坐标:`css = 像素 × scale + offset`。整页图的视口外位置需先滚动再输入。
345
372
 
346
373
  超时 5 秒,超过 4000 万像素被拒绝;元素无渲染 box、负文档坐标或透视 frame 也会失败。后台渲染停滞时显式 activate,或改读 AX/正文。默认隐藏约 1.2 秒的点击反馈,include_indicators=true 可保留。
@@ -360,6 +387,8 @@ browser_screenshot 默认截图视口,full_page=true 截整页,或用 ref/se
360
387
  - files=[] 清空指定 input 或结束待处理选择,派发合成 input/change,events_trusted=false;非空文件设置的事件为可信,仍不证明服务端上传完成。
361
388
  - 跨进程 iframe 的 file input 已支持;File System Access API 的 showOpenFilePicker() 不在范围内。
362
389
 
390
+ 若网站解析文件后重载表单,先等待具体字段或就绪标志,重新观察,再用新的 selector/batch 补齐确认缺失的字段。upload_file 不等待应用解析完成,不自动重放上传或恢复字段;batch 在主文档变化后停止。
391
+
363
392
  ## JavaScript 求值
364
393
 
365
394
  expression 在页面世界执行一次,默认主 frame,可用 frame/frame_id 指定子 frame。返回函数时调用一次并等待 Promise;多条语句用 IIFE。脚本可访问页面状态和产生写操作,但不提供用户激活或调用 MCP 原生输入的能力。
@@ -377,6 +406,8 @@ expression 在页面世界执行一次,默认主 frame,可用 frame/frame_id
377
406
 
378
407
  默认 result_type=json。数组按完整项、对象按键值对、字符串按片段分页,返回 result_kind、has_more、next_cursor。单项放不下时返回 budget_too_small、required_chars 和 cursor,提高预算续读;分页不拆分嵌套值,单个嵌套值超过 100000 字符时应在表达式中提取更小字段。
379
408
 
409
+ 第一页已能返回部分字段、但下一完整项超出当前 max_chars 时,同时返回 `next_item:{key或index,required_chars,exceeds_max_chars}`。直接用该 required_chars 和 next_cursor 续读即可;exceeds_max_chars=true 表示超出允许的最大页预算,需重新设计表达式提取更小结果。重新求值会再次执行脚本,不能自动重放含写入的表达式。
410
+
380
411
  续读传 tab_handle、cursor 和可选 max_chars,省略 expression/frame/frame_id;只读缓存,不执行脚本。每标签最多保留一份结果,输入不改变缓存,新求值、相关文档/frame 变化、释放或断连使其失效。
381
412
 
382
413
  只读自有可枚举数据属性,不调用 getter。undefined、bigint、NaN/Infinity/-0、date、error、function、symbol、accessor、circular_or_repeated 等特殊值用 $type 标记;Map/Set/DOM 等实例需要稳定结果时应自行转换为普通对象或字符串。
@@ -3,8 +3,13 @@
3
3
  export function deadline(timeout_ms = 5000, phase = 'input', parentEnd = Infinity) {
4
4
  if (!Number.isInteger(timeout_ms) || timeout_ms < 100 || timeout_ms > 30000) throw new Error('timeout_ms must be 100..30000');
5
5
  const end = Math.min(Date.now() + timeout_ms, parentEnd);
6
- let dispatched = false, waiting;
6
+ let dispatched = false, waiting, diagnostics;
7
7
  function error() {
8
+ const result = timeoutError();
9
+ if (diagnostics) result.diagnostics = diagnostics;
10
+ return result;
11
+ }
12
+ function timeoutError() {
8
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 });
9
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.`);
10
15
  if (phase === 'wait') return Object.assign(new Error(`Condition was not satisfied within ${timeout_ms} ms`), { input_dispatched: false });
@@ -23,7 +28,7 @@ export function deadline(timeout_ms = 5000, phase = 'input', parentEnd = Infinit
23
28
  timer = setTimeout(() => reject(error()), remaining);
24
29
  })]); } finally { clearTimeout(timer); }
25
30
  }
26
- return { run, has_dispatched: () => dispatched, child(ms, childPhase = phase) { return deadline(ms, childPhase, end); }, describe(detail) { waiting = detail; }, dispatch() { if (Date.now() >= end) throw error(); dispatched = true; }, remaining: () => Math.max(0, end - Date.now()) };
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()) };
27
32
  }
28
33
 
29
34
  export async function waitForTarget(cdp, value, target, node, budget, { editable = false } = {}) {
@@ -35,7 +40,7 @@ export async function waitForTarget(cdp, value, target, node, budget, { editable
35
40
  if (bounds?.detached) throw new Error('Element is detached; take a new snapshot. No input was dispatched.');
36
41
  if (bounds?.unsupported) throw new Error('Ref is not a supported editable field; no input was dispatched.');
37
42
  if (bounds?.ready && previous && JSON.stringify(bounds) === JSON.stringify(previous)) return;
38
- budget.describe?.(`visible, enabled, stable target; current=${JSON.stringify(bounds)}`);
43
+ budget.describe?.(`visible, enabled, stable target; current=${JSON.stringify(bounds)}`, { reason: 'not_actionable', frame_id: node.frameId, target: bounds });
39
44
  previous = bounds?.ready ? bounds : null;
40
45
  await budget.run(() => new Promise(resolve => setTimeout(resolve, 20)));
41
46
  }
@@ -21,8 +21,8 @@ export function validateSteps(steps, startAt = 0) {
21
21
  frameOptions(step);
22
22
  scrollOptions(step);
23
23
  } else {
24
- if (!step.selector || step.ref != null) throw new Error('Batch element actions require selector');
25
- targetOptions(step);
24
+ if ((!step.selector && !(step.action === 'type' && step.focused === true)) || step.ref != null) throw new Error('Batch element actions require selector; type also accepts focused=true');
25
+ targetOptions(step, { focused: step.action === 'type' });
26
26
  if (['click', 'fill'].includes(step.action)) inputMode(step);
27
27
  if (step.action === 'click') clickOptions(step);
28
28
  if (step.action === 'hover') offsetOption(step);
@@ -41,7 +41,7 @@ export function createClick(cdp, value, viewport, position, sessions, getTab, ge
41
41
  try {
42
42
  const receipt = JSON.parse(params.payload);
43
43
  if (receipt.matched === true) operation.resolve();
44
- else operation.reject(new Error('Click target changed or became covered during mouse dispatch. Incorrect target input was blocked; observe again before retrying.'));
44
+ else operation.reject(new Error('Click target changed or became covered during mouse dispatch. Further incorrect target input was blocked; outcome is unknown. Observe again before retrying.'));
45
45
  } catch (error) { operation.reject(error); }
46
46
  }
47
47
  function cancel(tabId) { pending.get(tabId)?.reject(new Error('Tab detached during click; outcome is unknown. Observe before retrying.')); }
@@ -51,9 +51,9 @@ export function createClick(cdp, value, viewport, position, sessions, getTab, ge
51
51
  if (args.coordinate_space != null && !['viewport', 'document'].includes(args.coordinate_space)) throw new Error('coordinate_space must be viewport or document');
52
52
  if (node && args.coordinate_space != null) throw new Error('coordinate_space applies to coordinate clicks only');
53
53
  const budget = args.budget || deadline(args.timeout_ms);
54
- const obstruction = detail => Object.assign(new Error(`${detail}. ${budget.has_dispatched()
54
+ const obstruction = (detail, diagnostics) => Object.assign(new Error(`${detail}. ${budget.has_dispatched()
55
55
  ? 'Input has already been dispatched. Outcome is unknown; observe before continuing. Do not replay automatically.'
56
- : 'No input was dispatched.'}`), { input_dispatched: budget.has_dispatched() });
56
+ : 'No input was dispatched.'}`), { input_dispatched: budget.has_dispatched(), diagnostics });
57
57
  let target = node?.target || { tabId };
58
58
  const send = (method, params) => budget.run(() => cdp(target, method, params));
59
59
  let point, view;
@@ -106,13 +106,22 @@ export function createClick(cdp, value, viewport, position, sessions, getTab, ge
106
106
  const { frameId } = hitNode;
107
107
  let hit = await send('DOM.resolveNode', { backendNodeId, objectGroup: 'browser-attach' });
108
108
  const pseudo = hit.object.className === 'CSSPseudoElement';
109
- if (pseudo) {
110
- // Pseudo-elements are event targets through their originating element,
111
- // but their Runtime wrappers are not Elements and have no matches().
109
+ if (pseudo || hit.object.className === 'ShadowRoot') {
110
+ let localPoint = point;
111
+ if (!pseudo && frameId && (await send('Page.getFrameTree')).frameTree.frame.id !== frameId) {
112
+ // Same-process frame quads are session-relative, while ShadowRoot hit
113
+ // testing expects the owning document's viewport coordinates.
114
+ const owner = await send('DOM.getFrameOwner', { frameId });
115
+ const { model } = await send('DOM.getBoxModel', { backendNodeId: owner.backendNodeId });
116
+ const { executionContextId } = await send('Page.createIsolatedWorld', { frameId, worldName: 'browser-attach-locator' });
117
+ const frameView = value(await send('Runtime.evaluate', { contextId: executionContextId,
118
+ expression: '({width: innerWidth, height: innerHeight})', returnByValue: true }));
119
+ localPoint = framePoint(point, model.content, frameView, true);
120
+ }
112
121
  const normalized = await send('Runtime.callFunctionOn', { objectId: hit.object.objectId,
113
- functionDeclaration: 'function() { if (this.element?.nodeType !== 1) throw new Error("Pseudo-element has no originating element; no input was dispatched."); return this.element; }', objectGroup: 'browser-attach' });
122
+ functionDeclaration: normalizeHit.toString(), arguments: [{ value: localPoint.x }, { value: localPoint.y }], objectGroup: 'browser-attach' });
114
123
  value(normalized);
115
- if (!normalized.result?.objectId) throw new Error('Pseudo-element could not be resolved; no input was dispatched.');
124
+ if (!normalized.result?.objectId) throw new Error('Hit element could not be resolved; no input was dispatched.');
116
125
  hit = { object: normalized.result };
117
126
  const { node: origin } = await send('DOM.describeNode', { objectId: hit.object.objectId });
118
127
  backendNodeId = origin.backendNodeId;
@@ -120,12 +129,14 @@ export function createClick(cdp, value, viewport, position, sessions, getTab, ge
120
129
  const hitTarget = value(await send('Runtime.callFunctionOn', { objectId: hit.object.objectId,
121
130
  functionDeclaration: describeHit.toString(), returnByValue: true }));
122
131
  if (pseudo) hitTarget.pseudo_element = true;
132
+ let relation = null;
123
133
  if (node) {
124
- const unblocked = value(await send('Runtime.callFunctionOn', {
125
- objectId: node.objectId, functionDeclaration: 'function(hit) { for (let e = hit; e; e = e.parentNode || e.host) if (e === this) return true; return false; }',
134
+ relation = value(await send('Runtime.callFunctionOn', {
135
+ objectId: node.objectId, functionDeclaration: hitRelation.toString(),
126
136
  arguments: [{ objectId: hit.object.objectId }], returnByValue: true,
127
137
  }));
128
- if (!unblocked) throw obstruction(`Another element covers this ref. hit_target=${JSON.stringify(hitTarget)}; point=${JSON.stringify(point)}; frame_id=${JSON.stringify(node.frameId)}`);
138
+ if (!relation) throw obstruction(`Another element covers this ref. hit_target=${JSON.stringify(hitTarget)}; point=${JSON.stringify(point)}; frame_id=${JSON.stringify(node.frameId)}`,
139
+ { reason: 'obstructed', hit_target: hitTarget, point, coordinate_space: 'session_viewport_css_pixels', frame_id: node.frameId, hit_relation: null });
129
140
  }
130
141
  // Dispatch through the root widget so overlays in ancestor documents cannot
131
142
  // be bypassed by sending directly to an OOPIF widget. Guard every boundary.
@@ -149,19 +160,20 @@ export function createClick(cdp, value, viewport, position, sessions, getTab, ge
149
160
  blocker = value(await diagnostic.run(() => cdp(parent.target, 'Runtime.callFunctionOn', { objectId: resolved.object.objectId,
150
161
  functionDeclaration: describeHit.toString(), returnByValue: true })));
151
162
  } catch { /* Preserve the obstruction error even if its node disappears. */ }
152
- throw obstruction(`Another element covers this frame. hit_target=${JSON.stringify(blocker ?? { backend_node_id: parentHit.backendNodeId })}; point=${JSON.stringify(rootPoint)}; blocked_frame_id=${JSON.stringify(parent.frameId)}`);
163
+ throw obstruction(`Another element covers this frame. hit_target=${JSON.stringify(blocker ?? { backend_node_id: parentHit.backendNodeId })}; point=${JSON.stringify(rootPoint)}; blocked_frame_id=${JSON.stringify(parent.frameId)}`,
164
+ { reason: 'frame_obstructed', hit_target: blocker ?? null, point: rootPoint, coordinate_space: 'session_viewport_css_pixels', blocked_frame_id: parent.frameId });
153
165
  }
154
166
  const ownerFrameId = parent.ownerFrameId || (await budget.run(() => cdp(parent.target, 'Page.getFrameTree'))).frameTree.frame.id;
155
167
  boundaries.push({ target: parent.target, backendNodeId: owner.backendNodeId, frameId: ownerFrameId });
156
168
  current = parent.target; currentView = parentView;
157
169
  }
158
- return { target, point: rootPoint, hitTarget, frameId: node?.frameId || frameId, backendNodeId: node?.backendNodeId || backendNodeId, boundaries };
170
+ return { target, point: rootPoint, hitTarget, relation, frameId: node?.frameId || frameId, backendNodeId: node?.backendNodeId || backendNodeId, boundaries };
159
171
  }
160
172
  async function click(tabId, node, args) {
161
173
  const options = { ...clickOptions(args), hover: args.hover === true };
162
174
  const budget = args.budget || deadline(args.timeout_ms);
163
175
  const prepared = await prepare(tabId, node, { ...args, budget });
164
- const { target, point: rootPoint, hitTarget, frameId, backendNodeId, boundaries } = prepared;
176
+ const { target, point: rootPoint, hitTarget, relation, frameId, backendNodeId, boundaries } = prepared;
165
177
  if (!options.hover && hitTarget.native_select) throw new Error("Native select popup is unsupported. No input was dispatched. Use browser_select_option by label/value/index; press Esc if a native popup is already open.");
166
178
  const name = `__attachClick_${crypto.randomUUID().replaceAll('-', '')}`;
167
179
  const guards = [], contexts = new Set();
@@ -200,8 +212,10 @@ export function createClick(cdp, value, viewport, position, sessions, getTab, ge
200
212
  commands.push(dispatch({ type: 'mouseReleased', button: options.button, buttons: 0, clickCount: count, modifiers: options.modifiers }));
201
213
  }
202
214
  await budget.run(() => delivery(Promise.all([Promise.all(commands), receipt])));
203
- if (options.hover) return { hovered: rootPoint, hit_target: hitTarget, page_state: pageState, target_verified: true, events_trusted: true, application_result: 'not_checked', observe_again: true };
204
- return { clicked: rootPoint, hit_target: hitTarget, page_state: pageState, target_verified: true, application_result: 'not_checked', coordinate_space: 'viewport_css_pixels', frame_id: node?.frameId || frameId, button: options.button, click_count: options.click_count, observe_again: true };
215
+ const verification = { target_verified: node ? true : null, delivery_verified: true, events_trusted: true,
216
+ target_verification: { basis: node ? 'element' : 'coordinate_hit', hit_relation: relation } };
217
+ if (options.hover) return { hovered: rootPoint, hit_target: hitTarget, page_state: pageState, ...verification, application_result: 'not_checked', observe_again: true };
218
+ return { clicked: rootPoint, hit_target: hitTarget, page_state: pageState, ...verification, application_result: 'not_checked', coordinate_space: 'viewport_css_pixels', frame_id: node?.frameId || frameId, button: options.button, click_count: options.click_count, observe_again: true };
205
219
  } finally {
206
220
  pending.delete(tabId);
207
221
  // A page handler may have opened a modal. Renderer cleanup can finish after
@@ -219,19 +233,38 @@ export function createClick(cdp, value, viewport, position, sessions, getTab, ge
219
233
 
220
234
  export function describeHit() {
221
235
  const element = this.nodeType === 3 ? this.parentElement : this;
236
+ if (element?.nodeType !== 1) throw new Error('Hit target is not an Element; no input was dispatched.');
222
237
  let clickable, disabled = false, nativeSelect = false;
223
238
  for (let parent = element; parent; parent = parent.assignedSlot || parent.parentElement || parent.getRootNode().host) {
224
239
  if (parent.tagName === 'SELECT' || parent.tagName === 'LABEL' && parent.control?.tagName === 'SELECT') nativeSelect = true;
225
240
  if (!clickable && parent.matches('button,a[href],input,select,textarea,summary,[role="button"],[role="link"],[onclick]')) clickable = parent;
226
241
  if (parent.matches(':disabled,[inert],[aria-disabled="true"]')) disabled = true;
227
242
  }
228
- return { tag: element.tagName?.toLowerCase(), text: (element.innerText || element.textContent || '').slice(0, 300),
243
+ const rect = element.getBoundingClientRect(), style = element.ownerDocument.defaultView.getComputedStyle(element);
244
+ return { tag: element.tagName?.toLowerCase(), id: element.id || null, text: (element.innerText || element.textContent || '').slice(0, 300),
245
+ rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, coordinate_space: 'frame_viewport_css_pixels',
246
+ position: style.position, z_index: style.zIndex, visibility: style.visibility,
229
247
  role: element.getAttribute('role'), disabled, clickable_hint: !!clickable,
230
248
  native_select: nativeSelect,
231
249
  clickable_target: clickable ? { tag: clickable.tagName.toLowerCase(), role: clickable.getAttribute('role'),
232
250
  text: (clickable.innerText || clickable.textContent || '').slice(0, 300) } : null };
233
251
  }
234
252
 
253
+ export function normalizeHit(x, y) {
254
+ let element = this.element || (this.nodeType === 3 ? this.parentElement : this);
255
+ if (element?.nodeType === 11 && element.host) element = element.elementFromPoint(x, y);
256
+ if (element?.nodeType !== 1) throw new Error('Hit test did not resolve an Element; no input was dispatched.');
257
+ return element;
258
+ }
259
+
260
+ export function hitRelation(hit) {
261
+ if (hit === this) return 'same_node';
262
+ for (let node = hit; node; node = node.assignedSlot || node.parentNode || node.host) {
263
+ if (node === this) return 'composed_descendant';
264
+ }
265
+ return null;
266
+ }
267
+
235
268
  function installGuard(name, options, expiryMs, verifyDelivery) {
236
269
  const target = this.nodeType === 3 ? this.parentElement : this;
237
270
  const win = target.ownerDocument.defaultView, report = win[name];
@@ -1,4 +1,5 @@
1
1
  import { compareVersions } from './version.js';
2
+ import { errorData } from './errors.js';
2
3
 
3
4
  export function createConnection(chrome, CONNECTION, controller) {
4
5
  let socket;
@@ -89,7 +90,7 @@ export function createConnection(chrome, CONNECTION, controller) {
89
90
  if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ id: request.id, result }));
90
91
  } catch (error) {
91
92
  if (request?.id && ws.readyState === WebSocket.OPEN) {
92
- ws.send(JSON.stringify({ id: request.id, error: error.message || String(error) }));
93
+ ws.send(JSON.stringify({ id: request.id, ...errorData(error) }));
93
94
  }
94
95
  }
95
96
  };