gstack browse:snapshot -i 如何补全下拉框/Popover 等无 ARIA 角色的可交互元素检测
本篇指南基于 gstack 仓库中的设计文档 PLAN-snapshot-dropdown-interactive.md,完整解析 browse 技能 snapshot 命令在检测下拉框/自动补全等现代 Web 应用交互元素时的缺陷成因与修复方案:从 Playwright 无障碍树(accessibility tree)的盲区、-C 光标扫描与 -i 交互模式的自动联动,到浮动容器(portal/popover)优先级扫描与 hasRole 跳过逻辑的修正。读完后你能掌握:为什么 React 门户(Portal)、Radix Popover 等动态渲染的下拉项会同时漏掉两套检测系统,以及 snapshot.ts 中实际落地的检测启发式、引用(ref)体系与测试验证方式。
问题背景:snapshot -i 为什么漏检下拉框项
browse 技能的 snapshot 命令通过 Playwright 的 ariaSnapshot() 构建页面的无障碍树,为每个元素分配 @e1、@e2 这类引用(ref),供 Agent 后续执行 click @e3 等操作。其核心流程在 snapshot.ts 中:
page.locator(scope).ariaSnapshot()得到 YAML 风格的无障碍树;- 解析树、分配
@e引用; - 为每个引用构建 Playwright Locator(
getByRole+nth消歧); - 将
Map<string, Locator>存入会话,输出带 ref 的紧凑文本。
-i(--interactive)模式只保留交互类元素,判断依据是 INTERACTIVE_ROLES 集合中的角色:
const INTERACTIVE_ROLES = new Set([
'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox',
'listbox', 'menuitem', 'menuitemcheckbox', 'menuitemradio',
'option', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab',
'treeitem',
]);
但在现代 Web 应用中,下拉框/自动补全项经常被漏掉。设计文档指出这类元素有三个典型特征(Problem 一节):
- 通常是带
onclick处理器的<div>/<li>,没有语义化的 ARIA 角色; - 位于动态创建的 portal/popover(浮动容器)内;
- 因此根本不会出现在 Playwright 的无障碍树(
ariaSnapshot()输出)中。
根本原因:无障碍树的三个盲区
ariaSnapshot() 的数据来源是浏览器的无障碍树。动态渲染的 popover(React Portal、Radix Popover 等)可能不在其中,常见于以下情形:
- 组件没有设置 ARIA role;
- portal 渲染在 scoped
bodylocator 子树的时序之外; - 浏览器尚未在 DOM 变更后更新无障碍树。
原有 -C 扫描的三个不足
-C(--cursor-interactive)扫描最初就是为这类元素设计的——扫描 cursor:pointer、onclick、tabindex 元素并分配 @c 引用。但它有三个问题:
- 需要单独传标志位:使用
-i的 Agent 默认拿不到它; - 会跳过带有 ARIA role 的元素(
if (hasRole) continue;),假设无障碍树已覆盖它们——恰恰在无障碍树漏掉这些元素时,它们会同时从两套系统漏网; - 没有对下拉项所在的 popover/portal 容器做优先级处理。
修复一:-i 自动启用光标扫描
设计文档的第一项改动:传入 -i 时自动包含 cursor-interactive 扫描,使 Agent 请求交互元素时总能同时看到可点击的非 ARIA 元素;-C 仍保留为独立选项,用于非交互模式下的完整快照。
这一改动已在源码中落地,位于 handleSnapshot 的 cursor 扫描入口:
// ─── Cursor-interactive scan (-C, or auto with -i) ────────
// Auto-enable cursor scan when interactive mode is on — agents asking for
// interactive elements should always see clickable non-ARIA items too.
if (opts.interactive && !opts.cursorInteractive) {
opts.cursorInteractive = true;
}
配套的标志元数据也同步更新。SNAPSHOT_FLAGS 是 CLI 解析与文档生成的单一事实来源(被 gen-skill-docs.ts 和 skill-parser.ts 导入),其中 -i 与 -C 的描述均已注明联动关系:
{ short: '-i', long: '--interactive', description: 'Interactive elements only (buttons, links, inputs) with @e refs. Also auto-enables cursor-interactive scan (-C) to capture dropdowns and popovers.', optionKey: 'interactive' },
...
{ short: '-C', long: '--cursor-interactive', description: 'Cursor-interactive elements (@c refs — divs with pointer, onclick). Auto-enabled when -i is used.', optionKey: 'cursorInteractive' },
命令行侧的命令注册(commands.ts)也把 -C 列入 snapshot 的完整标志清单:-i interactive only, -c compact, -d N depth limit, -s sel scope, -D diff, -a annotated screenshot, -o path output, -C cursor-interactive @c refs。
参数解析由 parseSnapshotArgs 基于 SNAPSHOT_FLAGS 元数据驱动:未知标志直接抛 Unknown snapshot flag 错误(有对应测试守护),带值标志(-d、-s、-o、-H)消费下一个参数,纯开关类标志置 true。
修复二:浮动容器(Popover/Portal)优先级扫描
设计文档的第二项改动:在 cursor-interactive 的 page.evaluate() 块中,优先检测可见的浮动容器(popover、下拉框、菜单),并把这些容器的子元素纳入交互元素。文档给出的浮动容器检测启发式:
position: fixed或position: absolute且z-index >= 10;- 带有
role="listbox"、role="menu"、role="dialog"、role="tooltip"、[data-radix-popper-content-wrapper]、[data-floating-ui-portal]等属性; - 近期才出现在 DOM 中(非初始页面加载);
- 可见(
offsetParent !== null或position: fixed)。
源码实现(evaluate 块内的 isInFloating 判定)采用了前两条与可见性的组合判定,通过向上遍历祖先链识别浮动容器:
// Check if element is inside a floating container (portal/popover/dropdown)
const isInFloating = (() => {
let parent: Element | null = el;
while (parent && parent !== document.documentElement) {
const pStyle = getComputedStyle(parent);
const isFloating = (pStyle.position === 'fixed' || pStyle.position === 'absolute') &&
parseInt(pStyle.zIndex || '0', 10) >= 10;
const hasPortalAttr = parent.hasAttribute('data-floating-ui-portal') ||
parent.hasAttribute('data-radix-popper-content-wrapper') ||
parent.hasAttribute('data-radix-portal') ||
parent.hasAttribute('data-popper-placement') ||
parent.getAttribute('role') === 'listbox' ||
parent.getAttribute('role') === 'menu';
if (isFloating || hasPortalAttr) return true;
parent = parent.parentElement;
}
return false;
})();
可以推断,实现与文档略有取舍:文档中“近期才出现在 DOM 中”这条启发式较难在单次 evaluate 内可靠实现,源码以“portal 属性白名单 + 高 z-index 浮动定位”替代,覆盖 Radix、floating-ui 两大主流 portal 方案。
整个扫描循环的元素筛选逻辑(完整实现)为:
| 检查项 | 规则 |
|---|---|
| 标准交互标签 | A、BUTTON、INPUT、SELECT、TEXTAREA、SUMMARY、DETAILS 直接跳过(已在 ARIA 树中) |
| 可见性 | offsetParent 为空且非 BODY 的元素跳过 |
| 交互信号 | cursor: pointer、onclick 属性、tabindex >= 0 三者之一 |
| 浮动容器内特例 | 即使没有上述信号,只要 role 为 option/menuitem/menuitemcheckbox/menuitemradio 也收录 |
| ARIA role 跳过 | 仅在非浮动容器内跳过带 role 的元素(见修复三) |
对每个命中的元素,扫描会沿祖先链向上构建确定性的 nth-child CSS 路径作为定位器(selector 构建),并生成 reason 标签——位于浮动容器内的元素会带上 popover-child 前缀:
const reasons: string[] = [];
if (isInFloating) reasons.push('popover-child');
if (hasCursorPointer) reasons.push('cursor:pointer');
if (hasOnclick) reasons.push('onclick');
if (hasTabindex) reasons.push(`tabindex=${el.getAttribute('tabindex')}`);
if (hasRole) reasons.push(`role=${el.getAttribute('role')}`);
命中结果以独立区块输出,每个元素分配递增的 @c 引用并存入 refMap(输出段):
── cursor-interactive (not in ARIA tree) ──
@c1 [popover-child, cursor:pointer, onclick] "Alice Johnson - Acme Corp"
一个细节:光标扫描包在 try/catch 中,对 CSP 严格或页面已导航的场景(错误消息含 Execution context、closed、Target、Content Security)降级为 (cursor scan failed — CSP restriction) 提示而不是整体报错(错误处理)。
修复三:浮动容器内移除 hasRole 跳过
原实现中 if (hasRole) continue; 会跳过任何带 ARIA 角色的元素,假设无障碍树已捕获它。问题在于:当无障碍树因时序、portal 或 DOM 结构问题漏掉了该元素,它就从两套系统同时漏网。
设计文档给出的折中方案(由于 page.evaluate() 内无法直接访问 refMap):浮动容器内的元素完全移除 hasRole 跳过;容器外保持原跳过逻辑以避免与 ARIA 树重复。源码落地为一条条件(snapshot.ts#L298-L300):
// Skip elements with ARIA roles UNLESS they're inside a floating container
// (floating container items may be missed by the accessibility tree)
if (hasRole && !isInFloating) continue;
即:带 role 的元素只有在浮动容器内才会被收录,此时若它连 cursor:pointer/onclick/tabindex 都没有,则进一步要求 role 是四个选项类角色之一(前文筛选逻辑中的 isInFloating && hasRole 分支)。普通页面正文中重复的按钮、链接仍然被跳过,@e 引用不会出现两份。
测试:dropdown.html 夹具与测试用例
设计文档的第四项改动是新增测试夹具与用例,二者均已在仓库中落地。
夹具 dropdown.html
该页面模拟了一个搜索输入 + 浮动 portal 下拉框,与文档描述完全对应:
<input>搜索框(aria-label="Search");- 标记
data-floating-ui-portal、position: fixed、z-index: 9999的 portal 容器,注释标明“Simulates a React portal / floating-ui popover”; - 三个带
onclick但无 ARIA 角色的<div class="dropdown-item">选项(Alice/Bob/Carol)——最常见的真实模式; - 一个带
role="option"的选项(Dave Wilson),用于验证修复三; - 一段无
cursor:pointer、无onclick的静态提示文本("No results? Try a different search."),作为不应被收录的负例; - 页面上的标准
<button>/<a>对照,验证 ARIA 树路径不受影响。
选项的 selectItem() 点击处理器会把值写入输入框并隐藏 portal,供“@c 引用可点击”测试验证真实点击行为。
测试用例 snapshot.test.ts
Dropdown/popover detection 分组(L452-L507)覆盖了文档规划的全部四类断言:
describe('Dropdown/popover detection', () => {
test('snapshot -i auto-enables cursor scan and finds dropdown items', ...)
// 断言同时出现 [button]、[link]、[textbox]、cursor-interactive、@c、
// "Alice Johnson"、"Bob Smith"
test('dropdown items in floating container are tagged as popover-child', ...)
// 断言输出包含 'popover-child'
test('dropdown items with role="option" in portal are captured', ...)
// 断言带 role="option" 的 "Dave Wilson" 被收录(ARIA 树盲区修复)
test('static text in dropdown without interactivity is NOT captured', ...)
// 负例:断言不包含 'No results'
test('@c ref from dropdown is clickable', ...)
// 从快照中解析 Alice 的 @c 引用,执行 click @cN,断言 'Clicked'
test('snapshot -C still works standalone without -i', ...)
// -C 单独使用时仍能扫出下拉项,且保留非交互 ARIA 元素([heading])
});
此外,Cursor-interactive 分组(L387-L448)验证了联动行为的关键回归点:snapshot -i alone also includes cursor-interactive elements 断言只传 -i 时输出中既含 [button]/[link] 又含 cursor-interactive 区块和 @c 引用;配合夹具 cursor-interactive.html(cursor:pointer 的 div/span、tabindex div、onclick div,以及不应出现的标准按钮/链接)验证各类信号的独立生效。
设计文档给出的验证命令(仓库内实际路径为 browse 目录):
cd browse && bun test snapshot
风险与性能权衡
设计文档将本次改动评估为低风险,理由有二:
- 增量而非替换:
-C扫描只新增@c引用,从不删除@e引用;-i自动启用它只增大输出体积,而 Agent 已习惯混合引用类型; - 性能边界:
-C扫描遍历document.querySelectorAll('*'),在重页面上可能较慢;而 popover 专属的判定只发生在检测到浮动容器之后,遍历的是小个子树,开销可控。这一点在源码中对应 STANDARD_INTERACTIVE 提前跳过 与可见性过滤,减少了对标准交互元素与隐藏元素的计算量。
小结:三层防御下的可交互元素检测
对照 PLAN 文档 的 “Files Changed” 清单,仓库中三处改动全部落地:
- browse/src/snapshot.ts——
-i自动启用-C(L245-L247)、浮动容器识别与popover-child标记(L270-L321)、浮动容器内移除hasRole跳过(L300); - browse/test/fixtures/dropdown.html——新增下拉/portal 测试夹具;
- browse/test/snapshot.test.ts——新增 Dropdown/popover 测试分组。
这套机制的价值在于让 Agent 浏览现代 Web 应用(大量使用 Radix、floating-ui 等 portal 方案的 React/Vue 前端)时,一次 snapshot -i 就能同时拿到 ARIA 树引用(@e)与非 ARIA 可点击元素引用(@c),且浮动容器内的元素即使带 role 也不漏网、页面正文又不产生重复——检测从“单一无障碍树”变为“无障碍树 + 光标/结构启发式扫描”的双通道互补。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00