mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-23 14:32:33 +02:00
Initial release — gstack v0.0.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Snapshot command — accessibility tree with ref-based element selection
|
||||
*
|
||||
* Architecture (Locator map — no DOM mutation):
|
||||
* 1. page.locator(scope).ariaSnapshot() → YAML-like accessibility tree
|
||||
* 2. Parse tree, assign refs @e1, @e2, ...
|
||||
* 3. Build Playwright Locator for each ref (getByRole + nth)
|
||||
* 4. Store Map<string, Locator> on BrowserManager
|
||||
* 5. Return compact text output with refs prepended
|
||||
*
|
||||
* Later: "click @e3" → look up Locator → locator.click()
|
||||
*/
|
||||
|
||||
import type { Page, Locator } from 'playwright';
|
||||
import type { BrowserManager } from './browser-manager';
|
||||
|
||||
// Roles considered "interactive" for the -i flag
|
||||
const INTERACTIVE_ROLES = new Set([
|
||||
'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox',
|
||||
'listbox', 'menuitem', 'menuitemcheckbox', 'menuitemradio',
|
||||
'option', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab',
|
||||
'treeitem',
|
||||
]);
|
||||
|
||||
interface SnapshotOptions {
|
||||
interactive?: boolean; // -i: only interactive elements
|
||||
compact?: boolean; // -c: remove empty structural elements
|
||||
depth?: number; // -d N: limit tree depth
|
||||
selector?: string; // -s SEL: scope to CSS selector
|
||||
}
|
||||
|
||||
interface ParsedNode {
|
||||
indent: number;
|
||||
role: string;
|
||||
name: string | null;
|
||||
props: string; // e.g., "[level=1]"
|
||||
children: string; // inline text content after ":"
|
||||
rawLine: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CLI args into SnapshotOptions
|
||||
*/
|
||||
export function parseSnapshotArgs(args: string[]): SnapshotOptions {
|
||||
const opts: SnapshotOptions = {};
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
case '-i':
|
||||
case '--interactive':
|
||||
opts.interactive = true;
|
||||
break;
|
||||
case '-c':
|
||||
case '--compact':
|
||||
opts.compact = true;
|
||||
break;
|
||||
case '-d':
|
||||
case '--depth':
|
||||
opts.depth = parseInt(args[++i], 10);
|
||||
if (isNaN(opts.depth!)) throw new Error('Usage: snapshot -d <number>');
|
||||
break;
|
||||
case '-s':
|
||||
case '--selector':
|
||||
opts.selector = args[++i];
|
||||
if (!opts.selector) throw new Error('Usage: snapshot -s <selector>');
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown snapshot flag: ${args[i]}`);
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one line of ariaSnapshot output.
|
||||
*
|
||||
* Format examples:
|
||||
* - heading "Test" [level=1]
|
||||
* - link "Link A":
|
||||
* - /url: /a
|
||||
* - textbox "Name"
|
||||
* - paragraph: Some text
|
||||
* - combobox "Role":
|
||||
*/
|
||||
function parseLine(line: string): ParsedNode | null {
|
||||
// Match: (indent)(- )(role)( "name")?( [props])?(: inline)?
|
||||
const match = line.match(/^(\s*)-\s+(\w+)(?:\s+"([^"]*)")?(?:\s+(\[.*?\]))?\s*(?::\s*(.*))?$/);
|
||||
if (!match) {
|
||||
// Skip metadata lines like "- /url: /a"
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
indent: match[1].length,
|
||||
role: match[2],
|
||||
name: match[3] ?? null,
|
||||
props: match[4] || '',
|
||||
children: match[5]?.trim() || '',
|
||||
rawLine: line,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Take an accessibility snapshot and build the ref map.
|
||||
*/
|
||||
export async function handleSnapshot(
|
||||
args: string[],
|
||||
bm: BrowserManager
|
||||
): Promise<string> {
|
||||
const opts = parseSnapshotArgs(args);
|
||||
const page = bm.getPage();
|
||||
|
||||
// Get accessibility tree via ariaSnapshot
|
||||
let rootLocator: Locator;
|
||||
if (opts.selector) {
|
||||
rootLocator = page.locator(opts.selector);
|
||||
const count = await rootLocator.count();
|
||||
if (count === 0) throw new Error(`Selector not found: ${opts.selector}`);
|
||||
} else {
|
||||
rootLocator = page.locator('body');
|
||||
}
|
||||
|
||||
const ariaText = await rootLocator.ariaSnapshot();
|
||||
if (!ariaText || ariaText.trim().length === 0) {
|
||||
bm.setRefMap(new Map());
|
||||
return '(no accessible elements found)';
|
||||
}
|
||||
|
||||
// Parse the ariaSnapshot output
|
||||
const lines = ariaText.split('\n');
|
||||
const refMap = new Map<string, Locator>();
|
||||
const output: string[] = [];
|
||||
let refCounter = 1;
|
||||
|
||||
// Track role+name occurrences for nth() disambiguation
|
||||
const roleNameCounts = new Map<string, number>();
|
||||
const roleNameSeen = new Map<string, number>();
|
||||
|
||||
// First pass: count role+name pairs for disambiguation
|
||||
for (const line of lines) {
|
||||
const node = parseLine(line);
|
||||
if (!node) continue;
|
||||
const key = `${node.role}:${node.name || ''}`;
|
||||
roleNameCounts.set(key, (roleNameCounts.get(key) || 0) + 1);
|
||||
}
|
||||
|
||||
// Second pass: assign refs and build locators
|
||||
for (const line of lines) {
|
||||
const node = parseLine(line);
|
||||
if (!node) continue;
|
||||
|
||||
const depth = Math.floor(node.indent / 2);
|
||||
const isInteractive = INTERACTIVE_ROLES.has(node.role);
|
||||
|
||||
// Depth filter
|
||||
if (opts.depth !== undefined && depth > opts.depth) continue;
|
||||
|
||||
// Interactive filter: skip non-interactive but still count for locator indices
|
||||
if (opts.interactive && !isInteractive) {
|
||||
// Still track for nth() counts
|
||||
const key = `${node.role}:${node.name || ''}`;
|
||||
roleNameSeen.set(key, (roleNameSeen.get(key) || 0) + 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compact filter: skip elements with no name and no inline content that aren't interactive
|
||||
if (opts.compact && !isInteractive && !node.name && !node.children) continue;
|
||||
|
||||
// Assign ref
|
||||
const ref = `e${refCounter++}`;
|
||||
const indent = ' '.repeat(depth);
|
||||
|
||||
// Build Playwright locator
|
||||
const key = `${node.role}:${node.name || ''}`;
|
||||
const seenIndex = roleNameSeen.get(key) || 0;
|
||||
roleNameSeen.set(key, seenIndex + 1);
|
||||
const totalCount = roleNameCounts.get(key) || 1;
|
||||
|
||||
let locator: Locator;
|
||||
if (opts.selector) {
|
||||
locator = page.locator(opts.selector).getByRole(node.role as any, {
|
||||
name: node.name || undefined,
|
||||
});
|
||||
} else {
|
||||
locator = page.getByRole(node.role as any, {
|
||||
name: node.name || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Disambiguate with nth() if multiple elements share role+name
|
||||
if (totalCount > 1) {
|
||||
locator = locator.nth(seenIndex);
|
||||
}
|
||||
|
||||
refMap.set(ref, locator);
|
||||
|
||||
// Format output line
|
||||
let outputLine = `${indent}@${ref} [${node.role}]`;
|
||||
if (node.name) outputLine += ` "${node.name}"`;
|
||||
if (node.props) outputLine += ` ${node.props}`;
|
||||
if (node.children) outputLine += `: ${node.children}`;
|
||||
|
||||
output.push(outputLine);
|
||||
}
|
||||
|
||||
// Store ref map on BrowserManager
|
||||
bm.setRefMap(refMap);
|
||||
|
||||
if (output.length === 0) {
|
||||
return '(no interactive elements found)';
|
||||
}
|
||||
|
||||
return output.join('\n');
|
||||
}
|
||||
Reference in New Issue
Block a user