From 4f6fd8ec5c4232c96f2b53f04552ee1274802c2b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 12 Jan 2026 11:41:25 -0600 Subject: [PATCH] support serverless environments (#29) * add --executable-path * tests * test vercel * fixes --- .github/workflows/ci.yml | 31 +++++++++++++++ README.md | 34 ++++++++++++++++ cli/src/commands.rs | 1 + cli/src/connection.rs | 20 ++++++++-- cli/src/flags.rs | 52 +++++++++++++++++++++++- cli/src/main.rs | 22 ++++++++--- cli/src/output.rs | 1 + src/browser.test.ts | 10 +++++ src/browser.ts | 1 + src/daemon.ts | 7 +++- src/types.ts | 1 + test/serverless.test.ts | 85 ++++++++++++++++++++++++++++++++++++++++ vitest.config.ts | 2 +- 13 files changed, 255 insertions(+), 12 deletions(-) create mode 100644 test/serverless.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43e250b..6b4fefb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -155,3 +155,34 @@ jobs: exit 1 } shell: pwsh + + serverless-chromium: + name: Serverless Chromium (@sparticuz/chromium) + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install + + - name: Install @sparticuz/chromium + run: pnpm add -D @sparticuz/chromium + + - name: Build TypeScript + run: pnpm build + + - name: Run serverless integration test + run: pnpm exec vitest run test/serverless.test.ts diff --git a/README.md b/README.md index 4e2702c..47963d2 100644 --- a/README.md +++ b/README.md @@ -293,6 +293,7 @@ agent-browser snapshot -i -c -d 5 # Combine options | Option | Description | |--------|-------------| | `--session ` | Use isolated session (or `AGENT_BROWSER_SESSION` env) | +| `--executable-path ` | Custom browser executable (or `AGENT_BROWSER_EXECUTABLE_PATH` env) | | `--json` | JSON output (for agents) | | `--full, -f` | Full page screenshot | | `--name, -n` | Locator name filter | @@ -387,6 +388,39 @@ agent-browser open example.com --headed This opens a visible browser window instead of running headless. +## Custom Browser Executable + +Use a custom browser executable instead of the bundled Chromium. This is useful for: +- **Serverless deployment**: Use lightweight Chromium builds like `@sparticuz/chromium` (~50MB vs ~684MB) +- **System browsers**: Use an existing Chrome/Chromium installation +- **Custom builds**: Use modified browser builds + +### CLI Usage + +```bash +# Via flag +agent-browser --executable-path /path/to/chromium open example.com + +# Via environment variable +AGENT_BROWSER_EXECUTABLE_PATH=/path/to/chromium agent-browser open example.com +``` + +### Serverless Example (Vercel/AWS Lambda) + +```typescript +import chromium from '@sparticuz/chromium'; +import { BrowserManager } from 'agent-browser'; + +export async function handler() { + const browser = new BrowserManager(); + await browser.launch({ + executablePath: await chromium.executablePath(), + headless: true, + }); + // ... use browser +} +``` + ## Architecture agent-browser uses a client-daemon architecture: diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 405f753..3ff4125 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -886,6 +886,7 @@ mod tests { full: false, headed: false, debug: false, + executable_path: None, } } diff --git a/cli/src/connection.rs b/cli/src/connection.rs index 2cfa83c..7a103ec 100644 --- a/cli/src/connection.rs +++ b/cli/src/connection.rs @@ -153,9 +153,15 @@ fn daemon_ready(session: &str) -> bool { } } -pub fn ensure_daemon(session: &str, headed: bool) -> Result<(), String> { +/// Result of ensure_daemon indicating whether a new daemon was started +pub struct DaemonResult { + /// True if we connected to an existing daemon, false if we started a new one + pub already_running: bool, +} + +pub fn ensure_daemon(session: &str, headed: bool, executable_path: Option<&str>) -> Result { if is_daemon_running(session) && daemon_ready(session) { - return Ok(()); + return Ok(DaemonResult { already_running: true }); } let exe_path = env::current_exe().map_err(|e| e.to_string())?; @@ -186,6 +192,10 @@ pub fn ensure_daemon(session: &str, headed: bool) -> Result<(), String> { cmd.env("AGENT_BROWSER_HEADED", "1"); } + if let Some(path) = executable_path { + cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path); + } + // Create new process group and session to fully detach unsafe { cmd.pre_exec(|| { @@ -220,6 +230,10 @@ pub fn ensure_daemon(session: &str, headed: bool) -> Result<(), String> { cmd.env("AGENT_BROWSER_HEADED", "1"); } + if let Some(path) = executable_path { + cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path); + } + // CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; const DETACHED_PROCESS: u32 = 0x00000008; @@ -234,7 +248,7 @@ pub fn ensure_daemon(session: &str, headed: bool) -> Result<(), String> { for _ in 0..50 { if daemon_ready(session) { - return Ok(()); + return Ok(DaemonResult { already_running: false }); } thread::sleep(Duration::from_millis(100)); } diff --git a/cli/src/flags.rs b/cli/src/flags.rs index fc2749b..54534c8 100644 --- a/cli/src/flags.rs +++ b/cli/src/flags.rs @@ -6,6 +6,7 @@ pub struct Flags { pub headed: bool, pub debug: bool, pub session: String, + pub executable_path: Option, } pub fn parse_flags(args: &[String]) -> Flags { @@ -15,6 +16,7 @@ pub fn parse_flags(args: &[String]) -> Flags { headed: false, debug: false, session: env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string()), + executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(), }; let mut i = 0; @@ -30,6 +32,12 @@ pub fn parse_flags(args: &[String]) -> Flags { i += 1; } } + "--executable-path" => { + if let Some(s) = args.get(i + 1) { + flags.executable_path = Some(s.clone()); + i += 1; + } + } _ => {} } i += 1; @@ -43,13 +51,15 @@ pub fn clean_args(args: &[String]) -> Vec { // Global flags that should be stripped from command args const GLOBAL_FLAGS: &[&str] = &["--json", "--full", "--headed", "--debug"]; + // Flags that take a value (skip both the flag and the next arg) + const VALUE_FLAGS: &[&str] = &["--session", "--executable-path"]; for arg in args.iter() { if skip_next { skip_next = false; continue; } - if arg == "--session" { + if VALUE_FLAGS.contains(&arg.as_str()) { skip_next = true; continue; } @@ -61,3 +71,43 @@ pub fn clean_args(args: &[String]) -> Vec { } result } + +#[cfg(test)] +mod tests { + use super::*; + + fn args(s: &str) -> Vec { + s.split_whitespace().map(String::from).collect() + } + + #[test] + fn test_parse_executable_path_flag() { + let flags = parse_flags(&args("--executable-path /path/to/chromium open example.com")); + assert_eq!(flags.executable_path, Some("/path/to/chromium".to_string())); + } + + #[test] + fn test_parse_executable_path_flag_no_value() { + let flags = parse_flags(&args("--executable-path")); + assert_eq!(flags.executable_path, None); + } + + #[test] + fn test_clean_args_removes_executable_path() { + let cleaned = clean_args(&args("--executable-path /path/to/chromium open example.com")); + assert_eq!(cleaned, vec!["open", "example.com"]); + } + + #[test] + fn test_clean_args_removes_executable_path_with_other_flags() { + let cleaned = clean_args(&args("--json --executable-path /path/to/chromium --headed open example.com")); + assert_eq!(cleaned, vec!["open", "example.com"]); + } + + #[test] + fn test_parse_flags_with_session_and_executable_path() { + let flags = parse_flags(&args("--session test --executable-path /custom/chrome open example.com")); + assert_eq!(flags.session, "test"); + assert_eq!(flags.executable_path, Some("/custom/chrome".to_string())); + } +} diff --git a/cli/src/main.rs b/cli/src/main.rs index 2ea8044..0413d3b 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -149,13 +149,23 @@ fn main() { } }; - if let Err(e) = ensure_daemon(&flags.session, flags.headed) { - if flags.json { - println!(r#"{{"success":false,"error":"{}"}}"#, e); - } else { - eprintln!("\x1b[31m✗\x1b[0m {}", e); + let daemon_result = match ensure_daemon(&flags.session, flags.headed, flags.executable_path.as_deref()) { + Ok(result) => result, + Err(e) => { + if flags.json { + println!(r#"{{"success":false,"error":"{}"}}"#, e); + } else { + eprintln!("\x1b[31m✗\x1b[0m {}", e); + } + exit(1); + } + }; + + // Warn if executable_path was specified but daemon was already running + if daemon_result.already_running && flags.executable_path.is_some() { + if !flags.json { + eprintln!("\x1b[33m⚠\x1b[0m --executable-path ignored: daemon already running. Use 'agent-browser close' first to restart with new path."); } - exit(1); } // If --headed flag is set, send launch command first to switch to headed mode diff --git a/cli/src/output.rs b/cli/src/output.rs index 4f4dadc..68e7fbe 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -1186,6 +1186,7 @@ Snapshot Options: Options: --session Isolated session (or AGENT_BROWSER_SESSION env) + --executable-path Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH) --json JSON output --full, -f Full page screenshot --headed Show browser window (not headless) diff --git a/src/browser.test.ts b/src/browser.test.ts index 9d40926..6de7daf 100644 --- a/src/browser.test.ts +++ b/src/browser.test.ts @@ -22,6 +22,16 @@ describe('BrowserManager', () => { const page = browser.getPage(); expect(page).toBeDefined(); }); + + it('should reject invalid executablePath', async () => { + const testBrowser = new BrowserManager(); + await expect( + testBrowser.launch({ + headless: true, + executablePath: '/nonexistent/path/to/chromium', + }) + ).rejects.toThrow(); + }); }); describe('navigation', () => { diff --git a/src/browser.ts b/src/browser.ts index f51e2bc..8409112 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -520,6 +520,7 @@ export class BrowserManager { // Launch browser this.browser = await launcher.launch({ headless: options.headless ?? true, + executablePath: options.executablePath, }); // Create context with viewport diff --git a/src/daemon.ts b/src/daemon.ts index c7e52be..61ecc07 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -158,7 +158,12 @@ export async function startDaemon(): Promise { parseResult.command.action !== 'launch' && parseResult.command.action !== 'close' ) { - await browser.launch({ id: 'auto', action: 'launch', headless: true }); + await browser.launch({ + id: 'auto', + action: 'launch', + headless: true, + executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH, + }); } // Handle close command specially diff --git a/src/types.ts b/src/types.ts index 64f1a66..e9caac1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,6 +12,7 @@ export interface LaunchCommand extends BaseCommand { headless?: boolean; viewport?: { width: number; height: number }; browser?: 'chromium' | 'firefox' | 'webkit'; + executablePath?: string; } export interface NavigateCommand extends BaseCommand { diff --git a/test/serverless.test.ts b/test/serverless.test.ts new file mode 100644 index 0000000..8efea63 --- /dev/null +++ b/test/serverless.test.ts @@ -0,0 +1,85 @@ +/** + * Integration test for @sparticuz/chromium compatibility + * This tests the executablePath option with a serverless-optimized Chromium build + * + * Note: @sparticuz/chromium only works on Linux (designed for AWS Lambda). + * This test will skip on non-Linux platforms. + */ +import { describe, it, expect, afterAll } from 'vitest'; +import { BrowserManager } from '../src/browser.js'; +import * as os from 'os'; + +const isLinux = os.platform() === 'linux'; + +// Only run if @sparticuz/chromium is available AND we're on Linux +const canRunTest = await (async () => { + if (!isLinux) { + console.log('Skipping @sparticuz/chromium test: only runs on Linux'); + return false; + } + try { + await import('@sparticuz/chromium'); + return true; + } catch { + console.log('Skipping @sparticuz/chromium test: package not installed'); + return false; + } +})(); + +describe.skipIf(!canRunTest)('Serverless Chromium Integration', () => { + let browser: BrowserManager; + let chromiumPath: string; + + it('should get executable path from @sparticuz/chromium', async () => { + const chromium = await import('@sparticuz/chromium'); + chromiumPath = await chromium.default.executablePath(); + expect(chromiumPath).toBeTruthy(); + expect(typeof chromiumPath).toBe('string'); + console.log('Chromium executable path:', chromiumPath); + }); + + it('should launch browser with custom executablePath', async () => { + const chromium = await import('@sparticuz/chromium'); + chromiumPath = await chromium.default.executablePath(); + + browser = new BrowserManager(); + await browser.launch({ + headless: true, + executablePath: chromiumPath, + }); + + expect(browser.isLaunched()).toBe(true); + }); + + it('should navigate to a page', async () => { + const page = browser.getPage(); + await page.goto('https://example.com'); + expect(page.url()).toBe('https://example.com/'); + }); + + it('should get page title', async () => { + const page = browser.getPage(); + const title = await page.title(); + expect(title).toBe('Example Domain'); + }); + + it('should take snapshot with refs', async () => { + const { tree, refs } = await browser.getSnapshot(); + expect(tree).toContain('Example Domain'); + expect(typeof refs).toBe('object'); + expect(Object.keys(refs).length).toBeGreaterThan(0); + }); + + it('should take screenshot', async () => { + const page = browser.getPage(); + const buffer = await page.screenshot(); + expect(buffer).toBeInstanceOf(Buffer); + expect(buffer.length).toBeGreaterThan(0); + }); + + afterAll(async () => { + if (browser?.isLaunched()) { + await browser.close(); + } + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 46ef8ae..31a6325 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { globals: true, - include: ['src/**/*.test.ts'], + include: ['src/**/*.test.ts', 'test/**/*.test.ts'], testTimeout: 30000, }, });