Compare commits
4
Commits
main
...
ctate/vercel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
581e166e8a | ||
|
|
7ecc422002 | ||
|
|
7b862d9dae | ||
|
|
8de8943dd7 |
@@ -155,3 +155,34 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
shell: pwsh
|
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
|
||||||
|
|||||||
@@ -293,6 +293,7 @@ agent-browser snapshot -i -c -d 5 # Combine options
|
|||||||
| Option | Description |
|
| Option | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `--session <name>` | Use isolated session (or `AGENT_BROWSER_SESSION` env) |
|
| `--session <name>` | Use isolated session (or `AGENT_BROWSER_SESSION` env) |
|
||||||
|
| `--executable-path <path>` | Custom browser executable (or `AGENT_BROWSER_EXECUTABLE_PATH` env) |
|
||||||
| `--json` | JSON output (for agents) |
|
| `--json` | JSON output (for agents) |
|
||||||
| `--full, -f` | Full page screenshot |
|
| `--full, -f` | Full page screenshot |
|
||||||
| `--name, -n` | Locator name filter |
|
| `--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.
|
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
|
## Architecture
|
||||||
|
|
||||||
agent-browser uses a client-daemon architecture:
|
agent-browser uses a client-daemon architecture:
|
||||||
|
|||||||
@@ -886,6 +886,7 @@ mod tests {
|
|||||||
full: false,
|
full: false,
|
||||||
headed: false,
|
headed: false,
|
||||||
debug: false,
|
debug: false,
|
||||||
|
executable_path: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-3
@@ -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) {
|
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())?;
|
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");
|
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
|
// Create new process group and session to fully detach
|
||||||
unsafe {
|
unsafe {
|
||||||
cmd.pre_exec(|| {
|
cmd.pre_exec(|| {
|
||||||
@@ -220,6 +230,10 @@ pub fn ensure_daemon(session: &str, headed: bool) -> Result<(), String> {
|
|||||||
cmd.env("AGENT_BROWSER_HEADED", "1");
|
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
|
// 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;
|
||||||
@@ -234,7 +248,7 @@ pub fn ensure_daemon(session: &str, headed: bool) -> Result<(), String> {
|
|||||||
|
|
||||||
for _ in 0..50 {
|
for _ in 0..50 {
|
||||||
if daemon_ready(session) {
|
if daemon_ready(session) {
|
||||||
return Ok(());
|
return Ok(DaemonResult { already_running: false });
|
||||||
}
|
}
|
||||||
thread::sleep(Duration::from_millis(100));
|
thread::sleep(Duration::from_millis(100));
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-1
@@ -6,6 +6,7 @@ pub struct Flags {
|
|||||||
pub headed: bool,
|
pub headed: bool,
|
||||||
pub debug: bool,
|
pub debug: bool,
|
||||||
pub session: String,
|
pub session: String,
|
||||||
|
pub executable_path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_flags(args: &[String]) -> Flags {
|
pub fn parse_flags(args: &[String]) -> Flags {
|
||||||
@@ -15,6 +16,7 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
headed: false,
|
headed: false,
|
||||||
debug: false,
|
debug: false,
|
||||||
session: env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string()),
|
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;
|
let mut i = 0;
|
||||||
@@ -30,6 +32,12 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"--executable-path" => {
|
||||||
|
if let Some(s) = args.get(i + 1) {
|
||||||
|
flags.executable_path = Some(s.clone());
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
i += 1;
|
i += 1;
|
||||||
@@ -43,13 +51,15 @@ 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"];
|
||||||
|
// 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() {
|
for arg in args.iter() {
|
||||||
if skip_next {
|
if skip_next {
|
||||||
skip_next = false;
|
skip_next = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if arg == "--session" {
|
if VALUE_FLAGS.contains(&arg.as_str()) {
|
||||||
skip_next = true;
|
skip_next = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -61,3 +71,43 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
|||||||
}
|
}
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn args(s: &str) -> Vec<String> {
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+16
-6
@@ -149,13 +149,23 @@ fn main() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = ensure_daemon(&flags.session, flags.headed) {
|
let daemon_result = match ensure_daemon(&flags.session, flags.headed, flags.executable_path.as_deref()) {
|
||||||
if flags.json {
|
Ok(result) => result,
|
||||||
println!(r#"{{"success":false,"error":"{}"}}"#, e);
|
Err(e) => {
|
||||||
} else {
|
if flags.json {
|
||||||
eprintln!("\x1b[31m✗\x1b[0m {}", e);
|
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
|
// If --headed flag is set, send launch command first to switch to headed mode
|
||||||
|
|||||||
@@ -1186,6 +1186,7 @@ Snapshot Options:
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
--session <name> Isolated session (or AGENT_BROWSER_SESSION env)
|
--session <name> Isolated session (or AGENT_BROWSER_SESSION env)
|
||||||
|
--executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH)
|
||||||
--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)
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ describe('BrowserManager', () => {
|
|||||||
const page = browser.getPage();
|
const page = browser.getPage();
|
||||||
expect(page).toBeDefined();
|
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', () => {
|
describe('navigation', () => {
|
||||||
|
|||||||
@@ -520,6 +520,7 @@ export class BrowserManager {
|
|||||||
// Launch browser
|
// Launch browser
|
||||||
this.browser = await launcher.launch({
|
this.browser = await launcher.launch({
|
||||||
headless: options.headless ?? true,
|
headless: options.headless ?? true,
|
||||||
|
executablePath: options.executablePath,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create context with viewport
|
// Create context with viewport
|
||||||
|
|||||||
+6
-1
@@ -158,7 +158,12 @@ export async function startDaemon(): Promise<void> {
|
|||||||
parseResult.command.action !== 'launch' &&
|
parseResult.command.action !== 'launch' &&
|
||||||
parseResult.command.action !== 'close'
|
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
|
// Handle close command specially
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface LaunchCommand extends BaseCommand {
|
|||||||
headless?: boolean;
|
headless?: boolean;
|
||||||
viewport?: { width: number; height: number };
|
viewport?: { width: number; height: number };
|
||||||
browser?: 'chromium' | 'firefox' | 'webkit';
|
browser?: 'chromium' | 'firefox' | 'webkit';
|
||||||
|
executablePath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NavigateCommand extends BaseCommand {
|
export interface NavigateCommand extends BaseCommand {
|
||||||
|
|||||||
@@ -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
@@ -3,7 +3,7 @@ import { defineConfig } from 'vitest/config';
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
include: ['src/**/*.test.ts'],
|
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
|
||||||
testTimeout: 30000,
|
testTimeout: 30000,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user