首页
/ gstack browse:snapshot -i 如何补全下拉框/Popover 等无 ARIA 角色的可交互元素检测

gstack browse:snapshot -i 如何补全下拉框/Popover 等无 ARIA 角色的可交互元素检测

2026-09-05 16:31:43作者:舒璇辛Bertina

本篇指南基于 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 中:

  1. page.locator(scope).ariaSnapshot() 得到 YAML 风格的无障碍树;
  2. 解析树、分配 @e 引用;
  3. 为每个引用构建 Playwright Locator(getByRole + nth 消歧);
  4. 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 一节):

  1. 通常是带 onclick 处理器的 <div>/<li>没有语义化的 ARIA 角色
  2. 位于动态创建的 portal/popover(浮动容器)内;
  3. 因此根本不会出现在 Playwright 的无障碍树ariaSnapshot() 输出)中。

根本原因:无障碍树的三个盲区

ariaSnapshot() 的数据来源是浏览器的无障碍树。动态渲染的 popover(React Portal、Radix Popover 等)可能不在其中,常见于以下情形:

  • 组件没有设置 ARIA role;
  • portal 渲染在 scoped body locator 子树的时序之外;
  • 浏览器尚未在 DOM 变更后更新无障碍树。

原有 -C 扫描的三个不足

-C--cursor-interactive)扫描最初就是为这类元素设计的——扫描 cursor:pointeronclicktabindex 元素并分配 @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.tsskill-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: fixedposition: absolutez-index >= 10
  • 带有 role="listbox"role="menu"role="dialog"role="tooltip"[data-radix-popper-content-wrapper][data-floating-ui-portal] 等属性;
  • 近期才出现在 DOM 中(非初始页面加载);
  • 可见(offsetParent !== nullposition: 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 方案。

整个扫描循环的元素筛选逻辑(完整实现)为:

检查项 规则
标准交互标签 ABUTTONINPUTSELECTTEXTAREASUMMARYDETAILS 直接跳过(已在 ARIA 树中)
可见性 offsetParent 为空且非 BODY 的元素跳过
交互信号 cursor: pointeronclick 属性、tabindex >= 0 三者之一
浮动容器内特例 即使没有上述信号,只要 roleoption/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 contextclosedTargetContent 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-portalposition: fixedz-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.htmlcursor: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” 清单,仓库中三处改动全部落地:

  1. browse/src/snapshot.ts——-i 自动启用 -CL245-L247)、浮动容器识别与 popover-child 标记(L270-L321)、浮动容器内移除 hasRole 跳过(L300);
  2. browse/test/fixtures/dropdown.html——新增下拉/portal 测试夹具;
  3. browse/test/snapshot.test.ts——新增 Dropdown/popover 测试分组。

这套机制的价值在于让 Agent 浏览现代 Web 应用(大量使用 Radix、floating-ui 等 portal 方案的 React/Vue 前端)时,一次 snapshot -i 就能同时拿到 ARIA 树引用(@e)与非 ARIA 可点击元素引用(@c),且浮动容器内的元素即使带 role 也不漏网、页面正文又不产生重复——检测从“单一无障碍树”变为“无障碍树 + 光标/结构启发式扫描”的双通道互补。

登录后查看全文
热门项目推荐
相关项目推荐