diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c713c2..218fd1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,43 +18,6 @@ jobs: - name: Check version sync run: node scripts/check-version-sync.js - typescript: - name: TypeScript (Node ${{ matrix.node-version }}) - runs-on: ubuntu-latest - strategy: - matrix: - node-version: [20, 22] - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 9 - - - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - cache: pnpm - - - name: Install dependencies - run: pnpm install - - - name: Typecheck - run: pnpm typecheck - - - name: Format check - run: pnpm format:check - - - name: Install Playwright browsers - run: pnpm exec playwright install --with-deps chromium - - - name: Run tests - run: pnpm test - rust: name: Rust runs-on: ubuntu-latest @@ -112,6 +75,30 @@ jobs: - name: Run Rust tests run: cargo test --profile ci --manifest-path cli/Cargo.toml --target ${{ matrix.target }} + native-e2e: + name: Native E2E Tests + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + needs: rust + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust build artifacts + uses: Swatinem/rust-cache@v2 + with: + workspaces: cli + + - name: Install Chrome + run: | + cargo run --manifest-path cli/Cargo.toml -- install --with-deps + + - name: Run e2e tests + run: cargo test --profile ci --manifest-path cli/Cargo.toml e2e -- --ignored --test-threads=1 + windows-integration: name: Windows Integration Test if: github.event_name != 'pull_request' @@ -122,17 +109,6 @@ jobs: - 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: @@ -146,12 +122,6 @@ jobs: - 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 @@ -169,18 +139,6 @@ jobs: shell: pwsh timeout-minutes: 10 - - 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 - - name: Test daemon lifecycle (open, snapshot, close) run: | $env:PATH = "$pwd\bin;$env:PATH" @@ -198,37 +156,6 @@ jobs: shell: pwsh timeout-minutes: 5 - 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 - global-install: name: Global Install (${{ matrix.os }}) if: github.event_name != 'pull_request' @@ -275,12 +202,6 @@ jobs: - name: Build Rust CLI run: cargo build --release --manifest-path cli/Cargo.toml --target ${{ matrix.target }} - - name: Install npm dependencies - run: pnpm install - - - name: Build TypeScript - run: pnpm build - - name: Copy CLI binary to bin directory (Unix) if: runner.os != 'Windows' run: cp cli/target/${{ matrix.target }}/release/agent-browser bin/${{ matrix.binary }} @@ -307,7 +228,7 @@ jobs: echo "ERROR: Symlink should point to native binary, not JS wrapper" exit 1 fi - echo "✓ Symlink correctly points to native binary" + echo "Symlink correctly points to native binary" shell: bash - name: Verify shim points to native binary (Windows) @@ -322,5 +243,5 @@ jobs: echo "ERROR: Shim should point to native .exe, not JS wrapper" exit 1 } - echo "✓ Shim correctly points to native binary" + echo "Shim correctly points to native binary" shell: pwsh diff --git a/.husky/pre-commit b/.husky/pre-commit index 1fe1f99..7509bfb 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,3 +1,2 @@ -pnpm lint-staged node scripts/sync-version.js git add cli/Cargo.toml cli/Cargo.lock diff --git a/AGENTS.md b/AGENTS.md index 6ec3399..46e7d2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,25 +26,9 @@ This applies to changes that either human users or AI agents would need to know In the `docs/src/app/` MDX files, always use HTML `` syntax for tables (not markdown pipe tables). This matches the existing convention across the docs site. -## Dual Architecture (Node.js + Native) +## Architecture -The codebase has two daemon implementations: - -- **Node.js/Playwright** (default) -- `src/daemon.ts`, `src/actions.ts`, `src/browser.ts`, and the rest of `src/` -- **Rust/Native** (experimental, `--native` or `AGENT_BROWSER_NATIVE=1`) -- `cli/src/native/daemon.rs`, `cli/src/native/actions.rs`, `cli/src/native/browser.rs`, and the rest of `cli/src/native/` - -When modifying browser automation logic (commands, actions, protocol handling), changes **must** be made in **both** paths: - -| Node.js Path | Native Path | -|---|---| -| `src/actions.ts` | `cli/src/native/actions.rs` | -| `src/browser.ts` | `cli/src/native/browser.rs` | -| `src/daemon.ts` | `cli/src/native/daemon.rs` | -| `src/protocol.ts` | `cli/src/native/cdp/client.rs` | -| `src/snapshot.ts` | `cli/src/native/snapshot.rs` | -| `src/state-utils.ts` | `cli/src/native/state.rs` | - -New commands must be implemented in both paths, or explicitly stubbed in the native path with a clear `"Not yet implemented: {action}"` error. The goal is eventual full migration to native, but until then both paths must stay in sync. +This is a Rust codebase. The browser automation daemon lives in `cli/src/native/` (daemon, actions, browser, CDP client, snapshot, state). The `--engine` flag selects Chrome vs Lightpanda. The `install` command downloads Chrome from Chrome for Testing directly. ## Testing diff --git a/README.md b/README.md index 5d04863..ffc3c04 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,34 @@ # agent-browser -Headless browser automation CLI for AI agents. Fast Rust CLI with Node.js fallback. +Headless browser automation CLI for AI agents. Fast native Rust CLI. ## Installation ### Global Installation (recommended) -Installs the native Rust binary for maximum performance: +Installs the native Rust binary: ```bash npm install -g agent-browser -agent-browser install # Download Chromium +agent-browser install # Download Chrome from Chrome for Testing (first time only) ``` -This is the fastest option -- commands run through the native Rust CLI directly with sub-millisecond parsing overhead. - -### Quick Start (no install) - -Run directly with `npx` if you want to try it without installing globally: - -```bash -npx agent-browser install # Download Chromium (first time only) -npx agent-browser open example.com -``` - -> **Note:** `npx` routes through Node.js before reaching the Rust CLI, so it is noticeably slower than a global install. For regular use, install globally. - ### Project Installation (local dependency) For projects that want to pin the version in `package.json`: ```bash npm install agent-browser -npx agent-browser install +agent-browser install ``` -Then use via `npx` or `package.json` scripts: - -```bash -npx agent-browser open example.com -``` +Then use via `package.json` scripts or by invoking `agent-browser` directly. ### Homebrew (macOS) ```bash brew install agent-browser -agent-browser install # Download Chromium +agent-browser install # Download Chrome from Chrome for Testing (first time only) ``` ### From Source @@ -66,9 +49,13 @@ On Linux, install system dependencies: ```bash agent-browser install --with-deps -# or manually: npx playwright install-deps chromium ``` +### Requirements + +- **Chrome** - Run `agent-browser install` to download Chrome from [Chrome for Testing](https://developer.chrome.com/blog/chrome-for-testing/) (Google's official automation channel). No Playwright or Node.js required for the daemon. +- **Rust** - Only needed when building from source (see From Source above). + ## Quick Start ```bash @@ -322,7 +309,7 @@ agent-browser reload # Reload page ### Setup ```bash -agent-browser install # Download Chromium browser +agent-browser install # Download Chrome from Chrome for Testing (Google's official automation channel) agent-browser install --with-deps # Also install system deps (Linux) ``` @@ -506,7 +493,7 @@ The `-C` flag is useful for modern web apps that use custom clickable elements ( The `--annotate` flag overlays numbered labels on interactive elements in the screenshot. Each label `[N]` corresponds to ref `@eN`, so the same refs work for both visual and text-based workflows. -In native mode, annotated screenshots are supported on the CDP-backed browser path (`--native` with Chromium/Lightpanda). The Safari/WebDriver backend does not yet support `--annotate`. +Annotated screenshots are supported on the CDP-backed browser path (Chrome/Lightpanda). The Safari/WebDriver backend does not yet support `--annotate`. ```bash agent-browser screenshot --annotate @@ -561,8 +548,7 @@ This is useful for multimodal AI models that can reason about visual layout, unl | `--action-policy ` | Path to action policy JSON file (or `AGENT_BROWSER_ACTION_POLICY` env) | | `--confirm-actions ` | Action categories requiring confirmation (or `AGENT_BROWSER_CONFIRM_ACTIONS` env) | | `--confirm-interactive` | Interactive confirmation prompts; auto-denies if stdin is not a TTY (or `AGENT_BROWSER_CONFIRM_INTERACTIVE` env) | -| `--engine ` | Browser engine: `chrome` (default), `lightpanda`; implies `--native` (or `AGENT_BROWSER_ENGINE` env) | -| `--native` | [Experimental] Use native Rust daemon instead of Node.js (or `AGENT_BROWSER_NATIVE` env) | +| `--engine ` | Browser engine: `chrome` (default), `lightpanda` (or `AGENT_BROWSER_ENGINE` env) | | `--config ` | Use a custom config file (or `AGENT_BROWSER_CONFIG` env) | | `--debug` | Debug output | @@ -606,7 +592,7 @@ Auto-discovered config files that are missing are silently ignored. If `--config ## Default Timeout -The default Playwright timeout for standard operations (clicks, waits, fills, etc.) is 25 seconds. This is intentionally below the CLI's 30-second IPC read timeout so that Playwright returns a proper error instead of the CLI timing out with EAGAIN. +The default timeout for standard operations (clicks, waits, fills, etc.) is 25 seconds. This is intentionally below the CLI's 30-second IPC read timeout so that the daemon returns a proper error instead of the CLI timing out with EAGAIN. Override the default timeout via environment variable: @@ -615,11 +601,11 @@ Override the default timeout via environment variable: export AGENT_BROWSER_DEFAULT_TIMEOUT=45000 ``` -> **Note:** Setting this above 30000 (30s) may cause EAGAIN errors on slow operations because the CLI's read timeout will expire before Playwright responds. The CLI retries transient errors automatically, but response times will increase. +> **Note:** Setting this above 30000 (30s) may cause EAGAIN errors on slow operations because the CLI's read timeout will expire before the daemon responds. The CLI retries transient errors automatically, but response times will increase. -| Variable | Description | -| ------------------------------- | ------------------------------------------------- | -| `AGENT_BROWSER_DEFAULT_TIMEOUT` | Default Playwright timeout in ms (default: 25000) | +| Variable | Description | +| ------------------------------- | ---------------------------------------- | +| `AGENT_BROWSER_DEFAULT_TIMEOUT` | Default operation timeout in ms (default: 25000) | ## Selectors @@ -1009,61 +995,22 @@ await browser.stopScreencast(); agent-browser uses a client-daemon architecture: -1. **Rust CLI** (fast native binary) - Parses commands, communicates with daemon -2. **Node.js Daemon** (default) - Manages Playwright browser instance -3. **Native Daemon** (experimental, `--native`) - Pure Rust daemon using direct CDP, no Node.js required -4. **Fallback** - If native binary unavailable, uses Node.js directly +1. **Rust CLI** - Parses commands, communicates with daemon +2. **Rust Daemon** - Pure Rust daemon using direct CDP, no Node.js required -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. To auto-shutdown the daemon after a period of inactivity, set `AGENT_BROWSER_IDLE_TIMEOUT_MS` (value in milliseconds). When set, the daemon closes the browser and exits after receiving no commands for the specified duration. -**Browser Engine:** Uses Chromium by default. The default Node.js daemon also supports Firefox and WebKit via Playwright. The experimental native daemon speaks Chrome DevTools Protocol (CDP) directly and supports Chromium-based browsers and Safari (via WebDriver). - -## Experimental: Native Mode - -The native daemon is a pure Rust implementation that communicates with Chrome directly via CDP, eliminating the Node.js and Playwright dependencies. It is currently **experimental** and opt-in. - -### Enabling Native Mode - -```bash -# Via flag -agent-browser --native open example.com - -# Via environment variable (recommended for persistent use) -export AGENT_BROWSER_NATIVE=1 -agent-browser open example.com -``` - -Or add to your config file (`agent-browser.json`): - -```json -{ "native": true } -``` - -### What's Different - -| | Default (Node.js) | Native (`--native`) | -| ------------------- | --------------------------- | -------------------------------- | -| **Runtime** | Node.js + Playwright | Pure Rust binary | -| **Protocol** | Playwright protocol | Direct CDP / WebDriver | -| **Install size** | Larger (Node.js + npm deps) | Smaller (single binary) | -| **Browser support** | Chromium, Firefox, WebKit | Chromium, Safari (via WebDriver) | -| **Stability** | Stable | Experimental | - -### Known Limitations - -- Firefox and WebKit are not yet supported (Chromium and Safari only) -- Some Playwright-specific features (tracing format, HAR export) are not available -- The native daemon and Node.js daemon share the same session socket, so you cannot run both simultaneously for the same session. Use `agent-browser close` before switching modes. +**Browser Engine:** Uses Chrome (from Chrome for Testing) by default. The `--engine` flag selects between `chrome` and `lightpanda`. Supported browsers: Chromium/Chrome (via CDP) and Safari (via WebDriver for iOS). ## Platforms -| Platform | Binary | Fallback | -| ----------- | ----------- | -------- | -| macOS ARM64 | Native Rust | Node.js | -| macOS x64 | Native Rust | Node.js | -| Linux ARM64 | Native Rust | Node.js | -| Linux x64 | Native Rust | Node.js | -| Windows x64 | Native Rust | Node.js | +| Platform | Binary | +| ----------- | ----------- | +| macOS ARM64 | Native Rust | +| macOS x64 | Native Rust | +| Linux ARM64 | Native Rust | +| Linux x64 | Native Rust | +| Windows x64 | Native Rust | ## Usage with AI Agents diff --git a/benchmarks/.env.example b/benchmarks/.env.example new file mode 100644 index 0000000..dfe9081 --- /dev/null +++ b/benchmarks/.env.example @@ -0,0 +1,4 @@ +# Vercel Sandbox credentials +SANDBOX_VERCEL_TOKEN= +SANDBOX_VERCEL_TEAM_ID= +SANDBOX_VERCEL_PROJECT_ID= diff --git a/benchmarks/.gitignore b/benchmarks/.gitignore new file mode 100644 index 0000000..22e763e --- /dev/null +++ b/benchmarks/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +results.json diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..b582447 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,76 @@ +# agent-browser Daemon Benchmarks + +Compares command latency and system metrics between the **Node.js daemon** (published npm version) and the **Rust native daemon** (built from source), running inside a [Vercel Sandbox](https://vercel.com/docs/sandbox) microVM. + +## What it measures + +**Command latency** -- per-scenario timing with warmup, multiple iterations, and stddev: + +- `navigate` -- page load round-trip +- `snapshot` -- accessibility tree generation +- `screenshot` -- viewport capture +- `evaluate` -- JavaScript execution +- `click` -- element interaction +- `fill` -- form input +- `agent-loop` -- snapshot/click/snapshot cycle (typical AI agent pattern) +- `full-workflow` -- realistic 7-command sequence + +**System metrics** -- collected while the daemon is running: + +- Cold start time (daemon spawn + browser launch) +- Binary size and total distribution size (including browser download) +- Daemon RSS and peak RSS (separated from browser process memory) +- Browser RSS (Chrome processes, same for both daemons) +- Daemon CPU time +- Process counts + +## Prerequisites + +- Node.js 18+ +- pnpm +- Vercel Sandbox credentials (token, team ID, project ID) + +## Setup + +```bash +cd benchmarks +pnpm install +cp .env.example .env +``` + +Fill in your Vercel Sandbox credentials in `.env`: + +``` +SANDBOX_VERCEL_TOKEN=your_token +SANDBOX_VERCEL_TEAM_ID=your_team_id +SANDBOX_VERCEL_PROJECT_ID=your_project_id +``` + +## Usage + +```bash +pnpm bench # 10 iterations, 1 warmup, 8 vCPUs +pnpm bench -- --iterations 20 # more iterations for tighter stats +pnpm bench -- --warmup 2 # extra warmup iterations +pnpm bench -- --json # write results.json +pnpm bench -- --branch main # build native from a different branch +pnpm bench -- --vcpus 16 # more vCPUs (faster Rust build) +``` + +## How it works + +1. Creates a Vercel Sandbox (Amazon Linux, configurable vCPUs) +2. Installs Chromium system dependencies +3. **Phase 1 -- Node.js daemon**: installs `agent-browser` from npm (last version with the Node daemon), runs all scenarios, collects metrics +4. **Phase 2 -- Rust native daemon**: installs Rust toolchain, clones the repo, runs `cargo build --release`, replaces the binary, runs the same scenarios, collects metrics +5. Prints comparison tables and optionally writes `results.json` + +## Interpreting results + +**Command latency** is dominated by Chrome (CDP round-trips), not the daemon. Both daemons are thin relays between the CLI and Chrome, so per-command speedups are typically small. The stddev column helps distinguish real differences from noise. + +**Where the native daemon wins** is in cold start (no Node.js runtime to boot), daemon memory (single Rust binary vs V8 heap), and distribution size (no Playwright dependency). + +The **daemon RSS** metric isolates the daemon process memory from Chrome. This is the apples-to-apples comparison -- both daemons talk to the same Chrome, but Node.js adds ~140 MB of V8 overhead while the Rust daemon uses ~7 MB. + +**Distribution size** includes the daemon plus its browser download. The Node version includes the npm package + Playwright's bundled Chromium. The Rust version is just the binary + Chrome for Testing. diff --git a/benchmarks/bench.ts b/benchmarks/bench.ts new file mode 100644 index 0000000..ac8cdc8 --- /dev/null +++ b/benchmarks/bench.ts @@ -0,0 +1,900 @@ +/** + * Node.js Daemon vs Rust Native Daemon benchmark. + * + * Compares the last published npm version (Node.js daemon) against the + * Rust-only build from a given branch, running real agent-browser commands + * inside a Vercel Sandbox. + * + * Captures: + * - Command latency (per-scenario, with warmup + measured iterations + stddev) + * - Cold start time (first launch to daemon ready) + * - Daemon memory (RSS, peak RSS) separated from browser memory + * - Daemon CPU time + * - Process tree (daemon + browser children) + * - Binary and distribution size on disk + * + * Usage: + * pnpm bench # default: 10 iterations, 1 warmup + * pnpm bench -- --iterations 20 # override iterations + * pnpm bench -- --warmup 2 # override warmup count + * pnpm bench -- --json # write results.json + * pnpm bench -- --branch my-branch # override native branch (default: ctate/native-2) + * pnpm bench -- --vcpus 8 # sandbox vCPUs (default: 8, higher = faster Rust build) + */ + +import { Sandbox } from "@vercel/sandbox"; +import { readFileSync, writeFileSync } from "fs"; +import { scenarios, type Scenario } from "./scenarios.js"; + +// --------------------------------------------------------------------------- +// Env +// --------------------------------------------------------------------------- + +function loadEnv() { + try { + const content = readFileSync(".env", "utf-8"); + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq === -1) continue; + const key = trimmed.slice(0, eq); + let val = trimmed.slice(eq + 1); + if ( + (val.startsWith('"') && val.endsWith('"')) || + (val.startsWith("'") && val.endsWith("'")) + ) { + val = val.slice(1, -1); + } + process.env[key] = val; + } + } catch {} +} +loadEnv(); + +const credentials = { + token: process.env.SANDBOX_VERCEL_TOKEN!, + teamId: process.env.SANDBOX_VERCEL_TEAM_ID!, + projectId: process.env.SANDBOX_VERCEL_PROJECT_ID!, +}; + +if (!credentials.token || !credentials.teamId || !credentials.projectId) { + console.error( + "Missing credentials. Set SANDBOX_VERCEL_TOKEN, SANDBOX_VERCEL_TEAM_ID, SANDBOX_VERCEL_PROJECT_ID in .env", + ); + process.exit(1); +} + +// --------------------------------------------------------------------------- +// CLI args +// --------------------------------------------------------------------------- + +function parseArgs() { + const args = process.argv.slice(2); + let iterations = 10; + let warmup = 1; + let json = false; + let branch = "ctate/native-2"; + let vcpus = 8; + + for (let i = 0; i < args.length; i++) { + if (args[i] === "--iterations" && args[i + 1]) { + iterations = parseInt(args[++i], 10); + } else if (args[i] === "--warmup" && args[i + 1]) { + warmup = parseInt(args[++i], 10); + } else if (args[i] === "--json") { + json = true; + } else if (args[i] === "--branch" && args[i + 1]) { + branch = args[++i]; + } else if (args[i] === "--vcpus" && args[i + 1]) { + vcpus = parseInt(args[++i], 10); + } + } + + return { iterations, warmup, json, branch, vcpus }; +} + +const config = parseArgs(); + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const TIMEOUT_MS = 30 * 60 * 1000; +const REPO_URL = "https://github.com/vercel-labs/agent-browser.git"; + +const CHROMIUM_SYSTEM_DEPS = [ + "nss", + "nspr", + "libxkbcommon", + "atk", + "at-spi2-atk", + "at-spi2-core", + "libXcomposite", + "libXdamage", + "libXrandr", + "libXfixes", + "libXcursor", + "libXi", + "libXtst", + "libXScrnSaver", + "libXext", + "mesa-libgbm", + "libdrm", + "mesa-libGL", + "mesa-libEGL", + "cups-libs", + "alsa-lib", + "pango", + "cairo", + "gtk3", + "dbus-libs", +]; + +// --------------------------------------------------------------------------- +// Sandbox helpers +// --------------------------------------------------------------------------- + +type SandboxInstance = InstanceType; + +async function run( + sandbox: SandboxInstance, + cmd: string, + args: string[], +): Promise { + const result = await sandbox.runCommand(cmd, args); + const stdout = await result.stdout(); + const stderr = await result.stderr(); + if (result.exitCode !== 0) { + throw new Error( + `Command failed (exit ${result.exitCode}): ${cmd} ${args.join(" ")}\n${stderr || stdout}`, + ); + } + return stdout; +} + +async function shell(sandbox: SandboxInstance, script: string): Promise { + return run(sandbox, "sh", ["-c", script]); +} + +async function shellSafe(sandbox: SandboxInstance, script: string): Promise { + const result = await sandbox.runCommand("sh", ["-c", script]); + return (await result.stdout()).trim(); +} + +// --------------------------------------------------------------------------- +// Stats +// --------------------------------------------------------------------------- + +interface Stats { + avgMs: number; + stddevMs: number; + minMs: number; + maxMs: number; + p50Ms: number; + samples: number[]; +} + +function computeStats(samples: number[]): Stats { + const sorted = [...samples].sort((a, b) => a - b); + const sum = sorted.reduce((a, b) => a + b, 0); + const avg = sum / sorted.length; + const variance = + sorted.reduce((acc, v) => acc + (v - avg) ** 2, 0) / sorted.length; + return { + avgMs: Math.round(avg), + stddevMs: Math.round(Math.sqrt(variance)), + minMs: sorted[0], + maxMs: sorted[sorted.length - 1], + p50Ms: sorted[Math.floor(sorted.length / 2)], + samples: sorted, + }; +} + +// --------------------------------------------------------------------------- +// Metrics collection +// --------------------------------------------------------------------------- + +interface ProcessMetrics { + pid: number; + rssKb: number; + vszKb: number; + cpuPercent: number; + memPercent: number; + cpuTimeSec: number; + command: string; +} + +interface DaemonMetrics { + coldStartMs: number; + binarySizeBytes: number; + distributionSizeBytes: number; + daemonProcesses: ProcessMetrics[]; + browserProcesses: ProcessMetrics[]; + daemonRssKb: number; + browserRssKb: number; + daemonPeakRssKb: number; + daemonCpuTimeSec: number; + totalCpuTimeSec: number; +} + +async function findDaemonPids( + sandbox: SandboxInstance, + _session: string, +): Promise { + // The daemon process name is "agent-browser" but session/daemon flags are + // env vars, not command-line args, so we can't grep them from `ps`. + // Instead, find all agent-browser processes that look like long-running daemons + // (not short-lived CLI invocations -- those exit immediately). + const raw = await shellSafe( + sandbox, + `pgrep -x agent-browser 2>/dev/null || true`, + ); + if (!raw) { + // Fallback: broader match on process name + const fallback = await shellSafe( + sandbox, + `pgrep -f 'agent-browser' 2>/dev/null | head -5 || true`, + ); + if (!fallback) return []; + return fallback.split("\n").map(Number).filter(Boolean); + } + return raw.split("\n").map(Number).filter(Boolean); +} + +async function collectProcessMetrics( + sandbox: SandboxInstance, + pid: number, +): Promise { + const raw = await shellSafe( + sandbox, + `ps -p ${pid} -o pid=,rss=,vsz=,%cpu=,%mem=,cputime=,comm= 2>/dev/null || true`, + ); + if (!raw) return null; + + const parts = raw.trim().split(/\s+/); + if (parts.length < 7) return null; + + // Parse cputime "HH:MM:SS" or "MM:SS" to seconds + const timeParts = parts[5].split(":").map(Number); + let cpuTimeSec = 0; + if (timeParts.length === 3) { + cpuTimeSec = timeParts[0] * 3600 + timeParts[1] * 60 + timeParts[2]; + } else if (timeParts.length === 2) { + cpuTimeSec = timeParts[0] * 60 + timeParts[1]; + } + + return { + pid: Number(parts[0]), + rssKb: Number(parts[1]), + vszKb: Number(parts[2]), + cpuPercent: Number(parts[3]), + memPercent: Number(parts[4]), + cpuTimeSec, + command: parts.slice(6).join(" "), + }; +} + +async function getPeakRssKb( + sandbox: SandboxInstance, + pid: number, +): Promise { + const raw = await shellSafe( + sandbox, + `cat /proc/${pid}/status 2>/dev/null | grep VmHWM | awk '{print $2}' || echo 0`, + ); + return Number(raw) || 0; +} + +async function getChildPids( + sandbox: SandboxInstance, + pid: number, +): Promise { + const raw = await shellSafe( + sandbox, + `pgrep -P ${pid} 2>/dev/null || true`, + ); + if (!raw) return []; + return raw.split("\n").map(Number).filter(Boolean); +} + +async function getAllDescendantPids( + sandbox: SandboxInstance, + pid: number, +): Promise { + const all: number[] = []; + const queue = [pid]; + while (queue.length > 0) { + const current = queue.shift()!; + all.push(current); + const children = await getChildPids(sandbox, current); + queue.push(...children); + } + return all; +} + +async function collectDaemonMetrics( + sandbox: SandboxInstance, + session: string, + coldStartMs: number, + binarySizeBytes: number, + distributionSizeBytes: number, +): Promise { + // Find daemon PIDs -- the agent-browser process itself + const daemonPids = await findDaemonPids(sandbox, session); + + // Also find the full process tree (daemon + Chrome children) + let allPids: number[] = []; + for (const pid of daemonPids) { + const descendants = await getAllDescendantPids(sandbox, pid); + allPids.push(...descendants); + } + allPids = [...new Set(allPids)]; + + // If no daemon PIDs found via pgrep, fall back to grabbing all + // agent-browser and chrome processes for metrics + if (allPids.length === 0) { + const fallback = await shellSafe( + sandbox, + `ps -eo pid,comm | grep -E 'agent-browser|chrome' | grep -v grep | awk '{print $1}' || true`, + ); + if (fallback) { + allPids = fallback.split("\n").map(Number).filter(Boolean); + } + } + + const daemonProcs: ProcessMetrics[] = []; + const browserProcs: ProcessMetrics[] = []; + let daemonPeakRssKb = 0; + + for (const pid of allPids) { + const metrics = await collectProcessMetrics(sandbox, pid); + if (!metrics) continue; + + const isBrowser = /chrome|chromium/i.test(metrics.command); + if (isBrowser) { + browserProcs.push(metrics); + } else { + daemonProcs.push(metrics); + const peak = await getPeakRssKb(sandbox, pid); + daemonPeakRssKb = Math.max(daemonPeakRssKb, peak); + } + } + + const daemonRssKb = daemonProcs.reduce((sum, p) => sum + p.rssKb, 0); + const browserRssKb = browserProcs.reduce((sum, p) => sum + p.rssKb, 0); + const daemonCpuTimeSec = daemonProcs.reduce((sum, p) => sum + p.cpuTimeSec, 0); + const allProcs = [...daemonProcs, ...browserProcs]; + const totalCpuTimeSec = allProcs.reduce((sum, p) => sum + p.cpuTimeSec, 0); + + return { + coldStartMs, + binarySizeBytes, + distributionSizeBytes, + daemonProcesses: daemonProcs, + browserProcesses: browserProcs, + daemonRssKb, + browserRssKb, + daemonPeakRssKb, + daemonCpuTimeSec, + totalCpuTimeSec, + }; +} + +async function getBinarySize( + sandbox: SandboxInstance, +): Promise { + // Follow symlinks to get the real binary/script size + const raw = await shellSafe( + sandbox, + `stat -L -c %s "$(readlink -f "$(which agent-browser)")" 2>/dev/null || echo 0`, + ); + return Number(raw) || 0; +} + +async function getDistributionSize( + sandbox: SandboxInstance, + mode: DaemonMode, +): Promise { + if (mode === "node") { + // Total size of the npm package + Playwright browser + const npmPkg = await shellSafe( + sandbox, + `du -sb "$(npm root -g)/agent-browser" 2>/dev/null | awk '{print $1}' || echo 0`, + ); + const pwBrowser = await shellSafe( + sandbox, + `du -sb "$HOME/.cache/ms-playwright" 2>/dev/null | awk '{print $1}' || echo 0`, + ); + return (Number(npmPkg) || 0) + (Number(pwBrowser) || 0); + } else { + // Rust binary + Chrome for Testing (checks multiple possible cache paths) + const binary = await shellSafe( + sandbox, + `stat -L -c %s "$(readlink -f "$(which agent-browser)")" 2>/dev/null || echo 0`, + ); + const chrome = await shellSafe( + sandbox, + [ + `size=0`, + `for d in "$HOME/.cache/agent-browser" "$HOME/.cache/ms-playwright" "$HOME/.agent-browser/chrome"; do`, + ` if [ -d "$d" ]; then size=$(du -sb "$d" 2>/dev/null | awk '{print $1}'); break; fi`, + `done`, + `echo $size`, + ].join("; "), + ); + return (Number(binary) || 0) + (Number(chrome) || 0); + } +} + +function formatBytes(bytes: number): string { + if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`; + if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${bytes} B`; +} + +function formatKb(kb: number): string { + if (kb >= 1024) return `${(kb / 1024).toFixed(1)} MB`; + return `${kb} KB`; +} + +// --------------------------------------------------------------------------- +// Scenario runner +// --------------------------------------------------------------------------- + +type DaemonMode = "node" | "native"; + +function daemonEnv(mode: DaemonMode): Record { + return { AGENT_BROWSER_SESSION: `bench-${mode}` }; +} + +async function agentBrowser( + sandbox: SandboxInstance, + args: string[], + mode: DaemonMode, +): Promise { + const result = await sandbox.runCommand({ + cmd: "agent-browser", + args, + env: daemonEnv(mode), + }); + if (result.exitCode !== 0) { + const stderr = await result.stderr(); + const stdout = await result.stdout(); + throw new Error( + `agent-browser ${args.join(" ")} failed (exit ${result.exitCode}): ${stderr || stdout}`, + ); + } +} + +async function timedAgentBrowser( + sandbox: SandboxInstance, + args: string[], + mode: DaemonMode, +): Promise { + const start = Date.now(); + const result = await sandbox.runCommand({ + cmd: "agent-browser", + args, + env: daemonEnv(mode), + }); + const elapsed = Date.now() - start; + if (result.exitCode !== 0) { + const stderr = await result.stderr(); + const stdout = await result.stdout(); + throw new Error( + `agent-browser ${args.join(" ")} failed (exit ${result.exitCode}): ${stderr || stdout}`, + ); + } + return elapsed; +} + +interface ScenarioResult { + name: string; + description: string; + stats: Stats; + error?: string; +} + +async function runScenario( + sandbox: SandboxInstance, + scenario: Scenario, + mode: DaemonMode, + iterations: number, + warmup: number, +): Promise { + try { + if (scenario.setup) { + for (const cmd of scenario.setup) { + await agentBrowser(sandbox, cmd, mode); + } + } + + for (let w = 0; w < warmup; w++) { + for (const cmd of scenario.commands) { + await agentBrowser(sandbox, cmd, mode); + } + } + + const samples: number[] = []; + for (let i = 0; i < iterations; i++) { + let totalMs = 0; + for (const cmd of scenario.commands) { + totalMs += await timedAgentBrowser(sandbox, cmd, mode); + } + samples.push(totalMs); + } + + if (scenario.teardown) { + for (const cmd of scenario.teardown) { + await agentBrowser(sandbox, cmd, mode); + } + } + + return { + name: scenario.name, + description: scenario.description, + stats: computeStats(samples), + }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + return { + name: scenario.name, + description: scenario.description, + stats: { avgMs: -1, stddevMs: -1, minMs: -1, maxMs: -1, p50Ms: -1, samples: [] }, + error: message, + }; + } +} + +// --------------------------------------------------------------------------- +// Benchmark phases +// --------------------------------------------------------------------------- + +interface DaemonResults { + mode: DaemonMode; + label: string; + scenarios: ScenarioResult[]; + metrics: DaemonMetrics; +} + +async function benchmarkDaemon( + sandbox: SandboxInstance, + mode: DaemonMode, + label: string, +): Promise { + console.log(`\n--- ${label} ---`); + + // Measure sizes before launch + const binarySizeBytes = await getBinarySize(sandbox); + const distributionSizeBytes = await getDistributionSize(sandbox, mode); + + // Cold start: time the first launch (daemon spawn + browser launch) + const coldStartBegin = Date.now(); + await agentBrowser(sandbox, ["open", "about:blank"], mode); + const coldStartMs = Date.now() - coldStartBegin; + console.log(` Cold start: ${coldStartMs}ms`); + console.log(` Binary size: ${formatBytes(binarySizeBytes)}`); + console.log(` Distribution size: ${formatBytes(distributionSizeBytes)}`); + + // Run all scenarios + const results: ScenarioResult[] = []; + for (const scenario of scenarios) { + process.stdout.write(` ${scenario.name} `); + const result = await runScenario( + sandbox, + scenario, + mode, + config.iterations, + config.warmup, + ); + if (result.error) { + console.log(`FAILED: ${result.error.slice(0, 120)}`); + } else { + const dots = ".".repeat(Math.max(1, 30 - scenario.name.length)); + const s = result.stats; + console.log( + `${dots} ${s.avgMs}ms avg +/-${s.stddevMs}ms (p50: ${s.p50Ms}ms, min: ${s.minMs}ms, max: ${s.maxMs}ms)`, + ); + } + results.push(result); + } + + // Collect system metrics after scenarios (daemon is still running) + const session = `bench-${mode}`; + const metrics = await collectDaemonMetrics( + sandbox, + session, + coldStartMs, + binarySizeBytes, + distributionSizeBytes, + ); + + // Also grab a full process snapshot for context + const psOutput = await shellSafe( + sandbox, + `ps aux --sort=-rss | head -20`, + ); + console.log(`\n Process snapshot (top by RSS):`); + for (const line of psOutput.split("\n").slice(0, 10)) { + console.log(` ${line}`); + } + + console.log(`\n Daemon processes (${metrics.daemonProcesses.length}):`); + console.log(` RSS: ${formatKb(metrics.daemonRssKb)} (peak: ${formatKb(metrics.daemonPeakRssKb)})`); + console.log(` CPU time: ${metrics.daemonCpuTimeSec.toFixed(1)}s`); + for (const p of metrics.daemonProcesses) { + console.log(` PID ${p.pid}: ${p.command} (RSS: ${formatKb(p.rssKb)}, CPU: ${p.cpuPercent}%)`); + } + console.log(` Browser processes (${metrics.browserProcesses.length}):`); + console.log(` RSS: ${formatKb(metrics.browserRssKb)}`); + for (const p of metrics.browserProcesses) { + console.log(` PID ${p.pid}: ${p.command} (RSS: ${formatKb(p.rssKb)}, CPU: ${p.cpuPercent}%)`); + } + + await agentBrowser(sandbox, ["close"], mode); + console.log(` Browser closed.`); + + return { mode, label, scenarios: results, metrics }; +} + +// --------------------------------------------------------------------------- +// Install helpers +// --------------------------------------------------------------------------- + +async function installChromiumDeps(sandbox: SandboxInstance) { + console.log("Installing Chromium system dependencies..."); + await shell( + sandbox, + `sudo dnf clean all 2>&1 && sudo dnf install -y --skip-broken ${CHROMIUM_SYSTEM_DEPS.join(" ")} 2>&1 && sudo ldconfig 2>&1`, + ); +} + +async function installNodeDaemon(sandbox: SandboxInstance) { + console.log("Installing agent-browser from npm (Node.js daemon)..."); + await run(sandbox, "npm", ["install", "-g", "agent-browser"]); + await run(sandbox, "npx", ["agent-browser", "install"]); + const version = await shell(sandbox, "agent-browser --version 2>&1 || true"); + console.log(` version: ${version.trim()}`); +} + +async function installNativeDaemon(sandbox: SandboxInstance, branch: string) { + console.log(`\nBuilding native daemon from ${branch}...`); + + console.log(" Installing build tools and Rust toolchain..."); + const rustStart = Date.now(); + await shell( + sandbox, + "sudo dnf install -y gcc gcc-c++ make perl-core openssl-devel 2>&1", + ); + await shell( + sandbox, + "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y 2>&1", + ); + console.log(` Rust + build tools installed (${Math.round((Date.now() - rustStart) / 1000)}s)`); + + console.log(` Cloning repo (branch: ${branch})...`); + const cloneStart = Date.now(); + await shell( + sandbox, + `git clone --depth 1 --branch ${branch} ${REPO_URL} /tmp/agent-browser 2>&1`, + ); + console.log(` Cloned (${Math.round((Date.now() - cloneStart) / 1000)}s)`); + + console.log(" Building release binary (cargo build --release)..."); + const buildStart = Date.now(); + await shell( + sandbox, + "source $HOME/.cargo/env && cd /tmp/agent-browser/cli && cargo build --release 2>&1", + ); + console.log(` Built (${Math.round((Date.now() - buildStart) / 1000)}s)`); + + const npmBinPath = (await shell(sandbox, "which agent-browser")).trim(); + console.log(` Replacing ${npmBinPath} with native build...`); + await shell( + sandbox, + `sudo cp /tmp/agent-browser/cli/target/release/agent-browser ${npmBinPath}`, + ); + + const version = await shell(sandbox, "agent-browser --version 2>&1 || true"); + console.log(` version: ${version.trim()}`); +} + +// --------------------------------------------------------------------------- +// Output +// --------------------------------------------------------------------------- + +function printResults(node: DaemonResults, native: DaemonResults) { + console.log("\n\n========== COMMAND LATENCY ==========\n"); + + const header = + "Scenario".padEnd(20) + "| Node avg +/-sd | Rust avg +/-sd | Speedup"; + const sep = "-".repeat(20) + "|-----------------|-----------------|--------"; + console.log(header); + console.log(sep); + + for (let i = 0; i < node.scenarios.length; i++) { + const n = node.scenarios[i]; + const r = native.scenarios[i]; + const name = n.name.padEnd(20); + + if (n.error || r.error) { + const nodeVal = n.error ? "FAILED".padEnd(15) : `${n.stats.avgMs}ms`.padEnd(15); + const rustVal = r.error ? "FAILED".padEnd(15) : `${r.stats.avgMs}ms`.padEnd(15); + console.log(`${name}| ${nodeVal} | ${rustVal} | --`); + continue; + } + + const nodeVal = `${n.stats.avgMs} +/-${n.stats.stddevMs}ms`.padEnd(15); + const rustVal = `${r.stats.avgMs} +/-${r.stats.stddevMs}ms`.padEnd(15); + const speedup = + r.stats.avgMs > 0 + ? (n.stats.avgMs / r.stats.avgMs).toFixed(2) + "x" + : "--"; + console.log(`${name}| ${nodeVal} | ${rustVal} | ${speedup.padStart(6)}`); + } + + console.log("\n\n========== SYSTEM METRICS ==========\n"); + + const nm = node.metrics; + const rm = native.metrics; + + function ratio(a: number, b: number): string { + if (b <= 0) return "--"; + return (a / b).toFixed(2) + "x"; + } + + const metricRows: [string, string, string, string][] = [ + [ + "Cold start", + `${nm.coldStartMs}ms`, + `${rm.coldStartMs}ms`, + ratio(nm.coldStartMs, rm.coldStartMs), + ], + [ + "Binary size", + formatBytes(nm.binarySizeBytes), + formatBytes(rm.binarySizeBytes), + ratio(nm.binarySizeBytes, rm.binarySizeBytes), + ], + [ + "Distribution size", + formatBytes(nm.distributionSizeBytes), + formatBytes(rm.distributionSizeBytes), + ratio(nm.distributionSizeBytes, rm.distributionSizeBytes), + ], + [ + "Daemon RSS", + formatKb(nm.daemonRssKb), + formatKb(rm.daemonRssKb), + ratio(nm.daemonRssKb, rm.daemonRssKb), + ], + [ + "Daemon peak RSS", + formatKb(nm.daemonPeakRssKb), + formatKb(rm.daemonPeakRssKb), + ratio(nm.daemonPeakRssKb, rm.daemonPeakRssKb), + ], + [ + "Browser RSS", + formatKb(nm.browserRssKb), + formatKb(rm.browserRssKb), + ratio(nm.browserRssKb, rm.browserRssKb), + ], + [ + "Daemon CPU time", + `${nm.daemonCpuTimeSec.toFixed(1)}s`, + `${rm.daemonCpuTimeSec.toFixed(1)}s`, + ratio(nm.daemonCpuTimeSec, rm.daemonCpuTimeSec), + ], + [ + "Daemon processes", + String(nm.daemonProcesses.length), + String(rm.daemonProcesses.length), + "--", + ], + [ + "Browser processes", + String(nm.browserProcesses.length), + String(rm.browserProcesses.length), + "--", + ], + ]; + + const mHeader = + "Metric".padEnd(20) + "| Node".padEnd(14) + "| Rust".padEnd(14) + "| Ratio"; + const mSep = "-".repeat(20) + "|" + "-".repeat(13) + "|" + "-".repeat(13) + "|--------"; + console.log(mHeader); + console.log(mSep); + for (const [metric, nodeVal, rustVal, ratio] of metricRows) { + console.log( + `${metric.padEnd(20)}| ${nodeVal.padEnd(12)}| ${rustVal.padEnd(12)}| ${ratio}`, + ); + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + console.log("agent-browser Daemon Benchmark (Node.js vs Rust Native)"); + console.log(`Branch: ${config.branch}`); + console.log(`Iterations: ${config.iterations} (+ ${config.warmup} warmup)`); + console.log(`vCPUs: ${config.vcpus}\n`); + + console.log("Creating sandbox..."); + const sandbox = await Sandbox.create({ + ...credentials, + timeout: TIMEOUT_MS, + runtime: "node22", + networkPolicy: "allow-all" as const, + resources: { vcpus: config.vcpus }, + }); + console.log(`Sandbox: ${sandbox.sandboxId}`); + + try { + await installChromiumDeps(sandbox); + + // Phase 1: Node.js daemon (from published npm package) + await installNodeDaemon(sandbox); + const nodeResults = await benchmarkDaemon( + sandbox, + "node", + "Node.js Daemon (npm)", + ); + + // Phase 2: Rust native daemon (built from branch) + await installNativeDaemon(sandbox, config.branch); + const nativeResults = await benchmarkDaemon( + sandbox, + "native", + `Rust Native Daemon (${config.branch})`, + ); + + printResults(nodeResults, nativeResults); + + if (config.json) { + const output = { + timestamp: new Date().toISOString(), + branch: config.branch, + vcpus: config.vcpus, + iterations: config.iterations, + warmup: config.warmup, + node: { + scenarios: nodeResults.scenarios.map((s) => ({ + name: s.name, + description: s.description, + ...s.stats, + error: s.error, + })), + metrics: nodeResults.metrics, + }, + native: { + scenarios: nativeResults.scenarios.map((s) => ({ + name: s.name, + description: s.description, + ...s.stats, + error: s.error, + })), + metrics: nativeResults.metrics, + }, + }; + writeFileSync("results.json", JSON.stringify(output, null, 2)); + console.log("\nResults written to results.json"); + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + console.error(`\nFatal error: ${message}`); + process.exit(1); + } finally { + try { + await sandbox.stop(); + console.log("\nSandbox stopped."); + } catch { + console.warn("Warning: failed to stop sandbox."); + } + } +} + +main(); diff --git a/benchmarks/package.json b/benchmarks/package.json new file mode 100644 index 0000000..e36b1ad --- /dev/null +++ b/benchmarks/package.json @@ -0,0 +1,13 @@ +{ + "name": "agent-browser-benchmarks", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "bench": "tsx bench.ts" + }, + "dependencies": { + "@vercel/sandbox": "^1.8.0", + "tsx": "^4.19.0" + } +} diff --git a/benchmarks/pnpm-lock.yaml b/benchmarks/pnpm-lock.yaml new file mode 100644 index 0000000..ec80974 --- /dev/null +++ b/benchmarks/pnpm-lock.yaml @@ -0,0 +1,472 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@vercel/sandbox': + specifier: ^1.8.0 + version: 1.8.1 + tsx: + specifier: ^4.19.0 + version: 4.21.0 + +packages: + + '@esbuild/aix-ppc64@0.27.4': + resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.4': + resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.4': + resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.4': + resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.4': + resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.4': + resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.4': + resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.4': + resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.4': + resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.4': + resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.4': + resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.4': + resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.4': + resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.4': + resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.4': + resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.4': + resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.4': + resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.4': + resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.4': + resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.4': + resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.4': + resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.4': + resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.4': + resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.4': + resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.4': + resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.4': + resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@vercel/oidc@3.2.0': + resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} + engines: {node: '>= 20'} + + '@vercel/sandbox@1.8.1': + resolution: {integrity: sha512-txohjI20aMxZiAzBL/KJi5EqTYsesBdOyIOtpTIyebPLTqYtDYfNhQ4OeYiUcPMUo0XBt8gSet/rIdLQEjj3/A==} + + async-retry@1.3.3: + resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + + b4a@1.8.0: + resolution: {integrity: sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + bare-events@2.8.2: + resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + esbuild@0.27.4: + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} + engines: {node: '>=18'} + hasBin: true + + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + + jsonlines@0.1.1: + resolution: {integrity: sha512-ekDrAGso79Cvf+dtm+mL8OBI2bmAOt3gssYs833De/C9NmIpWDWyUO4zPgB5x2/OhY366dkhgfPMYfwZF7yOZA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + os-paths@4.4.0: + resolution: {integrity: sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==} + engines: {node: '>= 6.0'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + + streamx@2.23.0: + resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} + + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + undici@7.24.1: + resolution: {integrity: sha512-5xoBibbmnjlcR3jdqtY2Lnx7WbrD/tHlT01TmvqZUFVc9Q1w4+j5hbnapTqbcXITMH1ovjq/W7BkqBilHiVAaA==} + engines: {node: '>=20.18.1'} + + xdg-app-paths@5.1.0: + resolution: {integrity: sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA==} + engines: {node: '>=6'} + + xdg-portable@7.3.0: + resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==} + engines: {node: '>= 6.0'} + + zod@3.24.4: + resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==} + +snapshots: + + '@esbuild/aix-ppc64@0.27.4': + optional: true + + '@esbuild/android-arm64@0.27.4': + optional: true + + '@esbuild/android-arm@0.27.4': + optional: true + + '@esbuild/android-x64@0.27.4': + optional: true + + '@esbuild/darwin-arm64@0.27.4': + optional: true + + '@esbuild/darwin-x64@0.27.4': + optional: true + + '@esbuild/freebsd-arm64@0.27.4': + optional: true + + '@esbuild/freebsd-x64@0.27.4': + optional: true + + '@esbuild/linux-arm64@0.27.4': + optional: true + + '@esbuild/linux-arm@0.27.4': + optional: true + + '@esbuild/linux-ia32@0.27.4': + optional: true + + '@esbuild/linux-loong64@0.27.4': + optional: true + + '@esbuild/linux-mips64el@0.27.4': + optional: true + + '@esbuild/linux-ppc64@0.27.4': + optional: true + + '@esbuild/linux-riscv64@0.27.4': + optional: true + + '@esbuild/linux-s390x@0.27.4': + optional: true + + '@esbuild/linux-x64@0.27.4': + optional: true + + '@esbuild/netbsd-arm64@0.27.4': + optional: true + + '@esbuild/netbsd-x64@0.27.4': + optional: true + + '@esbuild/openbsd-arm64@0.27.4': + optional: true + + '@esbuild/openbsd-x64@0.27.4': + optional: true + + '@esbuild/openharmony-arm64@0.27.4': + optional: true + + '@esbuild/sunos-x64@0.27.4': + optional: true + + '@esbuild/win32-arm64@0.27.4': + optional: true + + '@esbuild/win32-ia32@0.27.4': + optional: true + + '@esbuild/win32-x64@0.27.4': + optional: true + + '@vercel/oidc@3.2.0': {} + + '@vercel/sandbox@1.8.1': + dependencies: + '@vercel/oidc': 3.2.0 + async-retry: 1.3.3 + jsonlines: 0.1.1 + ms: 2.1.3 + picocolors: 1.1.1 + tar-stream: 3.1.7 + undici: 7.24.1 + xdg-app-paths: 5.1.0 + zod: 3.24.4 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + async-retry@1.3.3: + dependencies: + retry: 0.13.1 + + b4a@1.8.0: {} + + bare-events@2.8.2: {} + + esbuild@0.27.4: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.4 + '@esbuild/android-arm': 0.27.4 + '@esbuild/android-arm64': 0.27.4 + '@esbuild/android-x64': 0.27.4 + '@esbuild/darwin-arm64': 0.27.4 + '@esbuild/darwin-x64': 0.27.4 + '@esbuild/freebsd-arm64': 0.27.4 + '@esbuild/freebsd-x64': 0.27.4 + '@esbuild/linux-arm': 0.27.4 + '@esbuild/linux-arm64': 0.27.4 + '@esbuild/linux-ia32': 0.27.4 + '@esbuild/linux-loong64': 0.27.4 + '@esbuild/linux-mips64el': 0.27.4 + '@esbuild/linux-ppc64': 0.27.4 + '@esbuild/linux-riscv64': 0.27.4 + '@esbuild/linux-s390x': 0.27.4 + '@esbuild/linux-x64': 0.27.4 + '@esbuild/netbsd-arm64': 0.27.4 + '@esbuild/netbsd-x64': 0.27.4 + '@esbuild/openbsd-arm64': 0.27.4 + '@esbuild/openbsd-x64': 0.27.4 + '@esbuild/openharmony-arm64': 0.27.4 + '@esbuild/sunos-x64': 0.27.4 + '@esbuild/win32-arm64': 0.27.4 + '@esbuild/win32-ia32': 0.27.4 + '@esbuild/win32-x64': 0.27.4 + + events-universal@1.0.1: + dependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + + fast-fifo@1.3.2: {} + + fsevents@2.3.3: + optional: true + + get-tsconfig@4.13.6: + dependencies: + resolve-pkg-maps: 1.0.0 + + jsonlines@0.1.1: {} + + ms@2.1.3: {} + + os-paths@4.4.0: {} + + picocolors@1.1.1: {} + + resolve-pkg-maps@1.0.0: {} + + retry@0.13.1: {} + + streamx@2.23.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + tar-stream@3.1.7: + dependencies: + b4a: 1.8.0 + fast-fifo: 1.3.2 + streamx: 2.23.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + text-decoder@1.2.7: + dependencies: + b4a: 1.8.0 + transitivePeerDependencies: + - react-native-b4a + + tsx@4.21.0: + dependencies: + esbuild: 0.27.4 + get-tsconfig: 4.13.6 + optionalDependencies: + fsevents: 2.3.3 + + undici@7.24.1: {} + + xdg-app-paths@5.1.0: + dependencies: + xdg-portable: 7.3.0 + + xdg-portable@7.3.0: + dependencies: + os-paths: 4.4.0 + + zod@3.24.4: {} diff --git a/benchmarks/scenarios.ts b/benchmarks/scenarios.ts new file mode 100644 index 0000000..71fab16 --- /dev/null +++ b/benchmarks/scenarios.ts @@ -0,0 +1,105 @@ +/** + * Benchmark scenarios for comparing Node.js daemon vs Rust native daemon. + * + * Each scenario defines CLI commands run via `sandbox.runCommand("agent-browser", args)`. + * Setup/teardown commands run once and are not timed. + * The `commands` array is timed over N iterations. + */ + +export interface Scenario { + name: string; + description: string; + setup?: string[][]; + commands: string[][]; + teardown?: string[][]; +} + +const FORM_HTML = [ + "Bench", + "

