Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a0e51f4a6 | ||
|
|
6eafe50952 | ||
|
|
95675e9d55 | ||
|
|
97b17c98fb | ||
|
|
57a04385c1 | ||
|
|
1a88d7f585 | ||
|
|
4f6fd8ec5c | ||
|
|
3cd0ab468f |
@@ -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
|
||||
|
||||
@@ -18,6 +18,8 @@ git clone https://github.com/vercel-labs/agent-browser
|
||||
cd agent-browser
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm build:native # Requires Rust (https://rustup.rs)
|
||||
pnpm link --global # Makes agent-browser available globally
|
||||
agent-browser install
|
||||
```
|
||||
|
||||
@@ -293,11 +295,14 @@ agent-browser snapshot -i -c -d 5 # Combine options
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--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 |
|
||||
| `--exact` | Exact text match |
|
||||
| `--headed` | Show browser window (not headless) |
|
||||
| `--cdp <port>` | Connect via Chrome DevTools Protocol |
|
||||
| `--debug` | Debug output |
|
||||
|
||||
## Selectors
|
||||
@@ -387,6 +392,93 @@ agent-browser open example.com --headed
|
||||
|
||||
This opens a visible browser window instead of running headless.
|
||||
|
||||
## Authenticated Sessions
|
||||
|
||||
Use `--headers` to set HTTP headers for a specific origin, enabling authentication without login flows:
|
||||
|
||||
```bash
|
||||
# Headers are scoped to api.example.com only
|
||||
agent-browser open api.example.com --headers '{"Authorization": "Bearer <token>"}'
|
||||
|
||||
# Requests to api.example.com include the auth header
|
||||
agent-browser snapshot -i --json
|
||||
agent-browser click @e2
|
||||
|
||||
# Navigate to another domain - headers are NOT sent (safe!)
|
||||
agent-browser open other-site.com
|
||||
```
|
||||
|
||||
This is useful for:
|
||||
- **Skipping login flows** - Authenticate via headers instead of UI
|
||||
- **Switching users** - Start new sessions with different auth tokens
|
||||
- **API testing** - Access protected endpoints directly
|
||||
- **Security** - Headers are scoped to the origin, not leaked to other domains
|
||||
|
||||
To set headers for multiple origins, use `--headers` with each `open` command:
|
||||
|
||||
```bash
|
||||
agent-browser open api.example.com --headers '{"Authorization": "Bearer token1"}'
|
||||
agent-browser open api.acme.com --headers '{"Authorization": "Bearer token2"}'
|
||||
```
|
||||
|
||||
For global headers (all domains), use `set headers`:
|
||||
|
||||
```bash
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
## CDP Mode
|
||||
|
||||
Connect to an existing browser via Chrome DevTools Protocol:
|
||||
|
||||
```bash
|
||||
# Connect to Electron app
|
||||
agent-browser --cdp 9222 snapshot
|
||||
|
||||
# Connect to Chrome with remote debugging
|
||||
# (Start Chrome with: google-chrome --remote-debugging-port=9222)
|
||||
agent-browser --cdp 9222 open about:blank
|
||||
```
|
||||
|
||||
This enables control of:
|
||||
- Electron apps
|
||||
- Chrome/Chromium instances with remote debugging
|
||||
- WebView2 applications
|
||||
- Any browser exposing a CDP endpoint
|
||||
|
||||
## Architecture
|
||||
|
||||
agent-browser uses a client-daemon architecture:
|
||||
|
||||
Generated
+1
-1
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "agent-browser"
|
||||
version = "0.4.3"
|
||||
version = "0.4.4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"serde",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-browser"
|
||||
version = "0.4.3"
|
||||
version = "0.4.4"
|
||||
edition = "2021"
|
||||
description = "Fast browser automation CLI for AI agents"
|
||||
license = "Apache-2.0"
|
||||
|
||||
+93
-2
@@ -80,7 +80,14 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
} else {
|
||||
format!("https://{}", url)
|
||||
};
|
||||
Ok(json!({ "id": id, "action": "navigate", "url": url }))
|
||||
let mut nav_cmd = json!({ "id": id, "action": "navigate", "url": url });
|
||||
// If --headers flag is set, include headers (scoped to this origin)
|
||||
if let Some(ref headers_json) = flags.headers {
|
||||
if let Ok(headers) = serde_json::from_str::<serde_json::Value>(headers_json) {
|
||||
nav_cmd["headers"] = headers;
|
||||
}
|
||||
}
|
||||
Ok(nav_cmd)
|
||||
}
|
||||
"back" => Ok(json!({ "id": id, "action": "back" })),
|
||||
"forward" => Ok(json!({ "id": id, "action": "forward" })),
|
||||
@@ -766,7 +773,13 @@ fn parse_set(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
||||
context: "set headers".to_string(),
|
||||
usage: "set headers <json>",
|
||||
})?;
|
||||
Ok(json!({ "id": id, "action": "headers", "headers": headers_json }))
|
||||
// Parse the JSON string into an object
|
||||
let headers: serde_json::Value = serde_json::from_str(headers_json)
|
||||
.map_err(|_| ParseError::MissingArguments {
|
||||
context: "set headers".to_string(),
|
||||
usage: "set headers <json> (must be valid JSON object)",
|
||||
})?;
|
||||
Ok(json!({ "id": id, "action": "headers", "headers": headers }))
|
||||
}
|
||||
Some("credentials") | Some("auth") => {
|
||||
let user = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
|
||||
@@ -886,6 +899,9 @@ mod tests {
|
||||
full: false,
|
||||
headed: false,
|
||||
debug: false,
|
||||
headers: None,
|
||||
executable_path: None,
|
||||
cdp: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1012,6 +1028,81 @@ mod tests {
|
||||
assert_eq!(cmd["url"], "https://example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_navigate_with_headers() {
|
||||
let mut flags = default_flags();
|
||||
flags.headers = Some(r#"{"Authorization": "Bearer token"}"#.to_string());
|
||||
let cmd = parse_command(&args("open api.example.com"), &flags).unwrap();
|
||||
assert_eq!(cmd["action"], "navigate");
|
||||
assert_eq!(cmd["url"], "https://api.example.com");
|
||||
assert_eq!(cmd["headers"]["Authorization"], "Bearer token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_navigate_with_multiple_headers() {
|
||||
let mut flags = default_flags();
|
||||
flags.headers = Some(r#"{"Authorization": "Bearer token", "X-Custom": "value"}"#.to_string());
|
||||
let cmd = parse_command(&args("open api.example.com"), &flags).unwrap();
|
||||
assert_eq!(cmd["headers"]["Authorization"], "Bearer token");
|
||||
assert_eq!(cmd["headers"]["X-Custom"], "value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_navigate_without_headers_flag() {
|
||||
let cmd = parse_command(&args("open example.com"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "navigate");
|
||||
// headers should not be present when flag is not set
|
||||
assert!(cmd.get("headers").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_navigate_with_invalid_headers_json() {
|
||||
let mut flags = default_flags();
|
||||
flags.headers = Some("not valid json".to_string());
|
||||
let cmd = parse_command(&args("open api.example.com"), &flags).unwrap();
|
||||
// Invalid JSON should result in no headers field (graceful handling)
|
||||
assert!(cmd.get("headers").is_none());
|
||||
}
|
||||
|
||||
// === Set Headers Tests ===
|
||||
|
||||
#[test]
|
||||
fn test_set_headers_parses_json() {
|
||||
let input: Vec<String> = vec![
|
||||
"set".to_string(),
|
||||
"headers".to_string(),
|
||||
r#"{"Authorization":"Bearer token"}"#.to_string(),
|
||||
];
|
||||
let cmd = parse_command(&input, &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "headers");
|
||||
// Headers should be an object, not a string
|
||||
assert!(cmd["headers"].is_object());
|
||||
assert_eq!(cmd["headers"]["Authorization"], "Bearer token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_headers_with_multiple_values() {
|
||||
let input: Vec<String> = vec![
|
||||
"set".to_string(),
|
||||
"headers".to_string(),
|
||||
r#"{"Authorization": "Bearer token", "X-Custom": "value"}"#.to_string(),
|
||||
];
|
||||
let cmd = parse_command(&input, &default_flags()).unwrap();
|
||||
assert_eq!(cmd["headers"]["Authorization"], "Bearer token");
|
||||
assert_eq!(cmd["headers"]["X-Custom"], "value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_headers_invalid_json_error() {
|
||||
let input: Vec<String> = vec![
|
||||
"set".to_string(),
|
||||
"headers".to_string(),
|
||||
"not-valid-json".to_string(),
|
||||
];
|
||||
let result = parse_command(&input, &default_flags());
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_back() {
|
||||
let cmd = parse_command(&args("back"), &default_flags()).unwrap();
|
||||
|
||||
+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) {
|
||||
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));
|
||||
}
|
||||
|
||||
+135
-1
@@ -6,6 +6,9 @@ pub struct Flags {
|
||||
pub headed: bool,
|
||||
pub debug: bool,
|
||||
pub session: String,
|
||||
pub headers: Option<String>,
|
||||
pub executable_path: Option<String>,
|
||||
pub cdp: Option<String>,
|
||||
}
|
||||
|
||||
pub fn parse_flags(args: &[String]) -> Flags {
|
||||
@@ -15,6 +18,9 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
headed: false,
|
||||
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(),
|
||||
cdp: None,
|
||||
};
|
||||
|
||||
let mut i = 0;
|
||||
@@ -30,6 +36,24 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--headers" => {
|
||||
if let Some(h) = args.get(i + 1) {
|
||||
flags.headers = Some(h.clone());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--executable-path" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.executable_path = Some(s.clone());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--cdp" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.cdp = Some(s.clone());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
@@ -43,13 +67,15 @@ 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", "--executable-path", "--cdp"];
|
||||
|
||||
for arg in args.iter() {
|
||||
if skip_next {
|
||||
skip_next = false;
|
||||
continue;
|
||||
}
|
||||
if arg == "--session" {
|
||||
if GLOBAL_FLAGS_WITH_VALUE.contains(&arg.as_str()) {
|
||||
skip_next = true;
|
||||
continue;
|
||||
}
|
||||
@@ -61,3 +87,111 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn args(s: &str) -> Vec<String> {
|
||||
s.split_whitespace().map(String::from).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_headers_flag() {
|
||||
let flags = parse_flags(&args(r#"open example.com --headers {"Auth":"token"}"#));
|
||||
assert_eq!(flags.headers, Some(r#"{"Auth":"token"}"#.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_headers_flag_with_spaces() {
|
||||
// Headers JSON is passed as a single quoted argument in shell
|
||||
let input: Vec<String> = vec![
|
||||
"open".to_string(),
|
||||
"example.com".to_string(),
|
||||
"--headers".to_string(),
|
||||
r#"{"Authorization": "Bearer token"}"#.to_string(),
|
||||
];
|
||||
let flags = parse_flags(&input);
|
||||
assert_eq!(flags.headers, Some(r#"{"Authorization": "Bearer token"}"#.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_no_headers_flag() {
|
||||
let flags = parse_flags(&args("open example.com"));
|
||||
assert!(flags.headers.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_args_removes_headers() {
|
||||
let input: Vec<String> = vec![
|
||||
"open".to_string(),
|
||||
"example.com".to_string(),
|
||||
"--headers".to_string(),
|
||||
r#"{"Auth":"token"}"#.to_string(),
|
||||
];
|
||||
let clean = clean_args(&input);
|
||||
assert_eq!(clean, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_args_removes_headers_at_start() {
|
||||
let input: Vec<String> = vec![
|
||||
"--headers".to_string(),
|
||||
r#"{"Auth":"token"}"#.to_string(),
|
||||
"open".to_string(),
|
||||
"example.com".to_string(),
|
||||
];
|
||||
let clean = clean_args(&input);
|
||||
assert_eq!(clean, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_headers_with_other_flags() {
|
||||
let input: Vec<String> = vec![
|
||||
"open".to_string(),
|
||||
"example.com".to_string(),
|
||||
"--headers".to_string(),
|
||||
r#"{"Auth":"token"}"#.to_string(),
|
||||
"--json".to_string(),
|
||||
"--headed".to_string(),
|
||||
];
|
||||
let flags = parse_flags(&input);
|
||||
assert_eq!(flags.headers, Some(r#"{"Auth":"token"}"#.to_string()));
|
||||
assert!(flags.json);
|
||||
assert!(flags.headed);
|
||||
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
+75
-5
@@ -149,7 +149,9 @@ 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()) {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
if flags.json {
|
||||
println!(r#"{{"success":false,"error":"{}"}}"#, e);
|
||||
} else {
|
||||
@@ -157,13 +159,81 @@ fn main() {
|
||||
}
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
// Connect via CDP if --cdp flag is set
|
||||
if let Some(ref port) = flags.cdp {
|
||||
let cdp_port: u16 = match port.parse::<u32>() {
|
||||
Ok(p) if p == 0 => {
|
||||
let msg = "Invalid CDP port: port must be greater than 0".to_string();
|
||||
if flags.json {
|
||||
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
|
||||
} else {
|
||||
eprintln!("\x1b[31m✗\x1b[0m {}", msg);
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
Ok(p) if p > 65535 => {
|
||||
let msg = format!("Invalid CDP port: {} is out of range (valid range: 1-65535)", p);
|
||||
if flags.json {
|
||||
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
|
||||
} else {
|
||||
eprintln!("\x1b[31m✗\x1b[0m {}", msg);
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
Ok(p) => p as u16,
|
||||
Err(_) => {
|
||||
let msg = format!("Invalid CDP port: '{}' is not a valid number. Port must be a number between 1 and 65535", port);
|
||||
if flags.json {
|
||||
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
|
||||
} else {
|
||||
eprintln!("\x1b[31m✗\x1b[0m {}", msg);
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let launch_cmd = json!({
|
||||
"id": gen_id(),
|
||||
"action": "launch",
|
||||
"cdpPort": cdp_port
|
||||
});
|
||||
|
||||
let err = match send_command(launch_cmd, &flags.session) {
|
||||
Ok(resp) if resp.success => None,
|
||||
Ok(resp) => Some(resp.error.unwrap_or_else(|| "CDP connection failed".to_string())),
|
||||
Err(e) => Some(e.to_string()),
|
||||
};
|
||||
|
||||
if let Some(msg) = err {
|
||||
if flags.json {
|
||||
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
|
||||
} else {
|
||||
eprintln!("\x1b[31m✗\x1b[0m {}", msg);
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Launch headed browser if --headed flag is set (without CDP)
|
||||
if flags.headed && flags.cdp.is_none() {
|
||||
let launch_cmd = json!({
|
||||
"id": gen_id(),
|
||||
"action": "launch",
|
||||
"headless": false
|
||||
});
|
||||
|
||||
// If --headed flag is set, send launch command first to switch to headed mode
|
||||
if flags.headed {
|
||||
let launch_cmd = json!({ "id": gen_id(), "action": "launch", "headless": false });
|
||||
if let Err(e) = send_command(launch_cmd, &flags.session) {
|
||||
if !flags.json {
|
||||
eprintln!("\x1b[33m⚠\x1b[0m Could not switch to headed mode: {}", e);
|
||||
eprintln!("\x1b[33m⚠\x1b[0m Could not launch headed browser: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,12 +162,15 @@ Aliases: goto, navigate
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
--headers <json> Set HTTP headers (scoped to this origin)
|
||||
--headed Show browser window
|
||||
|
||||
Examples:
|
||||
agent-browser open example.com
|
||||
agent-browser open https://github.com
|
||||
agent-browser open localhost:3000
|
||||
agent-browser open api.example.com --headers '{"Authorization": "Bearer token"}'
|
||||
# ^ Headers only sent to api.example.com, not other domains
|
||||
"##,
|
||||
"back" => r##"
|
||||
agent-browser back - Navigate back in history
|
||||
@@ -1186,9 +1189,12 @@ 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)
|
||||
--cdp <port> Connect via CDP (Chrome DevTools Protocol)
|
||||
--debug Debug output
|
||||
|
||||
Examples:
|
||||
@@ -1199,6 +1205,7 @@ Examples:
|
||||
agent-browser find role button click --name Submit
|
||||
agent-browser get text @e1
|
||||
agent-browser screenshot --full
|
||||
agent-browser --cdp 9222 snapshot # Connect via CDP port
|
||||
"#
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agent-browser",
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.4",
|
||||
"description": "Headless browser automation CLI for AI agents",
|
||||
"type": "module",
|
||||
"main": "dist/daemon.js",
|
||||
|
||||
@@ -411,6 +411,12 @@ async function handleNavigate(
|
||||
browser: BrowserManager
|
||||
): Promise<Response<NavigateData>> {
|
||||
const page = browser.getPage();
|
||||
|
||||
// If headers are provided, set up scoped headers for this origin
|
||||
if (command.headers && Object.keys(command.headers).length > 0) {
|
||||
await browser.setScopedHeaders(command.url, command.headers);
|
||||
}
|
||||
|
||||
await page.goto(command.url, {
|
||||
waitUntil: command.waitUntil ?? 'load',
|
||||
});
|
||||
|
||||
@@ -22,6 +22,35 @@ 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();
|
||||
});
|
||||
|
||||
it('should be no-op when relaunching with same options', async () => {
|
||||
const browserInstance = browser.getBrowser();
|
||||
await browser.launch({ id: 'test', action: 'launch', headless: true });
|
||||
expect(browser.getBrowser()).toBe(browserInstance);
|
||||
});
|
||||
|
||||
it('should reconnect when CDP port changes', async () => {
|
||||
const newBrowser = new BrowserManager();
|
||||
await newBrowser.launch({ id: 'test', action: 'launch', headless: true });
|
||||
expect(newBrowser.getBrowser()).not.toBeNull();
|
||||
|
||||
await expect(
|
||||
newBrowser.launch({ id: 'test', action: 'launch', cdpPort: 59999 })
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(newBrowser.getBrowser()).toBeNull();
|
||||
await newBrowser.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('navigation', () => {
|
||||
@@ -294,4 +323,59 @@ describe('BrowserManager', () => {
|
||||
expect(h1).toBe('Example Domain');
|
||||
});
|
||||
});
|
||||
|
||||
describe('scoped headers', () => {
|
||||
it('should register route for scoped headers', async () => {
|
||||
// Test that setScopedHeaders doesn't throw and completes successfully
|
||||
await browser.clearScopedHeaders();
|
||||
await expect(
|
||||
browser.setScopedHeaders('https://example.com', { 'X-Test': 'value' })
|
||||
).resolves.not.toThrow();
|
||||
await browser.clearScopedHeaders();
|
||||
});
|
||||
|
||||
it('should handle full URL origin', async () => {
|
||||
await browser.clearScopedHeaders();
|
||||
await expect(
|
||||
browser.setScopedHeaders('https://api.example.com/path', { Authorization: 'Bearer token' })
|
||||
).resolves.not.toThrow();
|
||||
await browser.clearScopedHeaders();
|
||||
});
|
||||
|
||||
it('should handle hostname-only origin', async () => {
|
||||
await browser.clearScopedHeaders();
|
||||
await expect(
|
||||
browser.setScopedHeaders('example.com', { 'X-Custom': 'value' })
|
||||
).resolves.not.toThrow();
|
||||
await browser.clearScopedHeaders();
|
||||
});
|
||||
|
||||
it('should clear scoped headers for specific origin', async () => {
|
||||
await browser.clearScopedHeaders();
|
||||
await browser.setScopedHeaders('https://example.com', { 'X-Test': 'value' });
|
||||
await expect(browser.clearScopedHeaders('https://example.com')).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should clear all scoped headers', async () => {
|
||||
await browser.setScopedHeaders('https://example.com', { 'X-Test-1': 'value1' });
|
||||
await browser.setScopedHeaders('https://example.org', { 'X-Test-2': 'value2' });
|
||||
await expect(browser.clearScopedHeaders()).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should replace headers when called twice for same origin', async () => {
|
||||
await browser.clearScopedHeaders();
|
||||
await browser.setScopedHeaders('https://example.com', { 'X-First': 'first' });
|
||||
// Second call should replace, not add
|
||||
await expect(
|
||||
browser.setScopedHeaders('https://example.com', { 'X-Second': 'second' })
|
||||
).resolves.not.toThrow();
|
||||
await browser.clearScopedHeaders();
|
||||
});
|
||||
|
||||
it('should handle clearing non-existent origin gracefully', async () => {
|
||||
await browser.clearScopedHeaders();
|
||||
// Should not throw when clearing headers that were never set
|
||||
await expect(browser.clearScopedHeaders('https://never-set.com')).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+204
-14
@@ -39,6 +39,7 @@ interface PageError {
|
||||
*/
|
||||
export class BrowserManager {
|
||||
private browser: Browser | null = null;
|
||||
private cdpPort: number | null = null;
|
||||
private contexts: BrowserContext[] = [];
|
||||
private pages: Page[] = [];
|
||||
private activePageIndex: number = 0;
|
||||
@@ -51,6 +52,7 @@ export class BrowserManager {
|
||||
private isRecordingHar: boolean = false;
|
||||
private refMap: RefMap = {};
|
||||
private lastSnapshot: string = '';
|
||||
private scopedHeaderRoutes: Map<string, (route: Route) => Promise<void>> = new Map();
|
||||
|
||||
/**
|
||||
* Check if browser is launched
|
||||
@@ -439,7 +441,7 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set extra HTTP headers
|
||||
* Set extra HTTP headers (global - all requests)
|
||||
*/
|
||||
async setExtraHeaders(headers: Record<string, string>): Promise<void> {
|
||||
const context = this.contexts[0];
|
||||
@@ -448,6 +450,76 @@ export class BrowserManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set scoped HTTP headers (only for requests matching the origin)
|
||||
* Uses route interception to add headers only to matching requests
|
||||
*/
|
||||
async setScopedHeaders(origin: string, headers: Record<string, string>): Promise<void> {
|
||||
const page = this.getPage();
|
||||
|
||||
// Build URL pattern from origin (e.g., "api.example.com" -> "**://api.example.com/**")
|
||||
// Handle both full URLs and just hostnames
|
||||
let urlPattern: string;
|
||||
try {
|
||||
const url = new URL(origin.startsWith('http') ? origin : `https://${origin}`);
|
||||
// Match any protocol, the host, and any path
|
||||
urlPattern = `**://${url.host}/**`;
|
||||
} catch {
|
||||
// If parsing fails, treat as hostname pattern
|
||||
urlPattern = `**://${origin}/**`;
|
||||
}
|
||||
|
||||
// Remove existing route for this origin if any
|
||||
const existingHandler = this.scopedHeaderRoutes.get(urlPattern);
|
||||
if (existingHandler) {
|
||||
await page.unroute(urlPattern, existingHandler);
|
||||
}
|
||||
|
||||
// Create handler that adds headers to matching requests
|
||||
const handler = async (route: Route) => {
|
||||
const requestHeaders = route.request().headers();
|
||||
await route.continue({
|
||||
headers: {
|
||||
...requestHeaders,
|
||||
...headers,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Store and register the route
|
||||
this.scopedHeaderRoutes.set(urlPattern, handler);
|
||||
await page.route(urlPattern, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear scoped headers for an origin (or all if no origin specified)
|
||||
*/
|
||||
async clearScopedHeaders(origin?: string): Promise<void> {
|
||||
const page = this.getPage();
|
||||
|
||||
if (origin) {
|
||||
let urlPattern: string;
|
||||
try {
|
||||
const url = new URL(origin.startsWith('http') ? origin : `https://${origin}`);
|
||||
urlPattern = `**://${url.host}/**`;
|
||||
} catch {
|
||||
urlPattern = `**://${origin}/**`;
|
||||
}
|
||||
|
||||
const handler = this.scopedHeaderRoutes.get(urlPattern);
|
||||
if (handler) {
|
||||
await page.unroute(urlPattern, handler);
|
||||
this.scopedHeaderRoutes.delete(urlPattern);
|
||||
}
|
||||
} else {
|
||||
// Clear all scoped header routes
|
||||
for (const [pattern, handler] of this.scopedHeaderRoutes) {
|
||||
await page.unroute(pattern, handler);
|
||||
}
|
||||
this.scopedHeaderRoutes.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start tracing
|
||||
*/
|
||||
@@ -502,13 +574,51 @@ export class BrowserManager {
|
||||
return this.browser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an existing CDP connection is still alive
|
||||
* by verifying we can access browser contexts and that at least one has pages
|
||||
*/
|
||||
private isCdpConnectionAlive(): boolean {
|
||||
if (!this.browser) return false;
|
||||
try {
|
||||
const contexts = this.browser.contexts();
|
||||
if (contexts.length === 0) return false;
|
||||
return contexts.some((context) => context.pages().length > 0);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if CDP connection needs to be re-established
|
||||
*/
|
||||
private needsCdpReconnect(cdpPort: number): boolean {
|
||||
if (!this.browser?.isConnected()) return true;
|
||||
if (this.cdpPort !== cdpPort) return true;
|
||||
if (!this.isCdpConnectionAlive()) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the browser with the specified options
|
||||
* If already launched, this is a no-op (browser stays open)
|
||||
*/
|
||||
async launch(options: LaunchCommand): Promise<void> {
|
||||
// If already launched, don't relaunch
|
||||
const cdpPort = options.cdpPort;
|
||||
|
||||
if (this.browser) {
|
||||
const switchingFromCdpToBrowser = !cdpPort && this.cdpPort !== null;
|
||||
const needsCdpReconnect = !!cdpPort && this.needsCdpReconnect(cdpPort);
|
||||
|
||||
if (switchingFromCdpToBrowser || needsCdpReconnect) {
|
||||
await this.close();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (cdpPort) {
|
||||
await this.connectViaCDP(cdpPort);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -520,11 +630,14 @@ export class BrowserManager {
|
||||
// Launch browser
|
||||
this.browser = await launcher.launch({
|
||||
headless: options.headless ?? true,
|
||||
executablePath: options.executablePath,
|
||||
});
|
||||
this.cdpPort = null;
|
||||
|
||||
// Create context with viewport
|
||||
// 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)
|
||||
@@ -542,7 +655,56 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up console and error tracking for a page
|
||||
* Connect to a running browser via CDP (Chrome DevTools Protocol)
|
||||
*/
|
||||
private async connectViaCDP(cdpPort: number | undefined): Promise<void> {
|
||||
if (!cdpPort) {
|
||||
throw new Error('cdpPort is required for CDP connection');
|
||||
}
|
||||
|
||||
const browser = await chromium.connectOverCDP(`http://localhost:${cdpPort}`).catch(() => {
|
||||
throw new Error(
|
||||
`Failed to connect via CDP on port ${cdpPort}. ` +
|
||||
`Make sure the app is running with --remote-debugging-port=${cdpPort}`
|
||||
);
|
||||
});
|
||||
|
||||
// Validate and set up state, cleaning up browser connection if anything fails
|
||||
try {
|
||||
const contexts = browser.contexts();
|
||||
if (contexts.length === 0) {
|
||||
throw new Error('No browser context found. Make sure the app has an open window.');
|
||||
}
|
||||
|
||||
const allPages = contexts.flatMap((context) => context.pages());
|
||||
if (allPages.length === 0) {
|
||||
throw new Error('No page found. Make sure the app has loaded content.');
|
||||
}
|
||||
|
||||
// All validation passed - commit state
|
||||
this.browser = browser;
|
||||
this.cdpPort = cdpPort;
|
||||
|
||||
for (const context of contexts) {
|
||||
this.contexts.push(context);
|
||||
this.setupContextTracking(context);
|
||||
}
|
||||
|
||||
for (const page of allPages) {
|
||||
this.pages.push(page);
|
||||
this.setupPageTracking(page);
|
||||
}
|
||||
|
||||
this.activePageIndex = 0;
|
||||
} catch (error) {
|
||||
// Clean up browser connection if validation or setup failed
|
||||
await browser.close().catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up console, error, and close tracking for a page
|
||||
*/
|
||||
private setupPageTracking(page: Page): void {
|
||||
page.on('console', (msg) => {
|
||||
@@ -559,6 +721,26 @@ export class BrowserManager {
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
page.on('close', () => {
|
||||
const index = this.pages.indexOf(page);
|
||||
if (index !== -1) {
|
||||
this.pages.splice(index, 1);
|
||||
if (this.activePageIndex >= this.pages.length) {
|
||||
this.activePageIndex = Math.max(0, this.pages.length - 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up tracking for new pages in a context (for CDP connections)
|
||||
*/
|
||||
private setupContextTracking(context: BrowserContext): void {
|
||||
context.on('page', (page) => {
|
||||
this.pages.push(page);
|
||||
this.setupPageTracking(page);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -672,21 +854,29 @@ export class BrowserManager {
|
||||
* Close the browser and clean up
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
for (const page of this.pages) {
|
||||
await page.close().catch(() => {});
|
||||
}
|
||||
this.pages = [];
|
||||
|
||||
for (const context of this.contexts) {
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
this.contexts = [];
|
||||
|
||||
// CDP: only disconnect, don't close external app's pages
|
||||
if (this.cdpPort !== null) {
|
||||
if (this.browser) {
|
||||
await this.browser.close().catch(() => {});
|
||||
this.browser = null;
|
||||
}
|
||||
} else {
|
||||
// Regular browser: close everything
|
||||
for (const page of this.pages) {
|
||||
await page.close().catch(() => {});
|
||||
}
|
||||
for (const context of this.contexts) {
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
if (this.browser) {
|
||||
await this.browser.close().catch(() => {});
|
||||
this.browser = null;
|
||||
}
|
||||
}
|
||||
|
||||
this.pages = [];
|
||||
this.contexts = [];
|
||||
this.cdpPort = null;
|
||||
this.activePageIndex = 0;
|
||||
this.refMap = {};
|
||||
this.lastSnapshot = '';
|
||||
|
||||
+6
-1
@@ -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
|
||||
|
||||
@@ -461,6 +461,24 @@ describe('parseCommand', () => {
|
||||
expect(result.command.headless).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse launch with cdpPort', () => {
|
||||
const result = parseCommand(cmd({ id: '1', action: 'launch', cdpPort: 9222 }));
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.command.cdpPort).toBe(9222);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject launch with invalid cdpPort', () => {
|
||||
const result = parseCommand(cmd({ id: '1', action: 'launch', cdpPort: -1 }));
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject launch with non-numeric cdpPort', () => {
|
||||
const result = parseCommand(cmd({ id: '1', action: 'launch', cdpPort: 'invalid' }));
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mouse actions', () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ const launchSchema = baseCommandSchema.extend({
|
||||
})
|
||||
.optional(),
|
||||
browser: z.enum(['chromium', 'firefox', 'webkit']).optional(),
|
||||
cdpPort: z.number().positive().optional(),
|
||||
});
|
||||
|
||||
const navigateSchema = baseCommandSchema.extend({
|
||||
|
||||
@@ -12,12 +12,16 @@ export interface LaunchCommand extends BaseCommand {
|
||||
headless?: boolean;
|
||||
viewport?: { width: number; height: number };
|
||||
browser?: 'chromium' | 'firefox' | 'webkit';
|
||||
headers?: Record<string, string>;
|
||||
executablePath?: string;
|
||||
cdpPort?: number;
|
||||
}
|
||||
|
||||
export interface NavigateCommand extends BaseCommand {
|
||||
action: 'navigate';
|
||||
url: string;
|
||||
waitUntil?: 'load' | 'domcontentloaded' | 'networkidle';
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ClickCommand 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({
|
||||
test: {
|
||||
globals: true,
|
||||
include: ['src/**/*.test.ts'],
|
||||
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user