Compare commits

...
Author SHA1 Message Date
Chris Tate 21ea2b02bd address feedback 2026-01-12 00:24:22 -06:00
Chris Tate 0f7d2946af fix locator issues 2026-01-12 00:09:39 -06:00
3 changed files with 202 additions and 25 deletions
+81
View File
@@ -111,6 +111,47 @@ interface SnapshotData {
refs?: Record<string, { role: string; name?: string }>; refs?: Record<string, { role: string; name?: string }>;
} }
/**
* Convert Playwright errors to AI-friendly messages
*/
function toAIFriendlyError(error: unknown, selector: string): Error {
const message = error instanceof Error ? error.message : String(error);
// Handle strict mode violation (multiple elements match)
if (message.includes('strict mode violation')) {
// Extract count if available
const countMatch = message.match(/resolved to (\d+) elements/);
const count = countMatch ? countMatch[1] : 'multiple';
return new Error(
`Selector "${selector}" matched ${count} elements. ` +
`Run 'snapshot' to get updated refs, or use a more specific CSS selector.`
);
}
// Handle element not found
if (
message.includes('waiting for') &&
(message.includes('to be visible') || message.includes('Timeout'))
) {
return new Error(
`Element "${selector}" not found or not visible. ` +
`Run 'snapshot' to see current page elements.`
);
}
// Handle element not interactable
if (message.includes('intercepts pointer events') || message.includes('not visible')) {
return new Error(
`Element "${selector}" is not interactable (may be hidden or covered). ` +
`Try scrolling it into view or check if a modal/overlay is blocking it.`
);
}
// Return original error for unknown cases
return error instanceof Error ? error : new Error(message);
}
/** /**
* Execute a command and return a response * Execute a command and return a response
*/ */
@@ -384,11 +425,15 @@ async function handleClick(command: ClickCommand, browser: BrowserManager): Prom
// Support both refs (@e1) and regular selectors // Support both refs (@e1) and regular selectors
const locator = browser.getLocator(command.selector); const locator = browser.getLocator(command.selector);
try {
await locator.click({ await locator.click({
button: command.button, button: command.button,
clickCount: command.clickCount, clickCount: command.clickCount,
delay: command.delay, delay: command.delay,
}); });
} catch (error) {
throw toAIFriendlyError(error, command.selector);
}
return successResponse(command.id, { clicked: true }); return successResponse(command.id, { clicked: true });
} }
@@ -396,6 +441,7 @@ async function handleClick(command: ClickCommand, browser: BrowserManager): Prom
async function handleType(command: TypeCommand, browser: BrowserManager): Promise<Response> { async function handleType(command: TypeCommand, browser: BrowserManager): Promise<Response> {
const locator = browser.getLocator(command.selector); const locator = browser.getLocator(command.selector);
try {
if (command.clear) { if (command.clear) {
await locator.fill(''); await locator.fill('');
} }
@@ -403,6 +449,9 @@ async function handleType(command: TypeCommand, browser: BrowserManager): Promis
await locator.pressSequentially(command.text, { await locator.pressSequentially(command.text, {
delay: command.delay, delay: command.delay,
}); });
} catch (error) {
throw toAIFriendlyError(error, command.selector);
}
return successResponse(command.id, { typed: true }); return successResponse(command.id, { typed: true });
} }
@@ -556,14 +605,22 @@ async function handleSelect(command: SelectCommand, browser: BrowserManager): Pr
const locator = browser.getLocator(command.selector); const locator = browser.getLocator(command.selector);
const values = Array.isArray(command.values) ? command.values : [command.values]; const values = Array.isArray(command.values) ? command.values : [command.values];
try {
await locator.selectOption(values); await locator.selectOption(values);
} catch (error) {
throw toAIFriendlyError(error, command.selector);
}
return successResponse(command.id, { selected: values }); return successResponse(command.id, { selected: values });
} }
async function handleHover(command: HoverCommand, browser: BrowserManager): Promise<Response> { async function handleHover(command: HoverCommand, browser: BrowserManager): Promise<Response> {
const locator = browser.getLocator(command.selector); const locator = browser.getLocator(command.selector);
try {
await locator.hover(); await locator.hover();
} catch (error) {
throw toAIFriendlyError(error, command.selector);
}
return successResponse(command.id, { hovered: true }); return successResponse(command.id, { hovered: true });
} }
@@ -643,26 +700,42 @@ async function handleWindowNew(
async function handleFill(command: FillCommand, browser: BrowserManager): Promise<Response> { async function handleFill(command: FillCommand, browser: BrowserManager): Promise<Response> {
const locator = browser.getLocator(command.selector); const locator = browser.getLocator(command.selector);
try {
await locator.fill(command.value); await locator.fill(command.value);
} catch (error) {
throw toAIFriendlyError(error, command.selector);
}
return successResponse(command.id, { filled: true }); return successResponse(command.id, { filled: true });
} }
async function handleCheck(command: CheckCommand, browser: BrowserManager): Promise<Response> { async function handleCheck(command: CheckCommand, browser: BrowserManager): Promise<Response> {
const locator = browser.getLocator(command.selector); const locator = browser.getLocator(command.selector);
try {
await locator.check(); await locator.check();
} catch (error) {
throw toAIFriendlyError(error, command.selector);
}
return successResponse(command.id, { checked: true }); return successResponse(command.id, { checked: true });
} }
async function handleUncheck(command: UncheckCommand, browser: BrowserManager): Promise<Response> { async function handleUncheck(command: UncheckCommand, browser: BrowserManager): Promise<Response> {
const locator = browser.getLocator(command.selector); const locator = browser.getLocator(command.selector);
try {
await locator.uncheck(); await locator.uncheck();
} catch (error) {
throw toAIFriendlyError(error, command.selector);
}
return successResponse(command.id, { unchecked: true }); return successResponse(command.id, { unchecked: true });
} }
async function handleUpload(command: UploadCommand, browser: BrowserManager): Promise<Response> { async function handleUpload(command: UploadCommand, browser: BrowserManager): Promise<Response> {
const locator = browser.getLocator(command.selector); const locator = browser.getLocator(command.selector);
const files = Array.isArray(command.files) ? command.files : [command.files]; const files = Array.isArray(command.files) ? command.files : [command.files];
try {
await locator.setInputFiles(files); await locator.setInputFiles(files);
} catch (error) {
throw toAIFriendlyError(error, command.selector);
}
return successResponse(command.id, { uploaded: files }); return successResponse(command.id, { uploaded: files });
} }
@@ -671,13 +744,21 @@ async function handleDoubleClick(
browser: BrowserManager browser: BrowserManager
): Promise<Response> { ): Promise<Response> {
const locator = browser.getLocator(command.selector); const locator = browser.getLocator(command.selector);
try {
await locator.dblclick(); await locator.dblclick();
} catch (error) {
throw toAIFriendlyError(error, command.selector);
}
return successResponse(command.id, { clicked: true }); return successResponse(command.id, { clicked: true });
} }
async function handleFocus(command: FocusCommand, browser: BrowserManager): Promise<Response> { async function handleFocus(command: FocusCommand, browser: BrowserManager): Promise<Response> {
const locator = browser.getLocator(command.selector); const locator = browser.getLocator(command.selector);
try {
await locator.focus(); await locator.focus();
} catch (error) {
throw toAIFriendlyError(error, command.selector);
}
return successResponse(command.id, { focused: true }); return successResponse(command.id, { focused: true });
} }
+11 -3
View File
@@ -95,12 +95,20 @@ export class BrowserManager {
const page = this.getPage(); const page = this.getPage();
// Parse the selector and create locator // Build locator with exact: true to avoid substring matches
let locator: Locator;
if (refData.name) { if (refData.name) {
return page.getByRole(refData.role as any, { name: refData.name }); locator = page.getByRole(refData.role as any, { name: refData.name, exact: true });
} else { } else {
return page.getByRole(refData.role as any); locator = page.getByRole(refData.role as any);
} }
// If an nth index is stored (for disambiguation), use it
if (refData.nth !== undefined) {
locator = locator.nth(refData.nth);
}
return locator;
} }
/** /**
+91 -3
View File
@@ -24,6 +24,8 @@ export interface RefMap {
selector: string; selector: string;
role: string; role: string;
name?: string; name?: string;
/** Index for disambiguation when multiple elements have same role+name */
nth?: number;
}; };
} }
@@ -129,7 +131,7 @@ const STRUCTURAL_ROLES = new Set([
function buildSelector(role: string, name?: string): string { function buildSelector(role: string, name?: string): string {
if (name) { if (name) {
const escapedName = name.replace(/"/g, '\\"'); const escapedName = name.replace(/"/g, '\\"');
return `getByRole('${role}', { name: "${escapedName}" })`; return `getByRole('${role}', { name: "${escapedName}", exact: true })`;
} }
return `getByRole('${role}')`; return `getByRole('${role}')`;
} }
@@ -161,12 +163,60 @@ export async function getEnhancedSnapshot(
return { tree: enhancedTree, 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 * Process ARIA snapshot: add refs and apply filters
*/ */
function processAriaTree(ariaTree: string, refs: RefMap, options: SnapshotOptions): string { function processAriaTree(ariaTree: string, refs: RefMap, options: SnapshotOptions): string {
const lines = ariaTree.split('\n'); const lines = ariaTree.split('\n');
const result: string[] = []; const result: string[] = [];
const tracker = createRoleNameTracker();
// For interactive-only mode, we collect just interactive elements // For interactive-only mode, we collect just interactive elements
if (options.interactive) { if (options.interactive) {
@@ -179,31 +229,43 @@ function processAriaTree(ariaTree: string, refs: RefMap, options: SnapshotOption
if (INTERACTIVE_ROLES.has(roleLower)) { if (INTERACTIVE_ROLES.has(roleLower)) {
const ref = nextRef(); const ref = nextRef();
const nth = tracker.getNextIndex(roleLower, name);
tracker.trackRef(roleLower, name, ref);
refs[ref] = { refs[ref] = {
selector: buildSelector(roleLower, name), selector: buildSelector(roleLower, name),
role: roleLower, role: roleLower,
name, name,
nth, // Always store nth, we'll use it for duplicates
}; };
let enhanced = `- ${role}`; let enhanced = `- ${role}`;
if (name) enhanced += ` "${name}"`; if (name) enhanced += ` "${name}"`;
enhanced += ` [ref=${ref}]`; 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; if (suffix && suffix.includes('[')) enhanced += suffix;
result.push(enhanced); result.push(enhanced);
} }
} }
// Post-process: remove nth from refs that don't have duplicates
removeNthFromNonDuplicates(refs, tracker);
return result.join('\n') || '(no interactive elements)'; return result.join('\n') || '(no interactive elements)';
} }
// Normal processing with depth/compact filters // Normal processing with depth/compact filters
for (const line of lines) { for (const line of lines) {
const processed = processLine(line, refs, options); const processed = processLine(line, refs, options, tracker);
if (processed !== null) { if (processed !== null) {
result.push(processed); 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 compact mode, remove empty structural elements
if (options.compact) { if (options.compact) {
return compactTree(result.join('\n')); return compactTree(result.join('\n'));
@@ -212,6 +274,22 @@ function processAriaTree(ariaTree: string, refs: RefMap, options: SnapshotOption
return 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) * Get indentation level (number of spaces / 2)
*/ */
@@ -223,7 +301,12 @@ function getIndentLevel(line: string): number {
/** /**
* Process a single line: add ref if needed, filter if requested * Process a single line: add ref if needed, filter if requested
*/ */
function processLine(line: string, refs: RefMap, options: SnapshotOptions): string | null { function processLine(
line: string,
refs: RefMap,
options: SnapshotOptions,
tracker: RoleNameTracker
): string | null {
const depth = getIndentLevel(line); const depth = getIndentLevel(line);
// Check max depth // Check max depth
@@ -273,17 +356,22 @@ function processLine(line: string, refs: RefMap, options: SnapshotOptions): stri
if (shouldHaveRef) { if (shouldHaveRef) {
const ref = nextRef(); const ref = nextRef();
const nth = tracker.getNextIndex(roleLower, name);
tracker.trackRef(roleLower, name, ref);
refs[ref] = { refs[ref] = {
selector: buildSelector(roleLower, name), selector: buildSelector(roleLower, name),
role: roleLower, role: roleLower,
name, name,
nth, // Always store nth, we'll clean up non-duplicates later
}; };
// Build enhanced line with ref // Build enhanced line with ref
let enhanced = `${prefix}${role}`; let enhanced = `${prefix}${role}`;
if (name) enhanced += ` "${name}"`; if (name) enhanced += ` "${name}"`;
enhanced += ` [ref=${ref}]`; enhanced += ` [ref=${ref}]`;
// Only show nth in output if it's > 0 (for readability)
if (nth > 0) enhanced += ` [nth=${nth}]`;
if (suffix) enhanced += suffix; if (suffix) enhanced += suffix;
return enhanced; return enhanced;