Benchmark Page

", + "", + "", + "", + "", + "", + "", + "

Ready

", + "Click me", + "
    ", + ...Array.from({ length: 20 }, (_, i) => `
  • Item ${i + 1}
  • `), + "
", + "", +].join(""); + +const INJECT_FORM_SCRIPT = `document.open(); document.write(${JSON.stringify(FORM_HTML)}); document.close(); 'ok'`; + +const SETUP_PAGE: string[][] = [ + ["open", "about:blank"], + ["eval", INJECT_FORM_SCRIPT], +]; + +export const scenarios: Scenario[] = [ + { + name: "navigate", + description: "Page navigation (about:blank round-trip)", + commands: [["open", "about:blank"]], + }, + { + name: "snapshot", + description: "DOM snapshot (accessibility tree)", + setup: SETUP_PAGE, + commands: [["snapshot"]], + }, + { + name: "screenshot", + description: "Screenshot capture", + setup: SETUP_PAGE, + commands: [["screenshot"]], + }, + { + name: "evaluate", + description: "JavaScript evaluation", + setup: SETUP_PAGE, + commands: [ + [ + "eval", + "document.title + ' ' + document.querySelectorAll('li').length", + ], + ], + }, + { + name: "click", + description: "Element click interaction", + setup: SETUP_PAGE, + commands: [["click", "#link"]], + }, + { + name: "fill", + description: "Form field fill", + setup: SETUP_PAGE, + commands: [["fill", "#name", "Benchmark User"]], + }, + { + name: "agent-loop", + description: "AI agent loop: snapshot -> click -> snapshot (typical agent cycle)", + setup: SETUP_PAGE, + commands: [["snapshot"], ["click", "#link"], ["snapshot"]], + }, + { + name: "full-workflow", + description: + "Realistic workflow: navigate, inject form, snapshot, click, fill, evaluate, screenshot", + commands: [ + ["open", "about:blank"], + ["eval", INJECT_FORM_SCRIPT], + ["snapshot"], + ["click", "#link"], + ["fill", "#name", "Agent User"], + [ + "eval", + "document.getElementById('name').value", + ], + ["screenshot"], + ], + }, +]; diff --git a/benchmarks/tsconfig.json b/benchmarks/tsconfig.json new file mode 100644 index 0000000..277c34f --- /dev/null +++ b/benchmarks/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "outDir": "dist", + "declaration": true + }, + "include": ["*.ts"] +} diff --git a/cli/Cargo.lock b/cli/Cargo.lock index bd7c785..4d7a2fb 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -65,6 +65,7 @@ dependencies = [ "url", "uuid", "windows-sys 0.52.0", + "zip", ] [[package]] @@ -527,6 +528,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -1414,7 +1416,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -2090,6 +2092,12 @@ dependencies = [ "utf-8", ] +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typenum" version = "1.19.0" @@ -2777,12 +2785,44 @@ dependencies = [ "syn", ] +[[package]] +name = "zip" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b680f2a0cd479b4cff6e1233c483fdead418106eae419dc60200ae9850f6d004" +dependencies = [ + "crc32fast", + "flate2", + "indexmap", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.4.12" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 725aa45..74d468f 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -22,6 +22,7 @@ sha2 = "0.10" aes-gcm = "0.10" async-trait = "0.1" similar = "2" +zip = { version = "8.2.0", default-features = false, features = ["deflate"] } [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/cli/src/commands.rs b/cli/src/commands.rs index a898339..5d99b65 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -1061,7 +1061,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result { const VALID: &[&str] = &["start", "stop", "restart"]; match rest.first().copied() { @@ -2167,7 +2167,6 @@ mod tests { cli_allow_file_access: false, cli_annotate: false, cli_download_path: false, - cli_native: false, cli_headed: false, annotate: false, color_scheme: None, @@ -2178,7 +2177,6 @@ mod tests { action_policy: None, confirm_actions: None, confirm_interactive: false, - native: false, engine: None, screenshot_dir: None, screenshot_quality: None, diff --git a/cli/src/connection.rs b/cli/src/connection.rs index f6ce676..ff74ece 100644 --- a/cli/src/connection.rs +++ b/cli/src/connection.rs @@ -233,7 +233,6 @@ pub struct DaemonOptions<'a> { pub allowed_domains: Option<&'a [String]>, pub action_policy: Option<&'a str>, pub confirm_actions: Option<&'a str>, - pub native: bool, pub engine: Option<&'a str>, } @@ -359,137 +358,54 @@ pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result actual binary) let exe_path = exe_path.canonicalize().unwrap_or(exe_path); - // On Windows, canonicalize() returns \\?\ prefixed extended-length paths. - // Node.js cannot handle these, so strip the prefix. - #[cfg(windows)] - let exe_path = { - let p = exe_path.to_string_lossy(); - if let Some(stripped) = p.strip_prefix(r"\\?\") { - PathBuf::from(stripped) - } else { - exe_path - } - }; #[allow(unused_assignments)] let mut daemon_child: Option = None; - if opts.native { - // Native mode: spawn self as daemon (Rust/CDP, no Node.js needed) - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; - let mut cmd = Command::new(&exe_path); - cmd.env("AGENT_BROWSER_DAEMON", "1"); - apply_daemon_env(&mut cmd, session, opts); + let mut cmd = Command::new(&exe_path); + cmd.env("AGENT_BROWSER_DAEMON", "1"); + apply_daemon_env(&mut cmd, session, opts); - unsafe { - cmd.pre_exec(|| { - libc::setsid(); - Ok(()) - }); - } - - daemon_child = Some( - cmd.stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| format!("Failed to start native daemon: {}", e))?, - ); + unsafe { + cmd.pre_exec(|| { + libc::setsid(); + Ok(()) + }); } - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; + daemon_child = Some( + cmd.stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to start daemon: {}", e))?, + ); + } - let mut cmd = Command::new(&exe_path); - cmd.env("AGENT_BROWSER_DAEMON", "1"); - apply_daemon_env(&mut cmd, session, opts); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; - const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; - const DETACHED_PROCESS: u32 = 0x00000008; + let mut cmd = Command::new(&exe_path); + cmd.env("AGENT_BROWSER_DAEMON", "1"); + apply_daemon_env(&mut cmd, session, opts); - daemon_child = Some( - cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| format!("Failed to start native daemon: {}", e))?, - ); - } - } else { - // Default mode: spawn Node.js daemon (Playwright) - let exe_dir = exe_path.parent().unwrap(); + const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; + const DETACHED_PROCESS: u32 = 0x00000008; - let mut daemon_paths = vec![ - exe_dir.join("daemon.js"), - exe_dir.join("../dist/daemon.js"), - PathBuf::from("dist/daemon.js"), - ]; - - if let Ok(home) = env::var("AGENT_BROWSER_HOME") { - let home_path = PathBuf::from(&home); - daemon_paths.insert(0, home_path.join("dist/daemon.js")); - daemon_paths.insert(1, home_path.join("daemon.js")); - } - - let daemon_path = daemon_paths - .iter() - .find(|p| p.exists()) - .ok_or("Daemon not found. Set AGENT_BROWSER_HOME environment variable or run from project directory.")?; - - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - - let mut cmd = Command::new("node"); - cmd.arg(daemon_path); - apply_daemon_env(&mut cmd, session, opts); - - unsafe { - cmd.pre_exec(|| { - libc::setsid(); - Ok(()) - }); - } - - daemon_child = Some( - cmd.stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| format!("Failed to start daemon: {}", e))?, - ); - } - - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - - // Use node.exe explicitly to avoid Git Bash/MSYS2 shell wrapper resolution - let mut cmd = Command::new("node.exe"); - cmd.arg(daemon_path) - .env("MSYS_NO_PATHCONV", "1") - .env("MSYS2_ARG_CONV_EXCL", "*"); - apply_daemon_env(&mut cmd, session, opts); - - const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; - const DETACHED_PROCESS: u32 = 0x00000008; - - daemon_child = Some( - cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| format!("Failed to start daemon: {}", e))?, - ); - } + daemon_child = Some( + cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to start daemon: {}", e))?, + ); } for _ in 0..50 { diff --git a/cli/src/flags.rs b/cli/src/flags.rs index c48b26b..9f8b2da 100644 --- a/cli/src/flags.rs +++ b/cli/src/flags.rs @@ -41,7 +41,6 @@ pub struct Config { pub action_policy: Option, pub confirm_actions: Option, pub confirm_interactive: Option, - pub native: Option, pub engine: Option, pub screenshot_dir: Option, pub screenshot_quality: Option, @@ -87,7 +86,6 @@ impl Config { action_policy: other.action_policy.or(self.action_policy), confirm_actions: other.confirm_actions.or(self.confirm_actions), confirm_interactive: other.confirm_interactive.or(self.confirm_interactive), - native: other.native.or(self.native), engine: other.engine.or(self.engine), screenshot_dir: other.screenshot_dir.or(self.screenshot_dir), screenshot_quality: other.screenshot_quality.or(self.screenshot_quality), @@ -247,7 +245,6 @@ pub struct Flags { pub action_policy: Option, pub confirm_actions: Option, pub confirm_interactive: bool, - pub native: bool, pub engine: Option, pub screenshot_dir: Option, pub screenshot_quality: Option, @@ -266,7 +263,6 @@ pub struct Flags { pub cli_allow_file_access: bool, pub cli_annotate: bool, pub cli_download_path: bool, - pub cli_native: bool, pub cli_headed: bool, } @@ -358,7 +354,6 @@ pub fn parse_flags(args: &[String]) -> Flags { .or(config.confirm_actions), confirm_interactive: env_var_is_truthy("AGENT_BROWSER_CONFIRM_INTERACTIVE") || config.confirm_interactive.unwrap_or(false), - native: env_var_is_truthy("AGENT_BROWSER_NATIVE") || config.native.unwrap_or(false), engine: env::var("AGENT_BROWSER_ENGINE").ok().or(config.engine), screenshot_dir: env::var("AGENT_BROWSER_SCREENSHOT_DIR") .ok() @@ -382,7 +377,6 @@ pub fn parse_flags(args: &[String]) -> Flags { cli_allow_file_access: false, cli_annotate: false, cli_download_path: false, - cli_native: false, cli_headed: false, }; @@ -604,14 +598,6 @@ pub fn parse_flags(args: &[String]) -> Flags { i += 1; } } - "--native" => { - let (val, consumed) = parse_bool_arg(args, i); - flags.native = val; - flags.cli_native = true; - if consumed { - i += 1; - } - } "--screenshot-dir" => { if let Some(s) = args.get(i + 1) { flags.screenshot_dir = Some(s.clone()); @@ -675,7 +661,6 @@ pub fn clean_args(args: &[String]) -> Vec { "--annotate", "--content-boundaries", "--confirm-interactive", - "--native", ]; // Global flags that always take a value (need to skip the next arg too) const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &[ diff --git a/cli/src/install.rs b/cli/src/install.rs index a11ddbd..28208db 100644 --- a/cli/src/install.rs +++ b/cli/src/install.rs @@ -1,167 +1,357 @@ use crate::color; +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; use std::process::{exit, Command, Stdio}; +const LAST_KNOWN_GOOD_URL: &str = + "https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json"; + +pub fn get_browsers_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".agent-browser") + .join("browsers") +} + +pub fn find_installed_chrome() -> Option { + let browsers_dir = get_browsers_dir(); + if !browsers_dir.exists() { + return None; + } + + let mut versions: Vec<_> = fs::read_dir(&browsers_dir) + .ok()? + .filter_map(|e| e.ok()) + .filter(|e| { + e.file_name() + .to_str() + .is_some_and(|n| n.starts_with("chrome-")) + }) + .collect(); + + versions.sort_by_key(|b| std::cmp::Reverse(b.file_name())); + + for entry in versions { + if let Some(bin) = chrome_binary_in_dir(&entry.path()) { + if bin.exists() { + return Some(bin); + } + } + } + + None +} + +fn chrome_binary_in_dir(dir: &Path) -> Option { + #[cfg(target_os = "macos")] + { + let app = + dir.join("Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"); + if app.exists() { + return Some(app); + } + let inner = dir.join("chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"); + if inner.exists() { + return Some(inner); + } + let inner_x64 = dir.join( + "chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing", + ); + if inner_x64.exists() { + return Some(inner_x64); + } + None + } + + #[cfg(target_os = "linux")] + { + let bin = dir.join("chrome"); + if bin.exists() { + return Some(bin); + } + let inner = dir.join("chrome-linux64/chrome"); + if inner.exists() { + return Some(inner); + } + None + } + + #[cfg(target_os = "windows")] + { + let bin = dir.join("chrome.exe"); + if bin.exists() { + return Some(bin); + } + let inner = dir.join("chrome-win64/chrome.exe"); + if inner.exists() { + return Some(inner); + } + None + } + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + None + } +} + +fn platform_key() -> &'static str { + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + { + "mac-arm64" + } + #[cfg(all(target_os = "macos", target_arch = "x86_64"))] + { + "mac-x64" + } + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + { + "linux64" + } + #[cfg(all(target_os = "windows", target_arch = "x86_64"))] + { + "win64" + } + #[cfg(not(any( + all(target_os = "macos", target_arch = "aarch64"), + all(target_os = "macos", target_arch = "x86_64"), + all(target_os = "linux", target_arch = "x86_64"), + all(target_os = "windows", target_arch = "x86_64"), + )))] + { + // Compiles on unsupported platforms (e.g. linux aarch64) so the binary + // can still be used for other commands like `connect`. The install path + // guards against this at runtime before calling platform_key(). + panic!("Unsupported platform for Chrome for Testing download") + } +} + +async fn fetch_download_url() -> Result<(String, String), String> { + let resp = reqwest::get(LAST_KNOWN_GOOD_URL) + .await + .map_err(|e| format!("Failed to fetch version info: {}", e))?; + + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Failed to parse version info: {}", e))?; + + let channel = body + .get("channels") + .and_then(|c| c.get("Stable")) + .ok_or("No Stable channel found in version info")?; + + let version = channel + .get("version") + .and_then(|v| v.as_str()) + .ok_or("No version string found")? + .to_string(); + + let platform = platform_key(); + + let url = channel + .get("downloads") + .and_then(|d| d.get("chrome")) + .and_then(|c| c.as_array()) + .and_then(|arr| { + arr.iter().find_map(|entry| { + if entry.get("platform")?.as_str()? == platform { + Some(entry.get("url")?.as_str()?.to_string()) + } else { + None + } + }) + }) + .ok_or_else(|| format!("No download URL found for platform: {}", platform))?; + + Ok((version, url)) +} + +async fn download_bytes(url: &str) -> Result, String> { + let resp = reqwest::get(url) + .await + .map_err(|e| format!("Download failed: {}", e))?; + + let total = resp.content_length(); + let mut bytes = Vec::new(); + let mut stream = resp; + let mut downloaded: u64 = 0; + let mut last_pct: u64 = 0; + + loop { + let chunk = stream + .chunk() + .await + .map_err(|e| format!("Download error: {}", e))?; + match chunk { + Some(data) => { + downloaded += data.len() as u64; + bytes.extend_from_slice(&data); + + if let Some(total) = total { + let pct = (downloaded * 100) / total; + if pct >= last_pct + 5 { + last_pct = pct; + let mb = downloaded as f64 / 1_048_576.0; + let total_mb = total as f64 / 1_048_576.0; + eprint!("\r {:.0}/{:.0} MB ({pct}%)", mb, total_mb); + let _ = io::stderr().flush(); + } + } + } + None => break, + } + } + + eprintln!(); + Ok(bytes) +} + +fn extract_zip(bytes: Vec, dest: &Path) -> Result<(), String> { + fs::create_dir_all(dest).map_err(|e| format!("Failed to create directory: {}", e))?; + + let cursor = io::Cursor::new(bytes); + let mut archive = + zip::ZipArchive::new(cursor).map_err(|e| format!("Failed to read zip archive: {}", e))?; + + for i in 0..archive.len() { + let mut file = archive + .by_index(i) + .map_err(|e| format!("Failed to read zip entry: {}", e))?; + + let enclosed = match file.enclosed_name() { + Some(name) => name.to_owned(), + None => continue, + }; + let raw_name = enclosed.to_string_lossy().to_string(); + let rel_path = raw_name + .strip_prefix("chrome-") + .and_then(|s| s.split_once('/')) + .map(|(_, rest)| rest.to_string()) + .unwrap_or(raw_name.clone()); + + if rel_path.is_empty() { + continue; + } + + let out_path = dest.join(&rel_path); + + // Defense-in-depth: ensure the resolved path is inside dest + if !out_path.starts_with(dest) { + continue; + } + + if file.is_dir() { + fs::create_dir_all(&out_path) + .map_err(|e| format!("Failed to create dir {}: {}", out_path.display(), e))?; + } else { + if let Some(parent) = out_path.parent() { + fs::create_dir_all(parent).map_err(|e| { + format!("Failed to create parent dir {}: {}", parent.display(), e) + })?; + } + let mut out_file = fs::File::create(&out_path) + .map_err(|e| format!("Failed to create file {}: {}", out_path.display(), e))?; + io::copy(&mut file, &mut out_file) + .map_err(|e| format!("Failed to write {}: {}", out_path.display(), e))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Some(mode) = file.unix_mode() { + let _ = fs::set_permissions(&out_path, fs::Permissions::from_mode(mode)); + } + } + } + } + + Ok(()) +} + pub fn run_install(with_deps: bool) { + if cfg!(all(target_os = "linux", target_arch = "aarch64")) { + eprintln!( + "{} Chrome for Testing does not provide Linux ARM64 builds.", + color::error_indicator() + ); + eprintln!(" Install Chromium from your system package manager instead:"); + eprintln!(" sudo apt install chromium-browser # Debian/Ubuntu"); + eprintln!(" sudo dnf install chromium # Fedora"); + eprintln!(" Then use: agent-browser --executable-path /usr/bin/chromium"); + exit(1); + } + let is_linux = cfg!(target_os = "linux"); if is_linux { if with_deps { - println!("{}", color::cyan("Installing system dependencies...")); - - let (pkg_mgr, deps) = if which_exists("apt-get") { - let libasound = if package_exists_apt("libasound2t64") { - "libasound2t64" - } else { - "libasound2" - }; - - ( - "apt-get", - vec![ - "libxcb-shm0", - "libx11-xcb1", - "libx11-6", - "libxcb1", - "libxext6", - "libxrandr2", - "libxcomposite1", - "libxcursor1", - "libxdamage1", - "libxfixes3", - "libxi6", - "libgtk-3-0", - "libpangocairo-1.0-0", - "libpango-1.0-0", - "libatk1.0-0", - "libcairo-gobject2", - "libcairo2", - "libgdk-pixbuf-2.0-0", - "libxrender1", - libasound, - "libfreetype6", - "libfontconfig1", - "libdbus-1-3", - "libnss3", - "libnspr4", - "libatk-bridge2.0-0", - "libdrm2", - "libxkbcommon0", - "libatspi2.0-0", - "libcups2", - "libxshmfence1", - "libgbm1", - ], - ) - } else if which_exists("dnf") { - ( - "dnf", - vec![ - "nss", - "nspr", - "atk", - "at-spi2-atk", - "cups-libs", - "libdrm", - "libXcomposite", - "libXdamage", - "libXrandr", - "mesa-libgbm", - "pango", - "alsa-lib", - "libxkbcommon", - "libxcb", - "libX11-xcb", - "libX11", - "libXext", - "libXcursor", - "libXfixes", - "libXi", - "gtk3", - "cairo-gobject", - ], - ) - } else if which_exists("yum") { - ( - "yum", - vec![ - "nss", - "nspr", - "atk", - "at-spi2-atk", - "cups-libs", - "libdrm", - "libXcomposite", - "libXdamage", - "libXrandr", - "mesa-libgbm", - "pango", - "alsa-lib", - "libxkbcommon", - ], - ) - } else { - eprintln!( - "{} No supported package manager found (apt-get, dnf, or yum)", - color::error_indicator() - ); - exit(1); - }; - - let install_cmd = match pkg_mgr { - "apt-get" => { - format!( - "sudo apt-get update && sudo apt-get install -y {}", - deps.join(" ") - ) - } - _ => format!("sudo {} install -y {}", pkg_mgr, deps.join(" ")), - }; - - println!("Running: {}", install_cmd); - let status = Command::new("sh").arg("-c").arg(&install_cmd).status(); - - match status { - Ok(s) if s.success() => { - println!("{} System dependencies installed", color::success_indicator()) - } - Ok(_) => eprintln!( - "{} Failed to install some dependencies. You may need to run manually with sudo.", - color::warning_indicator() - ), - Err(e) => eprintln!("{} Could not run install command: {}", color::warning_indicator(), e), - } + install_linux_deps(); } else { println!( "{} Linux detected. If browser fails to launch, run:", color::warning_indicator() ); println!(" agent-browser install --with-deps"); - println!(" or: npx playwright install-deps chromium"); println!(); } } - println!("{}", color::cyan("Installing Chromium browser...")); + println!("{}", color::cyan("Installing Chrome...")); - // On Windows, we need to use cmd.exe to run npx because npx is actually npx.cmd - // and Command::new() doesn't resolve .cmd files the way the shell does. - // Pass the entire command as a single string to /c to handle paths with spaces. - #[cfg(windows)] - let status = Command::new("cmd") - .args(["/c", "npx playwright install chromium"]) - .status(); - - #[cfg(not(windows))] - let status = Command::new("npx") - .args(["playwright", "install", "chromium"]) - .status(); - - match status { - Ok(s) if s.success() => { - println!( - "{} Chromium installed successfully", - color::success_indicator() + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap_or_else(|e| { + eprintln!( + "{} Failed to create runtime: {}", + color::error_indicator(), + e ); + exit(1); + }); + + let (version, url) = match rt.block_on(fetch_download_url()) { + Ok(v) => v, + Err(e) => { + eprintln!("{} {}", color::error_indicator(), e); + exit(1); + } + }; + + let dest = get_browsers_dir().join(format!("chrome-{}", version)); + + if let Some(bin) = chrome_binary_in_dir(&dest) { + if bin.exists() { + println!( + "{} Chrome {} is already installed", + color::success_indicator(), + version + ); + return; + } + } + + println!(" Downloading Chrome {} for {}", version, platform_key()); + println!(" {}", url); + + let bytes = match rt.block_on(download_bytes(&url)) { + Ok(b) => b, + Err(e) => { + eprintln!("{} {}", color::error_indicator(), e); + exit(1); + } + }; + + match extract_zip(bytes, &dest) { + Ok(()) => { + println!( + "{} Chrome {} installed successfully", + color::success_indicator(), + version + ); + println!(" Location: {}", dest.display()); + if is_linux && !with_deps { println!(); println!( @@ -171,25 +361,148 @@ pub fn run_install(with_deps: bool) { println!(" agent-browser install --with-deps"); } } - Ok(_) => { - eprintln!("{} Failed to install browser", color::error_indicator()); - if is_linux { - println!( - "{} Try installing system dependencies first:", - color::yellow("Tip:") - ); - println!(" agent-browser install --with-deps"); - } - exit(1); - } Err(e) => { - eprintln!("{} Failed to run npx: {}", color::error_indicator(), e); - eprintln!("Make sure Node.js is installed and npx is in your PATH"); + let _ = fs::remove_dir_all(&dest); + eprintln!("{} {}", color::error_indicator(), e); exit(1); } } } +fn install_linux_deps() { + println!("{}", color::cyan("Installing system dependencies...")); + + let (pkg_mgr, deps) = if which_exists("apt-get") { + let libasound = if package_exists_apt("libasound2t64") { + "libasound2t64" + } else { + "libasound2" + }; + + ( + "apt-get", + vec![ + "libxcb-shm0", + "libx11-xcb1", + "libx11-6", + "libxcb1", + "libxext6", + "libxrandr2", + "libxcomposite1", + "libxcursor1", + "libxdamage1", + "libxfixes3", + "libxi6", + "libgtk-3-0", + "libpangocairo-1.0-0", + "libpango-1.0-0", + "libatk1.0-0", + "libcairo-gobject2", + "libcairo2", + "libgdk-pixbuf-2.0-0", + "libxrender1", + libasound, + "libfreetype6", + "libfontconfig1", + "libdbus-1-3", + "libnss3", + "libnspr4", + "libatk-bridge2.0-0", + "libdrm2", + "libxkbcommon0", + "libatspi2.0-0", + "libcups2", + "libxshmfence1", + "libgbm1", + ], + ) + } else if which_exists("dnf") { + ( + "dnf", + vec![ + "nss", + "nspr", + "atk", + "at-spi2-atk", + "cups-libs", + "libdrm", + "libXcomposite", + "libXdamage", + "libXrandr", + "mesa-libgbm", + "pango", + "alsa-lib", + "libxkbcommon", + "libxcb", + "libX11-xcb", + "libX11", + "libXext", + "libXcursor", + "libXfixes", + "libXi", + "gtk3", + "cairo-gobject", + ], + ) + } else if which_exists("yum") { + ( + "yum", + vec![ + "nss", + "nspr", + "atk", + "at-spi2-atk", + "cups-libs", + "libdrm", + "libXcomposite", + "libXdamage", + "libXrandr", + "mesa-libgbm", + "pango", + "alsa-lib", + "libxkbcommon", + ], + ) + } else { + eprintln!( + "{} No supported package manager found (apt-get, dnf, or yum)", + color::error_indicator() + ); + exit(1); + }; + + let install_cmd = match pkg_mgr { + "apt-get" => { + format!( + "sudo apt-get update && sudo apt-get install -y {}", + deps.join(" ") + ) + } + _ => format!("sudo {} install -y {}", pkg_mgr, deps.join(" ")), + }; + + println!("Running: {}", install_cmd); + let status = Command::new("sh").arg("-c").arg(&install_cmd).status(); + + match status { + Ok(s) if s.success() => { + println!( + "{} System dependencies installed", + color::success_indicator() + ) + } + Ok(_) => eprintln!( + "{} Failed to install some dependencies. You may need to run manually with sudo.", + color::warning_indicator() + ), + Err(e) => eprintln!( + "{} Could not run install command: {}", + color::warning_indicator(), + e + ), + } +} + fn which_exists(cmd: &str) -> bool { #[cfg(unix)] { diff --git a/cli/src/main.rs b/cli/src/main.rs index 3dda514..d9d781c 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -27,9 +27,6 @@ use output::{ print_command_help, print_help, print_response_with_opts, print_version, OutputOptions, }; -use std::path::PathBuf; -use std::process::Command as ProcessCommand; - fn serialize_json_value(value: &serde_json::Value) -> String { serde_json::to_string(value).unwrap_or_else(|_| { r#"{"success":false,"error":"Failed to serialize JSON response"}"#.to_string() @@ -55,110 +52,6 @@ fn print_json_error_with_type(message: impl AsRef, error_type: &str) { })); } -/// Run a local auth command (auth_save/list/show/delete) via node auth-cli.js. -/// These commands don't need a browser, so we handle them directly to avoid -/// sending passwords through the daemon's Unix socket channel. -fn run_auth_cli(cmd: &serde_json::Value, json_mode: bool) -> ! { - let exe_path = env::current_exe().unwrap_or_default(); - let exe_path = exe_path.canonicalize().unwrap_or(exe_path); - #[cfg(windows)] - let exe_path = { - let p = exe_path.to_string_lossy(); - if let Some(stripped) = p.strip_prefix(r"\\?\") { - PathBuf::from(stripped) - } else { - exe_path - } - }; - let exe_dir = exe_path.parent().unwrap_or(std::path::Path::new(".")); - - let mut script_paths = vec![ - exe_dir.join("auth-cli.js"), - exe_dir.join("../dist/auth-cli.js"), - PathBuf::from("dist/auth-cli.js"), - ]; - - if let Ok(home) = env::var("AGENT_BROWSER_HOME") { - let home_path = PathBuf::from(&home); - script_paths.insert(0, home_path.join("dist/auth-cli.js")); - script_paths.insert(1, home_path.join("auth-cli.js")); - } - - let script_path = match script_paths.iter().find(|p| p.exists()) { - Some(p) => p.clone(), - None => { - if json_mode { - print_json_error("auth-cli.js not found"); - } else { - eprintln!( - "{} auth-cli.js not found. Set AGENT_BROWSER_HOME or run from project directory.", - color::error_indicator() - ); - } - exit(1); - } - }; - - let cmd_json = serde_json::to_string(cmd).unwrap_or_default(); - - match ProcessCommand::new("node") - .arg(&script_path) - .arg(&cmd_json) - .output() - { - Ok(output) => { - let stderr = String::from_utf8_lossy(&output.stderr); - if !stderr.is_empty() { - eprint!("{}", stderr); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - let stdout = stdout.trim(); - - if stdout.is_empty() { - if json_mode { - print_json_error("No response from auth-cli"); - } else { - eprintln!("{} No response from auth-cli", color::error_indicator()); - } - exit(1); - } - - if json_mode { - println!("{}", stdout); - } else { - // Parse the JSON response and use the standard output formatter - match serde_json::from_str::(stdout) { - Ok(resp) => { - let action = cmd.get("action").and_then(|v| v.as_str()); - let opts = OutputOptions { - json: false, - content_boundaries: false, - max_output: None, - }; - print_response_with_opts(&resp, action, &opts); - if !resp.success { - exit(1); - } - } - Err(_) => { - println!("{}", stdout); - } - } - } - exit(output.status.code().unwrap_or(0)); - } - Err(e) => { - if json_mode { - print_json_error(format!("Failed to run auth-cli: {}", e)); - } else { - eprintln!("{} Failed to run auth-cli: {}", color::error_indicator(), e); - } - exit(1); - } - } -} - fn parse_proxy(proxy_str: &str) -> serde_json::Value { let Some(protocol_end) = proxy_str.find("://") else { return json!({ "server": proxy_str }); @@ -299,13 +192,9 @@ fn main() { } let args: Vec = env::args().skip(1).collect(); - let mut flags = parse_flags(&args); + let flags = parse_flags(&args); let clean = clean_args(&args); - if flags.engine.is_some() && !flags.native { - flags.native = true; - } - let has_help = args.iter().any(|a| a == "--help" || a == "-h"); let has_version = args.iter().any(|a| a == "--version" || a == "-V"); @@ -392,17 +281,6 @@ fn main() { } } - // Handle local auth commands without starting the daemon. - // These don't need a browser, so we avoid sending passwords through the socket. - if let Some(action) = cmd.get("action").and_then(|v| v.as_str()) { - if matches!( - action, - "auth_save" | "auth_list" | "auth_show" | "auth_delete" - ) { - run_auth_cli(&cmd, flags.json); - } - } - // Validate session name before starting daemon if let Some(ref name) = flags.session_name { if !validation::is_valid_session_name(name) { @@ -436,7 +314,6 @@ fn main() { allowed_domains: flags.allowed_domains.as_deref(), action_policy: flags.action_policy.as_deref(), confirm_actions: flags.confirm_actions.as_deref(), - native: flags.native, engine: flags.engine.as_deref(), }; let daemon_result = match ensure_daemon(&flags.session, &daemon_opts) { @@ -495,7 +372,6 @@ fn main() { flags.ignore_https_errors.then_some("--ignore-https-errors"), flags.cli_allow_file_access.then_some("--allow-file-access"), flags.cli_download_path.then_some("--download-path"), - flags.cli_native.then_some("--native"), flags.cli_headed.then_some("--headed"), ] .into_iter() diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index 4d6d13b..e64dca9 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -1,10 +1,12 @@ use serde_json::{json, Value}; use std::env; -use tokio::sync::broadcast; +use std::sync::Arc; +use tokio::sync::{broadcast, RwLock}; use super::auth; use super::browser::{BrowserManager, WaitUntil}; use super::cdp::chrome::LaunchOptions; +use super::cdp::client::CdpClient; use super::cdp::types::{ AttachToTargetParams, AttachToTargetResult, CdpEvent, ConsoleApiCalledEvent, CreateTargetResult, ExceptionThrownEvent, TargetCreatedEvent, TargetDestroyedEvent, @@ -102,6 +104,8 @@ pub struct DaemonState { pub tracked_requests: Vec, pub request_tracking: bool, pub active_frame_id: Option, + /// Shared slot for stream server to receive CDP client when browser launches. + pub stream_client: Option>>>>, } impl DaemonState { @@ -134,15 +138,33 @@ impl DaemonState { tracked_requests: Vec::new(), request_tracking: false, active_frame_id: None, + stream_client: None, } } + /// Create state with an optional stream client slot (for daemon startup with stream server). + pub fn new_with_stream_client( + stream_client: Option>>>>, + ) -> Self { + let mut s = Self::new(); + s.stream_client = stream_client; + s + } + fn subscribe_to_browser_events(&mut self) { if let Some(ref browser) = self.browser { self.event_rx = Some(browser.client.subscribe()); } } + /// Update the stream server's CDP client slot when browser is set or cleared. + pub async fn update_stream_client(&self) { + if let Some(ref slot) = self.stream_client { + let mut guard = slot.write().await; + *guard = self.browser.as_ref().map(|m| Arc::clone(&m.client)); + } + } + fn drain_cdp_events( &mut self, ) -> ( @@ -540,6 +562,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value { let _ = mgr.close().await; } state.browser = None; + state.update_stream_client().await; } if let Err(e) = auto_launch(state).await { return error_response(&id, &format!("Auto-launch failed: {}", e)); @@ -739,6 +762,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> { let mgr = BrowserManager::connect_cdp(&cdp).await?; state.browser = Some(mgr); state.subscribe_to_browser_events(); + state.update_stream_client().await; try_auto_restore_state(state).await; return Ok(()); } @@ -747,6 +771,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> { let mgr = BrowserManager::connect_auto().await?; state.browser = Some(mgr); state.subscribe_to_browser_events(); + state.update_stream_client().await; try_auto_restore_state(state).await; return Ok(()); } @@ -754,6 +779,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> { let mgr = BrowserManager::launch(options, engine.as_deref()).await?; state.browser = Some(mgr); state.subscribe_to_browser_events(); + state.update_stream_client().await; try_auto_restore_state(state).await; Ok(()) } @@ -857,6 +883,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result Result Result { state.browser = Some(mgr); state.subscribe_to_browser_events(); + state.update_stream_client().await; return Ok(json!({ "launched": true, "provider": provider })); } Err(e) => { @@ -1008,6 +1039,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result Result { mgr.close().await?; } state.browser = None; + state.update_stream_client().await; // Close WebDriver sessions if let Some(ref mut wb) = state.webdriver_backend { @@ -1463,6 +1496,44 @@ async fn handle_click(cmd: &Value, state: &mut DaemonState) -> Result unsafe { std::env::set_var(key_var, val) }, + None => unsafe { std::env::remove_var(key_var) }, + } } #[tokio::test] diff --git a/cli/src/native/browser.rs b/cli/src/native/browser.rs index 64f11e2..522fbde 100644 --- a/cli/src/native/browser.rs +++ b/cli/src/native/browser.rs @@ -160,7 +160,7 @@ impl BrowserProcess { } pub struct BrowserManager { - pub client: CdpClient, + pub client: Arc, browser_process: Option, ws_url: String, pages: Vec, @@ -226,7 +226,7 @@ impl BrowserManager { let manager = if engine == "lightpanda" { initialize_lightpanda_manager(ws_url, process).await? } else { - let client = CdpClient::connect(&ws_url).await?; + let client = Arc::new(CdpClient::connect(&ws_url).await?); let mut manager = Self { client, browser_process: Some(process), @@ -290,7 +290,7 @@ impl BrowserManager { pub async fn connect_cdp(url: &str) -> Result { let ws_url = resolve_cdp_url(url).await?; - let client = CdpClient::connect(&ws_url).await?; + let client = Arc::new(CdpClient::connect(&ws_url).await?); let mut manager = Self { client, browser_process: None, @@ -1173,7 +1173,7 @@ async fn initialize_lightpanda_manager( }; let mut manager = BrowserManager { - client, + client: Arc::new(client), browser_process: None, ws_url: ws_url.clone(), pages: Vec::new(), diff --git a/cli/src/native/cdp/chrome.rs b/cli/src/native/cdp/chrome.rs index 2075450..0479626 100644 --- a/cli/src/native/cdp/chrome.rs +++ b/cli/src/native/cdp/chrome.rs @@ -190,7 +190,7 @@ pub fn launch_chrome(options: &LaunchOptions) -> Result { let chrome_path = match &options.executable_path { Some(p) => PathBuf::from(p), None => { - find_chrome().ok_or("Chrome not found. Install Chrome or use --executable-path.")? + find_chrome().ok_or("Chrome not found. Run `agent-browser install` to download Chrome, or use --executable-path.")? } }; @@ -320,6 +320,12 @@ fn chrome_launch_error(message: &str, stderr_lines: &[String]) -> String { } pub fn find_chrome() -> Option { + // 1. Check Chrome downloaded by `agent-browser install` + if let Some(p) = crate::install::find_installed_chrome() { + return Some(p); + } + + // 2. Check system-installed Chrome #[cfg(target_os = "macos")] { let candidates = [ @@ -333,10 +339,6 @@ pub fn find_chrome() -> Option { return Some(p); } } - - if let Some(p) = find_playwright_chromium() { - return Some(p); - } } #[cfg(target_os = "linux")] @@ -357,10 +359,6 @@ pub fn find_chrome() -> Option { } } } - - if let Some(p) = find_playwright_chromium() { - return Some(p); - } } #[cfg(target_os = "windows")] @@ -383,6 +381,11 @@ pub fn find_chrome() -> Option { } } + // 3. Fallback: check Playwright's browser cache (for existing installs) + if let Some(p) = find_playwright_chromium() { + return Some(p); + } + None } @@ -500,7 +503,7 @@ fn should_disable_sandbox(existing_args: &[String]) -> bool { } /// Search Playwright's browser cache for a Chromium binary. -/// This is where `agent-browser install` (via `npx playwright install chromium`) puts it. +/// Legacy fallback for users who previously installed Chromium via Playwright. fn find_playwright_chromium() -> Option { let mut search_dirs = Vec::new(); diff --git a/cli/src/native/cdp/lightpanda.rs b/cli/src/native/cdp/lightpanda.rs index 60ef6e4..e43b4de 100644 --- a/cli/src/native/cdp/lightpanda.rs +++ b/cli/src/native/cdp/lightpanda.rs @@ -342,6 +342,7 @@ mod tests { socket.write_all(response.as_bytes()).await.unwrap(); } + #[cfg(unix)] #[tokio::test] async fn waits_for_ready_without_logs() { let port = unused_port(); @@ -369,6 +370,7 @@ mod tests { let _ = child.wait(); } + #[cfg(unix)] #[tokio::test] async fn child_exit_surfaces_logs() { let port = unused_port(); @@ -389,6 +391,7 @@ mod tests { assert!(err.contains("boom")); } + #[cfg(unix)] #[tokio::test] async fn timeout_reports_last_probe_error() { let port = unused_port(); diff --git a/cli/src/native/daemon.rs b/cli/src/native/daemon.rs index 3d2bfc3..65d3918 100644 --- a/cli/src/native/daemon.rs +++ b/cli/src/native/daemon.rs @@ -3,12 +3,17 @@ use std::env; use std::fs; use std::path::PathBuf; use std::process; +use std::sync::Arc; +use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::signal; +use tokio::sync::{mpsc, RwLock}; use super::actions::{execute_command, DaemonState}; +use super::cdp::client::CdpClient; use super::state; +use super::stream::StreamServer; pub async fn run_daemon(session: &str) { let socket_dir = get_daemon_socket_dir(); @@ -33,7 +38,34 @@ pub async fn run_daemon(session: &str) { } } - let result = run_socket_server(&socket_path, session).await; + let mut stream_client: Option>>>> = None; + if let Ok(port_str) = env::var("AGENT_BROWSER_STREAM_PORT") { + if let Ok(port) = port_str.parse::() { + if port > 0 { + match StreamServer::start_without_client(port, session.to_string()).await { + Ok((stream_server, client_slot)) => { + stream_client = Some(client_slot.clone()); + let stream_path = socket_dir.join(format!("{}.stream", session)); + if let Err(e) = fs::write(&stream_path, stream_server.port().to_string()) { + eprintln!("Failed to write .stream file: {}", e); + } + } + Err(e) => { + eprintln!("Stream server failed to start: {}", e); + } + } + } + } + } + + // Auto-shutdown the daemon after this many ms of inactivity (no commands received). + // Disabled when unset or 0. + let idle_timeout_ms = env::var("AGENT_BROWSER_IDLE_TIMEOUT_MS") + .ok() + .and_then(|s| s.parse::().ok()) + .filter(|&ms| ms > 0); + + let result = run_socket_server(&socket_path, session, stream_client, idle_timeout_ms).await; let _ = fs::remove_file(&socket_path); let _ = fs::remove_file(&pid_path); @@ -47,23 +79,36 @@ pub async fn run_daemon(session: &str) { } #[cfg(unix)] -async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(), String> { +async fn run_socket_server( + socket_path: &PathBuf, + _session: &str, + stream_client: Option>>>>, + idle_timeout_ms: Option, +) -> Result<(), String> { use tokio::net::UnixListener; let listener = UnixListener::bind(socket_path).map_err(|e| format!("Failed to bind socket: {}", e))?; - let state: std::sync::Arc> = - std::sync::Arc::new(tokio::sync::Mutex::new(DaemonState::new())); + let state: std::sync::Arc> = std::sync::Arc::new( + tokio::sync::Mutex::new(DaemonState::new_with_stream_client(stream_client)), + ); + + let (reset_tx, mut reset_rx) = mpsc::channel::<()>(64); + let reset_tx = idle_timeout_ms.map(|_| Arc::new(reset_tx)); loop { + let sleep_future = idle_timeout_ms.map(|ms| tokio::time::sleep(Duration::from_millis(ms))); + let mut sleep_pin = sleep_future.map(Box::pin); + tokio::select! { accept_result = listener.accept() => { match accept_result { Ok((stream, _)) => { let state = state.clone(); + let reset_tx = reset_tx.clone(); tokio::spawn(async move { - handle_connection(stream, state).await; + handle_connection(stream, state, reset_tx).await; }); } Err(e) => { @@ -71,6 +116,22 @@ async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(), } } } + _ = async { + if let Some(ref mut s) = sleep_pin { + s.as_mut().await + } else { + std::future::pending::<()>().await + } + }, if idle_timeout_ms.is_some() => { + let mut s = state.lock().await; + if let Some(ref mut mgr) = s.browser { + let _ = mgr.close().await; + } + break; + } + _ = reset_rx.recv(), if idle_timeout_ms.is_some() => { + continue; + } _ = shutdown_signal() => { let mut s = state.lock().await; if let Some(ref mut mgr) = s.browser { @@ -85,7 +146,12 @@ async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(), } #[cfg(windows)] -async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), String> { +async fn run_socket_server( + socket_path: &PathBuf, + session: &str, + stream_client: Option>>>>, + idle_timeout_ms: Option, +) -> Result<(), String> { use tokio::net::TcpListener; let port = get_port_for_session(session); @@ -97,17 +163,25 @@ async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), S let port_path = socket_dir.join(format!("{}.port", session)); let _ = fs::write(&port_path, port.to_string()); - let state: std::sync::Arc> = - std::sync::Arc::new(tokio::sync::Mutex::new(DaemonState::new())); + let state: std::sync::Arc> = std::sync::Arc::new( + tokio::sync::Mutex::new(DaemonState::new_with_stream_client(stream_client)), + ); + + let (reset_tx, mut reset_rx) = mpsc::channel::<()>(64); + let reset_tx = idle_timeout_ms.map(|_| Arc::new(reset_tx)); loop { + let sleep_future = idle_timeout_ms.map(|ms| tokio::time::sleep(Duration::from_millis(ms))); + let mut sleep_pin = sleep_future.map(Box::pin); + tokio::select! { accept_result = listener.accept() => { match accept_result { Ok((stream, _)) => { let state = state.clone(); + let reset_tx = reset_tx.clone(); tokio::spawn(async move { - handle_connection(stream, state).await; + handle_connection(stream, state, reset_tx).await; }); } Err(e) => { @@ -115,6 +189,23 @@ async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), S } } } + _ = async { + if let Some(ref mut s) = sleep_pin { + s.as_mut().await + } else { + std::future::pending::<()>().await + } + }, if idle_timeout_ms.is_some() => { + let mut s = state.lock().await; + if let Some(ref mut mgr) = s.browser { + let _ = mgr.close().await; + } + let _ = fs::remove_file(&port_path); + break; + } + _ = reset_rx.recv(), if idle_timeout_ms.is_some() => { + continue; + } _ = shutdown_signal() => { let mut s = state.lock().await; if let Some(ref mut mgr) = s.browser { @@ -129,8 +220,11 @@ async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), S Ok(()) } -async fn handle_connection(stream: S, state: std::sync::Arc>) -where +async fn handle_connection( + stream: S, + state: std::sync::Arc>, + idle_reset_tx: Option>>, +) where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, { let (reader, mut writer) = tokio::io::split(stream); @@ -165,6 +259,10 @@ where } }; + if let Some(ref tx) = idle_reset_tx { + let _ = tx.try_send(()); + } + let is_close = cmd.get("action").and_then(|v| v.as_str()) == Some("close"); let response = { diff --git a/cli/src/native/parity_tests.rs b/cli/src/native/parity_tests.rs index b773a38..9eef3d8 100644 --- a/cli/src/native/parity_tests.rs +++ b/cli/src/native/parity_tests.rs @@ -533,6 +533,7 @@ async fn test_daemon_state_new_defaults() { assert!(state.tracked_requests.is_empty()); assert!(state.active_frame_id.is_none()); assert!(state.webdriver_backend.is_none()); + assert!(state.stream_client.is_none()); } #[tokio::test] diff --git a/cli/src/native/stream.rs b/cli/src/native/stream.rs index b5f428c..95694c1 100644 --- a/cli/src/native/stream.rs +++ b/cli/src/native/stream.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use futures_util::{SinkExt, StreamExt}; use tokio::net::TcpListener; -use tokio::sync::{broadcast, Mutex}; +use tokio::sync::{broadcast, Mutex, RwLock}; use tokio_tungstenite::tungstenite::Message; use super::cdp::client::CdpClient; @@ -47,6 +47,27 @@ impl StreamServer { client: Arc, session_id: String, ) -> Result { + let client_slot = Arc::new(RwLock::new(Some(client))); + let (server, _) = Self::start_inner(preferred_port, client_slot, session_id).await?; + Ok(server) + } + + /// Start the stream server without a CDP client (e.g. at daemon startup before browser launch). + /// Returns the server and a shared slot to set the client when the browser launches. + /// Input messages are ignored until the client is set. + pub async fn start_without_client( + preferred_port: u16, + session_id: String, + ) -> Result<(Self, Arc>>>), String> { + let client_slot = Arc::new(RwLock::new(None::>)); + Self::start_inner(preferred_port, client_slot, session_id).await + } + + async fn start_inner( + preferred_port: u16, + client_slot: Arc>>>, + session_id: String, + ) -> Result<(Self, Arc>>>), String> { let addr = format!("127.0.0.1:{}", preferred_port); let listener = TcpListener::bind(&addr) .await @@ -62,23 +83,27 @@ impl StreamServer { let frame_tx_clone = frame_tx.clone(); let client_count_clone = client_count.clone(); + let client_slot_clone = client_slot.clone(); tokio::spawn(async move { accept_loop( listener, frame_tx_clone, client_count_clone, - client, + client_slot_clone, session_id, ) .await; }); - Ok(Self { - port, - frame_tx, - client_count, - }) + Ok(( + Self { + port, + frame_tx, + client_count, + }, + client_slot, + )) } pub fn port(&self) -> u16 { @@ -140,17 +165,17 @@ async fn accept_loop( listener: TcpListener, frame_tx: broadcast::Sender, client_count: Arc>, - cdp_client: Arc, + client_slot: Arc>>>, session_id: String, ) { while let Ok((stream, addr)) = listener.accept().await { let frame_rx = frame_tx.subscribe(); let client_count = client_count.clone(); - let cdp = cdp_client.clone(); + let client_slot = client_slot.clone(); let sid = session_id.clone(); tokio::spawn(async move { - handle_ws_client(stream, addr, frame_rx, client_count, cdp, sid).await; + handle_ws_client(stream, addr, frame_rx, client_count, client_slot, sid).await; }); } } @@ -161,10 +186,9 @@ async fn handle_ws_client( _addr: SocketAddr, mut frame_rx: broadcast::Receiver, client_count: Arc>, - cdp_client: Arc, + client_slot: Arc>>>, session_id: String, ) { - // Origin checking on WebSocket handshake let callback = |req: &tokio_tungstenite::tungstenite::handshake::server::Request, resp: tokio_tungstenite::tungstenite::handshake::server::Response| { @@ -211,7 +235,10 @@ async fn handle_ws_client( msg = ws_rx.next() => { match msg { Some(Ok(Message::Text(text))) => { - handle_client_message(&text, &cdp_client, &session_id).await; + let guard = client_slot.read().await; + if let Some(ref client) = *guard { + handle_client_message(&text, client.as_ref(), &session_id).await; + } } Some(Ok(Message::Close(_))) | None => break, _ => {} diff --git a/cli/src/output.rs b/cli/src/output.rs index 346db8d..fc91829 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -1389,8 +1389,7 @@ Options: Each label [N] corresponds to ref @eN from snapshot. Prints a legend mapping labels to element roles/names. With --json, annotations are included in the response. - In native mode, this is currently supported on the - CDP-backed browser path (Chromium/Lightpanda). + Supported on Chromium and Lightpanda. --screenshot-dir Default output directory for screenshots (or AGENT_BROWSER_SCREENSHOT_DIR env) --screenshot-quality <0-100> JPEG quality (0-100, only applies to jpeg format) @@ -1986,7 +1985,7 @@ agent-browser trace - Record execution trace Usage: agent-browser trace [path] -Record a trace for debugging with Playwright Trace Viewer. +Record a Chrome DevTools trace for debugging. Operations: start [path] Start recording trace @@ -2053,7 +2052,7 @@ Usage: agent-browser record start [url] agent-browser record stop agent-browser record restart [url] -Record the browser to a WebM video file using Playwright's native recording. +Record the browser to a WebM video file. Creates a fresh browser context but preserves cookies and localStorage. If no URL is provided, automatically navigates to your current page. @@ -2499,7 +2498,7 @@ Diff: diff url Compare two pages Debug: - trace start|stop [path] Record Playwright trace + trace start|stop [path] Record Chrome DevTools trace profiler start|stop [path] Record Chrome DevTools profile record start [url] Start video recording (WebM) record stop Stop and save video @@ -2576,8 +2575,7 @@ Options: --action-policy Action policy JSON file (or AGENT_BROWSER_ACTION_POLICY) --confirm-actions Categories requiring confirmation (or AGENT_BROWSER_CONFIRM_ACTIONS) --confirm-interactive Interactive confirmation prompts; auto-denies if stdin is not a TTY (or AGENT_BROWSER_CONFIRM_INTERACTIVE) - --engine Browser engine: chrome (default), lightpanda; implies --native (or AGENT_BROWSER_ENGINE) - --native [Experimental] Use native Rust daemon instead of Node.js (or AGENT_BROWSER_NATIVE) + --engine Browser engine: chrome (default), lightpanda (or AGENT_BROWSER_ENGINE) --config Use a custom config file (or AGENT_BROWSER_CONFIG env) --debug Debug output --version, -V Show version @@ -2620,11 +2618,12 @@ Environment: AGENT_BROWSER_ALLOW_FILE_ACCESS Allow file:// URLs to access local files AGENT_BROWSER_COLOR_SCHEME Color scheme preference (dark, light, no-preference) AGENT_BROWSER_DOWNLOAD_PATH Default download directory for browser downloads - AGENT_BROWSER_DEFAULT_TIMEOUT Default Playwright timeout in ms (default: 25000) + AGENT_BROWSER_DEFAULT_TIMEOUT Default action timeout in ms (default: 25000) AGENT_BROWSER_SESSION_NAME Auto-save/load state persistence name AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete saved states older than N days (default: 30) AGENT_BROWSER_ENCRYPTION_KEY 64-char hex key for AES-256-GCM session encryption AGENT_BROWSER_STREAM_PORT Enable WebSocket streaming on port (e.g., 9223) + AGENT_BROWSER_IDLE_TIMEOUT_MS Auto-shutdown daemon after N ms of inactivity (disabled by default) AGENT_BROWSER_IOS_DEVICE Default iOS device name AGENT_BROWSER_IOS_UDID Default iOS device UDID AGENT_BROWSER_CONTENT_BOUNDARIES Wrap page output in boundary markers @@ -2634,17 +2633,13 @@ Environment: AGENT_BROWSER_CONFIRM_ACTIONS Action categories requiring confirmation AGENT_BROWSER_CONFIRM_INTERACTIVE Enable interactive confirmation prompts AGENT_BROWSER_ENGINE Browser engine: chrome (default), lightpanda - AGENT_BROWSER_NATIVE Use native Rust daemon (experimental, no Node.js/Playwright) AGENT_BROWSER_SCREENSHOT_DIR Default screenshot output directory AGENT_BROWSER_SCREENSHOT_QUALITY JPEG quality 0-100 AGENT_BROWSER_SCREENSHOT_FORMAT Screenshot format: png, jpeg -Install (recommended, fastest - native Rust CLI): +Install: npm install -g agent-browser - agent-browser install # Download Chromium (first time) - -Try without installing (slower, routes through Node.js): - npx agent-browser open example.com + agent-browser install # Download Chrome (first time) Examples: agent-browser open example.com diff --git a/docs/src/app/cdp-mode/page.mdx b/docs/src/app/cdp-mode/page.mdx index 2004527..065b84e 100644 --- a/docs/src/app/cdp-mode/page.mdx +++ b/docs/src/app/cdp-mode/page.mdx @@ -62,7 +62,7 @@ This is useful when: ## Color scheme -Playwright overrides the browser's color scheme to `light` by default when connecting via CDP. Use `--color-scheme` to set a persistent preference: +Use `--color-scheme` to set a persistent preference when connecting via CDP: ```bash agent-browser --cdp 9222 --color-scheme dark open https://example.com diff --git a/docs/src/app/changelog/page.mdx b/docs/src/app/changelog/page.mdx index 6cbb2b0..79a6f64 100644 --- a/docs/src/app/changelog/page.mdx +++ b/docs/src/app/changelog/page.mdx @@ -50,7 +50,7 @@ agent-browser screenshot --screenshot-format jpeg --screenshot-quality 80 ### New Features -- **`inspect` command** -- Opens Chrome DevTools for the active page by launching a local proxy server that forwards the DevTools frontend to the browser's CDP WebSocket. Agent commands continue to work while DevTools is open. Implemented in both Node.js and native daemon paths. +- **`inspect` command** -- Opens Chrome DevTools for the active page by launching a local proxy server that forwards the DevTools frontend to the browser's CDP WebSocket. Agent commands continue to work while DevTools is open. ```bash agent-browser open example.com @@ -64,7 +64,7 @@ agent-browser click "Submit" # commands still work while DevTools is open agent-browser get cdp-url ``` -- **Native screenshot annotate** -- The `--annotate` flag for screenshots now works in the native Rust daemon, bringing full parity with the Node.js path. +- **Screenshot annotate** -- The `--annotate` flag overlays numbered labels on interactive elements in screenshots. ### Improvements diff --git a/docs/src/app/configuration/page.mdx b/docs/src/app/configuration/page.mdx index 5ade413..1edc7b8 100644 --- a/docs/src/app/configuration/page.mdx +++ b/docs/src/app/configuration/page.mdx @@ -81,7 +81,6 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
-
confirmActions--confirm-actionsstring
confirmInteractive--confirm-interactiveboolean
engine--enginestring (chrome, lightpanda)
native--nativeboolean (experimental)
headers--headersstring (JSON)
@@ -151,7 +150,7 @@ agent-browser --headed open example.com # same as --headed true agent-browser --headed true open example.com # explicit ``` -This applies to all boolean flags: `--headed`, `--debug`, `--json`, `--ignore-https-errors`, `--allow-file-access`, `--auto-connect`, `--content-boundaries`, `--confirm-interactive`, `--native`. +This applies to all boolean flags: `--headed`, `--debug`, `--json`, `--ignore-https-errors`, `--allow-file-access`, `--auto-connect`, `--content-boundaries`, `--confirm-interactive`. ## Extensions Merging @@ -172,13 +171,14 @@ These environment variables configure additional daemon and runtime behavior: AGENT_BROWSER_ALLOW_FILE_ACCESSAllow file:// URLs to access local files.(disabled) AGENT_BROWSER_COLOR_SCHEMEColor scheme preference (dark, light, no-preference).(none) AGENT_BROWSER_DOWNLOAD_PATHDefault directory for browser downloads.(temp directory) - AGENT_BROWSER_DEFAULT_TIMEOUTDefault Playwright timeout in ms. Keep below 30000 to avoid IPC timeouts.25000 + AGENT_BROWSER_DEFAULT_TIMEOUTDefault timeout in ms. Keep below 30000 to avoid IPC timeouts.25000 AGENT_BROWSER_SESSION_NAMEAuto-save/load state persistence name.(none) AGENT_BROWSER_STATE_EXPIRE_DAYSAuto-delete saved session states older than N days.30 AGENT_BROWSER_ENCRYPTION_KEY64-char hex key for AES-256-GCM session encryption.(none) AGENT_BROWSER_EXTENSIONSComma-separated browser extension paths. Extensions work in both headed and headless mode.(none) AGENT_BROWSER_HEADEDShow browser window instead of running headless (1 to enable).(disabled) AGENT_BROWSER_STREAM_PORTEnable WebSocket streaming on the specified port (e.g., 9223).(disabled) + AGENT_BROWSER_IDLE_TIMEOUT_MSAuto-shutdown the daemon after N ms of inactivity (no commands received). Useful for ephemeral environments.(disabled) AGENT_BROWSER_IOS_DEVICEDefault iOS device name for the ios provider.(none) AGENT_BROWSER_IOS_UDIDDefault iOS device UDID for the ios provider.(none) AGENT_BROWSER_DEBUGEnable debug output (1 to enable).(disabled) @@ -188,8 +188,7 @@ These environment variables configure additional daemon and runtime behavior: AGENT_BROWSER_ACTION_POLICYPath to action policy JSON file.(none) AGENT_BROWSER_CONFIRM_ACTIONSComma-separated action categories requiring confirmation.(none) AGENT_BROWSER_CONFIRM_INTERACTIVEEnable interactive confirmation prompts (auto-denies if stdin is not a TTY).(disabled) - AGENT_BROWSER_ENGINEBrowser engine to use: chrome (default), lightpanda. Implies --native.chrome - AGENT_BROWSER_NATIVEUse the experimental native Rust daemon instead of Node.js/Playwright.(disabled) + AGENT_BROWSER_ENGINEBrowser engine to use: chrome (default), lightpanda.chrome diff --git a/docs/src/app/engines/chrome/page.mdx b/docs/src/app/engines/chrome/page.mdx index 63720c3..7b4071d 100644 --- a/docs/src/app/engines/chrome/page.mdx +++ b/docs/src/app/engines/chrome/page.mdx @@ -21,7 +21,7 @@ When no `--executable-path` is provided, agent-browser searches for Chrome in th /Applications/Google Chrome.app, /Applications/Google Chrome Canary.app, /Applications/Chromium.app, - Playwright Chromium cache + Chrome for Testing cache @@ -31,7 +31,7 @@ When no `--executable-path` is provided, agent-browser searches for Chrome in th google-chrome-stable, chromium-browser, chromium in PATH, - Playwright Chromium cache + Chrome for Testing cache @@ -45,7 +45,7 @@ When no `--executable-path` is provided, agent-browser searches for Chrome in th -If Chrome is not found, run `agent-browser install` to download Chromium via Playwright. +If Chrome is not found, run `agent-browser install` to download Chrome from Chrome for Testing. ## Usage diff --git a/docs/src/app/installation/page.mdx b/docs/src/app/installation/page.mdx index f203965..7dd10cd 100644 --- a/docs/src/app/installation/page.mdx +++ b/docs/src/app/installation/page.mdx @@ -10,42 +10,34 @@ Installs the native Rust binary for maximum performance: ```bash npm install -g agent-browser -agent-browser install # Download Chromium +agent-browser install # Download Chrome from Chrome for Testing (first time) ``` This is the fastest option -- commands run through the native Rust CLI directly with sub-millisecond parsing overhead. ## Quick start (no install) -Run directly with `npx` if you want to try it without installing globally: - ```bash -npx agent-browser install # Download Chromium (first time only) +npx agent-browser install # Download Chrome (first time only) npx agent-browser open example.com ``` -> **Note:** `npx` routes through Node.js before reaching the Rust CLI, so it is noticeably slower than a global install. For regular use, install globally. - ## Project installation (local dependency) For projects that want to pin the version in `package.json`: ```bash npm install agent-browser -npx agent-browser install +npx agent-browser install # Download Chrome (first time) ``` -Then use via `npx` or `package.json` scripts: - -```bash -npx agent-browser open example.com -``` +Then use via `npx` or `package.json` scripts. ## Homebrew (macOS) ```bash brew install agent-browser -agent-browser install # Download Chromium +agent-browser install # Download Chrome (first time) ``` ## From source @@ -66,7 +58,6 @@ On Linux, install system dependencies: ```bash agent-browser install --with-deps -# or manually: npx playwright install-deps chromium ``` ## Custom browser @@ -87,19 +78,7 @@ AGENT_BROWSER_EXECUTABLE_PATH=/path/to/chromium agent-browser open example.com ### Serverless example -```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 -} -``` +Use `@sparticuz/chromium` or similar to obtain a Chromium executable path, then pass it via `--executable-path` or `AGENT_BROWSER_EXECUTABLE_PATH`. ## AI agent setup diff --git a/docs/src/app/native-mode/page.mdx b/docs/src/app/native-mode/page.mdx index a286d42..a5fb58e 100644 --- a/docs/src/app/native-mode/page.mdx +++ b/docs/src/app/native-mode/page.mdx @@ -2,85 +2,8 @@ import { pageMetadata } from "@/lib/page-metadata" export const metadata = pageMetadata("native-mode") -# Native Mode (Experimental) +# Native Mode -agent-browser includes an experimental native Rust daemon that communicates with Chrome directly via the Chrome DevTools Protocol (CDP), eliminating the Node.js and Playwright dependencies entirely. +agent-browser is now 100% native Rust by default. The Node.js/Playwright daemon has been removed. -## Enabling Native Mode - -Native mode is opt-in. Enable it with the `--native` flag or the `AGENT_BROWSER_NATIVE` environment variable. - -### CLI Flag - -```bash -agent-browser --native open example.com -agent-browser --native snapshot -agent-browser --native close -``` - -### Environment Variable - -Set `AGENT_BROWSER_NATIVE=1` to avoid passing the flag on every command: - -```bash -export AGENT_BROWSER_NATIVE=1 -agent-browser open example.com -agent-browser snapshot -agent-browser close -``` - -### Config File - -Add `"native": true` to your `agent-browser.json`: - -```json -{"native": true} -``` - -## Architecture Comparison - - - - - - - - - - - - -
Default (Node.js)Native (--native)
RuntimeNode.js + PlaywrightPure Rust binary
ProtocolPlaywright protocolDirect CDP / WebDriver
Install sizeLarger (Node.js + npm deps)Smaller (single binary)
Browser supportChromium, Firefox, WebKitChromium, Safari (via WebDriver)
StabilityStableExperimental
- -## What Works - -All core commands are supported in native mode: - -- Navigation: `open`, `back`, `forward`, `reload` -- Interaction: `click`, `fill`, `type`, `press`, `hover`, `select`, `check`, `uncheck`, `scroll`, `focus`, `clear`, `upload`, `drag` -- Observation: `snapshot`, `screenshot`, `eval`, `get text/html/value/attr/count/box/styles`, `is visible/enabled/checked` -- State: `cookies get/set/clear`, `storage local/session`, `state save/load/list` -- Tabs: `tab new/list/close`, tab switching -- Emulation: `set viewport`, `set device`, `set geo`, user agent, timezone, locale -- Streaming: WebSocket screencast and remote input -- Diffing: `diff snapshot`, `diff url` -- Recording: `record start/stop` -- Profiling: `profiler start/stop`, `trace start/stop` - -## Known Limitations - -- **Firefox and WebKit** are not yet supported (Chromium and Safari only) -- **Annotated screenshots** (`screenshot --annotate`) currently work on the CDP-backed browser path. The Safari/WebDriver backend does not yet support them. -- **Playwright trace format** is not available (native tracing uses Chrome's built-in tracing) -- **HAR export** is not available -- **Network route interception** uses CDP Fetch domain instead of Playwright's route API - -## Switching Between Modes - -The native daemon and Node.js daemon share the same session socket. You cannot run both simultaneously for the same session. Close the current daemon before switching: - -```bash -agent-browser close -export AGENT_BROWSER_NATIVE=1 -agent-browser open example.com -``` +This page is no longer relevant. See the main [documentation](/) for current architecture and usage. diff --git a/docs/src/app/page.mdx b/docs/src/app/page.mdx index 82ce664..aa31c83 100644 --- a/docs/src/app/page.mdx +++ b/docs/src/app/page.mdx @@ -4,11 +4,12 @@ export const metadata = pageMetadata("") # agent-browser -Browser automation CLI designed for AI agents. Compact text output minimizes context usage. Fast Rust CLI with Node.js fallback. +Browser automation CLI designed for AI agents. Compact text output minimizes context usage. 100% native Rust. ```bash -npm install -g agent-browser # all platforms (fastest, native Rust CLI) +npm install -g agent-browser # all platforms brew install agent-browser # macOS +agent-browser install # Download Chrome (first time) # or try without installing npx agent-browser open example.com @@ -59,8 +60,7 @@ has a unique ref like `@e1`, `@e2`. This provides: Client-daemon architecture for optimal performance: 1. **Rust CLI** - Parses commands, communicates with daemon -2. **Node.js Daemon** (default) - Manages Playwright browser instance -3. **Native Daemon** (experimental, `--native`) - Pure Rust daemon using direct CDP, no Node.js required +2. **Native Daemon** - Pure Rust daemon using direct CDP, manages Chrome via Chrome DevTools Protocol Daemon starts automatically and persists between commands. diff --git a/docs/src/lib/docs-navigation.ts b/docs/src/lib/docs-navigation.ts index 1b579f3..945de53 100644 --- a/docs/src/lib/docs-navigation.ts +++ b/docs/src/lib/docs-navigation.ts @@ -38,7 +38,7 @@ export const navigation: NavSection[] = [ { name: "iOS Simulator", href: "/ios" }, { name: "Security", href: "/security" }, { name: "Next.js + Vercel", href: "/next" }, - { name: "Native Mode (Experimental)", href: "/native-mode" }, + { name: "Native Mode", href: "/native-mode" }, ], }, { diff --git a/docs/src/lib/page-titles.ts b/docs/src/lib/page-titles.ts index 62444ae..155ead6 100644 --- a/docs/src/lib/page-titles.ts +++ b/docs/src/lib/page-titles.ts @@ -17,7 +17,7 @@ export const PAGE_TITLES: Record = { "engines/chrome": "Chrome", "engines/lightpanda": "Lightpanda", next: "Next.js + Vercel", - "native-mode": "Native Mode (Experimental)", + "native-mode": "Native Mode", changelog: "Changelog", }; diff --git a/package.json b/package.json index 2cfbdee..62174b4 100644 --- a/package.json +++ b/package.json @@ -3,18 +3,7 @@ "version": "0.19.0", "description": "Headless browser automation CLI for AI agents", "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "./package.json": "./package.json" - }, "files": [ - "dist", "bin", "scripts", "skills" @@ -23,39 +12,26 @@ "agent-browser": "./bin/agent-browser.js" }, "scripts": { - "prepare": "husky", "version:sync": "node scripts/sync-version.js", "version": "npm run version:sync && git add cli/Cargo.toml", - "build": "tsc", "build:native": "npm run version:sync && cargo build --release --manifest-path cli/Cargo.toml && node scripts/copy-native.js", "build:linux": "npm run version:sync && docker compose -f docker/docker-compose.yml run --rm build-linux", "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": "npm run version:sync && docker compose -f docker/docker-compose.yml run --rm build-windows", "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 .", - "release": "npm run version:sync && npm run build && npm run build:all-platforms && npm publish", - "start": "node dist/daemon.js", - "dev": "tsx src/daemon.ts", - "typecheck": "tsc --noEmit", - "format": "prettier --write 'src/**/*.ts'", - "format:check": "prettier --check 'src/**/*.ts'", - "test": "vitest run", - "test:watch": "vitest", - "test:e2e:dogfood": "vitest run test/e2e/dogfood.eval.ts", - "bench": "pnpm build && tsx test/benchmarks/run.ts", - "bench:node": "pnpm build && tsx test/benchmarks/run.ts --node-only", - "bench:native": "pnpm build && tsx test/benchmarks/run.ts --native-only", - "bench:engine": "pnpm build && tsx test/benchmarks/run.ts --engine", + "release": "npm run version:sync && npm run build:all-platforms && npm publish", "postinstall": "node scripts/postinstall.js", "changeset": "changeset", "ci:version": "changeset version && pnpm run version:sync && pnpm install --no-frozen-lockfile", - "ci:publish": "pnpm run version:sync && pnpm run build && changeset publish" + "ci:publish": "pnpm run version:sync && changeset publish" }, "keywords": [ "browser", "automation", "headless", - "playwright", + "chrome", + "cdp", "cli", "agent" ], @@ -68,27 +44,7 @@ "url": "https://github.com/vercel-labs/agent-browser/issues" }, "homepage": "https://github.com/vercel-labs/agent-browser#readme", - "dependencies": { - "node-simctl": "^7.4.0", - "playwright-core": "^1.57.0", - "webdriverio": "^9.15.0", - "ws": "^8.19.0", - "zod": "^3.22.4" - }, "devDependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.52", - "@changesets/cli": "^2.29.8", - "@types/node": "^20.10.0", - "@types/ws": "^8.18.1", - "husky": "^9.1.7", - "lint-staged": "^15.2.11", - "playwright": "^1.57.0", - "prettier": "^3.7.4", - "tsx": "^4.6.0", - "typescript": "^5.3.0", - "vitest": "^4.0.16" - }, - "lint-staged": { - "src/**/*.ts": "prettier --write" + "@changesets/cli": "^2.29.8" } } diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 8cf2a88..d37e7d0 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -80,7 +80,7 @@ async function main() { // On global installs, fix npm's bin entry to use native binary directly await fixGlobalInstallBin(); - showPlaywrightReminder(); + showInstallReminder(); return; } @@ -102,8 +102,7 @@ async function main() { console.log(`✓ Downloaded native binary: ${binaryName}`); } catch (err) { - console.log(`⚠ Could not download native binary: ${err.message}`); - console.log(` The CLI will use Node.js fallback (slightly slower startup)`); + console.log(`Could not download native binary: ${err.message}`); console.log(''); console.log('To build the native binary locally:'); console.log(' 1. Install Rust: https://rustup.rs'); @@ -114,21 +113,19 @@ async function main() { // This avoids the /bin/sh error on Windows and provides zero-overhead execution await fixGlobalInstallBin(); - showPlaywrightReminder(); + showInstallReminder(); } -function showPlaywrightReminder() { +function showInstallReminder() { + console.log(''); + console.log(' To download Chrome, run:'); + console.log(''); + console.log(' agent-browser install'); + console.log(''); + console.log(' On Linux, include system dependencies with:'); + console.log(''); + console.log(' agent-browser install --with-deps'); console.log(''); - console.log('╔═══════════════════════════════════════════════════════════════════════════╗'); - console.log('║ To download browser binaries, run: ║'); - console.log('║ ║'); - console.log('║ npx playwright install chromium ║'); - console.log('║ ║'); - console.log('║ On Linux, include system dependencies with: ║'); - console.log('║ ║'); - console.log('║ npx playwright install --with-deps chromium ║'); - console.log('║ ║'); - console.log('╚═══════════════════════════════════════════════════════════════════════════╝'); } /** diff --git a/skills/agent-browser/SKILL.md b/skills/agent-browser/SKILL.md index 11c84fc..b0153a2 100644 --- a/skills/agent-browser/SKILL.md +++ b/skills/agent-browser/SKILL.md @@ -6,6 +6,8 @@ allowed-tools: Bash(npx agent-browser:*), Bash(agent-browser:*) # Browser Automation with agent-browser +The CLI uses Chrome/Chromium via CDP directly. Run `agent-browser install` to download Chrome. + ## Core Workflow Every browser automation follows this pattern: @@ -441,7 +443,7 @@ agent-browser diff url https://staging.example.com https://prod.example.com --sc ## Timeouts and Slow Pages -The default Playwright timeout is 25 seconds for local browsers. This can be overridden with the `AGENT_BROWSER_DEFAULT_TIMEOUT` environment variable (value in milliseconds). For slow websites or large pages, use explicit waits instead of relying on the default timeout: +The default timeout is 25 seconds. This can be overridden with the `AGENT_BROWSER_DEFAULT_TIMEOUT` environment variable (value in milliseconds). For slow websites or large pages, use explicit waits instead of relying on the default timeout: ```bash # Wait for network activity to settle (best for slow pages) @@ -485,6 +487,12 @@ agent-browser --session agent1 close # Close specific session If a previous session was not closed properly, the daemon may still be running. Use `agent-browser close` to clean it up before starting new work. +To auto-shutdown the daemon after a period of inactivity (useful for ephemeral/CI environments): + +```bash +AGENT_BROWSER_IDLE_TIMEOUT_MS=60000 agent-browser open example.com +``` + ## Ref Lifecycle (Important) Refs (`@e1`, `@e2`, etc.) are invalidated when the page changes. Always re-snapshot after: @@ -503,8 +511,6 @@ agent-browser click @e1 # Use new refs Use `--annotate` to take a screenshot with numbered labels overlaid on interactive elements. Each label `[N]` maps to ref `@eN`. This also caches refs, so you can interact with elements immediately without a separate snapshot. -In native mode, this currently works on the CDP-backed browser path (Chromium/Lightpanda). The Safari/WebDriver backend does not yet support `--annotate`. - ```bash agent-browser screenshot --annotate # Output includes the image path and a legend: @@ -589,21 +595,6 @@ Priority (lowest to highest): `~/.agent-browser/config.json` < `./agent-browser. | [references/profiling.md](references/profiling.md) | Chrome DevTools profiling for performance analysis | | [references/proxy-support.md](references/proxy-support.md) | Proxy configuration, geo-testing, rotating proxies | -## Experimental: Native Mode - -agent-browser has an experimental native Rust daemon that communicates with Chrome directly via CDP, bypassing Node.js and Playwright entirely. It is opt-in and not recommended for production use yet. - -```bash -# Enable via flag -agent-browser --native open example.com - -# Enable via environment variable (avoids passing --native every time) -export AGENT_BROWSER_NATIVE=1 -agent-browser open example.com -``` - -The native daemon supports Chromium and Safari (via WebDriver). Firefox and WebKit are not yet supported. All core commands (navigate, snapshot, click, fill, screenshot, cookies, storage, tabs, eval, etc.) work identically in native mode. Use `agent-browser close` before switching between native and default mode within the same session. - ## Browser Engine Selection Use `--engine` to choose a local browser engine. The default is `chrome`. diff --git a/skills/electron/SKILL.md b/skills/electron/SKILL.md index 4931da6..2c0df99 100644 --- a/skills/electron/SKILL.md +++ b/skills/electron/SKILL.md @@ -104,11 +104,11 @@ agent-browser tab --url "*settings*" ## Webview Support -Electron `` elements are automatically discovered and can be controlled like regular pages. When using `--native` mode, webviews appear as separate targets in the tab list with `type: "webview"`: +Electron `` elements are automatically discovered and can be controlled like regular pages. Webviews appear as separate targets in the tab list with `type: "webview"`: ```bash -# Connect in native mode -agent-browser --native connect 9222 +# Connect to running Electron app +agent-browser connect 9222 # List targets -- webviews appear alongside pages agent-browser tab @@ -125,7 +125,7 @@ agent-browser click @e3 agent-browser screenshot webview.png ``` -**Note:** Webview support requires `--native` mode (raw CDP). The Playwright-based mode does not support webview targets. +**Note:** Webview support works via raw CDP connection. ## Common Patterns @@ -188,7 +188,7 @@ agent-browser --session vscode snapshot -i ## Color Scheme -Playwright overrides the color scheme to `light` by default when connecting via CDP. To preserve dark mode: +The default color scheme when connecting via CDP may be `light`. To preserve dark mode: ```bash agent-browser connect 9222 diff --git a/src/action-policy.test.ts b/src/action-policy.test.ts deleted file mode 100644 index edc68fe..0000000 --- a/src/action-policy.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { - getActionCategory, - checkPolicy, - loadPolicyFile, - describeAction, - KNOWN_CATEGORIES, - type ActionPolicy, -} from './action-policy.js'; - -describe('action-policy', () => { - describe('getActionCategory', () => { - it('should return correct category for known actions', () => { - expect(getActionCategory('navigate')).toBe('navigate'); - expect(getActionCategory('click')).toBe('click'); - expect(getActionCategory('fill')).toBe('fill'); - expect(getActionCategory('evaluate')).toBe('eval'); - expect(getActionCategory('download')).toBe('download'); - expect(getActionCategory('upload')).toBe('upload'); - expect(getActionCategory('snapshot')).toBe('snapshot'); - expect(getActionCategory('scroll')).toBe('scroll'); - expect(getActionCategory('wait')).toBe('wait'); - expect(getActionCategory('gettext')).toBe('get'); - expect(getActionCategory('route')).toBe('network'); - expect(getActionCategory('state_save')).toBe('state'); - expect(getActionCategory('hover')).toBe('interact'); - }); - - it('should return _internal for internal actions', () => { - expect(getActionCategory('launch')).toBe('_internal'); - expect(getActionCategory('close')).toBe('_internal'); - expect(getActionCategory('session')).toBe('_internal'); - expect(getActionCategory('auth_save')).toBe('_internal'); - expect(getActionCategory('confirm')).toBe('_internal'); - }); - - it('should return eval for security-sensitive actions', () => { - expect(getActionCategory('setcontent')).toBe('eval'); - expect(getActionCategory('expose')).toBe('eval'); - expect(getActionCategory('addstyle')).toBe('eval'); - }); - - it('should return unknown for unrecognized actions', () => { - expect(getActionCategory('nonexistent')).toBe('unknown'); - expect(getActionCategory('')).toBe('unknown'); - }); - - it('should return get for semantic locator actions', () => { - expect(getActionCategory('getbyrole')).toBe('get'); - expect(getActionCategory('getbytext')).toBe('get'); - expect(getActionCategory('getbylabel')).toBe('get'); - }); - }); - - describe('checkPolicy', () => { - it('should always allow internal actions regardless of policy', () => { - const denyAll: ActionPolicy = { default: 'deny' }; - expect(checkPolicy('launch', denyAll, new Set())).toBe('allow'); - expect(checkPolicy('close', denyAll, new Set())).toBe('allow'); - expect(checkPolicy('session', denyAll, new Set())).toBe('allow'); - }); - - it('should allow all when no policy and no confirm categories', () => { - expect(checkPolicy('navigate', null, new Set())).toBe('allow'); - expect(checkPolicy('click', null, new Set())).toBe('allow'); - expect(checkPolicy('evaluate', null, new Set())).toBe('allow'); - }); - - it('should deny actions in explicit deny list', () => { - const policy: ActionPolicy = { default: 'allow', deny: ['eval', 'download'] }; - expect(checkPolicy('evaluate', policy, new Set())).toBe('deny'); - expect(checkPolicy('download', policy, new Set())).toBe('deny'); - expect(checkPolicy('click', policy, new Set())).toBe('allow'); - }); - - it('should allow actions in explicit allow list with deny default', () => { - const policy: ActionPolicy = { default: 'deny', allow: ['navigate', 'snapshot'] }; - expect(checkPolicy('navigate', policy, new Set())).toBe('allow'); - expect(checkPolicy('snapshot', policy, new Set())).toBe('allow'); - expect(checkPolicy('click', policy, new Set())).toBe('deny'); - }); - - it('should return confirm for actions in confirm categories', () => { - expect(checkPolicy('evaluate', null, new Set(['eval']))).toBe('confirm'); - expect(checkPolicy('download', null, new Set(['download']))).toBe('confirm'); - }); - - it('should deny over confirm when action is in deny list', () => { - const policy: ActionPolicy = { default: 'allow', deny: ['eval'] }; - expect(checkPolicy('evaluate', policy, new Set(['eval']))).toBe('deny'); - }); - - it('should use default policy for unknown categories', () => { - const denyPolicy: ActionPolicy = { default: 'deny' }; - const allowPolicy: ActionPolicy = { default: 'allow' }; - expect(checkPolicy('nonexistent', denyPolicy, new Set())).toBe('deny'); - expect(checkPolicy('nonexistent', allowPolicy, new Set())).toBe('allow'); - }); - }); - - describe('loadPolicyFile', () => { - let tempDir: string; - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'action-policy-test-')); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - it('should load a valid allow-default policy', () => { - const policyPath = path.join(tempDir, 'policy.json'); - fs.writeFileSync(policyPath, JSON.stringify({ default: 'allow', deny: ['eval'] })); - const policy = loadPolicyFile(policyPath); - expect(policy.default).toBe('allow'); - expect(policy.deny).toEqual(['eval']); - }); - - it('should load a valid deny-default policy', () => { - const policyPath = path.join(tempDir, 'policy.json'); - fs.writeFileSync( - policyPath, - JSON.stringify({ default: 'deny', allow: ['navigate', 'snapshot'] }) - ); - const policy = loadPolicyFile(policyPath); - expect(policy.default).toBe('deny'); - expect(policy.allow).toEqual(['navigate', 'snapshot']); - }); - - it('should throw on invalid default value', () => { - const policyPath = path.join(tempDir, 'policy.json'); - fs.writeFileSync(policyPath, JSON.stringify({ default: 'maybe' })); - expect(() => loadPolicyFile(policyPath)).toThrow('must be "allow" or "deny"'); - }); - - it('should throw on missing file', () => { - expect(() => loadPolicyFile(path.join(tempDir, 'missing.json'))).toThrow(); - }); - - it('should throw on invalid JSON', () => { - const policyPath = path.join(tempDir, 'policy.json'); - fs.writeFileSync(policyPath, 'not json'); - expect(() => loadPolicyFile(policyPath)).toThrow(); - }); - - it('should warn on unrecognized category names', () => { - const policyPath = path.join(tempDir, 'policy.json'); - fs.writeFileSync( - policyPath, - JSON.stringify({ default: 'allow', deny: ['eval', 'typo_category'] }) - ); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const policy = loadPolicyFile(policyPath); - expect(policy.default).toBe('allow'); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('unrecognized action category "typo_category"') - ); - warnSpy.mockRestore(); - }); - - it('should not warn on valid category names', () => { - const policyPath = path.join(tempDir, 'policy.json'); - fs.writeFileSync( - policyPath, - JSON.stringify({ default: 'deny', allow: ['navigate', 'snapshot', 'get'] }) - ); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - loadPolicyFile(policyPath); - expect(warnSpy).not.toHaveBeenCalled(); - warnSpy.mockRestore(); - }); - }); - - describe('describeAction', () => { - it('should describe navigate actions', () => { - expect(describeAction('navigate', { url: 'https://example.com' })).toBe( - 'Navigate to https://example.com' - ); - }); - - it('should describe eval actions with truncation', () => { - const longScript = 'a'.repeat(200); - const desc = describeAction('evaluate', { script: longScript }); - expect(desc).toContain('Evaluate JavaScript:'); - expect(desc.length).toBeLessThan(200); - }); - - it('should describe click actions', () => { - expect(describeAction('click', { selector: '#btn' })).toBe('Click #btn'); - }); - - it('should describe dblclick actions', () => { - expect(describeAction('dblclick', { selector: '#btn' })).toBe('Double-click #btn'); - }); - - it('should describe tap actions', () => { - expect(describeAction('tap', { selector: '#btn' })).toBe('Tap #btn'); - }); - - it('should describe fill actions', () => { - expect(describeAction('fill', { selector: '#input' })).toBe('Fill #input'); - }); - - it('should use fallback for unknown actions', () => { - const desc = describeAction('scroll', {}); - expect(desc).toContain('scroll'); - }); - }); -}); diff --git a/src/action-policy.ts b/src/action-policy.ts deleted file mode 100644 index b9847d2..0000000 --- a/src/action-policy.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { readFileSync, statSync } from 'node:fs'; -import { resolve } from 'node:path'; - -export interface ActionPolicy { - default: 'allow' | 'deny'; - allow?: string[]; - deny?: string[]; -} - -export type PolicyDecision = 'allow' | 'deny' | 'confirm'; - -const ACTION_CATEGORIES: Record = { - navigate: 'navigate', - back: 'navigate', - forward: 'navigate', - reload: 'navigate', - tab_new: 'navigate', - - click: 'click', - dblclick: 'click', - tap: 'click', - - fill: 'fill', - type: 'fill', - // The `keyboard` action is a compound command that dispatches to sub-actions - // (type, inserttext, press, down, up). Its primary use is text input, so it - // maps to 'fill'. The interact-like sub-actions (press, down, up) are less - // common and don't have separate top-level action names in the protocol. - keyboard: 'fill', - inserttext: 'fill', - select: 'fill', - multiselect: 'fill', - check: 'fill', - uncheck: 'fill', - clear: 'fill', - selectall: 'fill', - setvalue: 'fill', - - download: 'download', - waitfordownload: 'download', - - upload: 'upload', - - evaluate: 'eval', - evalhandle: 'eval', - addscript: 'eval', - addinitscript: 'eval', - - snapshot: 'snapshot', - screenshot: 'snapshot', - pdf: 'snapshot', - diff_snapshot: 'snapshot', - diff_screenshot: 'snapshot', - diff_url: 'snapshot', - - scroll: 'scroll', - scrollintoview: 'scroll', - - wait: 'wait', - waitforurl: 'wait', - waitforloadstate: 'wait', - waitforfunction: 'wait', - - gettext: 'get', - content: 'get', - innerhtml: 'get', - innertext: 'get', - inputvalue: 'get', - url: 'get', - title: 'get', - getattribute: 'get', - count: 'get', - boundingbox: 'get', - styles: 'get', - isvisible: 'get', - isenabled: 'get', - ischecked: 'get', - responsebody: 'get', - - route: 'network', - unroute: 'network', - requests: 'network', - - state_save: 'state', - state_load: 'state', - cookies_set: 'state', - storage_set: 'state', - credentials: 'state', - - hover: 'interact', - focus: 'interact', - drag: 'interact', - press: 'interact', - keydown: 'interact', - keyup: 'interact', - mousemove: 'interact', - mousedown: 'interact', - mouseup: 'interact', - wheel: 'interact', - dispatch: 'interact', - - // These are always allowed (internal/meta operations) - launch: '_internal', - close: '_internal', - tab_list: '_internal', - tab_switch: '_internal', - tab_close: '_internal', - window_new: '_internal', - frame: '_internal', - mainframe: '_internal', - dialog: '_internal', - session: '_internal', - console: '_internal', - errors: '_internal', - cookies_get: '_internal', - cookies_clear: '_internal', - storage_get: '_internal', - storage_clear: '_internal', - state_list: '_internal', - state_show: '_internal', - state_clear: '_internal', - state_clean: '_internal', - state_rename: '_internal', - highlight: '_internal', - bringtofront: '_internal', - trace_start: '_internal', - trace_stop: '_internal', - har_start: '_internal', - har_stop: '_internal', - video_start: '_internal', - video_stop: '_internal', - recording_start: '_internal', - recording_stop: '_internal', - recording_restart: '_internal', - profiler_start: '_internal', - profiler_stop: '_internal', - clipboard: '_internal', - viewport: '_internal', - useragent: '_internal', - device: '_internal', - geolocation: '_internal', - permissions: '_internal', - emulatemedia: '_internal', - offline: '_internal', - headers: '_internal', - addstyle: 'eval', - expose: 'eval', - timezone: '_internal', - locale: '_internal', - pause: '_internal', - setcontent: 'eval', - screencast_start: '_internal', - screencast_stop: '_internal', - input_mouse: '_internal', - input_keyboard: '_internal', - input_touch: '_internal', - - auth_save: '_internal', - auth_login: '_internal', - auth_list: '_internal', - auth_delete: '_internal', - auth_show: '_internal', - confirm: '_internal', - deny: '_internal', - - // Find/semantic locator actions (read-only element resolution) - getbyrole: 'get', - getbytext: 'get', - getbylabel: 'get', - getbyplaceholder: 'get', - getbyalttext: 'get', - getbytitle: 'get', - getbytestid: 'get', - nth: 'get', -}; - -// User-facing categories used in policy files. '_internal' is excluded because -// internal actions always bypass policy. 'unknown' is intentionally not a value -// in ACTION_CATEGORIES -- it is only the fallback return of getActionCategory() -// for unrecognized actions. If a user puts "unknown" in a policy file, -// loadPolicyFile will warn about it as unrecognized, which is correct. -export const KNOWN_CATEGORIES = new Set( - Object.values(ACTION_CATEGORIES).filter((c) => c !== '_internal') -); - -export function getActionCategory(action: string): string { - return ACTION_CATEGORIES[action] ?? 'unknown'; -} - -export function loadPolicyFile(policyPath: string): ActionPolicy { - const resolved = resolve(policyPath); - const content = readFileSync(resolved, 'utf-8'); - const policy = JSON.parse(content) as ActionPolicy; - - if (policy.default !== 'allow' && policy.default !== 'deny') { - throw new Error( - `Invalid action policy: "default" must be "allow" or "deny", got "${policy.default}"` - ); - } - - for (const list of [policy.allow, policy.deny]) { - if (!list) continue; - for (const category of list) { - if (!KNOWN_CATEGORIES.has(category)) { - console.warn( - `[agent-browser] Warning: unrecognized action category "${category}" in policy file. ` + - `Known categories: ${[...KNOWN_CATEGORIES].sort().join(', ')}` - ); - } - } - } - - return policy; -} - -let cachedPolicyPath: string | null = null; -let cachedPolicyMtimeMs = 0; -let cachedPolicy: ActionPolicy | null = null; -const RELOAD_CHECK_INTERVAL_MS = 5_000; -let lastCheckMs = 0; - -export function initPolicyReloader(policyPath: string, policy: ActionPolicy): void { - cachedPolicyPath = resolve(policyPath); - cachedPolicyMtimeMs = statSync(cachedPolicyPath).mtimeMs; - cachedPolicy = policy; -} - -export function reloadPolicyIfChanged(): ActionPolicy | null { - if (!cachedPolicyPath) return cachedPolicy; - - const now = Date.now(); - if (now - lastCheckMs < RELOAD_CHECK_INTERVAL_MS) return cachedPolicy; - lastCheckMs = now; - - try { - const currentMtime = statSync(cachedPolicyPath).mtimeMs; - if (currentMtime !== cachedPolicyMtimeMs) { - cachedPolicy = loadPolicyFile(cachedPolicyPath); - cachedPolicyMtimeMs = currentMtime; - } - } catch { - // File may have been removed; keep using cached policy - } - - return cachedPolicy; -} - -export function checkPolicy( - action: string, - policy: ActionPolicy | null, - confirmCategories: Set -): PolicyDecision { - const category = getActionCategory(action); - - // Internal actions are always allowed - if (category === '_internal') return 'allow'; - - // Explicit deny takes precedence over confirmation - if (policy?.deny?.includes(category)) return 'deny'; - - // Check if this category requires confirmation - if (confirmCategories.has(category)) return 'confirm'; - - if (!policy) return 'allow'; - - // Explicit allow list - if (policy.allow?.includes(category)) return 'allow'; - - return policy.default; -} - -export function describeAction(action: string, command: Record): string { - const category = getActionCategory(action); - switch (action) { - case 'navigate': - return `Navigate to ${command.url}`; - case 'evaluate': - case 'evalhandle': - return `Evaluate JavaScript: ${String(command.script ?? '').slice(0, 80)}`; - case 'fill': - return `Fill ${command.selector}`; - case 'type': - return `Type into ${command.selector}`; - case 'click': - return `Click ${command.selector}`; - case 'dblclick': - return `Double-click ${command.selector}`; - case 'tap': - return `Tap ${command.selector}`; - case 'download': - return `Download via ${command.selector} to ${command.path}`; - case 'upload': - return `Upload files to ${command.selector}`; - default: - return `${category}: ${action}`; - } -} diff --git a/src/actions.test.ts b/src/actions.test.ts deleted file mode 100644 index 91d7afa..0000000 --- a/src/actions.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { toAIFriendlyError } from './actions.js'; - -describe('toAIFriendlyError', () => { - describe('element blocked by overlay', () => { - it('should detect intercepts pointer events even when Timeout is in message', () => { - // This is the exact error from Playwright when a cookie banner blocks an element - // Bug: Previously this was incorrectly reported as "not found or not visible" - const error = new Error( - 'TimeoutError: locator.click: Timeout 10000ms exceeded.\n' + - 'Call log:\n' + - " - waiting for getByRole('link', { name: 'Anmelden', exact: true }).first()\n" + - ' - locator resolved to Anmelden\n' + - ' - attempting click action\n' + - ' 2 x waiting for element to be visible, enabled and stable\n' + - ' - element is visible, enabled and stable\n' + - ' - scrolling into view if needed\n' + - ' - done scrolling\n' + - ' - ... intercepts pointer events\n' + - ' - retrying click action' - ); - - const result = toAIFriendlyError(error, '@e4'); - - // Must NOT say "not found" - the element WAS found - expect(result.message).not.toContain('not found'); - // Must indicate the element is blocked - expect(result.message).toContain('blocked by another element'); - expect(result.message).toContain('modal or overlay'); - }); - - it('should suggest dismissing cookie banners', () => { - const error = new Error(' - - - - - - - diff --git a/test/benchmarks/pages/dashboard.html b/test/benchmarks/pages/dashboard.html deleted file mode 100644 index a6943fe..0000000 --- a/test/benchmarks/pages/dashboard.html +++ /dev/null @@ -1,248 +0,0 @@ - - - - - -Operations Dashboard - - - - -
-

