full native (#754)
* full native * fix: apply cargo fmt formatting * fix: prevent zip path traversal in Chromium installer Use enclosed_name() to sanitize zip entry paths, preventing malicious archives from writing outside the extraction directory. * improvements * fix: apply cargo fmt formatting * benchmarks * bench * updates * fixes
This commit is contained in:
+26
-105
@@ -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
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
pnpm lint-staged
|
||||
node scripts/sync-version.js
|
||||
git add cli/Cargo.toml cli/Cargo.lock
|
||||
|
||||
@@ -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 `<table>` 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
|
||||
|
||||
|
||||
@@ -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>` | Path to action policy JSON file (or `AGENT_BROWSER_ACTION_POLICY` env) |
|
||||
| `--confirm-actions <list>` | 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 <name>` | 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 <name>` | Browser engine: `chrome` (default), `lightpanda` (or `AGENT_BROWSER_ENGINE` env) |
|
||||
| `--config <path>` | 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) |
|
||||
| ------------------------------- | ---------------------------------------- |
|
||||
| `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
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Vercel Sandbox credentials
|
||||
SANDBOX_VERCEL_TOKEN=
|
||||
SANDBOX_VERCEL_TEAM_ID=
|
||||
SANDBOX_VERCEL_PROJECT_ID=
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
results.json
|
||||
@@ -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.
|
||||
@@ -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<typeof Sandbox>;
|
||||
|
||||
async function run(
|
||||
sandbox: SandboxInstance,
|
||||
cmd: string,
|
||||
args: string[],
|
||||
): Promise<string> {
|
||||
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<string> {
|
||||
return run(sandbox, "sh", ["-c", script]);
|
||||
}
|
||||
|
||||
async function shellSafe(sandbox: SandboxInstance, script: string): Promise<string> {
|
||||
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<number[]> {
|
||||
// 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<ProcessMetrics | null> {
|
||||
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<number> {
|
||||
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<number[]> {
|
||||
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<number[]> {
|
||||
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<DaemonMetrics> {
|
||||
// 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<number> {
|
||||
// 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<number> {
|
||||
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<string, string> {
|
||||
return { AGENT_BROWSER_SESSION: `bench-${mode}` };
|
||||
}
|
||||
|
||||
async function agentBrowser(
|
||||
sandbox: SandboxInstance,
|
||||
args: string[],
|
||||
mode: DaemonMode,
|
||||
): Promise<void> {
|
||||
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<number> {
|
||||
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<ScenarioResult> {
|
||||
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<DaemonResults> {
|
||||
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();
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+472
@@ -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: {}
|
||||
@@ -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 = [
|
||||
"<html><head><title>Bench</title></head><body>",
|
||||
"<h1>Benchmark Page</h1>",
|
||||
"<input id='name' type='text' placeholder='Name'>",
|
||||
"<input id='email' type='email' placeholder='Email'>",
|
||||
"<select id='color'><option value='red'>Red</option><option value='blue'>Blue</option></select>",
|
||||
"<input id='agree' type='checkbox'>",
|
||||
"<textarea id='bio' placeholder='Bio'></textarea>",
|
||||
"<button id='submit'>Submit</button>",
|
||||
"<p id='status'>Ready</p>",
|
||||
"<a id='link' href='javascript:void(0)' onclick=\"document.getElementById('status').textContent='Clicked'\">Click me</a>",
|
||||
"<ul>",
|
||||
...Array.from({ length: 20 }, (_, i) => `<li class='item'>Item ${i + 1}</li>`),
|
||||
"</ul>",
|
||||
"</body></html>",
|
||||
].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"],
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["*.ts"]
|
||||
}
|
||||
Generated
+41
-1
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
+1
-3
@@ -1061,7 +1061,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
}
|
||||
}
|
||||
|
||||
// === Recording (Playwright native video recording) ===
|
||||
// === Recording (browser video recording) ===
|
||||
"record" => {
|
||||
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,
|
||||
|
||||
+2
-86
@@ -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,25 +358,11 @@ pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult
|
||||
}
|
||||
|
||||
let exe_path = env::current_exe().map_err(|e| e.to_string())?;
|
||||
// Canonicalize to resolve symlinks (e.g., npm global bin symlink -> 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<std::process::Child> = None;
|
||||
|
||||
if opts.native {
|
||||
// Native mode: spawn self as daemon (Rust/CDP, no Node.js needed)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
@@ -393,71 +378,6 @@ pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult
|
||||
});
|
||||
}
|
||||
|
||||
daemon_child = Some(
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start native daemon: {}", e))?,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
let mut cmd = Command::new(&exe_path);
|
||||
cmd.env("AGENT_BROWSER_DAEMON", "1");
|
||||
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 native daemon: {}", e))?,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Default mode: spawn Node.js daemon (Playwright)
|
||||
let exe_dir = exe_path.parent().unwrap();
|
||||
|
||||
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())
|
||||
@@ -471,11 +391,8 @@ pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult
|
||||
{
|
||||
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", "*");
|
||||
let mut cmd = Command::new(&exe_path);
|
||||
cmd.env("AGENT_BROWSER_DAEMON", "1");
|
||||
apply_daemon_env(&mut cmd, session, opts);
|
||||
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
||||
@@ -490,7 +407,6 @@ pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult
|
||||
.map_err(|e| format!("Failed to start daemon: {}", e))?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for _ in 0..50 {
|
||||
if daemon_ready(session) {
|
||||
|
||||
@@ -41,7 +41,6 @@ pub struct Config {
|
||||
pub action_policy: Option<String>,
|
||||
pub confirm_actions: Option<String>,
|
||||
pub confirm_interactive: Option<bool>,
|
||||
pub native: Option<bool>,
|
||||
pub engine: Option<String>,
|
||||
pub screenshot_dir: Option<String>,
|
||||
pub screenshot_quality: Option<u32>,
|
||||
@@ -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<String>,
|
||||
pub confirm_actions: Option<String>,
|
||||
pub confirm_interactive: bool,
|
||||
pub native: bool,
|
||||
pub engine: Option<String>,
|
||||
pub screenshot_dir: Option<String>,
|
||||
pub screenshot_quality: Option<u32>,
|
||||
@@ -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<String> {
|
||||
"--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] = &[
|
||||
|
||||
+373
-60
@@ -1,11 +1,375 @@
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
#[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<Vec<u8>, 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<u8>, 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 {
|
||||
install_linux_deps();
|
||||
} else {
|
||||
println!(
|
||||
"{} Linux detected. If browser fails to launch, run:",
|
||||
color::warning_indicator()
|
||||
);
|
||||
println!(" agent-browser install --with-deps");
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
println!("{}", color::cyan("Installing Chrome..."));
|
||||
|
||||
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!(
|
||||
"{} If you see \"shared library\" errors when running, use:",
|
||||
color::yellow("Note:")
|
||||
);
|
||||
println!(" agent-browser install --with-deps");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
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") {
|
||||
@@ -122,71 +486,20 @@ pub fn run_install(with_deps: bool) {
|
||||
|
||||
match status {
|
||||
Ok(s) if s.success() => {
|
||||
println!("{} System dependencies installed", color::success_indicator())
|
||||
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),
|
||||
}
|
||||
} 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..."));
|
||||
|
||||
// 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()
|
||||
);
|
||||
if is_linux && !with_deps {
|
||||
println!();
|
||||
println!(
|
||||
"{} If you see \"shared library\" errors when running, use:",
|
||||
color::yellow("Note:")
|
||||
);
|
||||
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");
|
||||
exit(1);
|
||||
}
|
||||
Err(e) => eprintln!(
|
||||
"{} Could not run install command: {}",
|
||||
color::warning_indicator(),
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-125
@@ -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<str>, 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::<connection::Response>(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<String> = 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()
|
||||
|
||||
@@ -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<TrackedRequest>,
|
||||
pub request_tracking: bool,
|
||||
pub active_frame_id: Option<String>,
|
||||
/// Shared slot for stream server to receive CDP client when browser launches.
|
||||
pub stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>>,
|
||||
}
|
||||
|
||||
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<Arc<RwLock<Option<Arc<CdpClient>>>>>,
|
||||
) -> 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<Value, St
|
||||
if let Some(ref mut b) = state.browser {
|
||||
b.close().await?;
|
||||
state.browser = None;
|
||||
state.update_stream_client().await;
|
||||
}
|
||||
} else {
|
||||
return Ok(json!({ "launched": true, "reused": true }));
|
||||
@@ -894,18 +921,21 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
||||
if let Some(url) = cdp_url {
|
||||
state.browser = Some(BrowserManager::connect_cdp(url).await?);
|
||||
state.subscribe_to_browser_events();
|
||||
state.update_stream_client().await;
|
||||
return Ok(json!({ "launched": true }));
|
||||
}
|
||||
|
||||
if let Some(port) = cdp_port {
|
||||
state.browser = Some(BrowserManager::connect_cdp(&port.to_string()).await?);
|
||||
state.subscribe_to_browser_events();
|
||||
state.update_stream_client().await;
|
||||
return Ok(json!({ "launched": true }));
|
||||
}
|
||||
|
||||
if auto_connect {
|
||||
state.browser = Some(BrowserManager::connect_auto().await?);
|
||||
state.subscribe_to_browser_events();
|
||||
state.update_stream_client().await;
|
||||
return Ok(json!({ "launched": true }));
|
||||
}
|
||||
|
||||
@@ -923,6 +953,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
||||
Ok(mgr) => {
|
||||
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<Value, St
|
||||
|
||||
state.browser = Some(BrowserManager::launch(options, engine.as_deref()).await?);
|
||||
state.subscribe_to_browser_events();
|
||||
state.update_stream_client().await;
|
||||
|
||||
if let Some(ref filter) = state.domain_filter {
|
||||
if let Some(ref mgr) = state.browser {
|
||||
@@ -1287,6 +1319,7 @@ async fn handle_close(state: &mut DaemonState) -> Result<Value, String> {
|
||||
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<Value, Str
|
||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||
let session_id = mgr.active_session_id()?.to_string();
|
||||
|
||||
let new_tab = cmd.get("newTab").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
|
||||
if new_tab {
|
||||
use super::element::resolve_element_object_id;
|
||||
let object_id =
|
||||
resolve_element_object_id(&mgr.client, &session_id, &state.ref_map, selector).await?;
|
||||
let call_params = json!({
|
||||
"objectId": object_id,
|
||||
"functionDeclaration": "function() { var h = this.getAttribute('href'); if (!h) return null; try { return new URL(h, document.baseURI).toString(); } catch(e) { return null; } }",
|
||||
"returnByValue": true
|
||||
});
|
||||
let call_result = mgr
|
||||
.client
|
||||
.send_command(
|
||||
"Runtime.callFunctionOn",
|
||||
Some(call_params),
|
||||
Some(&session_id),
|
||||
)
|
||||
.await?;
|
||||
let href = call_result
|
||||
.get("result")
|
||||
.and_then(|r| r.get("value"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"Element '{}' does not have an href attribute. --new-tab only works on links.",
|
||||
selector
|
||||
)
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
|
||||
state.ref_map.clear();
|
||||
mgr.tab_new(Some(&href)).await?;
|
||||
|
||||
return Ok(json!({ "clicked": selector, "newTab": true, "url": href }));
|
||||
}
|
||||
|
||||
let button = cmd.get("button").and_then(|v| v.as_str()).unwrap_or("left");
|
||||
let click_count = cmd.get("clickCount").and_then(|v| v.as_i64()).unwrap_or(1) as i32;
|
||||
|
||||
@@ -5334,6 +5405,12 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_credentials_roundtrip_via_actions() {
|
||||
let _lock = crate::native::auth::AUTH_TEST_MUTEX.lock().unwrap();
|
||||
let key_var = "AGENT_BROWSER_ENCRYPTION_KEY";
|
||||
let original = std::env::var(key_var).ok();
|
||||
// SAFETY: AUTH_TEST_MUTEX serializes all test access so no concurrent mutation.
|
||||
unsafe { std::env::set_var(key_var, "a".repeat(64)) };
|
||||
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
let set_cmd = json!({
|
||||
@@ -5366,6 +5443,12 @@ mod tests {
|
||||
});
|
||||
let result = execute_command(&del_cmd, &mut state).await;
|
||||
assert_eq!(result["success"], true);
|
||||
|
||||
// SAFETY: AUTH_TEST_MUTEX serializes all test access so no concurrent mutation.
|
||||
match original {
|
||||
Some(val) => unsafe { std::env::set_var(key_var, val) },
|
||||
None => unsafe { std::env::remove_var(key_var) },
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -160,7 +160,7 @@ impl BrowserProcess {
|
||||
}
|
||||
|
||||
pub struct BrowserManager {
|
||||
pub client: CdpClient,
|
||||
pub client: Arc<CdpClient>,
|
||||
browser_process: Option<BrowserProcess>,
|
||||
ws_url: String,
|
||||
pages: Vec<PageInfo>,
|
||||
@@ -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<Self, String> {
|
||||
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(),
|
||||
|
||||
@@ -190,7 +190,7 @@ pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
|
||||
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<PathBuf> {
|
||||
// 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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(p) = find_playwright_chromium() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -383,6 +381,11 @@ pub fn find_chrome() -> Option<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<PathBuf> {
|
||||
let mut search_dirs = Vec::new();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
+109
-11
@@ -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<Arc<RwLock<Option<Arc<CdpClient>>>>> = None;
|
||||
if let Ok(port_str) = env::var("AGENT_BROWSER_STREAM_PORT") {
|
||||
if let Ok(port) = port_str.parse::<u16>() {
|
||||
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::<u64>().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<Arc<RwLock<Option<Arc<CdpClient>>>>>,
|
||||
idle_timeout_ms: Option<u64>,
|
||||
) -> 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<tokio::sync::Mutex<DaemonState>> =
|
||||
std::sync::Arc::new(tokio::sync::Mutex::new(DaemonState::new()));
|
||||
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> = 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<Arc<RwLock<Option<Arc<CdpClient>>>>>,
|
||||
idle_timeout_ms: Option<u64>,
|
||||
) -> 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<tokio::sync::Mutex<DaemonState>> =
|
||||
std::sync::Arc::new(tokio::sync::Mutex::new(DaemonState::new()));
|
||||
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> = 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<S>(stream: S, state: std::sync::Arc<tokio::sync::Mutex<DaemonState>>)
|
||||
where
|
||||
async fn handle_connection<S>(
|
||||
stream: S,
|
||||
state: std::sync::Arc<tokio::sync::Mutex<DaemonState>>,
|
||||
idle_reset_tx: Option<Arc<mpsc::Sender<()>>>,
|
||||
) 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 = {
|
||||
|
||||
@@ -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]
|
||||
|
||||
+37
-10
@@ -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<CdpClient>,
|
||||
session_id: String,
|
||||
) -> Result<Self, String> {
|
||||
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<RwLock<Option<Arc<CdpClient>>>>), String> {
|
||||
let client_slot = Arc::new(RwLock::new(None::<Arc<CdpClient>>));
|
||||
Self::start_inner(preferred_port, client_slot, session_id).await
|
||||
}
|
||||
|
||||
async fn start_inner(
|
||||
preferred_port: u16,
|
||||
client_slot: Arc<RwLock<Option<Arc<CdpClient>>>>,
|
||||
session_id: String,
|
||||
) -> Result<(Self, Arc<RwLock<Option<Arc<CdpClient>>>>), 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 {
|
||||
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<String>,
|
||||
client_count: Arc<Mutex<usize>>,
|
||||
cdp_client: Arc<CdpClient>,
|
||||
client_slot: Arc<RwLock<Option<Arc<CdpClient>>>>,
|
||||
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<String>,
|
||||
client_count: Arc<Mutex<usize>>,
|
||||
cdp_client: Arc<CdpClient>,
|
||||
client_slot: Arc<RwLock<Option<Arc<CdpClient>>>>,
|
||||
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,
|
||||
_ => {}
|
||||
|
||||
+9
-14
@@ -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 <path> 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 <operation> [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 <path.webm> [url]
|
||||
agent-browser record stop
|
||||
agent-browser record restart <path.webm> [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 <u1> <u2> 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 <path> [url] Start video recording (WebM)
|
||||
record stop Stop and save video
|
||||
@@ -2576,8 +2575,7 @@ Options:
|
||||
--action-policy <path> Action policy JSON file (or AGENT_BROWSER_ACTION_POLICY)
|
||||
--confirm-actions <list> 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 <name> 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 <name> Browser engine: chrome (default), lightpanda (or AGENT_BROWSER_ENGINE)
|
||||
--config <path> 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -81,7 +81,6 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
|
||||
<tr><td><code>confirmActions</code></td><td><code>--confirm-actions</code></td><td>string</td></tr>
|
||||
<tr><td><code>confirmInteractive</code></td><td><code>--confirm-interactive</code></td><td>boolean</td></tr>
|
||||
<tr><td><code>engine</code></td><td><code>--engine</code></td><td>string (<code>chrome</code>, <code>lightpanda</code>)</td></tr>
|
||||
<tr><td><code>native</code></td><td><code>--native</code></td><td>boolean (experimental)</td></tr>
|
||||
<tr><td><code>headers</code></td><td><code>--headers</code></td><td>string (JSON)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -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:
|
||||
<tr><td><code>AGENT_BROWSER_ALLOW_FILE_ACCESS</code></td><td>Allow <code>file://</code> URLs to access local files.</td><td>(disabled)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_COLOR_SCHEME</code></td><td>Color scheme preference (<code>dark</code>, <code>light</code>, <code>no-preference</code>).</td><td>(none)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_DOWNLOAD_PATH</code></td><td>Default directory for browser downloads.</td><td>(temp directory)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_DEFAULT_TIMEOUT</code></td><td>Default Playwright timeout in ms. Keep below 30000 to avoid IPC timeouts.</td><td><code>25000</code></td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_DEFAULT_TIMEOUT</code></td><td>Default timeout in ms. Keep below 30000 to avoid IPC timeouts.</td><td><code>25000</code></td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_SESSION_NAME</code></td><td>Auto-save/load state persistence name.</td><td>(none)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_STATE_EXPIRE_DAYS</code></td><td>Auto-delete saved session states older than N days.</td><td><code>30</code></td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_ENCRYPTION_KEY</code></td><td>64-char hex key for AES-256-GCM session encryption.</td><td>(none)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_EXTENSIONS</code></td><td>Comma-separated browser extension paths. Extensions work in both headed and headless mode.</td><td>(none)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_HEADED</code></td><td>Show browser window instead of running headless (<code>1</code> to enable).</td><td>(disabled)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_STREAM_PORT</code></td><td>Enable WebSocket streaming on the specified port (e.g., <code>9223</code>).</td><td>(disabled)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_IDLE_TIMEOUT_MS</code></td><td>Auto-shutdown the daemon after N ms of inactivity (no commands received). Useful for ephemeral environments.</td><td>(disabled)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_IOS_DEVICE</code></td><td>Default iOS device name for the <code>ios</code> provider.</td><td>(none)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_IOS_UDID</code></td><td>Default iOS device UDID for the <code>ios</code> provider.</td><td>(none)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_DEBUG</code></td><td>Enable debug output (<code>1</code> to enable).</td><td>(disabled)</td></tr>
|
||||
@@ -188,8 +188,7 @@ These environment variables configure additional daemon and runtime behavior:
|
||||
<tr><td><code>AGENT_BROWSER_ACTION_POLICY</code></td><td>Path to action policy JSON file.</td><td>(none)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_CONFIRM_ACTIONS</code></td><td>Comma-separated action categories requiring confirmation.</td><td>(none)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_CONFIRM_INTERACTIVE</code></td><td>Enable interactive confirmation prompts (auto-denies if stdin is not a TTY).</td><td>(disabled)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_ENGINE</code></td><td>Browser engine to use: <code>chrome</code> (default), <code>lightpanda</code>. Implies <code>--native</code>.</td><td><code>chrome</code></td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_NATIVE</code></td><td>Use the experimental native Rust daemon instead of Node.js/Playwright.</td><td>(disabled)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_ENGINE</code></td><td>Browser engine to use: <code>chrome</code> (default), <code>lightpanda</code>.</td><td><code>chrome</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ When no `--executable-path` is provided, agent-browser searches for Chrome in th
|
||||
<code>/Applications/Google Chrome.app</code>,
|
||||
<code>/Applications/Google Chrome Canary.app</code>,
|
||||
<code>/Applications/Chromium.app</code>,
|
||||
Playwright Chromium cache
|
||||
Chrome for Testing cache
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -31,7 +31,7 @@ When no `--executable-path` is provided, agent-browser searches for Chrome in th
|
||||
<code>google-chrome-stable</code>,
|
||||
<code>chromium-browser</code>,
|
||||
<code>chromium</code> in PATH,
|
||||
Playwright Chromium cache
|
||||
Chrome for Testing cache
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -45,7 +45,7 @@ When no `--executable-path` is provided, agent-browser searches for Chrome in th
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th></th><th>Default (Node.js)</th><th>Native (<code>--native</code>)</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><strong>Runtime</strong></td><td>Node.js + Playwright</td><td>Pure Rust binary</td></tr>
|
||||
<tr><td><strong>Protocol</strong></td><td>Playwright protocol</td><td>Direct CDP / WebDriver</td></tr>
|
||||
<tr><td><strong>Install size</strong></td><td>Larger (Node.js + npm deps)</td><td>Smaller (single binary)</td></tr>
|
||||
<tr><td><strong>Browser support</strong></td><td>Chromium, Firefox, WebKit</td><td>Chromium, Safari (via WebDriver)</td></tr>
|
||||
<tr><td><strong>Stability</strong></td><td>Stable</td><td>Experimental</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -17,7 +17,7 @@ export const PAGE_TITLES: Record<string, string> = {
|
||||
"engines/chrome": "Chrome",
|
||||
"engines/lightpanda": "Lightpanda",
|
||||
next: "Next.js + Vercel",
|
||||
"native-mode": "Native Mode (Experimental)",
|
||||
"native-mode": "Native Mode",
|
||||
changelog: "Changelog",
|
||||
};
|
||||
|
||||
|
||||
+5
-49
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-15
@@ -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('╚═══════════════════════════════════════════════════════════════════════════╝');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -104,11 +104,11 @@ agent-browser tab --url "*settings*"
|
||||
|
||||
## Webview Support
|
||||
|
||||
Electron `<webview>` 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 `<webview>` 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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
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<string>
|
||||
): 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, unknown>): 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}`;
|
||||
}
|
||||
}
|
||||
@@ -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 <a href="https://example.com/login">Anmelden</a>\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' +
|
||||
' - <body class="font-sans antialiased">...</body> 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('<div class="cookie-overlay"> intercepts pointer events');
|
||||
const result = toAIFriendlyError(error, '@e1');
|
||||
|
||||
expect(result.message).toContain('cookie banners');
|
||||
});
|
||||
});
|
||||
});
|
||||
-2930
File diff suppressed because it is too large
Load Diff
-120
@@ -1,120 +0,0 @@
|
||||
/**
|
||||
* Standalone CLI entry point for auth vault operations that don't need a browser.
|
||||
* Invoked directly by the Rust CLI to avoid sending passwords through the daemon channel.
|
||||
*
|
||||
* Usage: node auth-cli.js <json-command>
|
||||
* Prints a JSON response to stdout and exits.
|
||||
*/
|
||||
import {
|
||||
saveAuthProfile,
|
||||
getAuthProfileMeta,
|
||||
listAuthProfiles,
|
||||
deleteAuthProfile,
|
||||
} from './auth-vault.js';
|
||||
|
||||
interface AuthCommand {
|
||||
id: string;
|
||||
action: string;
|
||||
name?: string;
|
||||
url?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
usernameSelector?: string;
|
||||
passwordSelector?: string;
|
||||
submitSelector?: string;
|
||||
}
|
||||
|
||||
function success(id: string, data: Record<string, unknown>): string {
|
||||
return JSON.stringify({ success: true, id, data });
|
||||
}
|
||||
|
||||
function error(id: string, message: string): string {
|
||||
return JSON.stringify({ success: false, id, error: message });
|
||||
}
|
||||
|
||||
function run(): void {
|
||||
const input = process.argv[2];
|
||||
if (!input) {
|
||||
process.stderr.write('Usage: node auth-cli.js <json-command>\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let cmd: AuthCommand;
|
||||
try {
|
||||
cmd = JSON.parse(input);
|
||||
} catch {
|
||||
console.log(error('', 'Invalid JSON input'));
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = cmd.id || '';
|
||||
|
||||
try {
|
||||
switch (cmd.action) {
|
||||
case 'auth_save': {
|
||||
if (!cmd.name || !cmd.url || !cmd.username || !cmd.password) {
|
||||
console.log(error(id, 'Missing required fields: name, url, username, password'));
|
||||
return;
|
||||
}
|
||||
const meta = saveAuthProfile({
|
||||
name: cmd.name,
|
||||
url: cmd.url,
|
||||
username: cmd.username,
|
||||
password: cmd.password,
|
||||
usernameSelector: cmd.usernameSelector,
|
||||
passwordSelector: cmd.passwordSelector,
|
||||
submitSelector: cmd.submitSelector,
|
||||
});
|
||||
console.log(
|
||||
success(id, {
|
||||
saved: !meta.updated,
|
||||
updated: meta.updated,
|
||||
name: meta.name,
|
||||
url: meta.url,
|
||||
username: meta.username,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
case 'auth_list': {
|
||||
const profiles = listAuthProfiles();
|
||||
console.log(success(id, { profiles }));
|
||||
return;
|
||||
}
|
||||
case 'auth_show': {
|
||||
if (!cmd.name) {
|
||||
console.log(error(id, 'Missing required field: name'));
|
||||
return;
|
||||
}
|
||||
const meta = getAuthProfileMeta(cmd.name);
|
||||
if (!meta) {
|
||||
console.log(error(id, `Auth profile '${cmd.name}' not found`));
|
||||
return;
|
||||
}
|
||||
console.log(success(id, { profile: meta }));
|
||||
return;
|
||||
}
|
||||
case 'auth_delete': {
|
||||
if (!cmd.name) {
|
||||
console.log(error(id, 'Missing required field: name'));
|
||||
return;
|
||||
}
|
||||
const deleted = deleteAuthProfile(cmd.name);
|
||||
if (!deleted) {
|
||||
console.log(error(id, `Auth profile '${cmd.name}' not found`));
|
||||
return;
|
||||
}
|
||||
console.log(success(id, { deleted: true, name: cmd.name }));
|
||||
return;
|
||||
}
|
||||
default:
|
||||
console.log(error(id, `Unknown auth action: ${cmd.action}`));
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Operation failed';
|
||||
console.log(error(id, msg));
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -1,278 +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';
|
||||
|
||||
let tempHome: string;
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('os')>();
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...actual,
|
||||
homedir: () => tempHome,
|
||||
},
|
||||
homedir: () => tempHome,
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
saveAuthProfile,
|
||||
getAuthProfile,
|
||||
getAuthProfileMeta,
|
||||
listAuthProfiles,
|
||||
deleteAuthProfile,
|
||||
updateLastLogin,
|
||||
} from './auth-vault.js';
|
||||
|
||||
describe('auth-vault', () => {
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-browser-auth-test-'));
|
||||
delete process.env.AGENT_BROWSER_ENCRYPTION_KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
function cleanAuthDir() {
|
||||
const authDir = path.join(tempHome, '.agent-browser', 'auth');
|
||||
if (fs.existsSync(authDir)) {
|
||||
for (const f of fs.readdirSync(authDir)) {
|
||||
fs.unlinkSync(path.join(authDir, f));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('saveAuthProfile', () => {
|
||||
it('should save a new profile', () => {
|
||||
const result = saveAuthProfile({
|
||||
name: 'github',
|
||||
url: 'https://github.com/login',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
});
|
||||
|
||||
expect(result.name).toBe('github');
|
||||
expect(result.url).toBe('https://github.com/login');
|
||||
expect(result.username).toBe('user');
|
||||
expect(result.updated).toBe(false);
|
||||
expect(result.createdAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should mark as updated when overwriting', () => {
|
||||
saveAuthProfile({
|
||||
name: 'github',
|
||||
url: 'https://github.com/login',
|
||||
username: 'user1',
|
||||
password: 'pass1',
|
||||
});
|
||||
|
||||
const result = saveAuthProfile({
|
||||
name: 'github',
|
||||
url: 'https://github.com/login',
|
||||
username: 'user2',
|
||||
password: 'pass2',
|
||||
});
|
||||
|
||||
expect(result.updated).toBe(true);
|
||||
expect(result.username).toBe('user2');
|
||||
});
|
||||
|
||||
it('should preserve createdAt on update', () => {
|
||||
const first = saveAuthProfile({
|
||||
name: 'github',
|
||||
url: 'https://github.com/login',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
});
|
||||
|
||||
const second = saveAuthProfile({
|
||||
name: 'github',
|
||||
url: 'https://github.com/login',
|
||||
username: 'user2',
|
||||
password: 'pass2',
|
||||
});
|
||||
|
||||
expect(second.createdAt).toBe(first.createdAt);
|
||||
});
|
||||
|
||||
it('should save with custom selectors', () => {
|
||||
saveAuthProfile({
|
||||
name: 'myapp',
|
||||
url: 'https://example.com/login',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
usernameSelector: '#email',
|
||||
passwordSelector: '#password',
|
||||
submitSelector: 'button.login',
|
||||
});
|
||||
|
||||
const profile = getAuthProfile('myapp');
|
||||
expect(profile).not.toBeNull();
|
||||
expect(profile!.usernameSelector).toBe('#email');
|
||||
expect(profile!.passwordSelector).toBe('#password');
|
||||
expect(profile!.submitSelector).toBe('button.login');
|
||||
});
|
||||
|
||||
it('should reject invalid profile names', () => {
|
||||
expect(() =>
|
||||
saveAuthProfile({
|
||||
name: '../escape',
|
||||
url: 'https://example.com',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
})
|
||||
).toThrow('only alphanumeric');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAuthProfile', () => {
|
||||
it('should return null for non-existent profile', () => {
|
||||
expect(getAuthProfile('nonexistent')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return full profile with password', () => {
|
||||
saveAuthProfile({
|
||||
name: 'test',
|
||||
url: 'https://example.com',
|
||||
username: 'user',
|
||||
password: 'secret',
|
||||
});
|
||||
|
||||
const profile = getAuthProfile('test');
|
||||
expect(profile).not.toBeNull();
|
||||
expect(profile!.password).toBe('secret');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAuthProfileMeta', () => {
|
||||
it('should return metadata without password', () => {
|
||||
saveAuthProfile({
|
||||
name: 'test',
|
||||
url: 'https://example.com',
|
||||
username: 'user',
|
||||
password: 'secret',
|
||||
});
|
||||
|
||||
const meta = getAuthProfileMeta('test');
|
||||
expect(meta).not.toBeNull();
|
||||
expect(meta!.name).toBe('test');
|
||||
expect(meta!.username).toBe('user');
|
||||
expect((meta as Record<string, unknown>).password).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return null for non-existent profile', () => {
|
||||
expect(getAuthProfileMeta('nonexistent')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listAuthProfiles', () => {
|
||||
it('should return empty array when no profiles', () => {
|
||||
cleanAuthDir();
|
||||
expect(listAuthProfiles()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should list all saved profiles', () => {
|
||||
cleanAuthDir();
|
||||
saveAuthProfile({
|
||||
name: 'github',
|
||||
url: 'https://github.com/login',
|
||||
username: 'user1',
|
||||
password: 'pass1',
|
||||
});
|
||||
saveAuthProfile({
|
||||
name: 'gitlab',
|
||||
url: 'https://gitlab.com/login',
|
||||
username: 'user2',
|
||||
password: 'pass2',
|
||||
});
|
||||
|
||||
const profiles = listAuthProfiles();
|
||||
expect(profiles).toHaveLength(2);
|
||||
const names = profiles.map((p) => p.name).sort();
|
||||
expect(names).toEqual(['github', 'gitlab']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAuthProfile', () => {
|
||||
it('should delete an existing profile', () => {
|
||||
saveAuthProfile({
|
||||
name: 'test',
|
||||
url: 'https://example.com',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
});
|
||||
|
||||
expect(deleteAuthProfile('test')).toBe(true);
|
||||
expect(getAuthProfile('test')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return false for non-existent profile', () => {
|
||||
expect(deleteAuthProfile('nonexistent')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateLastLogin', () => {
|
||||
it('should update lastLoginAt timestamp', () => {
|
||||
saveAuthProfile({
|
||||
name: 'test',
|
||||
url: 'https://example.com',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
});
|
||||
|
||||
const metaBefore = getAuthProfileMeta('test');
|
||||
expect(metaBefore!.lastLoginAt).toBeUndefined();
|
||||
|
||||
updateLastLogin('test');
|
||||
|
||||
const metaAfter = getAuthProfileMeta('test');
|
||||
expect(metaAfter!.lastLoginAt).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto-generated encryption key', () => {
|
||||
it('should auto-create key file and encrypt profile when no env var is set', () => {
|
||||
delete process.env.AGENT_BROWSER_ENCRYPTION_KEY;
|
||||
|
||||
saveAuthProfile({
|
||||
name: 'autokey',
|
||||
url: 'https://example.com',
|
||||
username: 'user',
|
||||
password: 'secret',
|
||||
});
|
||||
|
||||
const keyFilePath = path.join(tempHome, '.agent-browser', '.encryption-key');
|
||||
expect(fs.existsSync(keyFilePath)).toBe(true);
|
||||
|
||||
const keyHex = fs.readFileSync(keyFilePath, 'utf-8').trim();
|
||||
expect(keyHex).toMatch(/^[a-f0-9]{64}$/);
|
||||
|
||||
const profilePath = path.join(tempHome, '.agent-browser', 'auth', 'autokey.json');
|
||||
const raw = JSON.parse(fs.readFileSync(profilePath, 'utf-8'));
|
||||
expect(raw.encrypted).toBe(true);
|
||||
expect(raw.iv).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should read back profile using auto-generated key', () => {
|
||||
delete process.env.AGENT_BROWSER_ENCRYPTION_KEY;
|
||||
|
||||
saveAuthProfile({
|
||||
name: 'readback',
|
||||
url: 'https://example.com',
|
||||
username: 'user',
|
||||
password: 'secret123',
|
||||
});
|
||||
|
||||
const profile = getAuthProfile('readback');
|
||||
expect(profile).not.toBeNull();
|
||||
expect(profile!.password).toBe('secret123');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,189 +0,0 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
readdirSync,
|
||||
unlinkSync,
|
||||
} from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import {
|
||||
getEncryptionKey,
|
||||
ensureEncryptionKey,
|
||||
encryptData,
|
||||
decryptData,
|
||||
isEncryptedPayload,
|
||||
getKeyFilePath,
|
||||
restrictFilePermissions,
|
||||
restrictDirPermissions,
|
||||
type EncryptedPayload,
|
||||
} from './encryption.js';
|
||||
|
||||
const AUTH_DIR = 'auth';
|
||||
|
||||
interface AuthProfile {
|
||||
name: string;
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
usernameSelector?: string;
|
||||
passwordSelector?: string;
|
||||
submitSelector?: string;
|
||||
createdAt: string;
|
||||
lastLoginAt?: string;
|
||||
}
|
||||
|
||||
export interface AuthProfileMeta {
|
||||
name: string;
|
||||
url: string;
|
||||
username: string;
|
||||
createdAt: string;
|
||||
lastLoginAt?: string;
|
||||
}
|
||||
|
||||
function getAuthDir(): string {
|
||||
const dir = path.join(os.homedir(), '.agent-browser', AUTH_DIR);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
restrictDirPermissions(dir);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
const SAFE_NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
function validateProfileName(name: string): void {
|
||||
if (!SAFE_NAME_RE.test(name)) {
|
||||
throw new Error(
|
||||
`Invalid auth profile name '${name}': only alphanumeric characters, hyphens, and underscores are allowed`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function profilePath(name: string): string {
|
||||
validateProfileName(name);
|
||||
return path.join(getAuthDir(), `${name}.json`);
|
||||
}
|
||||
|
||||
function readProfile(name: string): AuthProfile | null {
|
||||
const p = profilePath(name);
|
||||
if (!existsSync(p)) return null;
|
||||
|
||||
const raw = readFileSync(p, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
|
||||
if (isEncryptedPayload(parsed)) {
|
||||
const key = getEncryptionKey();
|
||||
if (!key) {
|
||||
throw new Error(
|
||||
`Encryption key required to read encrypted auth profiles. ` +
|
||||
`Set AGENT_BROWSER_ENCRYPTION_KEY or ensure ${getKeyFilePath()} exists.`
|
||||
);
|
||||
}
|
||||
const decrypted = decryptData(parsed as EncryptedPayload, key);
|
||||
return JSON.parse(decrypted) as AuthProfile;
|
||||
}
|
||||
|
||||
return parsed as AuthProfile;
|
||||
}
|
||||
|
||||
function writeProfile(profile: AuthProfile): void {
|
||||
const key = ensureEncryptionKey();
|
||||
const serialized = JSON.stringify(profile, null, 2);
|
||||
const encrypted = encryptData(serialized, key);
|
||||
const filePath = profilePath(profile.name);
|
||||
writeFileSync(filePath, JSON.stringify(encrypted, null, 2), {
|
||||
mode: 0o600,
|
||||
});
|
||||
restrictFilePermissions(filePath);
|
||||
}
|
||||
|
||||
export function saveAuthProfile(opts: {
|
||||
name: string;
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
usernameSelector?: string;
|
||||
passwordSelector?: string;
|
||||
submitSelector?: string;
|
||||
}): AuthProfileMeta & { updated: boolean } {
|
||||
const existing = readProfile(opts.name);
|
||||
|
||||
const profile: AuthProfile = {
|
||||
name: opts.name,
|
||||
url: opts.url,
|
||||
username: opts.username,
|
||||
password: opts.password,
|
||||
usernameSelector: opts.usernameSelector,
|
||||
passwordSelector: opts.passwordSelector,
|
||||
submitSelector: opts.submitSelector,
|
||||
createdAt: existing?.createdAt ?? new Date().toISOString(),
|
||||
lastLoginAt: existing?.lastLoginAt,
|
||||
};
|
||||
|
||||
writeProfile(profile);
|
||||
|
||||
return {
|
||||
name: profile.name,
|
||||
url: profile.url,
|
||||
username: profile.username,
|
||||
createdAt: profile.createdAt,
|
||||
lastLoginAt: profile.lastLoginAt,
|
||||
updated: existing !== null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAuthProfile(name: string): AuthProfile | null {
|
||||
return readProfile(name);
|
||||
}
|
||||
|
||||
export function getAuthProfileMeta(name: string): AuthProfileMeta | null {
|
||||
const profile = readProfile(name);
|
||||
if (!profile) return null;
|
||||
return {
|
||||
name: profile.name,
|
||||
url: profile.url,
|
||||
username: profile.username,
|
||||
createdAt: profile.createdAt,
|
||||
lastLoginAt: profile.lastLoginAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function listAuthProfiles(): AuthProfileMeta[] {
|
||||
const dir = getAuthDir();
|
||||
const files = readdirSync(dir).filter((f) => f.endsWith('.json'));
|
||||
const profiles: AuthProfileMeta[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const name = file.replace(/\.json$/, '');
|
||||
try {
|
||||
const meta = getAuthProfileMeta(name);
|
||||
if (meta) profiles.push(meta);
|
||||
} catch {
|
||||
profiles.push({
|
||||
name,
|
||||
url: '(encrypted)',
|
||||
username: '(encrypted)',
|
||||
createdAt: '(unknown)',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return profiles;
|
||||
}
|
||||
|
||||
export function deleteAuthProfile(name: string): boolean {
|
||||
const p = profilePath(name);
|
||||
if (!existsSync(p)) return false;
|
||||
unlinkSync(p);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function updateLastLogin(name: string): void {
|
||||
const profile = readProfile(name);
|
||||
if (profile) {
|
||||
profile.lastLoginAt = new Date().toISOString();
|
||||
writeProfile(profile);
|
||||
}
|
||||
}
|
||||
-1414
File diff suppressed because it is too large
Load Diff
-2834
File diff suppressed because it is too large
Load Diff
@@ -1,67 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { requestConfirmation, getAndRemovePending } from './confirmation.js';
|
||||
|
||||
describe('confirmation', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('requestConfirmation', () => {
|
||||
it('should return a confirmation ID', () => {
|
||||
const result = requestConfirmation('evaluate', 'eval', 'Evaluate JS', { script: 'test' });
|
||||
expect(result.confirmationId).toBeTruthy();
|
||||
expect(result.confirmationId).toMatch(/^c_[0-9a-f]{16}$/);
|
||||
});
|
||||
|
||||
it('should generate unique IDs', () => {
|
||||
const r1 = requestConfirmation('evaluate', 'eval', 'desc', {});
|
||||
const r2 = requestConfirmation('click', 'click', 'desc', {});
|
||||
expect(r1.confirmationId).not.toBe(r2.confirmationId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAndRemovePending', () => {
|
||||
it('should retrieve and remove a pending confirmation', () => {
|
||||
const { confirmationId } = requestConfirmation('evaluate', 'eval', 'desc', {
|
||||
action: 'evaluate',
|
||||
script: 'test',
|
||||
});
|
||||
|
||||
const entry = getAndRemovePending(confirmationId);
|
||||
expect(entry).not.toBeNull();
|
||||
expect(entry!.action).toBe('evaluate');
|
||||
expect(entry!.command).toEqual({ action: 'evaluate', script: 'test' });
|
||||
});
|
||||
|
||||
it('should return null on second retrieval (already removed)', () => {
|
||||
const { confirmationId } = requestConfirmation('evaluate', 'eval', 'desc', {});
|
||||
getAndRemovePending(confirmationId);
|
||||
expect(getAndRemovePending(confirmationId)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for non-existent ID', () => {
|
||||
expect(getAndRemovePending('c_nonexistent')).toBeNull();
|
||||
});
|
||||
|
||||
it('should auto-deny after 60 seconds', () => {
|
||||
const { confirmationId } = requestConfirmation('evaluate', 'eval', 'desc', {});
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
|
||||
expect(getAndRemovePending(confirmationId)).toBeNull();
|
||||
});
|
||||
|
||||
it('should still be retrievable before 60 second timeout', () => {
|
||||
const { confirmationId } = requestConfirmation('evaluate', 'eval', 'desc', {});
|
||||
|
||||
vi.advanceTimersByTime(59_999);
|
||||
|
||||
const entry = getAndRemovePending(confirmationId);
|
||||
expect(entry).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
interface PendingConfirmation {
|
||||
id: string;
|
||||
action: string;
|
||||
category: string;
|
||||
description: string;
|
||||
command: Record<string, unknown>;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
const AUTO_DENY_TIMEOUT_MS = 60_000;
|
||||
|
||||
const pending = new Map<string, PendingConfirmation>();
|
||||
|
||||
function generateId(): string {
|
||||
return `c_${randomBytes(8).toString('hex')}`;
|
||||
}
|
||||
|
||||
export function requestConfirmation(
|
||||
action: string,
|
||||
category: string,
|
||||
description: string,
|
||||
command: Record<string, unknown>
|
||||
): { confirmationId: string } {
|
||||
const id = generateId();
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
}, AUTO_DENY_TIMEOUT_MS);
|
||||
|
||||
pending.set(id, {
|
||||
id,
|
||||
action,
|
||||
category,
|
||||
description,
|
||||
command,
|
||||
timer,
|
||||
});
|
||||
|
||||
return { confirmationId: id };
|
||||
}
|
||||
|
||||
export function getAndRemovePending(
|
||||
id: string
|
||||
): { command: Record<string, unknown>; action: string } | null {
|
||||
const entry = pending.get(id);
|
||||
if (!entry) return null;
|
||||
|
||||
clearTimeout(entry.timer);
|
||||
pending.delete(id);
|
||||
return { command: entry.command, action: entry.action };
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as net from 'net';
|
||||
import { EventEmitter } from 'events';
|
||||
import { getSocketDir, safeWrite, getPortForSession } from './daemon.js';
|
||||
|
||||
/**
|
||||
* HTTP request detection pattern used in daemon.ts to prevent cross-origin attacks.
|
||||
* This pattern detects HTTP method prefixes that browsers must send when using fetch().
|
||||
*/
|
||||
const HTTP_REQUEST_PATTERN = /^(GET|POST|PUT|DELETE|HEAD|OPTIONS|PATCH|CONNECT|TRACE)\s/i;
|
||||
|
||||
describe('HTTP request detection (security)', () => {
|
||||
it('should detect POST requests from fetch()', () => {
|
||||
const httpRequest = 'POST / HTTP/1.1\r\nHost: 127.0.0.1:51234\r\n';
|
||||
expect(HTTP_REQUEST_PATTERN.test(httpRequest.trimStart())).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect GET requests', () => {
|
||||
expect(HTTP_REQUEST_PATTERN.test('GET / HTTP/1.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect OPTIONS preflight requests', () => {
|
||||
expect(HTTP_REQUEST_PATTERN.test('OPTIONS / HTTP/1.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT detect valid JSON commands', () => {
|
||||
const jsonCommand = '{"id":"1","action":"navigate","url":"https://example.com"}';
|
||||
expect(HTTP_REQUEST_PATTERN.test(jsonCommand.trimStart())).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT detect JSON with leading whitespace', () => {
|
||||
const jsonCommand = ' {"id":"1","action":"click","selector":"button"}';
|
||||
expect(HTTP_REQUEST_PATTERN.test(jsonCommand.trimStart())).toBe(false);
|
||||
});
|
||||
|
||||
it('should be case insensitive for HTTP methods', () => {
|
||||
expect(HTTP_REQUEST_PATTERN.test('post / HTTP/1.1')).toBe(true);
|
||||
expect(HTTP_REQUEST_PATTERN.test('Post / HTTP/1.1')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSocketDir', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear relevant env vars before each test
|
||||
delete process.env.AGENT_BROWSER_SOCKET_DIR;
|
||||
delete process.env.XDG_RUNTIME_DIR;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original env
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
describe('AGENT_BROWSER_SOCKET_DIR', () => {
|
||||
it('should use custom path when set', () => {
|
||||
process.env.AGENT_BROWSER_SOCKET_DIR = '/custom/socket/path';
|
||||
expect(getSocketDir()).toBe('/custom/socket/path');
|
||||
});
|
||||
|
||||
it('should ignore empty string', () => {
|
||||
process.env.AGENT_BROWSER_SOCKET_DIR = '';
|
||||
const result = getSocketDir();
|
||||
expect(result).toContain('.agent-browser');
|
||||
});
|
||||
|
||||
it('should take priority over XDG_RUNTIME_DIR', () => {
|
||||
process.env.AGENT_BROWSER_SOCKET_DIR = '/custom/path';
|
||||
process.env.XDG_RUNTIME_DIR = '/run/user/1000';
|
||||
expect(getSocketDir()).toBe('/custom/path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('XDG_RUNTIME_DIR', () => {
|
||||
it('should use when AGENT_BROWSER_SOCKET_DIR is not set', () => {
|
||||
process.env.XDG_RUNTIME_DIR = '/run/user/1000';
|
||||
expect(getSocketDir()).toBe('/run/user/1000/agent-browser');
|
||||
});
|
||||
|
||||
it('should ignore empty string', () => {
|
||||
process.env.AGENT_BROWSER_SOCKET_DIR = '';
|
||||
process.env.XDG_RUNTIME_DIR = '';
|
||||
const result = getSocketDir();
|
||||
expect(result).toContain('.agent-browser');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fallback', () => {
|
||||
it('should use home directory when env vars are not set', () => {
|
||||
const result = getSocketDir();
|
||||
const expected = path.join(os.homedir(), '.agent-browser');
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createMockSocket(opts: { destroyed?: boolean; writeReturns?: boolean } = {}) {
|
||||
const emitter = new EventEmitter();
|
||||
const socket = Object.assign(emitter, {
|
||||
destroyed: opts.destroyed ?? false,
|
||||
write: vi.fn().mockReturnValue(opts.writeReturns ?? true),
|
||||
removeListener: emitter.removeListener.bind(emitter),
|
||||
});
|
||||
return socket as unknown as net.Socket;
|
||||
}
|
||||
|
||||
describe('safeWrite', () => {
|
||||
it('should resolve immediately when socket.write returns true', async () => {
|
||||
const socket = createMockSocket({ writeReturns: true });
|
||||
await safeWrite(socket, 'hello\n');
|
||||
expect(socket.write).toHaveBeenCalledWith('hello\n');
|
||||
});
|
||||
|
||||
it('should resolve immediately when socket is already destroyed', async () => {
|
||||
const socket = createMockSocket({ destroyed: true });
|
||||
await safeWrite(socket, 'hello\n');
|
||||
expect(socket.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should wait for drain event when socket.write returns false', async () => {
|
||||
const socket = createMockSocket({ writeReturns: false });
|
||||
const promise = safeWrite(socket, 'big payload');
|
||||
|
||||
// Simulate drain after a tick
|
||||
setTimeout(() => socket.emit('drain'), 0);
|
||||
await promise;
|
||||
|
||||
expect(socket.write).toHaveBeenCalledWith('big payload');
|
||||
});
|
||||
|
||||
it('should reject on socket error while waiting for drain', async () => {
|
||||
const socket = createMockSocket({ writeReturns: false });
|
||||
const promise = safeWrite(socket, 'data');
|
||||
|
||||
setTimeout(() => socket.emit('error', new Error('connection reset')), 0);
|
||||
await expect(promise).rejects.toThrow('connection reset');
|
||||
});
|
||||
|
||||
it('should resolve on socket close while waiting for drain', async () => {
|
||||
const socket = createMockSocket({ writeReturns: false });
|
||||
const promise = safeWrite(socket, 'data');
|
||||
|
||||
setTimeout(() => socket.emit('close'), 0);
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('should clean up listeners after drain resolves', async () => {
|
||||
const socket = createMockSocket({ writeReturns: false });
|
||||
const promise = safeWrite(socket, 'data');
|
||||
|
||||
setTimeout(() => socket.emit('drain'), 0);
|
||||
await promise;
|
||||
|
||||
expect(socket.listenerCount('drain')).toBe(0);
|
||||
expect(socket.listenerCount('error')).toBe(0);
|
||||
expect(socket.listenerCount('close')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPortForSession', () => {
|
||||
it('returns consistent port for "default"', () => {
|
||||
expect(getPortForSession('default')).toBe(50838);
|
||||
});
|
||||
|
||||
it('returns consistent port for named sessions', () => {
|
||||
expect(getPortForSession('my-session')).toBe(63105);
|
||||
expect(getPortForSession('work')).toBe(51184);
|
||||
});
|
||||
|
||||
it('returns base port for empty session', () => {
|
||||
expect(getPortForSession('')).toBe(49152);
|
||||
});
|
||||
|
||||
it('returns port within dynamic range (49152-65535)', () => {
|
||||
for (const name of ['default', 'my-session', 'work', 'test', 'a']) {
|
||||
const port = getPortForSession(name);
|
||||
expect(port).toBeGreaterThanOrEqual(49152);
|
||||
expect(port).toBeLessThanOrEqual(65535);
|
||||
}
|
||||
});
|
||||
});
|
||||
-772
@@ -1,772 +0,0 @@
|
||||
import * as net from 'net';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { BrowserManager } from './browser.js';
|
||||
import { IOSManager } from './ios-manager.js';
|
||||
import { parseCommand, serializeResponse, errorResponse } from './protocol.js';
|
||||
import { executeCommand, initActionPolicy } from './actions.js';
|
||||
import { executeIOSCommand } from './ios-actions.js';
|
||||
import { StreamServer } from './stream-server.js';
|
||||
import {
|
||||
getSessionsDir,
|
||||
ensureSessionsDir,
|
||||
getEncryptionKey,
|
||||
encryptData,
|
||||
isValidSessionName,
|
||||
cleanupExpiredStates,
|
||||
getAutoStateFilePath,
|
||||
} from './state-utils.js';
|
||||
|
||||
// Manager type - either desktop browser or iOS
|
||||
type Manager = BrowserManager | IOSManager;
|
||||
|
||||
/**
|
||||
* Backpressure-aware socket write.
|
||||
* If the kernel buffer is full (socket.write returns false),
|
||||
* waits for the 'drain' event before resolving.
|
||||
*/
|
||||
export function safeWrite(socket: net.Socket, payload: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (socket.destroyed) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const canContinue = socket.write(payload);
|
||||
if (canContinue) {
|
||||
resolve();
|
||||
} else if (socket.destroyed) {
|
||||
resolve();
|
||||
} else {
|
||||
const cleanup = () => {
|
||||
socket.removeListener('drain', onDrain);
|
||||
socket.removeListener('error', onError);
|
||||
socket.removeListener('close', onClose);
|
||||
};
|
||||
const onDrain = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onError = (err: Error) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
};
|
||||
const onClose = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
socket.once('drain', onDrain);
|
||||
socket.once('error', onError);
|
||||
socket.once('close', onClose);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Platform detection
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
// Session support - each session gets its own socket/pid
|
||||
let currentSession = process.env.AGENT_BROWSER_SESSION || 'default';
|
||||
|
||||
// Stream server for browser preview
|
||||
let streamServer: StreamServer | null = null;
|
||||
|
||||
// Idle timeout - shut down daemon after period of inactivity
|
||||
// Configurable via AGENT_BROWSER_IDLE_TIMEOUT_MS env var (default: 15 minutes, 0 to disable)
|
||||
const DEFAULT_IDLE_TIMEOUT_MS = 15 * 60 * 1000;
|
||||
const IDLE_TIMEOUT_MS = (() => {
|
||||
const env = process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS;
|
||||
if (env !== undefined) {
|
||||
const val = parseInt(env, 10);
|
||||
return isNaN(val) ? DEFAULT_IDLE_TIMEOUT_MS : val;
|
||||
}
|
||||
return DEFAULT_IDLE_TIMEOUT_MS;
|
||||
})();
|
||||
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Default stream port (can be overridden with AGENT_BROWSER_STREAM_PORT)
|
||||
const DEFAULT_STREAM_PORT = 9223;
|
||||
|
||||
/**
|
||||
* Save state to file with optional encryption.
|
||||
*/
|
||||
async function saveStateToFile(
|
||||
browser: BrowserManager,
|
||||
filepath: string
|
||||
): Promise<{ encrypted: boolean }> {
|
||||
const context = browser.getContext();
|
||||
if (!context) {
|
||||
throw new Error('No browser context available');
|
||||
}
|
||||
|
||||
const state = await context.storageState();
|
||||
const jsonData = JSON.stringify(state, null, 2);
|
||||
|
||||
const key = getEncryptionKey();
|
||||
if (key) {
|
||||
const encrypted = encryptData(jsonData, key);
|
||||
fs.writeFileSync(filepath, JSON.stringify(encrypted, null, 2));
|
||||
return { encrypted: true };
|
||||
}
|
||||
|
||||
fs.writeFileSync(filepath, jsonData);
|
||||
return { encrypted: false };
|
||||
}
|
||||
|
||||
const AUTO_EXPIRE_ENV = 'AGENT_BROWSER_STATE_EXPIRE_DAYS';
|
||||
const DEFAULT_EXPIRE_DAYS = 30;
|
||||
|
||||
function runCleanupExpiredStates(): void {
|
||||
const expireDaysStr = process.env[AUTO_EXPIRE_ENV];
|
||||
const expireDays = expireDaysStr ? parseInt(expireDaysStr, 10) : DEFAULT_EXPIRE_DAYS;
|
||||
|
||||
if (isNaN(expireDays) || expireDays <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const deleted = cleanupExpiredStates(expireDays);
|
||||
if (deleted.length > 0 && process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error(
|
||||
`[DEBUG] Auto-expired ${deleted.length} state file(s) older than ${expireDays} days`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error(`[DEBUG] Failed to clean up expired states:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validated session name and auto-state file path.
|
||||
* Centralizes session name validation to prevent path traversal.
|
||||
*/
|
||||
function getSessionAutoStatePath(): string | undefined {
|
||||
const sessionNameRaw = process.env.AGENT_BROWSER_SESSION_NAME;
|
||||
if (!sessionNameRaw) return undefined;
|
||||
|
||||
if (!isValidSessionName(sessionNameRaw)) {
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error(`[SECURITY] Invalid session name rejected: ${sessionNameRaw}`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sessionId = process.env.AGENT_BROWSER_SESSION || 'default';
|
||||
try {
|
||||
const autoStatePath = getAutoStateFilePath(sessionNameRaw, sessionId);
|
||||
return autoStatePath && fs.existsSync(autoStatePath) ? autoStatePath : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the auto-state file path for saving (creates sessions dir if needed).
|
||||
* Returns undefined if no valid session name is configured.
|
||||
*/
|
||||
function getSessionSaveStatePath(): string | undefined {
|
||||
const sessionNameRaw = process.env.AGENT_BROWSER_SESSION_NAME;
|
||||
if (!sessionNameRaw) return undefined;
|
||||
|
||||
if (!isValidSessionName(sessionNameRaw)) return undefined;
|
||||
|
||||
const sessionId = process.env.AGENT_BROWSER_SESSION || 'default';
|
||||
try {
|
||||
return getAutoStateFilePath(sessionNameRaw, sessionId) ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current session
|
||||
*/
|
||||
export function setSession(session: string): void {
|
||||
currentSession = session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current session
|
||||
*/
|
||||
export function getSession(): string {
|
||||
return currentSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get port number for TCP mode (Windows)
|
||||
* Uses a hash of the session name to get a consistent port
|
||||
*/
|
||||
export function getPortForSession(session: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < session.length; i++) {
|
||||
hash = (hash << 5) - hash + session.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
// Port range 49152-65535 (dynamic/private ports)
|
||||
return 49152 + (Math.abs(hash) % 16383);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the base directory for socket/pid files.
|
||||
* Priority: AGENT_BROWSER_SOCKET_DIR > XDG_RUNTIME_DIR > ~/.agent-browser > tmpdir
|
||||
*/
|
||||
export function getAppDir(): string {
|
||||
// 1. XDG_RUNTIME_DIR (Linux standard)
|
||||
if (process.env.XDG_RUNTIME_DIR) {
|
||||
return path.join(process.env.XDG_RUNTIME_DIR, 'agent-browser');
|
||||
}
|
||||
|
||||
// 2. Home directory fallback (like Docker Desktop's ~/.docker/run/)
|
||||
const homeDir = os.homedir();
|
||||
if (homeDir) {
|
||||
return path.join(homeDir, '.agent-browser');
|
||||
}
|
||||
|
||||
// 3. Last resort: temp dir
|
||||
return path.join(os.tmpdir(), 'agent-browser');
|
||||
}
|
||||
|
||||
export function getSocketDir(): string {
|
||||
// Allow explicit override for socket directory
|
||||
if (process.env.AGENT_BROWSER_SOCKET_DIR) {
|
||||
return process.env.AGENT_BROWSER_SOCKET_DIR;
|
||||
}
|
||||
return getAppDir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the socket path for the current session (Unix) or port (Windows)
|
||||
*/
|
||||
export function getSocketPath(session?: string): string {
|
||||
const sess = session ?? currentSession;
|
||||
if (isWindows) {
|
||||
return String(getPortForSession(sess));
|
||||
}
|
||||
return path.join(getSocketDir(), `${sess}.sock`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the port file path for Windows (stores the port number)
|
||||
*/
|
||||
export function getPortFile(session?: string): string {
|
||||
const sess = session ?? currentSession;
|
||||
return path.join(getSocketDir(), `${sess}.port`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the PID file path for the current session
|
||||
*/
|
||||
export function getPidFile(session?: string): string {
|
||||
const sess = session ?? currentSession;
|
||||
return path.join(getSocketDir(), `${sess}.pid`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if daemon is running for the current session
|
||||
*/
|
||||
export function isDaemonRunning(session?: string): boolean {
|
||||
const pidFile = getPidFile(session);
|
||||
if (!fs.existsSync(pidFile)) return false;
|
||||
|
||||
try {
|
||||
const pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
|
||||
// Check if process exists (works on both Unix and Windows)
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err: unknown) {
|
||||
// EPERM means the process exists but we lack permission to signal it
|
||||
// (e.g. caller is inside a macOS sandbox). Only ESRCH means it's gone.
|
||||
if (err instanceof Error && (err as NodeJS.ErrnoException).code === 'EPERM') {
|
||||
return true;
|
||||
}
|
||||
// Process doesn't exist, clean up stale files
|
||||
cleanupSocket(session);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection info for the current session
|
||||
* Returns { type: 'unix', path: string } or { type: 'tcp', port: number }
|
||||
*/
|
||||
export function getConnectionInfo(
|
||||
session?: string
|
||||
): { type: 'unix'; path: string } | { type: 'tcp'; port: number } {
|
||||
const sess = session ?? currentSession;
|
||||
if (isWindows) {
|
||||
return { type: 'tcp', port: getPortForSession(sess) };
|
||||
}
|
||||
return { type: 'unix', path: path.join(getSocketDir(), `${sess}.sock`) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up socket and PID file for the current session
|
||||
*/
|
||||
export function cleanupSocket(session?: string): void {
|
||||
const pidFile = getPidFile(session);
|
||||
const streamPortFile = getStreamPortFile(session);
|
||||
try {
|
||||
if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile);
|
||||
if (fs.existsSync(streamPortFile)) fs.unlinkSync(streamPortFile);
|
||||
if (isWindows) {
|
||||
const portFile = getPortFile(session);
|
||||
if (fs.existsSync(portFile)) fs.unlinkSync(portFile);
|
||||
} else {
|
||||
const socketPath = getSocketPath(session);
|
||||
if (fs.existsSync(socketPath)) fs.unlinkSync(socketPath);
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the stream port file path
|
||||
*/
|
||||
export function getStreamPortFile(session?: string): string {
|
||||
const sess = session ?? currentSession;
|
||||
return path.join(getSocketDir(), `${sess}.stream`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the daemon server
|
||||
* @param options.streamPort Port for WebSocket stream server (0 to disable)
|
||||
* @param options.provider Provider type ('ios' for iOS Simulator, undefined for desktop)
|
||||
*/
|
||||
export async function startDaemon(options?: {
|
||||
streamPort?: number;
|
||||
provider?: string;
|
||||
}): Promise<void> {
|
||||
// Ensure socket directory exists with restricted permissions (owner-only access)
|
||||
const socketDir = getSocketDir();
|
||||
if (!fs.existsSync(socketDir)) {
|
||||
fs.mkdirSync(socketDir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
// Clean up any stale socket
|
||||
cleanupSocket();
|
||||
|
||||
// Clean up expired state files on startup
|
||||
runCleanupExpiredStates();
|
||||
|
||||
// Initialize action policy enforcement
|
||||
initActionPolicy();
|
||||
|
||||
// Determine provider from options or environment
|
||||
const provider = options?.provider ?? process.env.AGENT_BROWSER_PROVIDER;
|
||||
const isIOS = provider === 'ios';
|
||||
|
||||
// Create appropriate manager
|
||||
const manager: Manager = isIOS ? new IOSManager() : new BrowserManager();
|
||||
let shuttingDown = false;
|
||||
|
||||
// Start stream server if port is specified (or use default if env var is set)
|
||||
// Note: Stream server only works with BrowserManager (desktop), not iOS
|
||||
const streamPort =
|
||||
options?.streamPort ??
|
||||
(process.env.AGENT_BROWSER_STREAM_PORT
|
||||
? parseInt(process.env.AGENT_BROWSER_STREAM_PORT, 10)
|
||||
: 0);
|
||||
|
||||
if (streamPort > 0 && !isIOS && manager instanceof BrowserManager) {
|
||||
streamServer = new StreamServer(manager, streamPort);
|
||||
await streamServer.start();
|
||||
|
||||
// Write stream port to file for clients to discover
|
||||
const streamPortFile = getStreamPortFile();
|
||||
fs.writeFileSync(streamPortFile, streamPort.toString());
|
||||
}
|
||||
|
||||
// Idle timeout: shut down daemon if no commands arrive within the timeout period.
|
||||
// Reset on every incoming command. Set AGENT_BROWSER_IDLE_TIMEOUT_MS=0 to disable.
|
||||
let shutdownRef: (() => Promise<void>) | null = null;
|
||||
|
||||
function resetIdleTimer(): void {
|
||||
if (IDLE_TIMEOUT_MS <= 0) return;
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(() => {
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error(`[DEBUG] Idle timeout reached (${IDLE_TIMEOUT_MS}ms), shutting down daemon`);
|
||||
}
|
||||
if (shutdownRef) shutdownRef();
|
||||
}, IDLE_TIMEOUT_MS);
|
||||
// Don't let the idle timer keep the process alive on its own
|
||||
if (idleTimer && typeof idleTimer === 'object' && 'unref' in idleTimer) {
|
||||
idleTimer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
// Start the idle timer immediately
|
||||
resetIdleTimer();
|
||||
|
||||
const server = net.createServer((socket) => {
|
||||
let buffer = '';
|
||||
let httpChecked = false;
|
||||
|
||||
// Command serialization: queue incoming lines and process them one at a time.
|
||||
// This prevents concurrent command execution which can cause socket.write
|
||||
// buffer contention and EAGAIN errors on the Rust CLI side.
|
||||
const commandQueue: string[] = [];
|
||||
let processing = false;
|
||||
|
||||
async function processQueue(): Promise<void> {
|
||||
if (processing) return;
|
||||
processing = true;
|
||||
|
||||
while (commandQueue.length > 0) {
|
||||
const line = commandQueue.shift()!;
|
||||
// Reset idle timer on every command
|
||||
resetIdleTimer();
|
||||
|
||||
try {
|
||||
const parseResult = parseCommand(line);
|
||||
|
||||
if (!parseResult.success) {
|
||||
const resp = errorResponse(parseResult.id ?? 'unknown', parseResult.error);
|
||||
await safeWrite(socket, serializeResponse(resp) + '\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle device_list specially - it works without a session and always uses IOSManager
|
||||
if (parseResult.command.action === 'device_list') {
|
||||
const iosManager = new IOSManager();
|
||||
try {
|
||||
const devices = await iosManager.listAllDevices();
|
||||
const response = {
|
||||
id: parseResult.command.id,
|
||||
success: true as const,
|
||||
data: { devices },
|
||||
};
|
||||
await safeWrite(socket, serializeResponse(response) + '\n');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await safeWrite(
|
||||
socket,
|
||||
serializeResponse(errorResponse(parseResult.command.id, message)) + '\n'
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Auto-launch if not already launched and this isn't a launch/close/state_load command
|
||||
if (
|
||||
!manager.isLaunched() &&
|
||||
parseResult.command.action !== 'launch' &&
|
||||
parseResult.command.action !== 'close' &&
|
||||
parseResult.command.action !== 'state_load'
|
||||
) {
|
||||
if (isIOS && manager instanceof IOSManager) {
|
||||
// Auto-launch iOS Safari
|
||||
// Check for device in command first (for reused daemons), then fall back to env vars
|
||||
const cmd = parseResult.command as { iosDevice?: string };
|
||||
const iosDevice = cmd.iosDevice || process.env.AGENT_BROWSER_IOS_DEVICE;
|
||||
await manager.launch({
|
||||
device: iosDevice,
|
||||
udid: process.env.AGENT_BROWSER_IOS_UDID,
|
||||
});
|
||||
} else if (manager instanceof BrowserManager) {
|
||||
// Auto-launch desktop browser
|
||||
const extensions = process.env.AGENT_BROWSER_EXTENSIONS
|
||||
? process.env.AGENT_BROWSER_EXTENSIONS.split(/[,\n]/)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
// Parse args from env (comma or newline separated)
|
||||
const argsEnv = process.env.AGENT_BROWSER_ARGS;
|
||||
const args = argsEnv
|
||||
? argsEnv
|
||||
.split(/[,\n]/)
|
||||
.map((a) => a.trim())
|
||||
.filter((a) => a.length > 0)
|
||||
: undefined;
|
||||
|
||||
// Parse proxy from env
|
||||
const proxyServer = process.env.AGENT_BROWSER_PROXY;
|
||||
const proxyBypass = process.env.AGENT_BROWSER_PROXY_BYPASS;
|
||||
const proxy = proxyServer
|
||||
? {
|
||||
server: proxyServer,
|
||||
...(proxyBypass && { bypass: proxyBypass }),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const ignoreHTTPSErrors = process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1';
|
||||
const allowFileAccess = process.env.AGENT_BROWSER_ALLOW_FILE_ACCESS === '1';
|
||||
const colorSchemeEnv = process.env.AGENT_BROWSER_COLOR_SCHEME;
|
||||
const colorScheme =
|
||||
colorSchemeEnv === 'dark' ||
|
||||
colorSchemeEnv === 'light' ||
|
||||
colorSchemeEnv === 'no-preference'
|
||||
? colorSchemeEnv
|
||||
: undefined;
|
||||
await manager.launch({
|
||||
headless:
|
||||
process.env.AGENT_BROWSER_HEADED !== '1' &&
|
||||
process.env.AGENT_BROWSER_HEADED !== 'true',
|
||||
executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH,
|
||||
extensions: extensions,
|
||||
profile: process.env.AGENT_BROWSER_PROFILE,
|
||||
storageState: process.env.AGENT_BROWSER_STATE,
|
||||
args,
|
||||
userAgent: process.env.AGENT_BROWSER_USER_AGENT,
|
||||
proxy,
|
||||
ignoreHTTPSErrors: ignoreHTTPSErrors,
|
||||
allowFileAccess: allowFileAccess,
|
||||
colorScheme,
|
||||
autoStateFilePath: getSessionAutoStatePath(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Recover from stale state: browser is launched but all pages were closed
|
||||
if (
|
||||
manager instanceof BrowserManager &&
|
||||
manager.isLaunched() &&
|
||||
!manager.hasPages() &&
|
||||
parseResult.command.action !== 'launch' &&
|
||||
parseResult.command.action !== 'close'
|
||||
) {
|
||||
await manager.ensurePage();
|
||||
}
|
||||
|
||||
// Handle explicit launch with auto-load state
|
||||
if (
|
||||
parseResult.command.action === 'launch' &&
|
||||
manager instanceof BrowserManager &&
|
||||
!parseResult.command.autoStateFilePath
|
||||
) {
|
||||
const autoStatePath = getSessionAutoStatePath();
|
||||
if (autoStatePath) {
|
||||
parseResult.command.autoStateFilePath = autoStatePath;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle close command specially - shuts down daemon
|
||||
if (parseResult.command.action === 'close') {
|
||||
// Auto-save state before closing
|
||||
if (manager instanceof BrowserManager && manager.isLaunched()) {
|
||||
const savePath = getSessionSaveStatePath();
|
||||
if (savePath) {
|
||||
try {
|
||||
const { encrypted } = await saveStateToFile(manager, savePath);
|
||||
fs.chmodSync(savePath, 0o600);
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error(
|
||||
`Auto-saved session state: ${savePath}${encrypted ? ' (encrypted)' : ''}`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error(`Failed to auto-save session state:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response =
|
||||
isIOS && manager instanceof IOSManager
|
||||
? await executeIOSCommand(parseResult.command, manager)
|
||||
: await executeCommand(parseResult.command, manager as BrowserManager);
|
||||
await safeWrite(socket, serializeResponse(response) + '\n');
|
||||
|
||||
if (!shuttingDown) {
|
||||
shuttingDown = true;
|
||||
setTimeout(() => {
|
||||
server.close();
|
||||
cleanupSocket();
|
||||
process.exit(0);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
commandQueue.length = 0;
|
||||
processing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute command with appropriate handler
|
||||
const response =
|
||||
isIOS && manager instanceof IOSManager
|
||||
? await executeIOSCommand(parseResult.command, manager)
|
||||
: await executeCommand(parseResult.command, manager as BrowserManager);
|
||||
|
||||
// Add any launch warnings to the response
|
||||
if (manager instanceof BrowserManager) {
|
||||
const warnings = manager.getAndClearWarnings();
|
||||
if (warnings.length > 0 && response.success && response.data) {
|
||||
(response.data as Record<string, unknown>).warnings = warnings;
|
||||
}
|
||||
}
|
||||
|
||||
await safeWrite(socket, serializeResponse(response) + '\n');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await safeWrite(socket, serializeResponse(errorResponse('error', message)) + '\n').catch(
|
||||
() => {}
|
||||
); // Socket may already be destroyed
|
||||
}
|
||||
}
|
||||
|
||||
processing = false;
|
||||
}
|
||||
|
||||
socket.on('data', (data) => {
|
||||
buffer += data.toString();
|
||||
|
||||
// Security: Detect and reject HTTP requests to prevent cross-origin attacks.
|
||||
// Browsers using fetch() must send HTTP headers (e.g., "POST / HTTP/1.1"),
|
||||
// while legitimate clients send raw JSON starting with "{".
|
||||
if (!httpChecked) {
|
||||
httpChecked = true;
|
||||
const trimmed = buffer.trimStart();
|
||||
if (/^(GET|POST|PUT|DELETE|HEAD|OPTIONS|PATCH|CONNECT|TRACE)\s/i.test(trimmed)) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract complete lines and enqueue them for serial processing
|
||||
while (buffer.includes('\n')) {
|
||||
const newlineIdx = buffer.indexOf('\n');
|
||||
const line = buffer.substring(0, newlineIdx);
|
||||
buffer = buffer.substring(newlineIdx + 1);
|
||||
|
||||
if (!line.trim()) continue;
|
||||
commandQueue.push(line);
|
||||
}
|
||||
|
||||
processQueue().catch((err) => {
|
||||
// Socket write failures during queue processing are non-fatal;
|
||||
// the client has likely disconnected.
|
||||
// Only log err.message to avoid leaking sensitive fields (e.g. passwords) from command objects.
|
||||
console.warn('[warn] processQueue error:', err?.message ?? String(err));
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error(
|
||||
'[DEBUG] processQueue error stack:',
|
||||
err?.stack ?? err?.message ?? String(err)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('error', () => {
|
||||
// Client disconnected, ignore
|
||||
});
|
||||
});
|
||||
|
||||
const pidFile = getPidFile();
|
||||
|
||||
// Write PID file before listening
|
||||
fs.writeFileSync(pidFile, process.pid.toString());
|
||||
|
||||
if (isWindows) {
|
||||
// Windows: use TCP socket on localhost
|
||||
const port = getPortForSession(currentSession);
|
||||
const portFile = getPortFile();
|
||||
fs.writeFileSync(portFile, port.toString());
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
// Daemon is ready on TCP port
|
||||
});
|
||||
} else {
|
||||
// Unix: use Unix domain socket
|
||||
const socketPath = getSocketPath();
|
||||
server.listen(socketPath, () => {
|
||||
// Daemon is ready
|
||||
});
|
||||
}
|
||||
|
||||
server.on('error', (err) => {
|
||||
console.error('Server error:', err);
|
||||
cleanupSocket();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Handle shutdown signals
|
||||
const shutdown = async () => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
|
||||
// Clear idle timer
|
||||
if (idleTimer) {
|
||||
clearTimeout(idleTimer);
|
||||
idleTimer = null;
|
||||
}
|
||||
|
||||
// Auto-save session state before closing (same as the explicit `close` command path)
|
||||
if (manager instanceof BrowserManager && manager.isLaunched()) {
|
||||
const savePath = getSessionSaveStatePath();
|
||||
if (savePath) {
|
||||
try {
|
||||
const { encrypted } = await saveStateToFile(manager, savePath);
|
||||
fs.chmodSync(savePath, 0o600);
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error(
|
||||
`Auto-saved session state before shutdown: ${savePath}${encrypted ? ' (encrypted)' : ''}`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error(`Failed to auto-save session state before shutdown:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop stream server if running
|
||||
if (streamServer) {
|
||||
await streamServer.stop();
|
||||
streamServer = null;
|
||||
// Clean up stream port file
|
||||
const streamPortFile = getStreamPortFile();
|
||||
try {
|
||||
if (fs.existsSync(streamPortFile)) fs.unlinkSync(streamPortFile);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
await manager.close();
|
||||
server.close();
|
||||
cleanupSocket();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
// Wire up idle timeout to shutdown
|
||||
shutdownRef = shutdown;
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGHUP', shutdown);
|
||||
|
||||
// Handle unexpected errors - always cleanup
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('Uncaught exception:', err);
|
||||
cleanupSocket();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.error('Unhandled rejection:', reason);
|
||||
cleanupSocket();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Cleanup on normal exit
|
||||
process.on('exit', () => {
|
||||
cleanupSocket();
|
||||
});
|
||||
|
||||
// Keep process alive
|
||||
process.stdin.resume();
|
||||
}
|
||||
|
||||
// Run daemon if this is the entry point
|
||||
if (process.argv[1]?.endsWith('daemon.js') || process.env.AGENT_BROWSER_DAEMON === '1') {
|
||||
startDaemon().catch((err) => {
|
||||
console.error('Daemon error:', err);
|
||||
cleanupSocket();
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { diffSnapshots, diffScreenshots } from './diff.js';
|
||||
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright-core';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
describe('diffSnapshots', () => {
|
||||
it('should report no changes for identical inputs', () => {
|
||||
const text = 'heading "Hello"\nbutton "Submit" [ref=e1]';
|
||||
const result = diffSnapshots(text, text);
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.additions).toBe(0);
|
||||
expect(result.removals).toBe(0);
|
||||
expect(result.unchanged).toBe(2);
|
||||
});
|
||||
|
||||
it('should report no changes for empty inputs', () => {
|
||||
const result = diffSnapshots('', '');
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.additions).toBe(0);
|
||||
expect(result.removals).toBe(0);
|
||||
expect(result.unchanged).toBe(1);
|
||||
});
|
||||
|
||||
it('should detect a single-line addition', () => {
|
||||
const before = 'heading "Hello"';
|
||||
const after = 'heading "Hello"\nbutton "New"';
|
||||
const result = diffSnapshots(before, after);
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.additions).toBe(1);
|
||||
expect(result.removals).toBe(0);
|
||||
expect(result.unchanged).toBe(1);
|
||||
expect(result.diff).toContain('+ button "New"');
|
||||
});
|
||||
|
||||
it('should detect a single-line removal', () => {
|
||||
const before = 'heading "Hello"\nbutton "Gone"';
|
||||
const after = 'heading "Hello"';
|
||||
const result = diffSnapshots(before, after);
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.additions).toBe(0);
|
||||
expect(result.removals).toBe(1);
|
||||
expect(result.unchanged).toBe(1);
|
||||
expect(result.diff).toContain('- button "Gone"');
|
||||
});
|
||||
|
||||
it('should detect completely different inputs', () => {
|
||||
const before = 'line A\nline B';
|
||||
const after = 'line C\nline D';
|
||||
const result = diffSnapshots(before, after);
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.additions).toBe(2);
|
||||
expect(result.removals).toBe(2);
|
||||
expect(result.unchanged).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle mixed additions, removals, and unchanged lines', () => {
|
||||
const before = [
|
||||
'heading "Title"',
|
||||
'button "Submit" [ref=e2]',
|
||||
'text "old value"',
|
||||
'footer "Copyright"',
|
||||
].join('\n');
|
||||
const after = [
|
||||
'heading "Title"',
|
||||
'button "Submit" [ref=e2] [disabled]',
|
||||
'text "new value"',
|
||||
'link "Help" [ref=e5]',
|
||||
'footer "Copyright"',
|
||||
].join('\n');
|
||||
const result = diffSnapshots(before, after);
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.additions).toBeGreaterThan(0);
|
||||
expect(result.removals).toBeGreaterThan(0);
|
||||
expect(result.unchanged).toBeGreaterThan(0);
|
||||
expect(result.diff).toContain('+ ');
|
||||
expect(result.diff).toContain('- ');
|
||||
});
|
||||
|
||||
it('should use + prefix for insertions and - prefix for deletions', () => {
|
||||
const before = 'alpha';
|
||||
const after = 'beta';
|
||||
const result = diffSnapshots(before, after);
|
||||
const lines = result.diff.split('\n');
|
||||
const deletions = lines.filter((l) => l.startsWith('- '));
|
||||
const insertions = lines.filter((l) => l.startsWith('+ '));
|
||||
expect(deletions.length).toBe(1);
|
||||
expect(insertions.length).toBe(1);
|
||||
expect(deletions[0]).toBe('- alpha');
|
||||
expect(insertions[0]).toBe('+ beta');
|
||||
});
|
||||
|
||||
it('should use two-space prefix for unchanged lines', () => {
|
||||
const text = 'unchanged line';
|
||||
const result = diffSnapshots(text, text);
|
||||
expect(result.diff).toBe(' unchanged line');
|
||||
});
|
||||
|
||||
it('should handle multiline to empty', () => {
|
||||
const before = 'line 1\nline 2\nline 3';
|
||||
const after = '';
|
||||
const result = diffSnapshots(before, after);
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.removals).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('should handle empty to multiline', () => {
|
||||
const before = '';
|
||||
const after = 'line 1\nline 2\nline 3';
|
||||
const result = diffSnapshots(before, after);
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.additions).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
const canLaunchBrowser = await (async () => {
|
||||
try {
|
||||
const b = await chromium.launch({ headless: true });
|
||||
await b.close();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
describe.skipIf(!canLaunchBrowser)('diffScreenshots', () => {
|
||||
let browser: Browser;
|
||||
let context: BrowserContext;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
context = await browser.newContext({ viewport: { width: 200, height: 200 } });
|
||||
page = await context.newPage();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
async function screenshotOfColor(color: string): Promise<Buffer> {
|
||||
await page.setContent(`<div style="width:200px;height:200px;background:${color}"></div>`);
|
||||
return await page.screenshot({ type: 'png' });
|
||||
}
|
||||
|
||||
it('should report match for identical images', async () => {
|
||||
const img = await screenshotOfColor('red');
|
||||
const result = await diffScreenshots(context, img, img, {});
|
||||
expect(result.match).toBe(true);
|
||||
expect(result.differentPixels).toBe(0);
|
||||
expect(result.mismatchPercentage).toBe(0);
|
||||
expect(result.dimensionMismatch).toBeUndefined();
|
||||
if (result.diffPath) fs.unlinkSync(result.diffPath);
|
||||
});
|
||||
|
||||
it('should detect differences between distinct images', async () => {
|
||||
const imgA = await screenshotOfColor('red');
|
||||
const imgB = await screenshotOfColor('blue');
|
||||
const result = await diffScreenshots(context, imgA, imgB, {});
|
||||
expect(result.match).toBe(false);
|
||||
expect(result.differentPixels).toBeGreaterThan(0);
|
||||
expect(result.mismatchPercentage).toBeGreaterThan(0);
|
||||
if (result.diffPath) fs.unlinkSync(result.diffPath);
|
||||
});
|
||||
|
||||
it('should detect dimension mismatch', async () => {
|
||||
const imgA = await screenshotOfColor('white');
|
||||
await page.setViewportSize({ width: 100, height: 100 });
|
||||
const imgB = await screenshotOfColor('white');
|
||||
await page.setViewportSize({ width: 200, height: 200 });
|
||||
const result = await diffScreenshots(context, imgA, imgB, {});
|
||||
expect(result.dimensionMismatch).toBe(true);
|
||||
expect(result.mismatchPercentage).toBe(100);
|
||||
if (result.diffPath) fs.unlinkSync(result.diffPath);
|
||||
});
|
||||
|
||||
it('should write diff image to custom outputPath', async () => {
|
||||
const imgA = await screenshotOfColor('green');
|
||||
const imgB = await screenshotOfColor('yellow');
|
||||
const outputPath = path.join(os.tmpdir(), `diff-test-${Date.now()}.png`);
|
||||
const result = await diffScreenshots(context, imgA, imgB, { outputPath });
|
||||
expect(result.diffPath).toBe(outputPath);
|
||||
expect(fs.existsSync(outputPath)).toBe(true);
|
||||
const stat = fs.statSync(outputPath);
|
||||
expect(stat.size).toBeGreaterThan(0);
|
||||
fs.unlinkSync(outputPath);
|
||||
});
|
||||
});
|
||||
-339
@@ -1,339 +0,0 @@
|
||||
import type { BrowserContext } from 'playwright-core';
|
||||
import type { DiffSnapshotData, DiffScreenshotData } from './types.js';
|
||||
import { writeFile, mkdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
// --- Text diffing (Myers algorithm, line-level) ---
|
||||
|
||||
interface DiffEdit {
|
||||
type: 'equal' | 'insert' | 'delete';
|
||||
line: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Myers diff algorithm operating on arrays of lines.
|
||||
* Returns a minimal edit script.
|
||||
*/
|
||||
function myersDiff(a: string[], b: string[]): DiffEdit[] {
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
const max = n + m;
|
||||
|
||||
if (max === 0) return [];
|
||||
|
||||
// Optimize: if both are identical, skip diff
|
||||
if (n === m) {
|
||||
let identical = true;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (a[i] !== b[i]) {
|
||||
identical = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (identical) return a.map((line) => ({ type: 'equal' as const, line }));
|
||||
}
|
||||
|
||||
const vSize = 2 * max + 1;
|
||||
const v = new Int32Array(vSize);
|
||||
v.fill(-1);
|
||||
const trace: Int32Array[] = [];
|
||||
|
||||
v[max + 1] = 0;
|
||||
for (let d = 0; d <= max; d++) {
|
||||
const snapshot = new Int32Array(v);
|
||||
trace.push(snapshot);
|
||||
|
||||
for (let k = -d; k <= d; k += 2) {
|
||||
const idx = k + max;
|
||||
let x: number;
|
||||
if (k === -d || (k !== d && v[idx - 1] < v[idx + 1])) {
|
||||
x = v[idx + 1];
|
||||
} else {
|
||||
x = v[idx - 1] + 1;
|
||||
}
|
||||
let y = x - k;
|
||||
|
||||
while (x < n && y < m && a[x] === b[y]) {
|
||||
x++;
|
||||
y++;
|
||||
}
|
||||
|
||||
v[idx] = x;
|
||||
|
||||
if (x >= n && y >= m) {
|
||||
return buildEditScript(trace, a, b, max);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return buildEditScript(trace, a, b, max);
|
||||
}
|
||||
|
||||
function buildEditScript(trace: Int32Array[], a: string[], b: string[], max: number): DiffEdit[] {
|
||||
const edits: DiffEdit[] = [];
|
||||
let x = a.length;
|
||||
let y = b.length;
|
||||
|
||||
for (let d = trace.length - 1; d > 0; d--) {
|
||||
const v = trace[d];
|
||||
const k = x - y;
|
||||
const idx = k + max;
|
||||
|
||||
let prevK: number;
|
||||
if (k === -d || (k !== d && v[idx - 1] < v[idx + 1])) {
|
||||
prevK = k + 1;
|
||||
} else {
|
||||
prevK = k - 1;
|
||||
}
|
||||
|
||||
const prevIdx = prevK + max;
|
||||
let prevX = v[prevIdx];
|
||||
let prevY = prevX - prevK;
|
||||
|
||||
// Diagonal (equal lines)
|
||||
while (x > prevX && y > prevY) {
|
||||
x--;
|
||||
y--;
|
||||
edits.push({ type: 'equal', line: a[x] });
|
||||
}
|
||||
|
||||
if (x === prevX) {
|
||||
y--;
|
||||
edits.push({ type: 'insert', line: b[y] });
|
||||
} else {
|
||||
x--;
|
||||
edits.push({ type: 'delete', line: a[x] });
|
||||
}
|
||||
}
|
||||
|
||||
// Remaining diagonal at d=0
|
||||
while (x > 0 && y > 0) {
|
||||
x--;
|
||||
y--;
|
||||
edits.push({ type: 'equal', line: a[x] });
|
||||
}
|
||||
|
||||
edits.reverse();
|
||||
return edits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a unified diff string and stats from two snapshot texts.
|
||||
*/
|
||||
export function diffSnapshots(before: string, after: string): DiffSnapshotData {
|
||||
const linesA = before.split('\n');
|
||||
const linesB = after.split('\n');
|
||||
|
||||
const edits = myersDiff(linesA, linesB);
|
||||
|
||||
let additions = 0;
|
||||
let removals = 0;
|
||||
let unchanged = 0;
|
||||
const diffLines: string[] = [];
|
||||
|
||||
for (const edit of edits) {
|
||||
switch (edit.type) {
|
||||
case 'equal':
|
||||
unchanged++;
|
||||
diffLines.push(` ${edit.line}`);
|
||||
break;
|
||||
case 'insert':
|
||||
additions++;
|
||||
diffLines.push(`+ ${edit.line}`);
|
||||
break;
|
||||
case 'delete':
|
||||
removals++;
|
||||
diffLines.push(`- ${edit.line}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
diff: diffLines.join('\n'),
|
||||
additions,
|
||||
removals,
|
||||
unchanged,
|
||||
changed: additions > 0 || removals > 0,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Image diffing (via browser Canvas API) ---
|
||||
|
||||
interface PixelDiffResult {
|
||||
totalPixels: number;
|
||||
differentPixels: number;
|
||||
mismatchPercentage: number;
|
||||
diffBase64: string;
|
||||
dimensionMismatch: boolean;
|
||||
}
|
||||
|
||||
const DIFF_ROUTE_PREFIX = 'https://agent-browser-diff.localhost';
|
||||
|
||||
/**
|
||||
* Compare two image buffers using the browser's Canvas API for pixel comparison.
|
||||
* Uses an isolated blank page to avoid CSP interference or DOM side effects on the
|
||||
* user's page. Images are served via intercepted routes to avoid large base64 payloads
|
||||
* through page.evaluate (which can be slow or hit CDP message size limits).
|
||||
*/
|
||||
export async function diffScreenshots(
|
||||
context: BrowserContext,
|
||||
baselineBuffer: Buffer,
|
||||
currentBuffer: Buffer,
|
||||
opts: { threshold?: number; outputPath?: string; baselineMime?: string }
|
||||
): Promise<DiffScreenshotData> {
|
||||
const baselineMime = opts.baselineMime ?? 'image/png';
|
||||
const threshold = opts.threshold ?? 0.1;
|
||||
|
||||
const nonce = Math.random().toString(36).slice(2, 10);
|
||||
const blankUrl = `${DIFF_ROUTE_PREFIX}/${nonce}/index.html`;
|
||||
const baselineUrl = `${DIFF_ROUTE_PREFIX}/${nonce}/baseline.png`;
|
||||
const currentUrl = `${DIFF_ROUTE_PREFIX}/${nonce}/current.png`;
|
||||
|
||||
const diffPage = await context.newPage();
|
||||
|
||||
let blankRouted = false;
|
||||
let baselineRouted = false;
|
||||
let currentRouted = false;
|
||||
try {
|
||||
await diffPage.route(blankUrl, (route) =>
|
||||
route.fulfill({ body: '<html><body></body></html>', contentType: 'text/html' })
|
||||
);
|
||||
blankRouted = true;
|
||||
await diffPage.route(baselineUrl, (route) =>
|
||||
route.fulfill({ body: baselineBuffer, contentType: baselineMime })
|
||||
);
|
||||
baselineRouted = true;
|
||||
await diffPage.route(currentUrl, (route) =>
|
||||
route.fulfill({ body: currentBuffer, contentType: 'image/png' })
|
||||
);
|
||||
currentRouted = true;
|
||||
|
||||
await diffPage.goto(blankUrl);
|
||||
|
||||
const pixelDiffFn = async (args: {
|
||||
baselineUrl: string;
|
||||
currentUrl: string;
|
||||
threshold: number;
|
||||
}) => {
|
||||
const g = globalThis as any;
|
||||
const doc = g.document;
|
||||
const Img = g.Image as new () => any;
|
||||
function loadImage(url: string) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Img();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error('Failed to load image'));
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
const [imgA, imgB] = (await Promise.all([
|
||||
loadImage(args.baselineUrl),
|
||||
loadImage(args.currentUrl),
|
||||
])) as any[];
|
||||
if (imgA.width !== imgB.width || imgA.height !== imgB.height) {
|
||||
const c = doc.createElement('canvas');
|
||||
c.width = 1;
|
||||
c.height = 1;
|
||||
return {
|
||||
totalPixels: Math.max(imgA.width * imgA.height, imgB.width * imgB.height),
|
||||
differentPixels: Math.max(imgA.width * imgA.height, imgB.width * imgB.height),
|
||||
mismatchPercentage: 100,
|
||||
diffBase64: c.toDataURL('image/png').split(',')[1],
|
||||
dimensionMismatch: true,
|
||||
};
|
||||
}
|
||||
const w = imgA.width;
|
||||
const h = imgA.height;
|
||||
const canvasA = doc.createElement('canvas');
|
||||
canvasA.width = w;
|
||||
canvasA.height = h;
|
||||
const ctxA = canvasA.getContext('2d')!;
|
||||
ctxA.drawImage(imgA, 0, 0);
|
||||
const dataA = ctxA.getImageData(0, 0, w, h).data;
|
||||
const canvasB = doc.createElement('canvas');
|
||||
canvasB.width = w;
|
||||
canvasB.height = h;
|
||||
const ctxB = canvasB.getContext('2d')!;
|
||||
ctxB.drawImage(imgB, 0, 0);
|
||||
const dataB = ctxB.getImageData(0, 0, w, h).data;
|
||||
const diffCanvas = doc.createElement('canvas');
|
||||
diffCanvas.width = w;
|
||||
diffCanvas.height = h;
|
||||
const ctxDiff = diffCanvas.getContext('2d')!;
|
||||
const diffImageData = ctxDiff.createImageData(w, h);
|
||||
const diffData = diffImageData.data;
|
||||
const maxColorDistance = args.threshold * 255 * Math.sqrt(3);
|
||||
let differentPixels = 0;
|
||||
const totalPixels = w * h;
|
||||
for (let i = 0; i < totalPixels; i++) {
|
||||
const offset = i * 4;
|
||||
const rA = dataA[offset],
|
||||
gA = dataA[offset + 1],
|
||||
bA = dataA[offset + 2];
|
||||
const rB = dataB[offset],
|
||||
gB = dataB[offset + 1],
|
||||
bB = dataB[offset + 2];
|
||||
const dr = rA - rB,
|
||||
dg = gA - gB,
|
||||
db = bA - bB;
|
||||
const dist = Math.sqrt(dr * dr + dg * dg + db * db);
|
||||
if (dist > maxColorDistance) {
|
||||
differentPixels++;
|
||||
diffData[offset] = 255;
|
||||
diffData[offset + 1] = 0;
|
||||
diffData[offset + 2] = 0;
|
||||
diffData[offset + 3] = 255;
|
||||
} else {
|
||||
diffData[offset] = Math.round(rA * 0.3);
|
||||
diffData[offset + 1] = Math.round(gA * 0.3);
|
||||
diffData[offset + 2] = Math.round(bA * 0.3);
|
||||
diffData[offset + 3] = 255;
|
||||
}
|
||||
}
|
||||
ctxDiff.putImageData(diffImageData, 0, 0);
|
||||
const diffBase64 = diffCanvas.toDataURL('image/png').split(',')[1];
|
||||
return {
|
||||
totalPixels,
|
||||
differentPixels,
|
||||
mismatchPercentage: Math.round((differentPixels / totalPixels) * 10000) / 100,
|
||||
diffBase64,
|
||||
dimensionMismatch: false,
|
||||
};
|
||||
};
|
||||
|
||||
const result = (await diffPage.evaluate(pixelDiffFn, {
|
||||
baselineUrl,
|
||||
currentUrl,
|
||||
threshold,
|
||||
})) as PixelDiffResult;
|
||||
|
||||
let outputPath = opts.outputPath;
|
||||
if (!outputPath) {
|
||||
const tmpDir = path.join(
|
||||
process.env.HOME || process.env.USERPROFILE || '/tmp',
|
||||
'.agent-browser',
|
||||
'tmp',
|
||||
'diffs'
|
||||
);
|
||||
await mkdir(tmpDir, { recursive: true });
|
||||
outputPath = path.join(tmpDir, `diff-${Date.now()}.png`);
|
||||
}
|
||||
|
||||
const diffBuffer = Buffer.from(result.diffBase64, 'base64');
|
||||
await writeFile(outputPath, diffBuffer);
|
||||
|
||||
return {
|
||||
diffPath: outputPath,
|
||||
totalPixels: result.totalPixels,
|
||||
differentPixels: result.differentPixels,
|
||||
mismatchPercentage: result.mismatchPercentage,
|
||||
match: result.differentPixels === 0,
|
||||
...(result.dimensionMismatch ? { dimensionMismatch: true } : {}),
|
||||
};
|
||||
} finally {
|
||||
if (blankRouted) await diffPage.unroute(blankUrl).catch(() => {});
|
||||
if (baselineRouted) await diffPage.unroute(baselineUrl).catch(() => {});
|
||||
if (currentRouted) await diffPage.unroute(currentUrl).catch(() => {});
|
||||
await diffPage.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isDomainAllowed, parseDomainList, buildWebSocketFilterScript } from './domain-filter.js';
|
||||
|
||||
describe('domain-filter', () => {
|
||||
describe('isDomainAllowed', () => {
|
||||
it('should match exact domains', () => {
|
||||
expect(isDomainAllowed('example.com', ['example.com'])).toBe(true);
|
||||
expect(isDomainAllowed('github.com', ['github.com'])).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject non-matching domains', () => {
|
||||
expect(isDomainAllowed('evil.com', ['example.com'])).toBe(false);
|
||||
expect(isDomainAllowed('notexample.com', ['example.com'])).toBe(false);
|
||||
});
|
||||
|
||||
it('should match wildcard patterns', () => {
|
||||
expect(isDomainAllowed('sub.example.com', ['*.example.com'])).toBe(true);
|
||||
expect(isDomainAllowed('deep.sub.example.com', ['*.example.com'])).toBe(true);
|
||||
});
|
||||
|
||||
it('should match bare domain against wildcard pattern', () => {
|
||||
expect(isDomainAllowed('example.com', ['*.example.com'])).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject non-matching wildcard patterns', () => {
|
||||
expect(isDomainAllowed('example.org', ['*.example.com'])).toBe(false);
|
||||
expect(isDomainAllowed('evil.com', ['*.example.com'])).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty allowlist', () => {
|
||||
expect(isDomainAllowed('example.com', [])).toBe(false);
|
||||
});
|
||||
|
||||
it('should match against multiple patterns', () => {
|
||||
const patterns = ['example.com', '*.github.com', 'vercel.app'];
|
||||
expect(isDomainAllowed('example.com', patterns)).toBe(true);
|
||||
expect(isDomainAllowed('api.github.com', patterns)).toBe(true);
|
||||
expect(isDomainAllowed('vercel.app', patterns)).toBe(true);
|
||||
expect(isDomainAllowed('evil.com', patterns)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not partially match domain suffixes without wildcard', () => {
|
||||
expect(isDomainAllowed('sub.example.com', ['example.com'])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDomainList', () => {
|
||||
it('should split comma-separated domains', () => {
|
||||
expect(parseDomainList('a.com,b.com')).toEqual(['a.com', 'b.com']);
|
||||
});
|
||||
|
||||
it('should trim whitespace', () => {
|
||||
expect(parseDomainList(' a.com , b.com ')).toEqual(['a.com', 'b.com']);
|
||||
});
|
||||
|
||||
it('should lowercase domains', () => {
|
||||
expect(parseDomainList('Example.COM,GitHub.Com')).toEqual(['example.com', 'github.com']);
|
||||
});
|
||||
|
||||
it('should filter empty entries', () => {
|
||||
expect(parseDomainList('a.com,,b.com,')).toEqual(['a.com', 'b.com']);
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
expect(parseDomainList('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should preserve wildcard prefixes', () => {
|
||||
expect(parseDomainList('*.example.com')).toEqual(['*.example.com']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildWebSocketFilterScript', () => {
|
||||
it('should produce a valid JavaScript IIFE', () => {
|
||||
const script = buildWebSocketFilterScript(['example.com', '*.github.com']);
|
||||
expect(script).toContain('_allowedDomains');
|
||||
expect(script).toContain('"example.com"');
|
||||
expect(script).toContain('"*.github.com"');
|
||||
});
|
||||
|
||||
it('should embed the domain list as JSON', () => {
|
||||
const script = buildWebSocketFilterScript(['a.com']);
|
||||
expect(script).toContain('["a.com"]');
|
||||
});
|
||||
|
||||
it('should include WebSocket, EventSource, and sendBeacon patches', () => {
|
||||
const script = buildWebSocketFilterScript(['a.com']);
|
||||
expect(script).toContain('WebSocket');
|
||||
expect(script).toContain('EventSource');
|
||||
expect(script).toContain('SecurityError');
|
||||
expect(script).toContain('sendBeacon');
|
||||
});
|
||||
|
||||
it('should handle empty allowlist', () => {
|
||||
const script = buildWebSocketFilterScript([]);
|
||||
expect(script).toContain('[]');
|
||||
});
|
||||
|
||||
it('should include domain matching logic consistent with isDomainAllowed', () => {
|
||||
const script = buildWebSocketFilterScript(['*.example.com']);
|
||||
expect(script).toContain('_isDomainAllowed');
|
||||
expect(script).toContain('slice(1)');
|
||||
expect(script).toContain('slice(2)');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,156 +0,0 @@
|
||||
import type { BrowserContext, Route } from 'playwright-core';
|
||||
|
||||
/**
|
||||
* Checks whether a hostname matches one of the allowed domain patterns.
|
||||
* Patterns support exact match ("example.com") and wildcard prefix ("*.example.com").
|
||||
*/
|
||||
export function isDomainAllowed(hostname: string, allowedDomains: string[]): boolean {
|
||||
for (const pattern of allowedDomains) {
|
||||
if (pattern.startsWith('*.')) {
|
||||
const suffix = pattern.slice(1); // ".example.com"
|
||||
if (hostname === pattern.slice(2) || hostname.endsWith(suffix)) {
|
||||
return true;
|
||||
}
|
||||
} else if (hostname === pattern) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function parseDomainList(raw: string): string[] {
|
||||
return raw
|
||||
.split(',')
|
||||
.map((d) => d.trim().toLowerCase())
|
||||
.filter((d) => d.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the init script source that monkey-patches WebSocket, EventSource,
|
||||
* and navigator.sendBeacon to block connections to non-allowed domains.
|
||||
* Exported for testing.
|
||||
*/
|
||||
export function buildWebSocketFilterScript(allowedDomains: string[]): string {
|
||||
const serialized = JSON.stringify(allowedDomains);
|
||||
return `(function() {
|
||||
var _allowedDomains = ${serialized};
|
||||
function _isDomainAllowed(hostname) {
|
||||
hostname = hostname.toLowerCase();
|
||||
for (var i = 0; i < _allowedDomains.length; i++) {
|
||||
var pattern = _allowedDomains[i];
|
||||
if (pattern.indexOf('*.') === 0) {
|
||||
var suffix = pattern.slice(1);
|
||||
if (hostname === pattern.slice(2) || hostname.slice(-suffix.length) === suffix) {
|
||||
return true;
|
||||
}
|
||||
} else if (hostname === pattern) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function _checkUrl(url) {
|
||||
try {
|
||||
var parsed = new URL(url);
|
||||
return _isDomainAllowed(parsed.hostname);
|
||||
} catch(e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (typeof WebSocket !== 'undefined') {
|
||||
var _OrigWS = WebSocket;
|
||||
WebSocket = function(url, protocols) {
|
||||
if (!_checkUrl(url)) {
|
||||
throw new DOMException(
|
||||
'WebSocket connection to ' + url + ' blocked by domain allowlist',
|
||||
'SecurityError'
|
||||
);
|
||||
}
|
||||
if (protocols !== undefined) {
|
||||
return new _OrigWS(url, protocols);
|
||||
}
|
||||
return new _OrigWS(url);
|
||||
};
|
||||
WebSocket.prototype = _OrigWS.prototype;
|
||||
WebSocket.CONNECTING = _OrigWS.CONNECTING;
|
||||
WebSocket.OPEN = _OrigWS.OPEN;
|
||||
WebSocket.CLOSING = _OrigWS.CLOSING;
|
||||
WebSocket.CLOSED = _OrigWS.CLOSED;
|
||||
}
|
||||
if (typeof EventSource !== 'undefined') {
|
||||
var _OrigES = EventSource;
|
||||
EventSource = function(url, opts) {
|
||||
if (!_checkUrl(url)) {
|
||||
throw new DOMException(
|
||||
'EventSource connection to ' + url + ' blocked by domain allowlist',
|
||||
'SecurityError'
|
||||
);
|
||||
}
|
||||
return new _OrigES(url, opts);
|
||||
};
|
||||
EventSource.prototype = _OrigES.prototype;
|
||||
EventSource.CONNECTING = _OrigES.CONNECTING;
|
||||
EventSource.OPEN = _OrigES.OPEN;
|
||||
EventSource.CLOSED = _OrigES.CLOSED;
|
||||
}
|
||||
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
|
||||
var _origSendBeacon = navigator.sendBeacon.bind(navigator);
|
||||
navigator.sendBeacon = function(url, data) {
|
||||
if (!_checkUrl(url)) {
|
||||
return false;
|
||||
}
|
||||
return _origSendBeacon(url, data);
|
||||
};
|
||||
}
|
||||
})();`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs a context-level route that enforces the domain allowlist.
|
||||
* Both document navigations and sub-resource requests (scripts, images, fetch, etc.)
|
||||
* to non-allowed domains are blocked, preventing data exfiltration.
|
||||
* Non-http(s) schemes (data:, blob:, etc.) are allowed for sub-resources
|
||||
* but blocked for document navigations.
|
||||
*
|
||||
* Also installs an init script that patches WebSocket, EventSource, and
|
||||
* navigator.sendBeacon to block connections to non-allowed domains. This is
|
||||
* a best-effort defense: if eval is permitted by action policy, page scripts
|
||||
* could theoretically restore the originals. Denying the eval action
|
||||
* category closes that loophole.
|
||||
*/
|
||||
export async function installDomainFilter(
|
||||
context: BrowserContext,
|
||||
allowedDomains: string[]
|
||||
): Promise<void> {
|
||||
if (allowedDomains.length === 0) return;
|
||||
|
||||
await context.addInitScript(buildWebSocketFilterScript(allowedDomains));
|
||||
|
||||
await context.route('**/*', async (route: Route) => {
|
||||
const request = route.request();
|
||||
const urlStr = request.url();
|
||||
|
||||
if (!urlStr.startsWith('http://') && !urlStr.startsWith('https://')) {
|
||||
if (request.resourceType() === 'document') {
|
||||
await route.abort('blockedbyclient');
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let hostname: string;
|
||||
try {
|
||||
hostname = new URL(urlStr).hostname.toLowerCase();
|
||||
} catch {
|
||||
await route.abort('blockedbyclient');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDomainAllowed(hostname, allowedDomains)) {
|
||||
await route.continue();
|
||||
} else {
|
||||
await route.abort('blockedbyclient');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,474 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as crypto from 'crypto';
|
||||
import {
|
||||
encryptData,
|
||||
decryptData,
|
||||
getEncryptionKey,
|
||||
getKeyFilePath,
|
||||
isEncryptedPayload,
|
||||
ENCRYPTION_KEY_ENV,
|
||||
IV_LENGTH,
|
||||
type EncryptedPayload,
|
||||
} from './encryption.js';
|
||||
|
||||
// Mock node:fs to isolate getEncryptionKey from the local filesystem
|
||||
const mockFs = vi.hoisted(() => ({
|
||||
existsSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
originals: {} as Pick<typeof import('node:fs'), 'existsSync' | 'readFileSync'>,
|
||||
}));
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
mockFs.originals = { existsSync: actual.existsSync, readFileSync: actual.readFileSync };
|
||||
mockFs.existsSync.mockImplementation(actual.existsSync);
|
||||
mockFs.readFileSync.mockImplementation(actual.readFileSync);
|
||||
return { ...actual, existsSync: mockFs.existsSync, readFileSync: mockFs.readFileSync };
|
||||
});
|
||||
|
||||
// Generate a valid test key (256 bits = 32 bytes = 64 hex chars)
|
||||
const generateTestKey = () => crypto.randomBytes(32);
|
||||
const generateTestKeyHex = () => crypto.randomBytes(32).toString('hex');
|
||||
|
||||
describe('encryption', () => {
|
||||
describe('encryptData / decryptData', () => {
|
||||
it('should round-trip encrypt and decrypt data correctly', () => {
|
||||
const key = generateTestKey();
|
||||
const plaintext = 'Hello, World! This is a test message.';
|
||||
|
||||
const encrypted = encryptData(plaintext, key);
|
||||
const decrypted = decryptData(encrypted, key);
|
||||
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('should round-trip with complex JSON data', () => {
|
||||
const key = generateTestKey();
|
||||
const data = {
|
||||
cookies: [{ name: 'session', value: 'abc123', domain: '.example.com' }],
|
||||
localStorage: { theme: 'dark', userId: '12345' },
|
||||
sessionStorage: {},
|
||||
};
|
||||
const plaintext = JSON.stringify(data);
|
||||
|
||||
const encrypted = encryptData(plaintext, key);
|
||||
const decrypted = decryptData(encrypted, key);
|
||||
|
||||
expect(JSON.parse(decrypted)).toEqual(data);
|
||||
});
|
||||
|
||||
it('should round-trip with empty string', () => {
|
||||
const key = generateTestKey();
|
||||
const plaintext = '';
|
||||
|
||||
const encrypted = encryptData(plaintext, key);
|
||||
const decrypted = decryptData(encrypted, key);
|
||||
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('should round-trip with unicode characters', () => {
|
||||
const key = generateTestKey();
|
||||
const plaintext = '你好世界 🌍 Привет мир émojis: 🔐🔑';
|
||||
|
||||
const encrypted = encryptData(plaintext, key);
|
||||
const decrypted = decryptData(encrypted, key);
|
||||
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('should round-trip with large data', () => {
|
||||
const key = generateTestKey();
|
||||
const plaintext = 'x'.repeat(100000); // 100KB of data
|
||||
|
||||
const encrypted = encryptData(plaintext, key);
|
||||
const decrypted = decryptData(encrypted, key);
|
||||
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IV uniqueness', () => {
|
||||
it('should generate different IVs for each encryption', () => {
|
||||
const key = generateTestKey();
|
||||
const plaintext = 'Same message encrypted twice';
|
||||
|
||||
const encrypted1 = encryptData(plaintext, key);
|
||||
const encrypted2 = encryptData(plaintext, key);
|
||||
|
||||
// IVs should be different
|
||||
expect(encrypted1.iv).not.toBe(encrypted2.iv);
|
||||
|
||||
// Ciphertext should also be different due to different IVs
|
||||
expect(encrypted1.data).not.toBe(encrypted2.data);
|
||||
|
||||
// Both should decrypt to the same plaintext
|
||||
expect(decryptData(encrypted1, key)).toBe(plaintext);
|
||||
expect(decryptData(encrypted2, key)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('should have correct IV length', () => {
|
||||
const key = generateTestKey();
|
||||
const encrypted = encryptData('test', key);
|
||||
|
||||
const ivBuffer = Buffer.from(encrypted.iv, 'base64');
|
||||
expect(ivBuffer.length).toBe(IV_LENGTH);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authentication (tamper detection)', () => {
|
||||
it('should throw error when auth tag is tampered', () => {
|
||||
const key = generateTestKey();
|
||||
const plaintext = 'Sensitive data';
|
||||
|
||||
const encrypted = encryptData(plaintext, key);
|
||||
|
||||
// Tamper with the auth tag
|
||||
const tamperedAuthTag = Buffer.from(encrypted.authTag, 'base64');
|
||||
tamperedAuthTag[0] ^= 0xff; // Flip bits
|
||||
const tamperedPayload: EncryptedPayload = {
|
||||
...encrypted,
|
||||
authTag: tamperedAuthTag.toString('base64'),
|
||||
};
|
||||
|
||||
expect(() => decryptData(tamperedPayload, key)).toThrow();
|
||||
});
|
||||
|
||||
it('should throw error when ciphertext is tampered', () => {
|
||||
const key = generateTestKey();
|
||||
const plaintext = 'Sensitive data';
|
||||
|
||||
const encrypted = encryptData(plaintext, key);
|
||||
|
||||
// Tamper with the ciphertext
|
||||
const tamperedData = Buffer.from(encrypted.data, 'base64');
|
||||
tamperedData[0] ^= 0xff; // Flip bits
|
||||
const tamperedPayload: EncryptedPayload = {
|
||||
...encrypted,
|
||||
data: tamperedData.toString('base64'),
|
||||
};
|
||||
|
||||
expect(() => decryptData(tamperedPayload, key)).toThrow();
|
||||
});
|
||||
|
||||
it('should throw error when IV is tampered', () => {
|
||||
const key = generateTestKey();
|
||||
const plaintext = 'Sensitive data';
|
||||
|
||||
const encrypted = encryptData(plaintext, key);
|
||||
|
||||
// Tamper with the IV
|
||||
const tamperedIv = Buffer.from(encrypted.iv, 'base64');
|
||||
tamperedIv[0] ^= 0xff; // Flip bits
|
||||
const tamperedPayload: EncryptedPayload = {
|
||||
...encrypted,
|
||||
iv: tamperedIv.toString('base64'),
|
||||
};
|
||||
|
||||
expect(() => decryptData(tamperedPayload, key)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('wrong key handling', () => {
|
||||
it('should throw error when decrypting with wrong key', () => {
|
||||
const key1 = generateTestKey();
|
||||
const key2 = generateTestKey();
|
||||
const plaintext = 'Sensitive data';
|
||||
|
||||
const encrypted = encryptData(plaintext, key1);
|
||||
|
||||
// Try to decrypt with a different key
|
||||
expect(() => decryptData(encrypted, key2)).toThrow();
|
||||
});
|
||||
|
||||
it('should throw error when key is partially wrong', () => {
|
||||
const key = generateTestKey();
|
||||
const plaintext = 'Sensitive data';
|
||||
|
||||
const encrypted = encryptData(plaintext, key);
|
||||
|
||||
// Create a key with one byte different
|
||||
const wrongKey = Buffer.from(key);
|
||||
wrongKey[0] ^= 0xff;
|
||||
|
||||
expect(() => decryptData(encrypted, wrongKey)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('malformed payload detection', () => {
|
||||
it('should throw error for empty IV', () => {
|
||||
const key = generateTestKey();
|
||||
const encrypted = encryptData('test', key);
|
||||
|
||||
const malformed: EncryptedPayload = {
|
||||
...encrypted,
|
||||
iv: '',
|
||||
};
|
||||
|
||||
expect(() => decryptData(malformed, key)).toThrow();
|
||||
});
|
||||
|
||||
it('should throw error for empty auth tag', () => {
|
||||
const key = generateTestKey();
|
||||
const encrypted = encryptData('test', key);
|
||||
|
||||
const malformed: EncryptedPayload = {
|
||||
...encrypted,
|
||||
authTag: '',
|
||||
};
|
||||
|
||||
expect(() => decryptData(malformed, key)).toThrow();
|
||||
});
|
||||
|
||||
it('should throw error for invalid base64 in IV', () => {
|
||||
const key = generateTestKey();
|
||||
const encrypted = encryptData('test', key);
|
||||
|
||||
const malformed: EncryptedPayload = {
|
||||
...encrypted,
|
||||
iv: '!!!not-valid-base64!!!',
|
||||
};
|
||||
|
||||
expect(() => decryptData(malformed, key)).toThrow();
|
||||
});
|
||||
|
||||
it('should throw error for truncated auth tag', () => {
|
||||
const key = generateTestKey();
|
||||
const encrypted = encryptData('test', key);
|
||||
|
||||
// Truncate auth tag to just 4 bytes (minimum allowed, but wrong value)
|
||||
// This won't match the actual tag, so authentication will fail
|
||||
const truncatedTag = crypto.randomBytes(4); // Random 4 bytes won't match
|
||||
const malformed: EncryptedPayload = {
|
||||
...encrypted,
|
||||
authTag: truncatedTag.toString('base64'),
|
||||
};
|
||||
|
||||
// Note: With Node.js deprecation warning, very short tags may still be
|
||||
// accepted but will fail authentication during decipher.final()
|
||||
expect(() => decryptData(malformed, key)).toThrow();
|
||||
});
|
||||
|
||||
it('should throw error for completely wrong auth tag length', () => {
|
||||
const key = generateTestKey();
|
||||
const encrypted = encryptData('test', key);
|
||||
|
||||
// Use a completely wrong auth tag (right length but wrong value)
|
||||
const wrongTag = crypto.randomBytes(16); // Same length as real tag
|
||||
const malformed: EncryptedPayload = {
|
||||
...encrypted,
|
||||
authTag: wrongTag.toString('base64'),
|
||||
};
|
||||
|
||||
expect(() => decryptData(malformed, key)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEncryptionKey', () => {
|
||||
const originalEnv = process.env[ENCRYPTION_KEY_ENV];
|
||||
const keyFilePath = getKeyFilePath();
|
||||
|
||||
function mockKeyFile(content?: string): void {
|
||||
const exists = content !== undefined;
|
||||
mockFs.existsSync.mockImplementation((path: string) => {
|
||||
if (path === keyFilePath) return exists;
|
||||
return mockFs.originals.existsSync(path);
|
||||
});
|
||||
if (exists) {
|
||||
mockFs.readFileSync.mockImplementation((path: string, encoding?: string) => {
|
||||
if (path === keyFilePath) return content;
|
||||
return mockFs.originals.readFileSync(path, encoding as BufferEncoding);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.existsSync.mockImplementation(mockFs.originals.existsSync);
|
||||
mockFs.readFileSync.mockImplementation(mockFs.originals.readFileSync);
|
||||
if (originalEnv !== undefined) {
|
||||
process.env[ENCRYPTION_KEY_ENV] = originalEnv;
|
||||
} else {
|
||||
delete process.env[ENCRYPTION_KEY_ENV];
|
||||
}
|
||||
});
|
||||
|
||||
describe('from env var', () => {
|
||||
beforeEach(() => {
|
||||
mockKeyFile();
|
||||
});
|
||||
|
||||
it('should return null when env var is not set', () => {
|
||||
delete process.env[ENCRYPTION_KEY_ENV];
|
||||
expect(getEncryptionKey()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for empty string', () => {
|
||||
process.env[ENCRYPTION_KEY_ENV] = '';
|
||||
expect(getEncryptionKey()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for invalid hex (too short)', () => {
|
||||
process.env[ENCRYPTION_KEY_ENV] = 'abc123'; // Only 6 chars, need 64
|
||||
expect(getEncryptionKey()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for invalid hex (too long)', () => {
|
||||
process.env[ENCRYPTION_KEY_ENV] = 'a'.repeat(128); // 128 chars, need 64
|
||||
expect(getEncryptionKey()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for non-hex characters', () => {
|
||||
process.env[ENCRYPTION_KEY_ENV] = 'g'.repeat(64); // 'g' is not hex
|
||||
expect(getEncryptionKey()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return valid key buffer for correct hex string', () => {
|
||||
const keyHex = generateTestKeyHex();
|
||||
process.env[ENCRYPTION_KEY_ENV] = keyHex;
|
||||
|
||||
const key = getEncryptionKey();
|
||||
expect(key).not.toBeNull();
|
||||
expect(key).toBeInstanceOf(Buffer);
|
||||
expect(key!.length).toBe(32); // 256 bits
|
||||
expect(key!.toString('hex')).toBe(keyHex.toLowerCase());
|
||||
});
|
||||
|
||||
it('should accept uppercase hex', () => {
|
||||
const keyHex = generateTestKeyHex().toUpperCase();
|
||||
process.env[ENCRYPTION_KEY_ENV] = keyHex;
|
||||
|
||||
const key = getEncryptionKey();
|
||||
expect(key).not.toBeNull();
|
||||
expect(key!.length).toBe(32);
|
||||
});
|
||||
|
||||
it('should accept mixed case hex', () => {
|
||||
const keyHex = generateTestKeyHex();
|
||||
const mixedCase = keyHex
|
||||
.split('')
|
||||
.map((c, i) => (i % 2 === 0 ? c.toUpperCase() : c.toLowerCase()))
|
||||
.join('');
|
||||
process.env[ENCRYPTION_KEY_ENV] = mixedCase;
|
||||
|
||||
const key = getEncryptionKey();
|
||||
expect(key).not.toBeNull();
|
||||
expect(key!.length).toBe(32);
|
||||
});
|
||||
});
|
||||
|
||||
describe('from key file fallback', () => {
|
||||
beforeEach(() => {
|
||||
delete process.env[ENCRYPTION_KEY_ENV];
|
||||
});
|
||||
|
||||
it('should return key when key file exists with valid hex', () => {
|
||||
const keyHex = generateTestKeyHex();
|
||||
mockKeyFile(keyHex);
|
||||
|
||||
const key = getEncryptionKey();
|
||||
expect(key).not.toBeNull();
|
||||
expect(key).toBeInstanceOf(Buffer);
|
||||
expect(key!.length).toBe(32);
|
||||
expect(key!.toString('hex')).toBe(keyHex.toLowerCase());
|
||||
});
|
||||
|
||||
it('should return null when key file does not exist', () => {
|
||||
mockKeyFile();
|
||||
expect(getEncryptionKey()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when key file contains invalid hex', () => {
|
||||
mockKeyFile('not-valid-hex');
|
||||
expect(getEncryptionKey()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEncryptedPayload', () => {
|
||||
it('should return true for valid encrypted payload', () => {
|
||||
const key = generateTestKey();
|
||||
const encrypted = encryptData('test', key);
|
||||
|
||||
expect(isEncryptedPayload(encrypted)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for null', () => {
|
||||
expect(isEncryptedPayload(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for undefined', () => {
|
||||
expect(isEncryptedPayload(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for plain object without encrypted flag', () => {
|
||||
expect(isEncryptedPayload({ data: 'test' })).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for object with encrypted: false', () => {
|
||||
expect(
|
||||
isEncryptedPayload({
|
||||
encrypted: false,
|
||||
version: 1,
|
||||
iv: 'test',
|
||||
authTag: 'test',
|
||||
data: 'test',
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for object missing version', () => {
|
||||
expect(
|
||||
isEncryptedPayload({
|
||||
encrypted: true,
|
||||
iv: 'test',
|
||||
authTag: 'test',
|
||||
data: 'test',
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for object missing iv', () => {
|
||||
expect(
|
||||
isEncryptedPayload({
|
||||
encrypted: true,
|
||||
version: 1,
|
||||
authTag: 'test',
|
||||
data: 'test',
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for object missing authTag', () => {
|
||||
expect(
|
||||
isEncryptedPayload({
|
||||
encrypted: true,
|
||||
version: 1,
|
||||
iv: 'test',
|
||||
data: 'test',
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for object missing data', () => {
|
||||
expect(
|
||||
isEncryptedPayload({
|
||||
encrypted: true,
|
||||
version: 1,
|
||||
iv: 'test',
|
||||
authTag: 'test',
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for array', () => {
|
||||
expect(isEncryptedPayload([])).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for string', () => {
|
||||
expect(isEncryptedPayload('encrypted')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for number', () => {
|
||||
expect(isEncryptedPayload(42)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,203 +0,0 @@
|
||||
/**
|
||||
* Encryption utilities for state file protection using AES-256-GCM.
|
||||
*/
|
||||
|
||||
import * as crypto from 'crypto';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
// ============================================
|
||||
// Constants
|
||||
// ============================================
|
||||
export const ENCRYPTION_ALGORITHM = 'aes-256-gcm';
|
||||
export const ENCRYPTION_KEY_ENV = 'AGENT_BROWSER_ENCRYPTION_KEY';
|
||||
export const IV_LENGTH = 12; // 96 bits for GCM
|
||||
const KEY_FILE_NAME = '.encryption-key';
|
||||
|
||||
/**
|
||||
* Encrypted payload structure.
|
||||
*/
|
||||
export interface EncryptedPayload {
|
||||
version: 1;
|
||||
encrypted: true;
|
||||
iv: string; // Base64 encoded
|
||||
authTag: string; // Base64 encoded
|
||||
data: string; // Base64 encoded ciphertext
|
||||
}
|
||||
|
||||
export function getKeyFilePath(): string {
|
||||
return join(os.homedir(), '.agent-browser', KEY_FILE_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict file permissions to the current user only.
|
||||
* On Unix, the caller should use `mode: 0o600` when writing. This function
|
||||
* handles Windows where Node's mode parameter is ignored.
|
||||
*/
|
||||
export function restrictFilePermissions(filePath: string): void {
|
||||
if (os.platform() !== 'win32') return;
|
||||
try {
|
||||
execSync(`icacls "${filePath}" /inheritance:r /grant:r "%USERNAME%:F"`, {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort; may fail in some environments (containers, restricted shells)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict directory permissions to the current user only.
|
||||
* On Unix, the caller should use `mode: 0o700` when creating. This function
|
||||
* handles Windows where Node's mode parameter is ignored.
|
||||
*/
|
||||
export function restrictDirPermissions(dirPath: string): void {
|
||||
if (os.platform() !== 'win32') return;
|
||||
try {
|
||||
execSync(`icacls "${dirPath}" /inheritance:r /grant:r "%USERNAME%:(OI)(CI)F"`, {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
}
|
||||
|
||||
function parseKeyHex(keyHex: string): Buffer | null {
|
||||
if (!/^[a-fA-F0-9]{64}$/.test(keyHex.trim())) return null;
|
||||
return Buffer.from(keyHex.trim(), 'hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get encryption key from environment variable or key file.
|
||||
* The key should be a 32-byte (256-bit) hex-encoded string (64 characters).
|
||||
* Generate with: openssl rand -hex 32
|
||||
*
|
||||
* Checks (in order):
|
||||
* 1. AGENT_BROWSER_ENCRYPTION_KEY env var
|
||||
* 2. ~/.agent-browser/.encryption-key file
|
||||
*
|
||||
* @returns Buffer containing the key, or null if not available
|
||||
*/
|
||||
export function getEncryptionKey(): Buffer | null {
|
||||
const keyHex = process.env[ENCRYPTION_KEY_ENV];
|
||||
if (keyHex) {
|
||||
const key = parseKeyHex(keyHex);
|
||||
if (!key) {
|
||||
console.warn(
|
||||
`Warning: ${ENCRYPTION_KEY_ENV} should be a 64-character hex string (256 bits). ` +
|
||||
`Generate one with: openssl rand -hex 32`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
const keyFilePath = getKeyFilePath();
|
||||
if (existsSync(keyFilePath)) {
|
||||
try {
|
||||
const fileHex = readFileSync(keyFilePath, 'utf-8');
|
||||
return parseKeyHex(fileHex);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure an encryption key is available, auto-generating one if needed.
|
||||
* On first call without an existing key, generates a random 256-bit key
|
||||
* and writes it to ~/.agent-browser/.encryption-key (mode 0600).
|
||||
*/
|
||||
export function ensureEncryptionKey(): Buffer {
|
||||
const existing = getEncryptionKey();
|
||||
if (existing) return existing;
|
||||
|
||||
const key = crypto.randomBytes(32);
|
||||
const keyHex = key.toString('hex');
|
||||
|
||||
const dir = join(os.homedir(), '.agent-browser');
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
restrictDirPermissions(dir);
|
||||
}
|
||||
|
||||
const keyFilePath = getKeyFilePath();
|
||||
writeFileSync(keyFilePath, keyHex + '\n', { mode: 0o600 });
|
||||
restrictFilePermissions(keyFilePath);
|
||||
|
||||
console.error(
|
||||
`[agent-browser] Auto-generated encryption key at ${keyFilePath} -- back up this file or set ${ENCRYPTION_KEY_ENV}`
|
||||
);
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt data using AES-256-GCM.
|
||||
* Returns a JSON-serializable payload with IV, auth tag, and encrypted data.
|
||||
*
|
||||
* @param plaintext - The string to encrypt
|
||||
* @param key - The 256-bit encryption key
|
||||
* @returns Encrypted payload object
|
||||
*/
|
||||
export function encryptData(plaintext: string, key: Buffer): EncryptedPayload {
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ENCRYPTION_ALGORITHM, key, iv);
|
||||
|
||||
let encrypted = cipher.update(plaintext, 'utf8');
|
||||
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
encrypted: true,
|
||||
iv: iv.toString('base64'),
|
||||
authTag: cipher.getAuthTag().toString('base64'),
|
||||
data: encrypted.toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt data using AES-256-GCM.
|
||||
*
|
||||
* @param payload - The encrypted payload object
|
||||
* @param key - The 256-bit encryption key
|
||||
* @returns Decrypted plaintext string
|
||||
* @throws Error if decryption fails (wrong key, tampered data, etc.)
|
||||
*/
|
||||
export function decryptData(payload: EncryptedPayload, key: Buffer): string {
|
||||
const iv = Buffer.from(payload.iv, 'base64');
|
||||
const authTag = Buffer.from(payload.authTag, 'base64');
|
||||
const encryptedData = Buffer.from(payload.data, 'base64');
|
||||
|
||||
const decipher = crypto.createDecipheriv(ENCRYPTION_ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(encryptedData);
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||
|
||||
return decrypted.toString('utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a parsed JSON object is an encrypted payload.
|
||||
*
|
||||
* @param data - The object to check
|
||||
* @returns True if the object is a valid encrypted payload
|
||||
*/
|
||||
export function isEncryptedPayload(data: unknown): data is EncryptedPayload {
|
||||
return (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'encrypted' in data &&
|
||||
(data as EncryptedPayload).encrypted === true &&
|
||||
'version' in data &&
|
||||
'iv' in data &&
|
||||
'authTag' in data &&
|
||||
'data' in data
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
export { BrowserManager, getDefaultTimeout } from './browser.js';
|
||||
export type {
|
||||
BrowserLaunchOptions,
|
||||
NavigateOptions,
|
||||
ScreencastFrame,
|
||||
ScreencastOptions,
|
||||
} from './browser.js';
|
||||
export { IOSManager } from './ios-manager.js';
|
||||
export { executeCommand } from './actions.js';
|
||||
export type { Command, LaunchCommand, NavigateCommand, Response } from './types.js';
|
||||
export {
|
||||
cleanupSocket,
|
||||
getAppDir,
|
||||
getConnectionInfo,
|
||||
getPidFile,
|
||||
getPortFile,
|
||||
getPortForSession,
|
||||
getSession,
|
||||
getSocketDir,
|
||||
getSocketPath,
|
||||
getStreamPortFile,
|
||||
isDaemonRunning,
|
||||
safeWrite,
|
||||
setSession,
|
||||
startDaemon,
|
||||
} from './daemon.js';
|
||||
@@ -1,35 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { injectSessionId, stripSessionId } from './inspect-server.js';
|
||||
|
||||
describe('injectSessionId', () => {
|
||||
it('should inject sessionId into a command', () => {
|
||||
const input = '{"id":1,"method":"DOM.getDocument"}';
|
||||
const result = JSON.parse(injectSessionId(input, 'abc123'));
|
||||
expect(result.sessionId).toBe('abc123');
|
||||
expect(result.method).toBe('DOM.getDocument');
|
||||
expect(result.id).toBe(1);
|
||||
});
|
||||
|
||||
it('should inject sessionId into an empty object', () => {
|
||||
const result = JSON.parse(injectSessionId('{}', 'abc'));
|
||||
expect(result.sessionId).toBe('abc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripSessionId', () => {
|
||||
it('should remove sessionId from a message', () => {
|
||||
const input = '{"id":1,"result":{},"sessionId":"abc123"}';
|
||||
const result = JSON.parse(stripSessionId(input));
|
||||
expect(result.sessionId).toBeUndefined();
|
||||
expect(result.id).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inject then strip roundtrip', () => {
|
||||
it('should return the original message after inject + strip', () => {
|
||||
const input = '{"id":42,"method":"Runtime.evaluate"}';
|
||||
const injected = injectSessionId(input, 'sess1');
|
||||
const stripped = stripSessionId(injected);
|
||||
expect(JSON.parse(stripped)).toEqual(JSON.parse(input));
|
||||
});
|
||||
});
|
||||
@@ -1,240 +0,0 @@
|
||||
import http from 'node:http';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
|
||||
export interface InspectServerOptions {
|
||||
chromeHostPort: string;
|
||||
targetId: string;
|
||||
chromeWsUrl: string;
|
||||
}
|
||||
|
||||
let nextAttachId = -1000;
|
||||
|
||||
export function injectSessionId(json: string, sessionId: string): string {
|
||||
const msg = JSON.parse(json);
|
||||
msg.sessionId = sessionId;
|
||||
return JSON.stringify(msg);
|
||||
}
|
||||
|
||||
export function stripSessionId(json: string): string {
|
||||
const msg = JSON.parse(json);
|
||||
delete msg.sessionId;
|
||||
return JSON.stringify(msg);
|
||||
}
|
||||
|
||||
// The Node.js path opens its own WebSocket to Chrome rather than sharing
|
||||
// Playwright's internal connection. This avoids interfering with Playwright's
|
||||
// CDP session management. The Rust/native path takes the opposite approach,
|
||||
// sharing the daemon's existing browser-level WebSocket via InspectProxyHandle.
|
||||
export class InspectServer {
|
||||
private httpServer: http.Server;
|
||||
private wss: WebSocketServer;
|
||||
private chromeWs: WebSocket | null = null;
|
||||
private sessions = new Map<string, WebSocket>();
|
||||
private pendingAttaches = new Map<number, (sessionId: string | null) => void>();
|
||||
private _port: number = 0;
|
||||
|
||||
constructor(private options: InspectServerOptions) {
|
||||
this.httpServer = http.createServer(this.handleHttp.bind(this));
|
||||
this.wss = new WebSocketServer({ server: this.httpServer, path: '/ws' });
|
||||
this.wss.on('connection', this.handleWsConnection.bind(this));
|
||||
}
|
||||
|
||||
get port(): number {
|
||||
return this._port;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
await this.connectChrome();
|
||||
return new Promise((resolve, reject) => {
|
||||
this.httpServer.listen(0, '127.0.0.1', () => {
|
||||
const addr = this.httpServer.address();
|
||||
if (addr && typeof addr !== 'string') {
|
||||
this._port = addr.port;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
this.httpServer.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
for (const [sessionId, devtoolsWs] of this.sessions) {
|
||||
this.detachSession(sessionId);
|
||||
devtoolsWs.close();
|
||||
}
|
||||
this.sessions.clear();
|
||||
this.chromeWs?.close();
|
||||
this.chromeWs = null;
|
||||
this.wss.close();
|
||||
this.httpServer.close();
|
||||
}
|
||||
|
||||
private connectChrome(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(this.options.chromeWsUrl);
|
||||
ws.on('open', () => {
|
||||
this.chromeWs = ws;
|
||||
resolve();
|
||||
});
|
||||
ws.on('error', (err) => {
|
||||
if (!this.chromeWs) {
|
||||
reject(new Error(`Chrome WebSocket connection failed: ${err.message}`));
|
||||
} else {
|
||||
console.error('[inspect] Chrome WebSocket error:', err.message);
|
||||
for (const devtoolsWs of this.sessions.values()) {
|
||||
devtoolsWs.close();
|
||||
}
|
||||
this.sessions.clear();
|
||||
}
|
||||
});
|
||||
ws.on('close', () => {
|
||||
this.chromeWs = null;
|
||||
for (const devtoolsWs of this.sessions.values()) {
|
||||
devtoolsWs.close();
|
||||
}
|
||||
this.sessions.clear();
|
||||
});
|
||||
ws.on('message', (data) => this.handleChromeMessage(data));
|
||||
});
|
||||
}
|
||||
|
||||
private handleChromeMessage(data: unknown): void {
|
||||
try {
|
||||
const text = String(data);
|
||||
const msg = JSON.parse(text);
|
||||
|
||||
// Check if this is a response to a pending attachToTarget request
|
||||
if (msg.id != null && msg.id < 0) {
|
||||
const resolve = this.pendingAttaches.get(msg.id);
|
||||
if (resolve) {
|
||||
this.pendingAttaches.delete(msg.id);
|
||||
resolve(msg.result?.sessionId ?? null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Route session-scoped messages to the correct DevTools client
|
||||
const sessionId: string | undefined = msg.sessionId;
|
||||
if (!sessionId) return;
|
||||
|
||||
const devtoolsWs = this.sessions.get(sessionId);
|
||||
if (!devtoolsWs || devtoolsWs.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
devtoolsWs.send(stripSessionId(text));
|
||||
} catch (err) {
|
||||
console.error('[inspect] Chrome message handling error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private handleHttp(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
if (req.url === '/' || req.url === '') {
|
||||
const location = `http://${this.options.chromeHostPort}/devtools/devtools_app.html?ws=127.0.0.1:${this._port}/ws`;
|
||||
res.writeHead(302, { Location: location, 'Content-Type': 'text/html' });
|
||||
res.end(`<html><body>Redirecting to <a href="${location}">${location}</a></body></html>`);
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
|
||||
private handleWsConnection(devtoolsWs: WebSocket): void {
|
||||
if (!this.chromeWs || this.chromeWs.readyState !== WebSocket.OPEN) {
|
||||
devtoolsWs.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const attachId = nextAttachId--;
|
||||
const attachMsg = JSON.stringify({
|
||||
id: attachId,
|
||||
method: 'Target.attachToTarget',
|
||||
params: { targetId: this.options.targetId, flatten: true },
|
||||
});
|
||||
|
||||
// Track the session ID once attach completes; closed by close/error handlers
|
||||
// that are registered immediately (before the async attach resolves) so
|
||||
// early disconnects still trigger cleanup.
|
||||
let sessionId: string | null = null;
|
||||
|
||||
devtoolsWs.on('close', () => {
|
||||
if (sessionId) {
|
||||
this.sessions.delete(sessionId);
|
||||
this.detachSession(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
devtoolsWs.on('error', () => {
|
||||
if (sessionId) {
|
||||
this.sessions.delete(sessionId);
|
||||
this.detachSession(sessionId);
|
||||
}
|
||||
devtoolsWs.close();
|
||||
});
|
||||
|
||||
const messageBuffer: string[] = [];
|
||||
|
||||
devtoolsWs.on('message', (data) => {
|
||||
if (!this.chromeWs || this.chromeWs.readyState !== WebSocket.OPEN) return;
|
||||
const text = String(data);
|
||||
if (!sessionId) {
|
||||
messageBuffer.push(text);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.chromeWs.send(injectSessionId(text, sessionId));
|
||||
} catch (err) {
|
||||
console.error('[inspect] DevTools message forwarding error:', err);
|
||||
}
|
||||
});
|
||||
|
||||
const attachPromise = new Promise<string | null>((resolve) => {
|
||||
this.pendingAttaches.set(attachId, resolve);
|
||||
this.chromeWs!.send(attachMsg);
|
||||
setTimeout(() => {
|
||||
if (this.pendingAttaches.has(attachId)) {
|
||||
this.pendingAttaches.delete(attachId);
|
||||
resolve(null);
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
attachPromise.then((sid) => {
|
||||
if (!sid) {
|
||||
console.error('[inspect] Failed to attach to target');
|
||||
devtoolsWs.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (devtoolsWs.readyState !== WebSocket.OPEN) {
|
||||
this.detachSession(sid);
|
||||
return;
|
||||
}
|
||||
|
||||
sessionId = sid;
|
||||
this.sessions.set(sid, devtoolsWs);
|
||||
|
||||
for (const buffered of messageBuffer) {
|
||||
try {
|
||||
this.chromeWs!.send(injectSessionId(buffered, sid));
|
||||
} catch (err) {
|
||||
console.error('[inspect] DevTools message forwarding error:', err);
|
||||
}
|
||||
}
|
||||
messageBuffer.length = 0;
|
||||
});
|
||||
}
|
||||
|
||||
private detachSession(sessionId: string): void {
|
||||
if (!this.chromeWs || this.chromeWs.readyState !== WebSocket.OPEN) return;
|
||||
const detachId = nextAttachId--;
|
||||
const detachMsg = JSON.stringify({
|
||||
id: detachId,
|
||||
method: 'Target.detachFromTarget',
|
||||
params: { sessionId },
|
||||
});
|
||||
try {
|
||||
this.chromeWs.send(detachMsg);
|
||||
} catch (err) {
|
||||
console.error('[inspect] Failed to detach session:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
/**
|
||||
* iOS command execution - mirrors actions.ts but for iOS Safari via Appium.
|
||||
* Provides 1:1 command parity where possible.
|
||||
*/
|
||||
|
||||
import type { IOSManager } from './ios-manager.js';
|
||||
import type { Command, Response } from './types.js';
|
||||
|
||||
function successResponse<T>(id: string, data: T): Response<T> {
|
||||
return { id, success: true, data };
|
||||
}
|
||||
|
||||
function errorResponse(id: string, error: string): Response {
|
||||
return { id, success: false, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command on the iOS manager
|
||||
*/
|
||||
export async function executeIOSCommand(command: Command, manager: IOSManager): Promise<Response> {
|
||||
const { id, action } = command;
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'launch': {
|
||||
const cmd = command as any;
|
||||
await manager.launch({
|
||||
device: cmd.device,
|
||||
udid: cmd.udid,
|
||||
});
|
||||
const info = manager.getDeviceInfo();
|
||||
return successResponse(id, {
|
||||
launched: true,
|
||||
device: info?.name ?? 'iOS Simulator',
|
||||
udid: info?.udid,
|
||||
});
|
||||
}
|
||||
|
||||
case 'navigate': {
|
||||
const cmd = command as any;
|
||||
const result = await manager.navigate(cmd.url);
|
||||
return successResponse(id, result);
|
||||
}
|
||||
|
||||
case 'click': {
|
||||
const cmd = command as any;
|
||||
await manager.click(cmd.selector);
|
||||
return successResponse(id, { clicked: true });
|
||||
}
|
||||
|
||||
case 'tap': {
|
||||
const cmd = command as any;
|
||||
await manager.tap(cmd.selector);
|
||||
return successResponse(id, { tapped: true });
|
||||
}
|
||||
|
||||
case 'type': {
|
||||
const cmd = command as any;
|
||||
await manager.type(cmd.selector, cmd.text, {
|
||||
delay: cmd.delay,
|
||||
clear: cmd.clear,
|
||||
});
|
||||
return successResponse(id, { typed: true });
|
||||
}
|
||||
|
||||
case 'fill': {
|
||||
const cmd = command as any;
|
||||
await manager.fill(cmd.selector, cmd.value);
|
||||
return successResponse(id, { filled: true });
|
||||
}
|
||||
|
||||
case 'screenshot': {
|
||||
const cmd = command as any;
|
||||
const result = await manager.screenshot({
|
||||
path: cmd.path,
|
||||
fullPage: cmd.fullPage,
|
||||
});
|
||||
return successResponse(id, result);
|
||||
}
|
||||
|
||||
case 'snapshot': {
|
||||
const cmd = command as any;
|
||||
const result = await manager.getSnapshot({
|
||||
interactive: cmd.interactive,
|
||||
});
|
||||
return successResponse(id, { snapshot: result.tree, refs: result.refs });
|
||||
}
|
||||
|
||||
case 'scroll': {
|
||||
const cmd = command as any;
|
||||
await manager.scroll({
|
||||
selector: cmd.selector,
|
||||
x: cmd.x,
|
||||
y: cmd.y,
|
||||
direction: cmd.direction,
|
||||
amount: cmd.amount,
|
||||
});
|
||||
return successResponse(id, { scrolled: true });
|
||||
}
|
||||
|
||||
case 'swipe': {
|
||||
const cmd = command as any;
|
||||
await manager.swipe(cmd.direction, { distance: cmd.distance });
|
||||
return successResponse(id, { swiped: true });
|
||||
}
|
||||
|
||||
case 'evaluate': {
|
||||
const cmd = command as any;
|
||||
const result = await manager.evaluate(cmd.script, ...(cmd.args ?? []));
|
||||
return successResponse(id, { result });
|
||||
}
|
||||
|
||||
case 'wait': {
|
||||
const cmd = command as any;
|
||||
await manager.wait({
|
||||
selector: cmd.selector,
|
||||
timeout: cmd.timeout,
|
||||
state: cmd.state,
|
||||
});
|
||||
return successResponse(id, { waited: true });
|
||||
}
|
||||
|
||||
case 'press': {
|
||||
const cmd = command as any;
|
||||
await manager.press(cmd.key);
|
||||
return successResponse(id, { pressed: true });
|
||||
}
|
||||
|
||||
case 'hover': {
|
||||
const cmd = command as any;
|
||||
await manager.hover(cmd.selector);
|
||||
return successResponse(id, { hovered: true });
|
||||
}
|
||||
|
||||
case 'content': {
|
||||
const cmd = command as any;
|
||||
const html = await manager.getContent(cmd.selector);
|
||||
return successResponse(id, { html });
|
||||
}
|
||||
|
||||
case 'gettext': {
|
||||
const cmd = command as any;
|
||||
const text = await manager.getText(cmd.selector);
|
||||
return successResponse(id, { text });
|
||||
}
|
||||
|
||||
case 'getattribute': {
|
||||
const cmd = command as any;
|
||||
const value = await manager.getAttribute(cmd.selector, cmd.attribute);
|
||||
return successResponse(id, { value });
|
||||
}
|
||||
|
||||
case 'isvisible': {
|
||||
const cmd = command as any;
|
||||
const visible = await manager.isVisible(cmd.selector);
|
||||
return successResponse(id, { visible });
|
||||
}
|
||||
|
||||
case 'isenabled': {
|
||||
const cmd = command as any;
|
||||
const enabled = await manager.isEnabled(cmd.selector);
|
||||
return successResponse(id, { enabled });
|
||||
}
|
||||
|
||||
case 'url': {
|
||||
const url = await manager.getUrl();
|
||||
return successResponse(id, { url });
|
||||
}
|
||||
|
||||
case 'title': {
|
||||
const title = await manager.getTitle();
|
||||
return successResponse(id, { title });
|
||||
}
|
||||
|
||||
case 'back': {
|
||||
await manager.goBack();
|
||||
return successResponse(id, { navigated: 'back' });
|
||||
}
|
||||
|
||||
case 'forward': {
|
||||
await manager.goForward();
|
||||
return successResponse(id, { navigated: 'forward' });
|
||||
}
|
||||
|
||||
case 'reload': {
|
||||
await manager.reload();
|
||||
return successResponse(id, { reloaded: true });
|
||||
}
|
||||
|
||||
case 'select': {
|
||||
const cmd = command as any;
|
||||
await manager.select(cmd.selector, cmd.values);
|
||||
return successResponse(id, { selected: true });
|
||||
}
|
||||
|
||||
case 'check': {
|
||||
const cmd = command as any;
|
||||
await manager.check(cmd.selector);
|
||||
return successResponse(id, { checked: true });
|
||||
}
|
||||
|
||||
case 'uncheck': {
|
||||
const cmd = command as any;
|
||||
await manager.uncheck(cmd.selector);
|
||||
return successResponse(id, { unchecked: true });
|
||||
}
|
||||
|
||||
case 'focus': {
|
||||
const cmd = command as any;
|
||||
await manager.focus(cmd.selector);
|
||||
return successResponse(id, { focused: true });
|
||||
}
|
||||
|
||||
case 'clear': {
|
||||
const cmd = command as any;
|
||||
await manager.clear(cmd.selector);
|
||||
return successResponse(id, { cleared: true });
|
||||
}
|
||||
|
||||
case 'count': {
|
||||
const cmd = command as any;
|
||||
const count = await manager.count(cmd.selector);
|
||||
return successResponse(id, { count });
|
||||
}
|
||||
|
||||
case 'boundingbox': {
|
||||
const cmd = command as any;
|
||||
const box = await manager.getBoundingBox(cmd.selector);
|
||||
return successResponse(id, { box });
|
||||
}
|
||||
|
||||
case 'close': {
|
||||
await manager.close();
|
||||
return successResponse(id, { closed: true });
|
||||
}
|
||||
|
||||
// iOS-specific: device list
|
||||
case 'device_list': {
|
||||
const devices = await manager.listDevices();
|
||||
return successResponse(id, { devices });
|
||||
}
|
||||
|
||||
// Commands that don't apply to iOS Safari
|
||||
case 'tab_new':
|
||||
case 'tab_list':
|
||||
case 'tab_switch':
|
||||
case 'tab_close':
|
||||
case 'window_new':
|
||||
return errorResponse(
|
||||
id,
|
||||
`Command '${action}' is not supported on iOS Safari. Mobile Safari does not support programmatic tab management.`
|
||||
);
|
||||
|
||||
case 'pdf':
|
||||
return errorResponse(id, 'PDF generation is not supported on iOS Safari.');
|
||||
|
||||
case 'screencast_start':
|
||||
case 'screencast_stop':
|
||||
return errorResponse(id, 'Screencast is not supported on iOS (requires CDP).');
|
||||
|
||||
case 'recording_start':
|
||||
case 'recording_stop':
|
||||
case 'recording_restart':
|
||||
return errorResponse(id, 'Video recording is not yet supported on iOS.');
|
||||
|
||||
default:
|
||||
return errorResponse(id, `Unknown or unsupported iOS command: ${action}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return errorResponse(id, message);
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { IOSManager } from './ios-manager.js';
|
||||
|
||||
// Mock node-simctl
|
||||
vi.mock('node-simctl', () => {
|
||||
return {
|
||||
Simctl: class MockSimctl {
|
||||
async getDevices() {
|
||||
return {
|
||||
'iOS 18.0': [
|
||||
{
|
||||
name: 'iPhone 16 Pro',
|
||||
udid: 'TEST-UDID-1234',
|
||||
state: 'Shutdown',
|
||||
isAvailable: true,
|
||||
},
|
||||
{
|
||||
name: 'iPhone 16',
|
||||
udid: 'TEST-UDID-5678',
|
||||
state: 'Booted',
|
||||
isAvailable: true,
|
||||
},
|
||||
{
|
||||
name: 'iPad Pro',
|
||||
udid: 'TEST-UDID-IPAD',
|
||||
state: 'Shutdown',
|
||||
isAvailable: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('IOSManager', () => {
|
||||
let manager: IOSManager;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new IOSManager();
|
||||
});
|
||||
|
||||
describe('listDevices', () => {
|
||||
it('should list available iOS simulators', async () => {
|
||||
const devices = await manager.listDevices();
|
||||
|
||||
expect(devices).toHaveLength(3);
|
||||
expect(devices[0]).toEqual({
|
||||
name: 'iPhone 16 Pro',
|
||||
udid: 'TEST-UDID-1234',
|
||||
state: 'Shutdown',
|
||||
runtime: 'iOS 18.0',
|
||||
isAvailable: true,
|
||||
isRealDevice: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should include runtime version for each device', async () => {
|
||||
const devices = await manager.listDevices();
|
||||
|
||||
devices.forEach((device) => {
|
||||
expect(device.runtime).toBe('iOS 18.0');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLaunched', () => {
|
||||
it('should return false when browser is not launched', () => {
|
||||
expect(manager.isLaunched()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRefData', () => {
|
||||
it('should return null for unknown refs', () => {
|
||||
// Access private method via bracket notation for testing
|
||||
const result = (manager as any).getRefData('@e99');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle @-prefixed refs', () => {
|
||||
// Set up a ref in the refMap
|
||||
(manager as any).refMap = {
|
||||
e1: { selector: 'button', role: 'button', name: 'Submit' },
|
||||
};
|
||||
|
||||
const result = (manager as any).getRefData('@e1');
|
||||
expect(result).toEqual({ selector: 'button', role: 'button', name: 'Submit' });
|
||||
});
|
||||
|
||||
it('should handle ref= prefixed refs', () => {
|
||||
(manager as any).refMap = {
|
||||
e2: { selector: 'a', role: 'link', name: 'Learn more' },
|
||||
};
|
||||
|
||||
const result = (manager as any).getRefData('ref=e2');
|
||||
expect(result).toEqual({ selector: 'a', role: 'link', name: 'Learn more' });
|
||||
});
|
||||
|
||||
it('should handle bare ref names', () => {
|
||||
(manager as any).refMap = {
|
||||
e3: { selector: 'input', role: 'textbox', name: 'Email' },
|
||||
};
|
||||
|
||||
const result = (manager as any).getRefData('e3');
|
||||
expect(result).toEqual({ selector: 'input', role: 'textbox', name: 'Email' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('IOSManager integration', () => {
|
||||
// These tests require Appium and iOS Simulator to be available
|
||||
// They are skipped by default and can be run manually
|
||||
describe.skip('with real simulator', () => {
|
||||
let manager: IOSManager;
|
||||
|
||||
beforeEach(() => {
|
||||
// Use real implementation for integration tests
|
||||
vi.resetModules();
|
||||
manager = new IOSManager();
|
||||
});
|
||||
|
||||
it('should launch Safari and navigate', async () => {
|
||||
await manager.launch({ device: 'iPhone 16 Pro' });
|
||||
expect(manager.isLaunched()).toBe(true);
|
||||
|
||||
const result = await manager.navigate('https://example.com');
|
||||
expect(result.url).toContain('example.com');
|
||||
expect(result.title).toBe('Example Domain');
|
||||
|
||||
await manager.close();
|
||||
}, 120000);
|
||||
|
||||
it('should take screenshots', async () => {
|
||||
await manager.launch({ device: 'iPhone 16 Pro' });
|
||||
await manager.navigate('https://example.com');
|
||||
|
||||
const result = await manager.screenshot();
|
||||
expect(result.base64).toBeDefined();
|
||||
expect(result.base64?.length).toBeGreaterThan(1000);
|
||||
|
||||
await manager.close();
|
||||
}, 120000);
|
||||
|
||||
it('should generate snapshots with refs', async () => {
|
||||
await manager.launch({ device: 'iPhone 16 Pro' });
|
||||
await manager.navigate('https://example.com');
|
||||
|
||||
const snapshot = await manager.getSnapshot();
|
||||
expect(snapshot.tree).toContain('link');
|
||||
expect(snapshot.tree).toContain('[ref=e1]');
|
||||
expect(snapshot.refs.e1).toBeDefined();
|
||||
expect(snapshot.refs.e1.role).toBe('link');
|
||||
|
||||
await manager.close();
|
||||
}, 120000);
|
||||
});
|
||||
});
|
||||
-1299
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
-1162
File diff suppressed because it is too large
Load Diff
-640
@@ -1,640 +0,0 @@
|
||||
/**
|
||||
* Enhanced snapshot with element refs for deterministic element selection.
|
||||
*
|
||||
* This module generates accessibility snapshots with embedded refs that can be
|
||||
* used to click/fill/interact with elements without re-querying the DOM.
|
||||
*
|
||||
* Example output:
|
||||
* - heading "Example Domain" [ref=e1] [level=1]
|
||||
* - paragraph: Some text content
|
||||
* - button "Submit" [ref=e2]
|
||||
* - textbox "Email" [ref=e3]
|
||||
*
|
||||
* Usage:
|
||||
* agent-browser snapshot # Full snapshot
|
||||
* agent-browser snapshot -i # Interactive elements only
|
||||
* agent-browser snapshot --depth 3 # Limit depth
|
||||
* agent-browser click @e2 # Click element by ref
|
||||
*/
|
||||
|
||||
import type { Page, Locator } from 'playwright-core';
|
||||
|
||||
export interface RefMap {
|
||||
[ref: string]: {
|
||||
selector: string;
|
||||
role: string;
|
||||
name: string;
|
||||
/** Index for disambiguation when multiple elements have same role+name */
|
||||
nth?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EnhancedSnapshot {
|
||||
tree: string;
|
||||
refs: RefMap;
|
||||
}
|
||||
|
||||
export interface SnapshotOptions {
|
||||
/** Only include interactive elements (buttons, links, inputs, etc.) */
|
||||
interactive?: boolean;
|
||||
/** Include cursor-interactive elements (cursor:pointer, onclick, tabindex) */
|
||||
cursor?: boolean;
|
||||
/** Maximum depth of tree to include (0 = root only) */
|
||||
maxDepth?: number;
|
||||
/** Remove structural elements without meaningful content */
|
||||
compact?: boolean;
|
||||
/** CSS selector to scope the snapshot */
|
||||
selector?: string;
|
||||
}
|
||||
|
||||
// Counter for generating refs
|
||||
let refCounter = 0;
|
||||
|
||||
/**
|
||||
* Reset ref counter (call at start of each snapshot)
|
||||
*/
|
||||
export function resetRefs(): void {
|
||||
refCounter = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate next ref ID
|
||||
*/
|
||||
function nextRef(): string {
|
||||
return `e${++refCounter}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Roles that are interactive and should get refs
|
||||
*/
|
||||
const INTERACTIVE_ROLES = new Set([
|
||||
'button',
|
||||
'link',
|
||||
'textbox',
|
||||
'checkbox',
|
||||
'radio',
|
||||
'combobox',
|
||||
'listbox',
|
||||
'menuitem',
|
||||
'menuitemcheckbox',
|
||||
'menuitemradio',
|
||||
'option',
|
||||
'searchbox',
|
||||
'slider',
|
||||
'spinbutton',
|
||||
'switch',
|
||||
'tab',
|
||||
'treeitem',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Roles that provide structure/context (get refs for text extraction)
|
||||
*/
|
||||
const CONTENT_ROLES = new Set([
|
||||
'heading',
|
||||
'cell',
|
||||
'gridcell',
|
||||
'columnheader',
|
||||
'rowheader',
|
||||
'listitem',
|
||||
'article',
|
||||
'region',
|
||||
'main',
|
||||
'navigation',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Roles that are purely structural (can be filtered in compact mode)
|
||||
*/
|
||||
const STRUCTURAL_ROLES = new Set([
|
||||
'generic',
|
||||
'group',
|
||||
'list',
|
||||
'table',
|
||||
'row',
|
||||
'rowgroup',
|
||||
'grid',
|
||||
'treegrid',
|
||||
'menu',
|
||||
'menubar',
|
||||
'toolbar',
|
||||
'tablist',
|
||||
'tree',
|
||||
'directory',
|
||||
'document',
|
||||
'application',
|
||||
'presentation',
|
||||
'none',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Build a selector string for storing in ref map
|
||||
*/
|
||||
function buildSelector(role: string, name: string): string {
|
||||
const escapedName = JSON.stringify(name);
|
||||
return `getByRole('${role}', { name: ${escapedName}, exact: true })`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the page for clickable elements that might not have proper ARIA roles.
|
||||
* This finds elements with cursor: pointer or onclick handlers.
|
||||
*/
|
||||
async function findCursorInteractiveElements(
|
||||
page: Page,
|
||||
selector?: string
|
||||
): Promise<
|
||||
Array<{
|
||||
selector: string;
|
||||
text: string;
|
||||
tagName: string;
|
||||
hasOnClick: boolean;
|
||||
hasCursorPointer: boolean;
|
||||
hasTabIndex: boolean;
|
||||
}>
|
||||
> {
|
||||
const rootSelector = selector || 'body';
|
||||
|
||||
// Use a string function body to avoid TypeScript transpilation issues
|
||||
const scriptBody = `(rootSel) => {
|
||||
const results = [];
|
||||
|
||||
// Elements that already have interactive ARIA roles - skip these
|
||||
const interactiveRoles = new Set([
|
||||
'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox', 'listbox',
|
||||
'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'searchbox',
|
||||
'slider', 'spinbutton', 'switch', 'tab', 'treeitem'
|
||||
]);
|
||||
|
||||
// Tags that are already interactive by default
|
||||
const interactiveTags = new Set([
|
||||
'a', 'button', 'input', 'select', 'textarea', 'details', 'summary'
|
||||
]);
|
||||
|
||||
const root = document.querySelector(rootSel) || document.body;
|
||||
const allElements = root.querySelectorAll('*');
|
||||
|
||||
// Build a unique selector for an element
|
||||
const buildSelector = (el) => {
|
||||
const testId = el.getAttribute('data-testid');
|
||||
if (testId) return '[data-testid="' + testId + '"]';
|
||||
if (el.id) return '#' + CSS.escape(el.id);
|
||||
|
||||
const path = [];
|
||||
let current = el;
|
||||
while (current && current !== document.body) {
|
||||
let sel = current.tagName.toLowerCase();
|
||||
const classes = Array.from(current.classList).filter(c => c.trim());
|
||||
if (classes.length > 0) sel += '.' + CSS.escape(classes[0]);
|
||||
|
||||
const parent = current.parentElement;
|
||||
if (parent) {
|
||||
const siblings = Array.from(parent.children);
|
||||
const matching = siblings.filter(s => {
|
||||
if (s.tagName !== current.tagName) return false;
|
||||
if (classes.length > 0 && !s.classList.contains(classes[0])) return false;
|
||||
return true;
|
||||
});
|
||||
if (matching.length > 1) {
|
||||
const idx = matching.indexOf(current) + 1;
|
||||
sel += ':nth-of-type(' + idx + ')';
|
||||
}
|
||||
}
|
||||
path.unshift(sel);
|
||||
current = current.parentElement;
|
||||
// Stop once the selector uniquely identifies the element (max 10 levels)
|
||||
if (path.length >= 1) {
|
||||
try {
|
||||
const candidate = path.join(' > ');
|
||||
if (document.querySelectorAll(candidate).length === 1) break;
|
||||
} catch (e) {
|
||||
// If selector is invalid, keep building
|
||||
}
|
||||
}
|
||||
if (path.length >= 10) break;
|
||||
}
|
||||
return path.join(' > ');
|
||||
};
|
||||
|
||||
for (const el of allElements) {
|
||||
const tagName = el.tagName.toLowerCase();
|
||||
if (interactiveTags.has(tagName)) continue;
|
||||
|
||||
const role = el.getAttribute('role');
|
||||
if (role && interactiveRoles.has(role.toLowerCase())) continue;
|
||||
|
||||
const computedStyle = getComputedStyle(el);
|
||||
const hasCursorPointer = computedStyle.cursor === 'pointer';
|
||||
const hasOnClick = el.hasAttribute('onclick') || el.onclick !== null;
|
||||
const tabIndex = el.getAttribute('tabindex');
|
||||
const hasTabIndex = tabIndex !== null && tabIndex !== '-1';
|
||||
|
||||
if (!hasCursorPointer && !hasOnClick && !hasTabIndex) continue;
|
||||
|
||||
// Skip elements that only inherit cursor:pointer from an ancestor
|
||||
// (the ancestor itself will be captured instead)
|
||||
if (hasCursorPointer && !hasOnClick && !hasTabIndex) {
|
||||
const parent = el.parentElement;
|
||||
if (parent && getComputedStyle(parent).cursor === 'pointer') continue;
|
||||
}
|
||||
|
||||
const text = (el.textContent || '').trim().slice(0, 100);
|
||||
if (!text) continue;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) continue;
|
||||
|
||||
results.push({
|
||||
selector: buildSelector(el),
|
||||
text,
|
||||
tagName,
|
||||
hasOnClick,
|
||||
hasCursorPointer,
|
||||
hasTabIndex
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}`;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
||||
const fn = new Function('return ' + scriptBody)();
|
||||
return page.evaluate(fn, rootSelector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enhanced snapshot with refs and optional filtering
|
||||
*/
|
||||
export async function getEnhancedSnapshot(
|
||||
page: Page,
|
||||
options: SnapshotOptions = {}
|
||||
): Promise<EnhancedSnapshot> {
|
||||
resetRefs();
|
||||
const refs: RefMap = {};
|
||||
|
||||
// Get ARIA snapshot from Playwright
|
||||
const locator = options.selector ? page.locator(options.selector) : page.locator(':root');
|
||||
const ariaTree = await locator.ariaSnapshot();
|
||||
|
||||
if (!ariaTree) {
|
||||
return {
|
||||
tree: '(empty)',
|
||||
refs: {},
|
||||
};
|
||||
}
|
||||
|
||||
// Parse and enhance the ARIA tree
|
||||
const enhancedTree = processAriaTree(ariaTree, refs, options);
|
||||
|
||||
// When cursor flag is set, also find cursor-interactive elements
|
||||
// that may not have proper ARIA roles
|
||||
if (options.cursor) {
|
||||
const cursorElements = await findCursorInteractiveElements(page, options.selector);
|
||||
|
||||
// Filter out elements whose text is already captured in the snapshot
|
||||
const existingTexts = new Set(Object.values(refs).map((r) => r.name.toLowerCase()));
|
||||
// Also extract quoted strings from the ARIA tree for broader dedup
|
||||
for (const m of enhancedTree.matchAll(/"([^"]+)"/g)) {
|
||||
existingTexts.add(m[1].toLowerCase());
|
||||
}
|
||||
|
||||
const additionalLines: string[] = [];
|
||||
for (const el of cursorElements) {
|
||||
const elTextLower = el.text.toLowerCase();
|
||||
// Skip if text already captured in the ARIA tree
|
||||
if (existingTexts.has(elTextLower)) continue;
|
||||
existingTexts.add(elTextLower);
|
||||
|
||||
const ref = nextRef();
|
||||
const role = el.hasCursorPointer || el.hasOnClick ? 'clickable' : 'focusable';
|
||||
|
||||
refs[ref] = {
|
||||
selector: el.selector,
|
||||
role: role,
|
||||
name: el.text,
|
||||
};
|
||||
|
||||
// Build description of why it's interactive
|
||||
const hints: string[] = [];
|
||||
if (el.hasCursorPointer) hints.push('cursor:pointer');
|
||||
if (el.hasOnClick) hints.push('onclick');
|
||||
if (el.hasTabIndex) hints.push('tabindex');
|
||||
|
||||
additionalLines.push(`- ${role} "${el.text}" [ref=${ref}] [${hints.join(', ')}]`);
|
||||
}
|
||||
|
||||
if (additionalLines.length > 0) {
|
||||
const separator =
|
||||
enhancedTree === '(no interactive elements)' ? '' : '\n# Cursor-interactive elements:\n';
|
||||
const base = enhancedTree === '(no interactive elements)' ? '' : enhancedTree;
|
||||
return {
|
||||
tree: base + separator + additionalLines.join('\n'),
|
||||
refs,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { tree: enhancedTree, refs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Track role+name combinations to detect duplicates
|
||||
*/
|
||||
interface RoleNameTracker {
|
||||
counts: Map<string, number>;
|
||||
/** Maps role+name key to array of ref IDs that use it */
|
||||
refsByKey: Map<string, string[]>;
|
||||
getKey(role: string, name?: string): string;
|
||||
getNextIndex(role: string, name?: string): number;
|
||||
trackRef(role: string, name: string | undefined, ref: string): void;
|
||||
/** Get all role+name keys that have duplicates */
|
||||
getDuplicateKeys(): Set<string>;
|
||||
}
|
||||
|
||||
function createRoleNameTracker(): RoleNameTracker {
|
||||
const counts = new Map<string, number>();
|
||||
const refsByKey = new Map<string, string[]>();
|
||||
return {
|
||||
counts,
|
||||
refsByKey,
|
||||
getKey(role: string, name?: string): string {
|
||||
return `${role}:${name ?? ''}`;
|
||||
},
|
||||
getNextIndex(role: string, name?: string): number {
|
||||
const key = this.getKey(role, name);
|
||||
const current = counts.get(key) ?? 0;
|
||||
counts.set(key, current + 1);
|
||||
return current;
|
||||
},
|
||||
trackRef(role: string, name: string | undefined, ref: string): void {
|
||||
const key = this.getKey(role, name);
|
||||
const refs = refsByKey.get(key) ?? [];
|
||||
refs.push(ref);
|
||||
refsByKey.set(key, refs);
|
||||
},
|
||||
getDuplicateKeys(): Set<string> {
|
||||
const duplicates = new Set<string>();
|
||||
for (const [key, refs] of refsByKey) {
|
||||
if (refs.length > 1) {
|
||||
duplicates.add(key);
|
||||
}
|
||||
}
|
||||
return duplicates;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process ARIA snapshot: add refs and apply filters
|
||||
*/
|
||||
function processAriaTree(ariaTree: string, refs: RefMap, options: SnapshotOptions): string {
|
||||
const lines = ariaTree.split('\n');
|
||||
const result: string[] = [];
|
||||
const tracker = createRoleNameTracker();
|
||||
|
||||
// For interactive-only mode, we collect just interactive elements
|
||||
if (options.interactive) {
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^(\s*-\s*)(\w+)(?:\s+"([^"]*)")?(.*)$/);
|
||||
if (!match) continue;
|
||||
|
||||
const [, , role, name, suffix] = match;
|
||||
const roleLower = role.toLowerCase();
|
||||
|
||||
if (INTERACTIVE_ROLES.has(roleLower)) {
|
||||
const ref = nextRef();
|
||||
const resolvedName = name ?? '';
|
||||
const nth = tracker.getNextIndex(roleLower, resolvedName);
|
||||
tracker.trackRef(roleLower, resolvedName, ref);
|
||||
refs[ref] = {
|
||||
selector: buildSelector(roleLower, resolvedName),
|
||||
role: roleLower,
|
||||
name: resolvedName,
|
||||
nth, // Always store nth, we'll use it for duplicates
|
||||
};
|
||||
|
||||
let enhanced = `- ${role}`;
|
||||
if (name) enhanced += ` "${name}"`;
|
||||
enhanced += ` [ref=${ref}]`;
|
||||
// Only show nth in output if it's > 0 (for readability)
|
||||
if (nth > 0) enhanced += ` [nth=${nth}]`;
|
||||
if (suffix && suffix.includes('[')) enhanced += suffix;
|
||||
|
||||
result.push(enhanced);
|
||||
}
|
||||
}
|
||||
|
||||
// Post-process: remove nth from refs that don't have duplicates
|
||||
removeNthFromNonDuplicates(refs, tracker);
|
||||
|
||||
return result.join('\n') || '(no interactive elements)';
|
||||
}
|
||||
|
||||
// Normal processing with depth/compact filters
|
||||
for (const line of lines) {
|
||||
const processed = processLine(line, refs, options, tracker);
|
||||
if (processed !== null) {
|
||||
result.push(processed);
|
||||
}
|
||||
}
|
||||
|
||||
// Post-process: remove nth from refs that don't have duplicates
|
||||
removeNthFromNonDuplicates(refs, tracker);
|
||||
|
||||
// If compact mode, remove empty structural elements
|
||||
if (options.compact) {
|
||||
return compactTree(result.join('\n'));
|
||||
}
|
||||
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove nth from refs that ended up not having duplicates
|
||||
* This keeps single-element locators simple (no unnecessary .nth(0))
|
||||
*/
|
||||
function removeNthFromNonDuplicates(refs: RefMap, tracker: RoleNameTracker): void {
|
||||
const duplicateKeys = tracker.getDuplicateKeys();
|
||||
|
||||
for (const [ref, data] of Object.entries(refs)) {
|
||||
const key = tracker.getKey(data.role, data.name);
|
||||
if (!duplicateKeys.has(key)) {
|
||||
// Not a duplicate, remove nth to keep locator simple
|
||||
delete refs[ref].nth;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get indentation level (number of spaces / 2)
|
||||
*/
|
||||
function getIndentLevel(line: string): number {
|
||||
const match = line.match(/^(\s*)/);
|
||||
return match ? Math.floor(match[1].length / 2) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single line: add ref if needed, filter if requested
|
||||
*/
|
||||
function processLine(
|
||||
line: string,
|
||||
refs: RefMap,
|
||||
options: SnapshotOptions,
|
||||
tracker: RoleNameTracker
|
||||
): string | null {
|
||||
const depth = getIndentLevel(line);
|
||||
|
||||
// Check max depth
|
||||
if (options.maxDepth !== undefined && depth > options.maxDepth) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Match lines like:
|
||||
// - button "Submit"
|
||||
// - heading "Title" [level=1]
|
||||
// - link "Click me":
|
||||
const match = line.match(/^(\s*-\s*)(\w+)(?:\s+"([^"]*)")?(.*)$/);
|
||||
|
||||
if (!match) {
|
||||
// Metadata lines (like /url:) or text content
|
||||
if (options.interactive) {
|
||||
// In interactive mode, only keep metadata under interactive elements
|
||||
return null;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
const [, prefix, role, name, suffix] = match;
|
||||
const roleLower = role.toLowerCase();
|
||||
|
||||
// Skip metadata lines (like /url:)
|
||||
if (role.startsWith('/')) {
|
||||
return line;
|
||||
}
|
||||
|
||||
const isInteractive = INTERACTIVE_ROLES.has(roleLower);
|
||||
const isContent = CONTENT_ROLES.has(roleLower);
|
||||
const isStructural = STRUCTURAL_ROLES.has(roleLower);
|
||||
|
||||
// In interactive-only mode, filter non-interactive elements
|
||||
if (options.interactive && !isInteractive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// In compact mode, skip unnamed structural elements
|
||||
if (options.compact && isStructural && !name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Add ref for interactive or named content elements
|
||||
const shouldHaveRef = isInteractive || (isContent && name);
|
||||
|
||||
if (shouldHaveRef) {
|
||||
const ref = nextRef();
|
||||
// Normalize to "" so unnamed elements get exact-match selectors
|
||||
const resolvedName = isInteractive ? (name ?? '') : name!;
|
||||
const nth = tracker.getNextIndex(roleLower, resolvedName);
|
||||
tracker.trackRef(roleLower, resolvedName, ref);
|
||||
|
||||
refs[ref] = {
|
||||
selector: buildSelector(roleLower, resolvedName),
|
||||
role: roleLower,
|
||||
name: resolvedName,
|
||||
nth, // Always store nth, we'll clean up non-duplicates later
|
||||
};
|
||||
|
||||
// Build enhanced line with ref
|
||||
let enhanced = `${prefix}${role}`;
|
||||
if (name) enhanced += ` "${name}"`;
|
||||
enhanced += ` [ref=${ref}]`;
|
||||
// Only show nth in output if it's > 0 (for readability)
|
||||
if (nth > 0) enhanced += ` [nth=${nth}]`;
|
||||
if (suffix) enhanced += suffix;
|
||||
|
||||
return enhanced;
|
||||
}
|
||||
|
||||
return line;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove empty structural branches in compact mode
|
||||
*/
|
||||
function compactTree(tree: string): string {
|
||||
const lines = tree.split('\n');
|
||||
const result: string[] = [];
|
||||
|
||||
// Simple pass: keep lines that have content or refs
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// Always keep lines with refs
|
||||
if (line.includes('[ref=')) {
|
||||
result.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Keep lines with text content (after :)
|
||||
if (line.includes(':') && !line.endsWith(':')) {
|
||||
result.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this structural element has children with refs
|
||||
const currentIndent = getIndentLevel(line);
|
||||
let hasRelevantChildren = false;
|
||||
|
||||
for (let j = i + 1; j < lines.length; j++) {
|
||||
const childIndent = getIndentLevel(lines[j]);
|
||||
if (childIndent <= currentIndent) break;
|
||||
if (lines[j].includes('[ref=')) {
|
||||
hasRelevantChildren = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasRelevantChildren) {
|
||||
result.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a ref from command argument (e.g., "@e1" -> "e1")
|
||||
*/
|
||||
export function parseRef(arg: string): string | null {
|
||||
if (arg.startsWith('@')) {
|
||||
return arg.slice(1);
|
||||
}
|
||||
if (arg.startsWith('ref=')) {
|
||||
return arg.slice(4);
|
||||
}
|
||||
if (/^e\d+$/.test(arg)) {
|
||||
return arg;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get snapshot statistics
|
||||
*/
|
||||
export function getSnapshotStats(
|
||||
tree: string,
|
||||
refs: RefMap
|
||||
): {
|
||||
lines: number;
|
||||
chars: number;
|
||||
tokens: number;
|
||||
refs: number;
|
||||
interactive: number;
|
||||
} {
|
||||
const interactive = Object.values(refs).filter((r) => INTERACTIVE_ROLES.has(r.role)).length;
|
||||
|
||||
return {
|
||||
lines: tree.split('\n').length,
|
||||
chars: tree.length,
|
||||
tokens: Math.ceil(tree.length / 4),
|
||||
refs: Object.keys(refs).length,
|
||||
interactive,
|
||||
};
|
||||
}
|
||||
@@ -1,271 +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';
|
||||
|
||||
let tempHome: string;
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('os')>();
|
||||
return {
|
||||
...actual,
|
||||
homedir: () => tempHome,
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
getAutoStateFilePath,
|
||||
isValidSessionName,
|
||||
getSessionsDir,
|
||||
safeHeaderMerge,
|
||||
listStateFiles,
|
||||
cleanupExpiredStates,
|
||||
} from './state-utils.js';
|
||||
|
||||
describe('state-utils', () => {
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-browser-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('isValidSessionName', () => {
|
||||
it('should accept alphanumeric names', () => {
|
||||
expect(isValidSessionName('twitter')).toBe(true);
|
||||
expect(isValidSessionName('Twitter123')).toBe(true);
|
||||
expect(isValidSessionName('123')).toBe(true);
|
||||
expect(isValidSessionName('ABC')).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept names with hyphens', () => {
|
||||
expect(isValidSessionName('my-session')).toBe(true);
|
||||
expect(isValidSessionName('twitter-prod')).toBe(true);
|
||||
expect(isValidSessionName('a-b-c-d')).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept names with underscores', () => {
|
||||
expect(isValidSessionName('my_session')).toBe(true);
|
||||
expect(isValidSessionName('twitter_prod')).toBe(true);
|
||||
expect(isValidSessionName('a_b_c_d')).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept mixed valid characters', () => {
|
||||
expect(isValidSessionName('my-session_123')).toBe(true);
|
||||
expect(isValidSessionName('Twitter_Prod-v2')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject empty string', () => {
|
||||
expect(isValidSessionName('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject path traversal attempts', () => {
|
||||
expect(isValidSessionName('../../../etc/passwd')).toBe(false);
|
||||
expect(isValidSessionName('..\\..\\windows\\system32')).toBe(false);
|
||||
expect(isValidSessionName('../parent')).toBe(false);
|
||||
expect(isValidSessionName('./current')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject names with slashes', () => {
|
||||
expect(isValidSessionName('path/to/file')).toBe(false);
|
||||
expect(isValidSessionName('path\\to\\file')).toBe(false);
|
||||
expect(isValidSessionName('/absolute/path')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject names with spaces', () => {
|
||||
expect(isValidSessionName('my session')).toBe(false);
|
||||
expect(isValidSessionName(' leading')).toBe(false);
|
||||
expect(isValidSessionName('trailing ')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject names with special characters', () => {
|
||||
expect(isValidSessionName('session@user')).toBe(false);
|
||||
expect(isValidSessionName('session#1')).toBe(false);
|
||||
expect(isValidSessionName('session$var')).toBe(false);
|
||||
expect(isValidSessionName('session%20')).toBe(false);
|
||||
expect(isValidSessionName('session:name')).toBe(false);
|
||||
expect(isValidSessionName('session;drop')).toBe(false);
|
||||
expect(isValidSessionName("session'sql")).toBe(false);
|
||||
expect(isValidSessionName('session"quote')).toBe(false);
|
||||
expect(isValidSessionName('session<script>')).toBe(false);
|
||||
expect(isValidSessionName('session|pipe')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject names with null bytes', () => {
|
||||
expect(isValidSessionName('session\x00name')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject names with newlines', () => {
|
||||
expect(isValidSessionName('session\nname')).toBe(false);
|
||||
expect(isValidSessionName('session\rname')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject Unicode tricks', () => {
|
||||
// Homograph attacks
|
||||
expect(isValidSessionName('sеssion')).toBe(false); // Cyrillic 'е'
|
||||
expect(isValidSessionName('session\u2024')).toBe(false); // One dot leader
|
||||
expect(isValidSessionName('session\u2025')).toBe(false); // Two dot leader
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAutoStateFilePath', () => {
|
||||
it('should return null for empty session name', () => {
|
||||
expect(getAutoStateFilePath('', 'default')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return valid path for valid inputs', () => {
|
||||
const result = getAutoStateFilePath('twitter', 'default');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toContain('twitter-default.json');
|
||||
expect(result).toContain('.agent-browser');
|
||||
expect(result).toContain('sessions');
|
||||
});
|
||||
|
||||
it('should throw error for path traversal in session name', () => {
|
||||
expect(() => getAutoStateFilePath('../etc/passwd', 'default')).toThrow(
|
||||
/Invalid session name/
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for path traversal in session ID', () => {
|
||||
expect(() => getAutoStateFilePath('twitter', '../../../etc/passwd')).toThrow(
|
||||
/Invalid session ID/
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for slashes in session name', () => {
|
||||
expect(() => getAutoStateFilePath('path/to/file', 'default')).toThrow(/Invalid session name/);
|
||||
});
|
||||
|
||||
it('should throw error for slashes in session ID', () => {
|
||||
expect(() => getAutoStateFilePath('twitter', 'path/to/file')).toThrow(/Invalid session ID/);
|
||||
});
|
||||
|
||||
it('should throw error for special characters in session name', () => {
|
||||
expect(() => getAutoStateFilePath('session@evil', 'default')).toThrow(/Invalid session name/);
|
||||
});
|
||||
|
||||
it('should throw error for special characters in session ID', () => {
|
||||
expect(() => getAutoStateFilePath('twitter', 'id@evil')).toThrow(/Invalid session ID/);
|
||||
});
|
||||
|
||||
it('should accept valid session name with hyphens and underscores', () => {
|
||||
const result = getAutoStateFilePath('my-session_v2', 'agent_1');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toContain('my-session_v2-agent_1.json');
|
||||
});
|
||||
|
||||
// Security: Ensure the resulting path is within the sessions directory
|
||||
it('should always produce path within sessions directory', () => {
|
||||
const sessionsDir = getSessionsDir();
|
||||
const result = getAutoStateFilePath('twitter', 'default');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.startsWith(sessionsDir)).toBe(true);
|
||||
|
||||
// Verify the path is actually within the directory (no traversal)
|
||||
const resolvedPath = path.resolve(result!);
|
||||
const resolvedSessionsDir = path.resolve(sessionsDir);
|
||||
expect(resolvedPath.startsWith(resolvedSessionsDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeHeaderMerge', () => {
|
||||
it('should merge two header objects', () => {
|
||||
const base = { 'Content-Type': 'application/json', Accept: 'text/html' };
|
||||
const override = { Authorization: 'Bearer token' };
|
||||
|
||||
const result = safeHeaderMerge(base, override);
|
||||
|
||||
expect(result['Content-Type']).toBe('application/json');
|
||||
expect(result['Accept']).toBe('text/html');
|
||||
expect(result['Authorization']).toBe('Bearer token');
|
||||
});
|
||||
|
||||
it('should allow override to replace base values', () => {
|
||||
const base = { 'Content-Type': 'text/plain' };
|
||||
const override = { 'Content-Type': 'application/json' };
|
||||
|
||||
const result = safeHeaderMerge(base, override);
|
||||
|
||||
expect(result['Content-Type']).toBe('application/json');
|
||||
});
|
||||
|
||||
it('should filter out __proto__ from base', () => {
|
||||
const base = { 'Content-Type': 'text/plain', __proto__: 'evil' } as Record<string, string>;
|
||||
const override = { Accept: 'text/html' };
|
||||
|
||||
const result = safeHeaderMerge(base, override);
|
||||
|
||||
expect(result['Content-Type']).toBe('text/plain');
|
||||
expect(result['Accept']).toBe('text/html');
|
||||
expect('__proto__' in result).toBe(false);
|
||||
expect(Object.prototype.hasOwnProperty.call(result, '__proto__')).toBe(false);
|
||||
});
|
||||
|
||||
it('should filter out __proto__ from override', () => {
|
||||
const base = { 'Content-Type': 'text/plain' };
|
||||
const override = { Accept: 'text/html', __proto__: 'evil' } as Record<string, string>;
|
||||
|
||||
const result = safeHeaderMerge(base, override);
|
||||
|
||||
expect(result['Content-Type']).toBe('text/plain');
|
||||
expect(result['Accept']).toBe('text/html');
|
||||
expect('__proto__' in result).toBe(false);
|
||||
});
|
||||
|
||||
it('should filter out constructor key', () => {
|
||||
const base = { constructor: 'evil' } as Record<string, string>;
|
||||
const override = { Accept: 'text/html' };
|
||||
|
||||
const result = safeHeaderMerge(base, override);
|
||||
|
||||
expect(result['Accept']).toBe('text/html');
|
||||
expect('constructor' in result).toBe(false);
|
||||
});
|
||||
|
||||
it('should filter out prototype key', () => {
|
||||
const base = { prototype: 'evil' } as Record<string, string>;
|
||||
const override = { Accept: 'text/html' };
|
||||
|
||||
const result = safeHeaderMerge(base, override);
|
||||
|
||||
expect(result['Accept']).toBe('text/html');
|
||||
expect('prototype' in result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return null-prototype object', () => {
|
||||
const base = { 'Content-Type': 'text/plain' };
|
||||
const override = {};
|
||||
|
||||
const result = safeHeaderMerge(base, override);
|
||||
|
||||
expect(Object.getPrototypeOf(result)).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle empty objects', () => {
|
||||
const result = safeHeaderMerge({}, {});
|
||||
expect(Object.keys(result)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listStateFiles', () => {
|
||||
it('should return empty array when directory does not exist', () => {
|
||||
const result = listStateFiles();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupExpiredStates', () => {
|
||||
it('should return empty array for 0 days', () => {
|
||||
const result = cleanupExpiredStates(0);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array for negative days', () => {
|
||||
const result = cleanupExpiredStates(-5);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,224 +0,0 @@
|
||||
/**
|
||||
* Shared utilities for session state management.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import {
|
||||
getEncryptionKey,
|
||||
encryptData,
|
||||
decryptData,
|
||||
isEncryptedPayload,
|
||||
type EncryptedPayload,
|
||||
ENCRYPTION_KEY_ENV,
|
||||
} from './encryption.js';
|
||||
|
||||
/**
|
||||
* Get the session persistence directory.
|
||||
* Located at ~/.agent-browser/sessions/
|
||||
*/
|
||||
export function getSessionsDir(): string {
|
||||
return path.join(os.homedir(), '.agent-browser', 'sessions');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the sessions directory exists with proper permissions.
|
||||
* Creates directory with mode 0o700 (owner only).
|
||||
*/
|
||||
export function ensureSessionsDir(): string {
|
||||
const sessionsDir = getSessionsDir();
|
||||
if (!fs.existsSync(sessionsDir)) {
|
||||
fs.mkdirSync(sessionsDir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
return sessionsDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a session ID to prevent path traversal attacks.
|
||||
* Only allows alphanumeric characters, hyphens, and underscores.
|
||||
*/
|
||||
function isValidSessionId(id: string): boolean {
|
||||
return /^[a-zA-Z0-9_-]+$/.test(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a session name for safety (no path traversal).
|
||||
* Only allows alphanumeric characters, dashes, and underscores.
|
||||
* This validation is critical for security - the daemon reads session names
|
||||
* from environment variables which can be set by attackers bypassing CLI validation.
|
||||
*/
|
||||
export function isValidSessionName(name: string): boolean {
|
||||
return /^[a-zA-Z0-9_-]+$/.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the auto-save state file path for a session.
|
||||
* Pattern: {SESSION_NAME}-{SESSION_ID}.json
|
||||
*
|
||||
* @param sessionName - The session name (e.g., "twitter")
|
||||
* @param sessionId - The session ID (e.g., "default" or "agent1")
|
||||
* @returns Full path to the state file, or null if sessionName is empty
|
||||
* @throws Error if sessionName or sessionId contains invalid characters (path traversal prevention)
|
||||
*/
|
||||
export function getAutoStateFilePath(sessionName: string, sessionId: string): string | null {
|
||||
if (!sessionName) return null;
|
||||
|
||||
// SECURITY: Validate sessionName to prevent path traversal attacks.
|
||||
// The daemon reads AGENT_BROWSER_SESSION_NAME from environment which
|
||||
// can be set directly by attackers, bypassing CLI validation.
|
||||
if (!isValidSessionName(sessionName)) {
|
||||
throw new Error(
|
||||
`Invalid session name '${sessionName}'. Only alphanumeric characters, hyphens, and underscores are allowed.`
|
||||
);
|
||||
}
|
||||
|
||||
if (!isValidSessionId(sessionId)) {
|
||||
throw new Error(
|
||||
`Invalid session ID '${sessionId}'. Only alphanumeric characters, hyphens, and underscores are allowed.`
|
||||
);
|
||||
}
|
||||
const sessionsDir = ensureSessionsDir();
|
||||
return path.join(sessionsDir, `${sessionName}-${sessionId}.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an auto-state file exists for a session.
|
||||
*/
|
||||
export function autoStateFileExists(sessionName: string, sessionId: string): boolean {
|
||||
const filePath = getAutoStateFilePath(sessionName, sessionId);
|
||||
return filePath ? fs.existsSync(filePath) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write state data to file, encrypting if encryption key is available.
|
||||
*
|
||||
* @param filepath - Path to write the state file
|
||||
* @param data - State data object to write
|
||||
* @returns Object indicating whether the file was encrypted
|
||||
*/
|
||||
export function writeStateFile(filepath: string, data: object): { encrypted: boolean } {
|
||||
const key = getEncryptionKey();
|
||||
const jsonData = JSON.stringify(data, null, 2);
|
||||
|
||||
if (key) {
|
||||
const encrypted = encryptData(jsonData, key);
|
||||
fs.writeFileSync(filepath, JSON.stringify(encrypted, null, 2));
|
||||
return { encrypted: true };
|
||||
}
|
||||
|
||||
fs.writeFileSync(filepath, jsonData);
|
||||
return { encrypted: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read state data from file, decrypting if necessary.
|
||||
*
|
||||
* @param filepath - Path to the state file
|
||||
* @returns Object containing the data and whether it was encrypted
|
||||
* @throws Error if file is encrypted but no key is available
|
||||
*/
|
||||
export function readStateFile(filepath: string): { data: object; wasEncrypted: boolean } {
|
||||
const content = fs.readFileSync(filepath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
if (isEncryptedPayload(parsed)) {
|
||||
const key = getEncryptionKey();
|
||||
if (!key) {
|
||||
throw new Error(
|
||||
`State file is encrypted but ${ENCRYPTION_KEY_ENV} is not set. ` +
|
||||
`Set the environment variable to decrypt.`
|
||||
);
|
||||
}
|
||||
const decrypted = decryptData(parsed, key);
|
||||
return { data: JSON.parse(decrypted), wasEncrypted: true };
|
||||
}
|
||||
|
||||
return { data: parsed, wasEncrypted: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* List all state files in the sessions directory.
|
||||
* @returns Array of filenames ending in .json
|
||||
*/
|
||||
export function listStateFiles(): string[] {
|
||||
const sessionsDir = getSessionsDir();
|
||||
if (!fs.existsSync(sessionsDir)) {
|
||||
return [];
|
||||
}
|
||||
return fs.readdirSync(sessionsDir).filter((f) => f.endsWith('.json'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up state files older than specified days.
|
||||
* @param days - Maximum age in days (files older than this are deleted)
|
||||
* @returns Array of deleted filenames
|
||||
*/
|
||||
export function cleanupExpiredStates(days: number): string[] {
|
||||
if (days <= 0) return [];
|
||||
|
||||
const sessionsDir = getSessionsDir();
|
||||
if (!fs.existsSync(sessionsDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const maxAge = days * 24 * 60 * 60 * 1000;
|
||||
const deleted: string[] = [];
|
||||
|
||||
const files = listStateFiles();
|
||||
for (const file of files) {
|
||||
const filepath = path.join(sessionsDir, file);
|
||||
try {
|
||||
const stats = fs.statSync(filepath);
|
||||
if (now - stats.mtime.getTime() > maxAge) {
|
||||
fs.unlinkSync(filepath);
|
||||
deleted.push(file);
|
||||
}
|
||||
} catch {
|
||||
// Ignore individual file errors
|
||||
}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
const DANGEROUS_KEYS = ['__proto__', 'constructor', 'prototype'];
|
||||
|
||||
/**
|
||||
* Safely merge headers without prototype pollution risk.
|
||||
* Filters out dangerous keys like __proto__, constructor, prototype.
|
||||
* @param base - Base headers object
|
||||
* @param override - Headers to merge (takes precedence)
|
||||
* @returns Merged headers object (null-prototype)
|
||||
*/
|
||||
export function safeHeaderMerge(
|
||||
base: Record<string, string>,
|
||||
override: Record<string, string>
|
||||
): Record<string, string> {
|
||||
const result: Record<string, string> = Object.create(null);
|
||||
|
||||
for (const [key, value] of Object.entries(base)) {
|
||||
if (!DANGEROUS_KEYS.includes(key)) {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(override)) {
|
||||
if (!DANGEROUS_KEYS.includes(key)) {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Re-export encryption utilities
|
||||
export {
|
||||
getEncryptionKey,
|
||||
encryptData,
|
||||
decryptData,
|
||||
isEncryptedPayload,
|
||||
type EncryptedPayload,
|
||||
ENCRYPTION_KEY_ENV,
|
||||
};
|
||||
@@ -1,64 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isAllowedOrigin } from './stream-server.js';
|
||||
|
||||
describe('isAllowedOrigin', () => {
|
||||
describe('allowed origins', () => {
|
||||
it('should allow connections with no origin (CLI tools)', () => {
|
||||
expect(isAllowedOrigin(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow empty string origin', () => {
|
||||
expect(isAllowedOrigin('')).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow file:// origins', () => {
|
||||
expect(isAllowedOrigin('file:///path/to/viewer.html')).toBe(true);
|
||||
expect(isAllowedOrigin('file:///C:/Users/user/viewer.html')).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow http://localhost origins', () => {
|
||||
expect(isAllowedOrigin('http://localhost')).toBe(true);
|
||||
expect(isAllowedOrigin('http://localhost:3000')).toBe(true);
|
||||
expect(isAllowedOrigin('http://localhost:9223')).toBe(true);
|
||||
expect(isAllowedOrigin('http://localhost:8080')).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow https://localhost origins', () => {
|
||||
expect(isAllowedOrigin('https://localhost')).toBe(true);
|
||||
expect(isAllowedOrigin('https://localhost:3000')).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow http://127.0.0.1 origins', () => {
|
||||
expect(isAllowedOrigin('http://127.0.0.1')).toBe(true);
|
||||
expect(isAllowedOrigin('http://127.0.0.1:3000')).toBe(true);
|
||||
expect(isAllowedOrigin('http://127.0.0.1:9223')).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow IPv6 loopback origins', () => {
|
||||
expect(isAllowedOrigin('http://[::1]')).toBe(true);
|
||||
expect(isAllowedOrigin('http://[::1]:3000')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rejected origins', () => {
|
||||
it('should reject remote origins', () => {
|
||||
expect(isAllowedOrigin('https://evil.com')).toBe(false);
|
||||
expect(isAllowedOrigin('http://attacker.local:8080')).toBe(false);
|
||||
expect(isAllowedOrigin('https://example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject origins with localhost in path but not hostname', () => {
|
||||
expect(isAllowedOrigin('https://evil.com/localhost')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject origins that look like localhost but are not', () => {
|
||||
expect(isAllowedOrigin('http://localhost.evil.com')).toBe(false);
|
||||
expect(isAllowedOrigin('http://not-localhost:3000')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject invalid origin URLs', () => {
|
||||
expect(isAllowedOrigin('not-a-url')).toBe(false);
|
||||
expect(isAllowedOrigin('://missing-scheme')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,411 +0,0 @@
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import type { BrowserManager, ScreencastFrame } from './browser.js';
|
||||
import { setScreencastFrameCallback } from './actions.js';
|
||||
|
||||
/**
|
||||
* Check whether a WebSocket connection origin should be allowed.
|
||||
* Allows: no origin (CLI tools), file:// origins, and localhost/loopback origins.
|
||||
* Rejects: all other origins (prevents malicious web pages from connecting).
|
||||
*/
|
||||
export function isAllowedOrigin(origin: string | undefined): boolean {
|
||||
// Allow connections with no origin (non-browser clients like CLI tools)
|
||||
if (!origin) {
|
||||
return true;
|
||||
}
|
||||
// Allow file:// origins (local HTML files)
|
||||
if (origin.startsWith('file://')) {
|
||||
return true;
|
||||
}
|
||||
// Allow localhost/loopback origins (browser-based stream viewers)
|
||||
try {
|
||||
const url = new URL(origin);
|
||||
const host = url.hostname;
|
||||
if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]') {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Invalid origin URL - reject
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Message types for WebSocket communication
|
||||
export interface FrameMessage {
|
||||
type: 'frame';
|
||||
data: string; // base64 encoded image
|
||||
metadata: {
|
||||
offsetTop: number;
|
||||
pageScaleFactor: number;
|
||||
deviceWidth: number;
|
||||
deviceHeight: number;
|
||||
scrollOffsetX: number;
|
||||
scrollOffsetY: number;
|
||||
timestamp?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface InputMouseMessage {
|
||||
type: 'input_mouse';
|
||||
eventType: 'mousePressed' | 'mouseReleased' | 'mouseMoved' | 'mouseWheel';
|
||||
x: number;
|
||||
y: number;
|
||||
button?: 'left' | 'right' | 'middle' | 'none';
|
||||
clickCount?: number;
|
||||
deltaX?: number;
|
||||
deltaY?: number;
|
||||
modifiers?: number;
|
||||
}
|
||||
|
||||
export interface InputKeyboardMessage {
|
||||
type: 'input_keyboard';
|
||||
eventType: 'keyDown' | 'keyUp' | 'char';
|
||||
key?: string;
|
||||
code?: string;
|
||||
text?: string;
|
||||
modifiers?: number;
|
||||
}
|
||||
|
||||
export interface InputTouchMessage {
|
||||
type: 'input_touch';
|
||||
eventType: 'touchStart' | 'touchEnd' | 'touchMove' | 'touchCancel';
|
||||
touchPoints: Array<{ x: number; y: number; id?: number }>;
|
||||
modifiers?: number;
|
||||
}
|
||||
|
||||
export interface StatusMessage {
|
||||
type: 'status';
|
||||
connected: boolean;
|
||||
screencasting: boolean;
|
||||
viewportWidth?: number;
|
||||
viewportHeight?: number;
|
||||
}
|
||||
|
||||
export interface ErrorMessage {
|
||||
type: 'error';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type StreamMessage =
|
||||
| FrameMessage
|
||||
| InputMouseMessage
|
||||
| InputKeyboardMessage
|
||||
| InputTouchMessage
|
||||
| StatusMessage
|
||||
| ErrorMessage;
|
||||
|
||||
/**
|
||||
* WebSocket server for streaming browser viewport and receiving input
|
||||
*/
|
||||
export class StreamServer {
|
||||
private wss: WebSocketServer | null = null;
|
||||
private clients: Set<WebSocket> = new Set();
|
||||
private browser: BrowserManager;
|
||||
private port: number;
|
||||
private isScreencasting: boolean = false;
|
||||
|
||||
constructor(browser: BrowserManager, port: number = 9223) {
|
||||
this.browser = browser;
|
||||
this.port = port;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the WebSocket server
|
||||
*/
|
||||
start(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// SECURITY: Bind to localhost only to prevent network exposure.
|
||||
// The stream server allows direct input injection (mouse, keyboard, touch)
|
||||
// which would be a critical security risk if exposed to the network.
|
||||
this.wss = new WebSocketServer({
|
||||
port: this.port,
|
||||
host: '127.0.0.1',
|
||||
// Security: Reject cross-origin WebSocket connections from untrusted origins.
|
||||
// This prevents malicious web pages from connecting and injecting input events.
|
||||
// Localhost origins are allowed so browser-based stream viewers can connect.
|
||||
verifyClient: (info: {
|
||||
origin: string;
|
||||
secure: boolean;
|
||||
req: import('http').IncomingMessage;
|
||||
}) => {
|
||||
if (isAllowedOrigin(info.origin)) {
|
||||
return true;
|
||||
}
|
||||
console.log(`[StreamServer] Rejected connection from origin: ${info.origin}`);
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
this.wss.on('connection', (ws) => {
|
||||
this.handleConnection(ws);
|
||||
});
|
||||
|
||||
this.wss.on('error', (error) => {
|
||||
console.error('[StreamServer] WebSocket error:', error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
this.wss.on('listening', () => {
|
||||
console.log(`[StreamServer] Listening on port ${this.port}`);
|
||||
|
||||
// Set up the screencast frame callback
|
||||
setScreencastFrameCallback((frame) => {
|
||||
this.broadcastFrame(frame);
|
||||
});
|
||||
|
||||
resolve();
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the WebSocket server
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
// Stop screencasting
|
||||
if (this.isScreencasting) {
|
||||
await this.stopScreencast();
|
||||
}
|
||||
|
||||
// Clear the callback
|
||||
setScreencastFrameCallback(null);
|
||||
|
||||
// Close all clients
|
||||
for (const client of this.clients) {
|
||||
client.close();
|
||||
}
|
||||
this.clients.clear();
|
||||
|
||||
// Close the server
|
||||
if (this.wss) {
|
||||
return new Promise((resolve) => {
|
||||
this.wss!.close(() => {
|
||||
this.wss = null;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a new WebSocket connection
|
||||
*/
|
||||
private handleConnection(ws: WebSocket): void {
|
||||
console.log('[StreamServer] Client connected');
|
||||
this.clients.add(ws);
|
||||
|
||||
// Send initial status
|
||||
this.sendStatus(ws);
|
||||
|
||||
// Start screencasting if this is the first client
|
||||
if (this.clients.size === 1 && !this.isScreencasting) {
|
||||
this.startScreencast().catch((error) => {
|
||||
console.error('[StreamServer] Failed to start screencast:', error);
|
||||
this.sendError(ws, error.message);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle messages from client
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString()) as StreamMessage;
|
||||
this.handleMessage(message, ws);
|
||||
} catch (error) {
|
||||
console.error('[StreamServer] Failed to parse message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle client disconnect
|
||||
ws.on('close', () => {
|
||||
console.log('[StreamServer] Client disconnected');
|
||||
this.clients.delete(ws);
|
||||
|
||||
// Stop screencasting if no more clients
|
||||
if (this.clients.size === 0 && this.isScreencasting) {
|
||||
this.stopScreencast().catch((error) => {
|
||||
console.error('[StreamServer] Failed to stop screencast:', error);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('[StreamServer] Client error:', error);
|
||||
this.clients.delete(ws);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming messages from clients
|
||||
*/
|
||||
private async handleMessage(message: StreamMessage, ws: WebSocket): Promise<void> {
|
||||
try {
|
||||
switch (message.type) {
|
||||
case 'input_mouse':
|
||||
await this.browser.injectMouseEvent({
|
||||
type: message.eventType,
|
||||
x: message.x,
|
||||
y: message.y,
|
||||
button: message.button,
|
||||
clickCount: message.clickCount,
|
||||
deltaX: message.deltaX,
|
||||
deltaY: message.deltaY,
|
||||
modifiers: message.modifiers,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'input_keyboard':
|
||||
await this.browser.injectKeyboardEvent({
|
||||
type: message.eventType,
|
||||
key: message.key,
|
||||
code: message.code,
|
||||
text: message.text,
|
||||
modifiers: message.modifiers,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'input_touch':
|
||||
await this.browser.injectTouchEvent({
|
||||
type: message.eventType,
|
||||
touchPoints: message.touchPoints,
|
||||
modifiers: message.modifiers,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'status':
|
||||
// Client is requesting status
|
||||
this.sendStatus(ws);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.sendError(ws, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast a frame to all connected clients
|
||||
*/
|
||||
private broadcastFrame(frame: ScreencastFrame): void {
|
||||
const message: FrameMessage = {
|
||||
type: 'frame',
|
||||
data: frame.data,
|
||||
metadata: frame.metadata,
|
||||
};
|
||||
|
||||
const payload = JSON.stringify(message);
|
||||
|
||||
for (const client of this.clients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send status to a client
|
||||
*/
|
||||
private sendStatus(ws: WebSocket): void {
|
||||
let viewportWidth: number | undefined;
|
||||
let viewportHeight: number | undefined;
|
||||
|
||||
try {
|
||||
const page = this.browser.getPage();
|
||||
const viewport = page.viewportSize();
|
||||
viewportWidth = viewport?.width;
|
||||
viewportHeight = viewport?.height;
|
||||
} catch {
|
||||
// Browser not launched yet
|
||||
}
|
||||
|
||||
const message: StatusMessage = {
|
||||
type: 'status',
|
||||
connected: true,
|
||||
screencasting: this.isScreencasting,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
};
|
||||
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an error to a client
|
||||
*/
|
||||
private sendError(ws: WebSocket, errorMessage: string): void {
|
||||
const message: ErrorMessage = {
|
||||
type: 'error',
|
||||
message: errorMessage,
|
||||
};
|
||||
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start screencasting
|
||||
*/
|
||||
private async startScreencast(): Promise<void> {
|
||||
// Set flag immediately to prevent race conditions with concurrent calls
|
||||
if (this.isScreencasting) return;
|
||||
this.isScreencasting = true;
|
||||
|
||||
try {
|
||||
// Check if browser is launched
|
||||
if (!this.browser.isLaunched()) {
|
||||
throw new Error('Browser not launched');
|
||||
}
|
||||
|
||||
await this.browser.startScreencast((frame) => this.broadcastFrame(frame), {
|
||||
format: 'jpeg',
|
||||
quality: 80,
|
||||
maxWidth: 1280,
|
||||
maxHeight: 720,
|
||||
everyNthFrame: 1,
|
||||
});
|
||||
|
||||
// Notify all clients
|
||||
for (const client of this.clients) {
|
||||
this.sendStatus(client);
|
||||
}
|
||||
} catch (error) {
|
||||
// Reset flag on failure so caller can retry
|
||||
this.isScreencasting = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop screencasting
|
||||
*/
|
||||
private async stopScreencast(): Promise<void> {
|
||||
if (!this.isScreencasting) return;
|
||||
|
||||
await this.browser.stopScreencast();
|
||||
this.isScreencasting = false;
|
||||
|
||||
// Notify all clients
|
||||
for (const client of this.clients) {
|
||||
this.sendStatus(client);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the port the server is running on
|
||||
*/
|
||||
getPort(): number {
|
||||
return this.port;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of connected clients
|
||||
*/
|
||||
getClientCount(): number {
|
||||
return this.clients.size;
|
||||
}
|
||||
}
|
||||
-1303
File diff suppressed because it is too large
Load Diff
@@ -1,317 +0,0 @@
|
||||
import type { BenchmarkCommand, Scenario } from "./scenarios.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML generators for realistic pages with complex DOM structures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function generateArticlePage(): string {
|
||||
const paragraphs = Array.from({ length: 30 }, (_, i) => {
|
||||
const words = Array.from(
|
||||
{ length: 40 + (i % 5) * 10 },
|
||||
(_, w) => ["the", "quick", "browser", "engine", "renders", "content", "across", "multiple", "layout", "passes", "while", "handling", "style", "recalculations", "and", "DOM", "mutations"][w % 17],
|
||||
).join(" ");
|
||||
return `<p class="article-p">${words}</p>`;
|
||||
});
|
||||
|
||||
const comments = Array.from(
|
||||
{ length: 40 },
|
||||
(_, i) =>
|
||||
`<div class="comment" data-id="${i}">` +
|
||||
`<div class="comment-header"><span class="author">User ${i}</span><time>2025-01-${String(i % 28 + 1).padStart(2, "0")}</time></div>` +
|
||||
`<div class="comment-body"><p>This is comment number ${i + 1} with some discussion text.</p></div>` +
|
||||
`<div class="comment-actions"><button class="reply-btn">Reply</button><button class="like-btn">Like</button></div>` +
|
||||
`</div>`,
|
||||
);
|
||||
|
||||
const sidebar = Array.from(
|
||||
{ length: 20 },
|
||||
(_, i) =>
|
||||
`<li class="sidebar-item"><a href="#section-${i}">Related Article ${i + 1}: A Longer Title Here</a></li>`,
|
||||
);
|
||||
|
||||
return [
|
||||
"<html><head><title>Benchmark Article</title>",
|
||||
"<style>",
|
||||
"body{font-family:system-ui;margin:0;padding:0;display:grid;grid-template-columns:1fr 300px;gap:20px;max-width:1200px;margin:0 auto}",
|
||||
".article{padding:20px}.sidebar{padding:20px;border-left:1px solid #ddd}",
|
||||
".comment{border:1px solid #eee;padding:12px;margin:8px 0;border-radius:4px}",
|
||||
".comment-header{display:flex;justify-content:space-between;font-size:14px;color:#666}",
|
||||
".nav{display:flex;gap:16px;padding:12px 20px;background:#f5f5f5;grid-column:1/-1}",
|
||||
".tag{display:inline-block;padding:2px 8px;background:#e0e7ff;border-radius:12px;font-size:12px;margin:2px}",
|
||||
"</style></head><body>",
|
||||
`<nav class="nav">${Array.from({ length: 8 }, (_, i) => `<a href="#nav-${i}">Section ${i + 1}</a>`).join("")}</nav>`,
|
||||
'<div class="article">',
|
||||
"<h1>Understanding Modern Browser Engine Architecture</h1>",
|
||||
'<div class="meta"><span class="author">Dr. Smith</span> | <time>2025-03-15</time> | <span>15 min read</span></div>',
|
||||
`<div class="tags">${Array.from({ length: 6 }, (_, i) => `<span class="tag">tag-${i + 1}</span>`).join("")}</div>`,
|
||||
"<h2>Introduction</h2>",
|
||||
...paragraphs.slice(0, 5),
|
||||
"<h2>Core Concepts</h2>",
|
||||
...paragraphs.slice(5, 12),
|
||||
'<blockquote>"Performance is not just about speed, it is about efficiency." - Anonymous</blockquote>',
|
||||
"<h2>Implementation Details</h2>",
|
||||
...paragraphs.slice(12, 20),
|
||||
"<h3>Subsection A</h3>",
|
||||
...paragraphs.slice(20, 25),
|
||||
"<h3>Subsection B</h3>",
|
||||
...paragraphs.slice(25),
|
||||
"<h2>Comments</h2>",
|
||||
'<div class="comments">',
|
||||
...comments,
|
||||
"</div></div>",
|
||||
'<div class="sidebar">',
|
||||
"<h3>Related Articles</h3>",
|
||||
`<ul>${sidebar.join("")}</ul>`,
|
||||
"<h3>Archives</h3>",
|
||||
`<ul>${Array.from({ length: 12 }, (_, i) => `<li><a href="#month-${i}">Month ${i + 1}, 2025</a></li>`).join("")}</ul>`,
|
||||
"</div>",
|
||||
"</body></html>",
|
||||
].join("");
|
||||
}
|
||||
|
||||
function generateDataTablePage(): string {
|
||||
const headerCells = [
|
||||
"ID", "Name", "Email", "Department", "Role", "Status", "Joined", "Last Active",
|
||||
];
|
||||
const header = `<tr>${headerCells.map((h) => `<th>${h}</th>`).join("")}</tr>`;
|
||||
|
||||
const rows = Array.from({ length: 200 }, (_, i) => {
|
||||
const dept = ["Engineering", "Design", "Marketing", "Sales", "Support"][i % 5];
|
||||
const role = ["Admin", "Manager", "Member", "Viewer"][i % 4];
|
||||
const status = ["Active", "Inactive", "Pending"][i % 3];
|
||||
return (
|
||||
`<tr data-row="${i}">` +
|
||||
`<td>${i + 1}</td>` +
|
||||
`<td><a href="#user-${i}">User ${i + 1}</a></td>` +
|
||||
`<td>user${i + 1}@example.com</td>` +
|
||||
`<td>${dept}</td>` +
|
||||
`<td><span class="badge badge-${role.toLowerCase()}">${role}</span></td>` +
|
||||
`<td><span class="status status-${status.toLowerCase()}">${status}</span></td>` +
|
||||
`<td>2024-${String((i % 12) + 1).padStart(2, "0")}-${String((i % 28) + 1).padStart(2, "0")}</td>` +
|
||||
`<td>${i % 3 === 0 ? "Today" : i % 3 === 1 ? "Yesterday" : "Last week"}</td>` +
|
||||
`</tr>`
|
||||
);
|
||||
});
|
||||
|
||||
return [
|
||||
"<html><head><title>Benchmark Table</title>",
|
||||
"<style>",
|
||||
"body{font-family:system-ui;margin:20px}",
|
||||
"table{width:100%;border-collapse:collapse}",
|
||||
"th,td{padding:8px 12px;border:1px solid #ddd;text-align:left}",
|
||||
"th{background:#f5f5f5;font-weight:600;position:sticky;top:0}",
|
||||
"tr:nth-child(even){background:#fafafa}",
|
||||
".badge{padding:2px 8px;border-radius:4px;font-size:12px}",
|
||||
".toolbar{display:flex;gap:12px;margin-bottom:16px;align-items:center}",
|
||||
"input,select,button{padding:6px 12px;border:1px solid #ccc;border-radius:4px}",
|
||||
"</style></head><body>",
|
||||
"<h1>User Management Dashboard</h1>",
|
||||
'<div class="toolbar">',
|
||||
'<input id="search" type="text" placeholder="Search users...">',
|
||||
'<select id="dept-filter"><option value="">All Departments</option><option value="eng">Engineering</option><option value="des">Design</option></select>',
|
||||
'<select id="status-filter"><option value="">All Statuses</option><option value="active">Active</option><option value="inactive">Inactive</option></select>',
|
||||
'<button id="add-user">Add User</button>',
|
||||
'<span id="count">Showing 200 users</span>',
|
||||
"</div>",
|
||||
`<table><thead>${header}</thead><tbody>${rows.join("")}</tbody></table>`,
|
||||
'<div class="pagination">',
|
||||
...Array.from({ length: 10 }, (_, i) => `<button class="page-btn" data-page="${i + 1}">${i + 1}</button>`),
|
||||
"</div>",
|
||||
"</body></html>",
|
||||
].join("");
|
||||
}
|
||||
|
||||
function generateNestedPage(): string {
|
||||
function nest(depth: number, breadth: number, prefix: string): string {
|
||||
if (depth === 0) {
|
||||
return `<span class="leaf" data-path="${prefix}">Leaf node at ${prefix}</span>`;
|
||||
}
|
||||
const children = Array.from(
|
||||
{ length: breadth },
|
||||
(_, i) =>
|
||||
`<div class="node depth-${depth}" data-depth="${depth}" data-idx="${i}">` +
|
||||
`<div class="node-header"><strong>Section ${prefix}.${i + 1}</strong> <em>(depth ${depth})</em></div>` +
|
||||
`<div class="node-content">${nest(depth - 1, Math.max(2, breadth - 1), `${prefix}.${i + 1}`)}</div>` +
|
||||
`</div>`,
|
||||
);
|
||||
return children.join("");
|
||||
}
|
||||
|
||||
return [
|
||||
"<html><head><title>Benchmark Nested</title>",
|
||||
"<style>",
|
||||
"body{font-family:system-ui;margin:20px}",
|
||||
".node{border-left:2px solid #ddd;padding-left:16px;margin:4px 0}",
|
||||
".node-header{padding:4px 0;cursor:pointer}",
|
||||
".leaf{display:block;padding:2px 8px;background:#f0f9ff;margin:2px 0;border-radius:2px}",
|
||||
"</style></head><body>",
|
||||
"<h1>Deeply Nested Document Structure</h1>",
|
||||
nest(7, 3, "root"),
|
||||
"</body></html>",
|
||||
].join("");
|
||||
}
|
||||
|
||||
function generateDashboardPage(): string {
|
||||
const cards = Array.from(
|
||||
{ length: 12 },
|
||||
(_, i) =>
|
||||
`<div class="card" data-card="${i}">` +
|
||||
`<div class="card-title">Metric ${i + 1}</div>` +
|
||||
`<div class="card-value">${Math.floor(Math.random() * 10000)}</div>` +
|
||||
`<div class="card-trend ${i % 2 === 0 ? "up" : "down"}">${i % 2 === 0 ? "+" : "-"}${(Math.random() * 20).toFixed(1)}%</div>` +
|
||||
`</div>`,
|
||||
);
|
||||
|
||||
const chartBars = Array.from(
|
||||
{ length: 24 },
|
||||
(_, i) => {
|
||||
const h = 20 + (i * 7 + 13) % 80;
|
||||
return `<div class="bar" style="height:${h}%" data-hour="${i}"><span class="bar-label">${String(i).padStart(2, "0")}:00</span></div>`;
|
||||
},
|
||||
);
|
||||
|
||||
const logRows = Array.from(
|
||||
{ length: 100 },
|
||||
(_, i) => {
|
||||
const level = ["INFO", "WARN", "ERROR", "DEBUG"][i % 4];
|
||||
return (
|
||||
`<tr class="log-${level.toLowerCase()}" data-log="${i}">` +
|
||||
`<td>${new Date(2025, 0, 1, i % 24, i % 60).toISOString()}</td>` +
|
||||
`<td><span class="level level-${level.toLowerCase()}">${level}</span></td>` +
|
||||
`<td>Service ${["auth", "api", "worker", "cache", "db"][i % 5]}</td>` +
|
||||
`<td>Log message number ${i + 1}: operation completed in ${(Math.random() * 1000).toFixed(0)}ms</td>` +
|
||||
`</tr>`
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return [
|
||||
"<html><head><title>Benchmark Dashboard</title>",
|
||||
"<style>",
|
||||
"body{font-family:system-ui;margin:0;background:#f5f5f5}",
|
||||
".header{background:#1a1a2e;color:white;padding:12px 24px;display:flex;justify-content:space-between;align-items:center}",
|
||||
".grid{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;padding:24px}",
|
||||
".card{background:white;padding:20px;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,.1)}",
|
||||
".card-value{font-size:28px;font-weight:700;margin:8px 0}",
|
||||
".card-trend.up{color:#16a34a}.card-trend.down{color:#dc2626}",
|
||||
".chart-area{background:white;margin:0 24px;padding:20px;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,.1)}",
|
||||
".bars{display:flex;align-items:flex-end;gap:4px;height:200px}",
|
||||
".bar{background:#3b82f6;flex:1;border-radius:2px 2px 0 0;position:relative;min-width:8px}",
|
||||
".log-table{margin:24px;background:white;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,.1);overflow:hidden}",
|
||||
"table{width:100%;border-collapse:collapse;font-size:13px}",
|
||||
"th,td{padding:6px 12px;border-bottom:1px solid #eee;text-align:left}",
|
||||
"th{background:#f9fafb;font-weight:600}",
|
||||
".tabs{display:flex;gap:0;margin:24px 24px 0}",
|
||||
".tab{padding:8px 20px;background:#e5e7eb;cursor:pointer;border-radius:6px 6px 0 0}",
|
||||
".tab.active{background:white}",
|
||||
"</style></head><body>",
|
||||
'<div class="header"><h1>Operations Dashboard</h1><div><input id="dash-search" placeholder="Search..." type="text"><button id="refresh">Refresh</button></div></div>',
|
||||
`<div class="grid">${cards.join("")}</div>`,
|
||||
'<div class="tabs"><div class="tab active">Hourly</div><div class="tab">Daily</div><div class="tab">Weekly</div></div>',
|
||||
`<div class="chart-area"><h3>Request Volume</h3><div class="bars">${chartBars.join("")}</div></div>`,
|
||||
'<div class="log-table">',
|
||||
"<h3 style='padding:16px 12px 0'>Recent Logs</h3>",
|
||||
`<table><thead><tr><th>Timestamp</th><th>Level</th><th>Service</th><th>Message</th></tr></thead><tbody>${logRows.join("")}</tbody></table>`,
|
||||
"</div>",
|
||||
"</body></html>",
|
||||
].join("");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pre-build HTML strings and injection commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ARTICLE_HTML = generateArticlePage();
|
||||
const TABLE_HTML = generateDataTablePage();
|
||||
const NESTED_HTML = generateNestedPage();
|
||||
const DASHBOARD_HTML = generateDashboardPage();
|
||||
|
||||
function injectCmd(id: string, html: string): BenchmarkCommand {
|
||||
return {
|
||||
id,
|
||||
action: "evaluate",
|
||||
script: `document.open(); document.write(${JSON.stringify(html)}); document.close(); 'ok'`,
|
||||
};
|
||||
}
|
||||
|
||||
function setupPage(html: string, tag: string): BenchmarkCommand[] {
|
||||
return [
|
||||
{ id: `${tag}-nav`, action: "navigate", url: "about:blank", waitUntil: "domcontentloaded" },
|
||||
injectCmd(`${tag}-inject`, html),
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Engine-specific scenarios: complex pages that stress real-world workloads
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const engineScenarios: Scenario[] = [
|
||||
{
|
||||
name: "article-snapshot",
|
||||
description: "Snapshot a realistic article page (~800 DOM nodes, 30 paragraphs, 40 comments)",
|
||||
setup: setupPage(ARTICLE_HTML, "art"),
|
||||
commands: [{ id: "snap", action: "snapshot" }],
|
||||
},
|
||||
{
|
||||
name: "table-snapshot",
|
||||
description: "Snapshot a data table with 200 rows and 8 columns",
|
||||
setup: setupPage(TABLE_HTML, "tbl"),
|
||||
commands: [{ id: "snap", action: "snapshot" }],
|
||||
},
|
||||
{
|
||||
name: "nested-snapshot",
|
||||
description: "Snapshot a deeply nested DOM tree (7 levels, ~3000 nodes)",
|
||||
setup: setupPage(NESTED_HTML, "nest"),
|
||||
commands: [{ id: "snap", action: "snapshot" }],
|
||||
},
|
||||
{
|
||||
name: "dashboard-snap",
|
||||
description: "Snapshot an operations dashboard with cards, chart, and 100 log rows",
|
||||
setup: setupPage(DASHBOARD_HTML, "dash"),
|
||||
commands: [{ id: "snap", action: "snapshot" }],
|
||||
},
|
||||
{
|
||||
name: "article-inject",
|
||||
description: "Write a full article page into the DOM (measures parse + layout)",
|
||||
setup: [
|
||||
{ id: "ai-nav", action: "navigate", url: "about:blank", waitUntil: "domcontentloaded" },
|
||||
],
|
||||
commands: [injectCmd("ai-write", ARTICLE_HTML)],
|
||||
},
|
||||
{
|
||||
name: "table-query",
|
||||
description: "Evaluate a querySelectorAll across a large table",
|
||||
setup: setupPage(TABLE_HTML, "tq"),
|
||||
commands: [
|
||||
{
|
||||
id: "query",
|
||||
action: "evaluate",
|
||||
script: "document.querySelectorAll('tr[data-row]').length + ' rows, ' + document.querySelectorAll('td').length + ' cells'",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "dashboard-workflow",
|
||||
description: "Full agent workflow on complex dashboard: snapshot, click, fill, eval, screenshot",
|
||||
setup: setupPage(DASHBOARD_HTML, "dw"),
|
||||
commands: [
|
||||
{ id: "dw-snap", action: "snapshot" },
|
||||
{ id: "dw-fill", action: "fill", selector: "#dash-search", value: "error logs" },
|
||||
{ id: "dw-click", action: "click", selector: "#refresh" },
|
||||
{ id: "dw-eval", action: "evaluate", script: "document.querySelectorAll('.card').length + ' cards'" },
|
||||
{ id: "dw-ss", action: "screenshot" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "nested-eval",
|
||||
description: "Recursive DOM traversal via evaluate on deeply nested tree",
|
||||
setup: setupPage(NESTED_HTML, "ne"),
|
||||
commands: [
|
||||
{
|
||||
id: "walk",
|
||||
action: "evaluate",
|
||||
script: "(function(){let c=0;const w=n=>{c++;for(const ch of n.children)w(ch);};w(document.body);return c+' nodes';})()",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -1,253 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Understanding Modern Browser Engine Architecture</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #1a1a2e; background: #fff; }
|
||||
.nav { display: flex; align-items: center; gap: 24px; padding: 12px 24px; background: #1a1a2e; color: #fff; position: sticky; top: 0; z-index: 100; }
|
||||
.nav a { color: #94a3b8; text-decoration: none; font-size: 14px; transition: color 0.2s; }
|
||||
.nav a:hover { color: #fff; }
|
||||
.layout { display: grid; grid-template-columns: 1fr 320px; gap: 40px; max-width: 1200px; margin: 0 auto; padding: 40px 24px; }
|
||||
.article { min-width: 0; }
|
||||
.article h1 { font-size: 2.2rem; line-height: 1.2; margin-bottom: 16px; }
|
||||
.meta { display: flex; gap: 16px; color: #64748b; font-size: 14px; margin-bottom: 24px; padding-bottom: 24px; border-bottom: 1px solid #e2e8f0; }
|
||||
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 32px; }
|
||||
.tag { display: inline-block; padding: 4px 12px; background: #e0e7ff; color: #3730a3; border-radius: 16px; font-size: 12px; font-weight: 500; }
|
||||
.article h2 { font-size: 1.5rem; margin: 32px 0 16px; padding-top: 24px; border-top: 1px solid #f1f5f9; }
|
||||
.article h3 { font-size: 1.2rem; margin: 24px 0 12px; }
|
||||
.article p { margin-bottom: 16px; color: #374151; }
|
||||
.article blockquote { margin: 24px 0; padding: 16px 24px; border-left: 4px solid #6366f1; background: #f8fafc; font-style: italic; color: #475569; border-radius: 0 8px 8px 0; }
|
||||
.article pre { background: #1e293b; color: #e2e8f0; padding: 16px 20px; border-radius: 8px; overflow-x: auto; margin: 16px 0; font-size: 14px; line-height: 1.5; }
|
||||
.article code { font-family: 'SF Mono', 'Fira Code', monospace; }
|
||||
.article img { max-width: 100%; height: auto; border-radius: 8px; margin: 16px 0; }
|
||||
.figure { margin: 24px 0; text-align: center; }
|
||||
.figure figcaption { font-size: 13px; color: #64748b; margin-top: 8px; }
|
||||
.comments { margin-top: 40px; }
|
||||
.comments h2 { border-top: 2px solid #e2e8f0; }
|
||||
.comment { padding: 16px; margin: 12px 0; border: 1px solid #e2e8f0; border-radius: 8px; transition: box-shadow 0.2s; }
|
||||
.comment:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
|
||||
.comment-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.comment-author { font-weight: 600; font-size: 14px; }
|
||||
.comment-date { font-size: 12px; color: #94a3b8; }
|
||||
.comment-body { font-size: 14px; color: #475569; }
|
||||
.comment-actions { display: flex; gap: 12px; margin-top: 8px; }
|
||||
.comment-actions button { background: none; border: none; color: #6366f1; font-size: 13px; cursor: pointer; padding: 2px 0; }
|
||||
.sidebar { position: sticky; top: 72px; align-self: start; }
|
||||
.sidebar section { margin-bottom: 32px; }
|
||||
.sidebar h3 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; margin-bottom: 12px; }
|
||||
.sidebar ul { list-style: none; }
|
||||
.sidebar li { margin-bottom: 8px; }
|
||||
.sidebar a { color: #3730a3; text-decoration: none; font-size: 14px; }
|
||||
.sidebar a:hover { text-decoration: underline; }
|
||||
.toc a { display: block; padding: 4px 0; border-left: 2px solid transparent; padding-left: 12px; }
|
||||
.toc a:hover { border-left-color: #6366f1; }
|
||||
.sidebar .widget { background: #f8fafc; padding: 16px; border-radius: 8px; }
|
||||
@media (max-width: 768px) { .layout { grid-template-columns: 1fr; } .sidebar { display: none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="nav">
|
||||
<strong style="font-size:18px;color:#fff">TechBlog</strong>
|
||||
<a href="#">Home</a><a href="#">Articles</a><a href="#">Tutorials</a><a href="#">About</a>
|
||||
<a href="#">Open Source</a><a href="#">Newsletter</a><a href="#">Contact</a>
|
||||
<div style="flex:1"></div>
|
||||
<a href="#">Sign In</a>
|
||||
</nav>
|
||||
|
||||
<div class="layout">
|
||||
<main class="article">
|
||||
<h1>Understanding Modern Browser Engine Architecture</h1>
|
||||
<div class="meta">
|
||||
<span>By <strong>Dr. Alexandra Chen</strong></span>
|
||||
<span>March 15, 2025</span>
|
||||
<span>18 min read</span>
|
||||
<span>2,847 views</span>
|
||||
</div>
|
||||
<div class="tags">
|
||||
<span class="tag">Browser Engines</span><span class="tag">Performance</span>
|
||||
<span class="tag">Web Standards</span><span class="tag">Rendering</span>
|
||||
<span class="tag">Architecture</span><span class="tag">Open Source</span>
|
||||
</div>
|
||||
|
||||
<p>Modern browser engines are among the most complex pieces of software ever created. They must parse HTML, CSS, and JavaScript, construct a DOM tree, compute styles, perform layout calculations, paint pixels, and composite layers -- all within milliseconds to maintain 60fps rendering.</p>
|
||||
|
||||
<p>This article explores the architecture of modern browser engines, examining how they process web content from raw bytes to rendered pixels on screen. We will trace the critical rendering path, examine optimization strategies, and understand why certain patterns lead to better performance.</p>
|
||||
|
||||
<h2>The Critical Rendering Path</h2>
|
||||
|
||||
<p>When a browser receives an HTML document, it begins a multi-stage pipeline known as the critical rendering path. Each stage transforms the document into progressively more structured representations until pixels are painted on screen.</p>
|
||||
|
||||
<p>The first stage involves parsing the HTML into a Document Object Model (DOM). The parser processes tokens sequentially, building a tree structure that represents the document's hierarchy. During this phase, the parser may encounter external resources like stylesheets and scripts that can block further processing.</p>
|
||||
|
||||
<p>CSS parsing happens in parallel where possible. The browser constructs the CSS Object Model (CSSOM), which represents all the style rules that apply to the document. This includes user-agent styles, author styles, and any inline styles specified directly on elements.</p>
|
||||
|
||||
<p>Once both the DOM and CSSOM are available, the browser combines them into a render tree. This tree contains only the elements that will be visible on screen -- elements with <code>display: none</code> are excluded, while pseudo-elements like <code>::before</code> and <code>::after</code> are added.</p>
|
||||
|
||||
<p>Layout (also called reflow) is the process of calculating the exact position and size of each element in the render tree. This is one of the most computationally expensive operations in the rendering pipeline, as changes to one element can cascade through the entire tree.</p>
|
||||
|
||||
<blockquote>"The fastest code is code that doesn't run. The fastest layout is layout that doesn't need to happen." -- Chrome DevTools Team</blockquote>
|
||||
|
||||
<h2>DOM Construction and Tree Building</h2>
|
||||
|
||||
<p>The DOM is a tree-structured representation of the HTML document. Each node in the tree corresponds to an element, text node, comment, or other construct in the HTML. The tree preserves the hierarchical relationships between elements, allowing efficient traversal and manipulation.</p>
|
||||
|
||||
<p>Modern parsers handle malformed HTML gracefully through error recovery algorithms specified in the HTML5 standard. This includes automatic closing of unclosed tags, adoption of misplaced elements, and reconstruction of the formatting element list.</p>
|
||||
|
||||
<p>Shadow DOM introduces additional complexity by creating encapsulated subtrees that can have their own scoped styles and behavior. Custom elements use shadow roots to attach shadow trees, which are rendered in place of the element's regular children.</p>
|
||||
|
||||
<h3>Incremental DOM Updates</h3>
|
||||
|
||||
<p>When JavaScript modifies the DOM, the browser must determine which parts of the rendering pipeline need to be re-executed. Modern engines use fine-grained invalidation to minimize the work required. A change to an element's text content, for example, may only require a repaint, while changing its width could trigger a full relayout of its subtree.</p>
|
||||
|
||||
<p>Mutation observers provide a way for JavaScript to respond to DOM changes without polling. The browser batches mutations and delivers them asynchronously, allowing multiple changes to be processed efficiently in a single callback.</p>
|
||||
|
||||
<h3>Memory Management</h3>
|
||||
|
||||
<p>DOM nodes are reference-counted objects that are garbage collected when no longer reachable. However, detached DOM trees -- subtrees that have been removed from the document but are still referenced by JavaScript -- represent a common source of memory leaks in web applications.</p>
|
||||
|
||||
<p>Browser engines use various strategies to minimize memory overhead: string interning for attribute names and common values, node pools for rapid allocation, and lazy initialization of rarely-accessed properties.</p>
|
||||
|
||||
<h2>Style Resolution and Cascade</h2>
|
||||
|
||||
<p>CSS style resolution involves matching each element against all applicable style rules and computing the final value for every CSS property. With thousands of rules and millions of elements on complex pages, this process must be highly optimized.</p>
|
||||
|
||||
<p>Modern engines use Bloom filters to quickly eliminate rules that cannot match an element, reducing the number of full selector matches required. Selector matching proceeds right-to-left, starting from the key selector (the rightmost part) and working backwards through ancestors.</p>
|
||||
|
||||
<p>The cascade algorithm resolves conflicts between competing declarations by considering origin, specificity, and source order. Custom properties (CSS variables) add another layer of complexity, as they must be resolved during the cascade before they can be used in property values.</p>
|
||||
|
||||
<p>Style sharing is an optimization where elements with identical computed styles share a single style data structure rather than each maintaining their own copy. This is particularly effective on pages with repetitive structures like lists and tables.</p>
|
||||
|
||||
<pre><code>/* Example: These list items can share computed styles */
|
||||
.data-grid tr:nth-child(even) td {
|
||||
background-color: #f8fafc;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}</code></pre>
|
||||
|
||||
<h2>Layout Algorithms</h2>
|
||||
|
||||
<p>Layout is the process of converting the styled render tree into a set of positioned boxes with concrete pixel dimensions. Different layout modes (block, inline, flex, grid, table) each have their own algorithm for determining element sizes and positions.</p>
|
||||
|
||||
<p>Flexbox layout involves multiple passes: first computing the flex basis of each item, then distributing free space according to flex-grow and flex-shrink factors, and finally positioning items along the cross axis. This multi-pass nature makes flex layout more expensive than simple block layout.</p>
|
||||
|
||||
<p>Grid layout is even more complex, supporting both explicit and implicit grid definitions, named areas, auto-placement, and spanning. The grid placement algorithm must resolve conflicts between explicitly-placed and auto-placed items while respecting sizing constraints.</p>
|
||||
|
||||
<p>Containing block queries are a frequent operation during layout. An element's containing block determines its available width for percentage calculations and establishes the coordinate system for positioned descendants. Finding the correct containing block requires walking up the tree, checking for elements that establish new containing blocks.</p>
|
||||
|
||||
<p>Fragmentation handles content that must be split across multiple pages or columns. The fragmentation algorithm inserts breaks at legal break points, avoiding orphans and widows while respecting the <code>break-before</code>, <code>break-after</code>, and <code>break-inside</code> properties.</p>
|
||||
|
||||
<h2>Paint and Compositing</h2>
|
||||
|
||||
<p>After layout, the browser must paint the visual representation of each element. This involves drawing backgrounds, borders, text, images, shadows, and other visual effects in the correct stacking order defined by the z-index property and stacking context rules.</p>
|
||||
|
||||
<p>Modern browsers use a layered compositing architecture. Elements that change frequently (animations, scrolling regions, video) are promoted to their own compositing layers. These layers can be updated independently and composited together on the GPU, avoiding expensive repaints of the entire page.</p>
|
||||
|
||||
<p>The compositor thread operates independently from the main thread, allowing smooth scrolling and animations even when JavaScript is executing. Touch events and scroll gestures are handled directly by the compositor, with the main thread notified asynchronously.</p>
|
||||
|
||||
<p>Paint operations are recorded into display lists -- serialized sequences of drawing commands. These display lists can be rasterized by worker threads on the CPU or directly by the GPU, depending on the content and the platform's capabilities.</p>
|
||||
|
||||
<p>Subpixel antialiasing, font hinting, and text shaping add complexity to text rendering. Each glyph must be positioned with fractional pixel precision, and the rendering must account for kerning pairs, ligatures, and complex scripts like Arabic and Devanagari that require contextual glyph substitution.</p>
|
||||
|
||||
<h2>JavaScript Engine Integration</h2>
|
||||
|
||||
<p>The JavaScript engine is tightly integrated with the browser's rendering pipeline. Script execution can trigger style recalculation, layout, and paint through DOM manipulation and CSSOM access. The browser must balance responsive script execution with maintaining smooth rendering.</p>
|
||||
|
||||
<p>Modern engines use just-in-time (JIT) compilation to achieve near-native performance for hot code paths. The compilation pipeline typically includes an interpreter for initial execution, a baseline compiler for warm functions, and an optimizing compiler for hot functions. Deoptimization handles cases where optimistic assumptions are invalidated.</p>
|
||||
|
||||
<p>Web Workers provide true parallelism by running JavaScript in separate threads with their own heap and message-passing communication. SharedArrayBuffer enables shared memory between workers, but requires careful synchronization to avoid data races.</p>
|
||||
|
||||
<p>The event loop orchestrates the interleaving of script execution, rendering, and I/O callbacks. Microtasks (promises, mutation observers) are processed between macrotasks, and rendering updates are synchronized with the display's refresh rate through requestAnimationFrame.</p>
|
||||
|
||||
<h2>Conclusion</h2>
|
||||
|
||||
<p>Browser engines represent decades of engineering effort to make the web fast, secure, and compatible. Understanding their architecture helps web developers write code that works with the browser rather than against it, leading to better performance and user experience.</p>
|
||||
|
||||
<p>As the web platform continues to evolve with new APIs, layout modes, and rendering capabilities, browser engines must adapt while maintaining backwards compatibility with billions of existing web pages. This tension between innovation and compatibility remains one of the greatest challenges in software engineering.</p>
|
||||
|
||||
<div class="comments">
|
||||
<h2>Comments (50)</h2>
|
||||
<script>
|
||||
(function() {
|
||||
var container = document.querySelector('.comments');
|
||||
var names = ['Alex Morgan', 'Jamie Rivera', 'Sam Patel', 'Taylor Kim', 'Jordan Lee',
|
||||
'Casey Wu', 'Riley Chen', 'Morgan Davis', 'Avery Singh', 'Quinn Zhao'];
|
||||
for (var i = 0; i < 50; i++) {
|
||||
var div = document.createElement('div');
|
||||
div.className = 'comment';
|
||||
div.dataset.id = i;
|
||||
var d = new Date(2025, 2, 15 - Math.floor(i / 5));
|
||||
div.innerHTML = '<div class="comment-header"><span class="comment-author">' + names[i % 10] +
|
||||
'</span><span class="comment-date">' + d.toLocaleDateString() + '</span></div>' +
|
||||
'<div class="comment-body"><p>' + (i % 3 === 0 ?
|
||||
'Great article! The section on compositing layers was particularly insightful. I have been struggling with janky scroll performance and this explains why promoting elements to their own layer helps.' :
|
||||
i % 3 === 1 ?
|
||||
'Thanks for the detailed breakdown. One thing I would add is that the style invalidation strategy varies significantly between engines. Blink uses a different approach from WebKit for descendant invalidation.' :
|
||||
'This is exactly the kind of deep dive I was looking for. The paint and compositing section cleared up several misconceptions I had about how GPU acceleration works in practice.') +
|
||||
'</p></div><div class="comment-actions"><button>Reply</button><button>Like (' + (Math.floor(Math.random() * 30)) + ')</button></div>';
|
||||
container.appendChild(div);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside class="sidebar">
|
||||
<section>
|
||||
<h3>Table of Contents</h3>
|
||||
<ul class="toc">
|
||||
<li><a href="#crp">The Critical Rendering Path</a></li>
|
||||
<li><a href="#dom">DOM Construction and Tree Building</a></li>
|
||||
<li><a href="#styles">Style Resolution and Cascade</a></li>
|
||||
<li><a href="#layout">Layout Algorithms</a></li>
|
||||
<li><a href="#paint">Paint and Compositing</a></li>
|
||||
<li><a href="#js">JavaScript Engine Integration</a></li>
|
||||
<li><a href="#conclusion">Conclusion</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Related Articles</h3>
|
||||
<ul>
|
||||
<li><a href="#">How V8 Optimizes JavaScript Execution</a></li>
|
||||
<li><a href="#">CSS Grid Layout: A Complete Guide</a></li>
|
||||
<li><a href="#">Web Performance Metrics That Matter</a></li>
|
||||
<li><a href="#">Understanding the Event Loop</a></li>
|
||||
<li><a href="#">Debugging Layout Thrashing</a></li>
|
||||
<li><a href="#">Service Workers and Caching Strategies</a></li>
|
||||
<li><a href="#">WebAssembly: Beyond JavaScript</a></li>
|
||||
<li><a href="#">Rendering Performance Case Studies</a></li>
|
||||
<li><a href="#">Accessibility Tree Deep Dive</a></li>
|
||||
<li><a href="#">Cross-Browser Compatibility Patterns</a></li>
|
||||
<li><a href="#">Progressive Web Apps in 2025</a></li>
|
||||
<li><a href="#">HTTP/3 and QUIC Explained</a></li>
|
||||
<li><a href="#">Container Queries Guide</a></li>
|
||||
<li><a href="#">CSS Houdini: Low-Level APIs</a></li>
|
||||
<li><a href="#">Browser DevTools Advanced Tips</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Archives</h3>
|
||||
<ul>
|
||||
<li><a href="#">March 2025</a></li><li><a href="#">February 2025</a></li>
|
||||
<li><a href="#">January 2025</a></li><li><a href="#">December 2024</a></li>
|
||||
<li><a href="#">November 2024</a></li><li><a href="#">October 2024</a></li>
|
||||
<li><a href="#">September 2024</a></li><li><a href="#">August 2024</a></li>
|
||||
<li><a href="#">July 2024</a></li><li><a href="#">June 2024</a></li>
|
||||
<li><a href="#">May 2024</a></li><li><a href="#">April 2024</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
<section class="widget">
|
||||
<h3>Newsletter</h3>
|
||||
<p style="font-size:13px;color:#475569;margin-bottom:8px">Get weekly browser engineering insights</p>
|
||||
<input type="email" placeholder="you@example.com" style="width:100%;padding:8px;border:1px solid #d1d5db;border-radius:4px;margin-bottom:8px">
|
||||
<button style="width:100%;padding:8px;background:#6366f1;color:#fff;border:none;border-radius:4px;cursor:pointer">Subscribe</button>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,248 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Operations Dashboard</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f1f5f9; color: #0f172a; }
|
||||
.header { background: #0f172a; color: #fff; padding: 12px 24px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.header h1 { font-size: 18px; }
|
||||
.header-actions { display: flex; gap: 12px; align-items: center; }
|
||||
.header input { padding: 6px 12px; border: 1px solid #334155; background: #1e293b; color: #fff; border-radius: 6px; font-size: 13px; width: 200px; }
|
||||
.header button { padding: 6px 16px; background: #6366f1; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; padding: 24px; }
|
||||
.card { background: #fff; padding: 20px; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||
.card-label { font-size: 13px; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.card-value { font-size: 32px; font-weight: 700; margin: 8px 0 4px; }
|
||||
.card-trend { font-size: 14px; font-weight: 500; }
|
||||
.card-trend.up { color: #16a34a; }
|
||||
.card-trend.down { color: #dc2626; }
|
||||
.card-sparkline { height: 40px; display: flex; align-items: flex-end; gap: 2px; margin-top: 8px; }
|
||||
.card-sparkline .bar { flex: 1; background: #e0e7ff; border-radius: 2px; min-width: 3px; transition: background 0.2s; }
|
||||
.card-sparkline .bar:last-child { background: #6366f1; }
|
||||
.section { margin: 0 24px 24px; background: #fff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); overflow: hidden; }
|
||||
.section-header { padding: 16px 20px; border-bottom: 1px solid #f1f5f9; display: flex; justify-content: space-between; align-items: center; }
|
||||
.section-header h2 { font-size: 16px; }
|
||||
.tabs { display: flex; gap: 0; }
|
||||
.tab { padding: 6px 16px; font-size: 13px; border: 1px solid #e2e8f0; background: #fff; cursor: pointer; }
|
||||
.tab:first-child { border-radius: 6px 0 0 6px; }
|
||||
.tab:last-child { border-radius: 0 6px 6px 0; }
|
||||
.tab.active { background: #6366f1; color: #fff; border-color: #6366f1; }
|
||||
.chart { padding: 20px; height: 240px; display: flex; align-items: flex-end; gap: 4px; }
|
||||
.chart .bar { flex: 1; background: #6366f1; border-radius: 4px 4px 0 0; position: relative; min-width: 6px; transition: opacity 0.2s; }
|
||||
.chart .bar:hover { opacity: 0.8; }
|
||||
.chart .bar-label { position: absolute; bottom: -20px; left: 50%; transform: translateX(-50%); font-size: 10px; color: #94a3b8; white-space: nowrap; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
thead th { background: #f8fafc; padding: 10px 16px; text-align: left; font-weight: 600; color: #475569; border-bottom: 1px solid #e2e8f0; position: sticky; top: 0; }
|
||||
tbody td { padding: 8px 16px; border-bottom: 1px solid #f1f5f9; }
|
||||
tbody tr:hover { background: #f8fafc; }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
|
||||
.badge-info { background: #dbeafe; color: #1d4ed8; }
|
||||
.badge-warn { background: #fef3c7; color: #b45309; }
|
||||
.badge-error { background: #fee2e2; color: #dc2626; }
|
||||
.badge-debug { background: #f1f5f9; color: #475569; }
|
||||
.badge-active { background: #dcfce7; color: #166534; }
|
||||
.badge-inactive { background: #f1f5f9; color: #64748b; }
|
||||
.badge-pending { background: #fef3c7; color: #b45309; }
|
||||
.pagination { display: flex; justify-content: center; gap: 4px; padding: 16px; }
|
||||
.pagination button { width: 32px; height: 32px; border: 1px solid #e2e8f0; background: #fff; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
||||
.pagination button.active { background: #6366f1; color: #fff; border-color: #6366f1; }
|
||||
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; padding: 0 24px 24px; }
|
||||
@media (max-width: 1024px) { .grid { grid-template-columns: repeat(2, 1fr); } .two-col { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<h1>Operations Dashboard</h1>
|
||||
<div class="header-actions">
|
||||
<input id="search" type="text" placeholder="Search...">
|
||||
<button id="refresh">Refresh</button>
|
||||
<button style="background:#334155">Export</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid" id="metrics-grid"></div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h2>Request Volume</h2>
|
||||
<div class="tabs">
|
||||
<div class="tab active">Hourly</div>
|
||||
<div class="tab">Daily</div>
|
||||
<div class="tab">Weekly</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart" id="chart"></div>
|
||||
</div>
|
||||
|
||||
<div class="two-col">
|
||||
<div class="section" style="margin:0">
|
||||
<div class="section-header"><h2>Top Endpoints</h2></div>
|
||||
<table>
|
||||
<thead><tr><th>Endpoint</th><th>Requests</th><th>Avg Latency</th><th>Error Rate</th></tr></thead>
|
||||
<tbody id="endpoints-table"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="section" style="margin:0">
|
||||
<div class="section-header"><h2>Active Alerts</h2></div>
|
||||
<table>
|
||||
<thead><tr><th>Alert</th><th>Severity</th><th>Service</th><th>Since</th></tr></thead>
|
||||
<tbody id="alerts-table"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h2>Recent Logs</h2>
|
||||
<div class="tabs">
|
||||
<div class="tab active">All</div>
|
||||
<div class="tab">Errors</div>
|
||||
<div class="tab">Warnings</div>
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Timestamp</th><th>Level</th><th>Service</th><th>Message</th><th>Duration</th></tr></thead>
|
||||
<tbody id="logs-table"></tbody>
|
||||
</table>
|
||||
<div class="pagination" id="pagination"></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header"><h2>Service Status</h2></div>
|
||||
<table>
|
||||
<thead><tr><th>Service</th><th>Status</th><th>Uptime</th><th>CPU</th><th>Memory</th><th>Requests/min</th><th>Error Rate</th><th>Last Deploy</th></tr></thead>
|
||||
<tbody id="services-table"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// Metric cards
|
||||
var metrics = [
|
||||
{ label: 'Total Requests', value: '1,284,392', trend: '+12.5%', up: true },
|
||||
{ label: 'Avg Response Time', value: '142ms', trend: '-8.3%', up: true },
|
||||
{ label: 'Error Rate', value: '0.42%', trend: '+0.12%', up: false },
|
||||
{ label: 'Active Users', value: '3,847', trend: '+5.1%', up: true },
|
||||
{ label: 'Throughput', value: '892/s', trend: '+3.7%', up: true },
|
||||
{ label: 'P99 Latency', value: '487ms', trend: '+15ms', up: false },
|
||||
{ label: 'CPU Usage', value: '67%', trend: '-2.4%', up: true },
|
||||
{ label: 'Memory Usage', value: '4.2GB', trend: '+180MB', up: false },
|
||||
{ label: 'Cache Hit Rate', value: '94.7%', trend: '+1.2%', up: true },
|
||||
{ label: 'Queue Depth', value: '234', trend: '-45', up: true },
|
||||
{ label: 'Open Connections', value: '12,483', trend: '+892', up: false },
|
||||
{ label: 'Deployments Today', value: '7', trend: '+2', up: true },
|
||||
];
|
||||
var grid = document.getElementById('metrics-grid');
|
||||
metrics.forEach(function(m) {
|
||||
var sparkBars = '';
|
||||
for (var s = 0; s < 12; s++) {
|
||||
var h = 20 + Math.floor(Math.random() * 80);
|
||||
sparkBars += '<div class="bar" style="height:' + h + '%"></div>';
|
||||
}
|
||||
grid.innerHTML += '<div class="card"><div class="card-label">' + m.label +
|
||||
'</div><div class="card-value">' + m.value +
|
||||
'</div><div class="card-trend ' + (m.up ? 'up' : 'down') + '">' +
|
||||
(m.up ? '+' : '') + m.trend +
|
||||
'</div><div class="card-sparkline">' + sparkBars + '</div></div>';
|
||||
});
|
||||
|
||||
// Chart bars
|
||||
var chart = document.getElementById('chart');
|
||||
for (var h = 0; h < 24; h++) {
|
||||
var height = 15 + ((h * 17 + 7) % 85);
|
||||
var bar = document.createElement('div');
|
||||
bar.className = 'bar';
|
||||
bar.style.height = height + '%';
|
||||
bar.innerHTML = '<span class="bar-label">' + String(h).padStart(2, '0') + ':00</span>';
|
||||
chart.appendChild(bar);
|
||||
}
|
||||
|
||||
// Endpoints table
|
||||
var endpoints = document.getElementById('endpoints-table');
|
||||
var paths = ['/api/users', '/api/auth', '/api/products', '/api/orders', '/api/search',
|
||||
'/api/analytics', '/api/notifications', '/api/payments', '/api/inventory', '/api/reports',
|
||||
'/api/settings', '/api/uploads', '/api/webhooks', '/api/health', '/api/metrics'];
|
||||
paths.forEach(function(p, i) {
|
||||
endpoints.innerHTML += '<tr><td><code>' + p + '</code></td><td>' +
|
||||
(50000 - i * 3000) + '</td><td>' + (45 + i * 12) + 'ms</td><td>' +
|
||||
(0.1 + i * 0.08).toFixed(2) + '%</td></tr>';
|
||||
});
|
||||
|
||||
// Alerts table
|
||||
var alerts = document.getElementById('alerts-table');
|
||||
var alertData = [
|
||||
['High error rate on /api/payments', 'error', 'payments'],
|
||||
['Memory usage above 85%', 'warn', 'api-gateway'],
|
||||
['Slow queries detected', 'warn', 'database'],
|
||||
['SSL certificate expiring in 7 days', 'info', 'infrastructure'],
|
||||
['Disk usage above 80%', 'warn', 'storage'],
|
||||
['Connection pool exhaustion', 'error', 'database'],
|
||||
['Rate limit threshold reached', 'warn', 'api-gateway'],
|
||||
['Deployment rollback detected', 'info', 'ci-cd'],
|
||||
];
|
||||
alertData.forEach(function(a, i) {
|
||||
var badge = a[1] === 'error' ? 'badge-error' : a[1] === 'warn' ? 'badge-warn' : 'badge-info';
|
||||
alerts.innerHTML += '<tr><td>' + a[0] + '</td><td><span class="badge ' + badge + '">' +
|
||||
a[1].toUpperCase() + '</span></td><td>' + a[2] + '</td><td>' + (i * 15 + 5) + 'm ago</td></tr>';
|
||||
});
|
||||
|
||||
// Logs table
|
||||
var logs = document.getElementById('logs-table');
|
||||
var services = ['auth', 'api-gateway', 'worker', 'cache', 'database', 'payments', 'search', 'notifications'];
|
||||
var levels = ['INFO', 'WARN', 'ERROR', 'DEBUG'];
|
||||
var messages = [
|
||||
'Request processed successfully',
|
||||
'Connection pool running low',
|
||||
'Failed to connect to upstream service',
|
||||
'Cache miss for key session:',
|
||||
'Query execution exceeded threshold',
|
||||
'Payment webhook received',
|
||||
'Search index rebuild started',
|
||||
'Rate limit applied to client',
|
||||
'Health check passed',
|
||||
'Background job completed',
|
||||
];
|
||||
for (var i = 0; i < 200; i++) {
|
||||
var level = levels[i % 4];
|
||||
var badge = level === 'ERROR' ? 'badge-error' : level === 'WARN' ? 'badge-warn' :
|
||||
level === 'DEBUG' ? 'badge-debug' : 'badge-info';
|
||||
var ts = new Date(2025, 2, 15, 23 - Math.floor(i / 8), 59 - (i % 60));
|
||||
logs.innerHTML += '<tr><td style="white-space:nowrap">' + ts.toISOString().replace('T', ' ').substring(0, 19) +
|
||||
'</td><td><span class="badge ' + badge + '">' + level +
|
||||
'</span></td><td>' + services[i % 8] +
|
||||
'</td><td>' + messages[i % 10] + ' #' + (i + 1) +
|
||||
'</td><td>' + (Math.floor(Math.random() * 500) + 10) + 'ms</td></tr>';
|
||||
}
|
||||
|
||||
// Pagination
|
||||
var pag = document.getElementById('pagination');
|
||||
for (var p = 1; p <= 10; p++) {
|
||||
pag.innerHTML += '<button class="' + (p === 1 ? 'active' : '') + '">' + p + '</button>';
|
||||
}
|
||||
|
||||
// Services table
|
||||
var svcTable = document.getElementById('services-table');
|
||||
var svcNames = ['api-gateway', 'auth-service', 'payment-processor', 'search-engine',
|
||||
'notification-hub', 'analytics-pipeline', 'cache-layer', 'worker-pool',
|
||||
'storage-service', 'cdn-origin', 'queue-processor', 'ml-inference'];
|
||||
svcNames.forEach(function(name, i) {
|
||||
var status = i < 9 ? 'active' : i === 9 ? 'pending' : 'inactive';
|
||||
var badge = status === 'active' ? 'badge-active' : status === 'pending' ? 'badge-pending' : 'badge-inactive';
|
||||
svcTable.innerHTML += '<tr><td><strong>' + name + '</strong></td>' +
|
||||
'<td><span class="badge ' + badge + '">' + status + '</span></td>' +
|
||||
'<td>' + (99.9 - i * 0.05).toFixed(2) + '%</td>' +
|
||||
'<td>' + (30 + i * 5) + '%</td>' +
|
||||
'<td>' + (512 + i * 128) + 'MB</td>' +
|
||||
'<td>' + (2000 - i * 150) + '</td>' +
|
||||
'<td>' + (0.1 + i * 0.04).toFixed(2) + '%</td>' +
|
||||
'<td>' + (i + 1) + 'h ago</td></tr>';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,179 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>TechStore - Electronics & Gadgets</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #fff; color: #0f172a; }
|
||||
.topbar { background: #0f172a; color: #94a3b8; font-size: 12px; padding: 6px 24px; display: flex; justify-content: space-between; }
|
||||
.navbar { display: flex; align-items: center; gap: 24px; padding: 12px 24px; border-bottom: 1px solid #e2e8f0; position: sticky; top: 0; background: #fff; z-index: 100; }
|
||||
.navbar .logo { font-size: 22px; font-weight: 800; color: #6366f1; }
|
||||
.navbar .search { flex: 1; max-width: 500px; position: relative; }
|
||||
.navbar .search input { width: 100%; padding: 10px 16px; border: 2px solid #e2e8f0; border-radius: 8px; font-size: 14px; }
|
||||
.navbar .search input:focus { border-color: #6366f1; outline: none; }
|
||||
.nav-links { display: flex; gap: 20px; }
|
||||
.nav-links a { text-decoration: none; color: #475569; font-size: 14px; }
|
||||
.nav-actions { display: flex; gap: 16px; align-items: center; }
|
||||
.nav-actions button { background: none; border: none; font-size: 14px; cursor: pointer; color: #475569; }
|
||||
.cart-badge { background: #6366f1; color: #fff; font-size: 11px; padding: 2px 6px; border-radius: 10px; margin-left: 4px; }
|
||||
.categories { display: flex; gap: 0; padding: 0 24px; border-bottom: 1px solid #f1f5f9; overflow-x: auto; }
|
||||
.categories a { padding: 10px 16px; font-size: 13px; color: #64748b; text-decoration: none; white-space: nowrap; border-bottom: 2px solid transparent; }
|
||||
.categories a:hover, .categories a.active { color: #6366f1; border-bottom-color: #6366f1; }
|
||||
.hero { background: linear-gradient(135deg, #312e81, #6366f1); color: #fff; padding: 60px 24px; text-align: center; }
|
||||
.hero h2 { font-size: 2.5rem; margin-bottom: 12px; }
|
||||
.hero p { font-size: 18px; opacity: 0.9; margin-bottom: 24px; }
|
||||
.hero button { padding: 12px 32px; background: #fff; color: #6366f1; border: none; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer; }
|
||||
.container { max-width: 1280px; margin: 0 auto; padding: 0 24px; }
|
||||
.section-title { font-size: 1.5rem; font-weight: 700; margin: 32px 0 16px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.section-title a { font-size: 14px; color: #6366f1; text-decoration: none; }
|
||||
.product-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin-bottom: 32px; }
|
||||
.product { border: 1px solid #e2e8f0; border-radius: 12px; overflow: hidden; transition: box-shadow 0.2s, transform 0.2s; }
|
||||
.product:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.1); transform: translateY(-2px); }
|
||||
.product-img { height: 200px; display: flex; align-items: center; justify-content: center; font-size: 48px; }
|
||||
.product-info { padding: 16px; }
|
||||
.product-brand { font-size: 12px; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.product-name { font-size: 15px; font-weight: 600; margin: 4px 0 8px; line-height: 1.3; }
|
||||
.product-price { font-size: 20px; font-weight: 700; color: #0f172a; }
|
||||
.product-original { font-size: 14px; color: #94a3b8; text-decoration: line-through; margin-left: 8px; }
|
||||
.product-rating { display: flex; align-items: center; gap: 4px; margin-top: 8px; font-size: 13px; color: #64748b; }
|
||||
.stars { color: #f59e0b; }
|
||||
.product-actions { display: flex; gap: 8px; margin-top: 12px; }
|
||||
.product-actions button { flex: 1; padding: 8px; border: none; border-radius: 6px; font-size: 13px; cursor: pointer; }
|
||||
.btn-primary { background: #6366f1; color: #fff; }
|
||||
.btn-secondary { background: #f1f5f9; color: #475569; }
|
||||
.filters { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.filter { padding: 6px 16px; border: 1px solid #e2e8f0; border-radius: 20px; font-size: 13px; background: #fff; cursor: pointer; }
|
||||
.filter.active { background: #6366f1; color: #fff; border-color: #6366f1; }
|
||||
.deals-banner { background: #fef3c7; border: 1px solid #fbbf24; border-radius: 12px; padding: 20px 24px; margin: 24px 0; display: flex; justify-content: space-between; align-items: center; }
|
||||
.deals-banner h3 { color: #b45309; }
|
||||
.reviews { margin: 24px 0; }
|
||||
.review { padding: 16px; border-bottom: 1px solid #f1f5f9; }
|
||||
.review-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.review-author { font-weight: 600; font-size: 14px; }
|
||||
.review-date { font-size: 12px; color: #94a3b8; }
|
||||
.review-body { font-size: 14px; color: #475569; }
|
||||
.footer { background: #0f172a; color: #94a3b8; padding: 48px 24px; margin-top: 48px; }
|
||||
.footer-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 32px; max-width: 1280px; margin: 0 auto; }
|
||||
.footer h4 { color: #fff; margin-bottom: 16px; font-size: 14px; }
|
||||
.footer a { display: block; color: #94a3b8; text-decoration: none; font-size: 13px; margin-bottom: 8px; }
|
||||
.footer-bottom { border-top: 1px solid #1e293b; padding-top: 24px; margin-top: 32px; text-align: center; font-size: 13px; max-width: 1280px; margin-left: auto; margin-right: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="topbar">
|
||||
<span>Free shipping on orders over $99</span>
|
||||
<span>Customer Service: 1-800-TECH | Track Order | Help</span>
|
||||
</div>
|
||||
|
||||
<nav class="navbar">
|
||||
<div class="logo">TechStore</div>
|
||||
<div class="search"><input type="text" placeholder="Search products, brands, categories..."></div>
|
||||
<div class="nav-actions">
|
||||
<button>Account</button>
|
||||
<button>Wishlist</button>
|
||||
<button>Cart <span class="cart-badge">3</span></button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="categories">
|
||||
<a href="#" class="active">All</a><a href="#">Laptops</a><a href="#">Phones</a>
|
||||
<a href="#">Tablets</a><a href="#">Audio</a><a href="#">Cameras</a>
|
||||
<a href="#">Monitors</a><a href="#">Storage</a><a href="#">Networking</a>
|
||||
<a href="#">Accessories</a><a href="#">Deals</a><a href="#">New Arrivals</a>
|
||||
</div>
|
||||
|
||||
<div class="hero">
|
||||
<h2>Spring Tech Sale</h2>
|
||||
<p>Up to 40% off on selected electronics. Limited time offer.</p>
|
||||
<button>Shop Now</button>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="deals-banner">
|
||||
<div><h3>Flash Deals - Ends in 04:32:17</h3><p style="font-size:13px;color:#92400e">Extra 15% off with code SPRING15</p></div>
|
||||
<button class="btn-primary" style="padding:10px 24px;border-radius:8px;border:none;cursor:pointer">View All Deals</button>
|
||||
</div>
|
||||
|
||||
<div class="section-title"><span>Featured Products</span><a href="#">View All</a></div>
|
||||
<div class="filters">
|
||||
<span class="filter active">All</span><span class="filter">Under $100</span>
|
||||
<span class="filter">$100 - $500</span><span class="filter">$500+</span>
|
||||
<span class="filter">Top Rated</span><span class="filter">New</span>
|
||||
</div>
|
||||
<div class="product-grid" id="featured-grid"></div>
|
||||
|
||||
<div class="section-title"><span>Best Sellers</span><a href="#">View All</a></div>
|
||||
<div class="product-grid" id="bestsellers-grid"></div>
|
||||
|
||||
<div class="section-title"><span>New Arrivals</span><a href="#">View All</a></div>
|
||||
<div class="product-grid" id="newarrivals-grid"></div>
|
||||
|
||||
<div class="section-title"><span>Customer Reviews</span></div>
|
||||
<div class="reviews" id="reviews"></div>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="footer-grid">
|
||||
<div><h4>Shop</h4><a href="#">Laptops</a><a href="#">Phones</a><a href="#">Tablets</a><a href="#">Audio</a><a href="#">Cameras</a><a href="#">Monitors</a><a href="#">Accessories</a></div>
|
||||
<div><h4>Support</h4><a href="#">Help Center</a><a href="#">Returns</a><a href="#">Warranty</a><a href="#">Contact Us</a><a href="#">Track Order</a><a href="#">Shipping Info</a></div>
|
||||
<div><h4>Company</h4><a href="#">About Us</a><a href="#">Careers</a><a href="#">Press</a><a href="#">Blog</a><a href="#">Sustainability</a><a href="#">Investor Relations</a></div>
|
||||
<div><h4>Connect</h4><a href="#">Newsletter</a><a href="#">Social Media</a><a href="#">Affiliate Program</a><a href="#">Partner With Us</a><a href="#">Developer API</a></div>
|
||||
</div>
|
||||
<div class="footer-bottom">2025 TechStore Inc. All rights reserved. | Privacy Policy | Terms of Service | Cookie Settings</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var brands = ['Apple', 'Samsung', 'Sony', 'Bose', 'Dell', 'Lenovo', 'LG', 'ASUS', 'Logitech', 'Canon', 'Nikon', 'JBL'];
|
||||
var categories = ['Laptop', 'Phone', 'Tablet', 'Headphones', 'Camera', 'Monitor', 'Speaker', 'Keyboard'];
|
||||
var colors = ['#dbeafe', '#fce7f3', '#d1fae5', '#fef3c7', '#e0e7ff', '#f1f5f9', '#fef2f2', '#f0fdf4'];
|
||||
|
||||
function makeProduct(i) {
|
||||
var brand = brands[i % brands.length];
|
||||
var cat = categories[i % categories.length];
|
||||
var price = 49 + (i * 73) % 1500;
|
||||
var original = Math.round(price * 1.25);
|
||||
var rating = (3.5 + (i % 15) * 0.1).toFixed(1);
|
||||
var reviews = 50 + (i * 37) % 2000;
|
||||
var stars = '';
|
||||
for (var s = 0; s < 5; s++) stars += s < Math.round(parseFloat(rating)) ? '*' : ' ';
|
||||
return '<div class="product"><div class="product-img" style="background:' + colors[i % 8] + '">' +
|
||||
cat.charAt(0).toUpperCase() + '</div><div class="product-info">' +
|
||||
'<div class="product-brand">' + brand + '</div>' +
|
||||
'<div class="product-name">' + brand + ' ' + cat + ' Pro ' + (2024 + (i % 3)) + ' Edition</div>' +
|
||||
'<div><span class="product-price">$' + price + '</span><span class="product-original">$' + original + '</span></div>' +
|
||||
'<div class="product-rating"><span class="stars">' + stars + '</span> ' + rating + ' (' + reviews + ')</div>' +
|
||||
'<div class="product-actions"><button class="btn-primary">Add to Cart</button><button class="btn-secondary">Compare</button></div>' +
|
||||
'</div></div>';
|
||||
}
|
||||
|
||||
var featured = document.getElementById('featured-grid');
|
||||
for (var i = 0; i < 16; i++) featured.innerHTML += makeProduct(i);
|
||||
|
||||
var bestsellers = document.getElementById('bestsellers-grid');
|
||||
for (var i = 16; i < 32; i++) bestsellers.innerHTML += makeProduct(i);
|
||||
|
||||
var newarrivals = document.getElementById('newarrivals-grid');
|
||||
for (var i = 32; i < 48; i++) newarrivals.innerHTML += makeProduct(i);
|
||||
|
||||
var reviewsEl = document.getElementById('reviews');
|
||||
var names = ['Alice M.', 'Bob K.', 'Carol S.', 'David L.', 'Eva R.', 'Frank W.', 'Grace H.', 'Henry P.'];
|
||||
var reviewTexts = [
|
||||
'Excellent product, arrived faster than expected. Build quality is outstanding.',
|
||||
'Good value for money. Would recommend to anyone looking for a reliable device.',
|
||||
'Decent product but the battery life could be better. Works well otherwise.',
|
||||
'Amazing quality! This is my third purchase from this brand and they never disappoint.',
|
||||
];
|
||||
for (var i = 0; i < 20; i++) {
|
||||
reviewsEl.innerHTML += '<div class="review"><div class="review-header"><div><span class="review-author">' +
|
||||
names[i % 8] + '</span></div><span class="review-date">March ' + (15 - i % 15) + ', 2025</span></div>' +
|
||||
'<div class="review-body">' + reviewTexts[i % 4] + '</div></div>';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 = [
|
||||
"<html><head><title>Bench</title></head><body>",
|
||||
"<h1>Benchmark Page</h1>",
|
||||
"<input id='name' type='text' placeholder='Name'>",
|
||||
"<input id='email' type='email' placeholder='Email'>",
|
||||
"<select id='color'><option value='red'>Red</option><option value='blue'>Blue</option></select>",
|
||||
"<input id='agree' type='checkbox'>",
|
||||
"<textarea id='bio' placeholder='Bio'></textarea>",
|
||||
"<button id='submit'>Submit</button>",
|
||||
"<p id='status'>Ready</p>",
|
||||
"<a id='link' href='javascript:void(0)' onclick=\"document.getElementById('status').textContent='Clicked'\">Click me</a>",
|
||||
"<ul>",
|
||||
...Array.from({ length: 20 }, (_, i) => `<li class='item'>Item ${i + 1}</li>`),
|
||||
"</ul>",
|
||||
"</body></html>",
|
||||
].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" },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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<string>;
|
||||
}> {
|
||||
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<string>();
|
||||
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<string, unknown>) {
|
||||
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<string, unknown>[] = [];
|
||||
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<string, unknown>;
|
||||
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<ReturnType<typeof runDogfood>>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> {
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!match) return {};
|
||||
const fields: Record<string, string> = {};
|
||||
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}**`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,159 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Buggy App - Dogfood Test Fixture</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: system-ui, sans-serif; color: #333; background: #f9f9f9; }
|
||||
header { background: #1a1a2e; color: #fff; padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; }
|
||||
header h1 { font-size: 20px; }
|
||||
nav { display: flex; gap: 16px; }
|
||||
nav a { color: #ccc; text-decoration: none; }
|
||||
nav a:hover { color: #fff; }
|
||||
main { max-width: 960px; margin: 0 auto; padding: 32px 24px; }
|
||||
.card { background: #fff; border: 1px solid #e0e0e0; border-radius: 8px; padding: 24px; margin-bottom: 24px; }
|
||||
.card h2 { margin-bottom: 12px; }
|
||||
.btn { padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; }
|
||||
.btn-primary { background: #3b82f6; color: #fff; }
|
||||
.btn-danger { background: #ef4444; color: #fff; }
|
||||
input, textarea { padding: 8px 12px; border: 1px solid #d0d0d0; border-radius: 4px; font-size: 14px; width: 100%; margin-bottom: 12px; }
|
||||
label { display: block; margin-bottom: 4px; font-weight: 500; }
|
||||
footer { text-align: center; padding: 24px; color: #999; font-size: 12px; }
|
||||
|
||||
/* BUG: Visual - clipped text via overflow: hidden on a short container */
|
||||
.clipped-container {
|
||||
overflow: hidden;
|
||||
height: 20px;
|
||||
border: 1px solid #e0e0e0;
|
||||
padding: 4px 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* BUG: Visual - misaligned element */
|
||||
.misaligned {
|
||||
display: flex;
|
||||
align-items: flex-start; /* should be center */
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: #f0f4ff;
|
||||
border-radius: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.misaligned .icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #3b82f6;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin-top: 14px; /* intentionally off */
|
||||
}
|
||||
.misaligned .label {
|
||||
font-size: 16px;
|
||||
line-height: 40px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>Buggy App</h1>
|
||||
<nav>
|
||||
<a href="#dashboard">Dashboard</a>
|
||||
<a href="#settings">Settings</a>
|
||||
<!-- BUG: Functional - broken link to nonexistent page -->
|
||||
<a href="#/this-page-does-not-exist">Reports</a>
|
||||
<a href="#help">Help</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- BUG: Content - typo "Welocme" -->
|
||||
<h2 style="margin-bottom: 24px;">Welocme to the Dashboard</h2>
|
||||
|
||||
<!-- Card 1: Functional bug - button throws JS error -->
|
||||
<div class="card">
|
||||
<h2>Quick Actions</h2>
|
||||
<p>Perform common tasks from here.</p>
|
||||
<div style="margin-top: 12px; display: flex; gap: 8px;">
|
||||
<!-- BUG: Functional - button throws JS error on click -->
|
||||
<button class="btn btn-primary" onclick="processAction()">Run Analysis</button>
|
||||
<button class="btn btn-danger" onclick="deleteAllData()">Delete All Data</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card 2: Visual bugs - clipped text and misaligned element -->
|
||||
<div class="card">
|
||||
<h2>System Status</h2>
|
||||
<!-- BUG: Visual - text is clipped because container is too short -->
|
||||
<div class="clipped-container">
|
||||
The system is currently operating normally. All services are online and responding within expected latency thresholds. Last health check completed at 14:32 UTC.
|
||||
</div>
|
||||
<!-- BUG: Visual - icon and label are misaligned -->
|
||||
<div class="misaligned">
|
||||
<div class="icon"></div>
|
||||
<span class="label">All systems operational</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card 3: Content bug - placeholder text -->
|
||||
<div class="card">
|
||||
<h2>Recent Activity</h2>
|
||||
<!-- BUG: Content - lorem ipsum placeholder left in -->
|
||||
<p>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.</p>
|
||||
</div>
|
||||
|
||||
<!-- Card 4: UX bug - form with no feedback on submit -->
|
||||
<div class="card">
|
||||
<h2>Contact Support</h2>
|
||||
<form id="support-form">
|
||||
<label for="subject">Subject</label>
|
||||
<input type="text" id="subject" placeholder="Enter subject">
|
||||
<label for="message">Message</label>
|
||||
<textarea id="message" rows="3" placeholder="Describe your issue"></textarea>
|
||||
<!-- BUG: UX - submit does nothing, no feedback -->
|
||||
<button type="submit" class="btn btn-primary">Send Message</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Card 5: UX bug - empty state with no message -->
|
||||
<div class="card">
|
||||
<h2>Notifications</h2>
|
||||
<!-- BUG: UX - empty container with no empty state message -->
|
||||
<div id="notifications-list" style="min-height: 60px;">
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
© 2025 Buggy App Inc. All rights reserved.
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// BUG: Console - error on page load
|
||||
console.error("Failed to initialize analytics: endpoint not configured");
|
||||
|
||||
// BUG: Console - failed fetch on page load
|
||||
fetch("https://api.nonexistent-endpoint.invalid/v1/health")
|
||||
.catch(function() {});
|
||||
|
||||
// BUG: Functional - function referenced by button is broken
|
||||
function processAction() {
|
||||
// Throws because undefinedService is not defined
|
||||
undefinedService.runAnalysis();
|
||||
}
|
||||
|
||||
// No confirmation for destructive action
|
||||
function deleteAllData() {
|
||||
alert("All data deleted!");
|
||||
}
|
||||
|
||||
// Form submit does nothing
|
||||
document.getElementById("support-form").addEventListener("submit", function(e) {
|
||||
e.preventDefault();
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -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,
|
||||
'<html><body><h1>Test File Access</h1><p>This content was loaded from a local file.</p></body></html>'
|
||||
);
|
||||
});
|
||||
|
||||
// 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<boolean>((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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user