Compare commits
16
Commits
ctate/0.4.1
...
ctate/web
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3a302056b | ||
|
|
0eb5936f4b | ||
|
|
a533ee8aea | ||
|
|
c6d5f9bca1 | ||
|
|
400dc8b850 | ||
|
|
95675e9d55 | ||
|
|
97b17c98fb | ||
|
|
57a04385c1 | ||
|
|
1a88d7f585 | ||
|
|
4f6fd8ec5c | ||
|
|
3cd0ab468f | ||
|
|
a4fcc1c198 | ||
|
|
574037080c | ||
|
|
278466764b | ||
|
|
f2878c750d | ||
|
|
bc0b99c374 |
@@ -83,3 +83,106 @@ jobs:
|
|||||||
|
|
||||||
- name: Build release binary
|
- name: Build release binary
|
||||||
run: cargo build --release --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
|
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
|
||||||
|
|||||||
@@ -42,3 +42,9 @@ yarn.lock
|
|||||||
|
|
||||||
# opensrc - source code for packages
|
# opensrc - source code for packages
|
||||||
opensrc/
|
opensrc/
|
||||||
|
|
||||||
|
# Docs site
|
||||||
|
docs/node_modules/
|
||||||
|
docs/.next/
|
||||||
|
docs/out/
|
||||||
|
docs/package-lock.json
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ git clone https://github.com/vercel-labs/agent-browser
|
|||||||
cd agent-browser
|
cd agent-browser
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm build
|
pnpm build
|
||||||
agent-browser install
|
pnpm build:native
|
||||||
|
./bin/agent-browser install
|
||||||
|
pnpm link --global
|
||||||
```
|
```
|
||||||
|
|
||||||
### Linux Dependencies
|
### Linux Dependencies
|
||||||
@@ -55,13 +57,13 @@ agent-browser find role button click --name "Submit"
|
|||||||
### Core Commands
|
### Core Commands
|
||||||
|
|
||||||
```bash
|
```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 click <sel> # Click element
|
||||||
agent-browser dblclick <sel> # Double-click element
|
agent-browser dblclick <sel> # Double-click element
|
||||||
agent-browser focus <sel> # Focus element
|
agent-browser focus <sel> # Focus element
|
||||||
agent-browser type <sel> <text> # Type into element
|
agent-browser type <sel> <text> # Type into element
|
||||||
agent-browser fill <sel> <text> # Clear and fill
|
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 keydown <key> # Hold key down
|
||||||
agent-browser keyup <key> # Release key
|
agent-browser keyup <key> # Release key
|
||||||
agent-browser hover <sel> # Hover element
|
agent-browser hover <sel> # Hover element
|
||||||
@@ -69,14 +71,14 @@ agent-browser select <sel> <val> # Select dropdown option
|
|||||||
agent-browser check <sel> # Check checkbox
|
agent-browser check <sel> # Check checkbox
|
||||||
agent-browser uncheck <sel> # Uncheck checkbox
|
agent-browser uncheck <sel> # Uncheck checkbox
|
||||||
agent-browser scroll <dir> [px] # Scroll (up/down/left/right)
|
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 drag <src> <tgt> # Drag and drop
|
||||||
agent-browser upload <sel> <files> # Upload files
|
agent-browser upload <sel> <files> # Upload files
|
||||||
agent-browser screenshot [path] # Take screenshot (--full for full page)
|
agent-browser screenshot [path] # Take screenshot (--full for full page)
|
||||||
agent-browser pdf <path> # Save as PDF
|
agent-browser pdf <path> # Save as PDF
|
||||||
agent-browser snapshot # Accessibility tree with refs (best for AI)
|
agent-browser snapshot # Accessibility tree with refs (best for AI)
|
||||||
agent-browser eval <js> # Run JavaScript
|
agent-browser eval <js> # Run JavaScript
|
||||||
agent-browser close # Close browser
|
agent-browser close # Close browser (aliases: quit, exit)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Get Info
|
### Get Info
|
||||||
@@ -129,9 +131,9 @@ agent-browser find nth 2 "a" text
|
|||||||
### Wait
|
### Wait
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
agent-browser wait <selector> # Wait for element
|
agent-browser wait <selector> # Wait for element to be visible
|
||||||
agent-browser wait <ms> # Wait for time
|
agent-browser wait <ms> # Wait for time (milliseconds)
|
||||||
agent-browser wait --text "Welcome" # Wait for text
|
agent-browser wait --text "Welcome" # Wait for text to appear
|
||||||
agent-browser wait --url "**/dash" # Wait for URL pattern
|
agent-browser wait --url "**/dash" # Wait for URL pattern
|
||||||
agent-browser wait --load networkidle # Wait for load state
|
agent-browser wait --load networkidle # Wait for load state
|
||||||
agent-browser wait --fn "window.ready === true" # Wait for JS condition
|
agent-browser wait --fn "window.ready === true" # Wait for JS condition
|
||||||
@@ -253,6 +255,10 @@ AGENT_BROWSER_SESSION=agent1 agent-browser click "#btn"
|
|||||||
|
|
||||||
# List active sessions
|
# List active sessions
|
||||||
agent-browser session list
|
agent-browser session list
|
||||||
|
# Output:
|
||||||
|
# Active sessions:
|
||||||
|
# -> default
|
||||||
|
# agent1
|
||||||
|
|
||||||
# Show current session
|
# Show current session
|
||||||
agent-browser session
|
agent-browser session
|
||||||
@@ -289,11 +295,14 @@ agent-browser snapshot -i -c -d 5 # Combine options
|
|||||||
| Option | Description |
|
| Option | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `--session <name>` | Use isolated session (or `AGENT_BROWSER_SESSION` env) |
|
| `--session <name>` | Use isolated session (or `AGENT_BROWSER_SESSION` env) |
|
||||||
|
| `--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) |
|
| `--json` | JSON output (for agents) |
|
||||||
| `--full, -f` | Full page screenshot |
|
| `--full, -f` | Full page screenshot |
|
||||||
| `--name, -n` | Locator name filter |
|
| `--name, -n` | Locator name filter |
|
||||||
| `--exact` | Exact text match |
|
| `--exact` | Exact text match |
|
||||||
| `--headed` | Show browser window (not headless) |
|
| `--headed` | Show browser window (not headless) |
|
||||||
|
| `--cdp <port>` | Connect via Chrome DevTools Protocol |
|
||||||
| `--debug` | Debug output |
|
| `--debug` | Debug output |
|
||||||
|
|
||||||
## Selectors
|
## Selectors
|
||||||
@@ -383,6 +392,93 @@ agent-browser open example.com --headed
|
|||||||
|
|
||||||
This opens a visible browser window instead of running headless.
|
This opens a visible browser window instead of running headless.
|
||||||
|
|
||||||
|
## 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
|
## Architecture
|
||||||
|
|
||||||
agent-browser uses a client-daemon architecture:
|
agent-browser uses a client-daemon architecture:
|
||||||
@@ -393,15 +489,17 @@ agent-browser uses a client-daemon architecture:
|
|||||||
|
|
||||||
The daemon starts automatically on first command and persists between commands for fast subsequent operations.
|
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
|
## Platforms
|
||||||
|
|
||||||
| Platform | Binary | Fallback |
|
| Platform | Binary | Fallback |
|
||||||
|----------|--------|----------|
|
|----------|--------|----------|
|
||||||
| macOS ARM64 | ✅ Native Rust | Node.js |
|
| macOS ARM64 | Native Rust | Node.js |
|
||||||
| macOS x64 | ✅ Native Rust | Node.js |
|
| macOS x64 | Native Rust | Node.js |
|
||||||
| Linux ARM64 | ✅ Native Rust | Node.js |
|
| Linux ARM64 | Native Rust | Node.js |
|
||||||
| Linux x64 | ✅ Native Rust | Node.js |
|
| Linux x64 | Native Rust | Node.js |
|
||||||
| Windows | - | Node.js |
|
| Windows x64 | Native Rust | Node.js |
|
||||||
|
|
||||||
## Usage with AI Agents
|
## Usage with AI Agents
|
||||||
|
|
||||||
@@ -436,15 +534,15 @@ Core workflow:
|
|||||||
For Claude Code, a [skill](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices) provides richer context:
|
For Claude Code, a [skill](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices) provides richer context:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp -r node_modules/agent-browser/skills/browsing-web .claude/skills/
|
cp -r node_modules/agent-browser/skills/agent-browser .claude/skills/
|
||||||
```
|
```
|
||||||
|
|
||||||
Or download:
|
Or download:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p .claude/skills/browsing-web
|
mkdir -p .claude/skills/agent-browser
|
||||||
curl -o .claude/skills/browsing-web/SKILL.md \
|
curl -o .claude/skills/agent-browser/SKILL.md \
|
||||||
https://raw.githubusercontent.com/vercel-labs/agent-browser/main/skills/browsing-web/SKILL.md
|
https://raw.githubusercontent.com/vercel-labs/agent-browser/main/skills/agent-browser/SKILL.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
Generated
+1
-1
@@ -4,7 +4,7 @@ version = 4
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agent-browser"
|
name = "agent-browser"
|
||||||
version = "0.4.0"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "agent-browser"
|
name = "agent-browser"
|
||||||
version = "0.4.0"
|
version = "0.4.4"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Fast browser automation CLI for AI agents"
|
description = "Fast browser automation CLI for AI agents"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|||||||
+182
-3
@@ -80,7 +80,14 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
|||||||
} else {
|
} else {
|
||||||
format!("https://{}", url)
|
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" })),
|
"back" => Ok(json!({ "id": id, "action": "back" })),
|
||||||
"forward" => Ok(json!({ "id": id, "action": "forward" })),
|
"forward" => Ok(json!({ "id": id, "action": "forward" })),
|
||||||
@@ -212,6 +219,44 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
|||||||
|
|
||||||
// === Wait ===
|
// === Wait ===
|
||||||
"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 let Some(arg) = rest.get(0) {
|
||||||
if arg.parse::<u64>().is_ok() {
|
if arg.parse::<u64>().is_ok() {
|
||||||
Ok(json!({ "id": id, "action": "wait", "timeout": arg.parse::<u64>().unwrap() }))
|
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 {
|
} else {
|
||||||
Err(ParseError::MissingArguments {
|
Err(ParseError::MissingArguments {
|
||||||
context: "wait".to_string(),
|
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(),
|
context: "set headers".to_string(),
|
||||||
usage: "set headers <json>",
|
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") => {
|
Some("credentials") | Some("auth") => {
|
||||||
let user = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
|
let user = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
|
||||||
@@ -848,6 +899,8 @@ mod tests {
|
|||||||
full: false,
|
full: false,
|
||||||
headed: false,
|
headed: false,
|
||||||
debug: false,
|
debug: false,
|
||||||
|
headers: None,
|
||||||
|
executable_path: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -974,6 +1027,81 @@ mod tests {
|
|||||||
assert_eq!(cmd["url"], "https://example.com");
|
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]
|
#[test]
|
||||||
fn test_back() {
|
fn test_back() {
|
||||||
let cmd = parse_command(&args("back"), &default_flags()).unwrap();
|
let cmd = parse_command(&args("back"), &default_flags()).unwrap();
|
||||||
@@ -1090,6 +1218,57 @@ mod tests {
|
|||||||
assert_eq!(cmd["maxDepth"], 3);
|
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 ===
|
// === Unknown command ===
|
||||||
|
|
||||||
#[test]
|
#[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) {
|
if is_daemon_running(session) && daemon_ready(session) {
|
||||||
return Ok(());
|
return Ok(DaemonResult { already_running: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
let exe_path = env::current_exe().map_err(|e| e.to_string())?;
|
let exe_path = env::current_exe().map_err(|e| e.to_string())?;
|
||||||
@@ -186,6 +192,10 @@ pub fn ensure_daemon(session: &str, headed: bool) -> Result<(), String> {
|
|||||||
cmd.env("AGENT_BROWSER_HEADED", "1");
|
cmd.env("AGENT_BROWSER_HEADED", "1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(path) = executable_path {
|
||||||
|
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
|
||||||
|
}
|
||||||
|
|
||||||
// Create new process group and session to fully detach
|
// Create new process group and session to fully detach
|
||||||
unsafe {
|
unsafe {
|
||||||
cmd.pre_exec(|| {
|
cmd.pre_exec(|| {
|
||||||
@@ -206,8 +216,13 @@ pub fn ensure_daemon(session: &str, headed: bool) -> Result<(), String> {
|
|||||||
{
|
{
|
||||||
use std::os::windows::process::CommandExt;
|
use std::os::windows::process::CommandExt;
|
||||||
|
|
||||||
let mut cmd = Command::new("node");
|
// On Windows, use cmd.exe to run node to ensure proper PATH resolution.
|
||||||
cmd.arg(daemon_path)
|
// 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_DAEMON", "1")
|
||||||
.env("AGENT_BROWSER_SESSION", session);
|
.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");
|
cmd.env("AGENT_BROWSER_HEADED", "1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(path) = executable_path {
|
||||||
|
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
|
||||||
|
}
|
||||||
|
|
||||||
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
|
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
|
||||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
||||||
const DETACHED_PROCESS: u32 = 0x00000008;
|
const DETACHED_PROCESS: u32 = 0x00000008;
|
||||||
@@ -229,7 +248,7 @@ pub fn ensure_daemon(session: &str, headed: bool) -> Result<(), String> {
|
|||||||
|
|
||||||
for _ in 0..50 {
|
for _ in 0..50 {
|
||||||
if daemon_ready(session) {
|
if daemon_ready(session) {
|
||||||
return Ok(());
|
return Ok(DaemonResult { already_running: false });
|
||||||
}
|
}
|
||||||
thread::sleep(Duration::from_millis(100));
|
thread::sleep(Duration::from_millis(100));
|
||||||
}
|
}
|
||||||
|
|||||||
+135
-1
@@ -6,6 +6,9 @@ pub struct Flags {
|
|||||||
pub headed: bool,
|
pub headed: bool,
|
||||||
pub debug: bool,
|
pub debug: bool,
|
||||||
pub session: String,
|
pub session: String,
|
||||||
|
pub headers: Option<String>,
|
||||||
|
pub executable_path: Option<String>,
|
||||||
|
pub cdp: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_flags(args: &[String]) -> Flags {
|
pub fn parse_flags(args: &[String]) -> Flags {
|
||||||
@@ -15,6 +18,9 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
headed: false,
|
headed: false,
|
||||||
debug: false,
|
debug: false,
|
||||||
session: env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string()),
|
session: env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string()),
|
||||||
|
headers: None,
|
||||||
|
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(),
|
||||||
|
cdp: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
@@ -30,6 +36,24 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
i += 1;
|
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;
|
i += 1;
|
||||||
@@ -43,13 +67,15 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
|||||||
|
|
||||||
// Global flags that should be stripped from command args
|
// Global flags that should be stripped from command args
|
||||||
const GLOBAL_FLAGS: &[&str] = &["--json", "--full", "--headed", "--debug"];
|
const GLOBAL_FLAGS: &[&str] = &["--json", "--full", "--headed", "--debug"];
|
||||||
|
// 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() {
|
for arg in args.iter() {
|
||||||
if skip_next {
|
if skip_next {
|
||||||
skip_next = false;
|
skip_next = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if arg == "--session" {
|
if GLOBAL_FLAGS_WITH_VALUE.contains(&arg.as_str()) {
|
||||||
skip_next = true;
|
skip_next = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -61,3 +87,111 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
|||||||
}
|
}
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn args(s: &str) -> Vec<String> {
|
||||||
|
s.split_whitespace().map(String::from).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_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");
|
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")
|
let status = Command::new("npx")
|
||||||
.args(["playwright", "install", "chromium"])
|
.args(["playwright", "install", "chromium"])
|
||||||
.status();
|
.status();
|
||||||
|
|||||||
+94
-12
@@ -21,7 +21,7 @@ use commands::{gen_id, parse_command, ParseError};
|
|||||||
use connection::{ensure_daemon, send_command};
|
use connection::{ensure_daemon, send_command};
|
||||||
use flags::{clean_args, parse_flags};
|
use flags::{clean_args, parse_flags};
|
||||||
use install::run_install;
|
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) {
|
fn run_session(args: &[String], session: &str, json_mode: bool) {
|
||||||
let subcommand = args.get(1).map(|s| s.as_str());
|
let subcommand = args.get(1).map(|s| s.as_str());
|
||||||
@@ -98,7 +98,19 @@ fn main() {
|
|||||||
let flags = parse_flags(&args);
|
let flags = parse_flags(&args);
|
||||||
let clean = clean_args(&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();
|
print_help();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -137,21 +149,91 @@ fn main() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = ensure_daemon(&flags.session, flags.headed) {
|
let daemon_result = match ensure_daemon(&flags.session, flags.headed, flags.executable_path.as_deref()) {
|
||||||
if flags.json {
|
Ok(result) => result,
|
||||||
println!(r#"{{"success":false,"error":"{}"}}"#, e);
|
Err(e) => {
|
||||||
} else {
|
if flags.json {
|
||||||
eprintln!("\x1b[31m✗\x1b[0m {}", e);
|
println!(r#"{{"success":false,"error":"{}"}}"#, e);
|
||||||
|
} else {
|
||||||
|
eprintln!("\x1b[31m✗\x1b[0m {}", e);
|
||||||
|
}
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Warn if executable_path was specified but daemon was already running
|
||||||
|
if daemon_result.already_running && flags.executable_path.is_some() {
|
||||||
|
if !flags.json {
|
||||||
|
eprintln!("\x1b[33m⚠\x1b[0m --executable-path ignored: daemon already running. Use 'agent-browser close' first to restart with new path.");
|
||||||
}
|
}
|
||||||
exit(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If --headed flag is set, send launch command first to switch to headed mode
|
// Connect via CDP if --cdp flag is set
|
||||||
if flags.headed {
|
if let Some(ref port) = flags.cdp {
|
||||||
let launch_cmd = json!({ "id": gen_id(), "action": "launch", "headless": false });
|
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 let Err(e) = send_command(launch_cmd, &flags.session) {
|
if let Err(e) = send_command(launch_cmd, &flags.session) {
|
||||||
if !flags.json {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
pub fn print_help() {
|
||||||
println!(
|
println!(
|
||||||
r#"
|
r#"
|
||||||
@@ -231,9 +1189,12 @@ Snapshot Options:
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
--session <name> Isolated session (or AGENT_BROWSER_SESSION env)
|
--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
|
--json JSON output
|
||||||
--full, -f Full page screenshot
|
--full, -f Full page screenshot
|
||||||
--headed Show browser window (not headless)
|
--headed Show browser window (not headless)
|
||||||
|
--cdp <port> Connect via CDP (Chrome DevTools Protocol)
|
||||||
--debug Debug output
|
--debug Debug output
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
@@ -244,6 +1205,7 @@ Examples:
|
|||||||
agent-browser find role button click --name Submit
|
agent-browser find role button click --name Submit
|
||||||
agent-browser get text @e1
|
agent-browser get text @e1
|
||||||
agent-browser screenshot --full
|
agent-browser screenshot --full
|
||||||
|
agent-browser --cdp 9222 snapshot # Connect via CDP port
|
||||||
"#
|
"#
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "docs",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "16.1.1",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"shiki": "^3.21.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.1.1",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+4327
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { CodeBlock } from "@/components/code-block";
|
||||||
|
|
||||||
|
export default function AgentMode() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||||
|
<div className="prose">
|
||||||
|
<h1>Agent Mode</h1>
|
||||||
|
<p>
|
||||||
|
agent-browser works with any AI coding agent. Use <code>--json</code> for machine-readable output.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Compatible agents</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Claude Code</li>
|
||||||
|
<li>Cursor</li>
|
||||||
|
<li>GitHub Copilot</li>
|
||||||
|
<li>OpenAI Codex</li>
|
||||||
|
<li>Google Gemini</li>
|
||||||
|
<li>opencode</li>
|
||||||
|
<li>Any agent that can run shell commands</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>JSON output</h2>
|
||||||
|
<CodeBlock code={`agent-browser snapshot --json
|
||||||
|
# {"success":true,"data":{"snapshot":"...","refs":{...}}}
|
||||||
|
|
||||||
|
agent-browser get text @e1 --json
|
||||||
|
agent-browser is visible @e2 --json`} />
|
||||||
|
|
||||||
|
<h2>Optimal workflow</h2>
|
||||||
|
<CodeBlock code={`# 1. Navigate and get snapshot
|
||||||
|
agent-browser open example.com
|
||||||
|
agent-browser snapshot -i --json # AI parses tree and refs
|
||||||
|
|
||||||
|
# 2. AI identifies target refs from snapshot
|
||||||
|
# 3. Execute actions using refs
|
||||||
|
agent-browser click @e2
|
||||||
|
agent-browser fill @e3 "input text"
|
||||||
|
|
||||||
|
# 4. Get new snapshot if page changed
|
||||||
|
agent-browser snapshot -i --json`} />
|
||||||
|
|
||||||
|
<h2>Integration</h2>
|
||||||
|
|
||||||
|
<h3>Just ask</h3>
|
||||||
|
<p>The simplest approach:</p>
|
||||||
|
<CodeBlock lang="text" code="Use agent-browser to test the login flow. Run agent-browser --help to see available commands." />
|
||||||
|
<p>The <code>--help</code> output is comprehensive.</p>
|
||||||
|
|
||||||
|
<h3>AGENTS.md / CLAUDE.md</h3>
|
||||||
|
<p>For consistent results, add to your instructions file:</p>
|
||||||
|
<CodeBlock lang="markdown" code={`## Browser Automation
|
||||||
|
|
||||||
|
Use \`agent-browser\` for web automation. Run \`agent-browser --help\` for all commands.
|
||||||
|
|
||||||
|
Core workflow:
|
||||||
|
1. \`agent-browser open <url>\` - Navigate to page
|
||||||
|
2. \`agent-browser snapshot -i\` - Get interactive elements with refs (@e1, @e2)
|
||||||
|
3. \`agent-browser click @e1\` / \`fill @e2 "text"\` - Interact using refs
|
||||||
|
4. Re-snapshot after page changes`} />
|
||||||
|
|
||||||
|
<h3>Claude Code skill</h3>
|
||||||
|
<p>For richer context:</p>
|
||||||
|
<CodeBlock code="cp -r node_modules/agent-browser/skills/agent-browser .claude/skills/" />
|
||||||
|
<p>Or download:</p>
|
||||||
|
<CodeBlock code={`mkdir -p .claude/skills/agent-browser
|
||||||
|
curl -o .claude/skills/agent-browser/SKILL.md \\
|
||||||
|
https://raw.githubusercontent.com/vercel-labs/agent-browser/main/skills/agent-browser/SKILL.md`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { CodeBlock } from "@/components/code-block";
|
||||||
|
|
||||||
|
export default function CDPMode() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||||
|
<div className="prose">
|
||||||
|
<h1>CDP Mode</h1>
|
||||||
|
<p>Connect to an existing browser via Chrome DevTools Protocol:</p>
|
||||||
|
<CodeBlock code={`# 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`} />
|
||||||
|
|
||||||
|
<h2>Use cases</h2>
|
||||||
|
<p>This enables control of:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Electron apps</li>
|
||||||
|
<li>Chrome/Chromium with remote debugging</li>
|
||||||
|
<li>WebView2 applications</li>
|
||||||
|
<li>Any browser exposing a CDP endpoint</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Global options</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Option</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>--session <name></code></td>
|
||||||
|
<td>Use isolated session</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>--headers <json></code></td>
|
||||||
|
<td>HTTP headers scoped to origin</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>--executable-path</code></td>
|
||||||
|
<td>Custom browser executable</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>--json</code></td>
|
||||||
|
<td>JSON output for agents</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>--full, -f</code></td>
|
||||||
|
<td>Full page screenshot</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>--name, -n</code></td>
|
||||||
|
<td>Locator name filter</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>--exact</code></td>
|
||||||
|
<td>Exact text match</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>--headed</code></td>
|
||||||
|
<td>Show browser window</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>--cdp <port></code></td>
|
||||||
|
<td>CDP connection port</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>--debug</code></td>
|
||||||
|
<td>Debug output</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { CodeBlock } from "@/components/code-block";
|
||||||
|
|
||||||
|
export default function Commands() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||||
|
<div className="prose">
|
||||||
|
<h1>Commands</h1>
|
||||||
|
|
||||||
|
<h2>Core</h2>
|
||||||
|
<CodeBlock code={`agent-browser open <url> # Navigate (aliases: goto, navigate)
|
||||||
|
agent-browser click <sel> # Click element
|
||||||
|
agent-browser dblclick <sel> # Double-click
|
||||||
|
agent-browser fill <sel> <text> # Clear and fill
|
||||||
|
agent-browser type <sel> <text> # Type into element
|
||||||
|
agent-browser press <key> # Press key (Enter, Tab, Control+a)
|
||||||
|
agent-browser hover <sel> # Hover element
|
||||||
|
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 screenshot [path] # Screenshot (--full for full page)
|
||||||
|
agent-browser snapshot # Accessibility tree with refs
|
||||||
|
agent-browser eval <js> # Run JavaScript
|
||||||
|
agent-browser close # Close browser`} />
|
||||||
|
|
||||||
|
<h2>Get info</h2>
|
||||||
|
<CodeBlock code={`agent-browser get text <sel> # Get text content
|
||||||
|
agent-browser get html <sel> # Get innerHTML
|
||||||
|
agent-browser get value <sel> # Get input value
|
||||||
|
agent-browser get attr <sel> <attr> # Get attribute
|
||||||
|
agent-browser get title # Get page title
|
||||||
|
agent-browser get url # Get current URL
|
||||||
|
agent-browser get count <sel> # Count matching elements
|
||||||
|
agent-browser get box <sel> # Get bounding box`} />
|
||||||
|
|
||||||
|
<h2>Check state</h2>
|
||||||
|
<CodeBlock code={`agent-browser is visible <sel> # Check if visible
|
||||||
|
agent-browser is enabled <sel> # Check if enabled
|
||||||
|
agent-browser is checked <sel> # Check if checked`} />
|
||||||
|
|
||||||
|
<h2>Find elements</h2>
|
||||||
|
<p>Semantic locators with actions (<code>click</code>, <code>fill</code>, <code>check</code>, <code>hover</code>, <code>text</code>):</p>
|
||||||
|
<CodeBlock code={`agent-browser find role <role> <action> [value]
|
||||||
|
agent-browser find text <text> <action>
|
||||||
|
agent-browser find label <label> <action> [value]
|
||||||
|
agent-browser find placeholder <ph> <action> [value]
|
||||||
|
agent-browser find testid <id> <action> [value]
|
||||||
|
agent-browser find first <sel> <action> [value]
|
||||||
|
agent-browser find nth <n> <sel> <action> [value]`} />
|
||||||
|
<p>Examples:</p>
|
||||||
|
<CodeBlock code={`agent-browser find role button click --name "Submit"
|
||||||
|
agent-browser find label "Email" fill "test@test.com"
|
||||||
|
agent-browser find first ".item" click`} />
|
||||||
|
|
||||||
|
<h2>Wait</h2>
|
||||||
|
<CodeBlock code={`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 --url "**/dash" # Wait for URL pattern
|
||||||
|
agent-browser wait --load networkidle # Wait for load state
|
||||||
|
agent-browser wait --fn "condition" # Wait for JS condition`} />
|
||||||
|
|
||||||
|
<h2>Mouse</h2>
|
||||||
|
<CodeBlock code={`agent-browser mouse move <x> <y> # Move mouse
|
||||||
|
agent-browser mouse down [button] # Press button
|
||||||
|
agent-browser mouse up [button] # Release button
|
||||||
|
agent-browser mouse wheel <dy> [dx] # Scroll wheel`} />
|
||||||
|
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<CodeBlock code={`agent-browser set viewport <w> <h> # Set viewport size
|
||||||
|
agent-browser set device <name> # Emulate device ("iPhone 14")
|
||||||
|
agent-browser set geo <lat> <lng> # Set geolocation
|
||||||
|
agent-browser set offline [on|off] # Toggle offline mode
|
||||||
|
agent-browser set headers <json> # Extra HTTP headers
|
||||||
|
agent-browser set credentials <u> <p> # HTTP basic auth
|
||||||
|
agent-browser set media [dark|light] # Emulate color scheme`} />
|
||||||
|
|
||||||
|
<h2>Cookies & storage</h2>
|
||||||
|
<CodeBlock code={`agent-browser cookies # Get all cookies
|
||||||
|
agent-browser cookies set <name> <val> # Set cookie
|
||||||
|
agent-browser cookies clear # Clear cookies
|
||||||
|
|
||||||
|
agent-browser storage local # Get all localStorage
|
||||||
|
agent-browser storage local <key> # Get specific key
|
||||||
|
agent-browser storage local set <k> <v> # Set value
|
||||||
|
agent-browser storage local clear # Clear all
|
||||||
|
|
||||||
|
agent-browser storage session # Same for sessionStorage`} />
|
||||||
|
|
||||||
|
<h2>Network</h2>
|
||||||
|
<CodeBlock code={`agent-browser network route <url> # Intercept requests
|
||||||
|
agent-browser network route <url> --abort # Block requests
|
||||||
|
agent-browser network route <url> --body <json> # Mock response
|
||||||
|
agent-browser network unroute [url] # Remove routes
|
||||||
|
agent-browser network requests # View tracked requests`} />
|
||||||
|
|
||||||
|
<h2>Tabs & frames</h2>
|
||||||
|
<CodeBlock code={`agent-browser tab # List tabs
|
||||||
|
agent-browser tab new [url] # New tab
|
||||||
|
agent-browser tab <n> # Switch to tab
|
||||||
|
agent-browser tab close [n] # Close tab
|
||||||
|
agent-browser frame <sel> # Switch to iframe
|
||||||
|
agent-browser frame main # Back to main frame`} />
|
||||||
|
|
||||||
|
<h2>Debug</h2>
|
||||||
|
<CodeBlock code={`agent-browser trace start [path] # Start trace
|
||||||
|
agent-browser trace stop [path] # Stop and save trace
|
||||||
|
agent-browser console # View console messages
|
||||||
|
agent-browser errors # View page errors
|
||||||
|
agent-browser highlight <sel> # Highlight element
|
||||||
|
agent-browser state save <path> # Save auth state
|
||||||
|
agent-browser state load <path> # Load auth state`} />
|
||||||
|
|
||||||
|
<h2>Navigation</h2>
|
||||||
|
<CodeBlock code={`agent-browser back # Go back
|
||||||
|
agent-browser forward # Go forward
|
||||||
|
agent-browser reload # Reload page`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,182 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #000000;
|
||||||
|
--foreground: #ededed;
|
||||||
|
--muted: #888888;
|
||||||
|
--border: #222222;
|
||||||
|
--accent: #ededed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--font-sans: var(--font-geist);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: var(--font-geist), system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #333;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code blocks */
|
||||||
|
pre {
|
||||||
|
background: #111 !important;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.875rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-family: var(--font-geist-mono), monospace;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block pre {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
pre {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: var(--font-geist-mono), monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
:not(pre) > code {
|
||||||
|
background: #1a1a1a;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prose */
|
||||||
|
.prose {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.prose h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose h2 {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose h3 {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 2rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose p {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose ul, .prose ol {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose li strong {
|
||||||
|
color: #ccc;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose a {
|
||||||
|
color: var(--foreground);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose a:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose th, .prose td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose th {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose td {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose td code {
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { CodeBlock } from "@/components/code-block";
|
||||||
|
|
||||||
|
export default function Installation() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||||
|
<div className="prose">
|
||||||
|
<h1>Installation</h1>
|
||||||
|
|
||||||
|
<h2>npm (recommended)</h2>
|
||||||
|
<CodeBlock code={`npm install -g agent-browser
|
||||||
|
agent-browser install # Download Chromium`} />
|
||||||
|
|
||||||
|
<h2>From source</h2>
|
||||||
|
<CodeBlock code={`git clone https://github.com/vercel-labs/agent-browser
|
||||||
|
cd agent-browser
|
||||||
|
pnpm install
|
||||||
|
pnpm build
|
||||||
|
pnpm build:native
|
||||||
|
./bin/agent-browser install
|
||||||
|
pnpm link --global`} />
|
||||||
|
|
||||||
|
<h2>Linux dependencies</h2>
|
||||||
|
<p>On Linux, install system dependencies:</p>
|
||||||
|
<CodeBlock code={`agent-browser install --with-deps
|
||||||
|
# or manually: npx playwright install-deps chromium`} />
|
||||||
|
|
||||||
|
<h2>Custom browser</h2>
|
||||||
|
<p>
|
||||||
|
Use a custom browser executable instead of bundled Chromium:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Serverless</strong> - Use <code>@sparticuz/chromium</code> (~50MB vs ~684MB)</li>
|
||||||
|
<li><strong>System browser</strong> - Use existing Chrome installation</li>
|
||||||
|
<li><strong>Custom builds</strong> - Use modified browser builds</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<CodeBlock code={`# 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`} />
|
||||||
|
|
||||||
|
<h3>Serverless example</h3>
|
||||||
|
<CodeBlock lang="typescript" code={`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
|
||||||
|
}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
import { Sidebar } from "@/components/sidebar";
|
||||||
|
|
||||||
|
const geist = Geist({
|
||||||
|
variable: "--font-geist",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "agent-browser",
|
||||||
|
description: "Headless browser automation CLI for AI agents",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en" className="dark">
|
||||||
|
<body
|
||||||
|
className={`${geist.variable} ${geistMono.variable} antialiased bg-zinc-950 text-zinc-100`}
|
||||||
|
>
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex-1 overflow-auto pt-14 lg:pt-0">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { CodeBlock } from "@/components/code-block";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||||
|
<div className="prose">
|
||||||
|
<h1>agent-browser</h1>
|
||||||
|
<p>
|
||||||
|
Headless browser automation CLI for AI agents. Fast Rust CLI with Node.js fallback.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<CodeBlock code="npm install -g agent-browser" />
|
||||||
|
|
||||||
|
<h2>Features</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Universal</strong> - Works with any AI agent: Claude Code, Cursor, Codex, Copilot, Gemini, opencode, and more</li>
|
||||||
|
<li><strong>AI-first</strong> - Snapshot returns accessibility tree with refs for deterministic element selection</li>
|
||||||
|
<li><strong>Fast</strong> - Native Rust CLI for instant command parsing</li>
|
||||||
|
<li><strong>Complete</strong> - 50+ commands for navigation, forms, screenshots, network, storage</li>
|
||||||
|
<li><strong>Sessions</strong> - Multiple isolated browser instances with separate auth</li>
|
||||||
|
<li><strong>Cross-platform</strong> - macOS, Linux, Windows with native binaries</li>
|
||||||
|
<li><strong>Serverless</strong> - Custom executable path for lightweight Chromium builds</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Example</h2>
|
||||||
|
<CodeBlock code={`# Navigate and get snapshot
|
||||||
|
agent-browser open example.com
|
||||||
|
agent-browser snapshot -i
|
||||||
|
|
||||||
|
# Output:
|
||||||
|
# - heading "Example Domain" [ref=e1]
|
||||||
|
# - link "More information..." [ref=e2]
|
||||||
|
|
||||||
|
# Interact using refs
|
||||||
|
agent-browser click @e2
|
||||||
|
agent-browser screenshot page.png
|
||||||
|
agent-browser close`} />
|
||||||
|
|
||||||
|
<h2>Why refs?</h2>
|
||||||
|
<p>
|
||||||
|
The <code>snapshot</code> command returns an accessibility tree where each element
|
||||||
|
has a unique ref like <code>@e1</code>, <code>@e2</code>. This provides:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Deterministic</strong> - Ref points to exact element from snapshot</li>
|
||||||
|
<li><strong>Fast</strong> - No DOM re-query needed</li>
|
||||||
|
<li><strong>AI-friendly</strong> - LLMs can reliably parse and use refs</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Architecture</h2>
|
||||||
|
<p>
|
||||||
|
Client-daemon architecture for optimal performance:
|
||||||
|
</p>
|
||||||
|
<ol>
|
||||||
|
<li><strong>Rust CLI</strong> - Parses commands, communicates with daemon</li>
|
||||||
|
<li><strong>Node.js Daemon</strong> - Manages Playwright browser instance</li>
|
||||||
|
</ol>
|
||||||
|
<p>
|
||||||
|
Daemon starts automatically and persists between commands.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Platforms</h2>
|
||||||
|
<p>
|
||||||
|
Native Rust binaries for macOS (ARM64, x64), Linux (ARM64, x64), and Windows (x64).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { CodeBlock } from "@/components/code-block";
|
||||||
|
|
||||||
|
export default function QuickStart() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||||
|
<div className="prose">
|
||||||
|
<h1>Quick Start</h1>
|
||||||
|
|
||||||
|
<h2>Basic workflow</h2>
|
||||||
|
<CodeBlock code={`agent-browser open example.com
|
||||||
|
agent-browser snapshot # Get accessibility tree with refs
|
||||||
|
agent-browser click @e2 # Click by ref from snapshot
|
||||||
|
agent-browser fill @e3 "test@example.com" # Fill by ref
|
||||||
|
agent-browser get text @e1 # Get text by ref
|
||||||
|
agent-browser screenshot page.png
|
||||||
|
agent-browser close`} />
|
||||||
|
|
||||||
|
<h2>Traditional selectors</h2>
|
||||||
|
<p>CSS selectors and semantic locators also supported:</p>
|
||||||
|
<CodeBlock code={`agent-browser click "#submit"
|
||||||
|
agent-browser fill "#email" "test@example.com"
|
||||||
|
agent-browser find role button click --name "Submit"`} />
|
||||||
|
|
||||||
|
<h2>AI workflow</h2>
|
||||||
|
<p>Optimal workflow for AI agents:</p>
|
||||||
|
<CodeBlock code={`# 1. Navigate and get snapshot
|
||||||
|
agent-browser open example.com
|
||||||
|
agent-browser snapshot -i --json # AI parses tree and refs
|
||||||
|
|
||||||
|
# 2. AI identifies target refs from snapshot
|
||||||
|
# 3. Execute actions using refs
|
||||||
|
agent-browser click @e2
|
||||||
|
agent-browser fill @e3 "input text"
|
||||||
|
|
||||||
|
# 4. Get new snapshot if page changed
|
||||||
|
agent-browser snapshot -i --json`} />
|
||||||
|
|
||||||
|
<h2>Headed mode</h2>
|
||||||
|
<p>Show browser window for debugging:</p>
|
||||||
|
<CodeBlock code="agent-browser open example.com --headed" />
|
||||||
|
|
||||||
|
<h2>JSON output</h2>
|
||||||
|
<p>Use <code>--json</code> for machine-readable output:</p>
|
||||||
|
<CodeBlock code={`agent-browser snapshot --json
|
||||||
|
agent-browser get text @e1 --json
|
||||||
|
agent-browser is visible @e2 --json`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { CodeBlock } from "@/components/code-block";
|
||||||
|
|
||||||
|
export default function Selectors() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||||
|
<div className="prose">
|
||||||
|
<h1>Selectors</h1>
|
||||||
|
|
||||||
|
<h2>Refs (recommended)</h2>
|
||||||
|
<p>
|
||||||
|
Refs provide deterministic element selection from snapshots. Best for AI agents.
|
||||||
|
</p>
|
||||||
|
<CodeBlock code={`# 1. Get snapshot with refs
|
||||||
|
agent-browser snapshot
|
||||||
|
# Output:
|
||||||
|
# - heading "Example Domain" [ref=e1] [level=1]
|
||||||
|
# - button "Submit" [ref=e2]
|
||||||
|
# - textbox "Email" [ref=e3]
|
||||||
|
# - link "Learn more" [ref=e4]
|
||||||
|
|
||||||
|
# 2. Use refs to interact
|
||||||
|
agent-browser click @e2 # Click the button
|
||||||
|
agent-browser fill @e3 "test@example.com" # Fill the textbox
|
||||||
|
agent-browser get text @e1 # Get heading text
|
||||||
|
agent-browser hover @e4 # Hover the link`} />
|
||||||
|
|
||||||
|
<h3>Why refs?</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Deterministic</strong> - Ref points to exact element from snapshot</li>
|
||||||
|
<li><strong>Fast</strong> - No DOM re-query needed</li>
|
||||||
|
<li><strong>AI-friendly</strong> - LLMs can reliably parse and use refs</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>CSS selectors</h2>
|
||||||
|
<CodeBlock code={`agent-browser click "#id"
|
||||||
|
agent-browser click ".class"
|
||||||
|
agent-browser click "div > button"
|
||||||
|
agent-browser click "[data-testid='submit']"`} />
|
||||||
|
|
||||||
|
<h2>Text & XPath</h2>
|
||||||
|
<CodeBlock code={`agent-browser click "text=Submit"
|
||||||
|
agent-browser click "xpath=//button[@type='submit']"`} />
|
||||||
|
|
||||||
|
<h2>Semantic locators</h2>
|
||||||
|
<p>Find elements by role, label, or other semantic properties:</p>
|
||||||
|
<CodeBlock code={`agent-browser find role button click --name "Submit"
|
||||||
|
agent-browser find label "Email" fill "test@test.com"
|
||||||
|
agent-browser find placeholder "Search..." fill "query"
|
||||||
|
agent-browser find testid "submit-btn" click`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { CodeBlock } from "@/components/code-block";
|
||||||
|
|
||||||
|
export default function Sessions() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||||
|
<div className="prose">
|
||||||
|
<h1>Sessions</h1>
|
||||||
|
<p>Run multiple isolated browser instances:</p>
|
||||||
|
<CodeBlock code={`# Different sessions
|
||||||
|
agent-browser --session agent1 open site-a.com
|
||||||
|
agent-browser --session agent2 open site-b.com
|
||||||
|
|
||||||
|
# Or via environment variable
|
||||||
|
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`} />
|
||||||
|
|
||||||
|
<h2>Session isolation</h2>
|
||||||
|
<p>Each session has its own:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Browser instance</li>
|
||||||
|
<li>Cookies and storage</li>
|
||||||
|
<li>Navigation history</li>
|
||||||
|
<li>Authentication state</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Authenticated sessions</h2>
|
||||||
|
<p>
|
||||||
|
Use <code>--headers</code> to set HTTP headers for a specific origin:
|
||||||
|
</p>
|
||||||
|
<CodeBlock code={`# Headers 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 NOT sent
|
||||||
|
agent-browser open other-site.com`} />
|
||||||
|
<p>Useful for:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Skipping login flows</strong> - Authenticate via headers</li>
|
||||||
|
<li><strong>Switching users</strong> - Different auth tokens per session</li>
|
||||||
|
<li><strong>API testing</strong> - Access protected endpoints</li>
|
||||||
|
<li><strong>Security</strong> - Headers scoped to origin, not leaked</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Multiple origins</h2>
|
||||||
|
<CodeBlock code={`agent-browser open api.example.com --headers '{"Authorization": "Bearer token1"}'
|
||||||
|
agent-browser open api.acme.com --headers '{"Authorization": "Bearer token2"}'`} />
|
||||||
|
|
||||||
|
<h2>Global headers</h2>
|
||||||
|
<p>For headers on all domains:</p>
|
||||||
|
<CodeBlock code={`agent-browser set headers '{"X-Custom-Header": "value"}'`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { CodeBlock } from "@/components/code-block";
|
||||||
|
|
||||||
|
export default function Snapshots() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||||
|
<div className="prose">
|
||||||
|
<h1>Snapshots</h1>
|
||||||
|
<p>
|
||||||
|
The <code>snapshot</code> command returns the accessibility tree with refs for AI-friendly interaction.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Options</h2>
|
||||||
|
<p>Filter output to reduce size:</p>
|
||||||
|
<CodeBlock code={`agent-browser snapshot # Full accessibility tree
|
||||||
|
agent-browser snapshot -i # Interactive elements only
|
||||||
|
agent-browser snapshot -c # Compact (remove empty elements)
|
||||||
|
agent-browser snapshot -d 3 # Limit depth to 3 levels
|
||||||
|
agent-browser snapshot -s "#main" # Scope to CSS selector
|
||||||
|
agent-browser snapshot -i -c -d 5 # Combine options`} />
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Option</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>-i, --interactive</code></td>
|
||||||
|
<td>Only interactive elements (buttons, links, inputs)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>-c, --compact</code></td>
|
||||||
|
<td>Remove empty structural elements</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>-d, --depth</code></td>
|
||||||
|
<td>Limit tree depth</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>-s, --selector</code></td>
|
||||||
|
<td>Scope to CSS selector</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Output format</h2>
|
||||||
|
<CodeBlock code={`agent-browser snapshot
|
||||||
|
# Output:
|
||||||
|
# - heading "Example Domain" [ref=e1] [level=1]
|
||||||
|
# - button "Submit" [ref=e2]
|
||||||
|
# - textbox "Email" [ref=e3]
|
||||||
|
# - link "Learn more" [ref=e4]`} />
|
||||||
|
|
||||||
|
<h2>JSON output</h2>
|
||||||
|
<p>Use <code>--json</code> for machine-readable output:</p>
|
||||||
|
<CodeBlock code={`agent-browser snapshot --json
|
||||||
|
# {"success":true,"data":{"snapshot":"...","refs":{"e1":{"role":"heading","name":"Title"},...}}}`} />
|
||||||
|
|
||||||
|
<h2>Best practices</h2>
|
||||||
|
<ol>
|
||||||
|
<li>Use <code>-i</code> to reduce output to actionable elements</li>
|
||||||
|
<li>Use <code>--json</code> for structured parsing</li>
|
||||||
|
<li>Re-snapshot after page changes to get updated refs</li>
|
||||||
|
<li>Scope with <code>-s</code> for specific page sections</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { codeToHtml } from "shiki";
|
||||||
|
import { CopyButton } from "./copy-button";
|
||||||
|
|
||||||
|
interface CodeBlockProps {
|
||||||
|
code: string;
|
||||||
|
lang?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function CodeBlock({ code, lang = "bash" }: CodeBlockProps) {
|
||||||
|
const trimmedCode = code.trim();
|
||||||
|
const html = await codeToHtml(trimmedCode, {
|
||||||
|
lang,
|
||||||
|
theme: "github-dark-default",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="code-block relative group">
|
||||||
|
<CopyButton code={trimmedCode} />
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface CopyButtonProps {
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CopyButton({ code }: CopyButtonProps) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(code);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to copy to clipboard:", error);
|
||||||
|
// Optionally, you could set an error state or show a toast notification here
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="absolute top-2 right-2 p-1.5 rounded text-[#666] hover:text-[#999] hover:bg-[#333] opacity-0 group-hover:opacity-100 transition-all"
|
||||||
|
aria-label="Copy code"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{ name: "Introduction", href: "/" },
|
||||||
|
{ name: "Installation", href: "/installation" },
|
||||||
|
{ name: "Quick Start", href: "/quick-start" },
|
||||||
|
{ name: "Commands", href: "/commands" },
|
||||||
|
{ name: "Selectors", href: "/selectors" },
|
||||||
|
{ name: "Sessions", href: "/sessions" },
|
||||||
|
{ name: "Snapshots", href: "/snapshots" },
|
||||||
|
{ name: "Agent Mode", href: "/agent-mode" },
|
||||||
|
{ name: "CDP Mode", href: "/cdp-mode" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") setIsOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
return () => document.removeEventListener("keydown", handleEscape);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Mobile header */}
|
||||||
|
<header className="lg:hidden fixed top-0 left-0 right-0 z-50 bg-black/90 backdrop-blur-sm border-b border-[#222] px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Link href="/" className="text-sm font-medium">
|
||||||
|
agent-browser
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="p-2 -mr-2 text-[#888] hover:text-white transition-colors"
|
||||||
|
aria-label="Toggle menu"
|
||||||
|
>
|
||||||
|
{isOpen ? (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Mobile overlay */}
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className="lg:hidden fixed inset-0 z-40 bg-black/80"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside
|
||||||
|
className={`
|
||||||
|
fixed lg:sticky top-0 left-0 z-50 lg:z-auto
|
||||||
|
w-56 lg:w-48 h-screen
|
||||||
|
bg-black border-r border-[#222]
|
||||||
|
transform transition-transform duration-150 ease-out
|
||||||
|
${isOpen ? "translate-x-0" : "-translate-x-full lg:translate-x-0"}
|
||||||
|
pt-14 lg:pt-0
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="h-full overflow-y-auto p-5">
|
||||||
|
{/* Desktop header */}
|
||||||
|
<div className="mb-8 hidden lg:block">
|
||||||
|
<Link href="/" className="text-sm font-medium">
|
||||||
|
agent-browser
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="space-y-0.5">
|
||||||
|
{navigation.map((item) => {
|
||||||
|
const isActive = pathname === item.href;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
href={item.href}
|
||||||
|
className={`block px-2 py-1.5 text-[13px] transition-colors ${
|
||||||
|
isActive
|
||||||
|
? "text-white"
|
||||||
|
: "text-[#666] hover:text-[#999]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="mt-8 pt-4 border-t border-[#222] space-y-0.5">
|
||||||
|
<a
|
||||||
|
href="https://github.com/vercel-labs/agent-browser"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="block px-2 py-1.5 text-[13px] text-[#666] hover:text-[#999] transition-colors"
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://www.npmjs.com/package/agent-browser"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="block px-2 py-1.5 text-[13px] text-[#666] hover:text-[#999] transition-colors"
|
||||||
|
>
|
||||||
|
npm
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
+9
-6
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agent-browser",
|
"name": "agent-browser",
|
||||||
"version": "0.4.0",
|
"version": "0.4.4",
|
||||||
"description": "Headless browser automation CLI for AI agents",
|
"description": "Headless browser automation CLI for AI agents",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/daemon.js",
|
"main": "dist/daemon.js",
|
||||||
@@ -15,13 +15,16 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
|
"version:sync": "node scripts/sync-version.js",
|
||||||
|
"version": "npm run version:sync && git add cli/Cargo.toml",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"build:native": "cargo build --release --manifest-path cli/Cargo.toml && node scripts/copy-native.js",
|
"build:native": "npm run version:sync && cargo build --release --manifest-path cli/Cargo.toml && node scripts/copy-native.js",
|
||||||
"build:linux": "docker compose -f docker/docker-compose.yml run --rm build-linux",
|
"build:linux": "npm run version:sync && docker compose -f docker/docker-compose.yml run --rm build-linux",
|
||||||
"build:macos": "(cargo build --release --manifest-path cli/Cargo.toml --target aarch64-apple-darwin & cargo build --release --manifest-path cli/Cargo.toml --target x86_64-apple-darwin & wait) && cp cli/target/aarch64-apple-darwin/release/agent-browser bin/agent-browser-darwin-arm64 && cp cli/target/x86_64-apple-darwin/release/agent-browser bin/agent-browser-darwin-x64",
|
"build:macos": "npm run version:sync && (cargo build --release --manifest-path cli/Cargo.toml --target aarch64-apple-darwin & cargo build --release --manifest-path cli/Cargo.toml --target x86_64-apple-darwin & wait) && cp cli/target/aarch64-apple-darwin/release/agent-browser bin/agent-browser-darwin-arm64 && cp cli/target/x86_64-apple-darwin/release/agent-browser bin/agent-browser-darwin-x64",
|
||||||
"build:windows": "docker compose -f docker/docker-compose.yml run --rm build-windows",
|
"build:windows": "npm run version:sync && docker compose -f docker/docker-compose.yml run --rm build-windows",
|
||||||
"build:all-platforms": "(npm run build:linux & npm run build:windows & wait) && npm run build:macos",
|
"build:all-platforms": "npm run version:sync && (npm run build:linux & npm run build:windows & wait) && npm run build:macos",
|
||||||
"build:docker": "docker build -t agent-browser-builder -f docker/Dockerfile.build .",
|
"build:docker": "docker build -t agent-browser-builder -f docker/Dockerfile.build .",
|
||||||
|
"release": "npm run version:sync && npm run build && npm run build:all-platforms && npm publish",
|
||||||
"start": "node dist/daemon.js",
|
"start": "node dist/daemon.js",
|
||||||
"dev": "tsx src/daemon.ts",
|
"dev": "tsx src/daemon.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Syncs the version from package.json to all other config files.
|
||||||
|
* Run this script before building or releasing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync } from "fs";
|
||||||
|
import { dirname, join } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const rootDir = join(__dirname, "..");
|
||||||
|
|
||||||
|
// Read version from package.json (single source of truth)
|
||||||
|
const packageJson = JSON.parse(
|
||||||
|
readFileSync(join(rootDir, "package.json"), "utf-8")
|
||||||
|
);
|
||||||
|
const version = packageJson.version;
|
||||||
|
|
||||||
|
console.log(`Syncing version ${version} to all config files...`);
|
||||||
|
|
||||||
|
// Update Cargo.toml
|
||||||
|
const cargoTomlPath = join(rootDir, "cli/Cargo.toml");
|
||||||
|
let cargoToml = readFileSync(cargoTomlPath, "utf-8");
|
||||||
|
const cargoVersionRegex = /^version\s*=\s*"[^"]*"/m;
|
||||||
|
const newCargoVersion = `version = "${version}"`;
|
||||||
|
|
||||||
|
if (cargoVersionRegex.test(cargoToml)) {
|
||||||
|
const oldMatch = cargoToml.match(cargoVersionRegex)?.[0];
|
||||||
|
if (oldMatch !== newCargoVersion) {
|
||||||
|
cargoToml = cargoToml.replace(cargoVersionRegex, newCargoVersion);
|
||||||
|
writeFileSync(cargoTomlPath, cargoToml);
|
||||||
|
console.log(` Updated cli/Cargo.toml: ${oldMatch} -> ${newCargoVersion}`);
|
||||||
|
} else {
|
||||||
|
console.log(` cli/Cargo.toml already up to date`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error(" Could not find version field in cli/Cargo.toml");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Version sync complete.");
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
name: browsing-web
|
name: agent-browser
|
||||||
description: Automates browser interactions for web testing, form filling, screenshots, and data extraction. Use when the user needs to navigate websites, interact with web pages, fill forms, take screenshots, test web applications, or extract information from web pages.
|
description: Automates browser interactions for web testing, form filling, screenshots, and data extraction. Use when the user needs to navigate websites, interact with web pages, fill forms, take screenshots, test web applications, or extract information from web pages.
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -411,6 +411,12 @@ async function handleNavigate(
|
|||||||
browser: BrowserManager
|
browser: BrowserManager
|
||||||
): Promise<Response<NavigateData>> {
|
): Promise<Response<NavigateData>> {
|
||||||
const page = browser.getPage();
|
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, {
|
await page.goto(command.url, {
|
||||||
waitUntil: command.waitUntil ?? 'load',
|
waitUntil: command.waitUntil ?? 'load',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,35 @@ describe('BrowserManager', () => {
|
|||||||
const page = browser.getPage();
|
const page = browser.getPage();
|
||||||
expect(page).toBeDefined();
|
expect(page).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should reject invalid executablePath', async () => {
|
||||||
|
const testBrowser = new BrowserManager();
|
||||||
|
await expect(
|
||||||
|
testBrowser.launch({
|
||||||
|
headless: true,
|
||||||
|
executablePath: '/nonexistent/path/to/chromium',
|
||||||
|
})
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
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', () => {
|
describe('navigation', () => {
|
||||||
@@ -294,4 +323,59 @@ describe('BrowserManager', () => {
|
|||||||
expect(h1).toBe('Example Domain');
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+206
-16
@@ -39,6 +39,7 @@ interface PageError {
|
|||||||
*/
|
*/
|
||||||
export class BrowserManager {
|
export class BrowserManager {
|
||||||
private browser: Browser | null = null;
|
private browser: Browser | null = null;
|
||||||
|
private cdpPort: number | null = null;
|
||||||
private contexts: BrowserContext[] = [];
|
private contexts: BrowserContext[] = [];
|
||||||
private pages: Page[] = [];
|
private pages: Page[] = [];
|
||||||
private activePageIndex: number = 0;
|
private activePageIndex: number = 0;
|
||||||
@@ -51,6 +52,7 @@ export class BrowserManager {
|
|||||||
private isRecordingHar: boolean = false;
|
private isRecordingHar: boolean = false;
|
||||||
private refMap: RefMap = {};
|
private refMap: RefMap = {};
|
||||||
private lastSnapshot: string = '';
|
private lastSnapshot: string = '';
|
||||||
|
private scopedHeaderRoutes: Map<string, (route: Route) => Promise<void>> = new Map();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if browser is launched
|
* 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> {
|
async setExtraHeaders(headers: Record<string, string>): Promise<void> {
|
||||||
const context = this.contexts[0];
|
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
|
* Start tracing
|
||||||
*/
|
*/
|
||||||
@@ -502,13 +574,51 @@ export class BrowserManager {
|
|||||||
return this.browser;
|
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
|
* Launch the browser with the specified options
|
||||||
* If already launched, this is a no-op (browser stays open)
|
* If already launched, this is a no-op (browser stays open)
|
||||||
*/
|
*/
|
||||||
async launch(options: LaunchCommand): Promise<void> {
|
async launch(options: LaunchCommand): Promise<void> {
|
||||||
// If already launched, don't relaunch
|
const cdpPort = options.cdpPort;
|
||||||
|
|
||||||
if (this.browser) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,11 +630,14 @@ export class BrowserManager {
|
|||||||
// Launch browser
|
// Launch browser
|
||||||
this.browser = await launcher.launch({
|
this.browser = await launcher.launch({
|
||||||
headless: options.headless ?? true,
|
headless: options.headless ?? true,
|
||||||
|
executablePath: options.executablePath,
|
||||||
});
|
});
|
||||||
|
this.cdpPort = null;
|
||||||
|
|
||||||
// Create context with viewport
|
// Create context with viewport and optional headers
|
||||||
const context = await this.browser.newContext({
|
const context = await this.browser.newContext({
|
||||||
viewport: options.viewport ?? { width: 1280, height: 720 },
|
viewport: options.viewport ?? { width: 1280, height: 720 },
|
||||||
|
extraHTTPHeaders: options.headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set default timeout to 10 seconds (Playwright default is 30s)
|
// 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 {
|
private setupPageTracking(page: Page): void {
|
||||||
page.on('console', (msg) => {
|
page.on('console', (msg) => {
|
||||||
@@ -559,6 +721,26 @@ export class BrowserManager {
|
|||||||
timestamp: Date.now(),
|
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
|
* Close the browser and clean up
|
||||||
*/
|
*/
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
for (const page of this.pages) {
|
// CDP: only disconnect, don't close external app's pages
|
||||||
await page.close().catch(() => {});
|
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.pages = [];
|
||||||
|
|
||||||
for (const context of this.contexts) {
|
|
||||||
await context.close().catch(() => {});
|
|
||||||
}
|
|
||||||
this.contexts = [];
|
this.contexts = [];
|
||||||
|
this.cdpPort = null;
|
||||||
if (this.browser) {
|
|
||||||
await this.browser.close().catch(() => {});
|
|
||||||
this.browser = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.activePageIndex = 0;
|
this.activePageIndex = 0;
|
||||||
this.refMap = {};
|
this.refMap = {};
|
||||||
this.lastSnapshot = '';
|
this.lastSnapshot = '';
|
||||||
|
|||||||
+6
-1
@@ -158,7 +158,12 @@ export async function startDaemon(): Promise<void> {
|
|||||||
parseResult.command.action !== 'launch' &&
|
parseResult.command.action !== 'launch' &&
|
||||||
parseResult.command.action !== 'close'
|
parseResult.command.action !== 'close'
|
||||||
) {
|
) {
|
||||||
await browser.launch({ id: 'auto', action: 'launch', headless: true });
|
await browser.launch({
|
||||||
|
id: 'auto',
|
||||||
|
action: 'launch',
|
||||||
|
headless: true,
|
||||||
|
executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle close command specially
|
// Handle close command specially
|
||||||
|
|||||||
@@ -461,6 +461,24 @@ describe('parseCommand', () => {
|
|||||||
expect(result.command.headless).toBe(false);
|
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', () => {
|
describe('mouse actions', () => {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const launchSchema = baseCommandSchema.extend({
|
|||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
browser: z.enum(['chromium', 'firefox', 'webkit']).optional(),
|
browser: z.enum(['chromium', 'firefox', 'webkit']).optional(),
|
||||||
|
cdpPort: z.number().positive().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const navigateSchema = baseCommandSchema.extend({
|
const navigateSchema = baseCommandSchema.extend({
|
||||||
|
|||||||
@@ -12,12 +12,16 @@ export interface LaunchCommand extends BaseCommand {
|
|||||||
headless?: boolean;
|
headless?: boolean;
|
||||||
viewport?: { width: number; height: number };
|
viewport?: { width: number; height: number };
|
||||||
browser?: 'chromium' | 'firefox' | 'webkit';
|
browser?: 'chromium' | 'firefox' | 'webkit';
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
executablePath?: string;
|
||||||
|
cdpPort?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NavigateCommand extends BaseCommand {
|
export interface NavigateCommand extends BaseCommand {
|
||||||
action: 'navigate';
|
action: 'navigate';
|
||||||
url: string;
|
url: string;
|
||||||
waitUntil?: 'load' | 'domcontentloaded' | 'networkidle';
|
waitUntil?: 'load' | 'domcontentloaded' | 'networkidle';
|
||||||
|
headers?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClickCommand extends BaseCommand {
|
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({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
include: ['src/**/*.test.ts'],
|
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
|
||||||
testTimeout: 30000,
|
testTimeout: 30000,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user