Operations Dashboard

-
- - - -
-
- -
- -
-
-

Request Volume

-
-
Hourly
-
Daily
-
Weekly
-
-
-
-
- -
-
-

Top Endpoints

- - - -
EndpointRequestsAvg LatencyError Rate
-
-
-

Active Alerts

- - - -
AlertSeverityServiceSince
-
-
- -
-
-

Recent Logs

-
-
All
-
Errors
-
Warnings
-
-
- - - -
TimestampLevelServiceMessageDuration
- -
- -
-

Service Status

- - - -
ServiceStatusUptimeCPUMemoryRequests/minError RateLast Deploy
-
- - - - - diff --git a/test/benchmarks/pages/ecommerce.html b/test/benchmarks/pages/ecommerce.html deleted file mode 100644 index bf7cc4b..0000000 --- a/test/benchmarks/pages/ecommerce.html +++ /dev/null @@ -1,179 +0,0 @@ - - - - - -TechStore - Electronics & Gadgets - - - - -
-Free shipping on orders over $99 -Customer Service: 1-800-TECH | Track Order | Help -
- - - - - -
-

Spring Tech Sale

-

Up to 40% off on selected electronics. Limited time offer.

- -
- -
-
-

Flash Deals - Ends in 04:32:17

Extra 15% off with code SPRING15

