@evan_zhong/browser-attach 0.20.0 → 0.21.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 +1 -1
- package/docs/ARCHITECTURE.md +5 -3
- package/docs/TEST-REPORT.md +9 -1
- package/docs/USAGE.md +21 -3
- package/extension/actionability.js +32 -9
- package/extension/click.js +3 -1
- package/extension/controller.js +75 -25
- package/extension/manifest.json +1 -1
- package/extension/wait.js +31 -7
- package/package.json +1 -1
- package/src/config.mjs +1 -1
- package/src/mcp.mjs +12 -5
- 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 |
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 架构与设计边界
|
|
2
2
|
|
|
3
|
-
当前实现为 **0.
|
|
3
|
+
当前实现为 **0.21.0**。本文说明 BrowserAttach 的组件、控制模型与设计取舍;工具参数见 [使用指南](USAGE.md)。
|
|
4
4
|
|
|
5
5
|
## 组件与数据流
|
|
6
6
|
|
|
@@ -74,6 +74,7 @@ HTTP 状态复用 diagnostics 的 Network.responseReceived 观察,按主文档
|
|
|
74
74
|
- **文本查询与精简输出**:`snapshot(query=...)` 复用同一次 AX 渲染,按当前视图中节点的完整名称(含合并正文)、value 或字段 context 做不区分大小写的字面子串筛选,只输出匹配行和对应 frame 标题;不搜索 role/ref 或任意 DOM 属性,不新增索引缓存。新查询仍采集新观察并使旧 refs 失效,query 与分页绑定。默认省略 viewport、snapshot_id 和重复规则说明,`verbose=true` 返回前两项;frame 覆盖、视图、预算和游标语义保留。
|
|
75
75
|
- **字段上下文**:表单控件可附带 context 并参与 query,保留至多 160 字符的前一兄弟分支 AX 文本;只在四层以内 generic/none/group 祖先、单控件容器中查找,每次容器/文本遍历分别限制 64/32 节点。不将其伪装为 accessible name,不用于 label 定位或 ref 身份验证;interactive/text=false 也保留提示。
|
|
76
76
|
- **ref 解析**:ref 保存 `backendNodeId`、所属 frame/session 和观察时的 AX role/name;动作时重新 `DOM.resolveNode` 并用局部 AX 查询核对语义身份。输入与求值不清空 refs,只使快照分页失效;原节点仍连接时可复用,节点替换、role/name 改变、文档或 frame 变化时安全失败,不按 role/name 猜测替代节点。
|
|
77
|
+
- **ref 失效诊断**:controller 每标签保留最近至多 1000 个已清理 ref 的 cause/frame_id,不保留 DOM 对象、字段值或替代定位信息。新观察、文档/frame 变化及对话框清理记录各自原因;解析时单独报告节点脱离、session 或 AX 身份变化。释放、断连、关闭和 detach 清空历史,淘汰后返回 unknown_ref。
|
|
77
78
|
- **双预算**:`max_nodes` 限制节点数,`max_chars` 限制每行序列化后的累计字符数;两者独立,任一耗尽都会返回 `next_cursor`。
|
|
78
79
|
- **续读**:续读沿用同一份 `state`(`entries`、`refs`、`nextOffset`),所以 refs 在分页过程中保持有效。单个节点放不进预算时直接报错要求提高预算,避免静默截断尾部。
|
|
79
80
|
- **frame 失效**:主文档导航或文档更新清空整份观察;子 frame 导航、移除或 session detach 只使该子树失效,并把受影响 frame 写入 `omitted_frames`。
|
|
@@ -98,7 +99,8 @@ HTTP 状态复用 diagnostics 的 Network.responseReceived 观察,按主文档
|
|
|
98
99
|
- **送达预算**:原生 click/hover/press_key 的派发及可信回执另有 1250 ms 上限,清理最多 250 ms;系统菜单捕获输入时快速释放队列,仍报告结果未知并提示 Esc,不自动重放。
|
|
99
100
|
- **hover 与 drag**:hover 复用点击准备与隔离校验,仅派发一次 mouseMoved,清理不移走指针。drag 显式区分 pointer 与 HTML5;后者从真实 dragstart 截获 DragData,再用 CDP 派发 dragover/drop,校验可信 dragstart/drop/dragend。目的节点在按住后滚入视口;所有步骤共用标签队列和总预算,失败时只取消/释放本次手势。
|
|
100
101
|
- **可选输入参数**:显式 offset 在元素 content quad 上插值后沿用命中与 OOPIF 边界检查;settle_ms 在 click/drag 首次派发前等待并复查就绪。drag trajectory 只替换既有插值段,采用法向控制点、平滑进度及随机间隔权重;type 的 delay_ms 可逐间隔独立区间采样。所有等待共用总预算,超时后没有继续派发的链;省略参数时不使用随机数,沿用既有确定性路径。不新增 tab 级历史、冷却、自动激活或重放路径。
|
|
101
|
-
- **条件等待**:wait 复用严格 selector
|
|
102
|
+
- **条件等待**:wait 复用严格 selector、可操作性和命中检查,支持出现、无匹配、可见、可操作、字段值精确相等、checked/selected/expanded、文本/URL 子串及网络空闲。控件优先读取明确的原生属性,否则读取对应 ARIA 属性;缺失/无效状态保持 unavailable,不当作 false。字段值不回显;文本使用 innerText,比较在 500 字符预览截取前完成。每次只读轮询重新解析 selector,可在节点替换时重读,歧义立即失败;AX 无匹配不证明 DOM 移除。单步与 batch 共享条件 schema 和参数校验,等待超时不改变前序动作的派发事实。
|
|
103
|
+
- **可操作性诊断**:区分 display_none、visibility_hidden、zero_size、disabled、inert、read_only 和 unstable_geometry;无可见落点单独返回 no_visible_click_point。失败时最多采集 8 层 composed 祖先的样式、滚动方向和 transition 属性/时长,成功路径保留原有几何就绪语义;不以 opacity=0 拒绝输入,不自动 hover、移除遮挡或等待推断出的组件状态。
|
|
102
104
|
- **原生下拉**:select_option 不要求可见或稳定几何,先只读核对节点、禁用/inert 祖先与唯一选项,再在一次派发中复查并用 selectedIndex setter 和合成 input/change 设置,核验保留结果并返回 events_trusted=false。单步和 batch 共用实现。native click 的 select 命中(含 UA shadow 内部节点和关联 label)在派发前拒绝。
|
|
103
105
|
- **命中提示**:普通命中保留实际 hit node,沿父元素、Shadow host、slot 查找最近的 HTML/ARIA 可点击候选,返回 clickable_target;hint=false 表示未识别候选,不代表无法响应事件。CSSPseudoElement 先解析为事件所属的 originating element,提示标记 pseudo_element=true,并使用该元素安装事件校验。语义候选不会被用于替换点击目标。
|
|
104
106
|
- **遮挡失败诊断**:当前 frame 的遮挡直接复用已读取的 hit_target;祖先 frame 遮挡额外用至多 200 ms 读取命中描述,诊断节点消失时保留原遮挡错误与 backend node ID。错误包含命中点和受阻 frame。根据共享预算的派发状态区分“未派发”和“结果未知”,避免 drag 按住后准备目的节点时误报零输入。
|
|
@@ -108,7 +110,7 @@ HTTP 状态复用 diagnostics 的 Network.responseReceived 观察,按主文档
|
|
|
108
110
|
- **命中对象**:伪元素先解析 originating Element;ShadowRoot 通过其 elementFromPoint 重新取得实际命中 Element,同进程子 frame 先把 session 坐标映射到所属文档视口。不将 ShadowRoot 直接当 Element 或无条件改成 host。后续身份、遮挡和事件回执检查保持一致;命中描述附带 frame-local rect 和样式诊断。
|
|
109
111
|
- **DOM 模式**:`mode: "dom"` 是显式的、受控的降级路径,用于原生菜单或标签拖动暂时捕获输入时操作普通 HTML 控件。它调用 `HTMLElement.click()` 或原生 value setter,事件非可信、无用户激活,且绝不会在原生失败后自动启用。
|
|
110
112
|
- **对话框策略**:ask 仍为默认;单操作/单步骤的显式 accept/dismiss 在事件回调中解除 JS 模态阻塞,保留消息与处理结果,最多自动处理 10 个。beforeunload dismiss 快速取消本次导航等待,实际主文档变化仍会停止 batch。
|
|
111
|
-
- **未知结果探测**:在可能写入的操作准备期安装目标 frame 的临时 MutationObserver;观察用的 frame 解析只尝试一次,不等待不存在的 frame 消耗动作准备预算。仅在不确定失败后做一次有界只读探测,回传对话框、导航、DOM 变动;读取最多 300 ms、清理最多 100 ms。
|
|
113
|
+
- **未知结果探测**:在可能写入的操作准备期安装目标 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
114
|
- **元素截图/求值 ref**:复用既有截图 clip/元数据与 ref 注册/失效模型,分别增加元素 border box 截图和显式单 DOM Element 结果;不暴露 Runtime objectId。JSON 求值自动调用函数一次并等待 Promise,缓存续读不执行脚本。
|
|
113
115
|
- **求值预算提示**:JSON 仍按顶层完整项分页;已有输出而下一项超出预算时,在同一响应提供 next_item 的 key/index、required_chars 和是否超过最大页预算。提示只读取缓存,不展开或改写嵌套值,也不提供任意脚本的执行进度。
|
|
114
116
|
- **历史导航**:在已有 navigate 中选择 URL 或 back/forward,按 history entry 与主 frame 生命周期等待;同文档/BFCache 恢复不等新的加载事件,不用 evaluate 重放导航。
|
package/docs/TEST-REPORT.md
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
# 验证报告
|
|
2
2
|
|
|
3
|
-
当前版本 **0.
|
|
3
|
+
当前版本 **0.21.0**(2026-10-24)。浏览器验证使用本地 fixture 和临时 profile;测试链路在各项下说明。
|
|
4
4
|
|
|
5
5
|
## 当前版本
|
|
6
6
|
|
|
7
|
+
- `npm test`:236 项通过。新增可操作性原因、有限祖先样式/滚动/transition 证据、零透明度放行、slot 继承 inert、无可见落点且保留既有派发事实、ref 历史上限及释放清理、身份解析期间文档变化、动作后观察不可用/未见变化/正面证据分型,以及明确状态等待的参数、严格匹配、节点替换、缺失 ARIA 状态和字段值不回显验证。
|
|
8
|
+
- 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
|
+
- `npm run test:package`:包外安装与 stdio MCP 通过,23 个工具、51 个发布文件,无配对密钥;未增加依赖或扩展权限。MCP 测试同时覆盖新 wait 条件在单步和 batch 中的 schema、布尔 false/mixed 传递与参数预检。
|
|
10
|
+
- 本机部署: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。
|
|
11
|
+
- 当前边界:状态等待只确认指定的页面状态,不推断业务成功;ARIA 缺失/无效时继续报告 unavailable。role 无匹配不证明 DOM 已移除;不可用 frame 不作为元素消失证据。ref 原因历史每标签限 1000 条;不自动重绑定、补发或切换输入模式。
|
|
12
|
+
|
|
13
|
+
## 0.20.0 验证
|
|
14
|
+
|
|
7
15
|
- `node --test tests/*.test.mjs`:226 项通过。新增 file:// 发现与接管的实时授权、普通操作与 batch 中途停权、HTTP→file 导航派发前检查、文件导航后的 ref/分页失效、停权后可靠释放及 detach 失败保留控制权、历史目标权限检查和迟到权限回执不派发导航。
|
|
8
16
|
- 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
17
|
- 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.21.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,6 +201,8 @@ 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
|
|
|
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
|
+
|
|
202
206
|
原生 click/hover/drag 要求活动、未冻结/丢弃的标签与未最小化窗口,准备前及派发前各检查一次,单次最多 500 ms。窗口失焦、document.visibilityState=hidden 或 document.hasFocus()=false 不独立触发拒绝;Click/Hover 成功结果的 page_state 提供这些诊断信号,实际送达仍需回执。就绪失败的处理见[结果未知时](#结果未知时)。
|
|
203
207
|
|
|
204
208
|
native 与 dom 由调用者显式选择。DOM click/fill 使用非可信事件且没有用户激活,不能满足依赖 isTrusted、pointer 事件或用户激活的功能。
|
|
@@ -261,17 +265,31 @@ pointer 用于鼠标驱动控件,核验 pointerdown/move/up;html5 用真实
|
|
|
261
265
|
| condition | 等待内容 |
|
|
262
266
|
|---|---|
|
|
263
267
|
| `attached` / `visible` | 元素出现 / 非隐藏且有尺寸;visible 不要求启用 |
|
|
268
|
+
| `absent` | 严格 selector 不再匹配;隐藏的 DOM 元素仍可能匹配 CSS/XPath,role 查询无匹配不证明 DOM 节点已移除 |
|
|
264
269
|
| `actionable` | 可见、启用、非 inert、几何稳定且命中无阻挡;不自动滚动,须已在视口内 |
|
|
265
270
|
| `text` | 渲染文本 innerText 含 expected,或与完整旧值 previous 不同;二者选一 |
|
|
271
|
+
| `value` | input/textarea/select 的 value 或 contenteditable 的 innerText 与字符串 expected 精确相等;不回显字段值 |
|
|
272
|
+
| `checked` | checkbox/radio 的原生状态,否则读取 aria-checked;expected 为 true/false,或字符串 mixed |
|
|
273
|
+
| `selected` | option.selected,否则读取 aria-selected;expected 为 true/false |
|
|
274
|
+
| `expanded` | details.open,否则读取 aria-expanded;expected 为 true/false |
|
|
266
275
|
| `url` | 主 frame URL 含 expected |
|
|
267
276
|
| `network_idle` | 已观察的请求 inflight=0,且连续 idle_ms 无活动 |
|
|
268
277
|
|
|
269
278
|
元素条件必须提供严格 selector,可带 frame/nth;全页渲染文字使用 `selector:"body"`。url/network_idle 不使用元素目标。工具只读等待,超时附带条件及最后状态。
|
|
270
279
|
|
|
280
|
+
控件状态不要求可见或启用。缺失或无效的 ARIA 状态报告 state=unavailable,不按 false 处理;checkbox.indeterminate 对应 mixed。每次轮询重新解析 selector,节点在重渲染中被替换可继续等待,歧义立即报错;frame 不可用不能证明元素消失。等待只验证明确条件,不证明保存或服务端业务完成,超时不使前序动作变得可安全重放。单步和 batch 使用相同的条件与校验。
|
|
281
|
+
|
|
271
282
|
```json
|
|
272
283
|
{"tab_handle":"...","selector":"#toast","condition":"text","expected":"保存成功","timeout_ms":5000}
|
|
273
284
|
```
|
|
274
285
|
|
|
286
|
+
```json
|
|
287
|
+
{"tab_handle":"...","steps":[
|
|
288
|
+
{"action":"click","selector":"#open-picker"},
|
|
289
|
+
{"action":"wait","selector":"#open-picker","condition":"expanded","expected":true}
|
|
290
|
+
]}
|
|
291
|
+
```
|
|
292
|
+
|
|
275
293
|
network_idle 的 idle_ms 默认 500,范围 100–10000。它只覆盖接管后观察到的页面/iframe 请求,不包含独立 worker 或 WebSocket 消息;长轮询会阻止满足。请求跟踪溢出时 tracking_overflow=true,不能报告已确认空闲。业务就绪优先等待具体元素或标志。
|
|
276
294
|
|
|
277
295
|
### browser_fill
|
|
@@ -37,10 +37,13 @@ export async function waitForTarget(cdp, value, target, node, budget, { editable
|
|
|
37
37
|
const bounds = value(await budget.run(() => cdp(target, 'Runtime.callFunctionOn', {
|
|
38
38
|
objectId: node.objectId, functionDeclaration: inspectTarget.toString(), arguments: [{ value: editable }], returnByValue: true,
|
|
39
39
|
})));
|
|
40
|
-
if (bounds?.detached) throw new Error(
|
|
41
|
-
|
|
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
|
+
});
|
|
42
45
|
if (bounds?.ready && previous && JSON.stringify(bounds) === JSON.stringify(previous)) return;
|
|
43
|
-
budget.describe?.(`visible, enabled, stable target; current=${JSON.stringify(bounds)}`, { reason: 'not_actionable', frame_id: node.frameId, target: bounds });
|
|
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 });
|
|
44
47
|
previous = bounds?.ready ? bounds : null;
|
|
45
48
|
await budget.run(() => new Promise(resolve => setTimeout(resolve, 20)));
|
|
46
49
|
}
|
|
@@ -52,13 +55,33 @@ export function inspectTarget(editable) {
|
|
|
52
55
|
const tag = element.tagName?.toLowerCase();
|
|
53
56
|
if (editable && ((!['input', 'textarea'].includes(tag) && !element.isContentEditable) ||
|
|
54
57
|
(tag === 'input' && !['text', 'search', 'email', 'password', 'tel', 'url', ''].includes(element.type)))) return { unsupported: true };
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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 };
|
|
58
76
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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');
|
|
62
85
|
return { ready: true,
|
|
63
86
|
x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
|
64
87
|
}
|
package/extension/click.js
CHANGED
|
@@ -67,7 +67,9 @@ export function createClick(cdp, value, viewport, position, sessions, getTab, ge
|
|
|
67
67
|
point = clippedPoint(quad, view, args.offset);
|
|
68
68
|
if (point) break;
|
|
69
69
|
}
|
|
70
|
-
if (!point) throw
|
|
70
|
+
if (!point) throw obstruction('Element has no visible click point. Inspect a screenshot', {
|
|
71
|
+
reason: 'no_visible_click_point', frame_id: node.frameId, viewport: view, quads: quads || [],
|
|
72
|
+
});
|
|
71
73
|
} else {
|
|
72
74
|
view = await budget.run(() => viewport(target));
|
|
73
75
|
if (args.coordinate_space === 'document') {
|
package/extension/controller.js
CHANGED
|
@@ -26,6 +26,7 @@ export function createController(api, notify = () => {}) {
|
|
|
26
26
|
const temporaryTabs = new Map();
|
|
27
27
|
const fetches = createFetches(fetchPage);
|
|
28
28
|
const snapshots = new Map();
|
|
29
|
+
const retiredRefs = new Map();
|
|
29
30
|
const documents = new Map();
|
|
30
31
|
const pageContexts = new Map();
|
|
31
32
|
const frameStates = new Map();
|
|
@@ -97,17 +98,52 @@ export function createController(api, notify = () => {}) {
|
|
|
97
98
|
return result.result?.value;
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
function retireRef(tabId, ref, cause, frameId) {
|
|
102
|
+
let history = retiredRefs.get(tabId);
|
|
103
|
+
if (!history) retiredRefs.set(tabId, history = new Map());
|
|
104
|
+
history.set(ref, { cause, frame_id: frameId });
|
|
105
|
+
if (history.size > 1000) history.delete(history.keys().next().value);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function invalidateSnapshot(tabId, cause) {
|
|
109
|
+
const state = snapshots.get(tabId);
|
|
110
|
+
if (state) for (const [ref, node] of state.refs) retireRef(tabId, ref, cause, node.frameId);
|
|
111
|
+
snapshots.delete(tabId);
|
|
112
|
+
}
|
|
113
|
+
|
|
100
114
|
async function getObject(tabId, ref, budget) {
|
|
115
|
+
const refError = (cause, frameId, message) => {
|
|
116
|
+
const dispatched = budget.has_dispatched();
|
|
117
|
+
return Object.assign(new Error(`${message}. Take a new browser_snapshot. ${dispatched ? 'Input was already dispatched; outcome is unknown. Do not replay automatically.' : 'No input was dispatched.'}`), {
|
|
118
|
+
input_dispatched: dispatched, diagnostics: { reason: 'stale_ref', cause, ref, ...(frameId ? { frame_id: frameId } : {}) },
|
|
119
|
+
});
|
|
120
|
+
};
|
|
101
121
|
const node = snapshots.get(tabId)?.refs.get(ref);
|
|
102
|
-
if (!node)
|
|
122
|
+
if (!node) {
|
|
123
|
+
const failure = retiredRefs.get(tabId)?.get(ref);
|
|
124
|
+
throw refError(failure?.cause || 'unknown_ref', failure?.frame_id, 'Element ref is stale or unknown');
|
|
125
|
+
}
|
|
103
126
|
const target = sessions.target(tabId, node.frameId);
|
|
104
|
-
if (target.sessionId !== node.sessionId) throw
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
127
|
+
if (target.sessionId !== node.sessionId) throw refError('frame_session_changed', node.frameId, 'Frame session changed');
|
|
128
|
+
let resolved, nodes;
|
|
129
|
+
try {
|
|
130
|
+
resolved = await budget.run(() => cdp(target, 'DOM.resolveNode', { backendNodeId: node.backendNodeId, objectGroup: 'browser-attach' }));
|
|
131
|
+
if (!resolved.object?.objectId) throw refError('element_detached', node.frameId, 'Element no longer exists');
|
|
132
|
+
({ nodes } = await budget.run(() => cdp(target, 'Accessibility.getPartialAXTree', { backendNodeId: node.backendNodeId, fetchRelatives: false })));
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
const retired = retiredRefs.get(tabId)?.get(ref);
|
|
136
|
+
if (retired) throw refError(retired.cause, retired.frame_id, 'Element ref became stale during resolution');
|
|
137
|
+
if (/no node|could not find node|node.*(detached|not found)/i.test(error.message)) throw refError('element_detached', node.frameId, 'Element is detached or no longer exists');
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
if (snapshots.get(tabId)?.refs.get(ref) !== node) {
|
|
141
|
+
const retired = retiredRefs.get(tabId)?.get(ref);
|
|
142
|
+
throw refError(retired?.cause || 'unknown_ref', node.frameId, 'Element ref became stale during resolution');
|
|
143
|
+
}
|
|
108
144
|
const current = nodes?.find(item => item.backendDOMNodeId === node.backendNodeId);
|
|
109
145
|
if (!current || !!current.ignored !== !!node.ignored || current.role?.value !== node.role || String(current.name?.value || '') !== node.name) {
|
|
110
|
-
throw
|
|
146
|
+
throw refError('identity_changed', node.frameId, 'Element ref identity changed (role/name or accessibility)');
|
|
111
147
|
}
|
|
112
148
|
return { ...node, target, objectId: resolved.object.objectId };
|
|
113
149
|
}
|
|
@@ -156,6 +192,7 @@ export function createController(api, notify = () => {}) {
|
|
|
156
192
|
claims.delete(tabId);
|
|
157
193
|
evaluations.forget(tabId);
|
|
158
194
|
snapshots.delete(tabId);
|
|
195
|
+
retiredRefs.delete(tabId);
|
|
159
196
|
documents.delete(tabId);
|
|
160
197
|
for (const key of pageContexts.keys()) if (JSON.parse(key)[0] === tabId) pageContexts.delete(key);
|
|
161
198
|
frameStates.delete(tabId);
|
|
@@ -184,6 +221,7 @@ export function createController(api, notify = () => {}) {
|
|
|
184
221
|
clickVisuals.forget(tabId);
|
|
185
222
|
claims.delete(tabId);
|
|
186
223
|
snapshots.delete(tabId);
|
|
224
|
+
retiredRefs.delete(tabId);
|
|
187
225
|
documents.delete(tabId);
|
|
188
226
|
for (const key of pageContexts.keys()) if (JSON.parse(key)[0] === tabId) pageContexts.delete(key);
|
|
189
227
|
frameStates.delete(tabId);
|
|
@@ -233,7 +271,7 @@ export function createController(api, notify = () => {}) {
|
|
|
233
271
|
if ((!source.sessionId && method === 'DOM.documentUpdated') || mainNavigation || sameDocumentMain) {
|
|
234
272
|
evaluations.forget(source.tabId);
|
|
235
273
|
documents.set(source.tabId, (documents.get(source.tabId) || 0) + 1);
|
|
236
|
-
|
|
274
|
+
invalidateSnapshot(source.tabId, sameDocumentMain ? 'same_document_navigation' : mainNavigation ? 'document_navigation' : 'document_updated');
|
|
237
275
|
fileChoosers.delete(source.tabId);
|
|
238
276
|
} else if (source.sessionId && method === 'DOM.documentUpdated') {
|
|
239
277
|
for (const [frame, session] of frameStates.get(source.tabId)?.sessions || []) if (session === source.sessionId) invalidateFrame(source.tabId, frame);
|
|
@@ -248,7 +286,7 @@ export function createController(api, notify = () => {}) {
|
|
|
248
286
|
if (mainNavigation) clickVisuals.navigated(source.tabId);
|
|
249
287
|
if (method === 'Page.javascriptDialogOpening') {
|
|
250
288
|
dialogs.set(source.tabId, { sessionId: source.sessionId, type: params.type, message: params.message, default_prompt: params.defaultPrompt, default_button: null });
|
|
251
|
-
|
|
289
|
+
invalidateSnapshot(source.tabId, 'dialog_opened');
|
|
252
290
|
const openedDialog = dialogs.get(source.tabId);
|
|
253
291
|
const message = { ...openedDialog, policy: policy?.policy || 'ask', accepted: autoDialog ? policy.policy === 'accept' : null };
|
|
254
292
|
for (let item = policy; item; item = item.parent) item.messages.push(message);
|
|
@@ -270,7 +308,7 @@ export function createController(api, notify = () => {}) {
|
|
|
270
308
|
if (change.url) {
|
|
271
309
|
evaluations.forget(tabId);
|
|
272
310
|
documents.set(tabId, (documents.get(tabId) || 0) + 1);
|
|
273
|
-
|
|
311
|
+
invalidateSnapshot(tabId, 'tab_url_changed'); fileChoosers.delete(tabId);
|
|
274
312
|
}
|
|
275
313
|
});
|
|
276
314
|
|
|
@@ -288,15 +326,18 @@ export function createController(api, notify = () => {}) {
|
|
|
288
326
|
for (const id of affected) {
|
|
289
327
|
evaluations.forgetFrame(tabId, id);
|
|
290
328
|
frames.versions.set(id, (frames.versions.get(id) || 0) + 1);
|
|
291
|
-
if (state) omitFrame(state, id, 'Frame navigated or detached after observation');
|
|
329
|
+
if (state) omitFrame(tabId, state, id, 'Frame navigated or detached after observation');
|
|
292
330
|
}
|
|
293
331
|
if (affected.has(fileChoosers.get(tabId)?.frame_id)) fileChoosers.delete(tabId);
|
|
294
332
|
}
|
|
295
333
|
|
|
296
|
-
function omitFrame(state, frameId, reason) {
|
|
334
|
+
function omitFrame(tabId, state, frameId, reason) {
|
|
297
335
|
if (state.invalidFrames.has(frameId)) return;
|
|
298
336
|
state.invalidFrames.add(frameId);
|
|
299
|
-
for (const [ref, node] of state.refs) if (node.frameId === frameId)
|
|
337
|
+
for (const [ref, node] of state.refs) if (node.frameId === frameId) {
|
|
338
|
+
retireRef(tabId, ref, 'frame_changed', frameId);
|
|
339
|
+
state.refs.delete(ref);
|
|
340
|
+
}
|
|
300
341
|
state.totalNodes -= state.entries.filter(entry => entry.frameId === frameId && entry.node).length;
|
|
301
342
|
state.metadata.omitted_frames.push({ frame_id: frameId, reason });
|
|
302
343
|
}
|
|
@@ -314,7 +355,7 @@ export function createController(api, notify = () => {}) {
|
|
|
314
355
|
return snapshotPage(state, args);
|
|
315
356
|
}
|
|
316
357
|
const options = snapshotOptions(args);
|
|
317
|
-
|
|
358
|
+
invalidateSnapshot(tabId, 'snapshot_replaced');
|
|
318
359
|
const documentVersion = documents.get(tabId);
|
|
319
360
|
const versions = new Map(frameStates.get(tabId)?.versions);
|
|
320
361
|
await (budget ? budget.run(() => sessions.settle(tabId)) : sessions.settle(tabId));
|
|
@@ -362,7 +403,7 @@ export function createController(api, notify = () => {}) {
|
|
|
362
403
|
metadata: { title: metadata.title, url: metadata.url, viewport: await (budget ? budget.run(() => viewport(tabId)) : viewport(tabId)), omitted_frames: omittedFrames } };
|
|
363
404
|
if (documentVersion !== documents.get(tabId)) throw new Error('Document changed while reading the snapshot. Take a new browser_snapshot.');
|
|
364
405
|
for (const frame of frames) if (versions.get(frame.id) !== frameStates.get(tabId)?.versions.get(frame.id)) {
|
|
365
|
-
omitFrame(state, frame.id, 'Frame changed while reading the snapshot; take a new snapshot to observe it');
|
|
406
|
+
omitFrame(tabId, state, frame.id, 'Frame changed while reading the snapshot; take a new snapshot to observe it');
|
|
366
407
|
}
|
|
367
408
|
const page = snapshotPage(state, args);
|
|
368
409
|
snapshots.set(tabId, state);
|
|
@@ -378,7 +419,7 @@ export function createController(api, notify = () => {}) {
|
|
|
378
419
|
await cdp({ tabId, ...(dialogs.get(tabId)?.sessionId ? { sessionId: dialogs.get(tabId).sessionId } : {}) }, 'Page.handleJavaScriptDialog', { accept: args.accept,
|
|
379
420
|
...(args.prompt_text != null ? { promptText: String(args.prompt_text) } : {}) });
|
|
380
421
|
dialogs.delete(tabId);
|
|
381
|
-
|
|
422
|
+
invalidateSnapshot(tabId, 'dialog_handled');
|
|
382
423
|
return { handled: true, dialog: dialog ?? null, accepted: args.accept, observe_again: true };
|
|
383
424
|
}
|
|
384
425
|
if (dialogs.has(tabId)) {
|
|
@@ -487,7 +528,7 @@ export function createController(api, notify = () => {}) {
|
|
|
487
528
|
const url = args.direction != null ? undefined : args.url;
|
|
488
529
|
if (args.direction != null && args.url != null) throw new Error('Provide url or direction, never both');
|
|
489
530
|
evaluations.forget(tabId);
|
|
490
|
-
|
|
531
|
+
invalidateSnapshot(tabId, 'navigation_requested');
|
|
491
532
|
fileChoosers.delete(tabId);
|
|
492
533
|
const { document: navigationDocument, ...result } = await navigation.navigate(tabId, url, args);
|
|
493
534
|
const budget = deadline(NAVIGATION_OBSERVATION_TIMEOUT_MS, 'observation');
|
|
@@ -500,7 +541,7 @@ export function createController(api, notify = () => {}) {
|
|
|
500
541
|
}
|
|
501
542
|
return { ...result, ...await snapshot(tabId, {}, budget) };
|
|
502
543
|
} catch (error) {
|
|
503
|
-
|
|
544
|
+
invalidateSnapshot(tabId, 'observation_failed');
|
|
504
545
|
if (attempt === 0 && budget.remaining() > 100 && /context.*(destroyed|not found)|cannot find context|document (changed|updated)|inspected target navigated|no frame with given id/i.test(error.message)) {
|
|
505
546
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
506
547
|
continue;
|
|
@@ -658,7 +699,7 @@ export function createController(api, notify = () => {}) {
|
|
|
658
699
|
dialogPolicies.set(tabId, state);
|
|
659
700
|
const version = documents.get(tabId), startTab = await api.tabs.get(tabId);
|
|
660
701
|
const watch = ['click', 'fill', 'type', 'press_key', 'hover', 'scroll', 'select_option', 'upload_file', 'evaluate', 'navigate'].includes(command) && !(command === 'evaluate' && args.cursor != null);
|
|
661
|
-
let observer, observationFrame = frameStates.get(tabId)?.mainId, observationTarget = { tabId }, observedVersion = frameStates.get(tabId)?.versions.get(observationFrame);
|
|
702
|
+
let observer, observationError, observationFrame = frameStates.get(tabId)?.mainId, observationTarget = { tabId }, observedVersion = frameStates.get(tabId)?.versions.get(observationFrame);
|
|
662
703
|
const startObservation = async () => { if (!watch || observer || dialogs.has(tabId)) return; try {
|
|
663
704
|
observer = await boundedRead(async () => {
|
|
664
705
|
if (args.ref) observationFrame = snapshots.get(tabId)?.refs.get(args.ref)?.frameId || observationFrame;
|
|
@@ -667,9 +708,10 @@ export function createController(api, notify = () => {}) {
|
|
|
667
708
|
observedVersion = frameStates.get(tabId)?.versions.get(observationFrame);
|
|
668
709
|
const { executionContextId } = await cdp(observationTarget, 'Page.createIsolatedWorld', { frameId: observationFrame, worldName: 'browser-attach-outcome' });
|
|
669
710
|
const result = await cdp(observationTarget, 'Runtime.evaluate', { contextId: executionContextId, expression: `(${installOutcomeObserver.toString()})()`, objectGroup: 'browser-attach-outcome' });
|
|
670
|
-
|
|
711
|
+
if (!result.result?.objectId) throw new Error('DOM outcome observer could not be installed');
|
|
712
|
+
return result.result.objectId;
|
|
671
713
|
}, Math.min(200, args.budget?.remaining() ?? 200));
|
|
672
|
-
} catch {} };
|
|
714
|
+
} catch (error) { observationError = error.message; } };
|
|
673
715
|
try {
|
|
674
716
|
const result = await action(tabId, command, { ...args, observe_start: startObservation });
|
|
675
717
|
if (state.handling) await boundedRead(() => state.handling, 500);
|
|
@@ -679,16 +721,24 @@ export function createController(api, notify = () => {}) {
|
|
|
679
721
|
if (state.handling) await boundedRead(() => state.handling, 500).catch(() => {});
|
|
680
722
|
if (state.messages.some(d => d.type === 'beforeunload' && d.accepted === false && d.handled) && /ERR_ABORTED|cancel/i.test(error.message)) return { navigated: false, cancelled_by_dialog: true, dialogs: state.messages, observe_again: true };
|
|
681
723
|
if (watch && error.input_dispatched !== false && /outcome is unknown|may already|effects may|may have been|timed out|did not reach|may still/i.test(error.message)) {
|
|
682
|
-
let current, dom;
|
|
724
|
+
let current, dom, tabError, domError = observationError;
|
|
683
725
|
await Promise.allSettled([
|
|
684
|
-
boundedRead(
|
|
685
|
-
observer ? boundedRead(async () => {
|
|
726
|
+
boundedRead(() => api.tabs.get(tabId)).then(result => { if (result) current = result; else tabError = 'Tab is unavailable'; }, error => { tabError = error.message; }),
|
|
727
|
+
observer ? boundedRead(async () => {
|
|
728
|
+
const result = value(await cdp(observationTarget, 'Runtime.callFunctionOn', { objectId: observer, functionDeclaration: 'function() { return this.read(); }', returnByValue: true }));
|
|
729
|
+
if (typeof result?.dom_changed !== 'boolean') throw new Error('DOM outcome observation is unavailable');
|
|
730
|
+
return result;
|
|
731
|
+
}).then(result => { dom = result; }, error => { domError = error.message; }) : Promise.resolve(),
|
|
686
732
|
]);
|
|
687
733
|
const frameChanged = frameStates.get(tabId)?.versions.get(observationFrame) !== observedVersion;
|
|
734
|
+
const navigationObserved = documents.get(tabId) !== version || !!current && current.url !== startTab.url;
|
|
688
735
|
error.post_action = { delivery: 'unknown', frame_id: observationFrame, frame_navigation_observed: frameChanged, dialog: dialogs.get(tabId) || null, dialogs: state.messages,
|
|
689
|
-
navigation_observed:
|
|
736
|
+
navigation_observed: navigationObserved,
|
|
690
737
|
current_url: current?.url ?? null, dom_changed: dom?.dom_changed ?? null,
|
|
691
|
-
|
|
738
|
+
observations: { tab: { available: !!current, ...(tabError ? { error: tabError } : {}) },
|
|
739
|
+
dom: { available: !!dom, ...(!dom ? { error: domError || 'DOM outcome observer is unavailable' } : {}) } },
|
|
740
|
+
assessment: dialogs.has(tabId) || state.messages.length || navigationObserved || frameChanged || dom?.dom_changed ? 'observable_effects'
|
|
741
|
+
: dom && current ? 'no_observable_effects' : 'observation_unavailable',
|
|
692
742
|
note: 'These observations do not prove business success or that an unchanged page received no input. Do not replay automatically.' };
|
|
693
743
|
} else if (state.messages.length) error.message += ` Dialogs: ${JSON.stringify(state.messages)}`;
|
|
694
744
|
throw error;
|
package/extension/manifest.json
CHANGED
package/extension/wait.js
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import { deadline, inspectTarget } from './actionability.js';
|
|
2
2
|
import { queryElement, targetOptions, resolveSelector, resolveFrame } from './locator.js';
|
|
3
3
|
|
|
4
|
+
export const WAIT_CONDITIONS = ['attached', 'absent', 'visible', 'actionable', 'text', 'value', 'checked', 'selected', 'expanded', 'url', 'network_idle'];
|
|
5
|
+
|
|
4
6
|
export function waitOptions(args) {
|
|
5
|
-
if (!
|
|
6
|
-
const element = ['
|
|
7
|
+
if (!WAIT_CONDITIONS.includes(args.condition)) throw new Error('Unknown wait condition');
|
|
8
|
+
const element = !['url', 'network_idle'].includes(args.condition);
|
|
7
9
|
if (element) {
|
|
8
10
|
if (!args.selector || args.ref != null) throw new Error(`${args.condition} wait requires a reusable selector${args.condition === 'text' ? '; use selector="body" for whole-page rendered text' : ''}`);
|
|
9
11
|
targetOptions(args);
|
|
10
12
|
} else if (args.selector != null || args.ref != null || args.frame_id != null || args.frame != null || args.nth != null || args.selector_type != null || args.name != null) throw new Error('This wait condition does not use an element target');
|
|
11
13
|
if (args.condition === 'text' && (Number(typeof args.expected === 'string') + Number(typeof args.previous === 'string') !== 1)) throw new Error('text wait requires exactly one of expected (substring) or previous (full prior text)');
|
|
12
14
|
if (args.condition === 'url' && typeof args.expected !== 'string') throw new Error('url wait requires expected text (substring)');
|
|
15
|
+
if (args.condition === 'value' && typeof args.expected !== 'string') throw new Error('value wait requires an exact expected string');
|
|
16
|
+
if (['checked', 'selected', 'expanded'].includes(args.condition) && typeof args.expected !== 'boolean' && !(args.condition === 'checked' && args.expected === 'mixed')) throw new Error(`${args.condition} wait requires expected=true/false${args.condition === 'checked' ? ' or "mixed"' : ''}`);
|
|
17
|
+
if (!['text', 'url', 'value', 'checked', 'selected', 'expanded'].includes(args.condition) && args.expected != null) throw new Error('expected is not used by this wait condition');
|
|
13
18
|
if (args.condition !== 'text' && args.previous != null) throw new Error('previous is only valid for text waits');
|
|
14
19
|
if (args.condition === 'network_idle' && (!Number.isInteger(args.idle_ms ?? 500) || (args.idle_ms ?? 500) < 100 || (args.idle_ms ?? 500) > 10000)) throw new Error('idle_ms must be 100..10000');
|
|
15
20
|
}
|
|
@@ -18,7 +23,7 @@ export async function waitFor(cdp, value, sessions, diagnostics, mouse, tabId, a
|
|
|
18
23
|
waitOptions(args);
|
|
19
24
|
const budget = args.budget || deadline(args.timeout_ms ?? 5000, 'wait');
|
|
20
25
|
let current = 'not observed', previous;
|
|
21
|
-
const description = `${args.condition}${args.selector ? ` selector=${JSON.stringify(args.selector)}` : ''}${args.name != null ? ` name=${JSON.stringify(args.name)}` : ''}${args.expected != null ? ` expected substring=${JSON.stringify(args.expected)}` : ''}${args.previous != null ? ` text different from=${JSON.stringify(args.previous).slice(0, 500)}` : ''}`;
|
|
26
|
+
const description = `${args.condition}${args.selector ? ` selector=${JSON.stringify(args.selector)}` : ''}${args.name != null ? ` name=${JSON.stringify(args.name)}` : ''}${args.expected != null && args.condition !== 'value' ? ` expected${['text', 'url'].includes(args.condition) ? ' substring' : ''}=${JSON.stringify(args.expected)}` : ''}${args.previous != null ? ` text different from=${JSON.stringify(args.previous).slice(0, 500)}` : ''}`;
|
|
22
27
|
try {
|
|
23
28
|
while (true) {
|
|
24
29
|
if (args.condition === 'network_idle') {
|
|
@@ -35,7 +40,7 @@ export async function waitFor(cdp, value, sessions, diagnostics, mouse, tabId, a
|
|
|
35
40
|
const node = await resolveSelector(cdp, value, sessions, tabId, args, budget, { wait: false });
|
|
36
41
|
try {
|
|
37
42
|
current = node ? value(await budget.run(() => cdp(node.target, 'Runtime.callFunctionOn', { objectId: node.objectId, returnByValue: true,
|
|
38
|
-
functionDeclaration: `function(args) { return (${inspectWait.toString()})(() => this, (${inspectTarget.toString()}), args); }`, arguments: [{ value: { selector: args.selector, condition: args.condition, expected: args.expected, previous: args.previous } }] }))) : { satisfied:
|
|
43
|
+
functionDeclaration: `function(args) { return (${inspectWait.toString()})(() => this, (${inspectTarget.toString()}), args); }`, arguments: [{ value: { selector: args.selector, condition: args.condition, expected: args.expected, previous: args.previous } }] }))) : { satisfied: args.condition === 'absent', state: 'absent' };
|
|
39
44
|
} finally {
|
|
40
45
|
if (node) await budget.run(() => cdp(node.target, 'Runtime.releaseObjectGroup', { objectGroup: 'browser-attach' }));
|
|
41
46
|
}
|
|
@@ -57,14 +62,14 @@ export async function waitFor(cdp, value, sessions, diagnostics, mouse, tabId, a
|
|
|
57
62
|
}
|
|
58
63
|
} catch (error) {
|
|
59
64
|
if (!/covers|no visible click point|detached before resolution/i.test(error.message)) throw error;
|
|
60
|
-
current = { satisfied: false, reason: error.message };
|
|
65
|
+
current = { satisfied: false, reason: error.message, ...(error.diagnostics ? { diagnostics: error.diagnostics } : {}) };
|
|
61
66
|
}
|
|
62
67
|
}
|
|
63
68
|
if (current.satisfied) return { satisfied: true, condition: args.condition, current };
|
|
64
69
|
}
|
|
65
70
|
previous = current;
|
|
66
71
|
} catch (error) {
|
|
67
|
-
if (!/context.*(destroyed|not found)|cannot find context|no frame with given id|inspected target navigated/i.test(error.message)) throw error;
|
|
72
|
+
if (!/context.*(destroyed|not found)|cannot find context|no frame with given id|inspected target navigated|detached before resolution|no node|could not find node|node.*(detached|not found)/i.test(error.message)) throw error;
|
|
68
73
|
current = 'document/frame changing'; previous = null;
|
|
69
74
|
}
|
|
70
75
|
}
|
|
@@ -79,12 +84,31 @@ export async function waitFor(cdp, value, sessions, diagnostics, mouse, tabId, a
|
|
|
79
84
|
|
|
80
85
|
export function inspectWait(query, inspect, args) {
|
|
81
86
|
const element = query(args.selector, args.selector_type ?? 'css', args.nth, true);
|
|
82
|
-
if (!element) return { satisfied:
|
|
87
|
+
if (!element) return { satisfied: args.condition === 'absent', state: 'absent' };
|
|
88
|
+
if (element.isConnected === false) return { satisfied: false, state: 'detached_during_observation' };
|
|
83
89
|
if (args.condition === 'attached') return { satisfied: true, state: 'attached' };
|
|
90
|
+
if (args.condition === 'absent') return { satisfied: false, state: 'attached' };
|
|
84
91
|
if (args.condition === 'text') {
|
|
85
92
|
const text = element.innerText ?? '';
|
|
86
93
|
return { satisfied: args.previous != null ? text !== args.previous : text.includes(args.expected), text: text.slice(0, 500) };
|
|
87
94
|
}
|
|
95
|
+
if (args.condition === 'value') {
|
|
96
|
+
const text = ['INPUT', 'TEXTAREA', 'SELECT'].includes(element.tagName) ? element.value : element.isContentEditable ? element.innerText : undefined;
|
|
97
|
+
// Field values may contain credentials. Report the comparison, not its contents.
|
|
98
|
+
return { satisfied: typeof text === 'string' && text === args.expected, state: typeof text === 'string' ? 'value' : 'unavailable' };
|
|
99
|
+
}
|
|
100
|
+
if (['checked', 'selected', 'expanded'].includes(args.condition)) {
|
|
101
|
+
let state, source = 'property';
|
|
102
|
+
if (args.condition === 'checked' && element.tagName === 'INPUT' && ['checkbox', 'radio'].includes(element.type)) state = element.type === 'checkbox' && element.indeterminate ? 'mixed' : element.checked;
|
|
103
|
+
else if (args.condition === 'selected' && element.tagName === 'OPTION') state = element.selected;
|
|
104
|
+
else if (args.condition === 'expanded' && element.tagName === 'DETAILS') state = element.open;
|
|
105
|
+
else {
|
|
106
|
+
source = `aria-${args.condition}`;
|
|
107
|
+
const attribute = element.getAttribute(source);
|
|
108
|
+
state = attribute === 'true' ? true : attribute === 'false' ? false : args.condition === 'checked' && attribute === 'mixed' ? 'mixed' : undefined;
|
|
109
|
+
}
|
|
110
|
+
return { satisfied: state !== undefined && state === args.expected, state: state === undefined ? 'unavailable' : args.condition, value: state ?? null, source };
|
|
111
|
+
}
|
|
88
112
|
const bounds = inspect.call(element);
|
|
89
113
|
if (args.condition === 'visible') return { ...bounds,
|
|
90
114
|
// Visibility is independent of disabled/inert state.
|
package/package.json
CHANGED
package/src/config.mjs
CHANGED
|
@@ -15,7 +15,7 @@ export const DATA_DIR = configuredHome
|
|
|
15
15
|
: process.platform === 'win32' ? join(homedir(), '.browser-attach')
|
|
16
16
|
: join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'browser-attach');
|
|
17
17
|
export const DEFAULT_CONFIG = join(DATA_DIR, 'config.json');
|
|
18
|
-
export const VERSION = '0.
|
|
18
|
+
export const VERSION = '0.21.0';
|
|
19
19
|
|
|
20
20
|
export async function loadConfig(path = DEFAULT_CONFIG) {
|
|
21
21
|
let config;
|
package/src/mcp.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { AttachClient, ensureHub } from './client.mjs';
|
|
|
5
5
|
import { VERSION } from './config.mjs';
|
|
6
6
|
import { validateFiles } from './upload.mjs';
|
|
7
7
|
import { dragOptions } from '../extension/drag.js';
|
|
8
|
-
import { waitOptions } from '../extension/wait.js';
|
|
8
|
+
import { waitOptions, WAIT_CONDITIONS } from '../extension/wait.js';
|
|
9
9
|
import { scrollOptions } from '../extension/scroll.js';
|
|
10
10
|
import { selectOptions, typeOptions } from '../extension/input.js';
|
|
11
11
|
import { targetOptions } from '../extension/locator.js';
|
|
@@ -13,6 +13,7 @@ import { fetchOptions } from '../extension/fetch.js';
|
|
|
13
13
|
import { clickOptions } from '../extension/click.js';
|
|
14
14
|
import { offsetOption } from '../extension/motion.js';
|
|
15
15
|
import { errorData } from '../extension/errors.js';
|
|
16
|
+
import { validateSteps } from '../extension/batch.js';
|
|
16
17
|
|
|
17
18
|
const text = data => ({ content: [{ type: 'text', text: JSON.stringify(data) }] });
|
|
18
19
|
function integerRange(name, min, max) {
|
|
@@ -43,6 +44,12 @@ export function createMcp(client) {
|
|
|
43
44
|
const offset = { offset: z.object({ x_ratio: z.number().min(0).max(1), y_ratio: z.number().min(0).max(1) }).strict().optional().describe('Native element only: ratios within its content quad; offscreen/covered points fail.') };
|
|
44
45
|
const settle = { settle_ms: timing('settle_ms').optional().describe('Pause after target preparation, before dispatch: fixed ms or uniform [min, max].') };
|
|
45
46
|
const typeDelay = timing('delay_ms').default(0).describe('Between characters: fixed ms or independently sampled uniform [min, max].');
|
|
47
|
+
const waitFields = {
|
|
48
|
+
condition: z.enum(WAIT_CONDITIONS),
|
|
49
|
+
expected: z.union([z.string().max(100000), z.boolean()]).optional().describe('text/url: substring; value: exact string; checked/selected/expanded: boolean (checked also accepts "mixed").'),
|
|
50
|
+
previous: z.string().max(100000).optional().describe('Text only: wait for full rendered innerText to differ; alternative to expected.'),
|
|
51
|
+
idle_ms: integerRange('idle_ms', 100, 10000).default(500),
|
|
52
|
+
};
|
|
46
53
|
function tool(name, description, schema, method, transform) {
|
|
47
54
|
server.registerTool(`browser_${name}`, { description, inputSchema: z.object(schema) }, async args => {
|
|
48
55
|
try {
|
|
@@ -148,9 +155,9 @@ export function createMcp(client) {
|
|
|
148
155
|
...settle, trajectory: z.object({ curve_px: z.number().min(-100).max(100), duration_ms: integerRange('duration_ms', 100, 5000) }).strict().optional().describe('Opt in to curved, variable-speed steps and varied intervals. Signed curve control-point offset in CSS px; duration covers interpolation waits only.'),
|
|
149
156
|
...handle, ...inputTimeout, source: dragTarget, destination: dragTarget, mode: z.enum(['pointer', 'html5']).default('pointer').describe('pointer for mouse-driven dragging; html5 for HTML drag-and-drop.'), steps: integerRange('steps', 2, 100).default(20),
|
|
150
157
|
}, null, args => { dragOptions(args); return args; });
|
|
151
|
-
tool('wait', 'Wait
|
|
158
|
+
tool('wait', 'Wait read-only for a selector to match/be absent, visibility/actionability, rendered text, exact field value, checked/selected/expanded state, URL or network idle. State uses native properties or explicit ARIA attributes; missing state is unavailable, never false. absent means no selector match, not proof of DOM removal for AX role queries. Values are not echoed. Timeout reports the last state; it never makes an earlier action safe to replay.', {
|
|
152
159
|
...handle, ...dialogPolicy, timeout_ms: integerRange('timeout_ms', 100, 30000).default(5000), ...selector, ...frameScope, nth: target.nth,
|
|
153
|
-
|
|
160
|
+
...waitFields,
|
|
154
161
|
}, null, args => { waitOptions(args); return args; });
|
|
155
162
|
tool('select_option', 'Set one HTML select option, including hidden selects, without native UI, using synthetic input/change (events_trusted=false). Disabled/inert targets are refused. Verifies retained selection only.', {
|
|
156
163
|
...handle, ...inputTimeout, ...target, value: z.string().optional().describe('Choose exactly one of value, label or zero-based index; value/label match exactly.'), label: z.string().optional(), index: integerRange('index', 0, Number.MAX_SAFE_INTEGER).optional(),
|
|
@@ -172,10 +179,10 @@ export function createMcp(client) {
|
|
|
172
179
|
z.object({ action: z.literal('hover'), ...stepOptions, ...batchTarget, ...offset }).strict(),
|
|
173
180
|
z.object({ action: z.literal('select_option'), ...stepOptions, ...batchTarget, value: z.string().optional(), label: z.string().optional(), index: integerRange('index', 0, Number.MAX_SAFE_INTEGER).optional() }).strict(),
|
|
174
181
|
z.object({ action: z.literal('scroll'), ...stepOptions, ...selector, ...frameScope, nth: target.nth, ...scrollFields }).strict(),
|
|
175
|
-
z.object({ action: z.literal('wait'), ...stepOptions, ...selector, ...frameScope, nth: target.nth,
|
|
182
|
+
z.object({ action: z.literal('wait'), ...stepOptions, ...selector, ...frameScope, nth: target.nth, ...waitFields }).strict(),
|
|
176
183
|
z.object({ action: z.literal('press_key'), ...stepOptions, key: z.string() }).strict(),
|
|
177
184
|
])).min(1).max(20),
|
|
178
|
-
});
|
|
185
|
+
}, null, args => { validateSteps(args.steps, args.start_at); return args; });
|
|
179
186
|
tool('press_key', 'Send a native key/chord to current focus and verify keydown delivery. Supports F1-F12, navigation/editing keys and US ASCII punctuation. Examples: Control+A, Control++, Shift+/.', { ...handle, ...inputTimeout, key: z.string() });
|
|
180
187
|
tool('scroll', 'Scroll an element container or at viewport x/y (default center). DOM mode emits no wheel events; wheel mode sends native wheel input. until.text runs a bounded search and returns a fresh ref; a miss is inconclusive.', {
|
|
181
188
|
...handle, ...inputTimeout, ...target, ...scrollFields,
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
# 0.15.1 工具效率与设计取舍
|
|
2
|
-
|
|
3
|
-
本次对照 `src/mcp.mjs`、`src/hub.mjs`、扩展实现与现有测试核查九项建议。结论是:返回内容和失败诊断存在可直接改进之处;等待、显式 frame、接管和输入结果边界主要是有意的可靠性策略。不能单凭一次调用的字符数或延迟判定策略有缺陷。题述 BA/PW 对照未在同一页面、版本和序列化口径下重跑,以下不把这些数值当作本次验收结果。
|
|
4
|
-
|
|
5
|
-
## 逐项判断
|
|
6
|
-
|
|
7
|
-
| 建议 | 当前实现与性质 | 通用性、复杂度及本次选择 |
|
|
8
|
-
|---|---|---|
|
|
9
|
-
| 1. 默认精简、interactive、delta | full 同时服务阅读与交互,是覆盖面策略;每页重复规则和固定诊断元数据则是输出冗余。input/evaluate 使 cursor 失效,但仍连接且语义未变的 refs 可复用,并非每次输入都必须重新 snapshot。 | 精简通用且成本低,已移除重复说明,viewport/snapshot_id 改为 verbose 可选。保留 full 默认与结果验证字段。interactive 已有,按任务选择。delta 需处理新旧观察、删除/替换、祖先上下文和子 frame 失效,复杂度高,本次不引入。 |
|
|
10
|
-
| 2. AX 索引查询 | 当前只能把树传给客户端后查找,确有返回量缺口;read_text 不能替代可操作 ref。 | 已在现有 snapshot 加 query,复用渲染、ref、frame 和分页,工具仍为 23 个。查询新采集 AX,不引入可能过期的第二份索引缓存。仅支持字面子串;正则及独立 find 工具暂不增加。能省返回内容和一次往返,不能保证 AX 采集变快。 |
|
|
11
|
-
| 3. 日志摘要与明细 | 当前已有 after_seq、limit、max_chars、level、url_contains 与有界缓冲;默认不保存 headers/body,必须显式启用 capture。网络输出是 request/redirect/response/finished 等事件,并非一条完整请求对象。 | 请求摘要具有通用价值,但正确合并需要处理 session/request_id/hop、未完成请求、分页与淘汰;单次投影也不能自然变成完整请求的耗时列表。seq 明细可复用 logs 扩展,无须先增加工具。默认隐藏静态资源会漏掉 CSS/脚本故障,本次保留,先用现有过滤与预算。 |
|
|
12
|
-
| 4. 零命中立即失败、诊断原因 | resolveSelector 的零命中等待是支持异步渲染和 batch 前一步创建后一步目标的明确策略;多命中和语法错误已立即失败。deadline 已保留最后的 selector/可见性/尺寸等状态。遮挡分支却只给泛化错误,属于诊断不足。 | 已补实际 hit_target、点和受阻 frame,不增加准备重试或动作重放。普通点击明确未派发;drag 已按住后的目的节点准备失败仍报告结果未知。保留零命中等待,紧预算可用 timeout_ms(最小 100 ms),只读查询零命中不等待出现。 |
|
|
13
|
-
| 5. deep-css、自动跨 frame | 当前 CSS/XPath 明确只解析指定文档;ref 可操作 AX 暴露的 Shadow 节点;evaluate(result_type=element) 可显式选取 open shadow 元素,frame URL/title/path 已可逐次解析。属于范围和消歧策略。 | 显式 deep-css 对组件站点可能有用,但要定义复合选择器、遍历顺序、nth 和多 root 歧义,成本中等。跨 frame 再涉及 OOPIF、重复控件、未加载/分离文档和多层命中校验,成本更高。本次保留显式 frame 与现有 ref/element 路径。 |
|
|
14
|
-
| 6. batch 结果传递 | 当前 batch 是预检后的有限输入序列;没有 evaluate 或 drag 步骤,element 步骤只用 selector。因此单加占位符不能实现“evaluate 测量 → drag”。这是有限编排的边界,也确有限制。 | 动态数据流需增加结果生产步骤、类型/路径检查、替换后的再校验,以及部分完成的恢复规则,复杂度中高。原生输入仍不能由页面 JS 调用。常见“定位 → 输入”已可用 selector 或 evaluate 返回元素 ref;坐标测量保留独立检查点,本次不扩展 batch。 |
|
|
15
|
-
| 7. tabs 一站式 | MCP 已把浏览器/标签发现合并为 discover,把 release/activate/close 合并为 tab_control。发现后按本连接观察的 tab_id 独占 claim 是授权模型,激活则是另一个显式意图。 | 客户端可保存 profile 与连接内句柄减少重复 setup。再增加 tabs 聚合入口主要重复现有分支;若自动选标签或激活,反而改变边界。本次不改。工具入口数量与必须经过的授权步骤不能简单等同。 |
|
|
16
|
-
| 8. 渐进披露 schema | 描述已在 0.13.1 分层精简;本次修改前实际 tools/list 紧凑 JSON 为 36701 字符、36703 UTF-8 字节,格式化 JSON 为 75945 字节,正好对应题述约 75.9 KB,23 个工具。两种序列化是同一 schema,不意味着宿主实际省掉一半 token。是否每轮重新计入、是否缓存,取决于 MCP 宿主,仓库不能保证。 | 工具分组对更大工具集有价值,但会新增启动配置、发现差异和支持组合。当前不加 caps 开关;query 复用已有工具,新增少量 schema 描述以保持自描述。包内文档链接不能替代模型调用所需的关键约束。 |
|
|
17
|
-
| 9. 可回放执行记录 | 当前不持久化操作轨迹;batch 调用输入本身就是可保存的步骤 JSON,输出已有部分完成、failed_step/start_at 等恢复信息。缺少长期审计是能力缺口,不是当前执行可靠性缺陷。 | 审计记录与可重新执行的计划应区分:已送达的动作不能因“可回放”自动重发,ref 也不能跨观察/连接复用。默认回传完整 steps 又与节省输出冲突,持久化还会包含表单数据。需要明确的保存/导出需求后再做显式功能,本次不新增存储或回放引擎。 |
|
|
18
|
-
|
|
19
|
-
## 实际改动
|
|
20
|
-
|
|
21
|
-
- `extension/snapshot.js`:query 在当前视图的完整 AX 名称(包括合并正文)或 value 上做大小写不敏感的字面子串匹配,只为匹配行生成 refs。复用原预算和游标;query 绑定观察,零命中返回空结果。
|
|
22
|
-
- `extension/controller.js`:查询仅输出有匹配行的 frame 标题;仍报告不可读或失效的 frame。每个新查询沿用新快照的采集与 ref 生命周期,续页不重复采集。
|
|
23
|
-
- 快照默认不再输出 viewport、snapshot_id 和重复说明;verbose 可恢复前两项。保留 profile/text/text_limit、frame 覆盖与分页字段。click/evaluate 的事件送达、分页或捕获损失等语义字段继续保留。
|
|
24
|
-
- `extension/click.js`:当前 frame 直接复用已有命中诊断;祖先 frame 最多额外读 200 ms,失败时保留原遮挡错误。读取预算已派发标记,防止复用准备逻辑的 drag 被误标为“无输入”。
|
|
25
|
-
- 版本按本次明确要求设为 **0.15.1**。没有新增工具、运行依赖、扩展权限、用户 UI、自动重放或独立状态管理层。
|
|
26
|
-
|
|
27
|
-
使用示例和边界见 [使用指南](USAGE.md),验证结果见 [验证报告](TEST-REPORT.md)。
|