Files
chrome-use/src/snapshot.ts
T
Chris Tate 74be667c80 feat: add cursor-interactive element detection in snapshots (#374)
* fix: only warn about ignored flags when explicitly passed via CLI

The warning about launch-time options being ignored (when daemon is
already running) was incorrectly shown when options were set via
environment variables like AGENT_BROWSER_EXECUTABLE_PATH, even when
no CLI flag was passed.

Now the warning only appears when flags are explicitly passed on the
command line, not when values come solely from environment variables.

Fixes #372

* feat: add cursor-interactive element detection in snapshots

Add -C/--cursor flag to snapshot command that detects clickable elements
that don't have proper ARIA roles but are interactive based on:
- cursor: pointer CSS style
- onclick attribute/handler
- tabindex attribute

This helps with modern web apps that use custom divs/spans as buttons.

Fixes #366

* fix: add cursor option to getSnapshot type signature
2026-02-04 23:44:58 -06:00

619 lines
16 KiB
TypeScript

/**
* Enhanced snapshot with element refs for deterministic element selection.
*
* This module generates accessibility snapshots with embedded refs that can be
* used to click/fill/interact with elements without re-querying the DOM.
*
* Example output:
* - heading "Example Domain" [ref=e1] [level=1]
* - paragraph: Some text content
* - button "Submit" [ref=e2]
* - textbox "Email" [ref=e3]
*
* Usage:
* agent-browser snapshot # Full snapshot
* agent-browser snapshot -i # Interactive elements only
* agent-browser snapshot --depth 3 # Limit depth
* agent-browser click @e2 # Click element by ref
*/
import type { Page, Locator } from 'playwright-core';
export interface RefMap {
[ref: string]: {
selector: string;
role: string;
name?: string;
/** Index for disambiguation when multiple elements have same role+name */
nth?: number;
};
}
export interface EnhancedSnapshot {
tree: string;
refs: RefMap;
}
export interface SnapshotOptions {
/** Only include interactive elements (buttons, links, inputs, etc.) */
interactive?: boolean;
/** Include cursor-interactive elements (cursor:pointer, onclick, tabindex) */
cursor?: boolean;
/** Maximum depth of tree to include (0 = root only) */
maxDepth?: number;
/** Remove structural elements without meaningful content */
compact?: boolean;
/** CSS selector to scope the snapshot */
selector?: string;
}
// Counter for generating refs
let refCounter = 0;
/**
* Reset ref counter (call at start of each snapshot)
*/
export function resetRefs(): void {
refCounter = 0;
}
/**
* Generate next ref ID
*/
function nextRef(): string {
return `e${++refCounter}`;
}
/**
* Roles that are interactive and should get refs
*/
const INTERACTIVE_ROLES = new Set([
'button',
'link',
'textbox',
'checkbox',
'radio',
'combobox',
'listbox',
'menuitem',
'menuitemcheckbox',
'menuitemradio',
'option',
'searchbox',
'slider',
'spinbutton',
'switch',
'tab',
'treeitem',
]);
/**
* Roles that provide structure/context (get refs for text extraction)
*/
const CONTENT_ROLES = new Set([
'heading',
'cell',
'gridcell',
'columnheader',
'rowheader',
'listitem',
'article',
'region',
'main',
'navigation',
]);
/**
* Roles that are purely structural (can be filtered in compact mode)
*/
const STRUCTURAL_ROLES = new Set([
'generic',
'group',
'list',
'table',
'row',
'rowgroup',
'grid',
'treegrid',
'menu',
'menubar',
'toolbar',
'tablist',
'tree',
'directory',
'document',
'application',
'presentation',
'none',
]);
/**
* Build a selector string for storing in ref map
*/
function buildSelector(role: string, name?: string): string {
if (name) {
const escapedName = name.replace(/"/g, '\\"');
return `getByRole('${role}', { name: "${escapedName}", exact: true })`;
}
return `getByRole('${role}')`;
}
/**
* Query the page for clickable elements that might not have proper ARIA roles.
* This finds elements with cursor: pointer or onclick handlers.
*/
async function findCursorInteractiveElements(
page: Page,
selector?: string
): Promise<
Array<{
selector: string;
text: string;
tagName: string;
hasOnClick: boolean;
hasCursorPointer: boolean;
hasTabIndex: boolean;
}>
> {
const rootSelector = selector || 'body';
// Use a string function body to avoid TypeScript transpilation issues
const scriptBody = `(rootSel) => {
const results = [];
// Elements that already have interactive ARIA roles - skip these
const interactiveRoles = new Set([
'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox', 'listbox',
'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'searchbox',
'slider', 'spinbutton', 'switch', 'tab', 'treeitem'
]);
// Tags that are already interactive by default
const interactiveTags = new Set([
'a', 'button', 'input', 'select', 'textarea', 'details', 'summary'
]);
const root = document.querySelector(rootSel) || document.body;
const allElements = root.querySelectorAll('*');
// Build a unique selector for an element
const buildSelector = (el) => {
const testId = el.getAttribute('data-testid');
if (testId) return '[data-testid="' + testId + '"]';
if (el.id) return '#' + CSS.escape(el.id);
const path = [];
let current = el;
while (current && current !== document.body) {
let sel = current.tagName.toLowerCase();
const classes = Array.from(current.classList).filter(c => c.trim());
if (classes.length > 0) sel += '.' + CSS.escape(classes[0]);
const parent = current.parentElement;
if (parent) {
const siblings = Array.from(parent.children);
const matching = siblings.filter(s => {
if (s.tagName !== current.tagName) return false;
if (classes.length > 0 && !s.classList.contains(classes[0])) return false;
return true;
});
if (matching.length > 1) {
const idx = matching.indexOf(current) + 1;
sel += ':nth-of-type(' + idx + ')';
}
}
path.unshift(sel);
current = current.parentElement;
if (path.length >= 3) break;
}
return path.join(' > ');
};
for (const el of allElements) {
const tagName = el.tagName.toLowerCase();
if (interactiveTags.has(tagName)) continue;
const role = el.getAttribute('role');
if (role && interactiveRoles.has(role.toLowerCase())) continue;
const computedStyle = getComputedStyle(el);
const hasCursorPointer = computedStyle.cursor === 'pointer';
const hasOnClick = el.hasAttribute('onclick') || el.onclick !== null;
const tabIndex = el.getAttribute('tabindex');
const hasTabIndex = tabIndex !== null && tabIndex !== '-1';
if (!hasCursorPointer && !hasOnClick && !hasTabIndex) continue;
const text = (el.textContent || '').trim().slice(0, 100);
if (!text) continue;
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) continue;
results.push({
selector: buildSelector(el),
text,
tagName,
hasOnClick,
hasCursorPointer,
hasTabIndex
});
}
return results;
}`;
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const fn = new Function('return ' + scriptBody)();
return page.evaluate(fn, rootSelector);
}
/**
* Get enhanced snapshot with refs and optional filtering
*/
export async function getEnhancedSnapshot(
page: Page,
options: SnapshotOptions = {}
): Promise<EnhancedSnapshot> {
resetRefs();
const refs: RefMap = {};
// Get ARIA snapshot from Playwright
const locator = options.selector ? page.locator(options.selector) : page.locator(':root');
const ariaTree = await locator.ariaSnapshot();
if (!ariaTree) {
return {
tree: '(empty)',
refs: {},
};
}
// Parse and enhance the ARIA tree
const enhancedTree = processAriaTree(ariaTree, refs, options);
// When cursor flag is set, also find cursor-interactive elements
// that may not have proper ARIA roles
if (options.cursor) {
const cursorElements = await findCursorInteractiveElements(page, options.selector);
// Filter out elements whose text is already captured in the snapshot
const existingTexts = new Set(Object.values(refs).map((r) => r.name?.toLowerCase()));
const additionalLines: string[] = [];
for (const el of cursorElements) {
// Skip if text already captured (likely already in ARIA tree)
if (existingTexts.has(el.text.toLowerCase())) continue;
const ref = nextRef();
const role = el.hasCursorPointer ? 'clickable' : el.hasOnClick ? 'clickable' : 'focusable';
refs[ref] = {
selector: el.selector,
role: role,
name: el.text,
};
// Build description of why it's interactive
const hints: string[] = [];
if (el.hasCursorPointer) hints.push('cursor:pointer');
if (el.hasOnClick) hints.push('onclick');
if (el.hasTabIndex) hints.push('tabindex');
additionalLines.push(`- ${role} "${el.text}" [ref=${ref}] [${hints.join(', ')}]`);
}
if (additionalLines.length > 0) {
const separator =
enhancedTree === '(no interactive elements)' ? '' : '\n# Cursor-interactive elements:\n';
const base = enhancedTree === '(no interactive elements)' ? '' : enhancedTree;
return {
tree: base + separator + additionalLines.join('\n'),
refs,
};
}
}
return { tree: enhancedTree, refs };
}
/**
* Track role+name combinations to detect duplicates
*/
interface RoleNameTracker {
counts: Map<string, number>;
/** Maps role+name key to array of ref IDs that use it */
refsByKey: Map<string, string[]>;
getKey(role: string, name?: string): string;
getNextIndex(role: string, name?: string): number;
trackRef(role: string, name: string | undefined, ref: string): void;
/** Get all role+name keys that have duplicates */
getDuplicateKeys(): Set<string>;
}
function createRoleNameTracker(): RoleNameTracker {
const counts = new Map<string, number>();
const refsByKey = new Map<string, string[]>();
return {
counts,
refsByKey,
getKey(role: string, name?: string): string {
return `${role}:${name ?? ''}`;
},
getNextIndex(role: string, name?: string): number {
const key = this.getKey(role, name);
const current = counts.get(key) ?? 0;
counts.set(key, current + 1);
return current;
},
trackRef(role: string, name: string | undefined, ref: string): void {
const key = this.getKey(role, name);
const refs = refsByKey.get(key) ?? [];
refs.push(ref);
refsByKey.set(key, refs);
},
getDuplicateKeys(): Set<string> {
const duplicates = new Set<string>();
for (const [key, refs] of refsByKey) {
if (refs.length > 1) {
duplicates.add(key);
}
}
return duplicates;
},
};
}
/**
* Process ARIA snapshot: add refs and apply filters
*/
function processAriaTree(ariaTree: string, refs: RefMap, options: SnapshotOptions): string {
const lines = ariaTree.split('\n');
const result: string[] = [];
const tracker = createRoleNameTracker();
// For interactive-only mode, we collect just interactive elements
if (options.interactive) {
for (const line of lines) {
const match = line.match(/^(\s*-\s*)(\w+)(?:\s+"([^"]*)")?(.*)$/);
if (!match) continue;
const [, , role, name, suffix] = match;
const roleLower = role.toLowerCase();
if (INTERACTIVE_ROLES.has(roleLower)) {
const ref = nextRef();
const nth = tracker.getNextIndex(roleLower, name);
tracker.trackRef(roleLower, name, ref);
refs[ref] = {
selector: buildSelector(roleLower, name),
role: roleLower,
name,
nth, // Always store nth, we'll use it for duplicates
};
let enhanced = `- ${role}`;
if (name) enhanced += ` "${name}"`;
enhanced += ` [ref=${ref}]`;
// Only show nth in output if it's > 0 (for readability)
if (nth > 0) enhanced += ` [nth=${nth}]`;
if (suffix && suffix.includes('[')) enhanced += suffix;
result.push(enhanced);
}
}
// Post-process: remove nth from refs that don't have duplicates
removeNthFromNonDuplicates(refs, tracker);
return result.join('\n') || '(no interactive elements)';
}
// Normal processing with depth/compact filters
for (const line of lines) {
const processed = processLine(line, refs, options, tracker);
if (processed !== null) {
result.push(processed);
}
}
// Post-process: remove nth from refs that don't have duplicates
removeNthFromNonDuplicates(refs, tracker);
// If compact mode, remove empty structural elements
if (options.compact) {
return compactTree(result.join('\n'));
}
return result.join('\n');
}
/**
* Remove nth from refs that ended up not having duplicates
* This keeps single-element locators simple (no unnecessary .nth(0))
*/
function removeNthFromNonDuplicates(refs: RefMap, tracker: RoleNameTracker): void {
const duplicateKeys = tracker.getDuplicateKeys();
for (const [ref, data] of Object.entries(refs)) {
const key = tracker.getKey(data.role, data.name);
if (!duplicateKeys.has(key)) {
// Not a duplicate, remove nth to keep locator simple
delete refs[ref].nth;
}
}
}
/**
* Get indentation level (number of spaces / 2)
*/
function getIndentLevel(line: string): number {
const match = line.match(/^(\s*)/);
return match ? Math.floor(match[1].length / 2) : 0;
}
/**
* Process a single line: add ref if needed, filter if requested
*/
function processLine(
line: string,
refs: RefMap,
options: SnapshotOptions,
tracker: RoleNameTracker
): string | null {
const depth = getIndentLevel(line);
// Check max depth
if (options.maxDepth !== undefined && depth > options.maxDepth) {
return null;
}
// Match lines like:
// - button "Submit"
// - heading "Title" [level=1]
// - link "Click me":
const match = line.match(/^(\s*-\s*)(\w+)(?:\s+"([^"]*)")?(.*)$/);
if (!match) {
// Metadata lines (like /url:) or text content
if (options.interactive) {
// In interactive mode, only keep metadata under interactive elements
return null;
}
return line;
}
const [, prefix, role, name, suffix] = match;
const roleLower = role.toLowerCase();
// Skip metadata lines (like /url:)
if (role.startsWith('/')) {
return line;
}
const isInteractive = INTERACTIVE_ROLES.has(roleLower);
const isContent = CONTENT_ROLES.has(roleLower);
const isStructural = STRUCTURAL_ROLES.has(roleLower);
// In interactive-only mode, filter non-interactive elements
if (options.interactive && !isInteractive) {
return null;
}
// In compact mode, skip unnamed structural elements
if (options.compact && isStructural && !name) {
return null;
}
// Add ref for interactive or named content elements
const shouldHaveRef = isInteractive || (isContent && name);
if (shouldHaveRef) {
const ref = nextRef();
const nth = tracker.getNextIndex(roleLower, name);
tracker.trackRef(roleLower, name, ref);
refs[ref] = {
selector: buildSelector(roleLower, name),
role: roleLower,
name,
nth, // Always store nth, we'll clean up non-duplicates later
};
// Build enhanced line with ref
let enhanced = `${prefix}${role}`;
if (name) enhanced += ` "${name}"`;
enhanced += ` [ref=${ref}]`;
// Only show nth in output if it's > 0 (for readability)
if (nth > 0) enhanced += ` [nth=${nth}]`;
if (suffix) enhanced += suffix;
return enhanced;
}
return line;
}
/**
* Remove empty structural branches in compact mode
*/
function compactTree(tree: string): string {
const lines = tree.split('\n');
const result: string[] = [];
// Simple pass: keep lines that have content or refs
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Always keep lines with refs
if (line.includes('[ref=')) {
result.push(line);
continue;
}
// Keep lines with text content (after :)
if (line.includes(':') && !line.endsWith(':')) {
result.push(line);
continue;
}
// Check if this structural element has children with refs
const currentIndent = getIndentLevel(line);
let hasRelevantChildren = false;
for (let j = i + 1; j < lines.length; j++) {
const childIndent = getIndentLevel(lines[j]);
if (childIndent <= currentIndent) break;
if (lines[j].includes('[ref=')) {
hasRelevantChildren = true;
break;
}
}
if (hasRelevantChildren) {
result.push(line);
}
}
return result.join('\n');
}
/**
* Parse a ref from command argument (e.g., "@e1" -> "e1")
*/
export function parseRef(arg: string): string | null {
if (arg.startsWith('@')) {
return arg.slice(1);
}
if (arg.startsWith('ref=')) {
return arg.slice(4);
}
if (/^e\d+$/.test(arg)) {
return arg;
}
return null;
}
/**
* Get snapshot statistics
*/
export function getSnapshotStats(
tree: string,
refs: RefMap
): {
lines: number;
chars: number;
tokens: number;
refs: number;
interactive: number;
} {
const interactive = Object.values(refs).filter((r) => INTERACTIVE_ROLES.has(r.role)).length;
return {
lines: tree.split('\n').length,
chars: tree.length,
tokens: Math.ceil(tree.length / 4),
refs: Object.keys(refs).length,
interactive,
};
}