- -
- -
Featured ProductsView All
-
-AllUnder $100 -$100 - $500$500+ -Top RatedNew -
- - -
Best SellersView All
-
- -
New ArrivalsView All
-
- -
Customer Reviews
-
-
- - - - - - - diff --git a/test/benchmarks/run.ts b/test/benchmarks/run.ts deleted file mode 100644 index 9bc3b57..0000000 --- a/test/benchmarks/run.ts +++ /dev/null @@ -1,1072 +0,0 @@ -import { spawn, ChildProcess } from "child_process"; -import * as http from "http"; -import * as net from "net"; -import * as os from "os"; -import * as path from "path"; -import * as fs from "fs"; -import { fileURLToPath } from "url"; -import { scenarios, type BenchmarkCommand, type Scenario } from "./scenarios.js"; -import { engineScenarios } from "./engine-scenarios.js"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -// --------------------------------------------------------------------------- -// Static file server for HTTP-served benchmarks -// --------------------------------------------------------------------------- - -const PAGES_DIR = path.join(__dirname, "pages"); - -const MIME_TYPES: Record = { - ".html": "text/html", - ".css": "text/css", - ".js": "application/javascript", - ".json": "application/json", - ".png": "image/png", - ".jpg": "image/jpeg", - ".svg": "image/svg+xml", -}; - -function startFileServer(): Promise<{ server: http.Server; port: number }> { - return new Promise((resolve, reject) => { - const server = http.createServer((req, res) => { - const url = new URL(req.url || "/", `http://localhost`); - let filePath = path.join(PAGES_DIR, url.pathname === "/" ? "article.html" : url.pathname); - - if (!filePath.startsWith(PAGES_DIR)) { - res.writeHead(403); - res.end(); - return; - } - - if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) { - filePath = path.join(filePath, "index.html"); - } - - try { - const content = fs.readFileSync(filePath); - const ext = path.extname(filePath); - res.writeHead(200, { "Content-Type": MIME_TYPES[ext] || "application/octet-stream" }); - res.end(content); - } catch { - res.writeHead(404); - res.end("Not found"); - } - }); - - server.listen(0, "127.0.0.1", () => { - const addr = server.address(); - if (!addr || typeof addr === "string") { - reject(new Error("Failed to get server address")); - return; - } - resolve({ server, port: addr.port }); - }); - - server.on("error", reject); - }); -} - -function stopFileServer(server: http.Server): Promise { - return new Promise((resolve) => { - server.close(() => resolve()); - }); -} - -// --------------------------------------------------------------------------- -// Memory measurement via /proc or ps -// --------------------------------------------------------------------------- - -function getProcessMemoryKB(pid: number): number | null { - if (process.platform === "linux") { - try { - const status = fs.readFileSync(`/proc/${pid}/status`, "utf-8"); - const match = status.match(/VmRSS:\s+(\d+)\s+kB/); - if (match) return parseInt(match[1], 10); - } catch { /* */ } - } - - try { - const { execSync } = require("child_process"); - const output = execSync(`ps -o rss= -p ${pid}`, { encoding: "utf-8", timeout: 2000 }); - const kb = parseInt(output.trim(), 10); - if (!isNaN(kb)) return kb; - } catch { /* */ } - - return null; -} - -function sampleMemory(pids: number[], intervalMs: number): { stop: () => number } { - let peakKB = 0; - const timer = setInterval(() => { - for (const pid of pids) { - const kb = getProcessMemoryKB(pid); - if (kb && kb > peakKB) peakKB = kb; - } - }, intervalMs); - - return { - stop() { - clearInterval(timer); - for (const pid of pids) { - const kb = getProcessMemoryKB(pid); - if (kb && kb > peakKB) peakKB = kb; - } - return peakKB; - }, - }; -} - -function formatMemory(kb: number): string { - if (kb >= 1024 * 1024) return `${(kb / 1024 / 1024).toFixed(1)}GB`; - if (kb >= 1024) return `${(kb / 1024).toFixed(1)}MB`; - return `${kb}KB`; -} - -// --------------------------------------------------------------------------- -// Socket / daemon helpers -// --------------------------------------------------------------------------- - -function getSocketDir(): string { - if (process.env.AGENT_BROWSER_SOCKET_DIR) { - return process.env.AGENT_BROWSER_SOCKET_DIR; - } - if (process.env.XDG_RUNTIME_DIR) { - return path.join(process.env.XDG_RUNTIME_DIR, "agent-browser"); - } - const home = os.homedir(); - if (home) { - return path.join(home, ".agent-browser"); - } - return path.join(os.tmpdir(), "agent-browser"); -} - -function getSocketPath(session: string): string { - return path.join(getSocketDir(), `${session}.sock`); -} - -function getProjectRoot(): string { - return path.resolve(__dirname, "../.."); -} - -function getNativeBinaryPath(): string { - const root = getProjectRoot(); - const p = os.platform(); - const a = os.arch(); - - const osKey = - p === "darwin" ? "darwin" : p === "linux" ? "linux" : p === "win32" ? "win32" : null; - const archKey = - a === "x64" || a === "x86_64" ? "x64" : a === "arm64" || a === "aarch64" ? "arm64" : null; - - if (!osKey || !archKey) { - throw new Error(`Unsupported platform: ${p}-${a}`); - } - - const ext = p === "win32" ? ".exe" : ""; - const binName = `agent-browser-${osKey}-${archKey}${ext}`; - - const candidates = [ - path.join(root, "cli/target/release/agent-browser"), - path.join(root, "cli/target/debug/agent-browser"), - path.join(root, "bin", binName), - ]; - - for (const candidate of candidates) { - if (fs.existsSync(candidate)) return candidate; - } - - throw new Error( - `Native binary not found. Tried:\n${candidates.map((c) => " " + c).join("\n")}\n` + - 'Run "pnpm build:native" to build the native binary.', - ); -} - -function sendCommand(session: string, cmd: BenchmarkCommand): Promise> { - return new Promise((resolve, reject) => { - const socketPath = getSocketPath(session); - const client = net.createConnection({ path: socketPath }, () => { - client.write(JSON.stringify(cmd) + "\n"); - }); - - let data = ""; - client.on("data", (chunk) => { - data += chunk.toString(); - const newlineIdx = data.indexOf("\n"); - if (newlineIdx !== -1) { - const line = data.slice(0, newlineIdx); - client.destroy(); - try { - resolve(JSON.parse(line)); - } catch { - reject(new Error(`Invalid JSON response: ${line}`)); - } - } - }); - - client.on("error", (err) => reject(err)); - client.on("timeout", () => { - client.destroy(); - reject(new Error("Socket timeout")); - }); - client.setTimeout(30_000); - }); -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function waitForSocket(session: string, timeoutMs = 15_000): Promise { - const start = Date.now(); - const socketPath = getSocketPath(session); - while (Date.now() - start < timeoutMs) { - if (fs.existsSync(socketPath)) { - try { - await new Promise((resolve, reject) => { - const c = net.createConnection({ path: socketPath }, () => { - c.destroy(); - resolve(); - }); - c.on("error", reject); - c.setTimeout(1000); - c.on("timeout", () => { - c.destroy(); - reject(new Error("timeout")); - }); - }); - return; - } catch { - // not ready yet - } - } - await sleep(100); - } - throw new Error(`Daemon '${session}' did not become ready within ${timeoutMs}ms`); -} - -interface DaemonHandle { - session: string; - process: ChildProcess; -} - -function spawnNodeDaemon(session: string): DaemonHandle { - const daemonPath = path.join(getProjectRoot(), "dist/daemon.js"); - if (!fs.existsSync(daemonPath)) { - throw new Error(`Node daemon not found at ${daemonPath}. Run "pnpm build" first.`); - } - - const child = spawn("node", [daemonPath], { - env: { - ...process.env, - AGENT_BROWSER_DAEMON: "1", - AGENT_BROWSER_SESSION: session, - }, - stdio: ["ignore", "ignore", "pipe"], - detached: true, - }); - - child.stderr?.on("data", (chunk) => { - const msg = chunk.toString().trim(); - if (msg && process.env.BENCH_DEBUG) { - process.stderr.write(`[node-daemon] ${msg}\n`); - } - }); - - return { session, process: child }; -} - -function spawnNativeDaemon(session: string, engine?: string): DaemonHandle { - const binaryPath = getNativeBinaryPath(); - - const env: Record = { - ...process.env as Record, - AGENT_BROWSER_DAEMON: "1", - AGENT_BROWSER_SESSION: session, - }; - if (engine) { - env.AGENT_BROWSER_ENGINE = engine; - } - - const child = spawn(binaryPath, [], { - env, - stdio: ["ignore", "ignore", "pipe"], - detached: true, - }); - - const label = engine ? `native-${engine}` : "native-daemon"; - child.stderr?.on("data", (chunk) => { - const msg = chunk.toString().trim(); - if (msg && process.env.BENCH_DEBUG) { - process.stderr.write(`[${label}] ${msg}\n`); - } - }); - - return { session, process: child }; -} - -async function closeDaemon(handle: DaemonHandle): Promise { - try { - await sendCommand(handle.session, { id: "close", action: "close" }); - } catch { - // daemon may already be gone - } - await sleep(200); - try { - handle.process.kill("SIGTERM"); - } catch { - // already exited - } -} - -function cleanupSockets(): void { - for (const session of [ - "bench-node", - "bench-native", - "bench-chrome", - "bench-lightpanda", - ]) { - const sockPath = getSocketPath(session); - const pidPath = sockPath.replace(/\.sock$/, ".pid"); - try { - fs.unlinkSync(sockPath); - } catch { - /* */ - } - try { - fs.unlinkSync(pidPath); - } catch { - /* */ - } - } -} - -// --------------------------------------------------------------------------- -// Statistics (microsecond precision) -// --------------------------------------------------------------------------- - -interface Stats { - avgUs: number; - minUs: number; - maxUs: number; - p50Us: number; - p95Us: number; -} - -function computeStats(timingsUs: number[]): Stats { - const sorted = [...timingsUs].sort((a, b) => a - b); - const sum = sorted.reduce((a, b) => a + b, 0); - return { - avgUs: Math.round(sum / sorted.length), - minUs: sorted[0], - maxUs: sorted[sorted.length - 1], - p50Us: sorted[Math.floor(sorted.length * 0.5)], - p95Us: sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))], - }; -} - -function formatDuration(us: number): string { - if (us >= 1_000_000) return `${(us / 1_000_000).toFixed(2)}s`; - if (us >= 1_000) return `${(us / 1_000).toFixed(1)}ms`; - return `${us}us`; -} - -// --------------------------------------------------------------------------- -// Benchmark runner -// --------------------------------------------------------------------------- - -async function runCommands(session: string, commands: BenchmarkCommand[]): Promise { - for (const cmd of commands) { - const resp = await sendCommand(session, cmd); - if (!(resp as { success?: boolean }).success) { - throw new Error( - `Command '${cmd.action}' failed on session '${session}': ${JSON.stringify(resp)}`, - ); - } - } -} - -async function timeCommands(session: string, commands: BenchmarkCommand[]): Promise { - const start = process.hrtime.bigint(); - await runCommands(session, commands); - const elapsedNs = process.hrtime.bigint() - start; - return Number(elapsedNs / 1000n); // microseconds -} - -interface ScenarioResult { - name: string; - nodeStats: Stats | null; - nativeStats: Stats | null; - chromeStats: Stats | null; - lightpandaStats: Stats | null; -} - -async function runScenario( - scenario: Scenario, - sessions: { node?: string; native?: string }, - iterations: number, - warmup: number, -): Promise { - const result: ScenarioResult = { - name: scenario.name, - nodeStats: null, - nativeStats: null, - chromeStats: null, - lightpandaStats: null, - }; - - for (const [label, session] of Object.entries(sessions)) { - if (!session) continue; - - if (scenario.setup) { - await runCommands(session, scenario.setup); - } - - for (let i = 0; i < warmup; i++) { - await timeCommands(session, scenario.commands); - } - - const timings: number[] = []; - for (let i = 0; i < iterations; i++) { - timings.push(await timeCommands(session, scenario.commands)); - } - - if (scenario.teardown) { - await runCommands(session, scenario.teardown); - } - - const stats = computeStats(timings); - if (label === "node") result.nodeStats = stats; - else if (label === "native") result.nativeStats = stats; - else if (label === "chrome") result.chromeStats = stats; - else if (label === "lightpanda") result.lightpandaStats = stats; - } - - return result; -} - -async function runScenarioWithErrorTolerance( - scenario: Scenario, - sessions: Record, - iterations: number, - warmup: number, -): Promise { - const result: ScenarioResult = { - name: scenario.name, - nodeStats: null, - nativeStats: null, - chromeStats: null, - lightpandaStats: null, - }; - - for (const [label, session] of Object.entries(sessions)) { - if (!session) continue; - - try { - if (scenario.setup) { - await runCommands(session, scenario.setup); - } - - for (let i = 0; i < warmup; i++) { - await timeCommands(session, scenario.commands); - } - - const timings: number[] = []; - for (let i = 0; i < iterations; i++) { - timings.push(await timeCommands(session, scenario.commands)); - } - - if (scenario.teardown) { - await runCommands(session, scenario.teardown); - } - - const stats = computeStats(timings); - if (label === "chrome") result.chromeStats = stats; - else if (label === "lightpanda") result.lightpandaStats = stats; - else if (label === "node") result.nodeStats = stats; - else if (label === "native") result.nativeStats = stats; - } catch (err) { - if (process.env.BENCH_DEBUG) { - const msg = err instanceof Error ? err.message : String(err); - process.stderr.write(` [${label}] scenario '${scenario.name}' failed: ${msg}\n`); - } - } - } - - return result; -} - -// --------------------------------------------------------------------------- -// Reporting -// --------------------------------------------------------------------------- - -function pad(s: string, len: number): string { - return s.padEnd(len); -} - -function rpad(s: string, len: number): string { - return s.padStart(len); -} - -function formatSpeedup(baselineUs: number, candidateUs: number): string { - if (candidateUs === 0 && baselineUs === 0) return " --"; - if (candidateUs === 0) return " >>>"; - const ratio = baselineUs / candidateUs; - return `${ratio.toFixed(1)}x`; -} - -type BenchmarkMode = "daemon" | "engine"; - -function printResults( - results: ScenarioResult[], - iterations: number, - warmup: number, - mode: BenchmarkMode = "daemon", -): void { - console.log(""); - - if (mode === "engine") { - printEngineResults(results, iterations, warmup); - return; - } - - const bothPaths = results[0].nodeStats !== null && results[0].nativeStats !== null; - - const header = bothPaths - ? `agent-browser benchmark: node vs native (${iterations} iterations, ${warmup} warmup)` - : `agent-browser benchmark (${iterations} iterations, ${warmup} warmup)`; - console.log(header); - console.log("=".repeat(header.length)); - console.log(""); - - if (bothPaths) { - const nameW = 20; - const colW = 14; - - console.log( - pad("Scenario", nameW) + - rpad("Node (avg)", colW) + - rpad("Native (avg)", colW) + - rpad("Speedup", 10), - ); - console.log("-".repeat(nameW + colW * 2 + 10)); - - let totalNodeUs = 0; - let totalNativeUs = 0; - let count = 0; - - for (const r of results) { - if (!r.nodeStats || !r.nativeStats) continue; - totalNodeUs += r.nodeStats.avgUs; - totalNativeUs += r.nativeStats.avgUs; - count++; - - console.log( - pad(r.name, nameW) + - rpad(formatDuration(r.nodeStats.avgUs), colW) + - rpad(formatDuration(r.nativeStats.avgUs), colW) + - rpad(formatSpeedup(r.nodeStats.avgUs, r.nativeStats.avgUs), 10), - ); - } - - console.log("-".repeat(nameW + colW * 2 + 10)); - - if (count > 0 && totalNativeUs > 0) { - const overallSpeedup = totalNodeUs / totalNativeUs; - const winner = overallSpeedup >= 1.0 ? "native is faster" : "node is faster"; - console.log(`Overall average speedup: ${overallSpeedup.toFixed(1)}x (${winner})`); - console.log(""); - - const allNativeFaster = results.every( - (r) => !r.nodeStats || !r.nativeStats || r.nodeStats.avgUs >= r.nativeStats.avgUs, - ); - if (allNativeFaster) { - console.log("Result: PASS -- native is faster across all scenarios"); - } else { - const slower = results - .filter((r) => r.nodeStats && r.nativeStats && r.nodeStats.avgUs < r.nativeStats.avgUs) - .map((r) => r.name); - console.log(`Result: WARN -- native is slower in: ${slower.join(", ")}`); - } - } - } else { - const nameW = 20; - const label = results[0].nodeStats ? "Node" : "Native"; - console.log( - pad("Scenario", nameW) + - rpad(`${label} avg`, 10) + - rpad("min", 10) + - rpad("max", 10) + - rpad("p50", 10) + - rpad("p95", 10), - ); - console.log("-".repeat(nameW + 50)); - for (const r of results) { - const s = r.nodeStats ?? r.nativeStats; - if (!s) continue; - console.log( - pad(r.name, nameW) + - rpad(formatDuration(s.avgUs), 10) + - rpad(formatDuration(s.minUs), 10) + - rpad(formatDuration(s.maxUs), 10) + - rpad(formatDuration(s.p50Us), 10) + - rpad(formatDuration(s.p95Us), 10), - ); - } - } - - console.log(""); -} - -function printEngineResults( - results: ScenarioResult[], - iterations: number, - warmup: number, -): void { - const header = `agent-browser benchmark: chrome vs lightpanda (${iterations} iterations, ${warmup} warmup)`; - console.log(header); - console.log("=".repeat(header.length)); - console.log(""); - - const nameW = 22; - const colW = 18; - - console.log( - pad("Scenario", nameW) + - rpad("Chrome (avg)", colW) + - rpad("Lightpanda (avg)", colW) + - rpad("Speedup", 10), - ); - console.log("-".repeat(nameW + colW * 2 + 10)); - - let totalChromeUs = 0; - let totalLightpandaUs = 0; - let comparableCount = 0; - - for (const r of results) { - const chromeAvg = r.chromeStats ? formatDuration(r.chromeStats.avgUs) : "N/A"; - const lpAvg = r.lightpandaStats ? formatDuration(r.lightpandaStats.avgUs) : "N/A"; - let speedup = " --"; - - if (r.chromeStats && r.lightpandaStats) { - totalChromeUs += r.chromeStats.avgUs; - totalLightpandaUs += r.lightpandaStats.avgUs; - comparableCount++; - speedup = formatSpeedup(r.chromeStats.avgUs, r.lightpandaStats.avgUs); - } - - console.log( - pad(r.name, nameW) + - rpad(chromeAvg, colW) + - rpad(lpAvg, colW) + - rpad(speedup, 10), - ); - } - - console.log("-".repeat(nameW + colW * 2 + 10)); - - if (comparableCount > 0 && totalLightpandaUs > 0) { - const ratio = totalChromeUs / totalLightpandaUs; - const winner = ratio >= 1.0 - ? `lightpanda ${ratio.toFixed(1)}x faster` - : `chrome ${(1 / ratio).toFixed(1)}x faster`; - console.log(`Overall: ${winner}`); - } - - console.log(""); -} - -function writeJsonResults( - results: ScenarioResult[], - outputPath: string, - mode: BenchmarkMode = "daemon", -): void { - const toMs = (us: number) => +(us / 1000).toFixed(2); - const statsToJson = (s: Stats) => ({ - avg_ms: toMs(s.avgUs), - min_ms: toMs(s.minUs), - max_ms: toMs(s.maxUs), - p50_ms: toMs(s.p50Us), - p95_ms: toMs(s.p95Us), - }); - - const json = results.map((r) => { - if (mode === "engine") { - return { - scenario: r.name, - chrome: r.chromeStats ? statsToJson(r.chromeStats) : null, - lightpanda: r.lightpandaStats ? statsToJson(r.lightpandaStats) : null, - speedup: - r.chromeStats && r.lightpandaStats && r.lightpandaStats.avgUs > 0 - ? +(r.chromeStats.avgUs / r.lightpandaStats.avgUs).toFixed(2) - : null, - }; - } - return { - scenario: r.name, - node: r.nodeStats ? statsToJson(r.nodeStats) : null, - native: r.nativeStats ? statsToJson(r.nativeStats) : null, - speedup: - r.nodeStats && r.nativeStats && r.nativeStats.avgUs > 0 - ? +(r.nodeStats.avgUs / r.nativeStats.avgUs).toFixed(2) - : null, - }; - }); - fs.writeFileSync(outputPath, JSON.stringify(json, null, 2) + "\n"); - console.log(`JSON results written to ${outputPath}`); -} - -// --------------------------------------------------------------------------- -// CLI argument parsing -// --------------------------------------------------------------------------- - -interface CliArgs { - iterations: number; - warmup: number; - nodeOnly: boolean; - nativeOnly: boolean; - engineMode: boolean; - json: boolean; -} - -function parseArgs(): CliArgs { - const args = process.argv.slice(2); - const result: CliArgs = { - iterations: 10, - warmup: 3, - nodeOnly: false, - nativeOnly: false, - engineMode: false, - json: false, - }; - - for (let i = 0; i < args.length; i++) { - switch (args[i]) { - case "--iterations": - result.iterations = parseInt(args[++i], 10); - break; - case "--warmup": - result.warmup = parseInt(args[++i], 10); - break; - case "--node-only": - result.nodeOnly = true; - break; - case "--native-only": - result.nativeOnly = true; - break; - case "--engine": - result.engineMode = true; - break; - case "--json": - result.json = true; - break; - default: - console.error(`Unknown flag: ${args[i]}`); - process.exit(1); - } - } - - return result; -} - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- - -async function runDaemonBenchmark(args: CliArgs): Promise { - const runNode = !args.nativeOnly; - const runNative = !args.nodeOnly; - - console.log("Starting benchmark daemons..."); - - let nodeHandle: DaemonHandle | undefined; - let nativeHandle: DaemonHandle | undefined; - - try { - if (runNode) { - nodeHandle = spawnNodeDaemon("bench-node"); - await waitForSocket("bench-node"); - console.log(" Node daemon ready"); - } - - if (runNative) { - nativeHandle = spawnNativeDaemon("bench-native"); - await waitForSocket("bench-native"); - console.log(" Native daemon ready"); - } - - const sessions: { node?: string; native?: string } = {}; - if (runNode) sessions.node = "bench-node"; - if (runNative) sessions.native = "bench-native"; - - for (const session of Object.values(sessions)) { - const resp = await sendCommand(session, { - id: "launch", - action: "launch", - headless: true, - }); - if (!(resp as { success?: boolean }).success) { - throw new Error(`Failed to launch browser on ${session}: ${JSON.stringify(resp)}`); - } - } - console.log(" Browsers launched"); - console.log(""); - - const results: ScenarioResult[] = []; - for (const scenario of scenarios) { - process.stdout.write(` Running: ${scenario.name}...`); - const result = await runScenario(scenario, sessions, args.iterations, args.warmup); - results.push(result); - - if (result.nodeStats && result.nativeStats) { - const speedup = formatSpeedup(result.nodeStats.avgUs, result.nativeStats.avgUs); - process.stdout.write( - ` node=${formatDuration(result.nodeStats.avgUs)} native=${formatDuration(result.nativeStats.avgUs)} (${speedup})\n`, - ); - } else { - const s = result.nodeStats ?? result.nativeStats; - process.stdout.write(` avg=${s ? formatDuration(s.avgUs) : "??"}\n`); - } - } - - printResults(results, args.iterations, args.warmup, "daemon"); - - if (args.json) { - writeJsonResults( - results, - path.join(getProjectRoot(), "test/benchmarks/results.json"), - "daemon", - ); - } - - for (const session of Object.values(sessions)) { - await sendCommand(session, { id: "close", action: "close" }).catch(() => {}); - } - - await sleep(300); - - if (runNode && runNative) { - let totalNodeUs = 0; - let totalNativeUs = 0; - for (const r of results) { - if (r.nodeStats && r.nativeStats) { - totalNodeUs += r.nodeStats.avgUs; - totalNativeUs += r.nativeStats.avgUs; - } - } - if (totalNativeUs > 0 && totalNodeUs / totalNativeUs < 1.0) { - process.exit(1); - } - } - } finally { - if (nodeHandle) await closeDaemon(nodeHandle); - if (nativeHandle) await closeDaemon(nativeHandle); - } -} - -function buildHttpScenarios(baseUrl: string): Scenario[] { - const pages = ["article.html", "dashboard.html", "ecommerce.html"]; - const httpScenarios: Scenario[] = []; - - for (const page of pages) { - const label = page.replace(".html", ""); - httpScenarios.push({ - name: `http-${label}`, - description: `Navigate to ${label} page over HTTP (full fetch + parse + layout)`, - commands: [ - { id: "nav", action: "navigate", url: `${baseUrl}/${page}`, waitUntil: "load" }, - ], - }); - } - - httpScenarios.push({ - name: "http-nav+snap", - description: "Navigate to article over HTTP then snapshot", - commands: [ - { id: "nav", action: "navigate", url: `${baseUrl}/article.html`, waitUntil: "load" }, - { id: "snap", action: "snapshot" }, - ], - }); - - // Multi-page throughput: cycle through all pages N times - const multiPageCmds: BenchmarkCommand[] = []; - for (let round = 0; round < 5; round++) { - for (const page of pages) { - multiPageCmds.push({ - id: `nav-${round}-${page}`, - action: "navigate", - url: `${baseUrl}/${page}`, - waitUntil: "load", - }); - } - } - httpScenarios.push({ - name: "http-multi-15pg", - description: "Navigate 15 pages in sequence (5 rounds x 3 pages)", - commands: multiPageCmds, - }); - - // Bulk navigation: 50 page loads of the article (closest to Lightpanda's 100-page benchmark) - const bulkCmds: BenchmarkCommand[] = []; - for (let i = 0; i < 50; i++) { - bulkCmds.push({ - id: `bulk-${i}`, - action: "navigate", - url: `${baseUrl}/${pages[i % pages.length]}`, - waitUntil: "load", - }); - } - httpScenarios.push({ - name: "http-bulk-50pg", - description: "Navigate 50 pages sequentially (throughput test)", - commands: bulkCmds, - }); - - return httpScenarios; -} - -async function runEngineBenchmark(args: CliArgs): Promise { - console.log("Starting local file server..."); - const { server, port } = await startFileServer(); - const baseUrl = `http://127.0.0.1:${port}`; - console.log(` Serving pages at ${baseUrl}`); - - console.log("Starting engine benchmark daemons..."); - - let chromeHandle: DaemonHandle | undefined; - let lightpandaHandle: DaemonHandle | undefined; - - try { - chromeHandle = spawnNativeDaemon("bench-chrome", "chrome"); - await waitForSocket("bench-chrome"); - console.log(" Chrome daemon ready"); - - lightpandaHandle = spawnNativeDaemon("bench-lightpanda", "lightpanda"); - await waitForSocket("bench-lightpanda"); - console.log(" Lightpanda daemon ready"); - - const sessions: Record = { - chrome: "bench-chrome", - lightpanda: "bench-lightpanda", - }; - - for (const [label, session] of Object.entries(sessions)) { - const resp = await sendCommand(session, { - id: "launch", - action: "launch", - headless: true, - }); - if (!(resp as { success?: boolean }).success) { - throw new Error( - `Failed to launch ${label} browser on ${session}: ${JSON.stringify(resp)}`, - ); - } - } - console.log(" Browsers launched"); - - // Collect PIDs for memory sampling - const chromePid = chromeHandle.process.pid; - const lpPid = lightpandaHandle.process.pid; - const pidsToSample: number[] = []; - if (chromePid) pidsToSample.push(chromePid); - if (lpPid) pidsToSample.push(lpPid); - - const memSampler = pidsToSample.length > 0 - ? sampleMemory(pidsToSample, 500) - : null; - - // Measure per-engine peak memory during the heavy scenarios - const chromeMemPids = chromePid ? [chromePid] : []; - const lpMemPids = lpPid ? [lpPid] : []; - - console.log(""); - - const httpScenarios = buildHttpScenarios(baseUrl); - const allScenarios = [...scenarios, ...engineScenarios, ...httpScenarios]; - const results: ScenarioResult[] = []; - for (const scenario of allScenarios) { - process.stdout.write(` Running: ${scenario.name}...`); - const result = await runScenarioWithErrorTolerance( - scenario, - sessions, - args.iterations, - args.warmup, - ); - results.push(result); - - const chromeAvg = result.chromeStats - ? formatDuration(result.chromeStats.avgUs) - : "N/A"; - const lpAvg = result.lightpandaStats - ? formatDuration(result.lightpandaStats.avgUs) - : "N/A"; - - if (result.chromeStats && result.lightpandaStats) { - const speedup = formatSpeedup( - result.chromeStats.avgUs, - result.lightpandaStats.avgUs, - ); - process.stdout.write(` chrome=${chromeAvg} lightpanda=${lpAvg} (${speedup})\n`); - } else { - process.stdout.write(` chrome=${chromeAvg} lightpanda=${lpAvg}\n`); - } - } - - // Final memory snapshot - const chromeMemKB = chromeMemPids.length > 0 ? getProcessMemoryKB(chromeMemPids[0]) : null; - const lpMemKB = lpMemPids.length > 0 ? getProcessMemoryKB(lpMemPids[0]) : null; - if (memSampler) memSampler.stop(); - - printResults(results, args.iterations, args.warmup, "engine"); - - if (chromeMemKB || lpMemKB) { - console.log("Memory (daemon RSS after benchmarks):"); - if (chromeMemKB) console.log(` Chrome daemon: ${formatMemory(chromeMemKB)}`); - if (lpMemKB) console.log(` Lightpanda daemon: ${formatMemory(lpMemKB)}`); - if (chromeMemKB && lpMemKB && lpMemKB > 0) { - const memRatio = chromeMemKB / lpMemKB; - console.log(` Ratio: chrome uses ${memRatio.toFixed(1)}x more memory`); - } - console.log(""); - } - - if (args.json) { - writeJsonResults( - results, - path.join(getProjectRoot(), "test/benchmarks/results-engine.json"), - "engine", - ); - } - - for (const session of Object.values(sessions)) { - await sendCommand(session, { id: "close", action: "close" }).catch(() => {}); - } - - await sleep(300); - } finally { - if (chromeHandle) await closeDaemon(chromeHandle); - if (lightpandaHandle) await closeDaemon(lightpandaHandle); - await stopFileServer(server); - } -} - -async function main(): Promise { - const args = parseArgs(); - - cleanupSockets(); - - try { - if (args.engineMode) { - await runEngineBenchmark(args); - } else { - await runDaemonBenchmark(args); - } - } finally { - cleanupSockets(); - } -} - -main().catch((err) => { - console.error("Benchmark failed:", err.message || err); - process.exit(2); -}); diff --git a/test/benchmarks/scenarios.ts b/test/benchmarks/scenarios.ts deleted file mode 100644 index 410dfa8..0000000 --- a/test/benchmarks/scenarios.ts +++ /dev/null @@ -1,119 +0,0 @@ -export interface BenchmarkCommand { - id: string; - action: string; - [key: string]: unknown; -} - -export interface Scenario { - name: string; - description: string; - /** Commands to run once before measured iterations (e.g. navigate to a page). */ - setup?: BenchmarkCommand[]; - /** The commands whose total execution time is measured per iteration. */ - commands: BenchmarkCommand[]; - /** Commands to run once after measured iterations (e.g. cleanup). */ - teardown?: BenchmarkCommand[]; -} - -const FORM_HTML = [ - "Bench", - "

