Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
068fc74a9c | ||
|
|
4f6fd8ec5c | ||
|
|
4e5ff20078 | ||
|
|
03e266ee38 | ||
|
|
8c412197ad | ||
|
|
3cd0ab468f | ||
|
|
a4fcc1c198 | ||
|
|
574037080c | ||
|
|
278466764b |
@@ -83,3 +83,106 @@ jobs:
|
||||
|
||||
- name: Build release binary
|
||||
run: cargo build --release --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
|
||||
|
||||
- name: Run Rust tests
|
||||
run: cargo test --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
|
||||
|
||||
windows-integration:
|
||||
name: Windows Integration Test
|
||||
runs-on: windows-latest
|
||||
needs: rust
|
||||
|
||||
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: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-pc-windows-msvc
|
||||
|
||||
- name: Cache Cargo dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
cli/target/
|
||||
key: windows-cargo-x86_64-pc-windows-msvc-${{ hashFiles('cli/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
windows-cargo-x86_64-pc-windows-msvc-
|
||||
|
||||
- name: Build Rust CLI
|
||||
run: cargo build --release --manifest-path cli/Cargo.toml --target x86_64-pc-windows-msvc
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build TypeScript
|
||||
run: pnpm build
|
||||
|
||||
- name: Copy CLI binary to bin directory
|
||||
run: |
|
||||
Copy-Item cli/target/x86_64-pc-windows-msvc/release/agent-browser.exe bin/agent-browser-win32-x64.exe
|
||||
|
||||
- name: Test agent-browser install command
|
||||
run: |
|
||||
$env:PATH = "$pwd\bin;$env:PATH"
|
||||
bin/agent-browser-win32-x64.exe install
|
||||
shell: pwsh
|
||||
|
||||
- name: Verify Chromium was installed
|
||||
run: |
|
||||
$playwrightPath = "$env:LOCALAPPDATA\ms-playwright"
|
||||
if (Test-Path $playwrightPath) {
|
||||
Write-Host "Playwright browsers installed at: $playwrightPath"
|
||||
Get-ChildItem $playwrightPath -Recurse -Depth 2 | Select-Object -First 20
|
||||
} else {
|
||||
Write-Error "Playwright browsers not found!"
|
||||
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
|
||||
|
||||
@@ -55,13 +55,13 @@ agent-browser find role button click --name "Submit"
|
||||
### Core Commands
|
||||
|
||||
```bash
|
||||
agent-browser open <url> # Navigate to URL
|
||||
agent-browser open <url> # Navigate to URL (aliases: goto, navigate)
|
||||
agent-browser click <sel> # Click element
|
||||
agent-browser dblclick <sel> # Double-click element
|
||||
agent-browser focus <sel> # Focus element
|
||||
agent-browser type <sel> <text> # Type into element
|
||||
agent-browser fill <sel> <text> # Clear and fill
|
||||
agent-browser press <key> # Press key (Enter, Tab, Control+a)
|
||||
agent-browser press <key> # Press key (Enter, Tab, Control+a) (alias: key)
|
||||
agent-browser keydown <key> # Hold key down
|
||||
agent-browser keyup <key> # Release key
|
||||
agent-browser hover <sel> # Hover element
|
||||
@@ -69,14 +69,14 @@ agent-browser select <sel> <val> # Select dropdown option
|
||||
agent-browser check <sel> # Check checkbox
|
||||
agent-browser uncheck <sel> # Uncheck checkbox
|
||||
agent-browser scroll <dir> [px] # Scroll (up/down/left/right)
|
||||
agent-browser scrollintoview <sel> # Scroll element into view
|
||||
agent-browser scrollintoview <sel> # Scroll element into view (alias: scrollinto)
|
||||
agent-browser drag <src> <tgt> # Drag and drop
|
||||
agent-browser upload <sel> <files> # Upload files
|
||||
agent-browser screenshot [path] # Take screenshot (--full for full page)
|
||||
agent-browser pdf <path> # Save as PDF
|
||||
agent-browser snapshot # Accessibility tree with refs (best for AI)
|
||||
agent-browser eval <js> # Run JavaScript
|
||||
agent-browser close # Close browser
|
||||
agent-browser close # Close browser (aliases: quit, exit)
|
||||
```
|
||||
|
||||
### Get Info
|
||||
@@ -129,9 +129,9 @@ agent-browser find nth 2 "a" text
|
||||
### Wait
|
||||
|
||||
```bash
|
||||
agent-browser wait <selector> # Wait for element
|
||||
agent-browser wait <ms> # Wait for time
|
||||
agent-browser wait --text "Welcome" # Wait for text
|
||||
agent-browser wait <selector> # Wait for element to be visible
|
||||
agent-browser wait <ms> # Wait for time (milliseconds)
|
||||
agent-browser wait --text "Welcome" # Wait for text to appear
|
||||
agent-browser wait --url "**/dash" # Wait for URL pattern
|
||||
agent-browser wait --load networkidle # Wait for load state
|
||||
agent-browser wait --fn "window.ready === true" # Wait for JS condition
|
||||
@@ -253,6 +253,10 @@ AGENT_BROWSER_SESSION=agent1 agent-browser click "#btn"
|
||||
|
||||
# List active sessions
|
||||
agent-browser session list
|
||||
# Output:
|
||||
# Active sessions:
|
||||
# -> default
|
||||
# agent1
|
||||
|
||||
# Show current session
|
||||
agent-browser session
|
||||
@@ -289,6 +293,8 @@ 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 |
|
||||
@@ -383,6 +389,74 @@ 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
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
agent-browser uses a client-daemon architecture:
|
||||
@@ -393,15 +467,17 @@ agent-browser uses a client-daemon architecture:
|
||||
|
||||
The daemon starts automatically on first command and persists between commands for fast subsequent operations.
|
||||
|
||||
**Browser Engine:** Uses Chromium by default. The daemon also supports Firefox and WebKit via the Playwright protocol.
|
||||
|
||||
## Platforms
|
||||
|
||||
| Platform | Binary | Fallback |
|
||||
|----------|--------|----------|
|
||||
| macOS ARM64 | ✅ Native Rust | Node.js |
|
||||
| macOS x64 | ✅ Native Rust | Node.js |
|
||||
| Linux ARM64 | ✅ Native Rust | Node.js |
|
||||
| Linux x64 | ✅ Native Rust | Node.js |
|
||||
| Windows | - | Node.js |
|
||||
| macOS ARM64 | Native Rust | Node.js |
|
||||
| macOS x64 | Native Rust | Node.js |
|
||||
| Linux ARM64 | Native Rust | Node.js |
|
||||
| Linux x64 | Native Rust | Node.js |
|
||||
| Windows x64 | Native Rust | Node.js |
|
||||
|
||||
## Usage with AI Agents
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "agent-browser"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"serde",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-browser"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
edition = "2021"
|
||||
description = "Fast browser automation CLI for AI agents"
|
||||
license = "Apache-2.0"
|
||||
|
||||
+182
-3
@@ -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" })),
|
||||
@@ -212,6 +219,44 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
|
||||
// === Wait ===
|
||||
"wait" => {
|
||||
// Check for --url flag: wait --url "**/dashboard"
|
||||
if let Some(idx) = rest.iter().position(|&s| s == "--url" || s == "-u") {
|
||||
let url = rest.get(idx + 1).ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "wait --url".to_string(),
|
||||
usage: "wait --url <pattern>",
|
||||
})?;
|
||||
return Ok(json!({ "id": id, "action": "waitforurl", "url": url }));
|
||||
}
|
||||
|
||||
// Check for --load flag: wait --load networkidle
|
||||
if let Some(idx) = rest.iter().position(|&s| s == "--load" || s == "-l") {
|
||||
let state = rest.get(idx + 1).ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "wait --load".to_string(),
|
||||
usage: "wait --load <state>",
|
||||
})?;
|
||||
return Ok(json!({ "id": id, "action": "waitforloadstate", "state": state }));
|
||||
}
|
||||
|
||||
// Check for --fn flag: wait --fn "window.ready === true"
|
||||
if let Some(idx) = rest.iter().position(|&s| s == "--fn" || s == "-f") {
|
||||
let expr = rest.get(idx + 1).ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "wait --fn".to_string(),
|
||||
usage: "wait --fn <expression>",
|
||||
})?;
|
||||
return Ok(json!({ "id": id, "action": "waitforfunction", "expression": expr }));
|
||||
}
|
||||
|
||||
// Check for --text flag: wait --text "Welcome"
|
||||
if let Some(idx) = rest.iter().position(|&s| s == "--text" || s == "-t") {
|
||||
let text = rest.get(idx + 1).ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "wait --text".to_string(),
|
||||
usage: "wait --text <text>",
|
||||
})?;
|
||||
// Use getByText locator to wait for text to appear
|
||||
return Ok(json!({ "id": id, "action": "wait", "selector": format!("text={}", text) }));
|
||||
}
|
||||
|
||||
// Default: selector or timeout
|
||||
if let Some(arg) = rest.get(0) {
|
||||
if arg.parse::<u64>().is_ok() {
|
||||
Ok(json!({ "id": id, "action": "wait", "timeout": arg.parse::<u64>().unwrap() }))
|
||||
@@ -221,7 +266,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
} else {
|
||||
Err(ParseError::MissingArguments {
|
||||
context: "wait".to_string(),
|
||||
usage: "wait <selector|ms>",
|
||||
usage: "wait <selector|ms|--url|--load|--fn|--text>",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -728,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 {
|
||||
@@ -848,6 +899,8 @@ mod tests {
|
||||
full: false,
|
||||
headed: false,
|
||||
debug: false,
|
||||
headers: None,
|
||||
executable_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -974,6 +1027,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();
|
||||
@@ -1090,6 +1218,57 @@ mod tests {
|
||||
assert_eq!(cmd["maxDepth"], 3);
|
||||
}
|
||||
|
||||
// === Wait ===
|
||||
|
||||
#[test]
|
||||
fn test_wait_selector() {
|
||||
let cmd = parse_command(&args("wait #element"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "wait");
|
||||
assert_eq!(cmd["selector"], "#element");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wait_timeout() {
|
||||
let cmd = parse_command(&args("wait 5000"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "wait");
|
||||
assert_eq!(cmd["timeout"], 5000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wait_url() {
|
||||
let cmd = parse_command(&args("wait --url **/dashboard"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "waitforurl");
|
||||
assert_eq!(cmd["url"], "**/dashboard");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wait_load() {
|
||||
let cmd = parse_command(&args("wait --load networkidle"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "waitforloadstate");
|
||||
assert_eq!(cmd["state"], "networkidle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wait_load_missing_state() {
|
||||
let result = parse_command(&args("wait --load"), &default_flags());
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), ParseError::MissingArguments { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wait_fn() {
|
||||
let cmd = parse_command(&args("wait --fn window.ready"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "waitforfunction");
|
||||
assert_eq!(cmd["expression"], "window.ready");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wait_text() {
|
||||
let cmd = parse_command(&args("wait --text Welcome"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "wait");
|
||||
assert_eq!(cmd["selector"], "text=Welcome");
|
||||
}
|
||||
|
||||
// === Unknown command ===
|
||||
|
||||
#[test]
|
||||
|
||||
+24
-5
@@ -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(|| {
|
||||
@@ -206,8 +216,13 @@ pub fn ensure_daemon(session: &str, headed: bool) -> Result<(), String> {
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
let mut cmd = Command::new("node");
|
||||
cmd.arg(daemon_path)
|
||||
// On Windows, use cmd.exe to run node to ensure proper PATH resolution.
|
||||
// This handles cases where node.exe isn't directly in PATH but node.cmd is.
|
||||
// Pass the entire command as a single string to /c to handle paths with spaces.
|
||||
let cmd_string = format!("node \"{}\"", daemon_path.display());
|
||||
let mut cmd = Command::new("cmd");
|
||||
cmd.arg("/c")
|
||||
.arg(&cmd_string)
|
||||
.env("AGENT_BROWSER_DAEMON", "1")
|
||||
.env("AGENT_BROWSER_SESSION", session);
|
||||
|
||||
@@ -215,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;
|
||||
@@ -229,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));
|
||||
}
|
||||
|
||||
+127
-1
@@ -6,6 +6,8 @@ pub struct Flags {
|
||||
pub headed: bool,
|
||||
pub debug: bool,
|
||||
pub session: String,
|
||||
pub headers: Option<String>,
|
||||
pub executable_path: Option<String>,
|
||||
}
|
||||
|
||||
pub fn parse_flags(args: &[String]) -> Flags {
|
||||
@@ -15,6 +17,8 @@ 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(),
|
||||
};
|
||||
|
||||
let mut i = 0;
|
||||
@@ -30,6 +34,18 @@ 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;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
@@ -43,13 +59,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"];
|
||||
|
||||
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 +79,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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,16 @@ pub fn run_install(with_deps: bool) {
|
||||
}
|
||||
|
||||
println!("\x1b[36mInstalling Chromium browser...\x1b[0m");
|
||||
|
||||
// On Windows, we need to use cmd.exe to run npx because npx is actually npx.cmd
|
||||
// and Command::new() doesn't resolve .cmd files the way the shell does.
|
||||
// Pass the entire command as a single string to /c to handle paths with spaces.
|
||||
#[cfg(windows)]
|
||||
let status = Command::new("cmd")
|
||||
.args(["/c", "npx playwright install chromium"])
|
||||
.status();
|
||||
|
||||
#[cfg(not(windows))]
|
||||
let status = Command::new("npx")
|
||||
.args(["playwright", "install", "chromium"])
|
||||
.status();
|
||||
|
||||
+30
-8
@@ -21,7 +21,7 @@ use commands::{gen_id, parse_command, ParseError};
|
||||
use connection::{ensure_daemon, send_command};
|
||||
use flags::{clean_args, parse_flags};
|
||||
use install::run_install;
|
||||
use output::{print_help, print_response};
|
||||
use output::{print_command_help, print_help, print_response};
|
||||
|
||||
fn run_session(args: &[String], session: &str, json_mode: bool) {
|
||||
let subcommand = args.get(1).map(|s| s.as_str());
|
||||
@@ -98,7 +98,19 @@ fn main() {
|
||||
let flags = parse_flags(&args);
|
||||
let clean = clean_args(&args);
|
||||
|
||||
if clean.is_empty() || args.iter().any(|a| a == "--help" || a == "-h") {
|
||||
let has_help = args.iter().any(|a| a == "--help" || a == "-h");
|
||||
|
||||
if clean.is_empty() {
|
||||
print_help();
|
||||
return;
|
||||
}
|
||||
|
||||
if has_help {
|
||||
if let Some(cmd) = clean.get(0) {
|
||||
if print_command_help(cmd) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
print_help();
|
||||
return;
|
||||
}
|
||||
@@ -137,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
|
||||
|
||||
@@ -145,6 +145,964 @@ pub fn print_response(resp: &Response, json_mode: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Print command-specific help. Returns true if help was printed, false if command unknown.
|
||||
pub fn print_command_help(command: &str) -> bool {
|
||||
let help = match command {
|
||||
// === Navigation ===
|
||||
"open" | "goto" | "navigate" => r##"
|
||||
agent-browser open - Navigate to a URL
|
||||
|
||||
Usage: agent-browser open <url>
|
||||
|
||||
Navigates the browser to the specified URL. If no protocol is provided,
|
||||
https:// is automatically prepended.
|
||||
|
||||
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
|
||||
|
||||
Usage: agent-browser back
|
||||
|
||||
Goes back one page in the browser history, equivalent to clicking
|
||||
the browser's back button.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser back
|
||||
"##,
|
||||
"forward" => r##"
|
||||
agent-browser forward - Navigate forward in history
|
||||
|
||||
Usage: agent-browser forward
|
||||
|
||||
Goes forward one page in the browser history, equivalent to clicking
|
||||
the browser's forward button.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser forward
|
||||
"##,
|
||||
"reload" => r##"
|
||||
agent-browser reload - Reload the current page
|
||||
|
||||
Usage: agent-browser reload
|
||||
|
||||
Reloads the current page, equivalent to pressing F5 or clicking
|
||||
the browser's reload button.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser reload
|
||||
"##,
|
||||
|
||||
// === Core Actions ===
|
||||
"click" => r##"
|
||||
agent-browser click - Click an element
|
||||
|
||||
Usage: agent-browser click <selector>
|
||||
|
||||
Clicks on the specified element. The selector can be a CSS selector,
|
||||
XPath, or an element reference from snapshot (e.g., @e1).
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser click "#submit-button"
|
||||
agent-browser click @e1
|
||||
agent-browser click "button.primary"
|
||||
agent-browser click "//button[@type='submit']"
|
||||
"##,
|
||||
"dblclick" => r##"
|
||||
agent-browser dblclick - Double-click an element
|
||||
|
||||
Usage: agent-browser dblclick <selector>
|
||||
|
||||
Double-clicks on the specified element. Useful for text selection
|
||||
or triggering double-click handlers.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser dblclick "#editable-text"
|
||||
agent-browser dblclick @e5
|
||||
"##,
|
||||
"fill" => r##"
|
||||
agent-browser fill - Clear and fill an input field
|
||||
|
||||
Usage: agent-browser fill <selector> <text>
|
||||
|
||||
Clears the input field and fills it with the specified text.
|
||||
This replaces any existing content in the field.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser fill "#email" "user@example.com"
|
||||
agent-browser fill @e3 "Hello World"
|
||||
agent-browser fill "input[name='search']" "query"
|
||||
"##,
|
||||
"type" => r##"
|
||||
agent-browser type - Type text into an element
|
||||
|
||||
Usage: agent-browser type <selector> <text>
|
||||
|
||||
Types text into the specified element character by character.
|
||||
Unlike fill, this does not clear existing content first.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser type "#search" "hello"
|
||||
agent-browser type @e2 "additional text"
|
||||
"##,
|
||||
"hover" => r##"
|
||||
agent-browser hover - Hover over an element
|
||||
|
||||
Usage: agent-browser hover <selector>
|
||||
|
||||
Moves the mouse to hover over the specified element. Useful for
|
||||
triggering hover states or dropdown menus.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser hover "#dropdown-trigger"
|
||||
agent-browser hover @e4
|
||||
"##,
|
||||
"focus" => r##"
|
||||
agent-browser focus - Focus an element
|
||||
|
||||
Usage: agent-browser focus <selector>
|
||||
|
||||
Sets keyboard focus to the specified element.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser focus "#input-field"
|
||||
agent-browser focus @e2
|
||||
"##,
|
||||
"check" => r##"
|
||||
agent-browser check - Check a checkbox
|
||||
|
||||
Usage: agent-browser check <selector>
|
||||
|
||||
Checks a checkbox element. If already checked, no action is taken.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser check "#terms-checkbox"
|
||||
agent-browser check @e7
|
||||
"##,
|
||||
"uncheck" => r##"
|
||||
agent-browser uncheck - Uncheck a checkbox
|
||||
|
||||
Usage: agent-browser uncheck <selector>
|
||||
|
||||
Unchecks a checkbox element. If already unchecked, no action is taken.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser uncheck "#newsletter-opt-in"
|
||||
agent-browser uncheck @e8
|
||||
"##,
|
||||
"select" => r##"
|
||||
agent-browser select - Select a dropdown option
|
||||
|
||||
Usage: agent-browser select <selector> <value>
|
||||
|
||||
Selects an option in a <select> dropdown by its value attribute.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser select "#country" "US"
|
||||
agent-browser select @e5 "option2"
|
||||
"##,
|
||||
"drag" => r##"
|
||||
agent-browser drag - Drag and drop
|
||||
|
||||
Usage: agent-browser drag <source> <target>
|
||||
|
||||
Drags an element from source to target location.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser drag "#draggable" "#drop-zone"
|
||||
agent-browser drag @e1 @e2
|
||||
"##,
|
||||
"upload" => r##"
|
||||
agent-browser upload - Upload files
|
||||
|
||||
Usage: agent-browser upload <selector> <files...>
|
||||
|
||||
Uploads one or more files to a file input element.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser upload "#file-input" ./document.pdf
|
||||
agent-browser upload @e3 ./image1.png ./image2.png
|
||||
"##,
|
||||
|
||||
// === Keyboard ===
|
||||
"press" | "key" => r##"
|
||||
agent-browser press - Press a key or key combination
|
||||
|
||||
Usage: agent-browser press <key>
|
||||
|
||||
Presses a key or key combination. Supports special keys and modifiers.
|
||||
|
||||
Aliases: key
|
||||
|
||||
Special Keys:
|
||||
Enter, Tab, Escape, Backspace, Delete, Space
|
||||
ArrowUp, ArrowDown, ArrowLeft, ArrowRight
|
||||
Home, End, PageUp, PageDown
|
||||
F1-F12
|
||||
|
||||
Modifiers (combine with +):
|
||||
Control, Alt, Shift, Meta
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser press Enter
|
||||
agent-browser press Tab
|
||||
agent-browser press Control+a
|
||||
agent-browser press Control+Shift+s
|
||||
agent-browser press Escape
|
||||
"##,
|
||||
"keydown" => r##"
|
||||
agent-browser keydown - Press a key down (without release)
|
||||
|
||||
Usage: agent-browser keydown <key>
|
||||
|
||||
Presses a key down without releasing it. Use keyup to release.
|
||||
Useful for holding modifier keys.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser keydown Shift
|
||||
agent-browser keydown Control
|
||||
"##,
|
||||
"keyup" => r##"
|
||||
agent-browser keyup - Release a key
|
||||
|
||||
Usage: agent-browser keyup <key>
|
||||
|
||||
Releases a key that was pressed with keydown.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser keyup Shift
|
||||
agent-browser keyup Control
|
||||
"##,
|
||||
|
||||
// === Scroll ===
|
||||
"scroll" => r##"
|
||||
agent-browser scroll - Scroll the page
|
||||
|
||||
Usage: agent-browser scroll [direction] [amount]
|
||||
|
||||
Scrolls the page in the specified direction.
|
||||
|
||||
Arguments:
|
||||
direction up, down, left, right (default: down)
|
||||
amount Pixels to scroll (default: 300)
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser scroll
|
||||
agent-browser scroll down 500
|
||||
agent-browser scroll up 200
|
||||
agent-browser scroll left 100
|
||||
"##,
|
||||
"scrollintoview" | "scrollinto" => r##"
|
||||
agent-browser scrollintoview - Scroll element into view
|
||||
|
||||
Usage: agent-browser scrollintoview <selector>
|
||||
|
||||
Scrolls the page until the specified element is visible in the viewport.
|
||||
|
||||
Aliases: scrollinto
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser scrollintoview "#footer"
|
||||
agent-browser scrollintoview @e15
|
||||
"##,
|
||||
|
||||
// === Wait ===
|
||||
"wait" => r##"
|
||||
agent-browser wait - Wait for condition
|
||||
|
||||
Usage: agent-browser wait <selector|ms|option>
|
||||
|
||||
Waits for an element to appear, a timeout, or other conditions.
|
||||
|
||||
Modes:
|
||||
<selector> Wait for element to appear
|
||||
<ms> Wait for specified milliseconds
|
||||
--url <pattern> Wait for URL to match pattern
|
||||
--load <state> Wait for load state (load, domcontentloaded, networkidle)
|
||||
--fn <expression> Wait for JavaScript expression to be truthy
|
||||
--text <text> Wait for text to appear on page
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser wait "#loading-spinner"
|
||||
agent-browser wait 2000
|
||||
agent-browser wait --url "**/dashboard"
|
||||
agent-browser wait --load networkidle
|
||||
agent-browser wait --fn "window.appReady === true"
|
||||
agent-browser wait --text "Welcome back"
|
||||
"##,
|
||||
|
||||
// === Screenshot/PDF ===
|
||||
"screenshot" => r##"
|
||||
agent-browser screenshot - Take a screenshot
|
||||
|
||||
Usage: agent-browser screenshot [path]
|
||||
|
||||
Captures a screenshot of the current page. If no path is provided,
|
||||
outputs base64-encoded image data.
|
||||
|
||||
Options:
|
||||
--full, -f Capture full page (not just viewport)
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser screenshot
|
||||
agent-browser screenshot ./screenshot.png
|
||||
agent-browser screenshot --full ./full-page.png
|
||||
"##,
|
||||
"pdf" => r##"
|
||||
agent-browser pdf - Save page as PDF
|
||||
|
||||
Usage: agent-browser pdf <path>
|
||||
|
||||
Saves the current page as a PDF file.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser pdf ./page.pdf
|
||||
agent-browser pdf ~/Documents/report.pdf
|
||||
"##,
|
||||
|
||||
// === Snapshot ===
|
||||
"snapshot" => r##"
|
||||
agent-browser snapshot - Get accessibility tree snapshot
|
||||
|
||||
Usage: agent-browser snapshot [options]
|
||||
|
||||
Returns an accessibility tree representation of the page with element
|
||||
references (like @e1, @e2) that can be used in subsequent commands.
|
||||
Designed for AI agents to understand page structure.
|
||||
|
||||
Options:
|
||||
-i, --interactive Only include interactive elements
|
||||
-c, --compact Remove empty structural elements
|
||||
-d, --depth <n> Limit tree depth
|
||||
-s, --selector <sel> Scope snapshot to CSS selector
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser snapshot
|
||||
agent-browser snapshot -i
|
||||
agent-browser snapshot --compact --depth 5
|
||||
agent-browser snapshot -s "#main-content"
|
||||
"##,
|
||||
|
||||
// === Eval ===
|
||||
"eval" => r##"
|
||||
agent-browser eval - Execute JavaScript
|
||||
|
||||
Usage: agent-browser eval <script>
|
||||
|
||||
Executes JavaScript code in the browser context and returns the result.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser eval "document.title"
|
||||
agent-browser eval "window.location.href"
|
||||
agent-browser eval "document.querySelectorAll('a').length"
|
||||
"##,
|
||||
|
||||
// === Close ===
|
||||
"close" | "quit" | "exit" => r##"
|
||||
agent-browser close - Close the browser
|
||||
|
||||
Usage: agent-browser close
|
||||
|
||||
Closes the browser instance for the current session.
|
||||
|
||||
Aliases: quit, exit
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser close
|
||||
agent-browser close --session mysession
|
||||
"##,
|
||||
|
||||
// === Get ===
|
||||
"get" => r##"
|
||||
agent-browser get - Retrieve information from elements or page
|
||||
|
||||
Usage: agent-browser get <subcommand> [args]
|
||||
|
||||
Retrieves various types of information from elements or the page.
|
||||
|
||||
Subcommands:
|
||||
text <selector> Get text content of element
|
||||
html <selector> Get inner HTML of element
|
||||
value <selector> Get value of input element
|
||||
attr <selector> <name> Get attribute value
|
||||
title Get page title
|
||||
url Get current URL
|
||||
count <selector> Count matching elements
|
||||
box <selector> Get bounding box (x, y, width, height)
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser get text @e1
|
||||
agent-browser get html "#content"
|
||||
agent-browser get value "#email-input"
|
||||
agent-browser get attr "#link" href
|
||||
agent-browser get title
|
||||
agent-browser get url
|
||||
agent-browser get count "li.item"
|
||||
agent-browser get box "#header"
|
||||
"##,
|
||||
|
||||
// === Is ===
|
||||
"is" => r##"
|
||||
agent-browser is - Check element state
|
||||
|
||||
Usage: agent-browser is <subcommand> <selector>
|
||||
|
||||
Checks the state of an element and returns true/false.
|
||||
|
||||
Subcommands:
|
||||
visible <selector> Check if element is visible
|
||||
enabled <selector> Check if element is enabled (not disabled)
|
||||
checked <selector> Check if checkbox/radio is checked
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser is visible "#modal"
|
||||
agent-browser is enabled "#submit-btn"
|
||||
agent-browser is checked "#agree-checkbox"
|
||||
"##,
|
||||
|
||||
// === Find ===
|
||||
"find" => r##"
|
||||
agent-browser find - Find and interact with elements by locator
|
||||
|
||||
Usage: agent-browser find <locator> <value> [action] [text]
|
||||
|
||||
Finds elements using semantic locators and optionally performs an action.
|
||||
|
||||
Locators:
|
||||
role <role> Find by ARIA role (--name <n>, --exact)
|
||||
text <text> Find by text content (--exact)
|
||||
label <label> Find by associated label (--exact)
|
||||
placeholder <text> Find by placeholder text (--exact)
|
||||
alt <text> Find by alt text (--exact)
|
||||
title <text> Find by title attribute (--exact)
|
||||
testid <id> Find by data-testid attribute
|
||||
first <selector> First matching element
|
||||
last <selector> Last matching element
|
||||
nth <index> <selector> Nth matching element (0-based)
|
||||
|
||||
Actions (default: click):
|
||||
click, fill, type, hover, focus, check, uncheck
|
||||
|
||||
Options:
|
||||
--name <name> Filter role by accessible name
|
||||
--exact Require exact text match
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser find role button click --name Submit
|
||||
agent-browser find text "Sign In" click
|
||||
agent-browser find label "Email" fill "user@example.com"
|
||||
agent-browser find placeholder "Search..." type "query"
|
||||
agent-browser find testid "login-form" click
|
||||
agent-browser find first "li.item" click
|
||||
agent-browser find nth 2 ".card" hover
|
||||
"##,
|
||||
|
||||
// === Mouse ===
|
||||
"mouse" => r##"
|
||||
agent-browser mouse - Low-level mouse operations
|
||||
|
||||
Usage: agent-browser mouse <subcommand> [args]
|
||||
|
||||
Performs low-level mouse operations for precise control.
|
||||
|
||||
Subcommands:
|
||||
move <x> <y> Move mouse to coordinates
|
||||
down [button] Press mouse button (left, right, middle)
|
||||
up [button] Release mouse button
|
||||
wheel <dy> [dx] Scroll mouse wheel
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser mouse move 100 200
|
||||
agent-browser mouse down
|
||||
agent-browser mouse up
|
||||
agent-browser mouse down right
|
||||
agent-browser mouse wheel 100
|
||||
agent-browser mouse wheel -50 0
|
||||
"##,
|
||||
|
||||
// === Set ===
|
||||
"set" => r##"
|
||||
agent-browser set - Configure browser settings
|
||||
|
||||
Usage: agent-browser set <setting> [args]
|
||||
|
||||
Configures various browser settings and emulation options.
|
||||
|
||||
Settings:
|
||||
viewport <w> <h> Set viewport size
|
||||
device <name> Emulate device (e.g., "iPhone 12")
|
||||
geo <lat> <lng> Set geolocation
|
||||
offline [on|off] Toggle offline mode
|
||||
headers <json> Set extra HTTP headers
|
||||
credentials <user> <pass> Set HTTP authentication
|
||||
media [dark|light] Set color scheme preference
|
||||
[reduced-motion] Enable reduced motion
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser set viewport 1920 1080
|
||||
agent-browser set device "iPhone 12"
|
||||
agent-browser set geo 37.7749 -122.4194
|
||||
agent-browser set offline on
|
||||
agent-browser set headers '{"X-Custom": "value"}'
|
||||
agent-browser set credentials admin secret123
|
||||
agent-browser set media dark
|
||||
agent-browser set media light reduced-motion
|
||||
"##,
|
||||
|
||||
// === Network ===
|
||||
"network" => r##"
|
||||
agent-browser network - Network interception and monitoring
|
||||
|
||||
Usage: agent-browser network <subcommand> [args]
|
||||
|
||||
Intercept, mock, or monitor network requests.
|
||||
|
||||
Subcommands:
|
||||
route <url> [options] Intercept requests matching URL pattern
|
||||
--abort Abort matching requests
|
||||
--body <json> Respond with custom body
|
||||
unroute [url] Remove route (all if no URL)
|
||||
requests [options] List captured requests
|
||||
--clear Clear request log
|
||||
--filter <pattern> Filter by URL pattern
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser network route "**/api/*" --abort
|
||||
agent-browser network route "**/data.json" --body '{"mock": true}'
|
||||
agent-browser network unroute
|
||||
agent-browser network requests
|
||||
agent-browser network requests --filter "api"
|
||||
agent-browser network requests --clear
|
||||
"##,
|
||||
|
||||
// === Storage ===
|
||||
"storage" => r##"
|
||||
agent-browser storage - Manage web storage
|
||||
|
||||
Usage: agent-browser storage <type> [operation] [key] [value]
|
||||
|
||||
Manage localStorage and sessionStorage.
|
||||
|
||||
Types:
|
||||
local localStorage
|
||||
session sessionStorage
|
||||
|
||||
Operations:
|
||||
get [key] Get all storage or specific key
|
||||
set <key> <value> Set a key-value pair
|
||||
clear Clear all storage
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser storage local
|
||||
agent-browser storage local get authToken
|
||||
agent-browser storage local set theme "dark"
|
||||
agent-browser storage local clear
|
||||
agent-browser storage session get userId
|
||||
"##,
|
||||
|
||||
// === Cookies ===
|
||||
"cookies" => r##"
|
||||
agent-browser cookies - Manage browser cookies
|
||||
|
||||
Usage: agent-browser cookies [operation] [args]
|
||||
|
||||
Manage browser cookies for the current context.
|
||||
|
||||
Operations:
|
||||
get Get all cookies (default)
|
||||
set <name> <value> Set a cookie
|
||||
clear Clear all cookies
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser cookies
|
||||
agent-browser cookies get
|
||||
agent-browser cookies set session_id "abc123"
|
||||
agent-browser cookies clear
|
||||
"##,
|
||||
|
||||
// === Tabs ===
|
||||
"tab" => r##"
|
||||
agent-browser tab - Manage browser tabs
|
||||
|
||||
Usage: agent-browser tab [operation] [args]
|
||||
|
||||
Manage browser tabs in the current window.
|
||||
|
||||
Operations:
|
||||
list List all tabs (default)
|
||||
new [url] Open new tab
|
||||
close [index] Close tab (current if no index)
|
||||
<index> Switch to tab by index
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser tab
|
||||
agent-browser tab list
|
||||
agent-browser tab new
|
||||
agent-browser tab new https://example.com
|
||||
agent-browser tab 2
|
||||
agent-browser tab close
|
||||
agent-browser tab close 1
|
||||
"##,
|
||||
|
||||
// === Window ===
|
||||
"window" => r##"
|
||||
agent-browser window - Manage browser windows
|
||||
|
||||
Usage: agent-browser window <operation>
|
||||
|
||||
Manage browser windows.
|
||||
|
||||
Operations:
|
||||
new Open new browser window
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser window new
|
||||
"##,
|
||||
|
||||
// === Frame ===
|
||||
"frame" => r##"
|
||||
agent-browser frame - Switch frame context
|
||||
|
||||
Usage: agent-browser frame <selector|main>
|
||||
|
||||
Switch to an iframe or back to the main frame.
|
||||
|
||||
Arguments:
|
||||
<selector> CSS selector for iframe
|
||||
main Switch back to main frame
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser frame "#embed-iframe"
|
||||
agent-browser frame "iframe[name='content']"
|
||||
agent-browser frame main
|
||||
"##,
|
||||
|
||||
// === Dialog ===
|
||||
"dialog" => r##"
|
||||
agent-browser dialog - Handle browser dialogs
|
||||
|
||||
Usage: agent-browser dialog <response> [text]
|
||||
|
||||
Respond to browser dialogs (alert, confirm, prompt).
|
||||
|
||||
Operations:
|
||||
accept [text] Accept dialog, optionally with prompt text
|
||||
dismiss Dismiss/cancel dialog
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser dialog accept
|
||||
agent-browser dialog accept "my input"
|
||||
agent-browser dialog dismiss
|
||||
"##,
|
||||
|
||||
// === Trace ===
|
||||
"trace" => r##"
|
||||
agent-browser trace - Record execution trace
|
||||
|
||||
Usage: agent-browser trace <operation> [path]
|
||||
|
||||
Record a trace for debugging with Playwright Trace Viewer.
|
||||
|
||||
Operations:
|
||||
start [path] Start recording trace
|
||||
stop [path] Stop recording and save trace
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser trace start
|
||||
agent-browser trace start ./my-trace
|
||||
agent-browser trace stop
|
||||
agent-browser trace stop ./debug-trace.zip
|
||||
"##,
|
||||
|
||||
// === Console/Errors ===
|
||||
"console" => r##"
|
||||
agent-browser console - View console logs
|
||||
|
||||
Usage: agent-browser console [--clear]
|
||||
|
||||
View browser console output (log, warn, error, info).
|
||||
|
||||
Options:
|
||||
--clear Clear console log buffer
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser console
|
||||
agent-browser console --clear
|
||||
"##,
|
||||
"errors" => r##"
|
||||
agent-browser errors - View page errors
|
||||
|
||||
Usage: agent-browser errors [--clear]
|
||||
|
||||
View JavaScript errors and uncaught exceptions.
|
||||
|
||||
Options:
|
||||
--clear Clear error buffer
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser errors
|
||||
agent-browser errors --clear
|
||||
"##,
|
||||
|
||||
// === Highlight ===
|
||||
"highlight" => r##"
|
||||
agent-browser highlight - Highlight an element
|
||||
|
||||
Usage: agent-browser highlight <selector>
|
||||
|
||||
Visually highlights an element on the page for debugging.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser highlight "#target-element"
|
||||
agent-browser highlight @e5
|
||||
"##,
|
||||
|
||||
// === State ===
|
||||
"state" => r##"
|
||||
agent-browser state - Save/load browser state
|
||||
|
||||
Usage: agent-browser state <operation> <path>
|
||||
|
||||
Save or restore browser state (cookies, localStorage, sessionStorage).
|
||||
|
||||
Operations:
|
||||
save <path> Save current state to file
|
||||
load <path> Load state from file
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser state save ./auth-state.json
|
||||
agent-browser state load ./auth-state.json
|
||||
"##,
|
||||
|
||||
// === Session ===
|
||||
"session" => r##"
|
||||
agent-browser session - Manage sessions
|
||||
|
||||
Usage: agent-browser session [operation]
|
||||
|
||||
Manage isolated browser sessions. Each session has its own browser
|
||||
instance with separate cookies, storage, and state.
|
||||
|
||||
Operations:
|
||||
(none) Show current session name
|
||||
list List all active sessions
|
||||
|
||||
Environment:
|
||||
AGENT_BROWSER_SESSION Default session name
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser session
|
||||
agent-browser session list
|
||||
agent-browser --session test open example.com
|
||||
"##,
|
||||
|
||||
// === Install ===
|
||||
"install" => r##"
|
||||
agent-browser install - Install browser binaries
|
||||
|
||||
Usage: agent-browser install [--with-deps]
|
||||
|
||||
Downloads and installs browser binaries required for automation.
|
||||
|
||||
Options:
|
||||
-d, --with-deps Also install system dependencies (Linux only)
|
||||
|
||||
Examples:
|
||||
agent-browser install
|
||||
agent-browser install --with-deps
|
||||
"##,
|
||||
|
||||
_ => return false,
|
||||
};
|
||||
println!("{}", help.trim());
|
||||
true
|
||||
}
|
||||
|
||||
pub fn print_help() {
|
||||
println!(
|
||||
r#"
|
||||
@@ -231,6 +1189,8 @@ 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)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agent-browser",
|
||||
"version": "0.4.2",
|
||||
"version": "0.4.3",
|
||||
"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,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', () => {
|
||||
@@ -294,4 +304,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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+75
-2
@@ -51,6 +51,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 +440,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 +449,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
|
||||
*/
|
||||
@@ -520,11 +591,13 @@ export class BrowserManager {
|
||||
// Launch browser
|
||||
this.browser = await launcher.launch({
|
||||
headless: options.headless ?? true,
|
||||
executablePath: options.executablePath,
|
||||
});
|
||||
|
||||
// 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)
|
||||
|
||||
+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
|
||||
|
||||
@@ -12,12 +12,15 @@ export interface LaunchCommand extends BaseCommand {
|
||||
headless?: boolean;
|
||||
viewport?: { width: number; height: number };
|
||||
browser?: 'chromium' | 'firefox' | 'webkit';
|
||||
headers?: Record<string, string>;
|
||||
executablePath?: string;
|
||||
}
|
||||
|
||||
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