Compare commits

..
Author SHA1 Message Date
Chris Tate 8b4f969219 v0.5.0 2026-01-13 21:48:18 -06:00
NoelandClaude Sonnet 4.5 7b43d408da fix: improve error message when element is blocked by overlay (#59)
When clicking an element that is blocked by a cookie banner or modal overlay,
the error message incorrectly showed "Element not found or not visible" even
though the element was found and visible.

The issue was in toAIFriendlyError(): the check for "Timeout" was evaluated
before "intercepts pointer events", causing the wrong error message to be
returned.

Changes:
- Reorder error detection to check "intercepts pointer events" before "Timeout"
- Improve error message to suggest dismissing modals/cookie banners
- Export toAIFriendlyError for testing
- Add focused tests for overlay blocking behavior

Before:
  Element "@e4" not found or not visible. Run 'snapshot' to see current page elements.

After:
  Element "@e4" is blocked by another element (likely a modal or overlay).
  Try dismissing any modals/cookie banners first.

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-13 15:25:46 -06:00
Chris TateandVercel <vercel[bot]@users.noreply.github.com> 2dc093cd62 add screencast (#67)
* docs

* updates

* Fix: The handleCopy function fails to handle errors from navigator.clipboard.writeText(), causing unhandled exceptions and misleading UI feedback when clipboard operations fail.

Co-authored-by: ctate <chris@ctate.dev>

* Fix: The benchmark file uses emojis (📊, 🚀, 🔨, 📈, 📋, , ⏱️, ⚠) in console output, violating repository guidelines that forbid emojis in code and output.

Co-authored-by: ctate <chris@ctate.dev>

* Remove benchmark/run.ts from PR

* screencast

* update docs

* address comments

---------

Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
2026-01-13 14:53:27 -06:00
Shirshak 673e2e266e feat: Add extension support (#48)
* Rebase: Add extension support

* Fix logs
2026-01-13 14:34:59 -06:00
13 changed files with 153 additions and 44 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ version = 4
[[package]] [[package]]
name = "agent-browser" name = "agent-browser"
version = "0.4.4" version = "0.5.0"
dependencies = [ dependencies = [
"libc", "libc",
"serde", "serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "agent-browser" name = "agent-browser"
version = "0.4.4" version = "0.5.0"
edition = "2021" edition = "2021"
description = "Fast browser automation CLI for AI agents" description = "Fast browser automation CLI for AI agents"
license = "Apache-2.0" license = "Apache-2.0"
+1
View File
@@ -901,6 +901,7 @@ mod tests {
debug: false, debug: false,
headers: None, headers: None,
executable_path: None, executable_path: None,
extensions: Vec::new(),
cdp: None, cdp: None,
} }
} }
+17 -2
View File
@@ -159,9 +159,16 @@ pub struct DaemonResult {
pub already_running: bool, pub already_running: bool,
} }
pub fn ensure_daemon(session: &str, headed: bool, executable_path: Option<&str>) -> Result<DaemonResult, String> { pub fn ensure_daemon(
session: &str,
headed: bool,
executable_path: Option<&str>,
extensions: &[String],
) -> Result<DaemonResult, String> {
if is_daemon_running(session) && daemon_ready(session) { if is_daemon_running(session) && daemon_ready(session) {
return Ok(DaemonResult { already_running: true }); return Ok(DaemonResult {
already_running: true,
});
} }
let exe_path = env::current_exe().map_err(|e| e.to_string())?; let exe_path = env::current_exe().map_err(|e| e.to_string())?;
@@ -196,6 +203,10 @@ pub fn ensure_daemon(session: &str, headed: bool, executable_path: Option<&str>)
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path); cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
} }
if !extensions.is_empty() {
cmd.env("AGENT_BROWSER_EXTENSIONS", extensions.join(","));
}
// Create new process group and session to fully detach // Create new process group and session to fully detach
unsafe { unsafe {
cmd.pre_exec(|| { cmd.pre_exec(|| {
@@ -234,6 +245,10 @@ pub fn ensure_daemon(session: &str, headed: bool, executable_path: Option<&str>)
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path); cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
} }
if !extensions.is_empty() {
cmd.env("AGENT_BROWSER_EXTENSIONS", extensions.join(","));
}
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS // CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008; const DETACHED_PROCESS: u32 = 0x00000008;
+15 -2
View File
@@ -9,9 +9,15 @@ pub struct Flags {
pub headers: Option<String>, pub headers: Option<String>,
pub executable_path: Option<String>, pub executable_path: Option<String>,
pub cdp: Option<String>, pub cdp: Option<String>,
pub extensions: Vec<String>,
} }
pub fn parse_flags(args: &[String]) -> Flags { pub fn parse_flags(args: &[String]) -> Flags {
let extensions_env = env::var("AGENT_BROWSER_EXTENSIONS")
.ok()
.map(|s| s.split(',').map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect::<Vec<_>>())
.unwrap_or_default();
let mut flags = Flags { let mut flags = Flags {
json: false, json: false,
full: false, full: false,
@@ -21,6 +27,7 @@ pub fn parse_flags(args: &[String]) -> Flags {
headers: None, headers: None,
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(), executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(),
cdp: None, cdp: None,
extensions: extensions_env,
}; };
let mut i = 0; let mut i = 0;
@@ -47,7 +54,13 @@ pub fn parse_flags(args: &[String]) -> Flags {
flags.executable_path = Some(s.clone()); flags.executable_path = Some(s.clone());
i += 1; i += 1;
} }
} },
"--extension" => {
if let Some(s) = args.get(i + 1) {
flags.extensions.push(s.clone());
i += 1;
}
},
"--cdp" => { "--cdp" => {
if let Some(s) = args.get(i + 1) { if let Some(s) = args.get(i + 1) {
flags.cdp = Some(s.clone()); flags.cdp = Some(s.clone());
@@ -68,7 +81,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
// Global flags that should be stripped from command args // Global flags that should be stripped from command args
const GLOBAL_FLAGS: &[&str] = &["--json", "--full", "--headed", "--debug"]; const GLOBAL_FLAGS: &[&str] = &["--json", "--full", "--headed", "--debug"];
// Global flags that take a value (need to skip the next arg too) // Global flags that take a value (need to skip the next arg too)
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &["--session", "--headers", "--executable-path", "--cdp"]; const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &["--session", "--headers", "--executable-path", "--cdp", "--extension"];
for arg in args.iter() { for arg in args.iter() {
if skip_next { if skip_next {
+8 -3
View File
@@ -149,7 +149,7 @@ fn main() {
} }
}; };
let daemon_result = match ensure_daemon(&flags.session, flags.headed, flags.executable_path.as_deref()) { let daemon_result = match ensure_daemon(&flags.session, flags.headed, flags.executable_path.as_deref(), &flags.extensions) {
Ok(result) => result, Ok(result) => result,
Err(e) => { Err(e) => {
if flags.json { if flags.json {
@@ -162,9 +162,14 @@ fn main() {
}; };
// Warn if executable_path was specified but daemon was already running // Warn if executable_path was specified but daemon was already running
if daemon_result.already_running && flags.executable_path.is_some() { if daemon_result.already_running && (flags.executable_path.is_some() || !flags.extensions.is_empty()) {
if !flags.json { if !flags.json {
eprintln!("\x1b[33m⚠\x1b[0m --executable-path ignored: daemon already running. Use 'agent-browser close' first to restart with new path."); if flags.executable_path.is_some() {
eprintln!("\x1b[33m⚠\x1b[0m --executable-path ignored: daemon already running. Use 'agent-browser close' first to restart with new path.");
}
if !flags.extensions.is_empty() {
eprintln!("\x1b[33m⚠\x1b[0m --extension ignored: daemon already running. Use 'agent-browser close' first to restart with extensions.");
}
} }
} }
+1
View File
@@ -1191,6 +1191,7 @@ Options:
--session <name> Isolated session (or AGENT_BROWSER_SESSION env) --session <name> Isolated session (or AGENT_BROWSER_SESSION env)
--headers <json> HTTP headers scoped to URL's origin (for auth) --headers <json> HTTP headers scoped to URL's origin (for auth)
--executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH) --executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH)
--extension <path> Load browser extensions (repeatable).
--json JSON output --json JSON output
--full, -f Full page screenshot --full, -f Full page screenshot
--headed Show browser window (not headless) --headed Show browser window (not headless)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "agent-browser", "name": "agent-browser",
"version": "0.4.4", "version": "0.5.0",
"description": "Headless browser automation CLI for AI agents", "description": "Headless browser automation CLI for AI agents",
"type": "module", "type": "module",
"main": "dist/daemon.js", "main": "dist/daemon.js",
+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { toAIFriendlyError } from './actions.js';
describe('toAIFriendlyError', () => {
describe('element blocked by overlay', () => {
it('should detect intercepts pointer events even when Timeout is in message', () => {
// This is the exact error from Playwright when a cookie banner blocks an element
// Bug: Previously this was incorrectly reported as "not found or not visible"
const error = new Error(
'TimeoutError: locator.click: Timeout 10000ms exceeded.\n' +
'Call log:\n' +
" - waiting for getByRole('link', { name: 'Anmelden', exact: true }).first()\n" +
' - locator resolved to <a href="https://example.com/login">Anmelden</a>\n' +
' - attempting click action\n' +
' 2 x waiting for element to be visible, enabled and stable\n' +
' - element is visible, enabled and stable\n' +
' - scrolling into view if needed\n' +
' - done scrolling\n' +
' - <body class="font-sans antialiased">...</body> intercepts pointer events\n' +
' - retrying click action'
);
const result = toAIFriendlyError(error, '@e4');
// Must NOT say "not found" - the element WAS found
expect(result.message).not.toContain('not found');
// Must indicate the element is blocked
expect(result.message).toContain('blocked by another element');
expect(result.message).toContain('modal or overlay');
});
it('should suggest dismissing cookie banners', () => {
const error = new Error('<div class="cookie-overlay"> intercepts pointer events');
const result = toAIFriendlyError(error, '@e1');
expect(result.message).toContain('cookie banners');
});
});
});
+20 -10
View File
@@ -134,8 +134,9 @@ interface SnapshotData {
/** /**
* Convert Playwright errors to AI-friendly messages * Convert Playwright errors to AI-friendly messages
* @internal Exported for testing
*/ */
function toAIFriendlyError(error: unknown, selector: string): Error { export function toAIFriendlyError(error: unknown, selector: string): Error {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
// Handle strict mode violation (multiple elements match) // Handle strict mode violation (multiple elements match)
@@ -150,7 +151,24 @@ function toAIFriendlyError(error: unknown, selector: string): Error {
); );
} }
// Handle element not found // Handle element not interactable (must be checked BEFORE timeout case)
// This includes cases where an overlay/modal blocks the element
if (message.includes('intercepts pointer events')) {
return new Error(
`Element "${selector}" is blocked by another element (likely a modal or overlay). ` +
`Try dismissing any modals/cookie banners first.`
);
}
// Handle element not visible
if (message.includes('not visible') && !message.includes('Timeout')) {
return new Error(
`Element "${selector}" is not visible. ` +
`Try scrolling it into view or check if it's hidden.`
);
}
// Handle element not found (timeout waiting for element)
if ( if (
message.includes('waiting for') && message.includes('waiting for') &&
(message.includes('to be visible') || message.includes('Timeout')) (message.includes('to be visible') || message.includes('Timeout'))
@@ -161,14 +179,6 @@ function toAIFriendlyError(error: unknown, selector: string): Error {
); );
} }
// 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 original error for unknown cases
return error instanceof Error ? error : new Error(message); return error instanceof Error ? error : new Error(message);
} }
+42 -24
View File
@@ -13,6 +13,8 @@ import {
type Locator, type Locator,
type CDPSession, type CDPSession,
} from 'playwright-core'; } from 'playwright-core';
import path from 'node:path';
import os from 'node:os';
import type { LaunchCommand } from './types.js'; import type { LaunchCommand } from './types.js';
import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js'; import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js';
@@ -65,6 +67,7 @@ interface PageError {
export class BrowserManager { export class BrowserManager {
private browser: Browser | null = null; private browser: Browser | null = null;
private cdpPort: number | null = null; private cdpPort: number | null = null;
private isPersistentContext: boolean = false;
private contexts: BrowserContext[] = []; private contexts: BrowserContext[] = [];
private pages: Page[] = []; private pages: Page[] = [];
private activePageIndex: number = 0; private activePageIndex: number = 0;
@@ -90,7 +93,7 @@ export class BrowserManager {
* Check if browser is launched * Check if browser is launched
*/ */
isLaunched(): boolean { isLaunched(): boolean {
return this.browser !== null; return this.browser !== null || this.isPersistentContext;
} }
/** /**
@@ -637,12 +640,16 @@ export class BrowserManager {
*/ */
async launch(options: LaunchCommand): Promise<void> { async launch(options: LaunchCommand): Promise<void> {
const cdpPort = options.cdpPort; const cdpPort = options.cdpPort;
const hasExtensions = !!options.extensions?.length;
if (this.browser) { if (hasExtensions && cdpPort) {
const switchingFromCdpToBrowser = !cdpPort && this.cdpPort !== null; throw new Error('Extensions cannot be used with CDP connection');
const needsCdpReconnect = !!cdpPort && this.needsCdpReconnect(cdpPort); }
if (switchingFromCdpToBrowser || needsCdpReconnect) { if (this.isLaunched()) {
const needsRelaunch =
(!cdpPort && this.cdpPort !== null) || (!!cdpPort && this.needsCdpReconnect(cdpPort));
if (needsRelaunch) {
await this.close(); await this.close();
} else { } else {
return; return;
@@ -654,35 +661,45 @@ export class BrowserManager {
return; return;
} }
// Select browser type
const browserType = options.browser ?? 'chromium'; const browserType = options.browser ?? 'chromium';
if (hasExtensions && browserType !== 'chromium') {
throw new Error('Extensions are only supported in Chromium');
}
const launcher = const launcher =
browserType === 'firefox' ? firefox : browserType === 'webkit' ? webkit : chromium; browserType === 'firefox' ? firefox : browserType === 'webkit' ? webkit : chromium;
const viewport = options.viewport ?? { width: 1280, height: 720 };
// Launch browser let context: BrowserContext;
this.browser = await launcher.launch({ if (hasExtensions) {
headless: options.headless ?? true, const extPaths = options.extensions!.join(',');
executablePath: options.executablePath, const session = process.env.AGENT_BROWSER_SESSION || 'default';
}); context = await launcher.launchPersistentContext(
this.cdpPort = null; path.join(os.tmpdir(), `agent-browser-ext-${session}`),
{
headless: false,
executablePath: options.executablePath,
args: [`--disable-extensions-except=${extPaths}`, `--load-extension=${extPaths}`],
viewport,
extraHTTPHeaders: options.headers,
}
);
this.isPersistentContext = true;
} else {
this.browser = await launcher.launch({
headless: options.headless ?? true,
executablePath: options.executablePath,
});
this.cdpPort = null;
context = await this.browser.newContext({ viewport, extraHTTPHeaders: options.headers });
}
// Create context with viewport and optional headers
const context = await this.browser.newContext({
viewport: options.viewport ?? { width: 1280, height: 720 },
extraHTTPHeaders: options.headers,
});
// Set default timeout to 10 seconds (Playwright default is 30s)
context.setDefaultTimeout(10000); context.setDefaultTimeout(10000);
this.contexts.push(context); this.contexts.push(context);
// Create initial page const page = context.pages()[0] ?? (await context.newPage());
const page = await context.newPage();
this.pages.push(page); this.pages.push(page);
this.activePageIndex = 0; this.activePageIndex = 0;
// Automatically start console and error tracking
this.setupPageTracking(page); this.setupPageTracking(page);
} }
@@ -1124,6 +1141,7 @@ export class BrowserManager {
this.pages = []; this.pages = [];
this.contexts = []; this.contexts = [];
this.cdpPort = null; this.cdpPort = null;
this.isPersistentContext = false;
this.activePageIndex = 0; this.activePageIndex = 0;
this.refMap = {}; this.refMap = {};
this.lastSnapshot = ''; this.lastSnapshot = '';
+6
View File
@@ -192,11 +192,17 @@ export async function startDaemon(options?: { streamPort?: number }): Promise<vo
parseResult.command.action !== 'launch' && parseResult.command.action !== 'launch' &&
parseResult.command.action !== 'close' parseResult.command.action !== 'close'
) { ) {
const extensions = process.env.AGENT_BROWSER_EXTENSIONS
? process.env.AGENT_BROWSER_EXTENSIONS.split(',')
.map((p) => p.trim())
.filter(Boolean)
: undefined;
await browser.launch({ await browser.launch({
id: 'auto', id: 'auto',
action: 'launch', action: 'launch',
headless: true, headless: true,
executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH, executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH,
extensions: extensions,
}); });
} }
+1
View File
@@ -15,6 +15,7 @@ export interface LaunchCommand extends BaseCommand {
headers?: Record<string, string>; headers?: Record<string, string>;
executablePath?: string; executablePath?: string;
cdpPort?: number; cdpPort?: number;
extensions?: string[];
} }
export interface NavigateCommand extends BaseCommand { export interface NavigateCommand extends BaseCommand {