Benchmark Page

", - "", - "", - "", - "", - "", - "", - "

Ready

", - "Click me", - "
    ", - ...Array.from({ length: 20 }, (_, i) => `
  • Item ${i + 1}
  • `), - "
", - "", -].join(""); - -const INJECT_FORM: BenchmarkCommand = { - id: "inject", - action: "evaluate", - script: `document.open(); document.write(${JSON.stringify(FORM_HTML)}); document.close(); 'ok'`, -}; - -const SETUP_PAGE: BenchmarkCommand[] = [ - { id: "setup-nav", action: "navigate", url: "about:blank", waitUntil: "domcontentloaded" }, - INJECT_FORM, -]; - -export const scenarios: Scenario[] = [ - { - name: "navigate", - description: "Page navigation (about:blank round-trip)", - commands: [ - { id: "nav", action: "navigate", url: "about:blank", waitUntil: "domcontentloaded" }, - ], - }, - { - name: "snapshot", - description: "DOM snapshot (accessibility tree)", - setup: SETUP_PAGE, - commands: [ - { id: "snap", action: "snapshot" }, - ], - }, - { - name: "screenshot", - description: "Screenshot capture", - setup: SETUP_PAGE, - commands: [ - { id: "ss", action: "screenshot" }, - ], - }, - { - name: "evaluate", - description: "JavaScript evaluation", - setup: SETUP_PAGE, - commands: [ - { id: "eval", action: "evaluate", script: "document.title + ' ' + document.querySelectorAll('li').length" }, - ], - }, - { - name: "click", - description: "Element click interaction", - setup: SETUP_PAGE, - commands: [ - { id: "clk", action: "click", selector: "#link" }, - ], - }, - { - name: "fill", - description: "Form field fill", - setup: SETUP_PAGE, - commands: [ - { id: "fill", action: "fill", selector: "#name", value: "Benchmark User" }, - ], - }, - { - name: "tabs", - description: "Tab new + list + switch", - commands: [ - { id: "tnew", action: "tab_new", url: "about:blank" }, - { id: "tlist", action: "tab_list" }, - { id: "tswitch", action: "tab_switch", index: 0 }, - ], - teardown: [ - { id: "tclose", action: "tab_close", index: 1 }, - ], - }, - { - name: "full-workflow", - description: "Realistic agent workflow: navigate, snapshot, click, fill, evaluate, screenshot", - commands: [ - { id: "w-nav", action: "navigate", url: "about:blank", waitUntil: "domcontentloaded" }, - INJECT_FORM, - { id: "w-snap", action: "snapshot" }, - { id: "w-click", action: "click", selector: "#link" }, - { id: "w-fill", action: "fill", selector: "#name", value: "Agent User" }, - { id: "w-eval", action: "evaluate", script: "document.getElementById('name').value" }, - { id: "w-ss", action: "screenshot" }, - ], - }, -]; diff --git a/test/e2e/dogfood.eval.ts b/test/e2e/dogfood.eval.ts deleted file mode 100644 index 6e5469e..0000000 --- a/test/e2e/dogfood.eval.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { describe, it, expect, beforeAll } from 'vitest'; -import { query } from '@anthropic-ai/claude-agent-sdk'; -import type { SDKMessage, SDKResultMessage } from '@anthropic-ai/claude-agent-sdk'; -import { mkdirSync, readFileSync, writeFileSync, appendFileSync, existsSync, readdirSync, rmSync } from 'node:fs'; -import path from 'node:path'; - -const AI_GATEWAY_URL = - process.env.ANTHROPIC_BASE_URL || 'https://ai-gateway.vercel.sh'; -const API_KEY = process.env.AI_GATEWAY_API_KEY; -const MODEL = process.env.DOGFOOD_MODEL || 'anthropic/claude-haiku-4.5'; -const CUSTOM_URL = process.env.DOGFOOD_URL; - -const FIXTURE_PATH = path.resolve('test/e2e/fixtures/buggy-app.html'); -const SKILL_PATH = path.resolve('skills/dogfood/SKILL.md'); -const TARGET_URL = CUSTOM_URL || `file://${FIXTURE_PATH}`; -const IS_FIXTURE = !CUSTOM_URL; - -const OUTPUT_DIR = path.resolve('test/e2e/.dogfood-output'); -const EVAL_TIMEOUT = 10 * 60 * 1000; - -async function runDogfood(outputDir: string): Promise<{ - result: SDKResultMessage | null; - messages: SDKMessage[]; - toolsUsed: Set; -}> { - const instruction = [ - `Read the dogfood skill at ${SKILL_PATH} and follow its workflow.`, - `Dogfood ${TARGET_URL}`, - `Output directory: ${outputDir}`, - ].join(' '); - - const messages: SDKMessage[] = []; - const toolsUsed = new Set(); - let result: SDKResultMessage | null = null; - - const conversation = query({ - prompt: instruction, - options: { - model: MODEL, - cwd: process.cwd(), - allowedTools: ['Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep'], - permissionMode: 'bypassPermissions', - allowDangerouslySkipPermissions: true, - maxTurns: 80, - maxBudgetUsd: 2, - settingSources: ['project'], - persistSession: false, - env: { - ...process.env, - ANTHROPIC_BASE_URL: AI_GATEWAY_URL, - ANTHROPIC_API_KEY: API_KEY, - }, - }, - }); - - const verbose = process.env.DOGFOOD_VERBOSE !== '0'; - const log = verbose ? (msg: string) => process.stderr.write(` [dogfood] ${msg}\n`) : () => {}; - - const chatLogPath = path.join(outputDir, 'chat-log.jsonl'); - writeFileSync(chatLogPath, ''); - - function appendToLog(entry: Record) { - appendFileSync(chatLogPath, JSON.stringify(entry) + '\n'); - } - - for await (const message of conversation) { - messages.push(message); - - if (message.type === 'system' && message.subtype === 'init') { - log(`session started (model: ${message.model})`); - appendToLog({ type: 'system', subtype: 'init', model: message.model }); - } - - if (message.type === 'assistant' && message.message?.content) { - const logParts: Record[] = []; - for (const block of message.message.content) { - if ('type' in block && block.type === 'tool_use') { - toolsUsed.add(block.name); - const input = block.input as Record; - let preview: string; - if (block.name === 'Bash') { - const cmd = String(input.command ?? ''); - const firstLine = cmd.split('\n').find(l => l.trim() && !l.trim().startsWith('#')) ?? cmd.split('\n')[0]; - preview = firstLine.trim().slice(0, 200); - } else if (block.name === 'Write') { - preview = String(input.file_path ?? input.path ?? ''); - } else if (block.name === 'Read') { - preview = String(input.file_path ?? input.path ?? ''); - } else if (block.name === 'Edit') { - preview = String(input.file_path ?? input.path ?? ''); - } else { - preview = JSON.stringify(input).slice(0, 120); - } - log(`${block.name}: ${preview}`); - logParts.push({ tool: block.name, input: block.input }); - } - if ('type' in block && block.type === 'text' && block.text) { - const line = block.text.split('\n')[0].slice(0, 120); - if (line.trim()) log(line); - logParts.push({ text: block.text }); - } - } - appendToLog({ type: 'assistant', content: logParts }); - } - - if (message.type === 'result') { - result = message; - const cost = `$${message.total_cost_usd.toFixed(4)}`; - const usage = message.usage; - const cacheRead = usage.cache_read_input_tokens ?? 0; - const cacheCreate = usage.cache_creation_input_tokens ?? 0; - const inputTokens = usage.input_tokens ?? 0; - const cacheInfo = cacheRead > 0 - ? ` | cache: ${cacheRead} read, ${cacheCreate} created, ${inputTokens} uncached` - : ''; - if (message.subtype === 'success') { - log(`done (${message.num_turns} turns, ${cost}${cacheInfo})`); - } else { - log(`stopped: ${message.subtype} (${message.num_turns} turns, ${cost}${cacheInfo})`); - } - appendToLog({ type: 'result', subtype: message.subtype, num_turns: message.num_turns, cost: message.total_cost_usd }); - } - } - - log(`chat log: ${chatLogPath}`); - - return { result, messages, toolsUsed }; -} - -function findFiles(dir: string, ext: string): string[] { - if (!existsSync(dir)) return []; - return readdirSync(dir, { recursive: true }) - .map(String) - .filter((f) => f.endsWith(ext)); -} - -describe.skipIf(!API_KEY)('Dogfood e2e eval (Agent SDK)', () => { - const outputDir = OUTPUT_DIR; - let evalResult: Awaited>; - - beforeAll(async () => { - if (existsSync(outputDir)) { - rmSync(outputDir, { recursive: true, force: true }); - } - mkdirSync(outputDir, { recursive: true }); - evalResult = await runDogfood(outputDir); - }, EVAL_TIMEOUT); - - it('completes without hard failure', () => { - expect(evalResult.result, 'No result message received').toBeTruthy(); - const acceptable = ['success', 'error_max_turns', 'error_max_budget_usd']; - expect( - acceptable, - `Agent failed unexpectedly: ${evalResult.result!.subtype}` - ).toContain(evalResult.result!.subtype); - }); - - it('used agent-browser via Bash tool', () => { - expect( - evalResult.toolsUsed.has('Bash'), - 'Agent never used Bash (needed for agent-browser commands)' - ).toBe(true); - }); - - it('produced a report file', () => { - const reportPath = path.join(outputDir, 'report.md'); - expect(existsSync(reportPath), 'report.md not found in output dir').toBe( - true - ); - }); - - it('found a minimum number of issues', () => { - const reportPath = path.join(outputDir, 'report.md'); - if (!existsSync(reportPath)) return; - const report = readFileSync(reportPath, 'utf-8'); - - const issueBlocks = report.match(/###\s+ISSUE-\d+/g) || []; - if (IS_FIXTURE) { - expect( - issueBlocks.length, - `Expected >=2 issues from fixture, found ${issueBlocks.length}` - ).toBeGreaterThanOrEqual(2); - } else { - expect(issueBlocks.length).toBeGreaterThanOrEqual(1); - } - }); - - it('each issue has required fields and repro evidence', () => { - const reportPath = path.join(outputDir, 'report.md'); - if (!existsSync(reportPath)) return; - const report = readFileSync(reportPath, 'utf-8'); - - const issueSections = report.split(/(?=###\s+ISSUE-\d+)/).slice(1); - for (const section of issueSections) { - const issueId = section.match(/ISSUE-\d+/)?.[0] ?? 'unknown'; - - expect(section, `${issueId}: missing Severity`).toMatch( - /\*\*Severity\*\*/i - ); - - const sevMatch = section.match( - /\*\*Severity\*\*\s*\|?\s*(critical|high|medium|low)/i - ); - expect(sevMatch, `${issueId}: invalid severity value`).toBeTruthy(); - - expect(section, `${issueId}: missing Category`).toMatch( - /\*\*Category\*\*/i - ); - - expect(section, `${issueId}: missing URL`).toMatch(/\*\*URL\*\*/i); - - expect(section, `${issueId}: missing Repro Video field`).toMatch( - /\*\*Repro Video\*\*/i - ); - - const hasScreenshot = /!\[.*?\]\(.*?\)/.test(section); - const hasReproSteps = /\*\*Repro Steps\*\*/i.test(section); - expect( - hasScreenshot || hasReproSteps, - `${issueId}: needs either screenshot refs or repro steps` - ).toBe(true); - } - }); - - it('has a summary table with non-zero total', () => { - const reportPath = path.join(outputDir, 'report.md'); - if (!existsSync(reportPath)) return; - const report = readFileSync(reportPath, 'utf-8'); - - expect(report, 'Missing Summary section').toContain('## Summary'); - const totalMatch = report.match(/\*\*Total\*\*\s*\|?\s*\*\*(\d+)\*\*/); - expect(totalMatch, 'Summary Total not found').toBeTruthy(); - if (totalMatch) { - const total = parseInt(totalMatch[1], 10); - expect(total, 'Summary Total should be > 0').toBeGreaterThan(0); - } - }); - - it('produced screenshot files', () => { - const screenshotsDir = path.join(outputDir, 'screenshots'); - const screenshots = findFiles(screenshotsDir, '.png'); - expect( - screenshots.length, - 'No screenshot files found in output' - ).toBeGreaterThan(0); - }); - - it('produced video files for interactive issues', () => { - const reportPath = path.join(outputDir, 'report.md'); - if (!existsSync(reportPath)) return; - const report = readFileSync(reportPath, 'utf-8'); - const hasVideoRefs = /videos\/issue-\d+/.test(report); - if (!hasVideoRefs) return; - const videosDir = path.join(outputDir, 'videos'); - const videos = findFiles(videosDir, '.webm'); - expect( - videos.length, - 'Report references videos but none were found' - ).toBeGreaterThan(0); - }); -}); diff --git a/test/e2e/dogfood.test.ts b/test/e2e/dogfood.test.ts deleted file mode 100644 index e0d5d31..0000000 --- a/test/e2e/dogfood.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { readFileSync, existsSync } from 'node:fs'; -import path from 'node:path'; - -const SKILL_DIR = path.resolve('skills/dogfood'); -const SKILL_MD = path.join(SKILL_DIR, 'SKILL.md'); -const TAXONOMY_MD = path.join(SKILL_DIR, 'references', 'issue-taxonomy.md'); -const TEMPLATE_MD = path.join(SKILL_DIR, 'templates', 'dogfood-report-template.md'); - -function readSkillFile(filePath: string): string { - return readFileSync(filePath, 'utf-8'); -} - -function parseFrontmatter(content: string): Record { - const match = content.match(/^---\n([\s\S]*?)\n---/); - if (!match) return {}; - const fields: Record = {}; - for (const line of match[1].split('\n')) { - const colonIdx = line.indexOf(':'); - if (colonIdx > 0) { - fields[line.slice(0, colonIdx).trim()] = line.slice(colonIdx + 1).trim(); - } - } - return fields; -} - -describe('Dogfood skill: file structure', () => { - it('SKILL.md exists', () => { - expect(existsSync(SKILL_MD)).toBe(true); - }); - - it('references/issue-taxonomy.md exists', () => { - expect(existsSync(TAXONOMY_MD)).toBe(true); - }); - - it('templates/dogfood-report-template.md exists', () => { - expect(existsSync(TEMPLATE_MD)).toBe(true); - }); -}); - -describe('Dogfood skill: SKILL.md frontmatter', () => { - const content = readSkillFile(SKILL_MD); - const frontmatter = parseFrontmatter(content); - - it('has name field', () => { - expect(frontmatter.name).toBe('dogfood'); - }); - - it('has description field', () => { - expect(frontmatter.description).toBeTruthy(); - expect(frontmatter.description!.length).toBeGreaterThan(50); - }); - - it('has allowed-tools field', () => { - expect(frontmatter['allowed-tools']).toBeTruthy(); - expect(frontmatter['allowed-tools']).toContain('agent-browser'); - }); -}); - -describe('Dogfood skill: SKILL.md body references', () => { - const content = readSkillFile(SKILL_MD); - - it('references issue-taxonomy.md', () => { - expect(content).toContain('references/issue-taxonomy.md'); - }); - - it('references dogfood-report-template.md', () => { - expect(content).toContain('templates/dogfood-report-template.md'); - }); - - it('referenced files exist on disk', () => { - const refPattern = /\[.*?\]\((references\/.*?\.md|templates\/.*?\.md)\)/g; - const refs = [...content.matchAll(refPattern)].map((m) => m[1]); - expect(refs.length).toBeGreaterThan(0); - for (const ref of refs) { - const fullPath = path.join(SKILL_DIR, ref); - expect(existsSync(fullPath), `Missing: ${ref}`).toBe(true); - } - }); -}); - -describe('Dogfood skill: report template', () => { - const template = readSkillFile(TEMPLATE_MD); - - it('has ISSUE- prefix in issue blocks', () => { - expect(template).toContain('ISSUE-'); - }); - - it('has Severity field', () => { - expect(template).toContain('**Severity**'); - }); - - it('has Category field', () => { - expect(template).toContain('**Category**'); - }); - - it('has URL field', () => { - expect(template).toContain('**URL**'); - }); - - it('has Repro Video field', () => { - expect(template).toContain('**Repro Video**'); - }); - - it('has Repro Steps section', () => { - expect(template).toContain('**Repro Steps**'); - }); - - it('has screenshot image references in repro steps', () => { - expect(template).toMatch(/!\[.*?\]\(screenshots\//); - }); - - it('lists all valid severity values', () => { - expect(template).toMatch(/critical\s*\/\s*high\s*\/\s*medium\s*\/\s*low/); - }); - - it('lists all valid category values', () => { - const categoryLine = template - .split('\n') - .find((l) => l.includes('**Category**')); - expect(categoryLine).toBeTruthy(); - for (const cat of [ - 'visual', - 'functional', - 'ux', - 'content', - 'performance', - 'console', - 'accessibility', - ]) { - expect(categoryLine!.toLowerCase()).toContain(cat); - } - }); - - it('has Summary table with severity counts', () => { - expect(template).toContain('## Summary'); - for (const sev of ['Critical', 'High', 'Medium', 'Low', 'Total']) { - expect(template).toContain(sev); - } - }); -}); - -describe('Dogfood skill: issue taxonomy', () => { - const taxonomy = readSkillFile(TAXONOMY_MD); - - it('has severity level definitions', () => { - expect(taxonomy).toContain('## Severity Levels'); - for (const sev of ['critical', 'high', 'medium', 'low']) { - expect(taxonomy.toLowerCase()).toContain(`**${sev}**`); - } - }); - - it('has all 7 category sections', () => { - const expectedCategories = [ - 'Visual', - 'Functional', - 'UX', - 'Content', - 'Performance', - 'Console', - 'Accessibility', - ]; - for (const cat of expectedCategories) { - expect(taxonomy).toMatch(new RegExp(`###\\s+.*${cat}`, 'i')); - } - }); - - it('has exploration checklist', () => { - expect(taxonomy).toContain('## Exploration Checklist'); - }); - - it('checklist has numbered items', () => { - const checklistSection = taxonomy.split('## Exploration Checklist')[1]; - expect(checklistSection).toBeTruthy(); - const numberedItems = checklistSection!.match(/^\d+\./gm); - expect(numberedItems!.length).toBeGreaterThanOrEqual(5); - }); -}); - -describe('Dogfood skill: cross-consistency', () => { - const template = readSkillFile(TEMPLATE_MD); - const taxonomy = readSkillFile(TAXONOMY_MD); - - it('every category in template exists in taxonomy', () => { - const categoryLine = template - .split('\n') - .find((l) => l.includes('**Category**')); - expect(categoryLine).toBeTruthy(); - - const categories = categoryLine! - .split('|') - .pop()! - .split('/') - .map((c) => c.trim().toLowerCase()) - .filter(Boolean); - - for (const cat of categories) { - expect( - taxonomy.toLowerCase(), - `Category "${cat}" from template not found in taxonomy` - ).toMatch(new RegExp(`###\\s+.*${cat}`)); - } - }); - - it('every severity in template exists in taxonomy', () => { - for (const sev of ['critical', 'high', 'medium', 'low']) { - expect(taxonomy.toLowerCase()).toContain(`**${sev}**`); - } - }); -}); diff --git a/test/e2e/fixtures/buggy-app.html b/test/e2e/fixtures/buggy-app.html deleted file mode 100644 index 6c45d83..0000000 --- a/test/e2e/fixtures/buggy-app.html +++ /dev/null @@ -1,159 +0,0 @@ - - - - - - Buggy App - Dogfood Test Fixture - - - - -
-

