Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d84a769949 | ||
|
|
343aa1f723 | ||
|
|
6da48c6903 |
+186
-686
File diff suppressed because it is too large
Load Diff
+8
-17
@@ -8,7 +8,7 @@ use serde_json::json;
|
|||||||
use std::env;
|
use std::env;
|
||||||
use std::process::exit;
|
use std::process::exit;
|
||||||
|
|
||||||
use commands::{gen_id, parse_command, ParseError};
|
use commands::{gen_id, parse_command};
|
||||||
use connection::{ensure_daemon, send_command};
|
use connection::{ensure_daemon, send_command};
|
||||||
use flags::{clean_args, parse_flags};
|
use flags::{clean_args, parse_flags};
|
||||||
use install::run_install;
|
use install::run_install;
|
||||||
@@ -32,22 +32,13 @@ fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cmd = match parse_command(&clean, &flags) {
|
let cmd = match parse_command(&clean, &flags) {
|
||||||
Ok(c) => c,
|
Some(c) => c,
|
||||||
Err(e) => {
|
None => {
|
||||||
if flags.json {
|
eprintln!(
|
||||||
let error_type = match &e {
|
"\x1b[31mUnknown command:\x1b[0m {}",
|
||||||
ParseError::UnknownCommand { .. } => "unknown_command",
|
clean.get(0).unwrap_or(&String::new())
|
||||||
ParseError::UnknownSubcommand { .. } => "unknown_subcommand",
|
);
|
||||||
ParseError::MissingArguments { .. } => "missing_arguments",
|
eprintln!("\x1b[2mRun: agent-browser --help\x1b[0m");
|
||||||
};
|
|
||||||
println!(
|
|
||||||
r#"{{"success":false,"error":"{}","type":"{}"}}"#,
|
|
||||||
e.format().replace('\n', " "),
|
|
||||||
error_type
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
eprintln!("\x1b[31m{}\x1b[0m", e.format());
|
|
||||||
}
|
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+19
-100
@@ -111,47 +111,6 @@ 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
|
||||||
*/
|
*/
|
||||||
@@ -425,15 +384,11 @@ 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 });
|
||||||
}
|
}
|
||||||
@@ -441,18 +396,14 @@ 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('');
|
|
||||||
}
|
|
||||||
|
|
||||||
await locator.pressSequentially(command.text, {
|
|
||||||
delay: command.delay,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
throw toAIFriendlyError(error, command.selector);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await locator.pressSequentially(command.text, {
|
||||||
|
delay: command.delay,
|
||||||
|
});
|
||||||
|
|
||||||
return successResponse(command.id, { typed: true });
|
return successResponse(command.id, { typed: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,22 +556,14 @@ 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 });
|
||||||
}
|
}
|
||||||
@@ -700,42 +643,26 @@ 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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -744,21 +671,13 @@ 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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-11
@@ -95,20 +95,12 @@ export class BrowserManager {
|
|||||||
|
|
||||||
const page = this.getPage();
|
const page = this.getPage();
|
||||||
|
|
||||||
// Build locator with exact: true to avoid substring matches
|
// Parse the selector and create locator
|
||||||
let locator: Locator;
|
|
||||||
if (refData.name) {
|
if (refData.name) {
|
||||||
locator = page.getByRole(refData.role as any, { name: refData.name, exact: true });
|
return page.getByRole(refData.role as any, { name: refData.name });
|
||||||
} else {
|
} else {
|
||||||
locator = page.getByRole(refData.role as any);
|
return 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+3
-91
@@ -24,8 +24,6 @@ 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;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +129,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}", exact: true })`;
|
return `getByRole('${role}', { name: "${escapedName}" })`;
|
||||||
}
|
}
|
||||||
return `getByRole('${role}')`;
|
return `getByRole('${role}')`;
|
||||||
}
|
}
|
||||||
@@ -163,60 +161,12 @@ 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) {
|
||||||
@@ -229,43 +179,31 @@ 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, tracker);
|
const processed = processLine(line, refs, options);
|
||||||
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'));
|
||||||
@@ -274,22 +212,6 @@ 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)
|
||||||
*/
|
*/
|
||||||
@@ -301,12 +223,7 @@ 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(
|
function processLine(line: string, refs: RefMap, options: SnapshotOptions): string | null {
|
||||||
line: string,
|
|
||||||
refs: RefMap,
|
|
||||||
options: SnapshotOptions,
|
|
||||||
tracker: RoleNameTracker
|
|
||||||
): string | null {
|
|
||||||
const depth = getIndentLevel(line);
|
const depth = getIndentLevel(line);
|
||||||
|
|
||||||
// Check max depth
|
// Check max depth
|
||||||
@@ -356,22 +273,17 @@ function processLine(
|
|||||||
|
|
||||||
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user