Merge main into ctate/custom-headers

This commit is contained in:
Chris Tate
2026-01-12 11:57:43 -06:00
13 changed files with 244 additions and 12 deletions
+31
View File
@@ -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
+34
View File
@@ -294,6 +294,7 @@ agent-browser snapshot -i -c -d 5 # Combine options
|--------|-------------|
| `--session <name>` | Use isolated session (or `AGENT_BROWSER_SESSION` env) |
| `--headers <json>` | Set HTTP headers scoped to the URL's origin |
| `--executable-path <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 |
@@ -423,6 +424,39 @@ For global headers (all domains), use `set headers`:
agent-browser set headers '{"X-Custom-Header": "value"}'
```
## 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:
+1
View File
@@ -900,6 +900,7 @@ mod tests {
headed: false,
debug: false,
headers: None,
executable_path: None,
}
}
+17 -3
View File
@@ -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<DaemonResult, String> {
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));
}
+40 -1
View File
@@ -7,6 +7,7 @@ pub struct Flags {
pub debug: bool,
pub session: String,
pub headers: Option<String>,
pub executable_path: Option<String>,
}
pub fn parse_flags(args: &[String]) -> Flags {
@@ -17,6 +18,7 @@ pub fn parse_flags(args: &[String]) -> Flags {
debug: false,
session: env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string()),
headers: None,
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(),
};
let mut i = 0;
@@ -38,6 +40,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;
@@ -52,7 +60,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
// Global flags that should be stripped from command args
const GLOBAL_FLAGS: &[&str] = &["--json", "--full", "--headed", "--debug"];
// Global flags that take a value (need to skip the next arg too)
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &["--session", "--headers"];
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &["--session", "--headers", "--executable-path"];
for arg in args.iter() {
if skip_next {
@@ -147,4 +155,35 @@ mod tests {
let clean = clean_args(&input);
assert_eq!(clean, vec!["open", "example.com"]);
}
#[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()));
}
}
+16 -6
View File
@@ -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
+1
View File
@@ -1190,6 +1190,7 @@ Snapshot Options:
Options:
--session <name> Isolated session (or AGENT_BROWSER_SESSION env)
--headers <json> HTTP headers scoped to URL's origin (for auth)
--executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH)
--json JSON output
--full, -f Full page screenshot
--headed Show browser window (not headless)
+10
View File
@@ -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', () => {
+1
View File
@@ -591,6 +591,7 @@ export class BrowserManager {
// Launch browser
this.browser = await launcher.launch({
headless: options.headless ?? true,
executablePath: options.executablePath,
});
// Create context with viewport and optional headers
+6 -1
View File
@@ -158,7 +158,12 @@ export async function startDaemon(): Promise<void> {
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
+1
View File
@@ -13,6 +13,7 @@ export interface LaunchCommand extends BaseCommand {
viewport?: { width: number; height: number };
browser?: 'chromium' | 'firefox' | 'webkit';
headers?: Record<string, string>;
executablePath?: string;
}
export interface NavigateCommand extends BaseCommand {
+85
View File
@@ -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();
}
});
});
+1 -1
View File
@@ -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,
},
});