Buggy App

- -
- -
- -

Welocme to the Dashboard

- - -
-

Quick Actions

-

Perform common tasks from here.

-
- - - -
-
- - -
-

System Status

- -
- The system is currently operating normally. All services are online and responding within expected latency thresholds. Last health check completed at 14:32 UTC. -
- -
-
- All systems operational -
-
- - -
-

Recent Activity

- -

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.

-
- - -
-

Contact Support

-
- - - - - - -
-
- - -
-

Notifications

- -
-
-
-
- -
- © 2025 Buggy App Inc. All rights reserved. -
- - - - - diff --git a/test/file-access.test.ts b/test/file-access.test.ts deleted file mode 100644 index 874a4cb..0000000 --- a/test/file-access.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { describe, it, expect, afterEach, beforeAll, afterAll } from 'vitest'; -import { BrowserManager } from '../src/browser.js'; -import { writeFileSync, unlinkSync } from 'node:fs'; -import path from 'node:path'; -import os from 'node:os'; - -describe('File Access (Issue #345)', () => { - let browser: BrowserManager; - const testFilePath = path.join(os.tmpdir(), 'agent-browser-test-file.html'); - const testFileUrl = `file://${testFilePath}`; - - // Create test HTML file before tests - beforeAll(() => { - writeFileSync( - testFilePath, - '

Test File Access

This content was loaded from a local file.

' - ); - }); - - // Clean up test file after tests - afterAll(() => { - try { - unlinkSync(testFilePath); - } catch { - // Ignore if file doesn't exist - } - }); - - afterEach(async () => { - if (browser?.isLaunched()) { - await browser.close(); - } - }); - - describe('without allowFileAccess flag', () => { - it('should fail to load file:// URL content by default', async () => { - browser = new BrowserManager(); - await browser.launch({ - headless: true, - }); - - const page = browser.getPage(); - - // Navigate to file:// URL - this should work for navigation - // but Chromium restricts what the page can do - await page.goto(testFileUrl); - - // The page should load but let's verify the URL - const url = page.url(); - expect(url).toBe(testFileUrl); - - // Content should be accessible when navigating directly - const content = await page.content(); - expect(content).toContain('Test File Access'); - }); - }); - - describe('with allowFileAccess flag', () => { - it('should load file:// URL with allowFileAccess enabled', async () => { - browser = new BrowserManager(); - await browser.launch({ - headless: true, - allowFileAccess: true, - }); - - const page = browser.getPage(); - await page.goto(testFileUrl); - - // Verify the page loaded correctly - const url = page.url(); - expect(url).toBe(testFileUrl); - - // Verify content is accessible - const heading = await page.locator('h1').textContent(); - expect(heading).toBe('Test File Access'); - - const paragraph = await page.locator('p').textContent(); - expect(paragraph).toBe('This content was loaded from a local file.'); - }); - - it('should allow file:// URL to access other local files via XMLHttpRequest', async () => { - browser = new BrowserManager(); - await browser.launch({ - headless: true, - allowFileAccess: true, - }); - - const page = browser.getPage(); - await page.goto(testFileUrl); - - // With allowFileAccess, XMLHttpRequest to local files should work - // This is the key difference - without the flag, this would be blocked - const canAccessFiles = await page.evaluate(() => { - return new Promise((resolve) => { - try { - // XMLHttpRequest is the traditional way to test --allow-file-access-from-files - const xhr = new XMLHttpRequest(); - xhr.open('GET', window.location.href, true); - xhr.onload = () => resolve(xhr.status === 0 || xhr.status === 200); - xhr.onerror = () => resolve(false); - xhr.send(); - } catch { - resolve(false); - } - }); - }); - - expect(canAccessFiles).toBe(true); - }); - }); - - describe('combined with other options', () => { - it('should work with allowFileAccess and custom user-agent', async () => { - const customUA = 'FileAccessTestBot/1.0'; - browser = new BrowserManager(); - await browser.launch({ - headless: true, - allowFileAccess: true, - userAgent: customUA, - }); - - const page = browser.getPage(); - await page.goto(testFileUrl); - - // Verify file access works - const content = await page.locator('h1').textContent(); - expect(content).toBe('Test File Access'); - - // Verify user-agent is set - const ua = await page.evaluate(() => navigator.userAgent); - expect(ua).toBe(customUA); - }); - - it('should work with allowFileAccess and custom args', async () => { - browser = new BrowserManager(); - await browser.launch({ - headless: true, - allowFileAccess: true, - args: ['--disable-blink-features=AutomationControlled'], - }); - - const page = browser.getPage(); - await page.goto(testFileUrl); - - // Verify file access works - const content = await page.locator('h1').textContent(); - expect(content).toBe('Test File Access'); - - // Verify webdriver is hidden (from custom arg) - const webdriver = await page.evaluate(() => navigator.webdriver); - expect(webdriver).toBe(false); - }); - }); -}); diff --git a/test/keyboard.test.ts b/test/keyboard.test.ts deleted file mode 100644 index 23bab5d..0000000 --- a/test/keyboard.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { parseCommand } from '../src/protocol.js'; - -describe('keyboard command validation', () => { - it('accepts keyboard type with text', () => { - const result = parseCommand( - JSON.stringify({ id: '1', action: 'keyboard', subaction: 'type', text: 'hello' }) - ); - expect(result.success).toBe(true); - }); - - it('accepts keyboard insertText with text', () => { - const result = parseCommand( - JSON.stringify({ id: '1', action: 'keyboard', subaction: 'insertText', text: 'hello' }) - ); - expect(result.success).toBe(true); - }); - - it('accepts keyboard press with keys', () => { - const result = parseCommand( - JSON.stringify({ id: '1', action: 'keyboard', subaction: 'press', keys: 'Enter' }) - ); - expect(result.success).toBe(true); - }); - - it('accepts legacy keyboard (no subaction) with keys', () => { - const result = parseCommand( - JSON.stringify({ id: '1', action: 'keyboard', keys: 'Enter' }) - ); - expect(result.success).toBe(true); - }); - - it('rejects keyboard type without text', () => { - const result = parseCommand( - JSON.stringify({ id: '1', action: 'keyboard', subaction: 'type' }) - ); - expect(result.success).toBe(false); - if (!result.success) expect(result.error).toContain('requires text'); - }); - - it('rejects keyboard insertText without text', () => { - const result = parseCommand( - JSON.stringify({ id: '1', action: 'keyboard', subaction: 'insertText' }) - ); - expect(result.success).toBe(false); - if (!result.success) expect(result.error).toContain('requires text'); - }); - - it('rejects keyboard press without keys', () => { - const result = parseCommand( - JSON.stringify({ id: '1', action: 'keyboard', subaction: 'press' }) - ); - expect(result.success).toBe(false); - if (!result.success) expect(result.error).toContain('requires keys'); - }); - - it('rejects legacy keyboard (no subaction) without keys', () => { - const result = parseCommand( - JSON.stringify({ id: '1', action: 'keyboard' }) - ); - expect(result.success).toBe(false); - if (!result.success) expect(result.error).toContain('requires keys'); - }); - - it('accepts keyboard type with delay option', () => { - const result = parseCommand( - JSON.stringify({ id: '1', action: 'keyboard', subaction: 'type', text: 'hello', delay: 50 }) - ); - expect(result.success).toBe(true); - }); -}); diff --git a/test/launch-options.test.ts b/test/launch-options.test.ts deleted file mode 100644 index e594e54..0000000 --- a/test/launch-options.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { describe, it, expect, afterEach } from 'vitest'; -import { BrowserManager } from '../src/browser.js'; - -describe('Launch Options', () => { - let browser: BrowserManager; - - afterEach(async () => { - if (browser?.isLaunched()) { - await browser.close(); - } - }); - - describe('browser args', () => { - it('should launch with custom args to disable webdriver detection', async () => { - browser = new BrowserManager(); - await browser.launch({ - headless: true, - args: ['--disable-blink-features=AutomationControlled'], - }); - - const page = browser.getPage(); - await page.goto('about:blank'); - - // Check that navigator.webdriver is false - const webdriver = await page.evaluate(() => navigator.webdriver); - expect(webdriver).toBe(false); - }); - - it('should launch with multiple args', async () => { - browser = new BrowserManager(); - await browser.launch({ - headless: true, - args: [ - '--disable-blink-features=AutomationControlled', - '--disable-dev-shm-usage', - ], - }); - - expect(browser.isLaunched()).toBe(true); - }); - - it('should launch without args (default behavior)', async () => { - browser = new BrowserManager(); - await browser.launch({ - headless: true, - }); - - const page = browser.getPage(); - await page.goto('about:blank'); - - // Default Playwright behavior - webdriver is true - const webdriver = await page.evaluate(() => navigator.webdriver); - expect(webdriver).toBe(true); - }); - }); - - describe('custom user-agent', () => { - it('should launch with custom user-agent', async () => { - const customUA = 'CustomTestBot/1.0'; - browser = new BrowserManager(); - await browser.launch({ - headless: true, - userAgent: customUA, - }); - - const page = browser.getPage(); - await page.goto('about:blank'); - - const ua = await page.evaluate(() => navigator.userAgent); - expect(ua).toBe(customUA); - }); - - it('should use default user-agent when not specified', async () => { - browser = new BrowserManager(); - await browser.launch({ - headless: true, - }); - - const page = browser.getPage(); - await page.goto('about:blank'); - - const ua = await page.evaluate(() => navigator.userAgent); - // Default UA should contain Chrome/Chromium - expect(ua).toContain('Chrome'); - }); - }); - - describe('proxy configuration', () => { - it('should accept proxy configuration', async () => { - browser = new BrowserManager(); - // Note: This test just verifies the proxy option is accepted without error - // Actual proxy testing requires a running proxy server - await browser.launch({ - headless: true, - proxy: { - server: 'http://localhost:8080', - }, - }); - - expect(browser.isLaunched()).toBe(true); - }); - - it('should accept proxy with bypass list', async () => { - browser = new BrowserManager(); - await browser.launch({ - headless: true, - proxy: { - server: 'http://localhost:8080', - bypass: 'localhost,*.internal.com', - }, - }); - - expect(browser.isLaunched()).toBe(true); - }); - - it('should fail connection when proxy is unreachable (proves proxy is being used)', async () => { - browser = new BrowserManager(); - await browser.launch({ - headless: true, - proxy: { - server: 'http://127.0.0.1:59999', // Non-existent proxy - }, - }); - - const page = browser.getPage(); - // Navigation should fail because proxy is unreachable - // This proves the proxy setting is actually being used - await expect(page.goto('https://example.com', { timeout: 5000 })).rejects.toThrow(); - }); - }); - - describe('combined options', () => { - it('should launch with args, user-agent, and proxy combined', async () => { - const customUA = 'CombinedTestBot/2.0'; - browser = new BrowserManager(); - await browser.launch({ - headless: true, - args: ['--disable-blink-features=AutomationControlled'], - userAgent: customUA, - proxy: { - server: 'http://localhost:8080', - bypass: 'localhost', - }, - }); - - const page = browser.getPage(); - await page.goto('about:blank'); - - // Verify user-agent - const ua = await page.evaluate(() => navigator.userAgent); - expect(ua).toBe(customUA); - - // Verify webdriver is hidden - const webdriver = await page.evaluate(() => navigator.webdriver); - expect(webdriver).toBe(false); - }); - }); -}); diff --git a/test/serverless.test.ts b/test/serverless.test.ts deleted file mode 100644 index 8efea63..0000000 --- a/test/serverless.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * 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(); - } - }); -}); diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index ff14e31..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2024", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "lib": [ - "ES2024" - ], - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true - }, - "include": [ - "src/**/*" - ], - "exclude": [ - "node_modules", - "dist", - "src/**/*.test.ts", - "opensrc" - ] -} diff --git a/vitest.config.ts b/vitest.config.ts deleted file mode 100644 index f7e8d9c..0000000 --- a/vitest.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - globals: true, - include: ['src/**/*.test.ts', 'test/**/*.test.ts', 'test/**/*.eval.ts'], - testTimeout: 30000, - }, -});