Compare commits
242
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8556da9bd2 | ||
|
|
0151a496cf | ||
|
|
caad12de81 | ||
|
|
51c7ee0e2c | ||
|
|
193467fdb7 | ||
|
|
a821006cc0 | ||
|
|
4a55e3407b | ||
|
|
4d57c3e69d | ||
|
|
0a36666587 | ||
|
|
2a44b515ee | ||
|
|
13ed01b3bd | ||
|
|
8561a755ef | ||
|
|
89595836c6 | ||
|
|
7b4b124e7f | ||
|
|
b2b6356d63 | ||
|
|
e6ba1eb8c9 | ||
|
|
1f4b6b9d7a | ||
|
|
6c93480d0d | ||
|
|
cc9da7aff7 | ||
|
|
3c942e2874 | ||
|
|
40fdb4284d | ||
|
|
f8bc8b368a | ||
|
|
fbcab375b0 | ||
|
|
8d78fcbbb3 | ||
|
|
312db04e5e | ||
|
|
369f48752a | ||
|
|
6dd53449e8 | ||
|
|
da7fef3fef | ||
|
|
43d9c40bc4 | ||
|
|
dc26ff7667 | ||
|
|
747a3772e1 | ||
|
|
bbad2de627 | ||
|
|
0f0f300d40 | ||
|
|
db215a1467 | ||
|
|
a95bc0f75a | ||
|
|
995a47fdb0 | ||
|
|
f9174513c2 | ||
|
|
63f03b8e06 | ||
|
|
699a461646 | ||
|
|
89a8ceccf7 | ||
|
|
67b5ee1600 | ||
|
|
eb64ca497a | ||
|
|
8c6fc35450 | ||
|
|
7d2cd726ec | ||
|
|
5ac01fa743 | ||
|
|
2fb766fc78 | ||
|
|
0865851293 | ||
|
|
a0981979ca | ||
|
|
cd1f255129 | ||
|
|
23a117c5c2 | ||
|
|
32ffd8f3c4 | ||
|
|
780edb2c45 | ||
|
|
3150acd574 | ||
|
|
b5b84051e4 | ||
|
|
859c8aaf94 | ||
|
|
3a3317b048 | ||
|
|
f806b666ba | ||
|
|
a7a59c94f3 | ||
|
|
ce1f1f5f81 | ||
|
|
be30bc902d | ||
|
|
1391f00404 | ||
|
|
c5020f2b89 | ||
|
|
9b1961af93 | ||
|
|
ceaee00952 | ||
|
|
5c5c0d8081 | ||
|
|
b48c3e9dd2 | ||
|
|
9c0955ca99 | ||
|
|
d374e413be | ||
|
|
fb1e860b4e | ||
|
|
84c6e6fd30 | ||
|
|
7e5baa6d77 | ||
|
|
59b6e5a034 | ||
|
|
39e54113e6 | ||
|
|
aed466b347 | ||
|
|
8d14ecb92b | ||
|
|
6daad22ada | ||
|
|
9837b9c1aa | ||
|
|
af800f8403 | ||
|
|
421f8fab82 | ||
|
|
2b3c5c26cd | ||
|
|
2d37967b7e | ||
|
|
06b3b94493 | ||
|
|
0749ad667e | ||
|
|
fa563b59b9 | ||
|
|
757626f27c | ||
|
|
755fa50400 | ||
|
|
fdc09c95f4 | ||
|
|
486e1b341f | ||
|
|
5b5dffe0a2 | ||
|
|
4be4605543 | ||
|
|
d03f6431ea | ||
|
|
1e7619d59d | ||
|
|
8cfba1752d | ||
|
|
b8a5fc7101 | ||
|
|
8fc1d000f7 | ||
|
|
c6de80b95e | ||
|
|
1cd90078b4 | ||
|
|
f8eb38c7f1 | ||
|
|
60f3afcf61 | ||
|
|
f51e955d99 | ||
|
|
59ea02cc8e | ||
|
|
8dd012f4fd | ||
|
|
663e10355a | ||
|
|
7734bb2702 | ||
|
|
a865dd56e0 | ||
|
|
9a8d9f439b | ||
|
|
664789f5c6 | ||
|
|
c0d4cf6a93 | ||
|
|
48a265057b | ||
|
|
0705b4ddac | ||
|
|
811e66f99e | ||
|
|
42c4c56c9a | ||
|
|
0883813cd3 | ||
|
|
8163f6cdca | ||
|
|
eda956b754 | ||
|
|
d866ee2022 | ||
|
|
ebfabe0e62 | ||
|
|
5fa239676b | ||
|
|
285eab46df | ||
|
|
5b74604a5c | ||
|
|
4b5fc78f71 | ||
|
|
c092ffd82b | ||
|
|
8ac7fe916e | ||
|
|
1fd8e9d09a | ||
|
|
a3d966244e | ||
|
|
8348b77800 | ||
|
|
609f32c986 | ||
|
|
79f464d47a | ||
|
|
51d9ab4d49 | ||
|
|
6636ac0e74 | ||
|
|
bc94eaf94f | ||
|
|
77a27fe6a3 | ||
|
|
daf7263385 | ||
|
|
0745db44a2 | ||
|
|
19ba9048a0 | ||
|
|
e78cc05cec | ||
|
|
25a152652a | ||
|
|
5ea508a917 | ||
|
|
02d1a7ad7c | ||
|
|
c23ce448bd | ||
|
|
fa91c22e50 | ||
|
|
c07f43e242 | ||
|
|
5f8e993602 | ||
|
|
fc091d294d | ||
|
|
c4f0f22ae9 | ||
|
|
23a8b19de1 | ||
|
|
e2ebde261c | ||
|
|
06842ea7c5 | ||
|
|
a773863214 | ||
|
|
f962c7a4c4 | ||
|
|
17d2785ca0 | ||
|
|
d1ba208a50 | ||
|
|
e365909d4f | ||
|
|
d4b9004a6d | ||
|
|
529b8acfbe | ||
|
|
fe3c1ddb48 | ||
|
|
944fa01247 | ||
|
|
da45d00b2a | ||
|
|
fbdae9b6ae | ||
|
|
388f19e1bb | ||
|
|
c4a40033e0 | ||
|
|
1fa7949542 | ||
|
|
974d735af5 | ||
|
|
bd05917f20 | ||
|
|
44ce24fb48 | ||
|
|
67986adffc | ||
|
|
790123f9af | ||
|
|
99c732c188 | ||
|
|
c7ad5ff66a | ||
|
|
f8482f3533 | ||
|
|
bdfcc4ee2d | ||
|
|
235fa88dc6 | ||
|
|
8e43469c8b | ||
|
|
d4b948c1d4 | ||
|
|
d40fd4009d | ||
|
|
056024b447 | ||
|
|
3a2e2796c8 | ||
|
|
f426860c04 | ||
|
|
ea9d456341 | ||
|
|
e02bc1a10c | ||
|
|
56bb92bfe1 | ||
|
|
087600e50e | ||
|
|
a673a77c4e | ||
|
|
1129a3e7fc | ||
|
|
640d259130 | ||
|
|
1327856889 | ||
|
|
a3dcf3fe60 | ||
|
|
870876b5c1 | ||
|
|
b9a24df40f | ||
|
|
fb7185d860 | ||
|
|
a66c5960f2 | ||
|
|
d019c09bbc | ||
|
|
d4f7fbc718 | ||
|
|
2fb2a51c82 | ||
|
|
c562ef5bb7 | ||
|
|
01172eaa44 | ||
|
|
942b8cd8ee | ||
|
|
315d191606 | ||
|
|
f2d4089284 | ||
|
|
cc82dd137c | ||
|
|
18c3112dbd | ||
|
|
89f9c97ac2 | ||
|
|
db3d23d496 | ||
|
|
78c9aef3c9 | ||
|
|
417428463b | ||
|
|
def2fd90fa | ||
|
|
d678058206 | ||
|
|
c2794232ee | ||
|
|
f6c83e410b | ||
|
|
9f41545216 | ||
|
|
82386b1c60 | ||
|
|
c309535691 | ||
|
|
d0651f14bc | ||
|
|
5bf9fedd58 | ||
|
|
c0a525c9e4 | ||
|
|
cc3c70dc86 | ||
|
|
3649787268 | ||
|
|
00a0e0707e | ||
|
|
94cd888ecb | ||
|
|
644a4f5b63 | ||
|
|
a0bd0c2f0f | ||
|
|
2bab729f26 | ||
|
|
94521e7a8c | ||
|
|
d9387aae58 | ||
|
|
aba2353112 | ||
|
|
68cebe5192 | ||
|
|
f262ff1bf3 | ||
|
|
788ad0e61f | ||
|
|
b7e7a2548e | ||
|
|
492830accb | ||
|
|
7acde7e29a | ||
|
|
0da54c7038 | ||
|
|
36c2e06f89 | ||
|
|
8f6ad817f1 | ||
|
|
de5ea1d8cf | ||
|
|
139dd0ec5a | ||
|
|
794a77e26e | ||
|
|
7d2c8957ac | ||
|
|
eaa968e229 | ||
|
|
7edc5d596c | ||
|
|
01ac5574d4 | ||
|
|
e5fd26eb9e |
@@ -1,23 +0,0 @@
|
||||
# Changesets
|
||||
|
||||
This project uses [Changesets](https://github.com/changesets/changesets) for versioning and changelog generation.
|
||||
|
||||
## Adding a changeset
|
||||
|
||||
When you make a change that should be released, run:
|
||||
|
||||
```bash
|
||||
pnpm changeset
|
||||
```
|
||||
|
||||
This will prompt you to:
|
||||
1. Select the type of change (patch, minor, major)
|
||||
2. Write a summary of your changes
|
||||
|
||||
The changeset file will be committed with your PR.
|
||||
|
||||
## Release process
|
||||
|
||||
When changesets are merged to `main`, the release workflow will:
|
||||
1. Create a "Version Packages" PR that updates version numbers and changelogs
|
||||
2. When that PR is merged, packages are automatically published to npm
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"commit": false,
|
||||
"fixed": [],
|
||||
"linked": [],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": []
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
||||
"name": "agent-browser",
|
||||
"description": "Headless browser automation for AI agents",
|
||||
"description": "Browser automation for AI agents",
|
||||
"owner": {
|
||||
"name": "Vercel",
|
||||
"email": "support@vercel.com"
|
||||
|
||||
+37
-114
@@ -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
|
||||
@@ -64,12 +27,20 @@ jobs:
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Cache Rust build artifacts
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: cli
|
||||
|
||||
- name: Format check
|
||||
run: cargo fmt --manifest-path cli/Cargo.toml -- --check
|
||||
|
||||
- name: Clippy check
|
||||
run: cargo clippy --manifest-path cli/Cargo.toml -- -D warnings
|
||||
|
||||
- name: Run Rust tests
|
||||
run: cargo test --profile ci --manifest-path cli/Cargo.toml
|
||||
|
||||
@@ -84,7 +55,7 @@ jobs:
|
||||
target: aarch64-apple-darwin
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
- os: windows-latest-8-cores
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
|
||||
steps:
|
||||
@@ -104,27 +75,40 @@ 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'
|
||||
runs-on: windows-latest-8-cores
|
||||
runs-on: windows-latest
|
||||
needs: rust-cross
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
@@ -138,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
|
||||
@@ -161,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"
|
||||
@@ -190,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'
|
||||
@@ -235,7 +170,7 @@ jobs:
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
binary: agent-browser-darwin-arm64
|
||||
- os: windows-latest-8-cores
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
binary: agent-browser-win32-x64.exe
|
||||
|
||||
@@ -243,16 +178,10 @@ 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
|
||||
@@ -267,12 +196,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 }}
|
||||
@@ -299,7 +222,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)
|
||||
@@ -314,5 +237,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
|
||||
|
||||
@@ -10,12 +10,40 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
# Build native binaries for all platforms first
|
||||
check-release:
|
||||
name: Check for new version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_release: ${{ steps.check.outputs.should_release }}
|
||||
version: ${{ steps.check.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Compare package.json version to npm
|
||||
id: check
|
||||
run: |
|
||||
LOCAL_VERSION=$(node -p "require('./package.json').version")
|
||||
echo "Local version: $LOCAL_VERSION"
|
||||
|
||||
NPM_VERSION=$(npm view agent-browser version 2>/dev/null || echo "0.0.0")
|
||||
echo "npm version: $NPM_VERSION"
|
||||
|
||||
if [ "$LOCAL_VERSION" != "$NPM_VERSION" ]; then
|
||||
echo "Version changed: $NPM_VERSION -> $LOCAL_VERSION"
|
||||
echo "should_release=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Version unchanged, skipping release"
|
||||
echo "should_release=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "version=$LOCAL_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build-binaries:
|
||||
name: Build ${{ matrix.name }}
|
||||
needs: check-release
|
||||
if: needs.check-release.outputs.should_release == 'true'
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -31,6 +59,16 @@ jobs:
|
||||
target: aarch64-unknown-linux-gnu
|
||||
binary: agent-browser-linux-arm64
|
||||
use_zigbuild: true
|
||||
- name: Linux musl x64
|
||||
os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
binary: agent-browser-linux-musl-x64
|
||||
use_zigbuild: true
|
||||
- name: Linux musl ARM64
|
||||
os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl
|
||||
binary: agent-browser-linux-musl-arm64
|
||||
use_zigbuild: true
|
||||
- name: Windows x64
|
||||
os: ubuntu-latest
|
||||
target: x86_64-pc-windows-gnu
|
||||
@@ -127,19 +165,13 @@ jobs:
|
||||
path: artifacts/${{ matrix.binary }}
|
||||
retention-days: 7
|
||||
|
||||
# Create release PR or publish to npm (with binaries)
|
||||
release:
|
||||
name: Release
|
||||
needs: build-binaries
|
||||
publish:
|
||||
name: Publish to npm
|
||||
needs: [check-release, build-binaries]
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
published: ${{ steps.changesets.outputs.published }}
|
||||
publishedPackages: ${{ steps.changesets.outputs.publishedPackages }}
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -153,7 +185,7 @@ jobs:
|
||||
cache: pnpm
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install Dependencies
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Download all binary artifacts
|
||||
@@ -175,11 +207,13 @@ jobs:
|
||||
EXPECTED_BINARIES=(
|
||||
"agent-browser-linux-x64"
|
||||
"agent-browser-linux-arm64"
|
||||
"agent-browser-linux-musl-x64"
|
||||
"agent-browser-linux-musl-arm64"
|
||||
"agent-browser-win32-x64.exe"
|
||||
"agent-browser-darwin-x64"
|
||||
"agent-browser-darwin-arm64"
|
||||
)
|
||||
MIN_SIZE=100000 # Binaries should be at least 100KB
|
||||
MIN_SIZE=100000
|
||||
ERRORS=0
|
||||
for binary in "${EXPECTED_BINARIES[@]}"; do
|
||||
if [ ! -f "bin/$binary" ]; then
|
||||
@@ -199,31 +233,20 @@ jobs:
|
||||
echo "Error: $ERRORS binary issues found"
|
||||
exit 1
|
||||
fi
|
||||
echo "All 5 platform binaries present and valid"
|
||||
echo "All 7 platform binaries present and valid"
|
||||
|
||||
- name: Create Release Pull Request or Publish to npm
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
version: pnpm ci:version
|
||||
publish: pnpm ci:publish
|
||||
title: 'chore: version packages'
|
||||
commit: 'chore: version packages'
|
||||
- name: Publish to npm
|
||||
run: pnpm publish --no-git-checks
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_VERCEL_TOKEN_ELEVATED }}
|
||||
|
||||
# Create GitHub release with binaries after npm publish
|
||||
github-release:
|
||||
name: Create GitHub Release
|
||||
needs: release
|
||||
if: needs.release.outputs.published == 'true'
|
||||
needs: [check-release, publish]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -241,28 +264,59 @@ jobs:
|
||||
- name: Verify binaries exist
|
||||
run: |
|
||||
BINARY_COUNT=$(ls bin/agent-browser-* 2>/dev/null | wc -l)
|
||||
if [ "$BINARY_COUNT" -lt 5 ]; then
|
||||
echo "Error: Expected 5 binaries, found $BINARY_COUNT"
|
||||
if [ "$BINARY_COUNT" -lt 7 ]; then
|
||||
echo "Error: Expected 7 binaries, found $BINARY_COUNT"
|
||||
ls -la bin/
|
||||
exit 1
|
||||
fi
|
||||
echo "Found $BINARY_COUNT binaries"
|
||||
|
||||
- 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 --frozen-lockfile
|
||||
|
||||
- name: Build dashboard
|
||||
run: pnpm --filter dashboard build
|
||||
|
||||
- name: Create dashboard.zip
|
||||
run: cd packages/dashboard/out && zip -r ../../../dashboard.zip .
|
||||
|
||||
- name: Extract changelog entry
|
||||
run: |
|
||||
VERSION="${{ needs.check-release.outputs.version }}"
|
||||
awk '/<!-- release:start -->/{found=1; next} /<!-- release:end -->/{found=0} found{print}' CHANGELOG.md > /tmp/release-notes.md
|
||||
|
||||
LINES=$(wc -l < /tmp/release-notes.md | tr -d ' ')
|
||||
if [ "$LINES" -lt 2 ]; then
|
||||
echo "Error: No release notes found between <!-- release:start --> and <!-- release:end --> markers in CHANGELOG.md"
|
||||
exit 1
|
||||
fi
|
||||
echo "Extracted release notes for $VERSION ($LINES lines)"
|
||||
|
||||
- name: Create GitHub Release
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
VERSION="${{ needs.check-release.outputs.version }}"
|
||||
TAG="v$VERSION"
|
||||
|
||||
# Check if release already exists
|
||||
|
||||
if gh release view "$TAG" &>/dev/null; then
|
||||
echo "Release $TAG already exists, uploading binaries..."
|
||||
gh release upload "$TAG" bin/agent-browser-* --clobber
|
||||
echo "Release $TAG already exists, uploading assets..."
|
||||
gh release upload "$TAG" bin/agent-browser-* dashboard.zip --clobber
|
||||
else
|
||||
echo "Creating release $TAG..."
|
||||
gh release create "$TAG" \
|
||||
--title "$TAG" \
|
||||
--generate-notes \
|
||||
bin/agent-browser-*
|
||||
--notes-file /tmp/release-notes.md \
|
||||
bin/agent-browser-* dashboard.zip
|
||||
fi
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -6,6 +6,7 @@ dist/
|
||||
|
||||
# Native binaries (keep the launcher scripts)
|
||||
bin/agent-browser-*
|
||||
bin/.install-method
|
||||
!bin/agent-browser
|
||||
!bin/agent-browser.cmd
|
||||
|
||||
@@ -45,6 +46,9 @@ yarn.lock
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Windows debug instance config
|
||||
scripts/windows-debug/.instance
|
||||
|
||||
# opensrc - source code for packages
|
||||
opensrc/
|
||||
|
||||
@@ -56,3 +60,7 @@ docs/package-lock.json
|
||||
|
||||
# pnpm
|
||||
.pnpm-store/
|
||||
|
||||
# next
|
||||
.next/
|
||||
out/
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
pnpm lint-staged
|
||||
node scripts/sync-version.js
|
||||
git add cli/Cargo.toml cli/Cargo.lock
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
if [ "${SKIP_CLAWHUB_SYNC:-0}" = "1" ]; then
|
||||
echo "Skipping ClawHub sync (SKIP_CLAWHUB_SYNC=1)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
pnpm run clawhub:sync || {
|
||||
echo "ClawHub sync failed. Push continues. Run 'pnpm run clawhub:sync' manually after fixing login/network."
|
||||
}
|
||||
@@ -9,6 +9,7 @@ This project uses **pnpm**. Always use `pnpm` instead of `npm` or `yarn` for ins
|
||||
## Code Style
|
||||
|
||||
- Do not use emojis in code, output, or documentation. Unicode symbols (✓, ✗, →, ⚠) are acceptable.
|
||||
- In documentation and markdown, never use double hyphens (`--`) as a dash. Use an emdash (—) sparingly when needed. Prefer rewriting the sentence to avoid dashes entirely.
|
||||
- CLI colored output uses `cli/src/color.rs`. This module respects the `NO_COLOR` environment variable. Never use hardcoded ANSI color codes.
|
||||
- CLI flags must always use kebab-case (e.g., `--auto-connect`, `--allow-file-access`). Never use camelCase for flags (e.g., `--autoConnect` is wrong).
|
||||
|
||||
@@ -16,35 +17,80 @@ This project uses **pnpm**. Always use `pnpm` instead of `npm` or `yarn` for ins
|
||||
|
||||
When adding or changing user-facing features (new flags, commands, behaviors, environment variables, etc.), update **all** of the following:
|
||||
|
||||
1. `cli/src/output.rs` -- `--help` output (flags list, examples, environment variables)
|
||||
2. `README.md` -- Options table, relevant feature sections, examples
|
||||
3. `skills/agent-browser/SKILL.md` -- so AI agents know about the feature
|
||||
4. `docs/src/app/` -- the Next.js docs site (MDX pages)
|
||||
1. `cli/src/output.rs` — `--help` output (flags list, examples, environment variables)
|
||||
2. `README.md` — Options table, relevant feature sections, examples
|
||||
3. `skills/agent-browser/SKILL.md` — so AI agents know about the feature
|
||||
4. `docs/src/app/` — the Next.js docs site (MDX pages)
|
||||
5. Inline doc comments in the relevant source files
|
||||
|
||||
This applies to changes that either human users or AI agents would need to know about. Do not skip any of these locations.
|
||||
|
||||
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)
|
||||
## Dashboard (packages/dashboard)
|
||||
|
||||
The codebase has two daemon implementations:
|
||||
- Never use native browser dialogs (`alert`, `confirm`, `prompt`). Use shadcn/ui components (`Dialog`, `AlertDialog`, etc.) instead.
|
||||
- Use param-case (kebab-case) for all file and folder names (e.g., `session-tree.tsx`, not `SessionTree.tsx`). The `ui/` directory follows shadcn conventions which already uses param-case.
|
||||
|
||||
- **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/`
|
||||
## Releasing
|
||||
|
||||
When modifying browser automation logic (commands, actions, protocol handling), changes **must** be made in **both** paths:
|
||||
Releases are manual, single-PR affairs. There is no changesets automation. The maintainer controls the changelog voice and format.
|
||||
|
||||
| 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` |
|
||||
To prepare a release:
|
||||
|
||||
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.
|
||||
1. Create a branch (e.g. `prepare-v0.24.0`)
|
||||
2. Bump `version` in `package.json`
|
||||
3. Run `pnpm version:sync` to update `cli/Cargo.toml`, `cli/Cargo.lock`, and `packages/dashboard/package.json`
|
||||
4. Write the changelog entry in `CHANGELOG.md` at the top, under a new `## <version>` heading, wrapped in `<!-- release:start -->` and `<!-- release:end -->` markers
|
||||
5. Add a matching entry to `docs/src/app/changelog/page.mdx` at the top (below the `# Changelog` heading)
|
||||
6. Open a PR and merge to `main`
|
||||
|
||||
When the PR merges, CI compares `package.json` version to what's on npm. If it differs, it builds all 7 platform binaries, publishes to npm, and creates the GitHub release automatically. The GitHub release body is extracted from the content between the `<!-- release:start -->` and `<!-- release:end -->` markers in `CHANGELOG.md`.
|
||||
|
||||
### Writing the changelog
|
||||
|
||||
Review the git log since the last release and write the entry in `CHANGELOG.md`. Follow the existing format and voice. Group changes under `### New Features`, `### Bug Fixes`, `### Improvements`, etc. Bold the feature/fix name, then describe it concisely. Reference PR numbers in parentheses.
|
||||
|
||||
Wrap the release notes (everything between the `## <version>` heading and the previous version) in markers so CI can extract them for the GitHub release:
|
||||
|
||||
```markdown
|
||||
## 0.24.0
|
||||
|
||||
<!-- release:start -->
|
||||
### New Features
|
||||
|
||||
- **Foo command** - Added `foo` command for bar (#1234)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed **baz** not working when qux is enabled (#1235)
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
- @somecontributor
|
||||
<!-- release:end -->
|
||||
|
||||
## 0.23.3
|
||||
```
|
||||
|
||||
Include a `### Contributors` section listing the GitHub usernames (with `@` prefix) of everyone who contributed to the release. Check the git log between the previous tag and HEAD to find them.
|
||||
|
||||
Do not prefix entries with commit hashes. Do not use the changesets `### Patch Changes` / `### Minor Changes` headings. Use descriptive section names instead.
|
||||
|
||||
### Docs changelog
|
||||
|
||||
The docs changelog at `docs/src/app/changelog/page.mdx` mirrors `CHANGELOG.md` but uses a slightly different format. Each entry uses:
|
||||
|
||||
- A `v` prefix on the version (e.g. `## v0.24.0`)
|
||||
- A date line with the full date: `<p className="text-[#888] text-sm">March 30, 2026</p>`
|
||||
- A `---` separator between entries
|
||||
|
||||
Match the existing style in that file.
|
||||
|
||||
## Architecture
|
||||
|
||||
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
|
||||
|
||||
@@ -77,6 +123,68 @@ cd cli && cargo fmt -- --check # Check formatting
|
||||
cd cli && cargo clippy # Lint
|
||||
```
|
||||
|
||||
## Windows Debugging
|
||||
|
||||
A remote Windows Server 2022 EC2 instance is available for debugging Windows-specific issues. It uses AWS Systems Manager (SSM) with no SSH or open ports. Commands run via `aws ssm send-command` and return stdout/stderr.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
The instance must be provisioned first (one-time, by a human):
|
||||
|
||||
```bash
|
||||
./scripts/windows-debug/provision.sh
|
||||
```
|
||||
|
||||
Requires: AWS CLI v2 configured with `ec2:*`, `iam:CreateRole`, `iam:AttachRolePolicy`, `ssm:SendCommand`, `ssm:GetCommandInvocation` permissions and a default VPC.
|
||||
|
||||
### Usage
|
||||
|
||||
Start the instance (if stopped):
|
||||
|
||||
```bash
|
||||
./scripts/windows-debug/start.sh
|
||||
```
|
||||
|
||||
Run a command on Windows:
|
||||
|
||||
```bash
|
||||
./scripts/windows-debug/run.sh "<powershell-command>"
|
||||
```
|
||||
|
||||
Sync the current git branch and rebuild:
|
||||
|
||||
```bash
|
||||
./scripts/windows-debug/sync.sh
|
||||
```
|
||||
|
||||
Stop the instance when done (avoids cost):
|
||||
|
||||
```bash
|
||||
./scripts/windows-debug/stop.sh
|
||||
```
|
||||
|
||||
### Common Workflows
|
||||
|
||||
Run unit tests on Windows:
|
||||
|
||||
```bash
|
||||
./scripts/windows-debug/run.sh "cd C:\agent-browser && cargo test --manifest-path cli\Cargo.toml"
|
||||
```
|
||||
|
||||
Run e2e tests on Windows:
|
||||
|
||||
```bash
|
||||
./scripts/windows-debug/run.sh "cd C:\agent-browser && cargo test e2e --manifest-path cli\Cargo.toml -- --ignored --test-threads=1"
|
||||
```
|
||||
|
||||
Check bootstrap progress (first boot only):
|
||||
|
||||
```bash
|
||||
./scripts/windows-debug/run.sh "Get-Content C:\bootstrap.log"
|
||||
```
|
||||
|
||||
The repo lives at `C:\agent-browser` on the instance. Rust, Git, and Chrome are pre-installed. The `run.sh` wrapper automatically adds cargo and git to PATH.
|
||||
|
||||
<!-- opensrc:start -->
|
||||
|
||||
## Source Code Reference
|
||||
|
||||
-253
@@ -1,253 +0,0 @@
|
||||
# agent-browser
|
||||
|
||||
## 0.16.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c4180c8: Improved Chrome launch reliability by automatically detecting containerized environments (Docker, Podman, Kubernetes) and enabling --no-sandbox when needed. Added support for discovering Playwright-installed Chromium browsers and enhanced error messages with helpful diagnostics when Chrome fails to launch.
|
||||
|
||||
## 0.16.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 05018b3: Added experimental native Rust daemon (`--native` flag, `AGENT_BROWSER_NATIVE=1` env, or `"native": true` in config). The native daemon communicates with Chrome directly via CDP, eliminating Node.js and Playwright dependencies. Supports 150+ commands with full parity to the default Node.js daemon. Includes WebDriver backend for Safari/iOS, CDP protocol codegen, request tracking, frame context management, and comprehensive e2e and parity tests.
|
||||
|
||||
## 0.15.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 62241b5: Fixed Windows compatibility issues including proper handling of extended-length path prefixes from canonicalize(), prevention of MSYS/Git Bash path translation that could mangle arguments, and improved daemon startup reliability. Also added ARM64 Windows support in postinstall shims and expanded CI testing with a full daemon lifecycle test on Windows.
|
||||
|
||||
## 0.15.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 6aea316: Documentation site improvements and internal tooling updates including enhanced code blocks, mobile navigation, and docs chat components. CLI connection and output handling refinements. Skill creator reference documentation and scripts have been reorganized.
|
||||
|
||||
## 0.15.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 7bd8ce9: Added support for chrome:// and chrome-extension:// URLs in navigation and recording commands. These special browser URLs are now preserved as-is instead of having https:// incorrectly prepended.
|
||||
|
||||
## 0.15.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 2e38882: - Added security hardening: authentication vault, content boundary markers, domain allowlist, action policy, action confirmation, and output length limits.
|
||||
- Added `--download-path` flag (and `AGENT_BROWSER_DOWNLOAD_PATH` env / `downloadPath` config key) to set a default download directory.
|
||||
- Added `--selector` flag to `scroll` command for scrolling within specific container elements.
|
||||
|
||||
## 0.14.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- b7665e5: - Added `keyboard` command for raw keyboard input -- type with real keystrokes, insert text, and press shortcuts at the currently focused element without needing a selector.
|
||||
- Added `--color-scheme` flag and `AGENT_BROWSER_COLOR_SCHEME` env var for persistent dark/light mode preference across browser sessions.
|
||||
- Fixed IPC EAGAIN errors (os error 35/11) by adding backpressure-aware socket writes, command serialization, and lowering the default Playwright timeout to 25s (configurable via `AGENT_BROWSER_DEFAULT_TIMEOUT`).
|
||||
- Fixed remote debugging (CDP) reconnection.
|
||||
- Fixed state load failing when no browser is running.
|
||||
- Fixed `--annotate` flag warning appearing when not explicitly passed via CLI.
|
||||
|
||||
## 0.13.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ebd8717: Added new diff commands for comparing snapshots, screenshots, and URLs between page states. You can now run visual pixel diffs against baseline images, compare accessibility tree snapshots with customizable depth and selectors, and diff two URLs side-by-side with optional screenshot comparison.
|
||||
|
||||
## 0.12.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 69ffad0: Add annotated screenshots with the new --annotate flag, which overlays numbered labels on interactive elements and prints a legend mapping each label to its element ref. This enables multimodal AI models to reason about visual layout while using the same @eN refs for subsequent interactions. The flag can also be set via the AGENT_BROWSER_ANNOTATE environment variable.
|
||||
|
||||
## 0.11.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c6fc7df: Added documentation for command chaining with && across README, CLI help output, docs, and skill files, explaining how to efficiently chain multiple agent-browser commands in a single shell invocation since the browser persists via a background daemon.
|
||||
|
||||
## 0.11.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 5dc40b4: Added configuration file support with automatic loading from user and project directories, new profiler commands for Chrome DevTools profiling, computed styles getter, browser extension loading, storage state management, and iOS device emulation. Expanded click command with new-tab option, improved find command with additional actions and filtering options, and enhanced CDP connection to accept WebSocket URLs. Documentation has been significantly expanded with new sections for configuration, profiling, and proxy support.
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 1112a16: Added session persistence with automatic save/restore of cookies and localStorage across browser restarts using --session-name flag, with optional AES-256-GCM encryption for saved state data. New state management commands allow listing, showing, renaming, clearing, and cleaning up old session files. Also added --new-tab option for click commands to open links in new tabs.
|
||||
|
||||
## 0.9.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 323b6cd: Fix all Clippy lint warnings in the Rust CLI: remove redundant import, use `.first()` instead of `.get(0)`, use `.copied()` instead of `.map(|s| *s)`, use `.contains()` instead of `.iter().any()`, use `then_some` instead of lazy `then`, and simplify redundant match guards.
|
||||
|
||||
## 0.9.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d03e238: Added support for custom executable path in CLI browser launch options. Documentation site received UI improvements including a new chat component with sheet-based interface and updated dependencies.
|
||||
|
||||
## 0.9.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 76d23db: Documentation site migrated to MDX for improved content authoring, added AI-powered docs chat feature, and updated README with Homebrew installation instructions for macOS users.
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ae34945: Added --allow-file-access flag to enable opening and interacting with local file:// URLs (PDFs, HTML files) by passing Chromium flags that allow JavaScript access to local files. Added -C/--cursor flag for snapshots to include cursor-interactive elements like divs with onclick handlers or cursor:pointer styles, which is useful for modern web apps using custom clickable elements.
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 9d021bd: Add iOS Simulator and real device support for mobile Safari testing via Appium. New CLI commands include `device list` to show available simulators, `tap` and `swipe` for touch interactions, and the `--device` flag to specify which iOS device to use. Configure with `-p ios` provider flag or `AGENT_BROWSER_PROVIDER=ios` environment variable.
|
||||
|
||||
## 0.8.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 17dba8f: Add --stdin flag for eval command to read JavaScript from stdin, enabling heredoc usage for multiline scripts
|
||||
- daeede4: Add --stdin flag for the eval command to read JavaScript from stdin, enabling heredoc usage for multiline scripts. Also fix binary permission issues on macOS/Linux when postinstall scripts don't run (e.g., with bun).
|
||||
|
||||
## 0.8.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 0dc36f2: Add --stdin flag for eval command to read JavaScript from stdin, enabling heredoc usage for multiline scripts
|
||||
|
||||
## 0.8.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 2771588: Added base64 encoding support for the eval command with -b/--base64 flag to avoid shell escaping issues when executing JavaScript. Updated documentation with AI agent setup instructions and reorganized the docs structure by consolidating agent mode content into the installation page.
|
||||
|
||||
## 0.8.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d24f753: Fixed browser launch options not being passed correctly when using persistent profiles, ensuring args, userAgent, proxy, and ignoreHTTPSErrors settings now work properly. Added pre-flight checks for socket path length limits and directory write permissions to provide clearer error messages when daemon startup fails. Improved error handling to properly exit with failure status when browser launch fails.
|
||||
|
||||
## 0.8.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d75350a: Improved daemon connection reliability by adding automatic retry logic for transient errors like connection resets, broken pipes, and temporary resource unavailability. The CLI now cleans up stale socket and PID files before starting a new daemon, and includes better detection of daemon responsiveness to handle race conditions during shutdown.
|
||||
|
||||
## 0.8.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cb2f8c3: Fixed version synchronization to automatically update Cargo.lock alongside Cargo.toml during releases, and made the CLI binary executable. This ensures the Rust CLI version stays in sync with the npm package version.
|
||||
|
||||
## 0.8.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 759302e: Fixed "Daemon not found" error when running through AI agents (e.g., Claude Code) by resolving symlinks in the executable path. Previously, npm global bin symlinks weren't being resolved correctly, causing intermittent daemon discovery failures.
|
||||
|
||||
## 0.8.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 4116a8a: Replaced shell-based CLI wrappers with a cross-platform Node.js wrapper to enable npx support on Windows. Added postinstall logic to patch npm's bin entry on global installs, allowing the native binary to be invoked directly with zero overhead. Added CI tests to verify global installation works correctly across all platforms.
|
||||
|
||||
## 0.8.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 7e6336f: Fixed the Windows CMD wrapper to use the native binary directly instead of routing through Node.js, improving startup performance and reliability. Added retry logic to the CI install command to handle transient failures during browser installation.
|
||||
|
||||
## 0.8.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8eec634: Improved release workflow to validate binary file sizes and ensure binaries are executable after npm install. Updated documentation site with a new mobile navigation system and added v0.8.0 changelog entries. Reformatted CHANGELOG.md for better readability.
|
||||
|
||||
## v0.8.0
|
||||
|
||||
### New Features
|
||||
|
||||
- **Kernel cloud browser provider** - Connect to Kernel (https://kernel.sh) for remote browser infrastructure via `-p kernel` flag or `AGENT_BROWSER_PROVIDER=kernel`. Supports stealth mode, persistent profiles, and automatic profile find-or-create.
|
||||
- **Ignore HTTPS certificate errors** - New `--ignore-https-errors` flag for working with self-signed certificates and development environments
|
||||
- **Enhanced cookie management** - Extended `cookies set` command with `--url`, `--domain`, `--path`, `--httpOnly`, `--secure`, `--sameSite`, and `--expires` flags for setting cookies before page load
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed tab list command not recognizing new pages opened via clicks or `target="_blank"` links (#275)
|
||||
- Fixed `check` command hanging indefinitely (#272)
|
||||
- Fixed `set device` not applying deviceScaleFactor - HiDPI screenshots now work correctly (#270)
|
||||
- Fixed state load and profile persistence not working in v0.7.6 (#268)
|
||||
- Screenshots now save to temp directory when no path is provided (#247)
|
||||
|
||||
### Security
|
||||
|
||||
- Daemon and stream server now reject cross-origin connections (#274)
|
||||
|
||||
## 0.7.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- a4d0c26: Allow null values for the screenshot selector field. Previously, passing a null selector would fail validation, but now it is properly handled as an optional value.
|
||||
|
||||
## 0.7.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8c2a6ec: Fix GitHub release workflow to handle existing releases. If a release already exists, binaries are uploaded to it instead of failing.
|
||||
|
||||
## 0.7.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 957b5e5: Fix binary permissions on install. npm doesn't preserve execute bits, so postinstall now ensures the native binary is executable.
|
||||
|
||||
## 0.7.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 161d8f5: Fix native binary distribution in npm package. Native binaries for all platforms (Linux x64/arm64, macOS x64/arm64, Windows x64) are now correctly included when publishing.
|
||||
|
||||
## 0.7.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 6afede2: Fix native binary distribution in npm package
|
||||
|
||||
Native binaries for all platforms (Linux x64/arm64, macOS x64/arm64, Windows x64) are now included in the npm package. Previously, the release workflow published to npm before building binaries, causing "No binary found" errors on installation.
|
||||
|
||||
## 0.7.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix native binary distribution in npm package. Native binaries for all platforms (Linux x64/arm64, macOS x64/arm64, Windows x64) are now included in the npm package. Previously, the release workflow published to npm before building binaries, causing "No binary found" errors on installation.
|
||||
|
||||
## 0.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 316e649: ## New Features
|
||||
- **Cloud browser providers** - Connect to Browserbase or Browser Use for remote browser infrastructure via `-p` flag or `AGENT_BROWSER_PROVIDER` env var
|
||||
- **Persistent browser profiles** - Store cookies, localStorage, and login sessions across browser restarts with `--profile`
|
||||
- **Remote CDP WebSocket URLs** - Connect to remote browser services via WebSocket URL (e.g., `--cdp "wss://..."`)
|
||||
- **Download commands** - New `download` command and `wait --download` for file downloads with ref support
|
||||
- **Browser launch configuration** - New `--args`, `--user-agent`, and `--proxy-bypass` flags for fine-grained browser control
|
||||
- **Enhanced skills** - Hierarchical structure with references and templates for Claude Code
|
||||
|
||||
## Bug Fixes
|
||||
- Screenshot command now supports refs and has improved error messages
|
||||
- WebSocket URLs work in `connect` command
|
||||
- Fixed socket file location (uses `~/.agent-browser` instead of TMPDIR)
|
||||
- Windows binary path fix (.exe extension)
|
||||
- State load and path-based actions now show correct output messages
|
||||
|
||||
## Documentation
|
||||
- Added Claude Code marketplace plugin installation instructions
|
||||
- Updated skill documentation with references and templates
|
||||
- Improved error documentation
|
||||
@@ -1,51 +1,41 @@
|
||||
# agent-browser
|
||||
|
||||
Headless browser automation CLI for AI agents. Fast Rust CLI with Node.js fallback.
|
||||
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)
|
||||
```
|
||||
|
||||
### Cargo (Rust)
|
||||
|
||||
```bash
|
||||
cargo install agent-browser
|
||||
agent-browser install # Download Chrome from Chrome for Testing (first time only)
|
||||
```
|
||||
|
||||
### From Source
|
||||
@@ -66,9 +56,23 @@ On Linux, install system dependencies:
|
||||
|
||||
```bash
|
||||
agent-browser install --with-deps
|
||||
# or manually: npx playwright install-deps chromium
|
||||
```
|
||||
|
||||
### Updating
|
||||
|
||||
Upgrade to the latest version:
|
||||
|
||||
```bash
|
||||
agent-browser upgrade
|
||||
```
|
||||
|
||||
Detects your installation method (npm, Homebrew, or Cargo) and runs the appropriate update command automatically.
|
||||
|
||||
### 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). Existing Chrome, Brave, Playwright, and Puppeteer installations are detected automatically. No Playwright or Node.js required for the daemon.
|
||||
- **Rust** - Only needed when building from source (see From Source above).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
@@ -115,11 +119,17 @@ agent-browser drag <src> <tgt> # Drag and drop
|
||||
agent-browser upload <sel> <files> # Upload files
|
||||
agent-browser screenshot [path] # Take screenshot (--full for full page, saves to a temporary directory if no path)
|
||||
agent-browser screenshot --annotate # Annotated screenshot with numbered element labels
|
||||
agent-browser screenshot --screenshot-dir ./shots # Save to custom directory
|
||||
agent-browser screenshot --screenshot-format jpeg --screenshot-quality 80
|
||||
agent-browser pdf <path> # Save as PDF
|
||||
agent-browser snapshot # Accessibility tree with refs (best for AI)
|
||||
agent-browser eval <js> # Run JavaScript (-b for base64, --stdin for piped input)
|
||||
agent-browser connect <port> # Connect to browser via CDP
|
||||
agent-browser stream enable [--port <port>] # Start runtime WebSocket streaming
|
||||
agent-browser stream status # Show runtime streaming state and bound port
|
||||
agent-browser stream disable # Stop runtime WebSocket streaming
|
||||
agent-browser close # Close browser (aliases: quit, exit)
|
||||
agent-browser close --all # Close all active sessions
|
||||
```
|
||||
|
||||
### Get Info
|
||||
@@ -131,6 +141,7 @@ agent-browser get value <sel> # Get input value
|
||||
agent-browser get attr <sel> <attr> # Get attribute
|
||||
agent-browser get title # Get page title
|
||||
agent-browser get url # Get current URL
|
||||
agent-browser get cdp-url # Get CDP WebSocket URL (for DevTools, debugging)
|
||||
agent-browser get count <sel> # Count matching elements
|
||||
agent-browser get box <sel> # Get bounding box
|
||||
agent-browser get styles <sel> # Get computed styles
|
||||
@@ -164,6 +175,7 @@ agent-browser find nth <n> <sel> <action> [value] # Nth match
|
||||
**Options:** `--name <name>` (filter role by accessible name), `--exact` (require exact text match)
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
agent-browser find role button click --name "Submit"
|
||||
agent-browser find text "Sign In" click
|
||||
@@ -177,14 +189,46 @@ agent-browser find nth 2 "a" text
|
||||
```bash
|
||||
agent-browser wait <selector> # Wait for element to be visible
|
||||
agent-browser wait <ms> # Wait for time (milliseconds)
|
||||
agent-browser wait --text "Welcome" # Wait for text to appear
|
||||
agent-browser wait --text "Welcome" # Wait for text to appear (substring match)
|
||||
agent-browser wait --url "**/dash" # Wait for URL pattern
|
||||
agent-browser wait --load networkidle # Wait for load state
|
||||
agent-browser wait --fn "window.ready === true" # Wait for JS condition
|
||||
|
||||
# Wait for text/element to disappear
|
||||
agent-browser wait --fn "!document.body.innerText.includes('Loading...')"
|
||||
agent-browser wait "#spinner" --state hidden
|
||||
```
|
||||
|
||||
**Load states:** `load`, `domcontentloaded`, `networkidle`
|
||||
|
||||
### Batch Execution
|
||||
|
||||
Execute multiple commands in a single invocation by piping a JSON array of
|
||||
string arrays to `batch`. This avoids per-command process startup overhead
|
||||
when running multi-step workflows.
|
||||
|
||||
```bash
|
||||
# Pipe commands as JSON
|
||||
echo '[
|
||||
["open", "https://example.com"],
|
||||
["snapshot", "-i"],
|
||||
["click", "@e1"],
|
||||
["screenshot", "result.png"]
|
||||
]' | agent-browser batch --json
|
||||
|
||||
# Stop on first error
|
||||
agent-browser batch --bail < commands.json
|
||||
```
|
||||
|
||||
### Clipboard
|
||||
|
||||
```bash
|
||||
agent-browser clipboard read # Read text from clipboard
|
||||
agent-browser clipboard write "Hello, World!" # Write text to clipboard
|
||||
agent-browser clipboard copy # Copy current selection (Ctrl+C)
|
||||
agent-browser clipboard paste # Paste from clipboard (Ctrl+V)
|
||||
```
|
||||
|
||||
### Mouse Control
|
||||
|
||||
```bash
|
||||
@@ -197,7 +241,7 @@ agent-browser mouse wheel <dy> [dx] # Scroll wheel
|
||||
### Browser Settings
|
||||
|
||||
```bash
|
||||
agent-browser set viewport <w> <h> # Set viewport size
|
||||
agent-browser set viewport <w> <h> [scale] # Set viewport size (scale for retina, e.g. 2)
|
||||
agent-browser set device <name> # Emulate device ("iPhone 14")
|
||||
agent-browser set geo <lat> <lng> # Set geolocation
|
||||
agent-browser set offline [on|off] # Toggle offline mode
|
||||
@@ -230,6 +274,12 @@ agent-browser network route <url> --body <json> # Mock response
|
||||
agent-browser network unroute [url] # Remove routes
|
||||
agent-browser network requests # View tracked requests
|
||||
agent-browser network requests --filter api # Filter requests
|
||||
agent-browser network requests --type xhr,fetch # Filter by resource type
|
||||
agent-browser network requests --method POST # Filter by HTTP method
|
||||
agent-browser network requests --status 2xx # Filter by status (200, 2xx, 400-499)
|
||||
agent-browser network request <requestId> # View full request/response detail
|
||||
agent-browser network har start # Start HAR recording
|
||||
agent-browser network har stop [output.har] # Stop and save HAR (temp path if omitted)
|
||||
```
|
||||
|
||||
### Tabs & Windows
|
||||
@@ -254,8 +304,13 @@ agent-browser frame main # Back to main frame
|
||||
```bash
|
||||
agent-browser dialog accept [text] # Accept (with optional prompt text)
|
||||
agent-browser dialog dismiss # Dismiss
|
||||
agent-browser dialog status # Check if a dialog is currently open
|
||||
```
|
||||
|
||||
By default, `alert` and `beforeunload` dialogs are automatically accepted so they never block the agent. `confirm` and `prompt` dialogs still require explicit handling. Use `--no-auto-dialog` (or `AGENT_BROWSER_NO_AUTO_DIALOG=1`) to disable automatic handling.
|
||||
|
||||
When a JavaScript dialog is pending, all command responses include a `warning` field with the dialog type and message.
|
||||
|
||||
### Diff
|
||||
|
||||
```bash
|
||||
@@ -279,10 +334,12 @@ agent-browser trace stop [path] # Stop and save trace
|
||||
agent-browser profiler start # Start Chrome DevTools profiling
|
||||
agent-browser profiler stop [path] # Stop and save profile (.json)
|
||||
agent-browser console # View console messages (log, error, warn, info)
|
||||
agent-browser console --json # JSON output with raw CDP args for programmatic access
|
||||
agent-browser console --clear # Clear console
|
||||
agent-browser errors # View page errors (uncaught JavaScript exceptions)
|
||||
agent-browser errors --clear # Clear errors
|
||||
agent-browser highlight <sel> # Highlight element
|
||||
agent-browser inspect # Open Chrome DevTools for the active page
|
||||
agent-browser state save <path> # Save auth state
|
||||
agent-browser state load <path> # Load auth state
|
||||
agent-browser state list # List saved state files
|
||||
@@ -304,10 +361,52 @@ 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)
|
||||
agent-browser upgrade # Upgrade agent-browser to the latest version
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
agent-browser provides multiple ways to persist login sessions so you don't re-authenticate every run.
|
||||
|
||||
### Quick summary
|
||||
|
||||
| Approach | Best for | Flag / Env |
|
||||
|----------|----------|------------|
|
||||
| **Persistent profile** | Full browser state (cookies, IndexedDB, service workers, cache) across restarts | `--profile <path>` / `AGENT_BROWSER_PROFILE` |
|
||||
| **Session persistence** | Auto-save/restore cookies + localStorage by name | `--session-name <name>` / `AGENT_BROWSER_SESSION_NAME` |
|
||||
| **Import from your browser** | Grab auth from a Chrome session you already logged into | `--auto-connect` + `state save` |
|
||||
| **State file** | Load a previously saved state JSON on launch | `--state <path>` / `AGENT_BROWSER_STATE` |
|
||||
| **Auth vault** | Store credentials locally (encrypted), login by name | `auth save` / `auth login` |
|
||||
|
||||
### Import auth from your browser
|
||||
|
||||
If you are already logged in to a site in Chrome, you can grab that auth state and reuse it:
|
||||
|
||||
```bash
|
||||
# 1. Launch Chrome with remote debugging enabled
|
||||
# macOS:
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --remote-debugging-port=9222
|
||||
# Or use --auto-connect to discover an already-running Chrome
|
||||
|
||||
# 2. Connect and save the authenticated state
|
||||
agent-browser --auto-connect state save ./my-auth.json
|
||||
|
||||
# 3. Use the saved auth in future sessions
|
||||
agent-browser --state ./my-auth.json open https://app.example.com/dashboard
|
||||
|
||||
# 4. Or use --session-name for automatic persistence
|
||||
agent-browser --session-name myapp state load ./my-auth.json
|
||||
# From now on, --session-name myapp auto-saves/restores this state
|
||||
```
|
||||
|
||||
> **Security notes:**
|
||||
> - `--remote-debugging-port` exposes full browser control on localhost. Any local process can connect. Only use on trusted machines and close Chrome when done.
|
||||
> - State files contain session tokens in plaintext. Add them to `.gitignore` and delete when no longer needed. For encryption at rest, set `AGENT_BROWSER_ENCRYPTION_KEY` (see [State Encryption](#state-encryption)).
|
||||
|
||||
For full details on login flows, OAuth, 2FA, cookie-based auth, and the auth vault, see the [Authentication](docs/src/app/sessions/page.mdx) docs.
|
||||
|
||||
## Sessions
|
||||
|
||||
Run multiple isolated browser instances:
|
||||
@@ -332,6 +431,7 @@ agent-browser session
|
||||
```
|
||||
|
||||
Each session has its own:
|
||||
|
||||
- Browser instance
|
||||
- Cookies and storage
|
||||
- Navigation history
|
||||
@@ -353,6 +453,7 @@ AGENT_BROWSER_PROFILE=~/.myapp-profile agent-browser open myapp.com
|
||||
```
|
||||
|
||||
The profile directory stores:
|
||||
|
||||
- Cookies and localStorage
|
||||
- IndexedDB data
|
||||
- Service workers
|
||||
@@ -389,33 +490,33 @@ export AGENT_BROWSER_ENCRYPTION_KEY=<64-char-hex-key>
|
||||
agent-browser --session-name secure open example.com
|
||||
```
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `AGENT_BROWSER_SESSION_NAME` | Auto-save/load state persistence name |
|
||||
| `AGENT_BROWSER_ENCRYPTION_KEY` | 64-char hex key for AES-256-GCM encryption |
|
||||
| Variable | Description |
|
||||
| --------------------------------- | -------------------------------------------------- |
|
||||
| `AGENT_BROWSER_SESSION_NAME` | Auto-save/load state persistence name |
|
||||
| `AGENT_BROWSER_ENCRYPTION_KEY` | 64-char hex key for AES-256-GCM encryption |
|
||||
| `AGENT_BROWSER_STATE_EXPIRE_DAYS` | Auto-delete states older than N days (default: 30) |
|
||||
|
||||
## Security
|
||||
|
||||
agent-browser includes security features for safe AI agent deployments. All features are opt-in -- existing workflows are unaffected until you explicitly enable a feature:
|
||||
|
||||
- **Authentication Vault** -- Store credentials locally (always encrypted), reference by name. The LLM never sees passwords. A key is auto-generated at `~/.agent-browser/.encryption-key` if `AGENT_BROWSER_ENCRYPTION_KEY` is not set: `echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin` then `agent-browser auth login github`
|
||||
- **Authentication Vault** -- Store credentials locally (always encrypted), reference by name. The LLM never sees passwords. `auth login` navigates with `load` and then waits for login form selectors to appear (SPA-friendly, timeout follows the default action timeout). A key is auto-generated at `~/.agent-browser/.encryption-key` if `AGENT_BROWSER_ENCRYPTION_KEY` is not set: `echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin` then `agent-browser auth login github`
|
||||
- **Content Boundary Markers** -- Wrap page output in delimiters so LLMs can distinguish tool output from untrusted content: `--content-boundaries`
|
||||
- **Domain Allowlist** -- Restrict navigation to trusted domains (wildcards like `*.example.com` also match the bare domain): `--allowed-domains "example.com,*.example.com"`. Sub-resource requests (scripts, images, fetch) and WebSocket/EventSource connections to non-allowed domains are also blocked. Include any CDN domains your target pages depend on (e.g., `*.cdn.example.com`).
|
||||
- **Action Policy** -- Gate destructive actions with a static policy file: `--action-policy ./policy.json`
|
||||
- **Action Confirmation** -- Require explicit approval for sensitive action categories: `--confirm-actions eval,download`
|
||||
- **Output Length Limits** -- Prevent context flooding: `--max-output 50000`
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `AGENT_BROWSER_CONTENT_BOUNDARIES` | Wrap page output in boundary markers |
|
||||
| `AGENT_BROWSER_MAX_OUTPUT` | Max characters for page output |
|
||||
| `AGENT_BROWSER_ALLOWED_DOMAINS` | Comma-separated allowed domain patterns |
|
||||
| `AGENT_BROWSER_ACTION_POLICY` | Path to action policy JSON file |
|
||||
| `AGENT_BROWSER_CONFIRM_ACTIONS` | Action categories requiring confirmation |
|
||||
| `AGENT_BROWSER_CONFIRM_INTERACTIVE` | Enable interactive confirmation prompts |
|
||||
| Variable | Description |
|
||||
| ----------------------------------- | ---------------------------------------- |
|
||||
| `AGENT_BROWSER_CONTENT_BOUNDARIES` | Wrap page output in boundary markers |
|
||||
| `AGENT_BROWSER_MAX_OUTPUT` | Max characters for page output |
|
||||
| `AGENT_BROWSER_ALLOWED_DOMAINS` | Comma-separated allowed domain patterns |
|
||||
| `AGENT_BROWSER_ACTION_POLICY` | Path to action policy JSON file |
|
||||
| `AGENT_BROWSER_CONFIRM_ACTIONS` | Action categories requiring confirmation |
|
||||
| `AGENT_BROWSER_CONFIRM_INTERACTIVE` | Enable interactive confirmation prompts |
|
||||
|
||||
See [Security documentation](https://agent-browser.vercel.app/security) for details.
|
||||
See [Security documentation](https://agent-browser.dev/security) for details.
|
||||
|
||||
## Snapshot Options
|
||||
|
||||
@@ -424,27 +525,25 @@ The `snapshot` command supports filtering to reduce output size:
|
||||
```bash
|
||||
agent-browser snapshot # Full accessibility tree
|
||||
agent-browser snapshot -i # Interactive elements only (buttons, inputs, links)
|
||||
agent-browser snapshot -i -C # Include cursor-interactive elements (divs with onclick, etc.)
|
||||
agent-browser snapshot -c # Compact (remove empty structural elements)
|
||||
agent-browser snapshot -d 3 # Limit depth to 3 levels
|
||||
agent-browser snapshot -s "#main" # Scope to CSS selector
|
||||
agent-browser snapshot -i -c -d 5 # Combine options
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-i, --interactive` | Only show interactive elements (buttons, links, inputs) |
|
||||
| `-C, --cursor` | Include cursor-interactive elements (cursor:pointer, onclick, tabindex) |
|
||||
| `-c, --compact` | Remove empty structural elements |
|
||||
| `-d, --depth <n>` | Limit tree depth |
|
||||
| `-s, --selector <sel>` | Scope to CSS selector |
|
||||
|
||||
The `-C` flag is useful for modern web apps that use custom clickable elements (divs, spans) instead of standard buttons/links.
|
||||
| Option | Description |
|
||||
| ---------------------- | ----------------------------------------------------------------------- |
|
||||
| `-i, --interactive` | Only show interactive elements (buttons, links, inputs) |
|
||||
| `-c, --compact` | Remove empty structural elements |
|
||||
| `-d, --depth <n>` | Limit tree depth |
|
||||
| `-s, --selector <sel>` | Scope to CSS selector |
|
||||
|
||||
## Annotated Screenshots
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
# -> Screenshot saved to /tmp/screenshot-2026-02-17T12-00-00-abc123.png
|
||||
@@ -482,9 +581,11 @@ This is useful for multimodal AI models that can reason about visual layout, unl
|
||||
| `-p, --provider <name>` | Cloud browser provider (or `AGENT_BROWSER_PROVIDER` env) |
|
||||
| `--device <name>` | iOS device name, e.g. "iPhone 15 Pro" (or `AGENT_BROWSER_IOS_DEVICE` env) |
|
||||
| `--json` | JSON output (for agents) |
|
||||
| `--full, -f` | Full page screenshot |
|
||||
| `--annotate` | Annotated screenshot with numbered element labels (or `AGENT_BROWSER_ANNOTATE` env) |
|
||||
| `--headed` | Show browser window (not headless) |
|
||||
| `--screenshot-dir <path>` | Default screenshot output directory (or `AGENT_BROWSER_SCREENSHOT_DIR` env) |
|
||||
| `--screenshot-quality <n>` | JPEG quality 0-100 (or `AGENT_BROWSER_SCREENSHOT_QUALITY` env) |
|
||||
| `--screenshot-format <fmt>` | Screenshot format: `png`, `jpeg` (or `AGENT_BROWSER_SCREENSHOT_FORMAT` env) |
|
||||
| `--headed` | Show browser window (not headless) (or `AGENT_BROWSER_HEADED` env) |
|
||||
| `--cdp <port\|url>` | Connect via Chrome DevTools Protocol (port or WebSocket URL) |
|
||||
| `--auto-connect` | Auto-discover and connect to running Chrome (or `AGENT_BROWSER_AUTO_CONNECT` env) |
|
||||
| `--color-scheme <scheme>` | Color scheme: `dark`, `light`, `no-preference` (or `AGENT_BROWSER_COLOR_SCHEME` env) |
|
||||
@@ -495,10 +596,38 @@ 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) |
|
||||
| `--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) |
|
||||
| `--no-auto-dialog` | Disable automatic dismissal of `alert`/`beforeunload` dialogs (or `AGENT_BROWSER_NO_AUTO_DIALOG` env) |
|
||||
| `--config <path>` | Use a custom config file (or `AGENT_BROWSER_CONFIG` env) |
|
||||
| `--debug` | Debug output |
|
||||
|
||||
## Observability Dashboard
|
||||
|
||||
Monitor agent-browser sessions in real time with a local web dashboard showing a live viewport and command activity feed.
|
||||
|
||||
```bash
|
||||
# Install the dashboard (one time)
|
||||
agent-browser dashboard install
|
||||
|
||||
# Start the dashboard server (runs in background on port 4848)
|
||||
agent-browser dashboard start
|
||||
agent-browser dashboard start --port 8080 # Custom port
|
||||
|
||||
# All sessions are automatically visible in the dashboard
|
||||
agent-browser open example.com
|
||||
|
||||
# Stop the dashboard
|
||||
agent-browser dashboard stop
|
||||
```
|
||||
|
||||
The dashboard runs as a standalone background process on port 4848, independent of browser sessions. It stays available even when no sessions are running. All sessions automatically stream to the dashboard.
|
||||
|
||||
The dashboard displays:
|
||||
- **Live viewport** -- real-time JPEG frames from the browser
|
||||
- **Activity feed** -- chronological command/result stream with timing and expandable details
|
||||
- **Console output** -- browser console messages (log, warn, error)
|
||||
- **Session creation** -- create new sessions from the UI with local engines (Chrome, Lightpanda) or cloud providers (AgentCore, Browserbase, Browserless, Browser Use, Kernel)
|
||||
|
||||
## Configuration
|
||||
|
||||
Create an `agent-browser.json` file to set persistent defaults instead of repeating flags on every command.
|
||||
@@ -539,7 +668,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:
|
||||
|
||||
@@ -548,11 +677,11 @@ Override the default timeout via environment variable:
|
||||
export AGENT_BROWSER_DEFAULT_TIMEOUT=45000
|
||||
```
|
||||
|
||||
> **Note:** Setting this above 30000 (30s) may cause EAGAIN errors on slow operations because the CLI's read timeout will expire before Playwright responds. The CLI retries transient errors automatically, but response times will increase.
|
||||
> **Note:** Setting this above 30000 (30s) may cause EAGAIN errors on slow operations because the CLI's read timeout will expire before the daemon responds. The CLI retries transient errors automatically, but response times will increase.
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `AGENT_BROWSER_DEFAULT_TIMEOUT` | Default Playwright timeout in ms (default: 25000) |
|
||||
| Variable | Description |
|
||||
| ------------------------------- | ---------------------------------------- |
|
||||
| `AGENT_BROWSER_DEFAULT_TIMEOUT` | Default operation timeout in ms (default: 25000) |
|
||||
|
||||
## Selectors
|
||||
|
||||
@@ -577,6 +706,7 @@ agent-browser hover @e4 # Hover the link
|
||||
```
|
||||
|
||||
**Why use refs?**
|
||||
|
||||
- **Deterministic**: Ref points to exact element from snapshot
|
||||
- **Fast**: No DOM re-query needed
|
||||
- **AI-friendly**: Snapshot + ref workflow is optimal for LLMs
|
||||
@@ -658,6 +788,8 @@ agent-browser open example.com --headed
|
||||
|
||||
This opens a visible browser window instead of running headless.
|
||||
|
||||
> **Note:** Browser extensions work in both headed and headless mode (Chrome's `--headless=new`).
|
||||
|
||||
## Authenticated Sessions
|
||||
|
||||
Use `--headers` to set HTTP headers for a specific origin, enabling authentication without login flows:
|
||||
@@ -675,6 +807,7 @@ agent-browser open other-site.com
|
||||
```
|
||||
|
||||
This is useful for:
|
||||
|
||||
- **Skipping login flows** - Authenticate via headers instead of UI
|
||||
- **Switching users** - Start new sessions with different auth tokens
|
||||
- **API testing** - Access protected endpoints directly
|
||||
@@ -696,6 +829,7 @@ agent-browser set headers '{"X-Custom-Header": "value"}'
|
||||
## Custom Browser Executable
|
||||
|
||||
Use a custom browser executable instead of the bundled Chromium. This is useful for:
|
||||
|
||||
- **Serverless deployment**: Use lightweight Chromium builds like `@sparticuz/chromium` (~50MB vs ~684MB)
|
||||
- **System browsers**: Use an existing Chrome/Chromium installation
|
||||
- **Custom builds**: Use modified browser builds
|
||||
@@ -710,19 +844,34 @@ agent-browser --executable-path /path/to/chromium open example.com
|
||||
AGENT_BROWSER_EXECUTABLE_PATH=/path/to/chromium agent-browser open example.com
|
||||
```
|
||||
|
||||
### Serverless Example (Vercel/AWS Lambda)
|
||||
### Serverless (Vercel)
|
||||
|
||||
Run agent-browser + Chrome in an ephemeral Vercel Sandbox microVM. No external server needed:
|
||||
|
||||
```typescript
|
||||
import { Sandbox } from "@vercel/sandbox";
|
||||
|
||||
const sandbox = await Sandbox.create({ runtime: "node24" });
|
||||
await sandbox.runCommand("agent-browser", ["open", "https://example.com"]);
|
||||
const result = await sandbox.runCommand("agent-browser", ["screenshot", "--json"]);
|
||||
await sandbox.stop();
|
||||
```
|
||||
|
||||
See the [environments example](examples/environments/) for a working demo with a UI and deploy-to-Vercel button.
|
||||
|
||||
### Serverless (AWS Lambda)
|
||||
|
||||
```typescript
|
||||
import chromium from '@sparticuz/chromium';
|
||||
import { BrowserManager } from 'agent-browser';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
export async function handler() {
|
||||
const browser = new BrowserManager();
|
||||
await browser.launch({
|
||||
executablePath: await chromium.executablePath(),
|
||||
headless: true,
|
||||
});
|
||||
// ... use browser
|
||||
const executablePath = await chromium.executablePath();
|
||||
const result = execSync(
|
||||
`AGENT_BROWSER_EXECUTABLE_PATH=${executablePath} agent-browser open https://example.com && agent-browser snapshot -i --json`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
return JSON.parse(result);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -741,6 +890,7 @@ agent-browser screenshot report.png
|
||||
```
|
||||
|
||||
The `--allow-file-access` flag adds Chromium flags (`--allow-file-access-from-files`, `--allow-file-access`) that allow `file://` URLs to:
|
||||
|
||||
- Load and render local files
|
||||
- Access other local files via JavaScript (XHR, fetch)
|
||||
- Load local resources (images, scripts, stylesheets)
|
||||
@@ -768,10 +918,12 @@ agent-browser --cdp "wss://your-browser-service.com/cdp?token=..." snapshot
|
||||
```
|
||||
|
||||
The `--cdp` flag accepts either:
|
||||
|
||||
- A port number (e.g., `9222`) for local connections via `http://localhost:{port}`
|
||||
- A full WebSocket URL (e.g., `wss://...` or `ws://...`) for remote browser services
|
||||
|
||||
This enables control of:
|
||||
|
||||
- Electron apps
|
||||
- Chrome/Chromium instances with remote debugging
|
||||
- WebView2 applications
|
||||
@@ -791,10 +943,13 @@ AGENT_BROWSER_AUTO_CONNECT=1 agent-browser snapshot
|
||||
```
|
||||
|
||||
Auto-connect discovers Chrome by:
|
||||
|
||||
1. Reading Chrome's `DevToolsActivePort` file from the default user data directory
|
||||
2. Falling back to probing common debugging ports (9222, 9229)
|
||||
3. If HTTP-based discovery (`/json/version`, `/json/list`) fails, falling back to a direct WebSocket connection
|
||||
|
||||
This is useful when:
|
||||
|
||||
- Chrome 144+ has remote debugging enabled via `chrome://inspect/#remote-debugging` (which uses a dynamic port)
|
||||
- You want a zero-configuration connection to your existing browser
|
||||
- You don't want to track which port Chrome is using
|
||||
@@ -803,21 +958,35 @@ This is useful when:
|
||||
|
||||
Stream the browser viewport via WebSocket for live preview or "pair browsing" where a human can watch and interact alongside an AI agent.
|
||||
|
||||
### Enable Streaming
|
||||
### Streaming
|
||||
|
||||
Set the `AGENT_BROWSER_STREAM_PORT` environment variable:
|
||||
Every session automatically starts a WebSocket stream server on an OS-assigned port. Use `stream status` to see the bound port and connection state:
|
||||
|
||||
```bash
|
||||
agent-browser stream status
|
||||
```
|
||||
|
||||
To bind to a specific port, set `AGENT_BROWSER_STREAM_PORT`:
|
||||
|
||||
```bash
|
||||
AGENT_BROWSER_STREAM_PORT=9223 agent-browser open example.com
|
||||
```
|
||||
|
||||
This starts a WebSocket server on the specified port that streams the browser viewport and accepts input events.
|
||||
You can also manage streaming at runtime with `stream enable`, `stream disable`, and `stream status`:
|
||||
|
||||
```bash
|
||||
agent-browser stream enable --port 9223 # Re-enable on a specific port
|
||||
agent-browser stream disable # Stop streaming for the session
|
||||
```
|
||||
|
||||
The WebSocket server streams the browser viewport and accepts input events.
|
||||
|
||||
### WebSocket Protocol
|
||||
|
||||
Connect to `ws://localhost:9223` to receive frames and send input:
|
||||
|
||||
**Receive frames:**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "frame",
|
||||
@@ -834,6 +1003,7 @@ Connect to `ws://localhost:9223` to receive frames and send input:
|
||||
```
|
||||
|
||||
**Send mouse events:**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "input_mouse",
|
||||
@@ -846,6 +1016,7 @@ Connect to `ws://localhost:9223` to receive frames and send input:
|
||||
```
|
||||
|
||||
**Send keyboard events:**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "input_keyboard",
|
||||
@@ -856,6 +1027,7 @@ Connect to `ws://localhost:9223` to receive frames and send input:
|
||||
```
|
||||
|
||||
**Send touch events:**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "input_touch",
|
||||
@@ -864,107 +1036,26 @@ Connect to `ws://localhost:9223` to receive frames and send input:
|
||||
}
|
||||
```
|
||||
|
||||
### Programmatic API
|
||||
|
||||
For advanced use, control streaming directly via the protocol:
|
||||
|
||||
```typescript
|
||||
import { BrowserManager } from 'agent-browser';
|
||||
|
||||
const browser = new BrowserManager();
|
||||
await browser.launch({ headless: true });
|
||||
await browser.navigate('https://example.com');
|
||||
|
||||
// Start screencast
|
||||
await browser.startScreencast((frame) => {
|
||||
// frame.data is base64-encoded image
|
||||
// frame.metadata contains viewport info
|
||||
console.log('Frame received:', frame.metadata.deviceWidth, 'x', frame.metadata.deviceHeight);
|
||||
}, {
|
||||
format: 'jpeg',
|
||||
quality: 80,
|
||||
maxWidth: 1280,
|
||||
maxHeight: 720,
|
||||
});
|
||||
|
||||
// Inject mouse events
|
||||
await browser.injectMouseEvent({
|
||||
type: 'mousePressed',
|
||||
x: 100,
|
||||
y: 200,
|
||||
button: 'left',
|
||||
});
|
||||
|
||||
// Inject keyboard events
|
||||
await browser.injectKeyboardEvent({
|
||||
type: 'keyDown',
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
});
|
||||
|
||||
// Stop when done
|
||||
await browser.stopScreencast();
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
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
|
||||
|
||||
@@ -1008,6 +1099,7 @@ For more consistent results, add to your project or global instructions file:
|
||||
Use `agent-browser` for web automation. Run `agent-browser --help` for all commands.
|
||||
|
||||
Core workflow:
|
||||
|
||||
1. `agent-browser open <url>` - Navigate to page
|
||||
2. `agent-browser snapshot -i` - Get interactive elements with refs (@e1, @e2)
|
||||
3. `agent-browser click @e1` / `fill @e2 "text"` - Interact using refs
|
||||
@@ -1059,11 +1151,11 @@ export AGENT_BROWSER_IOS_DEVICE="iPhone 16 Pro"
|
||||
agent-browser open https://example.com
|
||||
```
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `AGENT_BROWSER_PROVIDER` | Set to `ios` to enable iOS mode |
|
||||
| Variable | Description |
|
||||
| -------------------------- | ----------------------------------------------- |
|
||||
| `AGENT_BROWSER_PROVIDER` | Set to `ios` to enable iOS mode |
|
||||
| `AGENT_BROWSER_IOS_DEVICE` | Device name (e.g., "iPhone 16 Pro", "iPad Pro") |
|
||||
| `AGENT_BROWSER_IOS_UDID` | Device UDID (alternative to device name) |
|
||||
| `AGENT_BROWSER_IOS_UDID` | Device UDID (alternative to device name) |
|
||||
|
||||
**Supported devices:** All iOS Simulators available in Xcode (iPhones, iPads), plus real iOS devices.
|
||||
|
||||
@@ -1074,6 +1166,7 @@ agent-browser open https://example.com
|
||||
Appium also supports real iOS devices connected via USB. This requires additional one-time setup:
|
||||
|
||||
**1. Get your device UDID:**
|
||||
|
||||
```bash
|
||||
xcrun xctrace list devices
|
||||
# or
|
||||
@@ -1081,6 +1174,7 @@ system_profiler SPUSBDataType | grep -A 5 "iPhone\|iPad"
|
||||
```
|
||||
|
||||
**2. Sign WebDriverAgent (one-time):**
|
||||
|
||||
```bash
|
||||
# Open the WebDriverAgent Xcode project
|
||||
cd ~/.appium/node_modules/appium-xcuitest-driver/node_modules/appium-webdriveragent
|
||||
@@ -1088,12 +1182,14 @@ open WebDriverAgent.xcodeproj
|
||||
```
|
||||
|
||||
In Xcode:
|
||||
|
||||
- Select the `WebDriverAgentRunner` target
|
||||
- Go to Signing & Capabilities
|
||||
- Select your Team (requires Apple Developer account, free tier works)
|
||||
- Let Xcode manage signing automatically
|
||||
|
||||
**3. Use with agent-browser:**
|
||||
|
||||
```bash
|
||||
# Connect device via USB, then:
|
||||
agent-browser -p ios --device "<DEVICE_UDID>" open https://example.com
|
||||
@@ -1103,11 +1199,44 @@ agent-browser -p ios --device "John's iPhone" open https://example.com
|
||||
```
|
||||
|
||||
**Real device notes:**
|
||||
|
||||
- First run installs WebDriverAgent to the device (may require Trust prompt)
|
||||
- Device must be unlocked and connected via USB
|
||||
- Slightly slower initial connection than simulator
|
||||
- Tests against real Safari performance and behavior
|
||||
|
||||
### Browserless
|
||||
|
||||
[Browserless](https://browserless.io) provides cloud browser infrastructure with a Sessions API. Use it when running agent-browser in environments where a local browser isn't available.
|
||||
|
||||
To enable Browserless, use the `-p` flag:
|
||||
|
||||
```bash
|
||||
export BROWSERLESS_API_KEY="your-api-token"
|
||||
agent-browser -p browserless open https://example.com
|
||||
```
|
||||
|
||||
Or use environment variables for CI/scripts:
|
||||
|
||||
```bash
|
||||
export AGENT_BROWSER_PROVIDER=browserless
|
||||
export BROWSERLESS_API_KEY="your-api-token"
|
||||
agent-browser open https://example.com
|
||||
```
|
||||
|
||||
Optional configuration via environment variables:
|
||||
|
||||
| Variable | Description | Default |
|
||||
| -------------------------- | ------------------------------------------------ | --------------------------------------- |
|
||||
| `BROWSERLESS_API_URL` | Base API URL (for custom regions or self-hosted) | `https://production-sfo.browserless.io` |
|
||||
| `BROWSERLESS_BROWSER_TYPE` | Type of browser to use (chromium or chrome) | chromium |
|
||||
| `BROWSERLESS_TTL` | Session TTL in milliseconds | `300000` |
|
||||
| `BROWSERLESS_STEALTH` | Enable stealth mode (`true`/`false`) | `true` |
|
||||
|
||||
When enabled, agent-browser connects to a Browserless cloud session instead of launching a local browser. All commands work identically.
|
||||
|
||||
Get your API token from the [Browserless Dashboard](https://browserless.io).
|
||||
|
||||
### Browserbase
|
||||
|
||||
[Browserbase](https://browserbase.com) provides remote browser infrastructure to make deployment of agentic browsing agents easy. Use it when running the agent-browser CLI in an environment where a local browser isn't feasible.
|
||||
@@ -1116,7 +1245,6 @@ To enable Browserbase, use the `-p` flag:
|
||||
|
||||
```bash
|
||||
export BROWSERBASE_API_KEY="your-api-key"
|
||||
export BROWSERBASE_PROJECT_ID="your-project-id"
|
||||
agent-browser -p browserbase open https://example.com
|
||||
```
|
||||
|
||||
@@ -1125,13 +1253,12 @@ Or use environment variables for CI/scripts:
|
||||
```bash
|
||||
export AGENT_BROWSER_PROVIDER=browserbase
|
||||
export BROWSERBASE_API_KEY="your-api-key"
|
||||
export BROWSERBASE_PROJECT_ID="your-project-id"
|
||||
agent-browser open https://example.com
|
||||
```
|
||||
|
||||
When enabled, agent-browser connects to a Browserbase session instead of launching a local browser. All commands work identically.
|
||||
|
||||
Get your API key and project ID from the [Browserbase Dashboard](https://browserbase.com/overview).
|
||||
Get your API key from the [Browserbase Dashboard](https://browserbase.com/overview).
|
||||
|
||||
### Browser Use
|
||||
|
||||
@@ -1177,12 +1304,12 @@ agent-browser open https://example.com
|
||||
|
||||
Optional configuration via environment variables:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `KERNEL_HEADLESS` | Run browser in headless mode (`true`/`false`) | `false` |
|
||||
| `KERNEL_STEALTH` | Enable stealth mode to avoid bot detection (`true`/`false`) | `true` |
|
||||
| `KERNEL_TIMEOUT_SECONDS` | Session timeout in seconds | `300` |
|
||||
| `KERNEL_PROFILE_NAME` | Browser profile name for persistent cookies/logins (created if it doesn't exist) | (none) |
|
||||
| Variable | Description | Default |
|
||||
| ------------------------ | -------------------------------------------------------------------------------- | ------- |
|
||||
| `KERNEL_HEADLESS` | Run browser in headless mode (`true`/`false`) | `false` |
|
||||
| `KERNEL_STEALTH` | Enable stealth mode to avoid bot detection (`true`/`false`) | `true` |
|
||||
| `KERNEL_TIMEOUT_SECONDS` | Session timeout in seconds | `300` |
|
||||
| `KERNEL_PROFILE_NAME` | Browser profile name for persistent cookies/logins (created if it doesn't exist) | (none) |
|
||||
|
||||
When enabled, agent-browser connects to a Kernel cloud session instead of launching a local browser. All commands work identically.
|
||||
|
||||
@@ -1190,6 +1317,39 @@ When enabled, agent-browser connects to a Kernel cloud session instead of launch
|
||||
|
||||
Get your API key from the [Kernel Dashboard](https://dashboard.onkernel.com).
|
||||
|
||||
### AgentCore
|
||||
|
||||
[AWS Bedrock AgentCore](https://aws.amazon.com/bedrock/agentcore/) provides cloud browser sessions with SigV4 authentication.
|
||||
|
||||
To enable AgentCore, use the `-p` flag:
|
||||
|
||||
```bash
|
||||
agent-browser -p agentcore open https://example.com
|
||||
```
|
||||
|
||||
Or use environment variables for CI/scripts:
|
||||
|
||||
```bash
|
||||
export AGENT_BROWSER_PROVIDER=agentcore
|
||||
agent-browser open https://example.com
|
||||
```
|
||||
|
||||
Credentials are automatically resolved from environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`) or the AWS CLI (`aws configure export-credentials`), which supports SSO, profiles, and IAM roles.
|
||||
|
||||
Optional configuration via environment variables:
|
||||
|
||||
| Variable | Description | Default |
|
||||
| -------------------------- | -------------------------------------------------------------------- | ---------------- |
|
||||
| `AGENTCORE_REGION` | AWS region for the AgentCore endpoint | `us-east-1` |
|
||||
| `AGENTCORE_BROWSER_ID` | Browser identifier | `aws.browser.v1` |
|
||||
| `AGENTCORE_PROFILE_ID` | Browser profile for persistent state (cookies, localStorage) | (none) |
|
||||
| `AGENTCORE_SESSION_TIMEOUT`| Session timeout in seconds | `3600` |
|
||||
| `AWS_PROFILE` | AWS CLI profile for credential resolution | `default` |
|
||||
|
||||
**Browser profiles:** When `AGENTCORE_PROFILE_ID` is set, browser state (cookies, localStorage) is persisted across sessions automatically.
|
||||
|
||||
When enabled, agent-browser connects to an AgentCore cloud browser session instead of launching a local browser. All commands work identically.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
|
||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
||||
/Users/leo/github.com/agent-browser/cli/target/release/agent-browser: /Users/leo/github.com/agent-browser/cli/build.rs /Users/leo/github.com/agent-browser/cli/cdp-protocol/browser_protocol.json /Users/leo/github.com/agent-browser/cli/cdp-protocol/js_protocol.json /Users/leo/github.com/agent-browser/cli/src/color.rs /Users/leo/github.com/agent-browser/cli/src/commands.rs /Users/leo/github.com/agent-browser/cli/src/connection.rs /Users/leo/github.com/agent-browser/cli/src/flags.rs /Users/leo/github.com/agent-browser/cli/src/install.rs /Users/leo/github.com/agent-browser/cli/src/main.rs /Users/leo/github.com/agent-browser/cli/src/output.rs /Users/leo/github.com/agent-browser/cli/src/validation.rs
|
||||
+13
-2
@@ -8,7 +8,7 @@
|
||||
* binary directly (zero overhead).
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import { spawn, execSync } from 'child_process';
|
||||
import { existsSync, accessSync, chmodSync, constants } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
@@ -16,6 +16,17 @@ import { platform, arch } from 'os';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Detect if the system uses musl libc (e.g. Alpine Linux)
|
||||
function isMusl() {
|
||||
if (platform() !== 'linux') return false;
|
||||
try {
|
||||
const result = execSync('ldd --version 2>&1 || true', { encoding: 'utf8' });
|
||||
return result.toLowerCase().includes('musl');
|
||||
} catch {
|
||||
return existsSync('/lib/ld-musl-x86_64.so.1') || existsSync('/lib/ld-musl-aarch64.so.1');
|
||||
}
|
||||
}
|
||||
|
||||
// Map Node.js platform/arch to binary naming convention
|
||||
function getBinaryName() {
|
||||
const os = platform();
|
||||
@@ -27,7 +38,7 @@ function getBinaryName() {
|
||||
osKey = 'darwin';
|
||||
break;
|
||||
case 'linux':
|
||||
osKey = 'linux';
|
||||
osKey = isMusl() ? 'linux-musl' : 'linux';
|
||||
break;
|
||||
case 'win32':
|
||||
osKey = 'win32';
|
||||
|
||||
Generated
+235
-3
@@ -44,27 +44,35 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "agent-browser"
|
||||
version = "0.16.1"
|
||||
name = "agent-browser-stealth"
|
||||
version = "0.24.0-fork.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
"base64",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"futures-util",
|
||||
"getrandom 0.2.17",
|
||||
"hex",
|
||||
"hmac",
|
||||
"image",
|
||||
"libc",
|
||||
"regex-lite",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"similar",
|
||||
"socket2",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"windows-sys 0.52.0",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -85,6 +93,15 @@ dependencies = [
|
||||
"equator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
@@ -285,6 +302,19 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
@@ -301,6 +331,12 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "core2"
|
||||
version = "0.4.0"
|
||||
@@ -385,6 +421,15 @@ version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -393,6 +438,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -527,6 +573,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -698,6 +745,21 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
@@ -798,6 +860,30 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.1.1"
|
||||
@@ -1172,6 +1258,12 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
|
||||
|
||||
[[package]]
|
||||
name = "num-derive"
|
||||
version = "0.4.2"
|
||||
@@ -1294,6 +1386,12 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
@@ -1414,7 +1512,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1572,6 +1670,12 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-lite"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
@@ -1922,6 +2026,37 @@ dependencies = [
|
||||
"zune-jpeg 0.4.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"itoa",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.2"
|
||||
@@ -2090,6 +2225,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"
|
||||
@@ -2136,6 +2277,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
version = "0.7.6"
|
||||
@@ -2346,12 +2493,65 @@ version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
@@ -2777,12 +2977,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"
|
||||
|
||||
+20
-3
@@ -1,17 +1,27 @@
|
||||
[package]
|
||||
name = "agent-browser"
|
||||
version = "0.16.1"
|
||||
name = "agent-browser-stealth"
|
||||
version = "0.24.0-fork.2"
|
||||
edition = "2021"
|
||||
description = "Fast browser automation CLI for AI agents"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/leeguooooo/agent-browser-stealth"
|
||||
homepage = "https://github.com/leeguooooo/agent-browser-stealth"
|
||||
readme = "../README.md"
|
||||
keywords = ["browser", "automation", "ai", "cdp", "chrome"]
|
||||
categories = ["command-line-utilities", "web-programming"]
|
||||
|
||||
[[bin]]
|
||||
name = "agent-browser"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
regex-lite = "0.1"
|
||||
dirs = "5.0"
|
||||
base64 = "0.22"
|
||||
getrandom = "0.2"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "time", "sync", "signal"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "time", "sync", "signal", "process"] }
|
||||
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
|
||||
futures-util = "0.3"
|
||||
url = "2"
|
||||
@@ -21,7 +31,14 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
|
||||
sha2 = "0.10"
|
||||
aes-gcm = "0.10"
|
||||
async-trait = "0.1"
|
||||
socket2 = "0.6"
|
||||
similar = "2"
|
||||
zip = { version = "8.2.0", default-features = false, features = ["deflate"] }
|
||||
time = { version = "0.3", features = ["formatting"] }
|
||||
hmac = "0.12"
|
||||
hex = "0.4"
|
||||
chrono = "0.4"
|
||||
urlencoding = "2"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
+3
-3
@@ -175,7 +175,7 @@ fn to_snake_case(s: &str) -> String {
|
||||
// Only insert underscore at transitions from lowercase to uppercase,
|
||||
// or when an uppercase sequence ends (e.g. "DOM" -> "dom", not "d_o_m")
|
||||
let prev_upper = chars[i - 1].is_uppercase();
|
||||
let next_lower = chars.get(i + 1).map_or(false, |n| n.is_lowercase());
|
||||
let next_lower = chars.get(i + 1).is_some_and(|n| n.is_lowercase());
|
||||
if !prev_upper || next_lower {
|
||||
result.push('_');
|
||||
}
|
||||
@@ -202,7 +202,7 @@ fn resolve_ref(
|
||||
// Check if this type actually exists in the referenced domain
|
||||
if domain_types
|
||||
.get(ref_domain)
|
||||
.map_or(false, |t| t.contains(ref_type))
|
||||
.is_some_and(|t| t.contains(ref_type))
|
||||
{
|
||||
format!(
|
||||
"super::cdp_{}::{}",
|
||||
@@ -339,7 +339,7 @@ fn generate_domain(
|
||||
if variant == "Self" {
|
||||
variant = "SelfValue".to_string();
|
||||
}
|
||||
if variant.chars().next().map_or(false, |c| c.is_ascii_digit()) {
|
||||
if variant.chars().next().is_some_and(|c| c.is_ascii_digit()) {
|
||||
variant = format!("V{}", variant);
|
||||
}
|
||||
if seen_variants.insert(variant.clone()) {
|
||||
|
||||
+587
-65
@@ -112,7 +112,9 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
format!("https://{}", url)
|
||||
};
|
||||
let mut nav_cmd = json!({ "id": id, "action": "navigate", "url": url });
|
||||
// If --headers flag is set, include headers (scoped to this origin)
|
||||
if flags.provider.is_some() {
|
||||
nav_cmd["waitUntil"] = json!("none");
|
||||
}
|
||||
if let Some(ref headers_json) = flags.headers {
|
||||
let headers =
|
||||
serde_json::from_str::<serde_json::Value>(headers_json).map_err(|_| {
|
||||
@@ -137,7 +139,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
|
||||
// === Core Actions ===
|
||||
"click" => {
|
||||
let new_tab = rest.iter().any(|arg| *arg == "--new-tab");
|
||||
let new_tab = rest.contains(&"--new-tab");
|
||||
let sel = rest
|
||||
.iter()
|
||||
.find(|arg| **arg != "--new-tab")
|
||||
@@ -391,7 +393,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
return Ok(json!({ "id": id, "action": "waitforfunction", "expression": expr }));
|
||||
}
|
||||
|
||||
// Check for --text flag: wait --text "Welcome"
|
||||
// Check for --text flag: wait --text "Welcome" [--timeout ms]
|
||||
if let Some(idx) = rest.iter().position(|&s| s == "--text" || s == "-t") {
|
||||
let text = rest
|
||||
.get(idx + 1)
|
||||
@@ -399,10 +401,13 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
context: "wait --text".to_string(),
|
||||
usage: "wait --text <text>",
|
||||
})?;
|
||||
// Use getByText locator to wait for text to appear
|
||||
return Ok(
|
||||
json!({ "id": id, "action": "wait", "selector": format!("text={}", text) }),
|
||||
);
|
||||
let mut cmd = json!({ "id": id, "action": "wait", "text": text });
|
||||
if let Some(t_idx) = rest.iter().position(|&s| s == "--timeout") {
|
||||
if let Some(Ok(ms)) = rest.get(t_idx + 1).map(|s| s.parse::<u64>()) {
|
||||
cmd["timeout"] = json!(ms);
|
||||
}
|
||||
}
|
||||
return Ok(cmd);
|
||||
}
|
||||
|
||||
// Check for --download flag: wait --download [path] [--timeout ms]
|
||||
@@ -446,10 +451,22 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
|
||||
// === Screenshot/PDF ===
|
||||
"screenshot" => {
|
||||
// screenshot [selector] [path]
|
||||
// screenshot [selector] [path] [--full/-f]
|
||||
// selector: @ref or CSS selector
|
||||
// path: file path (contains / or . or ends with known extension)
|
||||
let (selector, path) = match (rest.first(), rest.get(1)) {
|
||||
let mut full_page = false;
|
||||
let positional: Vec<&str> = rest
|
||||
.iter()
|
||||
.filter(|arg| match **arg {
|
||||
"--full" | "-f" => {
|
||||
full_page = true;
|
||||
false
|
||||
}
|
||||
_ => true,
|
||||
})
|
||||
.copied()
|
||||
.collect();
|
||||
let (selector, path) = match (positional.first(), positional.get(1)) {
|
||||
(Some(first), Some(second)) => {
|
||||
// Two args: first is selector, second is path
|
||||
(Some(*first), Some(*second))
|
||||
@@ -474,9 +491,27 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
}
|
||||
_ => (None, None),
|
||||
};
|
||||
Ok(
|
||||
json!({ "id": id, "action": "screenshot", "path": path, "selector": selector, "fullPage": flags.full, "annotate": flags.annotate }),
|
||||
)
|
||||
let mut cmd = json!({
|
||||
"id": id, "action": "screenshot",
|
||||
"path": path, "selector": selector,
|
||||
"fullPage": full_page, "annotate": flags.annotate
|
||||
});
|
||||
if let Some(ref fmt) = flags.screenshot_format {
|
||||
cmd["format"] = json!(fmt);
|
||||
}
|
||||
if let Some(q) = flags.screenshot_quality {
|
||||
cmd["quality"] = json!(q);
|
||||
if flags.screenshot_format.as_deref() != Some("jpeg") {
|
||||
eprintln!(
|
||||
"{} --screenshot-quality is ignored for PNG; use --screenshot-format jpeg",
|
||||
color::warning_indicator()
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(ref dir) = flags.screenshot_dir {
|
||||
cmd["screenshotDir"] = json!(dir);
|
||||
}
|
||||
Ok(cmd)
|
||||
}
|
||||
"pdf" => {
|
||||
let path = rest.first().ok_or_else(|| ParseError::MissingArguments {
|
||||
@@ -500,6 +535,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
obj.insert("compact".to_string(), json!(true));
|
||||
}
|
||||
"-C" | "--cursor" => {
|
||||
// deprecated, cursor-interactive elements are referred by default now
|
||||
obj.insert("cursor".to_string(), json!(true));
|
||||
}
|
||||
"-d" | "--depth" => {
|
||||
@@ -568,6 +604,9 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
// === Close ===
|
||||
"close" | "quit" | "exit" => Ok(json!({ "id": id, "action": "close" })),
|
||||
|
||||
// === Inspect ===
|
||||
"inspect" => Ok(json!({ "id": id, "action": "inspect" })),
|
||||
|
||||
// === Authentication Vault ===
|
||||
"auth" => {
|
||||
let sub = rest.first().map(|s| s.as_ref());
|
||||
@@ -588,7 +627,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
|
||||
let mut j = 2;
|
||||
while j < rest.len() {
|
||||
match rest[j].as_ref() {
|
||||
match rest[j] {
|
||||
"--url" => {
|
||||
url = rest.get(j + 1).cloned();
|
||||
j += 1;
|
||||
@@ -759,6 +798,62 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
}
|
||||
}
|
||||
|
||||
// === Runtime stream control ===
|
||||
"stream" => match rest.first().copied() {
|
||||
Some("enable") => {
|
||||
let mut cmd = json!({ "id": id, "action": "stream_enable" });
|
||||
let mut i = 1;
|
||||
while i < rest.len() {
|
||||
match rest[i] {
|
||||
"--port" => {
|
||||
let value =
|
||||
rest.get(i + 1)
|
||||
.ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "stream enable --port".to_string(),
|
||||
usage: "stream enable [--port <port>]",
|
||||
})?;
|
||||
let port =
|
||||
value.parse::<u32>().map_err(|_| ParseError::InvalidValue {
|
||||
message: format!(
|
||||
"Invalid port: '{}' is not a valid integer",
|
||||
value
|
||||
),
|
||||
usage: "stream enable [--port <port>]",
|
||||
})?;
|
||||
if port > u16::MAX as u32 {
|
||||
return Err(ParseError::InvalidValue {
|
||||
message: format!(
|
||||
"Invalid port: {} is out of range (valid range: 0-65535)",
|
||||
port
|
||||
),
|
||||
usage: "stream enable [--port <port>]",
|
||||
});
|
||||
}
|
||||
cmd["port"] = json!(port);
|
||||
i += 2;
|
||||
}
|
||||
flag => {
|
||||
return Err(ParseError::InvalidValue {
|
||||
message: format!("Unknown flag for stream enable: {}", flag),
|
||||
usage: "stream enable [--port <port>]",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(cmd)
|
||||
}
|
||||
Some("disable") => Ok(json!({ "id": id, "action": "stream_disable" })),
|
||||
Some("status") => Ok(json!({ "id": id, "action": "stream_status" })),
|
||||
Some(sub) => Err(ParseError::UnknownSubcommand {
|
||||
subcommand: sub.to_string(),
|
||||
valid_options: &["enable", "disable", "status"],
|
||||
}),
|
||||
None => Err(ParseError::MissingArguments {
|
||||
context: "stream".to_string(),
|
||||
usage: "stream <enable|disable|status>",
|
||||
}),
|
||||
},
|
||||
|
||||
// === Get ===
|
||||
"get" => parse_get(&rest, &id),
|
||||
|
||||
@@ -950,7 +1045,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
|
||||
// === Dialog ===
|
||||
"dialog" => {
|
||||
const VALID: &[&str] = &["accept", "dismiss"];
|
||||
const VALID: &[&str] = &["accept", "dismiss", "status"];
|
||||
match rest.first().copied() {
|
||||
Some("accept") => {
|
||||
let mut cmd = json!({ "id": id, "action": "dialog", "response": "accept" });
|
||||
@@ -959,13 +1054,21 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
}
|
||||
Ok(cmd)
|
||||
}
|
||||
Some("dismiss") => {
|
||||
let mut cmd = json!({ "id": id, "action": "dialog", "response": "dismiss" });
|
||||
if let Some(prompt_text) = rest.get(1) {
|
||||
cmd["promptText"] = json!(prompt_text);
|
||||
}
|
||||
Ok(cmd)
|
||||
}
|
||||
Some("status") => Ok(json!({ "id": id, "action": "dialog", "response": "status" })),
|
||||
Some(sub) => Err(ParseError::UnknownSubcommand {
|
||||
subcommand: sub.to_string(),
|
||||
valid_options: VALID,
|
||||
}),
|
||||
None => Err(ParseError::MissingArguments {
|
||||
context: "dialog".to_string(),
|
||||
usage: "dialog <accept|dismiss> [text]",
|
||||
usage: "dialog <accept|dismiss|status> [text]",
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -1030,7 +1133,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() {
|
||||
@@ -1099,6 +1202,27 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
Ok(json!({ "id": id, "action": "highlight", "selector": sel }))
|
||||
}
|
||||
|
||||
// === Clipboard ===
|
||||
"clipboard" => match rest.first().copied() {
|
||||
Some("read") | None => {
|
||||
Ok(json!({ "id": id, "action": "clipboard", "operation": "read" }))
|
||||
}
|
||||
Some("write") => {
|
||||
rest.get(1).ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "clipboard write".to_string(),
|
||||
usage: "clipboard write <text>",
|
||||
})?;
|
||||
let text = rest[1..].join(" ");
|
||||
Ok(json!({ "id": id, "action": "clipboard", "operation": "write", "text": text }))
|
||||
}
|
||||
Some("copy") => Ok(json!({ "id": id, "action": "clipboard", "operation": "copy" })),
|
||||
Some("paste") => Ok(json!({ "id": id, "action": "clipboard", "operation": "paste" })),
|
||||
Some(sub) => Err(ParseError::UnknownSubcommand {
|
||||
subcommand: sub.to_string(),
|
||||
valid_options: &["read", "write", "copy", "paste"],
|
||||
}),
|
||||
},
|
||||
|
||||
// === State ===
|
||||
"state" => {
|
||||
const VALID: &[&str] = &["save", "load", "list", "clear", "show", "clean", "rename"];
|
||||
@@ -1158,7 +1282,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
context: "state show".to_string(),
|
||||
usage: "state show <filename>",
|
||||
})?;
|
||||
Ok(json!({ "id": id, "action": "state_show", "filename": filename }))
|
||||
Ok(json!({ "id": id, "action": "state_show", "path": filename }))
|
||||
}
|
||||
Some("clean") => {
|
||||
let mut days: Option<i64> = None;
|
||||
@@ -1263,7 +1387,13 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
}
|
||||
}
|
||||
|
||||
"diff" => parse_diff(&rest, &id, flags),
|
||||
"diff" => parse_diff(&rest, &id),
|
||||
|
||||
// === Batch ===
|
||||
"batch" => {
|
||||
let bail = rest.contains(&"--bail");
|
||||
Ok(json!({ "id": id, "action": "batch", "bail": bail }))
|
||||
}
|
||||
|
||||
_ => Err(ParseError::UnknownCommand {
|
||||
command: cmd.to_string(),
|
||||
@@ -1271,7 +1401,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_diff(rest: &[&str], id: &str, flags: &Flags) -> Result<Value, ParseError> {
|
||||
fn parse_diff(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
||||
const VALID: &[&str] = &["snapshot", "screenshot", "url"];
|
||||
|
||||
match rest.first().copied() {
|
||||
@@ -1416,27 +1546,24 @@ fn parse_diff(rest: &[&str], id: &str, flags: &Flags) -> Result<Value, ParseErro
|
||||
});
|
||||
}
|
||||
}
|
||||
"--full" => {
|
||||
"--full" | "-f" => {
|
||||
obj.insert("fullPage".to_string(), json!(true));
|
||||
}
|
||||
other if other.starts_with('-') => {
|
||||
return Err(ParseError::InvalidValue {
|
||||
message: format!("Unknown flag: {}", other),
|
||||
usage: "diff screenshot --baseline <file> [--output <file>] [--threshold <0-1>] [--selector <sel>] [--full]",
|
||||
usage: "diff screenshot --baseline <file> [--output <file>] [--threshold <0-1>] [--selector <sel>] [--full/-f]",
|
||||
});
|
||||
}
|
||||
other => {
|
||||
return Err(ParseError::InvalidValue {
|
||||
message: format!("Unexpected argument: {}", other),
|
||||
usage: "diff screenshot --baseline <file> [--output <file>] [--threshold <0-1>] [--selector <sel>] [--full]",
|
||||
usage: "diff screenshot --baseline <file> [--output <file>] [--threshold <0-1>] [--selector <sel>] [--full/-f]",
|
||||
});
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if flags.full {
|
||||
obj.insert("fullPage".to_string(), json!(true));
|
||||
}
|
||||
if !obj.contains_key("baseline") {
|
||||
return Err(ParseError::MissingArguments {
|
||||
context: "diff screenshot".to_string(),
|
||||
@@ -1467,7 +1594,7 @@ fn parse_diff(rest: &[&str], id: &str, flags: &Flags) -> Result<Value, ParseErro
|
||||
"--screenshot" => {
|
||||
obj.insert("screenshot".to_string(), json!(true));
|
||||
}
|
||||
"--full" => {
|
||||
"--full" | "-f" => {
|
||||
obj.insert("fullPage".to_string(), json!(true));
|
||||
}
|
||||
"--wait-until" => {
|
||||
@@ -1522,21 +1649,18 @@ fn parse_diff(rest: &[&str], id: &str, flags: &Flags) -> Result<Value, ParseErro
|
||||
other if other.starts_with('-') => {
|
||||
return Err(ParseError::InvalidValue {
|
||||
message: format!("Unknown flag: {}", other),
|
||||
usage: "diff url <url1> <url2> [--screenshot] [--full] [--wait-until <strategy>] [--selector <sel>] [--compact] [--depth <n>]",
|
||||
usage: "diff url <url1> <url2> [--screenshot] [--full/-f] [--wait-until <strategy>] [--selector <sel>] [--compact] [--depth <n>]",
|
||||
});
|
||||
}
|
||||
other => {
|
||||
return Err(ParseError::InvalidValue {
|
||||
message: format!("Unexpected argument: {}", other),
|
||||
usage: "diff url <url1> <url2> [--screenshot] [--full] [--wait-until <strategy>] [--selector <sel>] [--compact] [--depth <n>]",
|
||||
usage: "diff url <url1> <url2> [--screenshot] [--full/-f] [--wait-until <strategy>] [--selector <sel>] [--compact] [--depth <n>]",
|
||||
});
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if flags.full {
|
||||
obj.insert("fullPage".to_string(), json!(true));
|
||||
}
|
||||
Ok(cmd)
|
||||
}
|
||||
Some(sub) => Err(ParseError::UnknownSubcommand {
|
||||
@@ -1552,7 +1676,7 @@ fn parse_diff(rest: &[&str], id: &str, flags: &Flags) -> Result<Value, ParseErro
|
||||
|
||||
fn parse_get(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
||||
const VALID: &[&str] = &[
|
||||
"text", "html", "value", "attr", "url", "title", "count", "box", "styles",
|
||||
"text", "html", "value", "attr", "url", "title", "count", "box", "styles", "cdp-url",
|
||||
];
|
||||
|
||||
match rest.first().copied() {
|
||||
@@ -1589,6 +1713,7 @@ fn parse_get(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
||||
Ok(json!({ "id": id, "action": "getattribute", "selector": sel, "attribute": attr }))
|
||||
}
|
||||
Some("url") => Ok(json!({ "id": id, "action": "url" })),
|
||||
Some("cdp-url") => Ok(json!({ "id": id, "action": "cdp_url" })),
|
||||
Some("title") => Ok(json!({ "id": id, "action": "title" })),
|
||||
Some("count") => {
|
||||
let sel = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
|
||||
@@ -1617,7 +1742,7 @@ fn parse_get(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
||||
}),
|
||||
None => Err(ParseError::MissingArguments {
|
||||
context: "get".to_string(),
|
||||
usage: "get <text|html|value|attr|url|title|count|box|styles> [args...]",
|
||||
usage: "get <text|html|value|attr|url|title|count|box|styles|cdp-url> [args...]",
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -1677,10 +1802,6 @@ fn parse_find(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
||||
usage: "find <locator> <value> [action] [text]",
|
||||
})?;
|
||||
|
||||
let name_idx = rest.iter().position(|&s| s == "--name");
|
||||
let name = name_idx.and_then(|i| rest.get(i + 1).copied());
|
||||
let exact = rest.contains(&"--exact");
|
||||
|
||||
match *locator {
|
||||
"role" | "text" | "label" | "placeholder" | "alt" | "title" | "testid" | "first"
|
||||
| "last" => {
|
||||
@@ -1700,10 +1821,41 @@ fn parse_find(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
||||
},
|
||||
})?;
|
||||
let subaction = rest.get(2).unwrap_or(&"click");
|
||||
let fill_value = if rest.len() > 3 {
|
||||
Some(rest[3..].join(" "))
|
||||
} else {
|
||||
let mut name: Option<&str> = None;
|
||||
let mut exact = false;
|
||||
let mut fill_parts: Vec<&str> = Vec::new();
|
||||
|
||||
if rest.len() > 3 {
|
||||
let mut i = 3;
|
||||
while i < rest.len() {
|
||||
match rest[i] {
|
||||
"--exact" => {
|
||||
exact = true;
|
||||
i += 1;
|
||||
}
|
||||
"--name" => {
|
||||
let n =
|
||||
rest.get(i + 1)
|
||||
.ok_or_else(|| ParseError::MissingArguments {
|
||||
context: format!("find {}", locator),
|
||||
usage:
|
||||
"find role <role> [action] [--name <name>] [--exact]",
|
||||
})?;
|
||||
name = Some(*n);
|
||||
i += 2;
|
||||
}
|
||||
token => {
|
||||
fill_parts.push(token);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let fill_value = if fill_parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(fill_parts.join(" "))
|
||||
};
|
||||
|
||||
match *locator {
|
||||
@@ -1864,25 +2016,35 @@ fn parse_set(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
||||
Some("viewport") => {
|
||||
let w_str = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "set viewport".to_string(),
|
||||
usage: "set viewport <width> <height>",
|
||||
usage: "set viewport <width> <height> [scale]",
|
||||
})?;
|
||||
let h_str = rest.get(2).ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "set viewport".to_string(),
|
||||
usage: "set viewport <width> <height>",
|
||||
usage: "set viewport <width> <height> [scale]",
|
||||
})?;
|
||||
let w = w_str
|
||||
.parse::<i32>()
|
||||
.map_err(|_| ParseError::MissingArguments {
|
||||
context: "set viewport".to_string(),
|
||||
usage: "set viewport <width> <height>",
|
||||
usage: "set viewport <width> <height> [scale]",
|
||||
})?;
|
||||
let h = h_str
|
||||
.parse::<i32>()
|
||||
.map_err(|_| ParseError::MissingArguments {
|
||||
context: "set viewport".to_string(),
|
||||
usage: "set viewport <width> <height>",
|
||||
usage: "set viewport <width> <height> [scale]",
|
||||
})?;
|
||||
Ok(json!({ "id": id, "action": "viewport", "width": w, "height": h }))
|
||||
let mut cmd = json!({ "id": id, "action": "viewport", "width": w, "height": h });
|
||||
if let Some(scale_str) = rest.get(3) {
|
||||
let scale = scale_str
|
||||
.parse::<f64>()
|
||||
.map_err(|_| ParseError::MissingArguments {
|
||||
context: "set viewport".to_string(),
|
||||
usage: "set viewport <width> <height> [scale]",
|
||||
})?;
|
||||
cmd["deviceScaleFactor"] = json!(scale);
|
||||
}
|
||||
Ok(cmd)
|
||||
}
|
||||
Some("device") => {
|
||||
let dev = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
|
||||
@@ -1973,8 +2135,9 @@ fn parse_set(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse network interception, request inspection, and HAR recording commands.
|
||||
fn parse_network(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
||||
const VALID: &[&str] = &["route", "unroute", "requests"];
|
||||
const VALID: &[&str] = &["route", "unroute", "requests", "request", "har"];
|
||||
|
||||
match rest.first().copied() {
|
||||
Some("route") => {
|
||||
@@ -1998,19 +2161,62 @@ fn parse_network(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
||||
let clear = rest.contains(&"--clear");
|
||||
let filter_idx = rest.iter().position(|&s| s == "--filter");
|
||||
let filter = filter_idx.and_then(|i| rest.get(i + 1).copied());
|
||||
let type_idx = rest.iter().position(|&s| s == "--type");
|
||||
let rtype = type_idx.and_then(|i| rest.get(i + 1).copied());
|
||||
let method_idx = rest.iter().position(|&s| s == "--method");
|
||||
let method = method_idx.and_then(|i| rest.get(i + 1).copied());
|
||||
let status_idx = rest.iter().position(|&s| s == "--status");
|
||||
let status = status_idx.and_then(|i| rest.get(i + 1).copied());
|
||||
let mut cmd = json!({ "id": id, "action": "requests", "clear": clear });
|
||||
if let Some(f) = filter {
|
||||
cmd["filter"] = json!(f);
|
||||
}
|
||||
if let Some(t) = rtype {
|
||||
cmd["type"] = json!(t);
|
||||
}
|
||||
if let Some(m) = method {
|
||||
cmd["method"] = json!(m);
|
||||
}
|
||||
if let Some(s) = status {
|
||||
cmd["status"] = json!(s);
|
||||
}
|
||||
Ok(cmd)
|
||||
}
|
||||
Some("request") => {
|
||||
let request_id = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "network request".to_string(),
|
||||
usage: "network request <requestId>",
|
||||
})?;
|
||||
Ok(json!({ "id": id, "action": "request_detail", "requestId": request_id }))
|
||||
}
|
||||
Some("har") => {
|
||||
const HAR_VALID: &[&str] = &["start", "stop"];
|
||||
match rest.get(1).copied() {
|
||||
Some("start") => Ok(json!({ "id": id, "action": "har_start" })),
|
||||
Some("stop") => {
|
||||
let mut cmd = json!({ "id": id, "action": "har_stop" });
|
||||
if let Some(path) = rest.get(2) {
|
||||
cmd["path"] = json!(path);
|
||||
}
|
||||
Ok(cmd)
|
||||
}
|
||||
Some(sub) => Err(ParseError::UnknownSubcommand {
|
||||
subcommand: sub.to_string(),
|
||||
valid_options: HAR_VALID,
|
||||
}),
|
||||
None => Err(ParseError::MissingArguments {
|
||||
context: "network har".to_string(),
|
||||
usage: "network har <start|stop> [path]",
|
||||
}),
|
||||
}
|
||||
}
|
||||
Some(sub) => Err(ParseError::UnknownSubcommand {
|
||||
subcommand: sub.to_string(),
|
||||
valid_options: VALID,
|
||||
}),
|
||||
None => Err(ParseError::MissingArguments {
|
||||
context: "network".to_string(),
|
||||
usage: "network <route|unroute|requests> [args...]",
|
||||
usage: "network <route|unroute|requests|request|har> [args...]",
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -2021,10 +2227,14 @@ fn parse_storage(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
||||
match rest.first().copied() {
|
||||
Some("local") | Some("session") => {
|
||||
let storage_type = rest.first().unwrap();
|
||||
let op = rest.get(1).unwrap_or(&"get");
|
||||
let key = rest.get(2);
|
||||
let value = rest.get(3);
|
||||
match *op {
|
||||
let (op, key, value) = match rest.get(1) {
|
||||
Some(&"get") => ("get", rest.get(2), rest.get(3)),
|
||||
Some(&"set") => ("set", rest.get(2), rest.get(3)),
|
||||
Some(&"clear") => ("clear", rest.get(2), rest.get(3)),
|
||||
Some(_) => ("get", rest.get(1), rest.get(2)),
|
||||
None => ("get", None, None),
|
||||
};
|
||||
match op {
|
||||
"set" => {
|
||||
let k = key.ok_or_else(|| ParseError::MissingArguments {
|
||||
context: format!("storage {} set", storage_type),
|
||||
@@ -2070,7 +2280,6 @@ mod tests {
|
||||
Flags {
|
||||
session: "test".to_string(),
|
||||
json: false,
|
||||
full: false,
|
||||
headed: false,
|
||||
debug: false,
|
||||
headers: None,
|
||||
@@ -2100,6 +2309,7 @@ mod tests {
|
||||
cli_allow_file_access: false,
|
||||
cli_annotate: false,
|
||||
cli_download_path: false,
|
||||
cli_headed: false,
|
||||
annotate: false,
|
||||
color_scheme: None,
|
||||
download_path: None,
|
||||
@@ -2109,7 +2319,12 @@ mod tests {
|
||||
action_policy: None,
|
||||
confirm_actions: None,
|
||||
confirm_interactive: false,
|
||||
native: false,
|
||||
engine: None,
|
||||
screenshot_dir: None,
|
||||
screenshot_quality: None,
|
||||
screenshot_format: None,
|
||||
idle_timeout: None,
|
||||
no_auto_dialog: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2296,6 +2511,14 @@ mod tests {
|
||||
assert_eq!(cmd["key"], "mykey");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_storage_local_get_implicit_key() {
|
||||
let cmd = parse_command(&args("storage local mykey"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "storage_get");
|
||||
assert_eq!(cmd["type"], "local");
|
||||
assert_eq!(cmd["key"], "mykey");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_storage_session_get() {
|
||||
let cmd = parse_command(&args("storage session"), &default_flags()).unwrap();
|
||||
@@ -2303,6 +2526,14 @@ mod tests {
|
||||
assert_eq!(cmd["type"], "session");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_storage_session_get_implicit_key() {
|
||||
let cmd = parse_command(&args("storage session mykey"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "storage_get");
|
||||
assert_eq!(cmd["type"], "session");
|
||||
assert_eq!(cmd["key"], "mykey");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_storage_local_set() {
|
||||
let cmd =
|
||||
@@ -2565,6 +2796,82 @@ mod tests {
|
||||
assert_eq!(cmd["action"], "tab_close");
|
||||
}
|
||||
|
||||
// === Network ===
|
||||
|
||||
#[test]
|
||||
fn test_network_har_start() {
|
||||
let cmd = parse_command(&args("network har start"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "har_start");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_network_har_stop_with_path() {
|
||||
let cmd = parse_command(&args("network har stop ./capture.har"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "har_stop");
|
||||
assert_eq!(cmd["path"], "./capture.har");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_network_har_stop_without_path() {
|
||||
let cmd = parse_command(&args("network har stop"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "har_stop");
|
||||
assert!(cmd.get("path").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_network_har_requires_subcommand() {
|
||||
let result = parse_command(&args("network har"), &default_flags());
|
||||
assert!(matches!(result, Err(ParseError::MissingArguments { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_network_requests_type_filter() {
|
||||
let cmd =
|
||||
parse_command(&args("network requests --type xhr,fetch"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "requests");
|
||||
assert_eq!(cmd["type"], "xhr,fetch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_network_requests_method_filter() {
|
||||
let cmd = parse_command(&args("network requests --method POST"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "requests");
|
||||
assert_eq!(cmd["method"], "POST");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_network_requests_status_filter() {
|
||||
let cmd = parse_command(&args("network requests --status 2xx"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "requests");
|
||||
assert_eq!(cmd["status"], "2xx");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_network_requests_combined_filters() {
|
||||
let cmd = parse_command(
|
||||
&args("network requests --filter api --type xhr --method GET --status 200"),
|
||||
&default_flags(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cmd["filter"], "api");
|
||||
assert_eq!(cmd["type"], "xhr");
|
||||
assert_eq!(cmd["method"], "GET");
|
||||
assert_eq!(cmd["status"], "200");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_network_request_detail() {
|
||||
let cmd = parse_command(&args("network request 1234.5"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "request_detail");
|
||||
assert_eq!(cmd["requestId"], "1234.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_network_request_detail_requires_id() {
|
||||
let result = parse_command(&args("network request"), &default_flags());
|
||||
assert!(matches!(result, Err(ParseError::MissingArguments { .. })));
|
||||
}
|
||||
|
||||
// === Screenshot ===
|
||||
|
||||
#[test]
|
||||
@@ -2584,9 +2891,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_screenshot_full_page() {
|
||||
let mut flags = default_flags();
|
||||
flags.full = true;
|
||||
let cmd = parse_command(&args("screenshot"), &flags).unwrap();
|
||||
let cmd = parse_command(&args("screenshot --full"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "screenshot");
|
||||
assert_eq!(cmd["fullPage"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_screenshot_full_page_shorthand() {
|
||||
let cmd = parse_command(&args("screenshot -f"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "screenshot");
|
||||
assert_eq!(cmd["fullPage"], true);
|
||||
}
|
||||
@@ -2726,7 +3038,78 @@ mod tests {
|
||||
fn test_wait_text() {
|
||||
let cmd = parse_command(&args("wait --text Welcome"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "wait");
|
||||
assert_eq!(cmd["selector"], "text=Welcome");
|
||||
assert_eq!(cmd["text"], "Welcome");
|
||||
assert!(cmd.get("timeout").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wait_text_with_timeout() {
|
||||
let cmd = parse_command(
|
||||
&args("wait --text Welcome --timeout 5000"),
|
||||
&default_flags(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cmd["action"], "wait");
|
||||
assert_eq!(cmd["text"], "Welcome");
|
||||
assert_eq!(cmd["timeout"], 5000);
|
||||
}
|
||||
|
||||
// === Clipboard Tests ===
|
||||
|
||||
#[test]
|
||||
fn test_clipboard_read_default() {
|
||||
let cmd = parse_command(&args("clipboard"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "clipboard");
|
||||
assert_eq!(cmd["operation"], "read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clipboard_read_explicit() {
|
||||
let cmd = parse_command(&args("clipboard read"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "clipboard");
|
||||
assert_eq!(cmd["operation"], "read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clipboard_write() {
|
||||
let cmd = parse_command(&args("clipboard write hello"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "clipboard");
|
||||
assert_eq!(cmd["operation"], "write");
|
||||
assert_eq!(cmd["text"], "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clipboard_write_multi_word() {
|
||||
let cmd = parse_command(&args("clipboard write hello world"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "clipboard");
|
||||
assert_eq!(cmd["operation"], "write");
|
||||
assert_eq!(cmd["text"], "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clipboard_copy() {
|
||||
let cmd = parse_command(&args("clipboard copy"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "clipboard");
|
||||
assert_eq!(cmd["operation"], "copy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clipboard_paste() {
|
||||
let cmd = parse_command(&args("clipboard paste"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "clipboard");
|
||||
assert_eq!(cmd["operation"], "paste");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clipboard_write_missing_text() {
|
||||
let result = parse_command(&args("clipboard write"), &default_flags());
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clipboard_unknown_subcommand() {
|
||||
let result = parse_command(&args("clipboard clear"), &default_flags());
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// === Unknown command ===
|
||||
@@ -3036,6 +3419,45 @@ mod tests {
|
||||
assert_eq!(cmd["reducedMotion"], "reduce");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_viewport() {
|
||||
let cmd = parse_command(&args("set viewport 1920 1080"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "viewport");
|
||||
assert_eq!(cmd["width"], 1920);
|
||||
assert_eq!(cmd["height"], 1080);
|
||||
assert!(cmd.get("deviceScaleFactor").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_viewport_with_scale() {
|
||||
let cmd = parse_command(&args("set viewport 1920 1080 2"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "viewport");
|
||||
assert_eq!(cmd["width"], 1920);
|
||||
assert_eq!(cmd["height"], 1080);
|
||||
assert_eq!(cmd["deviceScaleFactor"], 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_viewport_with_fractional_scale() {
|
||||
let cmd = parse_command(&args("set viewport 375 812 3"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "viewport");
|
||||
assert_eq!(cmd["width"], 375);
|
||||
assert_eq!(cmd["height"], 812);
|
||||
assert_eq!(cmd["deviceScaleFactor"], 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_viewport_missing_height() {
|
||||
let result = parse_command(&args("set viewport 1920"), &default_flags());
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_viewport_invalid_scale() {
|
||||
let result = parse_command(&args("set viewport 1920 1080 abc"), &default_flags());
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_first_no_value() {
|
||||
let cmd = parse_command(&args("find first a click"), &default_flags()).unwrap();
|
||||
@@ -3060,6 +3482,21 @@ mod tests {
|
||||
assert!(cmd.get("value").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_role_fill_does_not_include_flags_in_value() {
|
||||
let cmd = parse_command(
|
||||
&args("find role textbox fill hello --name username --exact"),
|
||||
&default_flags(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cmd["action"], "getbyrole");
|
||||
assert_eq!(cmd["role"], "textbox");
|
||||
assert_eq!(cmd["subaction"], "fill");
|
||||
assert_eq!(cmd["name"], "username");
|
||||
assert_eq!(cmd["exact"], true);
|
||||
assert_eq!(cmd["value"], "hello");
|
||||
}
|
||||
|
||||
// === Download Tests ===
|
||||
|
||||
#[test]
|
||||
@@ -3239,6 +3676,46 @@ mod tests {
|
||||
assert_eq!(cmd["cdpPort"], 1);
|
||||
}
|
||||
|
||||
// === Runtime stream control tests ===
|
||||
|
||||
#[test]
|
||||
fn test_stream_enable_auto_port() {
|
||||
let cmd = parse_command(&args("stream enable"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "stream_enable");
|
||||
assert!(cmd.get("port").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stream_enable_with_port() {
|
||||
let cmd = parse_command(&args("stream enable --port 9223"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "stream_enable");
|
||||
assert_eq!(cmd["port"], 9223);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stream_status() {
|
||||
let cmd = parse_command(&args("stream status"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "stream_status");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stream_disable() {
|
||||
let cmd = parse_command(&args("stream disable"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "stream_disable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stream_enable_invalid_port() {
|
||||
let result = parse_command(&args("stream enable --port abc"), &default_flags());
|
||||
assert!(matches!(result, Err(ParseError::InvalidValue { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stream_missing_subcommand() {
|
||||
let result = parse_command(&args("stream"), &default_flags());
|
||||
assert!(matches!(result, Err(ParseError::MissingArguments { .. })));
|
||||
}
|
||||
|
||||
// === Trace Tests ===
|
||||
|
||||
#[test]
|
||||
@@ -3344,10 +3821,23 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_screenshot_global_full_flag() {
|
||||
let mut flags = default_flags();
|
||||
flags.full = true;
|
||||
let cmd = parse_command(&args("diff screenshot --baseline b.png"), &flags).unwrap();
|
||||
fn test_diff_screenshot_command_full_flag() {
|
||||
let cmd = parse_command(
|
||||
&args("diff screenshot --baseline b.png --full"),
|
||||
&default_flags(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cmd["action"], "diff_screenshot");
|
||||
assert_eq!(cmd["fullPage"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_screenshot_command_full_flag_shorthand() {
|
||||
let cmd = parse_command(
|
||||
&args("diff screenshot --baseline b.png -f"),
|
||||
&default_flags(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cmd["action"], "diff_screenshot");
|
||||
assert_eq!(cmd["fullPage"], true);
|
||||
}
|
||||
@@ -3388,10 +3878,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_url_global_full_flag() {
|
||||
let mut flags = default_flags();
|
||||
flags.full = true;
|
||||
let cmd = parse_command(&args("diff url https://a.com https://b.com"), &flags).unwrap();
|
||||
fn test_diff_url_command_full_flag() {
|
||||
let cmd = parse_command(
|
||||
&args("diff url https://a.com https://b.com --full"),
|
||||
&default_flags(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cmd["fullPage"], true);
|
||||
}
|
||||
|
||||
@@ -3749,4 +4241,34 @@ mod tests {
|
||||
ParseError::MissingArguments { .. }
|
||||
));
|
||||
}
|
||||
|
||||
// === Inspect / CDP URL ===
|
||||
|
||||
#[test]
|
||||
fn test_inspect() {
|
||||
let cmd = parse_command(&args("inspect"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "inspect");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_cdp_url() {
|
||||
let cmd = parse_command(&args("get cdp-url"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "cdp_url");
|
||||
}
|
||||
|
||||
// === Batch Tests ===
|
||||
|
||||
#[test]
|
||||
fn test_batch_default() {
|
||||
let cmd = parse_command(&args("batch"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "batch");
|
||||
assert_eq!(cmd["bail"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_with_bail() {
|
||||
let cmd = parse_command(&args("batch --bail"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "batch");
|
||||
assert_eq!(cmd["bail"], true);
|
||||
}
|
||||
}
|
||||
|
||||
+163
-200
@@ -26,6 +26,8 @@ pub struct Response {
|
||||
pub success: bool,
|
||||
pub data: Option<Value>,
|
||||
pub error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub warning: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -120,6 +122,8 @@ fn get_pid_path(session: &str) -> PathBuf {
|
||||
fn cleanup_stale_files(session: &str) {
|
||||
let pid_path = get_pid_path(session);
|
||||
let _ = fs::remove_file(&pid_path);
|
||||
let stream_path = get_socket_dir().join(format!("{}.stream", session));
|
||||
let _ = fs::remove_file(&stream_path);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -140,7 +144,7 @@ fn get_port_path(session: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn get_port_for_session(session: &str) -> u16 {
|
||||
pub fn get_port_for_session(session: &str) -> u16 {
|
||||
let mut hash: i32 = 0;
|
||||
for c in session.chars() {
|
||||
hash = ((hash << 5).wrapping_sub(hash)).wrapping_add(c as i32);
|
||||
@@ -150,43 +154,19 @@ fn get_port_for_session(session: &str) -> u16 {
|
||||
49152 + ((hash.unsigned_abs() as u32 % 16383) as u16)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn is_daemon_running(session: &str) -> bool {
|
||||
let pid_path = get_pid_path(session);
|
||||
if !pid_path.exists() {
|
||||
return false;
|
||||
}
|
||||
if let Ok(pid_str) = fs::read_to_string(&pid_path) {
|
||||
if let Ok(pid) = pid_str.trim().parse::<i32>() {
|
||||
unsafe {
|
||||
if libc::kill(pid, 0) == 0 {
|
||||
return true;
|
||||
}
|
||||
// EPERM means the process exists but we lack permission to
|
||||
// signal it (e.g. inside a macOS sandbox). Only ESRCH means
|
||||
// the process is genuinely gone.
|
||||
return std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH);
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Read the actual daemon port from the `.port` file written by the daemon.
|
||||
/// Falls back to the hash-derived port if the file does not exist or is
|
||||
/// unreadable (e.g. daemon has not started yet).
|
||||
#[cfg(windows)]
|
||||
fn is_daemon_running(session: &str) -> bool {
|
||||
let pid_path = get_pid_path(session);
|
||||
if !pid_path.exists() {
|
||||
return false;
|
||||
}
|
||||
let port = get_port_for_session(session);
|
||||
TcpStream::connect_timeout(
|
||||
&format!("127.0.0.1:{}", port).parse().unwrap(),
|
||||
Duration::from_millis(100),
|
||||
)
|
||||
.is_ok()
|
||||
pub fn resolve_port(session: &str) -> u16 {
|
||||
let port_path = get_port_path(session);
|
||||
fs::read_to_string(&port_path)
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u16>().ok())
|
||||
.unwrap_or_else(|| get_port_for_session(session))
|
||||
}
|
||||
|
||||
fn daemon_ready(session: &str) -> bool {
|
||||
pub fn daemon_ready(session: &str) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let socket_path = get_socket_path(session);
|
||||
@@ -194,7 +174,7 @@ fn daemon_ready(session: &str) -> bool {
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let port = get_port_for_session(session);
|
||||
let port = resolve_port(session);
|
||||
TcpStream::connect_timeout(
|
||||
&format!("127.0.0.1:{}", port).parse().unwrap(),
|
||||
Duration::from_millis(50),
|
||||
@@ -215,12 +195,15 @@ pub struct DaemonResult {
|
||||
/// The daemon only needs `confirm_actions` to gate action categories.
|
||||
pub struct DaemonOptions<'a> {
|
||||
pub headed: bool,
|
||||
pub debug: bool,
|
||||
pub executable_path: Option<&'a str>,
|
||||
pub extensions: &'a [String],
|
||||
pub args: Option<&'a str>,
|
||||
pub user_agent: Option<&'a str>,
|
||||
pub proxy: Option<&'a str>,
|
||||
pub proxy_bypass: Option<&'a str>,
|
||||
pub proxy_username: Option<&'a str>,
|
||||
pub proxy_password: Option<&'a str>,
|
||||
pub ignore_https_errors: bool,
|
||||
pub allow_file_access: bool,
|
||||
pub profile: Option<&'a str>,
|
||||
@@ -232,7 +215,12 @@ 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>,
|
||||
pub auto_connect: bool,
|
||||
pub force_launch: bool,
|
||||
pub idle_timeout: Option<&'a str>,
|
||||
pub cdp: Option<&'a str>,
|
||||
pub no_auto_dialog: bool,
|
||||
}
|
||||
|
||||
fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
|
||||
@@ -242,6 +230,9 @@ fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
|
||||
if opts.headed {
|
||||
cmd.env("AGENT_BROWSER_HEADED", "1");
|
||||
}
|
||||
if opts.debug {
|
||||
cmd.env("AGENT_BROWSER_DEBUG", "1");
|
||||
}
|
||||
if let Some(path) = opts.executable_path {
|
||||
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
|
||||
}
|
||||
@@ -260,6 +251,12 @@ fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
|
||||
if let Some(pb) = opts.proxy_bypass {
|
||||
cmd.env("AGENT_BROWSER_PROXY_BYPASS", pb);
|
||||
}
|
||||
if let Some(pu) = opts.proxy_username {
|
||||
cmd.env("AGENT_BROWSER_PROXY_USERNAME", pu);
|
||||
}
|
||||
if let Some(pp) = opts.proxy_password {
|
||||
cmd.env("AGENT_BROWSER_PROXY_PASSWORD", pp);
|
||||
}
|
||||
if opts.ignore_https_errors {
|
||||
cmd.env("AGENT_BROWSER_IGNORE_HTTPS_ERRORS", "1");
|
||||
}
|
||||
@@ -293,11 +290,31 @@ fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
|
||||
if let Some(ca) = opts.confirm_actions {
|
||||
cmd.env("AGENT_BROWSER_CONFIRM_ACTIONS", ca);
|
||||
}
|
||||
if let Some(engine) = opts.engine {
|
||||
cmd.env("AGENT_BROWSER_ENGINE", engine);
|
||||
}
|
||||
if opts.auto_connect {
|
||||
cmd.env("AGENT_BROWSER_AUTO_CONNECT", "1");
|
||||
}
|
||||
if opts.force_launch {
|
||||
cmd.env("AGENT_BROWSER_FORCE_LAUNCH", "1");
|
||||
}
|
||||
if let Some(idle) = opts.idle_timeout {
|
||||
cmd.env("AGENT_BROWSER_IDLE_TIMEOUT_MS", idle);
|
||||
}
|
||||
if let Some(cdp) = opts.cdp {
|
||||
cmd.env("AGENT_BROWSER_CDP", cdp);
|
||||
}
|
||||
if opts.no_auto_dialog {
|
||||
cmd.env("AGENT_BROWSER_NO_AUTO_DIALOG", "1");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult, String> {
|
||||
// Check if daemon is running AND responsive
|
||||
if is_daemon_running(session) && daemon_ready(session) {
|
||||
// Socket connectivity is the sole liveness check — no PID check — so
|
||||
// callers in a different PID namespace (e.g. unshare) can still reuse
|
||||
// an existing daemon they can reach over the socket.
|
||||
if daemon_ready(session) {
|
||||
// Double-check it's actually responsive by waiting and checking again
|
||||
// This handles the race condition where daemon is shutting down
|
||||
// (daemon has a 100ms shutdown delay, so we wait longer)
|
||||
@@ -351,126 +368,54 @@ 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.
|
||||
|
||||
#[allow(unused_assignments)]
|
||||
let mut daemon_child: Option<std::process::Child> = None;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
let mut cmd = Command::new(&exe_path);
|
||||
cmd.env("AGENT_BROWSER_DAEMON", "1");
|
||||
apply_daemon_env(&mut cmd, session, opts);
|
||||
|
||||
unsafe {
|
||||
cmd.pre_exec(|| {
|
||||
libc::setsid();
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
daemon_child = Some(
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start daemon: {}", e))?,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
let exe_path = {
|
||||
let p = exe_path.to_string_lossy();
|
||||
if let Some(stripped) = p.strip_prefix(r"\\?\") {
|
||||
PathBuf::from(stripped)
|
||||
} else {
|
||||
exe_path
|
||||
}
|
||||
};
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
if opts.native {
|
||||
// Native mode: spawn self as daemon (Rust/CDP, no Node.js needed)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
let mut cmd = Command::new(&exe_path);
|
||||
cmd.env("AGENT_BROWSER_DAEMON", "1");
|
||||
apply_daemon_env(&mut cmd, session, opts);
|
||||
|
||||
let mut cmd = Command::new(&exe_path);
|
||||
cmd.env("AGENT_BROWSER_DAEMON", "1");
|
||||
apply_daemon_env(&mut cmd, session, opts);
|
||||
|
||||
unsafe {
|
||||
cmd.pre_exec(|| {
|
||||
libc::setsid();
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.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;
|
||||
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::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(())
|
||||
});
|
||||
}
|
||||
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start daemon: {}", e))?;
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
// Use node.exe explicitly to avoid Git Bash/MSYS2 shell wrapper resolution
|
||||
let mut cmd = Command::new("node.exe");
|
||||
cmd.arg(daemon_path)
|
||||
.env("MSYS_NO_PATHCONV", "1")
|
||||
.env("MSYS2_ARG_CONV_EXCL", "*");
|
||||
apply_daemon_env(&mut cmd, session, opts);
|
||||
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
||||
const DETACHED_PROCESS: u32 = 0x00000008;
|
||||
|
||||
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start daemon: {}", e))?;
|
||||
}
|
||||
.map_err(|e| format!("Failed to start daemon: {}", e))?,
|
||||
);
|
||||
}
|
||||
|
||||
for _ in 0..50 {
|
||||
@@ -479,21 +424,45 @@ pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult
|
||||
already_running: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Detect early daemon exit and surface the real error from stderr
|
||||
if let Some(ref mut child) = daemon_child {
|
||||
if let Ok(Some(_)) = child.try_wait() {
|
||||
let mut stderr_output = String::new();
|
||||
if let Some(mut stderr) = child.stderr.take() {
|
||||
let _ = stderr.read_to_string(&mut stderr_output);
|
||||
}
|
||||
let stderr_trimmed = stderr_output.trim();
|
||||
if !stderr_trimmed.is_empty() {
|
||||
let msg = if stderr_trimmed.len() > 500 {
|
||||
let mut end = 500;
|
||||
while !stderr_trimmed.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
&stderr_trimmed[..end]
|
||||
} else {
|
||||
stderr_trimmed
|
||||
};
|
||||
return Err(format!("Daemon process exited during startup:\n{}", msg));
|
||||
}
|
||||
return Err(
|
||||
"Daemon process exited during startup with no error output. \
|
||||
Re-run with --debug for more details."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
let endpoint_info = format!(
|
||||
"socket: {}",
|
||||
get_socket_dir()
|
||||
.join(format!("{}.sock", session))
|
||||
.display()
|
||||
get_socket_dir().join(format!("{}.sock", session)).display()
|
||||
);
|
||||
#[cfg(windows)]
|
||||
let endpoint_info = format!(
|
||||
"port: 127.0.0.1:{}",
|
||||
get_port_for_session(session)
|
||||
);
|
||||
let endpoint_info = format!("port: 127.0.0.1:{}", resolve_port(session));
|
||||
|
||||
Err(format!("Daemon failed to start ({})", endpoint_info))
|
||||
}
|
||||
@@ -508,7 +477,7 @@ fn connect(session: &str) -> Result<Connection, String> {
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let port = get_port_for_session(session);
|
||||
let port = resolve_port(session);
|
||||
TcpStream::connect(format!("127.0.0.1:{}", port))
|
||||
.map(Connection::Tcp)
|
||||
.map_err(|e| format!("Failed to connect: {}", e))
|
||||
@@ -566,6 +535,8 @@ fn is_transient_error(error: &str) -> bool {
|
||||
|| error.contains("os error 2") // No such file or directory (socket gone)
|
||||
|| error.contains("os error 61") // Connection refused (macOS)
|
||||
|| error.contains("os error 111") // Connection refused (Linux)
|
||||
|| error.contains("os error 10061") // Connection refused (Windows)
|
||||
|| error.contains("os error 10054") // Connection reset by peer (Windows)
|
||||
}
|
||||
|
||||
fn send_command_once(cmd: &Value, session: &str) -> Result<Response, String> {
|
||||
@@ -593,45 +564,14 @@ fn send_command_once(cmd: &Value, session: &str) -> Result<Response, String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
// Mutex to prevent parallel tests from interfering with env vars
|
||||
static ENV_MUTEX: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// RAII guard that locks env mutex and restores env vars on drop
|
||||
struct EnvGuard<'a> {
|
||||
_lock: MutexGuard<'a, ()>,
|
||||
vars: Vec<(String, Option<String>)>,
|
||||
}
|
||||
|
||||
impl<'a> EnvGuard<'a> {
|
||||
fn new(var_names: &[&str]) -> Self {
|
||||
let lock = ENV_MUTEX.lock().unwrap();
|
||||
let vars = var_names
|
||||
.iter()
|
||||
.map(|&name| (name.to_string(), env::var(name).ok()))
|
||||
.collect();
|
||||
Self { _lock: lock, vars }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
for (name, value) in &self.vars {
|
||||
match value {
|
||||
Some(v) => env::set_var(name, v),
|
||||
None => env::remove_var(name),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
use crate::test_utils::EnvGuard;
|
||||
|
||||
#[test]
|
||||
fn test_get_socket_dir_explicit_override() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
|
||||
env::set_var("AGENT_BROWSER_SOCKET_DIR", "/custom/socket/path");
|
||||
env::remove_var("XDG_RUNTIME_DIR");
|
||||
_guard.set("AGENT_BROWSER_SOCKET_DIR", "/custom/socket/path");
|
||||
_guard.remove("XDG_RUNTIME_DIR");
|
||||
|
||||
assert_eq!(get_socket_dir(), PathBuf::from("/custom/socket/path"));
|
||||
}
|
||||
@@ -640,8 +580,8 @@ mod tests {
|
||||
fn test_get_socket_dir_ignores_empty_socket_dir() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
|
||||
env::set_var("AGENT_BROWSER_SOCKET_DIR", "");
|
||||
env::remove_var("XDG_RUNTIME_DIR");
|
||||
_guard.set("AGENT_BROWSER_SOCKET_DIR", "");
|
||||
_guard.remove("XDG_RUNTIME_DIR");
|
||||
|
||||
assert!(get_socket_dir()
|
||||
.to_string_lossy()
|
||||
@@ -652,8 +592,8 @@ mod tests {
|
||||
fn test_get_socket_dir_xdg_runtime() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
|
||||
env::remove_var("AGENT_BROWSER_SOCKET_DIR");
|
||||
env::set_var("XDG_RUNTIME_DIR", "/run/user/1000");
|
||||
_guard.remove("AGENT_BROWSER_SOCKET_DIR");
|
||||
_guard.set("XDG_RUNTIME_DIR", "/run/user/1000");
|
||||
|
||||
assert_eq!(
|
||||
get_socket_dir(),
|
||||
@@ -665,8 +605,8 @@ mod tests {
|
||||
fn test_get_socket_dir_ignores_empty_xdg_runtime() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
|
||||
env::set_var("AGENT_BROWSER_SOCKET_DIR", "");
|
||||
env::set_var("XDG_RUNTIME_DIR", "");
|
||||
_guard.set("AGENT_BROWSER_SOCKET_DIR", "");
|
||||
_guard.set("XDG_RUNTIME_DIR", "");
|
||||
|
||||
assert!(get_socket_dir()
|
||||
.to_string_lossy()
|
||||
@@ -677,8 +617,8 @@ mod tests {
|
||||
fn test_get_socket_dir_home_fallback() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
|
||||
env::remove_var("AGENT_BROWSER_SOCKET_DIR");
|
||||
env::remove_var("XDG_RUNTIME_DIR");
|
||||
_guard.remove("AGENT_BROWSER_SOCKET_DIR");
|
||||
_guard.remove("XDG_RUNTIME_DIR");
|
||||
|
||||
let result = get_socket_dir();
|
||||
assert!(result.to_string_lossy().ends_with(".agent-browser"));
|
||||
@@ -772,6 +712,20 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_connection_refused_windows() {
|
||||
assert!(is_transient_error(
|
||||
"Failed to connect: No connection could be made because the target machine actively refused it. (os error 10061)"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_connection_reset_windows() {
|
||||
assert!(is_transient_error(
|
||||
"Failed to send: An existing connection was forcibly closed by the remote host. (os error 10054)"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_non_transient() {
|
||||
// These should NOT be considered transient
|
||||
@@ -780,4 +734,13 @@ mod tests {
|
||||
assert!(!is_transient_error("Permission denied"));
|
||||
assert!(!is_transient_error("Daemon not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn test_get_port_for_session() {
|
||||
assert_eq!(get_port_for_session("default"), 50838);
|
||||
assert_eq!(get_port_for_session("my-session"), 63105);
|
||||
assert_eq!(get_port_for_session("work"), 51184);
|
||||
assert_eq!(get_port_for_session(""), 49152);
|
||||
}
|
||||
}
|
||||
|
||||
+266
-57
@@ -8,12 +8,53 @@ const CONFIG_DIR: &str = ".agent-browser";
|
||||
const CONFIG_FILENAME: &str = "config.json";
|
||||
const PROJECT_CONFIG_FILENAME: &str = "agent-browser.json";
|
||||
|
||||
/// Parse idle timeout from user-friendly format.
|
||||
/// Supports: "10s" (seconds), "3m" (minutes), "1h" (hours), or raw milliseconds.
|
||||
fn parse_idle_timeout(s: &str) -> Result<String, String> {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
return Err("Empty idle timeout".to_string());
|
||||
}
|
||||
|
||||
// If the value ends with a unit suffix, convert it to milliseconds.
|
||||
if s.chars().last().is_some_and(|c| c.is_ascii_alphabetic()) {
|
||||
let (num_str, unit) = s.split_at(s.len() - 1);
|
||||
let num: u64 = num_str.parse().map_err(|_| "Invalid number")?;
|
||||
|
||||
let ms = match unit {
|
||||
"s" => num * 1000,
|
||||
"m" => num * 60 * 1000,
|
||||
"h" => num * 60 * 60 * 1000,
|
||||
_ => return Err("Invalid idle timeout unit (use s, m, h, or raw ms)".to_string()),
|
||||
};
|
||||
return Ok(ms.to_string());
|
||||
}
|
||||
|
||||
// Pure numbers are already expressed in milliseconds.
|
||||
s.parse::<u64>().map_err(|_| "Invalid idle timeout")?;
|
||||
Ok(s.to_string())
|
||||
}
|
||||
|
||||
fn parse_idle_timeout_value(value: Option<String>, source: &str) -> Option<String> {
|
||||
value.and_then(|raw| match parse_idle_timeout(&raw) {
|
||||
Ok(ms) => Some(ms),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{} invalid idle timeout from {}: {}",
|
||||
color::warning_indicator(),
|
||||
source,
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Config {
|
||||
pub headed: Option<bool>,
|
||||
pub json: Option<bool>,
|
||||
pub full: Option<bool>,
|
||||
pub debug: Option<bool>,
|
||||
pub session: Option<String>,
|
||||
pub session_name: Option<String>,
|
||||
@@ -41,7 +82,12 @@ 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>,
|
||||
pub screenshot_format: Option<String>,
|
||||
pub idle_timeout: Option<String>,
|
||||
pub no_auto_dialog: Option<bool>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -49,7 +95,6 @@ impl Config {
|
||||
Config {
|
||||
headed: other.headed.or(self.headed),
|
||||
json: other.json.or(self.json),
|
||||
full: other.full.or(self.full),
|
||||
debug: other.debug.or(self.debug),
|
||||
session: other.session.or(self.session),
|
||||
session_name: other.session_name.or(self.session_name),
|
||||
@@ -83,7 +128,12 @@ 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),
|
||||
screenshot_format: other.screenshot_format.or(self.screenshot_format),
|
||||
idle_timeout: other.idle_timeout.or(self.idle_timeout),
|
||||
no_auto_dialog: other.no_auto_dialog.or(self.no_auto_dialog),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,7 +141,13 @@ impl Config {
|
||||
fn read_config_file(path: &Path) -> Option<Config> {
|
||||
let content = fs::read_to_string(path).ok()?;
|
||||
match serde_json::from_str::<Config>(&content) {
|
||||
Ok(config) => Some(config),
|
||||
Ok(mut config) => {
|
||||
config.idle_timeout = parse_idle_timeout_value(
|
||||
config.idle_timeout.take(),
|
||||
&format!("config file {}", path.display()),
|
||||
);
|
||||
Some(config)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{} invalid config file {}: {}",
|
||||
@@ -158,6 +214,11 @@ fn extract_config_path(args: &[String]) -> Option<Option<String>> {
|
||||
"--allowed-domains",
|
||||
"--action-policy",
|
||||
"--confirm-actions",
|
||||
"--engine",
|
||||
"--screenshot-dir",
|
||||
"--screenshot-quality",
|
||||
"--screenshot-format",
|
||||
"--idle-timeout",
|
||||
];
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
@@ -206,7 +267,6 @@ pub fn load_config(args: &[String]) -> Result<Config, String> {
|
||||
|
||||
pub struct Flags {
|
||||
pub json: bool,
|
||||
pub full: bool,
|
||||
pub headed: bool,
|
||||
pub debug: bool,
|
||||
pub session: String,
|
||||
@@ -225,6 +285,7 @@ pub struct Flags {
|
||||
pub allow_file_access: bool,
|
||||
pub device: Option<String>,
|
||||
pub auto_connect: bool,
|
||||
pub force_launch: bool,
|
||||
pub session_name: Option<String>,
|
||||
pub annotate: bool,
|
||||
pub color_scheme: Option<String>,
|
||||
@@ -235,7 +296,12 @@ 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>,
|
||||
pub screenshot_format: Option<String>,
|
||||
pub idle_timeout: Option<String>, // Canonical milliseconds string for AGENT_BROWSER_IDLE_TIMEOUT_MS
|
||||
pub no_auto_dialog: bool,
|
||||
|
||||
// Track which launch-time options were explicitly passed via CLI
|
||||
// (as opposed to being set only via environment variables)
|
||||
@@ -250,6 +316,7 @@ pub struct Flags {
|
||||
pub cli_allow_file_access: bool,
|
||||
pub cli_annotate: bool,
|
||||
pub cli_download_path: bool,
|
||||
pub cli_headed: bool,
|
||||
}
|
||||
|
||||
pub fn parse_flags(args: &[String]) -> Flags {
|
||||
@@ -276,7 +343,6 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
|
||||
let mut flags = Flags {
|
||||
json: env_var_is_truthy("AGENT_BROWSER_JSON") || config.json.unwrap_or(false),
|
||||
full: env_var_is_truthy("AGENT_BROWSER_FULL") || config.full.unwrap_or(false),
|
||||
headed: env_var_is_truthy("AGENT_BROWSER_HEADED") || config.headed.unwrap_or(false),
|
||||
debug: env_var_is_truthy("AGENT_BROWSER_DEBUG") || config.debug.unwrap_or(false),
|
||||
session: env::var("AGENT_BROWSER_SESSION")
|
||||
@@ -291,10 +357,20 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
extensions,
|
||||
profile: env::var("AGENT_BROWSER_PROFILE").ok().or(config.profile),
|
||||
state: env::var("AGENT_BROWSER_STATE").ok().or(config.state),
|
||||
proxy: env::var("AGENT_BROWSER_PROXY").ok().or(config.proxy),
|
||||
proxy: env::var("AGENT_BROWSER_PROXY")
|
||||
.ok()
|
||||
.or(config.proxy)
|
||||
.or_else(|| env::var("HTTP_PROXY").ok())
|
||||
.or_else(|| env::var("http_proxy").ok())
|
||||
.or_else(|| env::var("HTTPS_PROXY").ok())
|
||||
.or_else(|| env::var("https_proxy").ok())
|
||||
.or_else(|| env::var("ALL_PROXY").ok())
|
||||
.or_else(|| env::var("all_proxy").ok()),
|
||||
proxy_bypass: env::var("AGENT_BROWSER_PROXY_BYPASS")
|
||||
.ok()
|
||||
.or(config.proxy_bypass),
|
||||
.or(config.proxy_bypass)
|
||||
.or_else(|| env::var("NO_PROXY").ok())
|
||||
.or_else(|| env::var("no_proxy").ok()),
|
||||
args: env::var("AGENT_BROWSER_ARGS").ok().or(config.args),
|
||||
user_agent: env::var("AGENT_BROWSER_USER_AGENT")
|
||||
.ok()
|
||||
@@ -305,8 +381,11 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
allow_file_access: env_var_is_truthy("AGENT_BROWSER_ALLOW_FILE_ACCESS")
|
||||
|| config.allow_file_access.unwrap_or(false),
|
||||
device: env::var("AGENT_BROWSER_IOS_DEVICE").ok().or(config.device),
|
||||
auto_connect: env_var_is_truthy("AGENT_BROWSER_AUTO_CONNECT")
|
||||
|| config.auto_connect.unwrap_or(false),
|
||||
auto_connect: !env_var_is_truthy("AGENT_BROWSER_NO_AUTO_CONNECT")
|
||||
&& (env_var_is_truthy("AGENT_BROWSER_AUTO_CONNECT")
|
||||
|| config.auto_connect.unwrap_or(true)),
|
||||
force_launch: env_var_is_truthy("AGENT_BROWSER_FORCE_LAUNCH")
|
||||
|| env::var("CI").is_ok(),
|
||||
session_name: env::var("AGENT_BROWSER_SESSION_NAME")
|
||||
.ok()
|
||||
.or(config.session_name),
|
||||
@@ -340,7 +419,25 @@ 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()
|
||||
.or(config.screenshot_dir),
|
||||
screenshot_quality: env::var("AGENT_BROWSER_SCREENSHOT_QUALITY")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or(config.screenshot_quality),
|
||||
screenshot_format: env::var("AGENT_BROWSER_SCREENSHOT_FORMAT")
|
||||
.ok()
|
||||
.or(config.screenshot_format)
|
||||
.filter(|s| s == "png" || s == "jpeg"),
|
||||
idle_timeout: parse_idle_timeout_value(
|
||||
env::var("AGENT_BROWSER_IDLE_TIMEOUT_MS").ok(),
|
||||
"AGENT_BROWSER_IDLE_TIMEOUT_MS",
|
||||
)
|
||||
.or(config.idle_timeout),
|
||||
no_auto_dialog: env_var_is_truthy("AGENT_BROWSER_NO_AUTO_DIALOG")
|
||||
|| config.no_auto_dialog.unwrap_or(false),
|
||||
cli_executable_path: false,
|
||||
cli_extensions: false,
|
||||
cli_profile: false,
|
||||
@@ -352,6 +449,7 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
cli_allow_file_access: false,
|
||||
cli_annotate: false,
|
||||
cli_download_path: false,
|
||||
cli_headed: false,
|
||||
};
|
||||
|
||||
let mut i = 0;
|
||||
@@ -364,16 +462,10 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--full" | "-f" => {
|
||||
let (val, consumed) = parse_bool_arg(args, i);
|
||||
flags.full = val;
|
||||
if consumed {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--headed" => {
|
||||
let (val, consumed) = parse_bool_arg(args, i);
|
||||
flags.headed = val;
|
||||
flags.cli_headed = true;
|
||||
if consumed {
|
||||
i += 1;
|
||||
}
|
||||
@@ -391,6 +483,19 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--idle-timeout" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
match parse_idle_timeout(s) {
|
||||
Ok(ms) => flags.idle_timeout = Some(ms),
|
||||
Err(e) => eprintln!(
|
||||
"{} Invalid --idle-timeout: {}",
|
||||
color::warning_indicator(),
|
||||
e
|
||||
),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--headers" => {
|
||||
if let Some(h) = args.get(i + 1) {
|
||||
flags.headers = Some(h.clone());
|
||||
@@ -489,10 +594,17 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
"--auto-connect" => {
|
||||
let (val, consumed) = parse_bool_arg(args, i);
|
||||
flags.auto_connect = val;
|
||||
if !val {
|
||||
flags.force_launch = true;
|
||||
}
|
||||
if consumed {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--launch" | "--new" => {
|
||||
flags.force_launch = true;
|
||||
flags.auto_connect = false;
|
||||
}
|
||||
"--session-name" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.session_name = Some(s.clone());
|
||||
@@ -565,9 +677,51 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--native" => {
|
||||
"--engine" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.engine = Some(s.clone());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--screenshot-dir" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.screenshot_dir = Some(s.clone());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--screenshot-quality" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
if let Ok(n) = s.parse::<u32>() {
|
||||
if n <= 100 {
|
||||
flags.screenshot_quality = Some(n);
|
||||
} else {
|
||||
eprintln!(
|
||||
"{} --screenshot-quality must be 0-100, got {}",
|
||||
color::warning_indicator(),
|
||||
n
|
||||
);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--screenshot-format" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
if s == "png" || s == "jpeg" {
|
||||
flags.screenshot_format = Some(s.clone());
|
||||
} else {
|
||||
eprintln!(
|
||||
"{} --screenshot-format must be png or jpeg, got '{}'",
|
||||
color::warning_indicator(),
|
||||
s
|
||||
);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--no-auto-dialog" => {
|
||||
let (val, consumed) = parse_bool_arg(args, i);
|
||||
flags.native = val;
|
||||
flags.no_auto_dialog = val;
|
||||
if consumed {
|
||||
i += 1;
|
||||
}
|
||||
@@ -590,16 +744,17 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
// Boolean flags that optionally take true/false
|
||||
const GLOBAL_BOOL_FLAGS: &[&str] = &[
|
||||
"--json",
|
||||
"--full",
|
||||
"--headed",
|
||||
"--debug",
|
||||
"--ignore-https-errors",
|
||||
"--allow-file-access",
|
||||
"--auto-connect",
|
||||
"--launch",
|
||||
"--new",
|
||||
"--annotate",
|
||||
"--content-boundaries",
|
||||
"--confirm-interactive",
|
||||
"--native",
|
||||
"--no-auto-dialog",
|
||||
];
|
||||
// Global flags that always take a value (need to skip the next arg too)
|
||||
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &[
|
||||
@@ -625,6 +780,11 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
"--action-policy",
|
||||
"--confirm-actions",
|
||||
"--config",
|
||||
"--engine",
|
||||
"--screenshot-dir",
|
||||
"--screenshot-quality",
|
||||
"--screenshot-format",
|
||||
"--idle-timeout",
|
||||
];
|
||||
|
||||
let mut i = 0;
|
||||
@@ -640,7 +800,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if GLOBAL_BOOL_FLAGS.contains(&arg.as_str()) || arg == "-f" {
|
||||
if GLOBAL_BOOL_FLAGS.contains(&arg.as_str()) {
|
||||
if let Some(v) = args.get(i + 1) {
|
||||
if matches!(v.as_str(), "true" | "false") {
|
||||
i += 1;
|
||||
@@ -669,6 +829,36 @@ mod tests {
|
||||
assert_eq!(flags.headers, Some(r#"{"Auth":"token"}"#.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_idle_timeout_raw_ms() {
|
||||
assert_eq!(parse_idle_timeout("10").unwrap(), "10");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_idle_timeout_seconds() {
|
||||
assert_eq!(parse_idle_timeout("10s").unwrap(), "10000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_idle_timeout_minutes() {
|
||||
assert_eq!(parse_idle_timeout("3m").unwrap(), "180000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_idle_timeout_hours() {
|
||||
assert_eq!(parse_idle_timeout("1h").unwrap(), "3600000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_idle_timeout_rejects_capital_m() {
|
||||
assert!(parse_idle_timeout("10M").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_idle_timeout_rejects_unknown_unit() {
|
||||
assert!(parse_idle_timeout("10x").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_headers_flag_with_spaces() {
|
||||
// Headers JSON is passed as a single quoted argument in shell
|
||||
@@ -764,6 +954,18 @@ mod tests {
|
||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_args_removes_idle_timeout_before_command() {
|
||||
let cleaned = clean_args(&args("--idle-timeout 10s open example.com"));
|
||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_idle_timeout_flag_converts_to_ms() {
|
||||
let flags = parse_flags(&args("--idle-timeout 10s open example.com"));
|
||||
assert_eq!(flags.idle_timeout.as_deref(), Some("10000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_flags_with_session_and_executable_path() {
|
||||
let flags = parse_flags(&args(
|
||||
@@ -846,7 +1048,6 @@ mod tests {
|
||||
let json = r#"{
|
||||
"headed": true,
|
||||
"json": true,
|
||||
"full": true,
|
||||
"debug": true,
|
||||
"session": "test-session",
|
||||
"sessionName": "my-app",
|
||||
@@ -869,7 +1070,6 @@ mod tests {
|
||||
let config: Config = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.headed, Some(true));
|
||||
assert_eq!(config.json, Some(true));
|
||||
assert_eq!(config.full, Some(true));
|
||||
assert_eq!(config.debug, Some(true));
|
||||
assert_eq!(config.session.as_deref(), Some("test-session"));
|
||||
assert_eq!(config.session_name.as_deref(), Some("my-app"));
|
||||
@@ -969,6 +1169,22 @@ mod tests {
|
||||
let _ = fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_config_from_file_parses_idle_timeout() {
|
||||
use std::io::Write;
|
||||
let dir = std::env::temp_dir().join("ab-test-idle-timeout-config");
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
let config_path = dir.join("test-config.json");
|
||||
let mut f = fs::File::create(&config_path).unwrap();
|
||||
writeln!(f, r#"{{"idleTimeout": "10s"}}"#).unwrap();
|
||||
|
||||
let config = read_config_file(&config_path).unwrap();
|
||||
assert_eq!(config.idle_timeout.as_deref(), Some("10000"));
|
||||
|
||||
let _ = fs::remove_file(&config_path);
|
||||
let _ = fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_config_missing_file_returns_none() {
|
||||
let result = read_config_file(&PathBuf::from("/nonexistent/agent-browser.json"));
|
||||
@@ -1129,36 +1345,6 @@ mod tests {
|
||||
assert!(!flags.auto_connect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_bare_defaults_true() {
|
||||
let flags = parse_flags(&args("--full open example.com"));
|
||||
assert!(flags.full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_false() {
|
||||
let flags = parse_flags(&args("--full false open example.com"));
|
||||
assert!(!flags.full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_short_flag() {
|
||||
let flags = parse_flags(&args("-f open example.com"));
|
||||
assert!(flags.full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_args_removes_full_with_value() {
|
||||
let cleaned = clean_args(&args("--full false open example.com"));
|
||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_args_removes_short_full() {
|
||||
let cleaned = clean_args(&args("-f open example.com"));
|
||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_args_removes_bool_flag_with_value() {
|
||||
let cleaned = clean_args(&args("--headed false --debug true open example.com"));
|
||||
@@ -1215,4 +1401,27 @@ mod tests {
|
||||
let merged = user.merge(project);
|
||||
assert_eq!(merged.extensions, Some(vec!["/ext2".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_auto_dialog_flag() {
|
||||
let flags = parse_flags(&args("open example.com --no-auto-dialog"));
|
||||
assert!(flags.no_auto_dialog);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_auto_dialog_default_false() {
|
||||
let flags = parse_flags(&args("open example.com"));
|
||||
assert!(!flags.no_auto_dialog);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_args_removes_no_auto_dialog() {
|
||||
let input: Vec<String> = vec![
|
||||
"open".to_string(),
|
||||
"example.com".to_string(),
|
||||
"--no-auto-dialog".to_string(),
|
||||
];
|
||||
let clean = clean_args(&input);
|
||||
assert_eq!(clean, vec!["open", "example.com"]);
|
||||
}
|
||||
}
|
||||
|
||||
+757
-157
@@ -1,167 +1,416 @@
|
||||
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();
|
||||
let debug = std::env::var("AGENT_BROWSER_DEBUG").is_ok();
|
||||
|
||||
if debug {
|
||||
let _ = writeln!(
|
||||
io::stderr(),
|
||||
"[chrome-search] home_dir={:?} browsers_dir={}",
|
||||
dirs::home_dir(),
|
||||
browsers_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
if !browsers_dir.exists() {
|
||||
if debug {
|
||||
let _ = writeln!(io::stderr(), "[chrome-search] browsers_dir does not exist");
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
let entries = match fs::read_dir(&browsers_dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
let _ = writeln!(
|
||||
io::stderr(),
|
||||
"Warning: cannot read Chrome cache directory {}: {}",
|
||||
browsers_dir.display(),
|
||||
e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let mut versions: Vec<_> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
let matches = e
|
||||
.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|n| n.starts_with("chrome-"));
|
||||
if debug {
|
||||
let _ = writeln!(
|
||||
io::stderr(),
|
||||
"[chrome-search] entry {:?} matches={}",
|
||||
e.file_name(),
|
||||
matches
|
||||
);
|
||||
}
|
||||
matches
|
||||
})
|
||||
.collect();
|
||||
|
||||
versions.sort_by_key(|b| std::cmp::Reverse(b.file_name()));
|
||||
|
||||
for entry in versions {
|
||||
let dir = entry.path();
|
||||
if let Some(bin) = chrome_binary_in_dir(&dir) {
|
||||
let exists = bin.exists();
|
||||
if debug {
|
||||
let _ = writeln!(
|
||||
io::stderr(),
|
||||
"[chrome-search] candidate {} exists={}",
|
||||
bin.display(),
|
||||
exists
|
||||
);
|
||||
}
|
||||
if exists {
|
||||
return Some(bin);
|
||||
}
|
||||
} else if debug {
|
||||
let _ = writeln!(
|
||||
io::stderr(),
|
||||
"[chrome-search] no binary found in {}",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if debug {
|
||||
let _ = writeln!(io::stderr(), "[chrome-search] no installed Chrome found");
|
||||
}
|
||||
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();
|
||||
// Strip the top-level "chrome-<platform>/" directory from zip entries.
|
||||
// On Windows, enclosed_name() normalizes paths to backslashes, so we
|
||||
// must split on either separator.
|
||||
let rel_path = raw_name
|
||||
.strip_prefix("chrome-")
|
||||
.and_then(|s| s.find(['/', '\\']).map(|i| &s[i + 1..]))
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or(raw_name.clone());
|
||||
|
||||
if rel_path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let out_path = dest.join(&rel_path);
|
||||
|
||||
// Defense-in-depth: ensure the resolved path is inside dest
|
||||
if !out_path.starts_with(dest) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if file.is_dir() {
|
||||
fs::create_dir_all(&out_path)
|
||||
.map_err(|e| format!("Failed to create dir {}: {}", out_path.display(), e))?;
|
||||
} else {
|
||||
if let Some(parent) = out_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| {
|
||||
format!("Failed to create parent dir {}: {}", parent.display(), e)
|
||||
})?;
|
||||
}
|
||||
let mut out_file = fs::File::create(&out_path)
|
||||
.map_err(|e| format!("Failed to create file {}: {}", out_path.display(), e))?;
|
||||
io::copy(&mut file, &mut out_file)
|
||||
.map_err(|e| format!("Failed to write {}: {}", out_path.display(), e))?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Some(mode) = file.unix_mode() {
|
||||
let _ = fs::set_permissions(&out_path, fs::Permissions::from_mode(mode));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_install(with_deps: bool) {
|
||||
if cfg!(all(target_os = "linux", target_arch = "aarch64")) {
|
||||
eprintln!(
|
||||
"{} Chrome for Testing does not provide Linux ARM64 builds.",
|
||||
color::error_indicator()
|
||||
);
|
||||
eprintln!(" Install Chromium from your system package manager instead:");
|
||||
eprintln!(" sudo apt install chromium-browser # Debian/Ubuntu");
|
||||
eprintln!(" sudo dnf install chromium # Fedora");
|
||||
eprintln!(" Then use: agent-browser --executable-path /usr/bin/chromium");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
let is_linux = cfg!(target_os = "linux");
|
||||
|
||||
if is_linux {
|
||||
if with_deps {
|
||||
println!("{}", color::cyan("Installing system dependencies..."));
|
||||
|
||||
let (pkg_mgr, deps) = if which_exists("apt-get") {
|
||||
let libasound = if package_exists_apt("libasound2t64") {
|
||||
"libasound2t64"
|
||||
} else {
|
||||
"libasound2"
|
||||
};
|
||||
|
||||
(
|
||||
"apt-get",
|
||||
vec![
|
||||
"libxcb-shm0",
|
||||
"libx11-xcb1",
|
||||
"libx11-6",
|
||||
"libxcb1",
|
||||
"libxext6",
|
||||
"libxrandr2",
|
||||
"libxcomposite1",
|
||||
"libxcursor1",
|
||||
"libxdamage1",
|
||||
"libxfixes3",
|
||||
"libxi6",
|
||||
"libgtk-3-0",
|
||||
"libpangocairo-1.0-0",
|
||||
"libpango-1.0-0",
|
||||
"libatk1.0-0",
|
||||
"libcairo-gobject2",
|
||||
"libcairo2",
|
||||
"libgdk-pixbuf-2.0-0",
|
||||
"libxrender1",
|
||||
libasound,
|
||||
"libfreetype6",
|
||||
"libfontconfig1",
|
||||
"libdbus-1-3",
|
||||
"libnss3",
|
||||
"libnspr4",
|
||||
"libatk-bridge2.0-0",
|
||||
"libdrm2",
|
||||
"libxkbcommon0",
|
||||
"libatspi2.0-0",
|
||||
"libcups2",
|
||||
"libxshmfence1",
|
||||
"libgbm1",
|
||||
],
|
||||
)
|
||||
} else if which_exists("dnf") {
|
||||
(
|
||||
"dnf",
|
||||
vec![
|
||||
"nss",
|
||||
"nspr",
|
||||
"atk",
|
||||
"at-spi2-atk",
|
||||
"cups-libs",
|
||||
"libdrm",
|
||||
"libXcomposite",
|
||||
"libXdamage",
|
||||
"libXrandr",
|
||||
"mesa-libgbm",
|
||||
"pango",
|
||||
"alsa-lib",
|
||||
"libxkbcommon",
|
||||
"libxcb",
|
||||
"libX11-xcb",
|
||||
"libX11",
|
||||
"libXext",
|
||||
"libXcursor",
|
||||
"libXfixes",
|
||||
"libXi",
|
||||
"gtk3",
|
||||
"cairo-gobject",
|
||||
],
|
||||
)
|
||||
} else if which_exists("yum") {
|
||||
(
|
||||
"yum",
|
||||
vec![
|
||||
"nss",
|
||||
"nspr",
|
||||
"atk",
|
||||
"at-spi2-atk",
|
||||
"cups-libs",
|
||||
"libdrm",
|
||||
"libXcomposite",
|
||||
"libXdamage",
|
||||
"libXrandr",
|
||||
"mesa-libgbm",
|
||||
"pango",
|
||||
"alsa-lib",
|
||||
"libxkbcommon",
|
||||
],
|
||||
)
|
||||
} else {
|
||||
eprintln!(
|
||||
"{} No supported package manager found (apt-get, dnf, or yum)",
|
||||
color::error_indicator()
|
||||
);
|
||||
exit(1);
|
||||
};
|
||||
|
||||
let install_cmd = match pkg_mgr {
|
||||
"apt-get" => {
|
||||
format!(
|
||||
"sudo apt-get update && sudo apt-get install -y {}",
|
||||
deps.join(" ")
|
||||
)
|
||||
}
|
||||
_ => format!("sudo {} install -y {}", pkg_mgr, deps.join(" ")),
|
||||
};
|
||||
|
||||
println!("Running: {}", install_cmd);
|
||||
let status = Command::new("sh").arg("-c").arg(&install_cmd).status();
|
||||
|
||||
match status {
|
||||
Ok(s) if s.success() => {
|
||||
println!("{} System dependencies installed", color::success_indicator())
|
||||
}
|
||||
Ok(_) => eprintln!(
|
||||
"{} Failed to install some dependencies. You may need to run manually with sudo.",
|
||||
color::warning_indicator()
|
||||
),
|
||||
Err(e) => eprintln!("{} Could not run install command: {}", color::warning_indicator(), e),
|
||||
}
|
||||
install_linux_deps();
|
||||
} else {
|
||||
println!(
|
||||
"{} Linux detected. If browser fails to launch, run:",
|
||||
color::warning_indicator()
|
||||
);
|
||||
println!(" agent-browser install --with-deps");
|
||||
println!(" or: npx playwright install-deps chromium");
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
println!("{}", color::cyan("Installing Chromium browser..."));
|
||||
println!("{}", color::cyan("Installing Chrome..."));
|
||||
|
||||
// On Windows, we need to use cmd.exe to run npx because npx is actually npx.cmd
|
||||
// and Command::new() doesn't resolve .cmd files the way the shell does.
|
||||
// Pass the entire command as a single string to /c to handle paths with spaces.
|
||||
#[cfg(windows)]
|
||||
let status = Command::new("cmd")
|
||||
.args(["/c", "npx playwright install chromium"])
|
||||
.status();
|
||||
|
||||
#[cfg(not(windows))]
|
||||
let status = Command::new("npx")
|
||||
.args(["playwright", "install", "chromium"])
|
||||
.status();
|
||||
|
||||
match status {
|
||||
Ok(s) if s.success() => {
|
||||
println!(
|
||||
"{} Chromium installed successfully",
|
||||
color::success_indicator()
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!(
|
||||
"{} Failed to create runtime: {}",
|
||||
color::error_indicator(),
|
||||
e
|
||||
);
|
||||
exit(1);
|
||||
});
|
||||
|
||||
let (version, url) = match rt.block_on(fetch_download_url()) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("{} {}", color::error_indicator(), e);
|
||||
exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let dest = get_browsers_dir().join(format!("chrome-{}", version));
|
||||
|
||||
if let Some(bin) = chrome_binary_in_dir(&dest) {
|
||||
if bin.exists() {
|
||||
println!(
|
||||
"{} Chrome {} is already installed",
|
||||
color::success_indicator(),
|
||||
version
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
println!(" Downloading Chrome {} for {}", version, platform_key());
|
||||
println!(" {}", url);
|
||||
|
||||
let bytes = match rt.block_on(download_bytes(&url)) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
eprintln!("{} {}", color::error_indicator(), e);
|
||||
exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
match extract_zip(bytes, &dest) {
|
||||
Ok(()) => {
|
||||
println!(
|
||||
"{} Chrome {} installed successfully",
|
||||
color::success_indicator(),
|
||||
version
|
||||
);
|
||||
println!(" Location: {}", dest.display());
|
||||
|
||||
if is_linux && !with_deps {
|
||||
println!();
|
||||
println!(
|
||||
@@ -171,25 +420,255 @@ pub fn run_install(with_deps: bool) {
|
||||
println!(" agent-browser install --with-deps");
|
||||
}
|
||||
}
|
||||
Ok(_) => {
|
||||
eprintln!("{} Failed to install browser", color::error_indicator());
|
||||
if is_linux {
|
||||
println!(
|
||||
"{} Try installing system dependencies first:",
|
||||
color::yellow("Tip:")
|
||||
);
|
||||
println!(" agent-browser install --with-deps");
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("{} Failed to run npx: {}", color::error_indicator(), e);
|
||||
eprintln!("Make sure Node.js is installed and npx is in your PATH");
|
||||
let _ = fs::remove_dir_all(&dest);
|
||||
eprintln!("{} {}", color::error_indicator(), e);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn report_install_status(status: io::Result<std::process::ExitStatus>) {
|
||||
match status {
|
||||
Ok(s) if s.success() => {
|
||||
println!(
|
||||
"{} System dependencies installed",
|
||||
color::success_indicator()
|
||||
)
|
||||
}
|
||||
Ok(_) => eprintln!(
|
||||
"{} Failed to install some dependencies. You may need to run manually with sudo.",
|
||||
color::warning_indicator()
|
||||
),
|
||||
Err(e) => eprintln!(
|
||||
"{} Could not run install command: {}",
|
||||
color::warning_indicator(),
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn install_linux_deps() {
|
||||
println!("{}", color::cyan("Installing system dependencies..."));
|
||||
|
||||
let (pkg_mgr, deps) = if which_exists("apt-get") {
|
||||
// On Ubuntu 24.04+, many libraries were renamed with a t64 suffix as
|
||||
// part of the 64-bit time_t transition. Using the old names can cause
|
||||
// apt to propose removing hundreds of system packages to resolve
|
||||
// conflicts. We check for the t64 variant first to avoid this.
|
||||
let apt_deps: Vec<&str> = vec![
|
||||
("libxcb-shm0", None),
|
||||
("libx11-xcb1", None),
|
||||
("libx11-6", None),
|
||||
("libxcb1", None),
|
||||
("libxext6", None),
|
||||
("libxrandr2", None),
|
||||
("libxcomposite1", None),
|
||||
("libxcursor1", None),
|
||||
("libxdamage1", None),
|
||||
("libxfixes3", None),
|
||||
("libxi6", None),
|
||||
("libgtk-3-0", Some("libgtk-3-0t64")),
|
||||
("libpangocairo-1.0-0", Some("libpangocairo-1.0-0t64")),
|
||||
("libpango-1.0-0", Some("libpango-1.0-0t64")),
|
||||
("libatk1.0-0", Some("libatk1.0-0t64")),
|
||||
("libcairo-gobject2", Some("libcairo-gobject2t64")),
|
||||
("libcairo2", Some("libcairo2t64")),
|
||||
("libgdk-pixbuf-2.0-0", Some("libgdk-pixbuf-2.0-0t64")),
|
||||
("libxrender1", None),
|
||||
("libasound2", Some("libasound2t64")),
|
||||
("libfreetype6", None),
|
||||
("libfontconfig1", None),
|
||||
("libdbus-1-3", Some("libdbus-1-3t64")),
|
||||
("libnss3", None),
|
||||
("libnspr4", None),
|
||||
("libatk-bridge2.0-0", Some("libatk-bridge2.0-0t64")),
|
||||
("libdrm2", None),
|
||||
("libxkbcommon0", None),
|
||||
("libatspi2.0-0", Some("libatspi2.0-0t64")),
|
||||
("libcups2", Some("libcups2t64")),
|
||||
("libxshmfence1", None),
|
||||
("libgbm1", None),
|
||||
// Fonts: without actual font files, pages render with missing glyphs
|
||||
// (tofu). This is especially visible for CJK and emoji characters.
|
||||
("fonts-noto-color-emoji", None),
|
||||
("fonts-noto-cjk", None),
|
||||
("fonts-freefont-ttf", None),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(base, t64_variant)| {
|
||||
if let Some(t64) = t64_variant {
|
||||
if package_exists_apt(t64) {
|
||||
return t64;
|
||||
}
|
||||
}
|
||||
base
|
||||
})
|
||||
.collect();
|
||||
|
||||
("apt-get", apt_deps)
|
||||
} else if which_exists("dnf") {
|
||||
(
|
||||
"dnf",
|
||||
vec![
|
||||
"nss",
|
||||
"nspr",
|
||||
"atk",
|
||||
"at-spi2-atk",
|
||||
"cups-libs",
|
||||
"libdrm",
|
||||
"libXcomposite",
|
||||
"libXdamage",
|
||||
"libXrandr",
|
||||
"mesa-libgbm",
|
||||
"pango",
|
||||
"alsa-lib",
|
||||
"libxkbcommon",
|
||||
"libxcb",
|
||||
"libX11-xcb",
|
||||
"libX11",
|
||||
"libXext",
|
||||
"libXcursor",
|
||||
"libXfixes",
|
||||
"libXi",
|
||||
"gtk3",
|
||||
"cairo-gobject",
|
||||
// Fonts
|
||||
"google-noto-cjk-fonts",
|
||||
"google-noto-emoji-color-fonts",
|
||||
"liberation-fonts",
|
||||
],
|
||||
)
|
||||
} else if which_exists("yum") {
|
||||
(
|
||||
"yum",
|
||||
vec![
|
||||
"nss",
|
||||
"nspr",
|
||||
"atk",
|
||||
"at-spi2-atk",
|
||||
"cups-libs",
|
||||
"libdrm",
|
||||
"libXcomposite",
|
||||
"libXdamage",
|
||||
"libXrandr",
|
||||
"mesa-libgbm",
|
||||
"pango",
|
||||
"alsa-lib",
|
||||
"libxkbcommon",
|
||||
// Fonts
|
||||
"google-noto-cjk-fonts",
|
||||
"liberation-fonts",
|
||||
],
|
||||
)
|
||||
} else {
|
||||
eprintln!(
|
||||
"{} No supported package manager found (apt-get, dnf, or yum)",
|
||||
color::error_indicator()
|
||||
);
|
||||
exit(1);
|
||||
};
|
||||
|
||||
if pkg_mgr == "apt-get" {
|
||||
// Run apt-get update first
|
||||
println!("Running: sudo apt-get update");
|
||||
let update_status = Command::new("sudo").args(["apt-get", "update"]).status();
|
||||
|
||||
match update_status {
|
||||
Ok(s) if !s.success() => {
|
||||
eprintln!(
|
||||
"{} apt-get update failed. Continuing with existing package lists.",
|
||||
color::warning_indicator()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{} Could not run apt-get update: {}",
|
||||
color::warning_indicator(),
|
||||
e
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Simulate the install first to detect if apt would remove any
|
||||
// packages. This prevents the catastrophic scenario where installing
|
||||
// these libraries triggers removal of hundreds of system packages
|
||||
// due to dependency conflicts (e.g. on Ubuntu 24.04 with the
|
||||
// t64 transition).
|
||||
println!("Checking for conflicts...");
|
||||
let sim_output = Command::new("sudo")
|
||||
.args(["apt-get", "install", "--simulate"])
|
||||
.args(&deps)
|
||||
.output();
|
||||
|
||||
match sim_output {
|
||||
Ok(output) => {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let combined = format!("{}\n{}", stdout, stderr);
|
||||
|
||||
// Count packages that would be removed
|
||||
let removals: Vec<&str> = combined
|
||||
.lines()
|
||||
.filter(|line| line.starts_with("Remv "))
|
||||
.collect();
|
||||
|
||||
if !removals.is_empty() {
|
||||
eprintln!(
|
||||
"{} Aborting: apt would remove {} package(s) to install these dependencies.",
|
||||
color::error_indicator(),
|
||||
removals.len()
|
||||
);
|
||||
eprintln!(
|
||||
" This usually means some package names have changed on your system"
|
||||
);
|
||||
eprintln!(" (e.g. Ubuntu 24.04 renamed libraries with a t64 suffix).");
|
||||
eprintln!();
|
||||
eprintln!(" Packages that would be removed:");
|
||||
for line in removals.iter().take(20) {
|
||||
eprintln!(" {}", line);
|
||||
}
|
||||
if removals.len() > 20 {
|
||||
eprintln!(" ... and {} more", removals.len() - 20);
|
||||
}
|
||||
eprintln!();
|
||||
eprintln!(" To install dependencies manually, run:");
|
||||
eprintln!(" sudo apt-get install {}", deps.join(" "));
|
||||
eprintln!();
|
||||
eprintln!(" Review the apt output carefully before confirming.");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{} Could not simulate install ({}). Proceeding with caution.",
|
||||
color::warning_indicator(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Safe to proceed: no removals detected
|
||||
let install_cmd = format!("sudo apt-get install -y {}", deps.join(" "));
|
||||
println!("Running: {}", install_cmd);
|
||||
let status = Command::new("sudo")
|
||||
.args(["apt-get", "install", "-y"])
|
||||
.args(&deps)
|
||||
.status();
|
||||
|
||||
report_install_status(status);
|
||||
} else {
|
||||
// dnf / yum path — these package managers do not remove packages
|
||||
// during install, so the simulate-first guard is not needed.
|
||||
let install_cmd = format!("sudo {} install -y {}", pkg_mgr, deps.join(" "));
|
||||
println!("Running: {}", install_cmd);
|
||||
let status = Command::new("sh").arg("-c").arg(&install_cmd).status();
|
||||
|
||||
report_install_status(status);
|
||||
}
|
||||
}
|
||||
|
||||
fn which_exists(cmd: &str) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -223,3 +702,124 @@ fn package_exists_apt(pkg: &str) -> bool {
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dashboard install
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn get_dashboard_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".agent-browser")
|
||||
.join("dashboard")
|
||||
}
|
||||
|
||||
const DASHBOARD_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
fn dashboard_download_url() -> String {
|
||||
format!(
|
||||
"https://github.com/vercel-labs/agent-browser/releases/download/v{}/dashboard.zip",
|
||||
DASHBOARD_VERSION
|
||||
)
|
||||
}
|
||||
|
||||
pub fn run_dashboard_install() {
|
||||
println!("{}", color::cyan("Installing dashboard..."));
|
||||
|
||||
let dest = get_dashboard_dir();
|
||||
|
||||
if dest.join("index.html").exists() {
|
||||
println!(
|
||||
"{} Dashboard is already installed at {}",
|
||||
color::success_indicator(),
|
||||
dest.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let url = dashboard_download_url();
|
||||
println!(" Downloading dashboard v{}", DASHBOARD_VERSION);
|
||||
println!(" {}", url);
|
||||
|
||||
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 bytes = match rt.block_on(download_bytes(&url)) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
eprintln!("{} {}", color::error_indicator(), e);
|
||||
eprintln!(" The dashboard may not be available for this version yet.");
|
||||
eprintln!(" You can build it locally: cd packages/dashboard && pnpm build");
|
||||
exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
match extract_dashboard_zip(bytes, &dest) {
|
||||
Ok(()) => {
|
||||
println!(
|
||||
"{} Dashboard v{} installed successfully",
|
||||
color::success_indicator(),
|
||||
DASHBOARD_VERSION
|
||||
);
|
||||
println!(" Location: {}", dest.display());
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = fs::remove_dir_all(&dest);
|
||||
eprintln!("{} {}", color::error_indicator(), e);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_dashboard_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 rel_path = enclosed.to_string_lossy().to_string();
|
||||
|
||||
if rel_path.is_empty() || file.is_dir() {
|
||||
if file.is_dir() {
|
||||
let out_dir = dest.join(&rel_path);
|
||||
let _ = fs::create_dir_all(&out_dir);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let out_path = dest.join(&rel_path);
|
||||
if !out_path.starts_with(dest) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+753
-261
File diff suppressed because it is too large
Load Diff
+4015
-810
File diff suppressed because it is too large
Load Diff
+312
-71
@@ -1,11 +1,13 @@
|
||||
use aes_gcm::{aead::Aead, aead::KeyInit, Aes256Gcm};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthProfile {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
@@ -17,6 +19,10 @@ pub struct AuthProfile {
|
||||
pub password_selector: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub submit_selector: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_login_at: Option<String>,
|
||||
}
|
||||
|
||||
// Keep legacy Credential alias for backward compatibility
|
||||
@@ -48,79 +54,203 @@ fn get_profile_path(name: &str) -> PathBuf {
|
||||
get_auth_dir().join(format!("{}.json", name))
|
||||
}
|
||||
|
||||
fn derive_encryption_key() -> Vec<u8> {
|
||||
let hostname = std::env::var("HOSTNAME")
|
||||
.or_else(|_| std::env::var("COMPUTERNAME"))
|
||||
.unwrap_or_else(|_| {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let mut buf = [0u8; 256];
|
||||
let len = unsafe { libc::gethostname(buf.as_mut_ptr() as *mut _, buf.len()) };
|
||||
if len == 0 {
|
||||
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
|
||||
String::from_utf8_lossy(&buf[..end]).to_string()
|
||||
} else {
|
||||
"unknown-host".to_string()
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
"unknown-host".to_string()
|
||||
}
|
||||
});
|
||||
let username = std::env::var("USER")
|
||||
.or_else(|_| std::env::var("USERNAME"))
|
||||
.unwrap_or_else(|_| "unknown-user".to_string());
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(format!("agent-browser:{}:{}", hostname, username).as_bytes());
|
||||
hasher.finalize().to_vec()
|
||||
const ENCRYPTION_KEY_ENV: &str = "AGENT_BROWSER_ENCRYPTION_KEY";
|
||||
const KEY_FILE_NAME: &str = ".encryption-key";
|
||||
|
||||
fn get_agent_browser_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser")
|
||||
} else {
|
||||
std::env::temp_dir().join("agent-browser")
|
||||
}
|
||||
}
|
||||
|
||||
fn encrypt_profile(profile: &AuthProfile) -> Result<Vec<u8>, String> {
|
||||
let key = derive_encryption_key();
|
||||
fn get_key_file_path() -> PathBuf {
|
||||
get_agent_browser_dir().join(KEY_FILE_NAME)
|
||||
}
|
||||
|
||||
fn parse_key_hex(hex_str: &str) -> Option<Vec<u8>> {
|
||||
let hex_str = hex_str.trim();
|
||||
if hex_str.len() != 64 || !hex_str.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let bytes: Vec<u8> = (0..32)
|
||||
.map(|i| u8::from_str_radix(&hex_str[i * 2..i * 2 + 2], 16).unwrap())
|
||||
.collect();
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
/// Read the encryption key from AGENT_BROWSER_ENCRYPTION_KEY env var or
|
||||
/// ~/.agent-browser/.encryption-key file (matching the Node.js implementation).
|
||||
fn get_encryption_key() -> Result<Vec<u8>, String> {
|
||||
if let Ok(key_hex) = std::env::var(ENCRYPTION_KEY_ENV) {
|
||||
return parse_key_hex(&key_hex).ok_or_else(|| {
|
||||
format!(
|
||||
"{} should be a 64-character hex string (256 bits). Generate one with: openssl rand -hex 32",
|
||||
ENCRYPTION_KEY_ENV
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
let key_file = get_key_file_path();
|
||||
if key_file.exists() {
|
||||
let hex = fs::read_to_string(&key_file)
|
||||
.map_err(|e| format!("Failed to read encryption key file: {}", e))?;
|
||||
return parse_key_hex(&hex).ok_or_else(|| {
|
||||
format!(
|
||||
"Invalid encryption key in {}. Expected 64-character hex string.",
|
||||
key_file.display()
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Encryption key required. Set {} or ensure {} exists.",
|
||||
ENCRYPTION_KEY_ENV,
|
||||
key_file.display()
|
||||
))
|
||||
}
|
||||
|
||||
/// Ensure an encryption key exists, auto-generating one if needed.
|
||||
fn ensure_encryption_key() -> Result<Vec<u8>, String> {
|
||||
if let Ok(key) = get_encryption_key() {
|
||||
return Ok(key);
|
||||
}
|
||||
|
||||
let mut key = [0u8; 32];
|
||||
getrandom::getrandom(&mut key).map_err(|e| format!("Failed to generate key: {}", e))?;
|
||||
let key_hex = key.iter().map(|b| format!("{:02x}", b)).collect::<String>();
|
||||
|
||||
let dir = get_agent_browser_dir();
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create directory: {}", e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
|
||||
let key_file = get_key_file_path();
|
||||
fs::write(&key_file, format!("{}\n", key_hex))
|
||||
.map_err(|e| format!("Failed to write encryption key: {}", e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(&key_file, fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
|
||||
let _ = writeln!(
|
||||
std::io::stderr(),
|
||||
"[agent-browser] Auto-generated encryption key at {} -- back up this file or set {}",
|
||||
key_file.display(),
|
||||
ENCRYPTION_KEY_ENV
|
||||
);
|
||||
|
||||
Ok(key.to_vec())
|
||||
}
|
||||
|
||||
/// Encrypt a profile to the JSON+base64 format compatible with Node.js.
|
||||
fn encrypt_profile(profile: &AuthProfile) -> Result<String, String> {
|
||||
let key = ensure_encryption_key()?;
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Encryption key error: {}", e))?;
|
||||
|
||||
let plaintext = serde_json::to_string(profile)
|
||||
.map_err(|e| format!("Failed to serialize profile: {}", e))?;
|
||||
|
||||
let mut nonce = [0u8; 12];
|
||||
getrandom::getrandom(&mut nonce).map_err(|e| format!("Failed to generate nonce: {}", e))?;
|
||||
let ciphertext = cipher
|
||||
.encrypt(aes_gcm::Nonce::from_slice(&nonce), plaintext.as_bytes())
|
||||
let mut iv = [0u8; 12];
|
||||
getrandom::getrandom(&mut iv).map_err(|e| format!("Failed to generate IV: {}", e))?;
|
||||
|
||||
// aes_gcm appends the 16-byte auth tag to the ciphertext
|
||||
let encrypted = cipher
|
||||
.encrypt(aes_gcm::Nonce::from_slice(&iv), plaintext.as_bytes())
|
||||
.map_err(|e| format!("Encryption failed: {}", e))?;
|
||||
|
||||
let mut result = Vec::with_capacity(12 + ciphertext.len());
|
||||
result.extend_from_slice(&nonce);
|
||||
result.extend_from_slice(&ciphertext);
|
||||
Ok(result)
|
||||
let tag_offset = encrypted.len() - 16;
|
||||
let ciphertext = &encrypted[..tag_offset];
|
||||
let auth_tag = &encrypted[tag_offset..];
|
||||
|
||||
let payload = json!({
|
||||
"version": 1,
|
||||
"encrypted": true,
|
||||
"iv": STANDARD.encode(iv),
|
||||
"authTag": STANDARD.encode(auth_tag),
|
||||
"data": STANDARD.encode(ciphertext),
|
||||
});
|
||||
|
||||
serde_json::to_string_pretty(&payload)
|
||||
.map_err(|e| format!("Failed to serialize payload: {}", e))
|
||||
}
|
||||
|
||||
/// JSON envelope written by Node.js encryption (src/encryption.ts).
|
||||
#[derive(Deserialize)]
|
||||
struct EncryptedPayload {
|
||||
#[allow(dead_code)]
|
||||
version: u32,
|
||||
#[allow(dead_code)]
|
||||
encrypted: bool,
|
||||
iv: String,
|
||||
#[serde(rename = "authTag")]
|
||||
auth_tag: String,
|
||||
data: String,
|
||||
}
|
||||
|
||||
fn decrypt_profile(data: &[u8]) -> Result<AuthProfile, String> {
|
||||
if data.len() < 13 {
|
||||
return Err("Encrypted data too short".to_string());
|
||||
let text = std::str::from_utf8(data).map_err(|_| {
|
||||
"Profile is not valid UTF-8 -- it may use an older incompatible binary format".to_string()
|
||||
})?;
|
||||
|
||||
if let Ok(payload) = serde_json::from_str::<EncryptedPayload>(text) {
|
||||
let key = get_encryption_key()?;
|
||||
|
||||
let iv = STANDARD
|
||||
.decode(&payload.iv)
|
||||
.map_err(|e| format!("Invalid base64 iv: {}", e))?;
|
||||
let auth_tag = STANDARD
|
||||
.decode(&payload.auth_tag)
|
||||
.map_err(|e| format!("Invalid base64 authTag: {}", e))?;
|
||||
let ciphertext = STANDARD
|
||||
.decode(&payload.data)
|
||||
.map_err(|e| format!("Invalid base64 data: {}", e))?;
|
||||
|
||||
// aes_gcm expects ciphertext || auth_tag as input to decrypt
|
||||
let mut combined = Vec::with_capacity(ciphertext.len() + auth_tag.len());
|
||||
combined.extend_from_slice(&ciphertext);
|
||||
combined.extend_from_slice(&auth_tag);
|
||||
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Decryption key error: {}", e))?;
|
||||
let plaintext = cipher
|
||||
.decrypt(aes_gcm::Nonce::from_slice(&iv), combined.as_slice())
|
||||
.map_err(|e| format!("Decryption failed: {}", e))?;
|
||||
|
||||
let json_str = String::from_utf8(plaintext)
|
||||
.map_err(|e| format!("Decrypted data is not valid UTF-8: {}", e))?;
|
||||
return serde_json::from_str(&json_str).map_err(|e| format!("Invalid profile data: {}", e));
|
||||
}
|
||||
let (nonce_bytes, ciphertext) = data.split_at(12);
|
||||
|
||||
let key = derive_encryption_key();
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Decryption key error: {}", e))?;
|
||||
let plaintext = cipher
|
||||
.decrypt(aes_gcm::Nonce::from_slice(nonce_bytes), ciphertext)
|
||||
.map_err(|e| format!("Decryption failed: {}", e))?;
|
||||
|
||||
let json_str = String::from_utf8(plaintext)
|
||||
.map_err(|e| format!("Decrypted data is not valid UTF-8: {}", e))?;
|
||||
serde_json::from_str(&json_str).map_err(|e| format!("Invalid profile data: {}", e))
|
||||
// Fallback: try as plain unencrypted JSON profile
|
||||
serde_json::from_str::<AuthProfile>(text)
|
||||
.map_err(|_| "Profile is not a valid encrypted or unencrypted payload".to_string())
|
||||
}
|
||||
|
||||
fn save_profile(profile: &AuthProfile) -> Result<(), String> {
|
||||
let dir = get_auth_dir();
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create auth dir: {}", e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
|
||||
let encrypted = encrypt_profile(profile)?;
|
||||
let encrypted_json = encrypt_profile(profile)?;
|
||||
let path = get_profile_path(&profile.name);
|
||||
fs::write(&path, &encrypted).map_err(|e| format!("Failed to write profile: {}", e))
|
||||
fs::write(&path, &encrypted_json).map_err(|e| format!("Failed to write profile: {}", e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_profile(name: &str) -> Result<AuthProfile, String> {
|
||||
@@ -147,6 +277,8 @@ pub fn credentials_set(
|
||||
username_selector: None,
|
||||
password_selector: None,
|
||||
submit_selector: None,
|
||||
created_at: None,
|
||||
last_login_at: None,
|
||||
};
|
||||
save_profile(&profile)?;
|
||||
Ok(json!({ "saved": name }))
|
||||
@@ -170,6 +302,8 @@ pub fn auth_save(
|
||||
username_selector: username_selector.map(String::from),
|
||||
password_selector: password_selector.map(String::from),
|
||||
submit_selector: submit_selector.map(String::from),
|
||||
created_at: None,
|
||||
last_login_at: None,
|
||||
};
|
||||
save_profile(&profile)?;
|
||||
Ok(json!({ "saved": name }))
|
||||
@@ -252,10 +386,27 @@ pub fn auth_show(name: &str) -> Result<Value, String> {
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) static AUTH_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn with_test_key<F: FnOnce()>(f: F) {
|
||||
let _lock = AUTH_TEST_MUTEX.lock().unwrap();
|
||||
let original = std::env::var(ENCRYPTION_KEY_ENV).ok();
|
||||
let test_key = "a".repeat(64);
|
||||
// SAFETY: TEST_MUTEX serializes all test access so no concurrent mutation.
|
||||
unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, &test_key) };
|
||||
f();
|
||||
// SAFETY: TEST_MUTEX serializes all test access so no concurrent mutation.
|
||||
match original {
|
||||
Some(val) => unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, val) },
|
||||
None => unsafe { std::env::remove_var(ENCRYPTION_KEY_ENV) },
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_profile_name() {
|
||||
assert!(validate_profile_name("github").is_ok());
|
||||
@@ -277,6 +428,8 @@ mod tests {
|
||||
username_selector: None,
|
||||
password_selector: None,
|
||||
submit_selector: Some("button[type=submit]".to_string()),
|
||||
created_at: None,
|
||||
last_login_at: None,
|
||||
};
|
||||
let json = serde_json::to_string(&profile).unwrap();
|
||||
let parsed: AuthProfile = serde_json::from_str(&json).unwrap();
|
||||
@@ -290,26 +443,114 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_roundtrip() {
|
||||
let profile = AuthProfile {
|
||||
name: "roundtrip".to_string(),
|
||||
url: "https://example.com".to_string(),
|
||||
username: "user".to_string(),
|
||||
password: "s3cret!".to_string(),
|
||||
username_selector: None,
|
||||
password_selector: None,
|
||||
submit_selector: None,
|
||||
};
|
||||
let encrypted = encrypt_profile(&profile).unwrap();
|
||||
let decrypted = decrypt_profile(&encrypted).unwrap();
|
||||
assert_eq!(decrypted.name, "roundtrip");
|
||||
assert_eq!(decrypted.password, "s3cret!");
|
||||
with_test_key(|| {
|
||||
let profile = AuthProfile {
|
||||
name: "roundtrip".to_string(),
|
||||
url: "https://example.com".to_string(),
|
||||
username: "user".to_string(),
|
||||
password: "s3cret!".to_string(),
|
||||
username_selector: None,
|
||||
password_selector: None,
|
||||
submit_selector: None,
|
||||
created_at: None,
|
||||
last_login_at: None,
|
||||
};
|
||||
let encrypted_json = encrypt_profile(&profile).unwrap();
|
||||
let decrypted = decrypt_profile(encrypted_json.as_bytes()).unwrap();
|
||||
assert_eq!(decrypted.name, "roundtrip");
|
||||
assert_eq!(decrypted.password, "s3cret!");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_encryption_key_is_stable() {
|
||||
let k1 = derive_encryption_key();
|
||||
let k2 = derive_encryption_key();
|
||||
assert_eq!(k1, k2);
|
||||
assert_eq!(k1.len(), 32);
|
||||
fn test_get_encryption_key_from_env() {
|
||||
with_test_key(|| {
|
||||
let key = get_encryption_key().unwrap();
|
||||
assert_eq!(key.len(), 32);
|
||||
assert!(key.iter().all(|&b| b == 0xaa));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_key_hex_valid() {
|
||||
let hex = "ab".repeat(32);
|
||||
let key = parse_key_hex(&hex).unwrap();
|
||||
assert_eq!(key.len(), 32);
|
||||
assert!(key.iter().all(|&b| b == 0xab));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_key_hex_invalid() {
|
||||
assert!(parse_key_hex("too_short").is_none());
|
||||
assert!(parse_key_hex(&"g".repeat(64)).is_none());
|
||||
assert!(parse_key_hex("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_json_payload_format() {
|
||||
with_test_key(|| {
|
||||
let key = get_encryption_key().unwrap();
|
||||
let profile = AuthProfile {
|
||||
name: "json-test".to_string(),
|
||||
url: "https://example.com/login".to_string(),
|
||||
username: "admin".to_string(),
|
||||
password: "hunter2".to_string(),
|
||||
username_selector: Some("#email".to_string()),
|
||||
password_selector: None,
|
||||
submit_selector: None,
|
||||
created_at: None,
|
||||
last_login_at: None,
|
||||
};
|
||||
|
||||
// Encrypt with aes_gcm, then manually build the JSON payload
|
||||
// to simulate what Node.js would produce
|
||||
let cipher = Aes256Gcm::new_from_slice(&key).unwrap();
|
||||
let mut iv = [0u8; 12];
|
||||
getrandom::getrandom(&mut iv).unwrap();
|
||||
let plaintext = serde_json::to_string(&profile).unwrap();
|
||||
let encrypted = cipher
|
||||
.encrypt(aes_gcm::Nonce::from_slice(&iv), plaintext.as_bytes())
|
||||
.unwrap();
|
||||
|
||||
let tag_offset = encrypted.len() - 16;
|
||||
let ciphertext = &encrypted[..tag_offset];
|
||||
let auth_tag = &encrypted[tag_offset..];
|
||||
|
||||
let payload = format!(
|
||||
r#"{{"version":1,"encrypted":true,"iv":"{}","authTag":"{}","data":"{}"}}"#,
|
||||
STANDARD.encode(iv),
|
||||
STANDARD.encode(auth_tag),
|
||||
STANDARD.encode(ciphertext),
|
||||
);
|
||||
|
||||
let decrypted = decrypt_profile(payload.as_bytes()).unwrap();
|
||||
assert_eq!(decrypted.name, "json-test");
|
||||
assert_eq!(decrypted.password, "hunter2");
|
||||
assert_eq!(decrypted.username_selector, Some("#email".to_string()));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypted_output_is_json_format() {
|
||||
with_test_key(|| {
|
||||
let profile = AuthProfile {
|
||||
name: "format-check".to_string(),
|
||||
url: "https://example.com".to_string(),
|
||||
username: "user".to_string(),
|
||||
password: "pass".to_string(),
|
||||
username_selector: None,
|
||||
password_selector: None,
|
||||
submit_selector: None,
|
||||
created_at: None,
|
||||
last_login_at: None,
|
||||
};
|
||||
let encrypted = encrypt_profile(&profile).unwrap();
|
||||
let parsed: Value = serde_json::from_str(&encrypted).unwrap();
|
||||
assert_eq!(parsed["version"], 1);
|
||||
assert_eq!(parsed["encrypted"], true);
|
||||
assert!(parsed["iv"].is_string());
|
||||
assert!(parsed["authTag"].is_string());
|
||||
assert!(parsed["data"].is_string());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+844
-115
File diff suppressed because it is too large
Load Diff
+642
-126
@@ -1,13 +1,14 @@
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::types::BrowserVersionInfo;
|
||||
use super::discovery::discover_cdp_url;
|
||||
|
||||
pub struct ChromeProcess {
|
||||
child: Child,
|
||||
pub ws_url: String,
|
||||
temp_user_data_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl ChromeProcess {
|
||||
@@ -15,11 +16,60 @@ impl ChromeProcess {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
|
||||
/// Returns the OS process ID of the Chrome child process.
|
||||
pub fn id(&self) -> u32 {
|
||||
self.child.id()
|
||||
}
|
||||
|
||||
/// Non-blocking check whether Chrome has exited.
|
||||
/// Returns `true` if the process has exited (and reaps it), `false` if still running.
|
||||
pub fn has_exited(&mut self) -> bool {
|
||||
matches!(self.child.try_wait(), Ok(Some(_)) | Err(_))
|
||||
}
|
||||
|
||||
/// Wait for Chrome to exit on its own (after Browser.close CDP command),
|
||||
/// falling back to kill() if it doesn't exit within the timeout.
|
||||
/// This allows Chrome to flush cookies and other state to the user-data-dir.
|
||||
pub fn wait_or_kill(&mut self, timeout: Duration) {
|
||||
let start = std::time::Instant::now();
|
||||
let poll_interval = Duration::from_millis(50);
|
||||
|
||||
while start.elapsed() < timeout {
|
||||
match self.child.try_wait() {
|
||||
Ok(Some(_)) => return,
|
||||
Ok(None) => std::thread::sleep(poll_interval),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
self.kill();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ChromeProcess {
|
||||
fn drop(&mut self) {
|
||||
self.kill();
|
||||
if let Some(ref dir) = self.temp_user_data_dir {
|
||||
for attempt in 0..3 {
|
||||
match std::fs::remove_dir_all(dir) {
|
||||
Ok(()) => break,
|
||||
Err(_) if attempt < 2 => {
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
Err(e) => {
|
||||
// Use write! instead of eprintln! to avoid panicking
|
||||
// if the daemon's stderr pipe is broken (parent dropped it).
|
||||
let _ = writeln!(
|
||||
std::io::stderr(),
|
||||
"Warning: failed to clean up temp profile {}: {}",
|
||||
dir.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +78,8 @@ pub struct LaunchOptions {
|
||||
pub executable_path: Option<String>,
|
||||
pub proxy: Option<String>,
|
||||
pub proxy_bypass: Option<String>,
|
||||
pub proxy_username: Option<String>,
|
||||
pub proxy_password: Option<String>,
|
||||
pub profile: Option<String>,
|
||||
pub args: Vec<String>,
|
||||
pub allow_file_access: bool,
|
||||
@@ -46,6 +98,8 @@ impl Default for LaunchOptions {
|
||||
executable_path: None,
|
||||
proxy: None,
|
||||
proxy_bypass: None,
|
||||
proxy_username: None,
|
||||
proxy_password: None,
|
||||
profile: None,
|
||||
args: Vec::new(),
|
||||
allow_file_access: false,
|
||||
@@ -59,18 +113,21 @@ impl Default for LaunchOptions {
|
||||
}
|
||||
}
|
||||
|
||||
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.")?
|
||||
}
|
||||
};
|
||||
struct ChromeArgs {
|
||||
args: Vec<String>,
|
||||
user_data_dir: PathBuf,
|
||||
temp_user_data_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
|
||||
let mut args = vec![
|
||||
"--remote-debugging-port=0".to_string(),
|
||||
"--no-first-run".to_string(),
|
||||
"--no-default-browser-check".to_string(),
|
||||
// Stealth: reduce automation fingerprint surface
|
||||
"--disable-blink-features=AutomationControlled".to_string(),
|
||||
"--use-gl=angle".to_string(),
|
||||
"--use-angle=default".to_string(),
|
||||
"--disable-background-networking".to_string(),
|
||||
"--disable-backgrounding-occluded-windows".to_string(),
|
||||
"--disable-component-update".to_string(),
|
||||
@@ -79,14 +136,27 @@ pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
|
||||
"--disable-popup-blocking".to_string(),
|
||||
"--disable-prompt-on-repost".to_string(),
|
||||
"--disable-sync".to_string(),
|
||||
"--disable-features=Translate".to_string(),
|
||||
"--enable-features=NetworkService,NetworkServiceInProcess".to_string(),
|
||||
"--metrics-recording-only".to_string(),
|
||||
"--password-store=basic".to_string(),
|
||||
"--use-mock-keychain".to_string(),
|
||||
];
|
||||
|
||||
if options.headless {
|
||||
let has_extensions = options
|
||||
.extensions
|
||||
.as_ref()
|
||||
.is_some_and(|exts| !exts.is_empty());
|
||||
|
||||
// Extensions require headed mode in native Chrome (content scripts are not
|
||||
// injected in headless mode). Skip --headless when extensions are loaded.
|
||||
if options.headless && !has_extensions {
|
||||
args.push("--headless=new".to_string());
|
||||
// Enable SwiftShader software rendering in headless mode. This
|
||||
// prevents silent crashes in environments where GPU drivers are
|
||||
// missing or restricted (VMs, containers, some cloud machines)
|
||||
// while preserving WebGL support. Playwright uses the same flag.
|
||||
args.push("--enable-unsafe-swiftshader".to_string());
|
||||
}
|
||||
|
||||
if let Some(ref proxy) = options.proxy {
|
||||
@@ -97,10 +167,19 @@ pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
|
||||
args.push(format!("--proxy-bypass-list={}", bypass));
|
||||
}
|
||||
|
||||
if let Some(ref profile) = options.profile {
|
||||
let (user_data_dir, temp_user_data_dir) = if let Some(ref profile) = options.profile {
|
||||
let expanded = expand_tilde(profile);
|
||||
let dir = PathBuf::from(&expanded);
|
||||
args.push(format!("--user-data-dir={}", expanded));
|
||||
}
|
||||
(dir, None)
|
||||
} else {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("agent-browser-chrome-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir)
|
||||
.map_err(|e| format!("Failed to create temp profile dir: {}", e))?;
|
||||
args.push(format!("--user-data-dir={}", dir.display()));
|
||||
(dir.clone(), Some(dir))
|
||||
};
|
||||
|
||||
if options.allow_file_access {
|
||||
args.push("--allow-file-access-from-files".to_string());
|
||||
@@ -115,13 +194,12 @@ pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user args set window size (skip viewport override)
|
||||
let has_window_size = options
|
||||
.args
|
||||
.iter()
|
||||
.any(|a| a.starts_with("--start-maximized") || a.starts_with("--window-size="));
|
||||
|
||||
if !has_window_size && options.headless {
|
||||
if !has_window_size && options.headless && !has_extensions {
|
||||
args.push("--window-size=1280,720".to_string());
|
||||
}
|
||||
|
||||
@@ -131,27 +209,161 @@ pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
|
||||
args.push("--no-sandbox".to_string());
|
||||
}
|
||||
|
||||
let mut child = Command::new(&chrome_path)
|
||||
if should_disable_dev_shm(&args) {
|
||||
args.push("--disable-dev-shm-usage".to_string());
|
||||
}
|
||||
|
||||
Ok(ChromeArgs {
|
||||
args,
|
||||
user_data_dir,
|
||||
temp_user_data_dir,
|
||||
})
|
||||
}
|
||||
|
||||
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_else(|| {
|
||||
let cache_dir = crate::install::get_browsers_dir();
|
||||
format!(
|
||||
"Chrome not found. Checked:\n \
|
||||
- agent-browser cache: {}\n \
|
||||
- System Chrome installations\n \
|
||||
- Puppeteer browser cache\n \
|
||||
- Playwright browser cache\n\
|
||||
Run `agent-browser install` to download Chrome, or use --executable-path.",
|
||||
cache_dir.display()
|
||||
)
|
||||
})?,
|
||||
};
|
||||
|
||||
let max_attempts = 3;
|
||||
let mut last_err = String::new();
|
||||
|
||||
for attempt in 1..=max_attempts {
|
||||
match try_launch_chrome(&chrome_path, options) {
|
||||
Ok(process) => return Ok(process),
|
||||
Err(e) => {
|
||||
last_err = e;
|
||||
if attempt < max_attempts {
|
||||
// Use write! instead of eprintln! to avoid panicking
|
||||
// if the daemon's stderr pipe is broken (parent dropped it).
|
||||
let _ = writeln!(
|
||||
std::io::stderr(),
|
||||
"[chrome] Launch attempt {}/{} failed, retrying in 500ms...",
|
||||
attempt,
|
||||
max_attempts
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_err)
|
||||
}
|
||||
|
||||
fn try_launch_chrome(chrome_path: &Path, options: &LaunchOptions) -> Result<ChromeProcess, String> {
|
||||
let ChromeArgs {
|
||||
args,
|
||||
user_data_dir,
|
||||
temp_user_data_dir,
|
||||
} = build_chrome_args(options)?;
|
||||
|
||||
// Mitigate stale DevToolsActivePort risk (e.g., previous crash left it behind).
|
||||
// Puppeteer does similar cleanup before spawning.
|
||||
let _ = std::fs::remove_file(user_data_dir.join("DevToolsActivePort"));
|
||||
|
||||
let cleanup_temp_dir = |dir: &Option<PathBuf>| {
|
||||
if let Some(ref d) = dir {
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
};
|
||||
|
||||
let mut child = Command::new(chrome_path)
|
||||
.args(&args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to launch Chrome at {:?}: {}", chrome_path, e))?;
|
||||
.map_err(|e| {
|
||||
cleanup_temp_dir(&temp_user_data_dir);
|
||||
format!("Failed to launch Chrome at {:?}: {}", chrome_path, e)
|
||||
})?;
|
||||
|
||||
let stderr = child
|
||||
.stderr
|
||||
.take()
|
||||
.ok_or("Failed to capture Chrome stderr")?;
|
||||
let reader = BufReader::new(stderr);
|
||||
// Shared overall deadline so we don't double-wait (poll + stderr fallback).
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(30);
|
||||
|
||||
let ws_url = wait_for_ws_url(reader)?;
|
||||
// Primary path: use DevToolsActivePort written into user-data-dir.
|
||||
// This is more reliable on Windows than scraping stderr for "DevTools listening on ...",
|
||||
// which can be missing/empty depending on how Chrome is launched.
|
||||
let ws_url = match wait_for_devtools_active_port(&mut child, &user_data_dir, deadline) {
|
||||
Ok(url) => url,
|
||||
Err(primary_err) => {
|
||||
// Fallback: scrape stderr (legacy behavior) for better diagnostics.
|
||||
let stderr = child.stderr.take().ok_or_else(|| {
|
||||
let _ = child.kill();
|
||||
cleanup_temp_dir(&temp_user_data_dir);
|
||||
"Failed to capture Chrome stderr".to_string()
|
||||
})?;
|
||||
let reader = BufReader::new(stderr);
|
||||
match wait_for_ws_url_until(reader, deadline) {
|
||||
Ok(url) => url,
|
||||
Err(fallback_err) => {
|
||||
let _ = child.kill();
|
||||
cleanup_temp_dir(&temp_user_data_dir);
|
||||
return Err(format!(
|
||||
"{}\n(also tried parsing stderr) {}",
|
||||
primary_err, fallback_err
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(ChromeProcess { child, ws_url })
|
||||
Ok(ChromeProcess {
|
||||
child,
|
||||
ws_url,
|
||||
temp_user_data_dir,
|
||||
})
|
||||
}
|
||||
|
||||
fn wait_for_ws_url(reader: BufReader<std::process::ChildStderr>) -> Result<String, String> {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(30);
|
||||
fn wait_for_devtools_active_port(
|
||||
child: &mut Child,
|
||||
user_data_dir: &Path,
|
||||
deadline: std::time::Instant,
|
||||
) -> Result<String, String> {
|
||||
let poll_interval = Duration::from_millis(50);
|
||||
|
||||
while std::time::Instant::now() <= deadline {
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
// Chrome exited before writing DevToolsActivePort -- report the
|
||||
// exit code so the caller can surface it alongside stderr output.
|
||||
let code = status
|
||||
.code()
|
||||
.map(|c| format!("{}", c))
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
return Err(format!(
|
||||
"Chrome exited early (exit code: {}) without writing DevToolsActivePort",
|
||||
code
|
||||
));
|
||||
}
|
||||
|
||||
if let Some((port, ws_path)) = read_devtools_active_port(user_data_dir) {
|
||||
let ws_url = format!("ws://127.0.0.1:{}{}", port, ws_path);
|
||||
return Ok(ws_url);
|
||||
}
|
||||
|
||||
std::thread::sleep(poll_interval);
|
||||
}
|
||||
|
||||
Err("Timeout waiting for DevToolsActivePort".to_string())
|
||||
}
|
||||
|
||||
fn wait_for_ws_url_until(
|
||||
reader: BufReader<std::process::ChildStderr>,
|
||||
deadline: std::time::Instant,
|
||||
) -> Result<String, String> {
|
||||
let prefix = "DevTools listening on ";
|
||||
let mut stderr_lines: Vec<String> = Vec::new();
|
||||
|
||||
@@ -193,7 +405,10 @@ fn chrome_launch_error(message: &str, stderr_lines: &[String]) -> String {
|
||||
|
||||
if relevant.is_empty() {
|
||||
if stderr_lines.is_empty() {
|
||||
return format!("{} (no stderr output from Chrome)", message);
|
||||
return format!(
|
||||
"{} (no stderr output from Chrome)\nHint: try passing --args \"--no-sandbox\" if Chrome crashes silently in your environment",
|
||||
message
|
||||
);
|
||||
}
|
||||
let last_lines: Vec<&String> = stderr_lines.iter().rev().take(5).collect();
|
||||
return format!(
|
||||
@@ -231,12 +446,31 @@ 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);
|
||||
}
|
||||
|
||||
// If the cache directory exists but no Chrome was found, warn -- this
|
||||
// likely means the cache is corrupted or the directory layout is unexpected.
|
||||
let cache_dir = crate::install::get_browsers_dir();
|
||||
if cache_dir.exists() {
|
||||
let _ = writeln!(
|
||||
std::io::stderr(),
|
||||
"Warning: Chrome cache directory exists ({}) but no Chrome binary found inside. \
|
||||
Falling back to system Chrome. Run `agent-browser install` to re-download.",
|
||||
cache_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Check system-installed Chrome
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let candidates = [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
||||
];
|
||||
for c in &candidates {
|
||||
let p = PathBuf::from(c);
|
||||
@@ -244,10 +478,6 @@ pub fn find_chrome() -> Option<PathBuf> {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(p) = find_playwright_chromium() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -257,6 +487,8 @@ pub fn find_chrome() -> Option<PathBuf> {
|
||||
"google-chrome-stable",
|
||||
"chromium-browser",
|
||||
"chromium",
|
||||
"brave-browser",
|
||||
"brave-browser-stable",
|
||||
];
|
||||
for name in &candidates {
|
||||
if let Ok(output) = Command::new("which").arg(name).output() {
|
||||
@@ -268,10 +500,6 @@ pub fn find_chrome() -> Option<PathBuf> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(p) = find_playwright_chromium() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -281,9 +509,14 @@ pub fn find_chrome() -> Option<PathBuf> {
|
||||
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
||||
];
|
||||
if let Ok(local) = std::env::var("LOCALAPPDATA") {
|
||||
let p = PathBuf::from(&local).join(r"Google\Chrome\Application\chrome.exe");
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
let chrome = PathBuf::from(&local).join(r"Google\Chrome\Application\chrome.exe");
|
||||
if chrome.exists() {
|
||||
return Some(chrome);
|
||||
}
|
||||
let brave =
|
||||
PathBuf::from(&local).join(r"BraveSoftware\Brave-Browser\Application\brave.exe");
|
||||
if brave.exists() {
|
||||
return Some(brave);
|
||||
}
|
||||
}
|
||||
for c in &candidates {
|
||||
@@ -294,78 +527,17 @@ pub fn find_chrome() -> Option<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback: check Puppeteer / Playwright browser caches
|
||||
if let Some(p) = find_puppeteer_chrome() {
|
||||
return Some(p);
|
||||
}
|
||||
if let Some(p) = find_playwright_chromium() {
|
||||
return Some(p);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn discover_cdp_url(port: u16) -> Result<String, String> {
|
||||
let url = format!("http://127.0.0.1:{}/json/version", port);
|
||||
|
||||
let body = tokio::time::timeout(Duration::from_secs(2), async {
|
||||
reqwest_get_string(&url).await
|
||||
})
|
||||
.await
|
||||
.map_err(|_| format!("Timeout connecting to CDP on port {}", port))?
|
||||
.map_err(|e| format!("Failed to connect to CDP on port {}: {}", port, e))?;
|
||||
|
||||
let info: BrowserVersionInfo = serde_json::from_str(&body)
|
||||
.map_err(|e| format!("Invalid /json/version response: {}", e))?;
|
||||
|
||||
info.web_socket_debugger_url
|
||||
.ok_or_else(|| format!("No webSocketDebuggerUrl in /json/version on port {}", port))
|
||||
}
|
||||
|
||||
async fn reqwest_get_string(url: &str) -> Result<String, String> {
|
||||
let client = tokio::net::TcpStream::connect(
|
||||
url.strip_prefix("http://")
|
||||
.unwrap_or(url)
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap_or("127.0.0.1:9222"),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let path = url
|
||||
.find('/')
|
||||
.and_then(|i| url[i..].find('/').map(|j| &url[i + j..]))
|
||||
.unwrap_or("/json/version");
|
||||
|
||||
let host = url
|
||||
.strip_prefix("http://")
|
||||
.unwrap_or(url)
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap_or("127.0.0.1");
|
||||
|
||||
let request = format!(
|
||||
"GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n",
|
||||
path, host
|
||||
);
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
let mut client = client;
|
||||
client
|
||||
.write_all(request.as_bytes())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut response = Vec::new();
|
||||
client
|
||||
.read_to_end(&mut response)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let response_str = String::from_utf8_lossy(&response);
|
||||
let body = response_str
|
||||
.split("\r\n\r\n")
|
||||
.nth(1)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
pub fn read_devtools_active_port(user_data_dir: &Path) -> Option<(u16, String)> {
|
||||
let path = user_data_dir.join("DevToolsActivePort");
|
||||
let content = std::fs::read_to_string(&path).ok()?;
|
||||
@@ -385,18 +557,26 @@ pub async fn auto_connect_cdp() -> Result<String, String> {
|
||||
for dir in &user_data_dirs {
|
||||
if let Some((port, ws_path)) = read_devtools_active_port(dir) {
|
||||
// Try HTTP endpoint first (pre-M144)
|
||||
if let Ok(ws_url) = discover_cdp_url(port).await {
|
||||
if let Ok(ws_url) = discover_cdp_url("127.0.0.1", port, None).await {
|
||||
return Ok(ws_url);
|
||||
}
|
||||
// M144+: direct WebSocket
|
||||
let ws_url = format!("ws://127.0.0.1:{}{}", port, ws_path);
|
||||
return Ok(ws_url);
|
||||
// M144+: direct WebSocket — verify the port is actually listening
|
||||
// before returning, otherwise a stale DevToolsActivePort file
|
||||
// (left behind after Chrome exits/crashes) produces a confusing
|
||||
// "connection refused" error instead of falling through.
|
||||
if is_port_reachable(port) {
|
||||
let ws_url = format!("ws://127.0.0.1:{}{}", port, ws_path);
|
||||
return Ok(ws_url);
|
||||
}
|
||||
// Port is dead — remove the stale file so future runs skip it.
|
||||
let stale = dir.join("DevToolsActivePort");
|
||||
let _ = std::fs::remove_file(&stale);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: probe common ports
|
||||
for port in [9222u16, 9229] {
|
||||
if let Ok(ws_url) = discover_cdp_url(port).await {
|
||||
if let Ok(ws_url) = discover_cdp_url("127.0.0.1", port, None).await {
|
||||
return Ok(ws_url);
|
||||
}
|
||||
}
|
||||
@@ -404,6 +584,12 @@ pub async fn auto_connect_cdp() -> Result<String, String> {
|
||||
Err("No running Chrome instance found. Launch Chrome with --remote-debugging-port or use --cdp.".to_string())
|
||||
}
|
||||
|
||||
fn is_port_reachable(port: u16) -> bool {
|
||||
use std::net::TcpStream;
|
||||
let addr = format!("127.0.0.1:{}", port);
|
||||
TcpStream::connect_timeout(&addr.parse().unwrap(), Duration::from_millis(500)).is_ok()
|
||||
}
|
||||
|
||||
fn get_chrome_user_data_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
|
||||
@@ -411,7 +597,12 @@ fn get_chrome_user_data_dirs() -> Vec<PathBuf> {
|
||||
{
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let base = home.join("Library/Application Support");
|
||||
for name in ["Google/Chrome", "Google/Chrome Canary", "Chromium"] {
|
||||
for name in [
|
||||
"Google/Chrome",
|
||||
"Google/Chrome Canary",
|
||||
"Chromium",
|
||||
"BraveSoftware/Brave-Browser",
|
||||
] {
|
||||
dirs.push(base.join(name));
|
||||
}
|
||||
}
|
||||
@@ -421,7 +612,12 @@ fn get_chrome_user_data_dirs() -> Vec<PathBuf> {
|
||||
{
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let config = home.join(".config");
|
||||
for name in ["google-chrome", "google-chrome-unstable", "chromium"] {
|
||||
for name in [
|
||||
"google-chrome",
|
||||
"google-chrome-unstable",
|
||||
"chromium",
|
||||
"BraveSoftware/Brave-Browser",
|
||||
] {
|
||||
dirs.push(config.join(name));
|
||||
}
|
||||
}
|
||||
@@ -435,6 +631,7 @@ fn get_chrome_user_data_dirs() -> Vec<PathBuf> {
|
||||
r"Google\Chrome\User Data",
|
||||
r"Google\Chrome SxS\User Data",
|
||||
r"Chromium\User Data",
|
||||
r"BraveSoftware\Brave-Browser\User Data",
|
||||
] {
|
||||
dirs.push(base.join(name));
|
||||
}
|
||||
@@ -445,12 +642,18 @@ fn get_chrome_user_data_dirs() -> Vec<PathBuf> {
|
||||
}
|
||||
|
||||
/// Returns true if Chrome's sandbox should be disabled because the environment
|
||||
/// doesn't support it (containers, VMs, running as root).
|
||||
/// doesn't support it (containers, VMs, CI runners, running as root).
|
||||
fn should_disable_sandbox(existing_args: &[String]) -> bool {
|
||||
if existing_args.iter().any(|a| a == "--no-sandbox") {
|
||||
return false; // already set by user
|
||||
}
|
||||
|
||||
// CI environments (GitHub Actions, GitLab CI, etc.) often lack user namespace
|
||||
// support due to AppArmor or kernel restrictions.
|
||||
if std::env::var("CI").is_ok() {
|
||||
return true;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// Root user -- standard container default, Chrome sandbox requires non-root
|
||||
@@ -470,10 +673,7 @@ fn should_disable_sandbox(existing_args: &[String]) -> bool {
|
||||
|
||||
// Generic container detection: cgroup contains docker/kubepods/lxc
|
||||
if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup") {
|
||||
if cgroup.contains("docker")
|
||||
|| cgroup.contains("kubepods")
|
||||
|| cgroup.contains("lxc")
|
||||
{
|
||||
if cgroup.contains("docker") || cgroup.contains("kubepods") || cgroup.contains("lxc") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -482,8 +682,108 @@ fn should_disable_sandbox(existing_args: &[String]) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Returns true if Chrome should use disk instead of /dev/shm for shared memory.
|
||||
/// On CI runners and containers, /dev/shm is often too small (64MB default),
|
||||
/// which causes Chrome to crash mid-session.
|
||||
fn should_disable_dev_shm(existing_args: &[String]) -> bool {
|
||||
if existing_args.iter().any(|a| a == "--disable-dev-shm-usage") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if std::env::var("CI").is_ok() {
|
||||
return true;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if unsafe { libc::geteuid() } == 0 {
|
||||
return true;
|
||||
}
|
||||
if Path::new("/.dockerenv").exists() || Path::new("/run/.containerenv").exists() {
|
||||
return true;
|
||||
}
|
||||
if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup") {
|
||||
if cgroup.contains("docker") || cgroup.contains("kubepods") || cgroup.contains("lxc") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Search Puppeteer's browser cache for a Chrome binary.
|
||||
/// Puppeteer v19+ stores Chrome in ~/.cache/puppeteer/chrome/<platform>-<version>/
|
||||
fn find_puppeteer_chrome() -> Option<PathBuf> {
|
||||
let mut search_dirs = Vec::new();
|
||||
|
||||
if let Ok(custom) = std::env::var("PUPPETEER_CACHE_DIR") {
|
||||
search_dirs.push(PathBuf::from(custom).join("chrome"));
|
||||
}
|
||||
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
search_dirs.push(home.join(".cache/puppeteer/chrome"));
|
||||
}
|
||||
|
||||
for dir in &search_dirs {
|
||||
if !dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||
let mut matches: Vec<PathBuf> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().is_dir())
|
||||
.filter_map(|e| {
|
||||
let candidate = build_puppeteer_binary_path(&e.path());
|
||||
if candidate.exists() {
|
||||
Some(candidate)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
matches.sort();
|
||||
matches.reverse();
|
||||
if let Some(p) = matches.into_iter().next() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn build_puppeteer_binary_path(version_dir: &Path) -> PathBuf {
|
||||
version_dir.join("chrome-linux64/chrome")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn build_puppeteer_binary_path(version_dir: &Path) -> PathBuf {
|
||||
// Puppeteer uses chrome-mac-arm64 or chrome-mac-x64 depending on arch
|
||||
let arm = version_dir.join(
|
||||
"chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
|
||||
);
|
||||
if arm.exists() {
|
||||
return arm;
|
||||
}
|
||||
version_dir.join(
|
||||
"chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn build_puppeteer_binary_path(version_dir: &Path) -> PathBuf {
|
||||
version_dir.join(r"chrome-win64\chrome.exe")
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn build_puppeteer_binary_path(version_dir: &Path) -> PathBuf {
|
||||
version_dir.join("chrome")
|
||||
}
|
||||
|
||||
/// 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();
|
||||
|
||||
@@ -559,6 +859,29 @@ fn expand_tilde(path: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_utils::EnvGuard;
|
||||
|
||||
#[cfg(unix)]
|
||||
fn spawn_noop_child() -> Child {
|
||||
Command::new("/bin/sh")
|
||||
.args(["-c", "exit 0"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn spawn_noop_child() -> Child {
|
||||
Command::new("cmd.exe")
|
||||
.args(["/C", "exit 0"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_chrome_returns_some_on_host() {
|
||||
@@ -600,6 +923,8 @@ mod tests {
|
||||
fn test_chrome_launch_error_no_stderr() {
|
||||
let msg = chrome_launch_error("Chrome exited", &[]);
|
||||
assert!(msg.contains("no stderr output"));
|
||||
assert!(msg.contains("Hint:"));
|
||||
assert!(msg.contains("--no-sandbox"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -616,20 +941,211 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_chrome_launch_error_generic() {
|
||||
let lines = vec![
|
||||
"info line".to_string(),
|
||||
"another info line".to_string(),
|
||||
];
|
||||
let lines = vec!["info line".to_string(), "another info line".to_string()];
|
||||
let msg = chrome_launch_error("Chrome exited", &lines);
|
||||
assert!(msg.contains("last 2 lines"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_playwright_chromium_nonexistent() {
|
||||
// With no Playwright cache, should return None
|
||||
std::env::set_var("PLAYWRIGHT_BROWSERS_PATH", "/nonexistent/path");
|
||||
let guard = EnvGuard::new(&["PLAYWRIGHT_BROWSERS_PATH", "HOME", "USERPROFILE"]);
|
||||
guard.set("PLAYWRIGHT_BROWSERS_PATH", "/nonexistent/path");
|
||||
|
||||
let temp_home = std::env::temp_dir().join(format!(
|
||||
"agent-browser-test-home-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("system clock should be after unix epoch")
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&temp_home).expect("temp home should be created");
|
||||
let temp_home = temp_home.to_string_lossy().to_string();
|
||||
guard.set("HOME", &temp_home);
|
||||
guard.set("USERPROFILE", &temp_home);
|
||||
|
||||
let result = find_playwright_chromium();
|
||||
std::env::remove_var("PLAYWRIGHT_BROWSERS_PATH");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_args_headless_includes_headless_flag() {
|
||||
let opts = LaunchOptions {
|
||||
headless: true,
|
||||
..Default::default()
|
||||
};
|
||||
let result = build_chrome_args(&opts).unwrap();
|
||||
assert!(result.args.iter().any(|a| a == "--headless=new"));
|
||||
assert!(result
|
||||
.args
|
||||
.iter()
|
||||
.any(|a| a == "--enable-unsafe-swiftshader"));
|
||||
assert!(result.args.iter().any(|a| a == "--window-size=1280,720"));
|
||||
// Temp dir created when no profile
|
||||
assert!(result.temp_user_data_dir.is_some());
|
||||
let dir = result.temp_user_data_dir.unwrap();
|
||||
assert!(dir.exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_args_headed_no_headless_flag() {
|
||||
let opts = LaunchOptions {
|
||||
headless: false,
|
||||
..Default::default()
|
||||
};
|
||||
let result = build_chrome_args(&opts).unwrap();
|
||||
assert!(!result.args.iter().any(|a| a.contains("--headless")));
|
||||
assert!(!result
|
||||
.args
|
||||
.iter()
|
||||
.any(|a| a == "--enable-unsafe-swiftshader"));
|
||||
assert!(!result.args.iter().any(|a| a.starts_with("--window-size=")));
|
||||
// Temp dir created when no profile
|
||||
assert!(result.temp_user_data_dir.is_some());
|
||||
let dir = result.temp_user_data_dir.unwrap();
|
||||
assert!(dir.exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_args_temp_user_data_dir_created() {
|
||||
let opts = LaunchOptions::default();
|
||||
let result = build_chrome_args(&opts).unwrap();
|
||||
let dir = result.temp_user_data_dir.as_ref().unwrap();
|
||||
assert!(dir.exists());
|
||||
assert!(result
|
||||
.args
|
||||
.iter()
|
||||
.any(|a| a.starts_with("--user-data-dir=")));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_args_profile_no_temp_dir() {
|
||||
let opts = LaunchOptions {
|
||||
profile: Some("/tmp/my-profile".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let result = build_chrome_args(&opts).unwrap();
|
||||
assert!(result.temp_user_data_dir.is_none());
|
||||
assert!(result
|
||||
.args
|
||||
.iter()
|
||||
.any(|a| a == "--user-data-dir=/tmp/my-profile"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_args_custom_window_size_not_overridden() {
|
||||
let opts = LaunchOptions {
|
||||
headless: true,
|
||||
args: vec!["--window-size=1920,1080".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
let result = build_chrome_args(&opts).unwrap();
|
||||
assert!(!result.args.iter().any(|a| a == "--window-size=1280,720"));
|
||||
assert!(result.args.iter().any(|a| a == "--window-size=1920,1080"));
|
||||
if let Some(ref dir) = result.temp_user_data_dir {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_args_start_maximized_suppresses_default_window_size() {
|
||||
let opts = LaunchOptions {
|
||||
headless: true,
|
||||
args: vec!["--start-maximized".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
let result = build_chrome_args(&opts).unwrap();
|
||||
assert!(!result.args.iter().any(|a| a == "--window-size=1280,720"));
|
||||
assert!(result.args.iter().any(|a| a == "--start-maximized"));
|
||||
if let Some(ref dir) = result.temp_user_data_dir {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_args_disables_translate() {
|
||||
let opts = LaunchOptions::default();
|
||||
let result = build_chrome_args(&opts).unwrap();
|
||||
assert!(result
|
||||
.args
|
||||
.iter()
|
||||
.any(|a| a.contains("--disable-features") && a.contains("Translate")));
|
||||
if let Some(ref dir) = result.temp_user_data_dir {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_args_headless_with_extensions_skips_headless_flag() {
|
||||
let opts = LaunchOptions {
|
||||
headless: true,
|
||||
extensions: Some(vec!["/tmp/my-ext".to_string()]),
|
||||
..Default::default()
|
||||
};
|
||||
let result = build_chrome_args(&opts).unwrap();
|
||||
assert!(
|
||||
!result.args.iter().any(|a| a.contains("--headless")),
|
||||
"headless flag should be omitted when extensions are present"
|
||||
);
|
||||
assert!(
|
||||
!result.args.iter().any(|a| a.contains("--window-size")),
|
||||
"window-size should be omitted when extensions force headed mode"
|
||||
);
|
||||
assert!(result
|
||||
.args
|
||||
.iter()
|
||||
.any(|a| a.starts_with("--load-extension=")));
|
||||
if let Some(ref dir) = result.temp_user_data_dir {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_args_headed_with_extensions_no_headless_flag() {
|
||||
let opts = LaunchOptions {
|
||||
headless: false,
|
||||
extensions: Some(vec!["/tmp/my-ext".to_string()]),
|
||||
..Default::default()
|
||||
};
|
||||
let result = build_chrome_args(&opts).unwrap();
|
||||
assert!(
|
||||
!result.args.iter().any(|a| a.contains("--headless")),
|
||||
"headless flag should not be present in headed mode"
|
||||
);
|
||||
assert!(result
|
||||
.args
|
||||
.iter()
|
||||
.any(|a| a.starts_with("--load-extension=")));
|
||||
if let Some(ref dir) = result.temp_user_data_dir {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chrome_process_drop_cleans_temp_dir() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"agent-browser-chrome-drop-test-{}",
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
assert!(dir.exists());
|
||||
|
||||
{
|
||||
// Simulate a ChromeProcess with a temp dir but a dummy child.
|
||||
// We can't actually spawn Chrome here, but we can verify the Drop
|
||||
// logic by creating a small helper process.
|
||||
let child = spawn_noop_child();
|
||||
let _process = ChromeProcess {
|
||||
child,
|
||||
ws_url: String::new(),
|
||||
temp_user_data_dir: Some(dir.clone()),
|
||||
};
|
||||
// _process dropped here
|
||||
}
|
||||
|
||||
assert!(!dir.exists(), "Temp dir should be cleaned up on drop");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::Value;
|
||||
use tokio::sync::{broadcast, oneshot, Mutex};
|
||||
use tokio_tungstenite::connect_async;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
use super::types::{CdpCommand, CdpEvent, CdpMessage};
|
||||
|
||||
type PendingMap = Arc<Mutex<HashMap<u64, oneshot::Sender<CdpMessage>>>>;
|
||||
|
||||
/// Interval between WebSocket ping frames sent to keep the connection alive
|
||||
/// through intermediate proxies (reverse proxies, load balancers, service meshes).
|
||||
const WS_KEEPALIVE_INTERVAL_SECS: u64 = 30;
|
||||
|
||||
/// Raw incoming CDP message (text) broadcast to all subscribers.
|
||||
/// Used by the inspect proxy to forward responses and events to DevTools.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RawCdpMessage {
|
||||
pub text: String,
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
pub struct CdpClient {
|
||||
ws_tx: Arc<
|
||||
Mutex<
|
||||
@@ -26,35 +40,110 @@ pub struct CdpClient {
|
||||
next_id: AtomicU64,
|
||||
pending: PendingMap,
|
||||
event_tx: broadcast::Sender<CdpEvent>,
|
||||
raw_tx: broadcast::Sender<RawCdpMessage>,
|
||||
_reader_handle: tokio::task::JoinHandle<()>,
|
||||
_keepalive_handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl CdpClient {
|
||||
pub async fn connect(url: &str) -> Result<Self, String> {
|
||||
let (ws_stream, _) = connect_async(url)
|
||||
.await
|
||||
.map_err(|e| format!("CDP WebSocket connect failed: {}", e))?;
|
||||
Self::connect_with_headers(url, None).await
|
||||
}
|
||||
|
||||
pub async fn connect_with_headers(
|
||||
url: &str,
|
||||
headers: Option<Vec<(String, String)>>,
|
||||
) -> Result<Self, String> {
|
||||
let mut request = url
|
||||
.into_client_request()
|
||||
.map_err(|e| format!("Invalid WebSocket URL: {}", e))?;
|
||||
|
||||
if let Some(hdrs) = headers {
|
||||
let req_headers = request.headers_mut();
|
||||
for (key, value) in hdrs {
|
||||
if let (Ok(name), Ok(val)) = (
|
||||
key.parse::<tokio_tungstenite::tungstenite::http::header::HeaderName>(),
|
||||
value.parse::<tokio_tungstenite::tungstenite::http::header::HeaderValue>(),
|
||||
) {
|
||||
req_headers.insert(name, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ws_config = WebSocketConfig {
|
||||
max_message_size: None,
|
||||
max_frame_size: None,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (ws_stream, _) =
|
||||
tokio_tungstenite::connect_async_with_config(request, Some(ws_config), false)
|
||||
.await
|
||||
.map_err(|e| format!("CDP WebSocket connect failed: {}", e))?;
|
||||
|
||||
enable_tcp_keepalive(ws_stream.get_ref());
|
||||
|
||||
let (ws_tx, mut ws_rx) = ws_stream.split();
|
||||
let ws_tx = Arc::new(Mutex::new(ws_tx));
|
||||
|
||||
let pending: PendingMap = Arc::new(Mutex::new(HashMap::new()));
|
||||
let (event_tx, _) = broadcast::channel(256);
|
||||
let (raw_tx, _) = broadcast::channel(512);
|
||||
|
||||
let pending_clone = pending.clone();
|
||||
let event_tx_clone = event_tx.clone();
|
||||
let raw_tx_clone = raw_tx.clone();
|
||||
|
||||
// Notify used to stop the keepalive task when the reader loop exits.
|
||||
let (cancel_tx, mut cancel_rx) = tokio::sync::watch::channel(false);
|
||||
|
||||
let reader_handle = tokio::spawn(async move {
|
||||
while let Some(msg) = ws_rx.next().await {
|
||||
// Accept both Text and Binary frames — remote CDP proxies
|
||||
// (e.g. Browserless) may send responses as Binary frames.
|
||||
let msg = match msg {
|
||||
Ok(Message::Text(text)) => text,
|
||||
Ok(Message::Close(_)) => break,
|
||||
Ok(Message::Binary(data)) => match String::from_utf8(data) {
|
||||
Ok(text) => text,
|
||||
Err(_) => continue,
|
||||
},
|
||||
Ok(Message::Close(frame)) => {
|
||||
if std::env::var("AGENT_BROWSER_DEBUG").is_ok() {
|
||||
let reason = frame
|
||||
.as_ref()
|
||||
.map(|f| format!("code={}, reason={}", f.code, f.reason))
|
||||
.unwrap_or_else(|| "no frame".to_string());
|
||||
let _ =
|
||||
writeln!(std::io::stderr(), "[cdp] WebSocket Close: {}", reason);
|
||||
}
|
||||
break;
|
||||
}
|
||||
Ok(Message::Pong(_)) => continue,
|
||||
Ok(_) => continue,
|
||||
Err(_) => break,
|
||||
Err(e) => {
|
||||
if std::env::var("AGENT_BROWSER_DEBUG").is_ok() {
|
||||
let _ = writeln!(std::io::stderr(), "[cdp] WebSocket Error: {}", e);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Broadcast raw message for inspect proxy subscribers before typed parse,
|
||||
// so messages with negative IDs (used by the inspect proxy) are still delivered.
|
||||
if raw_tx_clone.receiver_count() > 0 {
|
||||
let session_id = serde_json::from_str::<serde_json::Value>(&msg)
|
||||
.ok()
|
||||
.and_then(|v| v.get("sessionId")?.as_str().map(String::from));
|
||||
let _ = raw_tx_clone.send(RawCdpMessage {
|
||||
text: msg.clone(),
|
||||
session_id,
|
||||
});
|
||||
}
|
||||
|
||||
let parsed: CdpMessage = match serde_json::from_str(&msg) {
|
||||
Ok(m) => m,
|
||||
// Expected for inspect proxy messages with negative IDs
|
||||
// (CdpMessage.id is u64); handled via raw broadcast above.
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
@@ -74,6 +163,33 @@ impl CdpClient {
|
||||
let _ = event_tx_clone.send(event);
|
||||
}
|
||||
}
|
||||
|
||||
// Reader loop exited (connection closed or error). Drop all pending
|
||||
// command senders so callers get an immediate channel-closed error
|
||||
// instead of waiting for the 30-second timeout.
|
||||
pending_clone.lock().await.clear();
|
||||
|
||||
// Stop the keepalive task — the connection is gone.
|
||||
let _ = cancel_tx.send(true);
|
||||
});
|
||||
|
||||
// Spawn a keepalive task that sends WebSocket Ping frames at a regular
|
||||
// interval. This prevents intermediate proxies (Envoy, nginx, OpenResty,
|
||||
// cloud load balancers) from closing idle WebSocket connections. If the
|
||||
// send fails, the connection is dead and we stop pinging.
|
||||
let keepalive_tx = ws_tx.clone();
|
||||
let keepalive_handle = tokio::spawn(async move {
|
||||
let interval = std::time::Duration::from_secs(WS_KEEPALIVE_INTERVAL_SECS);
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(interval) => {}
|
||||
_ = cancel_rx.changed() => break,
|
||||
}
|
||||
let mut tx = keepalive_tx.lock().await;
|
||||
if tx.send(Message::Ping(Vec::new())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
@@ -81,7 +197,9 @@ impl CdpClient {
|
||||
next_id: AtomicU64::new(1),
|
||||
pending,
|
||||
event_tx,
|
||||
raw_tx,
|
||||
_reader_handle: reader_handle,
|
||||
_keepalive_handle: keepalive_handle,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -97,7 +215,7 @@ impl CdpClient {
|
||||
id,
|
||||
method: method.to_string(),
|
||||
params,
|
||||
session_id: session_id.map(|s| s.to_string()),
|
||||
session_id: session_id.filter(|s| !s.is_empty()).map(|s| s.to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&cmd)
|
||||
@@ -138,6 +256,21 @@ impl CdpClient {
|
||||
self.event_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Subscribe to all raw incoming CDP messages (responses + events).
|
||||
/// Used by the inspect proxy to forward traffic to the DevTools frontend.
|
||||
pub fn subscribe_raw(&self) -> broadcast::Receiver<RawCdpMessage> {
|
||||
self.raw_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Create a lightweight handle for the inspect WebSocket proxy.
|
||||
/// Contains only what's needed to forward messages bidirectionally.
|
||||
pub fn inspect_handle(&self) -> InspectProxyHandle {
|
||||
InspectProxyHandle {
|
||||
ws_tx: self.ws_tx.clone(),
|
||||
raw_tx: self.raw_tx.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_command_typed<P: serde::Serialize, R: serde::de::DeserializeOwned>(
|
||||
&self,
|
||||
method: &str,
|
||||
@@ -160,4 +293,69 @@ impl CdpClient {
|
||||
) -> Result<Value, String> {
|
||||
self.send_command(method, None, session_id).await
|
||||
}
|
||||
|
||||
/// Send raw JSON through the WebSocket without tracking a response.
|
||||
/// Used by the inspect proxy to forward DevTools frontend messages.
|
||||
pub async fn send_raw(&self, json: String) -> Result<(), String> {
|
||||
let mut ws_tx = self.ws_tx.lock().await;
|
||||
ws_tx
|
||||
.send(Message::Text(json))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send raw CDP message: {}", e))
|
||||
}
|
||||
}
|
||||
|
||||
type WsTx = Arc<
|
||||
Mutex<
|
||||
futures_util::stream::SplitSink<
|
||||
tokio_tungstenite::WebSocketStream<
|
||||
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
||||
>,
|
||||
Message,
|
||||
>,
|
||||
>,
|
||||
>;
|
||||
|
||||
/// Lightweight handle for the inspect WebSocket proxy, holding only
|
||||
/// the cloneable parts of CdpClient needed for bidirectional message forwarding.
|
||||
pub struct InspectProxyHandle {
|
||||
ws_tx: WsTx,
|
||||
raw_tx: broadcast::Sender<RawCdpMessage>,
|
||||
}
|
||||
|
||||
impl InspectProxyHandle {
|
||||
pub async fn send_raw(&self, json: String) -> Result<(), String> {
|
||||
let mut ws_tx = self.ws_tx.lock().await;
|
||||
ws_tx
|
||||
.send(Message::Text(json))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send raw CDP message: {}", e))
|
||||
}
|
||||
|
||||
pub fn subscribe_raw(&self) -> broadcast::Receiver<RawCdpMessage> {
|
||||
self.raw_tx.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable TCP SO_KEEPALIVE on the underlying socket of a WebSocket connection.
|
||||
/// This is best-effort: failures are silently ignored since the WebSocket-level
|
||||
/// Ping keepalive provides the primary connection liveness mechanism.
|
||||
fn enable_tcp_keepalive(stream: &tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>) {
|
||||
let tcp_stream = match stream {
|
||||
tokio_tungstenite::MaybeTlsStream::Plain(s) => s,
|
||||
tokio_tungstenite::MaybeTlsStream::Rustls(s) => s.get_ref().0,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
// SockRef borrows the fd without taking ownership.
|
||||
let sock = socket2::SockRef::from(tcp_stream);
|
||||
let keepalive = socket2::TcpKeepalive::new().with_time(std::time::Duration::from_secs(30));
|
||||
|
||||
// with_interval sets TCP_KEEPINTVL — the time between probes after the
|
||||
// first keepalive probe goes unanswered. Available on most platforms
|
||||
// (Linux, macOS, Windows, FreeBSD, etc.) but not OpenBSD or Haiku.
|
||||
#[cfg(not(any(target_os = "openbsd", target_os = "haiku")))]
|
||||
let keepalive = keepalive.with_interval(std::time::Duration::from_secs(10));
|
||||
|
||||
let _ = sock.set_tcp_keepalive(&keepalive);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
use super::types::BrowserVersionInfo;
|
||||
|
||||
/// Default timeout for CDP discovery HTTP requests.
|
||||
const DEFAULT_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Discover the CDP WebSocket URL for the given host and port.
|
||||
///
|
||||
/// Tries three methods in order: `/json/version`, `/json/list`, and a direct
|
||||
/// WebSocket connection to `/devtools/browser`. The returned URL has its
|
||||
/// host/port rewritten to match the requested target.
|
||||
///
|
||||
/// An optional `query` string (without the leading `?`) is appended to the
|
||||
/// final WebSocket URL so that user-supplied URL parameters (e.g.
|
||||
/// `?mode=Hello`) are forwarded to the remote endpoint.
|
||||
pub async fn discover_cdp_url(
|
||||
host: &str,
|
||||
port: u16,
|
||||
query: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
discover_cdp_url_with_timeout(host, port, query, DEFAULT_DISCOVERY_TIMEOUT).await
|
||||
}
|
||||
|
||||
/// Like [`discover_cdp_url`] but with a custom request timeout.
|
||||
pub async fn discover_cdp_url_with_timeout(
|
||||
host: &str,
|
||||
port: u16,
|
||||
query: Option<&str>,
|
||||
timeout: Duration,
|
||||
) -> Result<String, String> {
|
||||
// Primary: /json/version (standard path)
|
||||
let version_err = match fetch_cdp_info(host, port, timeout).await {
|
||||
Ok(info) => {
|
||||
if let Some(ws_url) = info.web_socket_debugger_url {
|
||||
return Ok(append_query(&rewrite_ws_host(&ws_url, host, port), query));
|
||||
}
|
||||
format!(
|
||||
"No webSocketDebuggerUrl in /json/version at {}:{}",
|
||||
host, port
|
||||
)
|
||||
}
|
||||
Err(e) => e,
|
||||
};
|
||||
|
||||
// Fallback: /json/list (returns target list; look for the browser target)
|
||||
let list_err = match fetch_cdp_list(host, port, timeout).await {
|
||||
Ok(ws_url) => return Ok(append_query(&rewrite_ws_host(&ws_url, host, port), query)),
|
||||
Err(e) => e,
|
||||
};
|
||||
|
||||
// Final fallback: direct WebSocket at /devtools/browser.
|
||||
// Chrome 136+ with UI-based remote debugging (chrome://inspect) exposes
|
||||
// CDP over WebSocket but does not serve HTTP discovery endpoints.
|
||||
match discover_cdp_ws(host, port, timeout).await {
|
||||
Ok(ws_url) => Ok(append_query(&ws_url, query)),
|
||||
Err(ws_err) => Err(format!(
|
||||
"All CDP discovery methods failed for {}:{}: /json/version: {}; /json/list: {}; WebSocket: {}",
|
||||
host, port, version_err, list_err, ws_err
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bracket an IPv6 address for use in URLs. No-op for IPv4 or already-bracketed addresses.
|
||||
fn bracket_ipv6(host: &str) -> String {
|
||||
if host.contains(':') && !host.starts_with('[') {
|
||||
format!("[{}]", host)
|
||||
} else {
|
||||
host.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch `/json/version` from the given host:port and parse the response.
|
||||
async fn fetch_cdp_info(
|
||||
host: &str,
|
||||
port: u16,
|
||||
timeout: Duration,
|
||||
) -> Result<BrowserVersionInfo, String> {
|
||||
let url = format!("http://{}:{}/json/version", bracket_ipv6(host), port);
|
||||
|
||||
let body = tokio::time::timeout(timeout, reqwest_get_string(&url))
|
||||
.await
|
||||
.map_err(|_| format!("Timeout connecting to CDP at {}:{}", host, port))?
|
||||
.map_err(|e| format!("Failed to connect to CDP at {}:{}: {}", host, port, e))?;
|
||||
|
||||
serde_json::from_str(&body).map_err(|e| format!("Invalid /json/version response: {}", e))
|
||||
}
|
||||
|
||||
/// Rewrite the host and port in a WebSocket URL to match the target we
|
||||
/// actually connected to. Chrome's `/json/version` always returns
|
||||
/// `ws://127.0.0.1:<local-port>/...` which is unreachable when the
|
||||
/// browser is on a remote machine or behind a port-forward.
|
||||
fn rewrite_ws_host(ws_url: &str, host: &str, port: u16) -> String {
|
||||
if let Ok(mut parsed) = url::Url::parse(ws_url) {
|
||||
let _ = parsed.set_host(Some(&bracket_ipv6(host)));
|
||||
let _ = parsed.set_port(Some(port));
|
||||
parsed.to_string()
|
||||
} else {
|
||||
ws_url.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a query string to a URL, preserving any existing query parameters.
|
||||
fn append_query(url: &str, query: Option<&str>) -> String {
|
||||
match query {
|
||||
Some(q) if !q.is_empty() => {
|
||||
if let Ok(mut parsed) = url::Url::parse(url) {
|
||||
{
|
||||
let mut pairs = parsed.query_pairs_mut();
|
||||
pairs.extend_pairs(url::form_urlencoded::parse(q.as_bytes()));
|
||||
}
|
||||
parsed.to_string()
|
||||
} else {
|
||||
// Fallback: raw string append
|
||||
if url.contains('?') {
|
||||
format!("{}&{}", url, q)
|
||||
} else {
|
||||
format!("{}?{}", url, q)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => url.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch `/json/list` and extract the `webSocketDebuggerUrl` from the first
|
||||
/// target with `type == "browser"`, or the first target if none has that type.
|
||||
async fn fetch_cdp_list(host: &str, port: u16, timeout: Duration) -> Result<String, String> {
|
||||
let url = format!("http://{}:{}/json/list", bracket_ipv6(host), port);
|
||||
|
||||
let body = tokio::time::timeout(timeout, reqwest_get_string(&url))
|
||||
.await
|
||||
.map_err(|_| format!("Timeout connecting to /json/list at {}:{}", host, port))?
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed to connect to /json/list at {}:{}: {}",
|
||||
host, port, e
|
||||
)
|
||||
})?;
|
||||
|
||||
let targets: Vec<serde_json::Value> =
|
||||
serde_json::from_str(&body).map_err(|e| format!("Invalid /json/list response: {}", e))?;
|
||||
|
||||
// Prefer targets with type "browser", fall back to first target with a ws URL
|
||||
let browser_target = targets
|
||||
.iter()
|
||||
.find(|t| t.get("type").and_then(|v| v.as_str()) == Some("browser"));
|
||||
|
||||
let target = browser_target.or_else(|| targets.first());
|
||||
|
||||
target
|
||||
.and_then(|t| t.get("webSocketDebuggerUrl"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "No webSocketDebuggerUrl found in /json/list targets".to_string())
|
||||
}
|
||||
|
||||
/// Discover a CDP endpoint by connecting directly to `ws://host:port/devtools/browser`
|
||||
/// and verifying it responds to `Browser.getVersion`.
|
||||
/// Returns the WebSocket URL on success.
|
||||
async fn discover_cdp_ws(host: &str, port: u16, timeout: Duration) -> Result<String, String> {
|
||||
let ws_url = format!("ws://{}:{}/devtools/browser", bracket_ipv6(host), port);
|
||||
|
||||
tokio::time::timeout(timeout, async {
|
||||
let (mut ws_stream, _) = tokio_tungstenite::connect_async(&ws_url)
|
||||
.await
|
||||
.map_err(|e| format!("WebSocket connect failed at {}: {}", ws_url, e))?;
|
||||
|
||||
let cmd = r#"{"id":1,"method":"Browser.getVersion"}"#;
|
||||
ws_stream
|
||||
.send(Message::Text(cmd.into()))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send command: {}", e))?;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CdpReply {
|
||||
id: u64,
|
||||
}
|
||||
|
||||
let mut result: Result<(), String> = Err("No valid CDP response received".to_string());
|
||||
while let Some(msg) = ws_stream.next().await {
|
||||
match msg {
|
||||
Ok(Message::Text(text)) => {
|
||||
if serde_json::from_str::<CdpReply>(&text).is_ok_and(|r| r.id == 1) {
|
||||
result = Ok(());
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Message::Close(_)) | Err(_) => break,
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
|
||||
let _ = ws_stream.close(None).await;
|
||||
result
|
||||
})
|
||||
.await
|
||||
.map_err(|_| format!("Timeout connecting to WebSocket at {}", ws_url))?
|
||||
.map(|()| ws_url)
|
||||
}
|
||||
|
||||
async fn reqwest_get_string(url: &str) -> Result<String, String> {
|
||||
let resp = reqwest::get(url).await.map_err(|e| e.to_string())?;
|
||||
resp.text().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
const HTTP_404: &str =
|
||||
"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
|
||||
|
||||
fn http_200(body: &str) -> String {
|
||||
format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\nContent-Type: application/json\r\n\r\n{}",
|
||||
body.len(), body
|
||||
)
|
||||
}
|
||||
|
||||
async fn accept_http(listener: &TcpListener, response: &str) {
|
||||
let (mut s, _) = listener.accept().await.unwrap();
|
||||
let mut buf = [0u8; 1024];
|
||||
let _ = s.read(&mut buf).await;
|
||||
s.write_all(response.as_bytes()).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discovers_ws_url_from_json_version() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = tokio::spawn(async move {
|
||||
accept_http(
|
||||
&listener,
|
||||
&http_200(r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:1234/"}"#),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let ws_url = discover_cdp_url("127.0.0.1", port, None).await.unwrap();
|
||||
assert_eq!(ws_url, format!("ws://127.0.0.1:{}/", port));
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_error_when_version_returns_invalid_json() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = tokio::spawn(async move {
|
||||
accept_http(&listener, &http_200("not-json")).await;
|
||||
// /json/list and ws fallback both fail (server closes)
|
||||
});
|
||||
|
||||
let err = discover_cdp_url("127.0.0.1", port, None).await.unwrap_err();
|
||||
assert!(err.contains("Invalid /json/version response"));
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn falls_back_to_json_list_on_version_404() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = tokio::spawn(async move {
|
||||
accept_http(&listener, HTTP_404).await;
|
||||
accept_http(
|
||||
&listener,
|
||||
&http_200(r#"[{"type":"browser","webSocketDebuggerUrl":"ws://127.0.0.1:1234/devtools/browser/abc"}]"#),
|
||||
).await;
|
||||
});
|
||||
|
||||
let ws_url = discover_cdp_url("127.0.0.1", port, None).await.unwrap();
|
||||
assert!(ws_url.contains("/devtools/browser/abc"));
|
||||
assert!(ws_url.contains(&port.to_string()));
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn falls_back_to_ws_when_http_returns_404() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = tokio::spawn(async move {
|
||||
// /json/version -> 404, /json/list -> 404
|
||||
accept_http(&listener, HTTP_404).await;
|
||||
accept_http(&listener, HTTP_404).await;
|
||||
|
||||
// WebSocket handshake + respond to Browser.getVersion
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
|
||||
if let Some(Ok(Message::Text(text))) = ws.next().await {
|
||||
let req: serde_json::Value = serde_json::from_str(&text).unwrap();
|
||||
let id = req.get("id").unwrap();
|
||||
let reply = format!(
|
||||
r#"{{"id":{},"result":{{"protocolVersion":"1.3","product":"Chrome/136"}}}}"#,
|
||||
id
|
||||
);
|
||||
ws.send(Message::Text(reply)).await.unwrap();
|
||||
}
|
||||
let _ = ws.close(None).await;
|
||||
});
|
||||
|
||||
let ws_url = discover_cdp_url("127.0.0.1", port, None).await.unwrap();
|
||||
assert_eq!(ws_url, format!("ws://127.0.0.1:{}/devtools/browser", port));
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_ws_host_replaces_host_and_port() {
|
||||
let original = "ws://127.0.0.1:9222/devtools/browser/abc";
|
||||
let rewritten = rewrite_ws_host(original, "10.211.55.12", 9223);
|
||||
assert_eq!(rewritten, "ws://10.211.55.12:9223/devtools/browser/abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_ws_host_handles_ipv6() {
|
||||
let original = "ws://127.0.0.1:9222/devtools/browser/abc";
|
||||
let rewritten = rewrite_ws_host(original, "::1", 9222);
|
||||
assert_eq!(rewritten, "ws://[::1]:9222/devtools/browser/abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_query_adds_params_to_url_without_query() {
|
||||
let url = "ws://127.0.0.1:9222/devtools/browser/abc";
|
||||
let result = append_query(url, Some("mode=Hello"));
|
||||
assert_eq!(
|
||||
result,
|
||||
"ws://127.0.0.1:9222/devtools/browser/abc?mode=Hello"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_query_merges_with_existing_query() {
|
||||
let url = "ws://127.0.0.1:9222/devtools/browser/abc?token=xyz";
|
||||
let result = append_query(url, Some("mode=Hello"));
|
||||
assert_eq!(
|
||||
result,
|
||||
"ws://127.0.0.1:9222/devtools/browser/abc?token=xyz&mode=Hello"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_query_noop_for_none() {
|
||||
let url = "ws://127.0.0.1:9222/devtools/browser/abc";
|
||||
let result = append_query(url, None);
|
||||
assert_eq!(result, url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_query_noop_for_empty() {
|
||||
let url = "ws://127.0.0.1:9222/devtools/browser/abc";
|
||||
let result = append_query(url, Some(""));
|
||||
assert_eq!(result, url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_query_handles_multiple_params() {
|
||||
let url = "ws://127.0.0.1:9222/devtools/browser/abc";
|
||||
let result = append_query(url, Some("mode=Hello&token=abc"));
|
||||
assert_eq!(
|
||||
result,
|
||||
"ws://127.0.0.1:9222/devtools/browser/abc?mode=Hello&token=abc"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discover_preserves_query_params() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = tokio::spawn(async move {
|
||||
accept_http(
|
||||
&listener,
|
||||
&http_200(r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:1234/"}"#),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let ws_url = discover_cdp_url("127.0.0.1", port, Some("mode=Hello"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ws_url, format!("ws://127.0.0.1:{}/?mode=Hello", port));
|
||||
server.await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::net::TcpListener;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::discovery::discover_cdp_url_with_timeout;
|
||||
|
||||
const LIGHTPANDA_STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const LIGHTPANDA_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
||||
const LIGHTPANDA_DISCOVERY_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
const LIGHTPANDA_SESSION_TIMEOUT_SECS: u64 = 604800; // 1 week, the documented maximum
|
||||
const MAX_LOG_LINES: usize = 40;
|
||||
|
||||
pub struct LightpandaProcess {
|
||||
child: Child,
|
||||
pub ws_url: String,
|
||||
_log_drainers: Vec<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl LightpandaProcess {
|
||||
pub fn kill(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LightpandaProcess {
|
||||
fn drop(&mut self) {
|
||||
self.kill();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct LightpandaLaunchOptions {
|
||||
pub executable_path: Option<String>,
|
||||
pub proxy: Option<String>,
|
||||
pub port: Option<u16>,
|
||||
}
|
||||
|
||||
fn build_lightpanda_serve_args(port: u16, proxy: Option<&str>) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"serve".to_string(),
|
||||
"--host".to_string(),
|
||||
"127.0.0.1".to_string(),
|
||||
"--port".to_string(),
|
||||
port.to_string(),
|
||||
"--timeout".to_string(),
|
||||
LIGHTPANDA_SESSION_TIMEOUT_SECS.to_string(),
|
||||
];
|
||||
|
||||
if let Some(proxy) = proxy {
|
||||
args.push("--http_proxy".to_string());
|
||||
args.push(proxy.to_string());
|
||||
}
|
||||
|
||||
args
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct LaunchLogBuffer {
|
||||
stdout: Arc<Mutex<VecDeque<String>>>,
|
||||
stderr: Arc<Mutex<VecDeque<String>>>,
|
||||
}
|
||||
|
||||
impl LaunchLogBuffer {
|
||||
fn push_stdout(&self, line: String) {
|
||||
push_bounded(&self.stdout, line);
|
||||
}
|
||||
|
||||
fn push_stderr(&self, line: String) {
|
||||
push_bounded(&self.stderr, line);
|
||||
}
|
||||
|
||||
fn snapshot_stdout(&self) -> Vec<String> {
|
||||
self.stdout
|
||||
.lock()
|
||||
.expect("stdout log buffer poisoned")
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn snapshot_stderr(&self) -> Vec<String> {
|
||||
self.stderr
|
||||
.lock()
|
||||
.expect("stderr log buffer poisoned")
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn push_bounded(buffer: &Mutex<VecDeque<String>>, line: String) {
|
||||
let mut guard = buffer.lock().expect("log buffer poisoned");
|
||||
if guard.len() >= MAX_LOG_LINES {
|
||||
guard.pop_front();
|
||||
}
|
||||
guard.push_back(line);
|
||||
}
|
||||
|
||||
pub fn find_lightpanda() -> Option<PathBuf> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if let Ok(output) = Command::new("which").arg("lightpanda").output() {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Some(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Ok(output) = Command::new("where").arg("lightpanda").output() {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
if !path.is_empty() {
|
||||
return Some(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let candidates = [
|
||||
home.join(".lightpanda/lightpanda"),
|
||||
home.join(".local/bin/lightpanda"),
|
||||
];
|
||||
for c in &candidates {
|
||||
if c.exists() {
|
||||
return Some(c.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn launch_lightpanda(
|
||||
options: &LightpandaLaunchOptions,
|
||||
) -> Result<LightpandaProcess, String> {
|
||||
let binary_path = match &options.executable_path {
|
||||
Some(p) => PathBuf::from(p),
|
||||
None => find_lightpanda().ok_or(
|
||||
"Lightpanda not found. Install it from https://lightpanda.io/docs/open-source/installation or use --executable-path.",
|
||||
)?,
|
||||
};
|
||||
|
||||
let port = match options.port {
|
||||
Some(p) => p,
|
||||
None => TcpListener::bind("127.0.0.1:0")
|
||||
.and_then(|l| l.local_addr())
|
||||
.map(|a| a.port())
|
||||
.map_err(|e| format!("Failed to find an available port for Lightpanda: {}", e))?,
|
||||
};
|
||||
let args = build_lightpanda_serve_args(port, options.proxy.as_deref());
|
||||
|
||||
let mut child = Command::new(&binary_path)
|
||||
.args(&args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to launch Lightpanda at {:?}: {}", binary_path, e))?;
|
||||
|
||||
let (log_buffer, log_drainers) = start_log_drainers(&mut child)?;
|
||||
|
||||
let ws_url =
|
||||
match wait_for_lightpanda_ready(&mut child, port, &log_buffer, LIGHTPANDA_STARTUP_TIMEOUT)
|
||||
.await
|
||||
{
|
||||
Ok(url) => url,
|
||||
Err(e) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
Ok(LightpandaProcess {
|
||||
child,
|
||||
ws_url,
|
||||
_log_drainers: log_drainers,
|
||||
})
|
||||
}
|
||||
|
||||
fn start_log_drainers(
|
||||
child: &mut Child,
|
||||
) -> Result<(LaunchLogBuffer, Vec<std::thread::JoinHandle<()>>), String> {
|
||||
let stdout = child.stdout.take().ok_or_else(|| {
|
||||
let _ = child.kill();
|
||||
"Failed to capture Lightpanda stdout".to_string()
|
||||
})?;
|
||||
let stderr = child.stderr.take().ok_or_else(|| {
|
||||
let _ = child.kill();
|
||||
"Failed to capture Lightpanda stderr".to_string()
|
||||
})?;
|
||||
|
||||
let logs = LaunchLogBuffer::default();
|
||||
let stdout_logs = logs.clone();
|
||||
let stderr_logs = logs.clone();
|
||||
|
||||
let stdout_handle =
|
||||
std::thread::spawn(move || drain_reader(stdout, move |line| stdout_logs.push_stdout(line)));
|
||||
let stderr_handle =
|
||||
std::thread::spawn(move || drain_reader(stderr, move |line| stderr_logs.push_stderr(line)));
|
||||
|
||||
Ok((logs, vec![stdout_handle, stderr_handle]))
|
||||
}
|
||||
|
||||
fn drain_reader<R, F>(reader: R, mut push: F)
|
||||
where
|
||||
R: std::io::Read,
|
||||
F: FnMut(String),
|
||||
{
|
||||
for line in BufReader::new(reader).lines() {
|
||||
match line {
|
||||
Ok(line) => push(line),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_lightpanda_ready(
|
||||
child: &mut Child,
|
||||
port: u16,
|
||||
logs: &LaunchLogBuffer,
|
||||
startup_timeout: Duration,
|
||||
) -> Result<String, String> {
|
||||
let deadline = std::time::Instant::now() + startup_timeout;
|
||||
let mut last_probe_error = None;
|
||||
|
||||
loop {
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
// Give the drainer threads a brief window to flush the last log lines
|
||||
// before we snapshot them. This is best-effort: lines written just
|
||||
// before exit may still be missing, but the most useful output (early
|
||||
// startup errors) will already be in the buffer.
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
return Err(lightpanda_launch_error(
|
||||
&format!(
|
||||
"Lightpanda exited before CDP became ready (status: {})",
|
||||
status
|
||||
),
|
||||
logs,
|
||||
last_probe_error.as_deref(),
|
||||
));
|
||||
}
|
||||
|
||||
match discover_cdp_url_with_timeout("127.0.0.1", port, None, LIGHTPANDA_DISCOVERY_TIMEOUT)
|
||||
.await
|
||||
{
|
||||
Ok(ws_url) => return Ok(ws_url),
|
||||
Err(err) => last_probe_error = Some(err),
|
||||
}
|
||||
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return Err(lightpanda_launch_error(
|
||||
&format!(
|
||||
"Timed out after {}ms waiting for Lightpanda CDP endpoint on port {}",
|
||||
startup_timeout.as_millis(),
|
||||
port
|
||||
),
|
||||
logs,
|
||||
last_probe_error.as_deref(),
|
||||
));
|
||||
}
|
||||
|
||||
tokio::time::sleep(LIGHTPANDA_POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn lightpanda_launch_error(
|
||||
message: &str,
|
||||
logs: &LaunchLogBuffer,
|
||||
last_probe_error: Option<&str>,
|
||||
) -> String {
|
||||
let stdout_lines = logs.snapshot_stdout();
|
||||
let stderr_lines = logs.snapshot_stderr();
|
||||
let mut details = Vec::new();
|
||||
|
||||
if let Some(err) = last_probe_error {
|
||||
details.push(format!("Last probe error: {}", err));
|
||||
}
|
||||
|
||||
if !stderr_lines.is_empty() {
|
||||
details.push(format!(
|
||||
"Lightpanda stderr (last {} lines):\n {}",
|
||||
stderr_lines.len(),
|
||||
stderr_lines.join("\n ")
|
||||
));
|
||||
}
|
||||
|
||||
if !stdout_lines.is_empty() {
|
||||
details.push(format!(
|
||||
"Lightpanda stdout (last {} lines):\n {}",
|
||||
stdout_lines.len(),
|
||||
stdout_lines.join("\n ")
|
||||
));
|
||||
}
|
||||
|
||||
if details.is_empty() {
|
||||
format!("{} (no stdout/stderr output from Lightpanda)", message)
|
||||
} else {
|
||||
format!("{}\n{}", message, details.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener as TokioTcpListener;
|
||||
|
||||
fn unused_port() -> u16 {
|
||||
std::net::TcpListener::bind("127.0.0.1:0")
|
||||
.unwrap()
|
||||
.local_addr()
|
||||
.unwrap()
|
||||
.port()
|
||||
}
|
||||
|
||||
async fn serve_json_version_once_after_delay(port: u16, delay_ms: u64, body: &'static str) {
|
||||
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
|
||||
let listener = TokioTcpListener::bind(("127.0.0.1", port)).await.unwrap();
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut buf = [0u8; 1024];
|
||||
let _ = socket.read(&mut buf).await;
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\nContent-Type: application/json\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
socket.write_all(response.as_bytes()).await.unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn waits_for_ready_without_logs() {
|
||||
let port = unused_port();
|
||||
tokio::spawn(serve_json_version_once_after_delay(
|
||||
port,
|
||||
150,
|
||||
r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:9222/"}"#,
|
||||
));
|
||||
|
||||
let mut child = Command::new("/bin/sh")
|
||||
.args(["-c", "sleep 5"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
|
||||
let (logs, _drainers) = start_log_drainers(&mut child).unwrap();
|
||||
let ws_url = wait_for_lightpanda_ready(&mut child, port, &logs, LIGHTPANDA_STARTUP_TIMEOUT)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(ws_url, format!("ws://127.0.0.1:{}/", port));
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn child_exit_surfaces_logs() {
|
||||
let port = unused_port();
|
||||
let mut child = Command::new("/bin/sh")
|
||||
.args(["-c", "echo boom >&2; sleep 0.1; exit 23"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
|
||||
let (logs, _drainers) = start_log_drainers(&mut child).unwrap();
|
||||
let err = wait_for_lightpanda_ready(&mut child, port, &logs, LIGHTPANDA_STARTUP_TIMEOUT)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.contains("Lightpanda exited before CDP became ready"));
|
||||
assert!(err.contains("boom"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn timeout_reports_last_probe_error() {
|
||||
let port = unused_port();
|
||||
let mut child = Command::new("/bin/sh")
|
||||
.args(["-c", "sleep 30"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
|
||||
let timeout = Duration::from_millis(300);
|
||||
let (logs, _drainers) = start_log_drainers(&mut child).unwrap();
|
||||
let err = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
wait_for_lightpanda_ready(&mut child, port, &logs, timeout),
|
||||
)
|
||||
.await
|
||||
.expect("ready wait should return before outer timeout")
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.contains("Timed out after 300ms waiting for Lightpanda CDP endpoint"));
|
||||
assert!(
|
||||
err.contains("Failed to connect to CDP") || err.contains("Timeout connecting to CDP")
|
||||
);
|
||||
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_lightpanda_returns_none_when_missing() {
|
||||
let _ = find_lightpanda();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lightpanda_launch_error_no_logs() {
|
||||
let logs = LaunchLogBuffer::default();
|
||||
let msg = lightpanda_launch_error("Lightpanda exited", &logs, None);
|
||||
assert!(msg.contains("no stdout/stderr output"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lightpanda_launch_error_with_lines() {
|
||||
let logs = LaunchLogBuffer::default();
|
||||
logs.push_stdout("stdout line".to_string());
|
||||
logs.push_stderr("stderr line".to_string());
|
||||
let msg = lightpanda_launch_error("Lightpanda exited", &logs, Some("connect failed"));
|
||||
assert!(msg.contains("stdout line"));
|
||||
assert!(msg.contains("stderr line"));
|
||||
assert!(msg.contains("Last probe error: connect failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_options() {
|
||||
let opts = LightpandaLaunchOptions::default();
|
||||
assert!(opts.executable_path.is_none());
|
||||
assert!(opts.proxy.is_none());
|
||||
assert!(opts.port.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_lightpanda_serve_args_sets_explicit_session_timeout() {
|
||||
let args = build_lightpanda_serve_args(9222, None);
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"serve".to_string(),
|
||||
"--host".to_string(),
|
||||
"127.0.0.1".to_string(),
|
||||
"--port".to_string(),
|
||||
"9222".to_string(),
|
||||
"--timeout".to_string(),
|
||||
"604800".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_lightpanda_serve_args_with_proxy() {
|
||||
let args = build_lightpanda_serve_args(9333, Some("http://127.0.0.1:8080"));
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"serve".to_string(),
|
||||
"--host".to_string(),
|
||||
"127.0.0.1".to_string(),
|
||||
"--port".to_string(),
|
||||
"9333".to_string(),
|
||||
"--timeout".to_string(),
|
||||
"604800".to_string(),
|
||||
"--http_proxy".to_string(),
|
||||
"http://127.0.0.1:8080".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
pub mod chrome;
|
||||
pub mod client;
|
||||
pub mod discovery;
|
||||
pub mod lightpanda;
|
||||
pub mod types;
|
||||
|
||||
@@ -1,6 +1,51 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Deserialize a value that may be either a string or an integer into a String.
|
||||
/// Lightpanda sends numeric nodeIds/childIds in AX tree responses, while Chrome
|
||||
/// sends strings. This accepts both.
|
||||
fn string_or_int<'de, D>(deserializer: D) -> Result<String, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let v = Value::deserialize(deserializer)?;
|
||||
match v {
|
||||
Value::String(s) => Ok(s),
|
||||
Value::Number(n) => Ok(n.to_string()),
|
||||
other => Err(serde::de::Error::custom(format!(
|
||||
"expected string or integer, got {}",
|
||||
other
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize an optional Vec where each element may be a string or integer.
|
||||
fn opt_vec_string_or_int<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let opt: Option<Vec<Value>> = Option::deserialize(deserializer)?;
|
||||
match opt {
|
||||
None => Ok(None),
|
||||
Some(vec) => {
|
||||
let mut result = Vec::with_capacity(vec.len());
|
||||
for v in vec {
|
||||
match v {
|
||||
Value::String(s) => result.push(s),
|
||||
Value::Number(n) => result.push(n.to_string()),
|
||||
other => {
|
||||
return Err(serde::de::Error::custom(format!(
|
||||
"expected string or integer in array, got {}",
|
||||
other
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CDP message envelope
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -215,6 +260,7 @@ pub struct RemoteObject {
|
||||
pub object_id: Option<String>,
|
||||
pub class_name: Option<String>,
|
||||
pub unserializable_value: Option<String>,
|
||||
pub preview: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
@@ -257,12 +303,14 @@ pub struct GetFullAXTreeResult {
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AXNode {
|
||||
#[serde(deserialize_with = "string_or_int")]
|
||||
pub node_id: String,
|
||||
pub role: Option<AXValue>,
|
||||
pub name: Option<AXValue>,
|
||||
pub value: Option<AXValue>,
|
||||
pub description: Option<AXValue>,
|
||||
pub properties: Option<Vec<AXProperty>>,
|
||||
#[serde(default, deserialize_with = "opt_vec_string_or_int")]
|
||||
pub child_ids: Option<Vec<String>>,
|
||||
pub backend_d_o_m_node_id: Option<i64>,
|
||||
pub ignored: Option<bool>,
|
||||
@@ -532,6 +580,7 @@ pub struct BrowserVersionInfo {
|
||||
/// Chromium source) into `cli/cdp-protocol/` and rebuild.
|
||||
///
|
||||
/// Usage: `use super::cdp::types::generated::cdp_page::*;`
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
pub mod generated {
|
||||
include!(concat!(env!("OUT_DIR"), "/cdp_generated.rs"));
|
||||
}
|
||||
|
||||
@@ -24,6 +24,19 @@ pub struct Cookie {
|
||||
pub same_site: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_all_cookies(client: &CdpClient, session_id: &str) -> Result<Vec<Cookie>, String> {
|
||||
let result = client
|
||||
.send_command_no_params("Network.getAllCookies", Some(session_id))
|
||||
.await?;
|
||||
|
||||
let cookies: Vec<Cookie> = result
|
||||
.get("cookies")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(cookies)
|
||||
}
|
||||
|
||||
pub async fn get_cookies(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
|
||||
+338
-29
@@ -1,14 +1,20 @@
|
||||
use serde_json::Value;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
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();
|
||||
@@ -16,15 +22,50 @@ pub async fn run_daemon(session: &str) {
|
||||
let _ = fs::create_dir_all(&socket_dir);
|
||||
}
|
||||
|
||||
// When debug mode is on, redirect stderr to a log file so daemon
|
||||
// output can be inspected (the daemon normally has stderr piped to its
|
||||
// parent which drops the read end after startup).
|
||||
#[cfg(unix)]
|
||||
if env::var("AGENT_BROWSER_DEBUG").is_ok() {
|
||||
let log_path = socket_dir.join(format!("{}.log", session));
|
||||
if let Ok(file) = fs::File::create(&log_path) {
|
||||
use std::os::unix::io::IntoRawFd;
|
||||
let fd = file.into_raw_fd();
|
||||
unsafe {
|
||||
libc::dup2(fd, 2);
|
||||
libc::close(fd);
|
||||
}
|
||||
let _ = writeln!(
|
||||
std::io::stderr(),
|
||||
"[daemon] Debug logging started for session: {}",
|
||||
session
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let pid_path = socket_dir.join(format!("{}.pid", session));
|
||||
let _ = fs::write(&pid_path, process::id().to_string());
|
||||
|
||||
// On Unix the daemon listens on a Unix domain socket; on Windows it uses
|
||||
// TCP, so there is no .sock file — only a .port file written by the server.
|
||||
let socket_path = socket_dir.join(format!("{}.sock", session));
|
||||
|
||||
#[cfg(unix)]
|
||||
if socket_path.exists() {
|
||||
let _ = fs::remove_file(&socket_path);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.port", session)));
|
||||
}
|
||||
|
||||
let stream_path = socket_dir.join(format!("{}.stream", session));
|
||||
let _ = fs::remove_file(&stream_path);
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.engine", session)));
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.provider", session)));
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.extensions", session)));
|
||||
|
||||
if let Ok(days_str) = env::var("AGENT_BROWSER_STATE_EXPIRE_DAYS") {
|
||||
if let Ok(days) = days_str.parse::<u64>() {
|
||||
if days > 0 {
|
||||
@@ -33,44 +74,140 @@ 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;
|
||||
let mut stream_server_instance: Option<Arc<StreamServer>> = None;
|
||||
let preferred_port = env::var("AGENT_BROWSER_STREAM_PORT")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u16>().ok())
|
||||
.unwrap_or(0);
|
||||
match StreamServer::start_without_client(preferred_port, session.to_string(), true).await {
|
||||
Ok((stream_server, client_slot)) => {
|
||||
stream_client = Some(client_slot.clone());
|
||||
if let Err(e) = fs::write(&stream_path, stream_server.port().to_string()) {
|
||||
let _ = writeln!(std::io::stderr(), "Failed to write .stream file: {}", e);
|
||||
}
|
||||
stream_server_instance = Some(Arc::new(stream_server));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = writeln!(std::io::stderr(), "Stream server failed to start: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
let _ = fs::remove_file(&socket_path);
|
||||
// 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,
|
||||
stream_server_instance,
|
||||
idle_timeout_ms,
|
||||
)
|
||||
.await;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let _ = fs::remove_file(&socket_path);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.port", session)));
|
||||
}
|
||||
let _ = fs::remove_file(&pid_path);
|
||||
let stream_path = socket_dir.join(format!("{}.stream", session));
|
||||
let _ = fs::remove_file(&stream_path);
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.engine", session)));
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.provider", session)));
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.extensions", session)));
|
||||
|
||||
if let Err(e) = result {
|
||||
eprintln!("Daemon error: {}", e);
|
||||
let _ = writeln!(std::io::stderr(), "Daemon error: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
#[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>>>>>,
|
||||
stream_server: Option<Arc<StreamServer>>,
|
||||
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 stream_file: Option<PathBuf> = if stream_server.is_some() {
|
||||
let dir = socket_path.parent().unwrap_or(std::path::Path::new("."));
|
||||
Some(dir.join(format!("{}.stream", session)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> = std::sync::Arc::new(
|
||||
tokio::sync::Mutex::new(DaemonState::new_with_stream(stream_client, stream_server)),
|
||||
);
|
||||
|
||||
let (reset_tx, mut reset_rx) = mpsc::channel::<()>(64);
|
||||
let reset_tx = idle_timeout_ms.map(|_| Arc::new(reset_tx));
|
||||
|
||||
let mut drain_interval = tokio::time::interval(Duration::from_millis(500));
|
||||
drain_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
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();
|
||||
let sf = stream_file.clone();
|
||||
tokio::spawn(async move {
|
||||
handle_connection(stream, state).await;
|
||||
handle_connection(stream, state, reset_tx, sf).await;
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Accept error: {}", e);
|
||||
let _ = writeln!(std::io::stderr(), "Accept error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = drain_interval.tick() => {
|
||||
let mut s = state.lock().await;
|
||||
if let Some(ref mut mgr) = s.browser {
|
||||
if mgr.has_process_exited() {
|
||||
let _ = mgr.close().await;
|
||||
s.browser = None;
|
||||
s.screencasting = false;
|
||||
s.update_stream_client().await;
|
||||
} else {
|
||||
s.drain_cdp_events_background().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = 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,36 +222,83 @@ 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>>>>>,
|
||||
stream_server: Option<Arc<StreamServer>>,
|
||||
idle_timeout_ms: Option<u64>,
|
||||
) -> Result<(), String> {
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
let port = get_port_for_session(session);
|
||||
let listener = TcpListener::bind(format!("127.0.0.1:{}", port))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to bind TCP: {}", e))?;
|
||||
let preferred_port = get_port_for_session(session);
|
||||
// Try the hash-derived port first; if it is blocked (e.g. Windows Hyper-V
|
||||
// excluded port range), fall back to an OS-assigned ephemeral port.
|
||||
let listener = match TcpListener::bind(format!("127.0.0.1:{}", preferred_port)).await {
|
||||
Ok(l) => l,
|
||||
Err(_) => TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|e| format!("Failed to bind TCP: {}", e))?,
|
||||
};
|
||||
let actual_port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| format!("Failed to get local address: {}", e))?
|
||||
.port();
|
||||
|
||||
let socket_dir = socket_path.parent().unwrap_or(std::path::Path::new("."));
|
||||
let port_path = socket_dir.join(format!("{}.port", session));
|
||||
let _ = fs::write(&port_path, port.to_string());
|
||||
let _ = fs::write(&port_path, actual_port.to_string());
|
||||
|
||||
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> =
|
||||
std::sync::Arc::new(tokio::sync::Mutex::new(DaemonState::new()));
|
||||
let stream_file: Option<PathBuf> = if stream_server.is_some() {
|
||||
Some(socket_dir.join(format!("{}.stream", session)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> = std::sync::Arc::new(
|
||||
tokio::sync::Mutex::new(DaemonState::new_with_stream(stream_client, stream_server)),
|
||||
);
|
||||
|
||||
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();
|
||||
let sf = stream_file.clone();
|
||||
tokio::spawn(async move {
|
||||
handle_connection(stream, state).await;
|
||||
handle_connection(stream, state, reset_tx, sf).await;
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Accept error: {}", e);
|
||||
let _ = writeln!(std::io::stderr(), "Accept error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = 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 +313,12 @@ 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<()>>>,
|
||||
stream_file_cleanup: Option<PathBuf>,
|
||||
) where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
|
||||
{
|
||||
let (reader, mut writer) = tokio::io::split(stream);
|
||||
@@ -165,6 +353,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 = {
|
||||
@@ -179,6 +371,9 @@ where
|
||||
}
|
||||
|
||||
if is_close {
|
||||
if let Some(ref path) = stream_file_cleanup {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
process::exit(0);
|
||||
}
|
||||
@@ -201,21 +396,25 @@ async fn shutdown_signal() {
|
||||
let mut sigint = match signal::unix::signal(signal::unix::SignalKind::interrupt()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to install SIGINT handler: {}", e);
|
||||
let _ = writeln!(std::io::stderr(), "Failed to install SIGINT handler: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
let mut sigterm = match signal::unix::signal(signal::unix::SignalKind::terminate()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to install SIGTERM handler: {}", e);
|
||||
let _ = writeln!(
|
||||
std::io::stderr(),
|
||||
"Failed to install SIGTERM handler: {}",
|
||||
e
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
let mut sighup = match signal::unix::signal(signal::unix::SignalKind::hangup()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to install SIGHUP handler: {}", e);
|
||||
let _ = writeln!(std::io::stderr(), "Failed to install SIGHUP handler: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
@@ -230,7 +429,7 @@ async fn shutdown_signal() {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Err(e) = signal::ctrl_c().await {
|
||||
eprintln!("Failed to install Ctrl+C handler: {}", e);
|
||||
let _ = writeln!(std::io::stderr(), "Failed to install Ctrl+C handler: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -258,9 +457,119 @@ fn get_daemon_socket_dir() -> PathBuf {
|
||||
|
||||
#[cfg(windows)]
|
||||
fn get_port_for_session(session: &str) -> u16 {
|
||||
let mut hash: i64 = 0;
|
||||
for b in session.bytes() {
|
||||
hash = hash.wrapping_mul(31).wrapping_add(b as i64);
|
||||
let mut hash: i32 = 0;
|
||||
for c in session.chars() {
|
||||
hash = ((hash << 5).wrapping_sub(hash)).wrapping_add(c as i32);
|
||||
}
|
||||
49152 + ((hash.unsigned_abs() as u32 % 16383) as u16)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[allow(unused_imports)]
|
||||
use super::*;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn test_port_matches_client_algorithm() {
|
||||
assert_eq!(get_port_for_session("default"), 50838);
|
||||
assert_eq!(get_port_for_session("my-session"), 63105);
|
||||
assert_eq!(get_port_for_session("work"), 51184);
|
||||
assert_eq!(get_port_for_session(""), 49152);
|
||||
}
|
||||
|
||||
/// Guard against re-introducing `waitpid(-1)` in daemon code.
|
||||
///
|
||||
/// Issue #1035: a SIGCHLD handler that called `waitpid(-1, WNOHANG)` was
|
||||
/// added in v0.22.3 to reap zombie Chrome processes. This races with
|
||||
/// Rust's `Child::try_wait()` / `Child::wait()` because `waitpid(-1)`
|
||||
/// reaps *any* child, stealing the exit status before Rust can collect
|
||||
/// it. The result is ECHILD errors in `BrowserManager::has_process_exited()`
|
||||
/// and `ChromeProcess::kill()`, which can leave the daemon in a broken
|
||||
/// state or cause hangs on certain Linux configurations.
|
||||
///
|
||||
/// The fix uses the existing 500ms drain interval to call
|
||||
/// `has_process_exited()` (which delegates to `Child::try_wait()`)
|
||||
/// for targeted, race-free zombie detection.
|
||||
#[test]
|
||||
fn test_no_waitpid_minus_one_in_daemon() {
|
||||
let source = include_str!("daemon.rs");
|
||||
// Only check production code (everything before `#[cfg(test)]`)
|
||||
let production_code = source.split("#[cfg(test)]").next().unwrap_or(source);
|
||||
assert!(
|
||||
!production_code.contains("waitpid(-1"),
|
||||
"daemon.rs production code must not call waitpid(-1, ...). \
|
||||
Use Child::try_wait() via has_process_exited() instead. \
|
||||
See issue #1035."
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify that `Child::try_wait()` correctly detects a crashed child
|
||||
/// without needing a global SIGCHLD handler or `waitpid(-1)`.
|
||||
/// This is what `has_process_exited()` uses in the fixed code.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_child_try_wait_detects_exit_without_sigchld_handler() {
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let mut child = Command::new("/bin/sh")
|
||||
.args(["-c", "exit 42"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("failed to spawn child");
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
assert!(
|
||||
!status.success(),
|
||||
"child exited with code 42, should not be success"
|
||||
);
|
||||
}
|
||||
Ok(None) => panic!("try_wait() returned None but child should have exited"),
|
||||
Err(e) => panic!("try_wait() should succeed without waitpid(-1): {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify that `ChromeProcess::has_exited()` (which uses `Child::try_wait()`)
|
||||
/// correctly detects a killed child, the same way the drain interval does
|
||||
/// in the fixed daemon code. This ensures crash detection works without
|
||||
/// a SIGCHLD handler.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_has_exited_detects_killed_process() {
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let mut child = Command::new("/bin/sh")
|
||||
.args(["-c", "sleep 60"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("failed to spawn child");
|
||||
|
||||
// Process should be running
|
||||
match child.try_wait() {
|
||||
Ok(None) => {} // expected
|
||||
other => panic!("expected Ok(None) for running process, got {:?}", other),
|
||||
}
|
||||
|
||||
// Kill it (simulates Chrome crash)
|
||||
child.kill().expect("failed to kill child");
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
|
||||
// try_wait should detect the exit
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => {} // expected: detected the crash
|
||||
other => panic!(
|
||||
"expected Ok(Some(_)) after kill, got {:?}. \
|
||||
Crash detection via try_wait() must work for the drain \
|
||||
interval fix (issue #1035) to function correctly.",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
49152 + (hash.unsigned_abs() % 16383) as u16
|
||||
}
|
||||
|
||||
@@ -101,6 +101,21 @@ pub fn diff_screenshot(
|
||||
|
||||
/// Compute a snapshot diff using the Myers algorithm via the `similar` crate.
|
||||
pub fn diff_snapshots(before: &str, after: &str) -> SnapshotDiffResult {
|
||||
// Fast path: identical inputs.
|
||||
// This avoids constructing the `similar` TextDiff object and running the diff
|
||||
// iteration when agents compare a snapshot to itself (common in retry/loop
|
||||
// workloads).
|
||||
if before == after {
|
||||
let unchanged = before.lines().count();
|
||||
return SnapshotDiffResult {
|
||||
diff: String::new(),
|
||||
additions: 0,
|
||||
removals: 0,
|
||||
unchanged,
|
||||
changed: false,
|
||||
};
|
||||
}
|
||||
|
||||
let text_diff = TextDiff::from_lines(before, after);
|
||||
|
||||
let mut additions = 0usize;
|
||||
@@ -192,4 +207,68 @@ mod tests {
|
||||
assert_eq!(result.unchanged, 1);
|
||||
assert!(!result.diff.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_snapshots_identical_fast_path() {
|
||||
let input = "hello\nworld\n";
|
||||
let result = diff_snapshots(input, input);
|
||||
assert!(!result.changed);
|
||||
assert_eq!(result.additions, 0);
|
||||
assert_eq!(result.removals, 0);
|
||||
assert_eq!(result.unchanged, input.lines().count());
|
||||
assert!(result.diff.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_diff_snapshots_identical_and_changed() {
|
||||
use std::hint::black_box;
|
||||
use std::time::Instant;
|
||||
|
||||
let identical_a = (0..200)
|
||||
.map(|i| format!("line {i}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let identical_b = identical_a.clone();
|
||||
|
||||
let changed_a = identical_a.clone();
|
||||
let changed_b = (0..200)
|
||||
.map(|i| {
|
||||
if i == 123 {
|
||||
format!("line {i} changed")
|
||||
} else {
|
||||
format!("line {i}")
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
// Keep the iteration count high enough to measure, but low enough
|
||||
// to avoid long CI times when someone runs `--ignored`.
|
||||
let iters = 50_000usize;
|
||||
|
||||
let start = Instant::now();
|
||||
let mut acc_changed = 0usize;
|
||||
for _ in 0..iters {
|
||||
let r = diff_snapshots(black_box(&identical_a), black_box(&identical_b));
|
||||
acc_changed ^= r.unchanged;
|
||||
}
|
||||
let identical_ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
|
||||
let start = Instant::now();
|
||||
let mut acc_changed2 = 0usize;
|
||||
for _ in 0..iters {
|
||||
let r = diff_snapshots(black_box(&changed_a), black_box(&changed_b));
|
||||
acc_changed2 ^= r.additions;
|
||||
}
|
||||
let changed_ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
|
||||
// Prevent the compiler from optimizing everything away.
|
||||
black_box(acc_changed);
|
||||
black_box(acc_changed2);
|
||||
|
||||
println!(
|
||||
"bench_diff_snapshots_identical_and_changed: iters={iters} identical_ms={identical_ms:.2} changed_ms={changed_ms:.2}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+2538
-9
File diff suppressed because it is too large
Load Diff
+496
-99
@@ -12,6 +12,7 @@ pub struct RefEntry {
|
||||
pub name: String,
|
||||
pub nth: Option<usize>,
|
||||
pub selector: Option<String>,
|
||||
pub frame_id: Option<String>,
|
||||
}
|
||||
|
||||
pub struct RefMap {
|
||||
@@ -34,6 +35,18 @@ impl RefMap {
|
||||
role: &str,
|
||||
name: &str,
|
||||
nth: Option<usize>,
|
||||
) {
|
||||
self.add_with_frame(ref_id, backend_node_id, role, name, nth, None);
|
||||
}
|
||||
|
||||
pub fn add_with_frame(
|
||||
&mut self,
|
||||
ref_id: String,
|
||||
backend_node_id: Option<i64>,
|
||||
role: &str,
|
||||
name: &str,
|
||||
nth: Option<usize>,
|
||||
frame_id: Option<&str>,
|
||||
) {
|
||||
self.map.insert(
|
||||
ref_id,
|
||||
@@ -43,6 +56,28 @@ impl RefMap {
|
||||
name: name.to_string(),
|
||||
nth,
|
||||
selector: None,
|
||||
frame_id: frame_id.map(|s| s.to_string()),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn add_selector(
|
||||
&mut self,
|
||||
ref_id: String,
|
||||
selector: String,
|
||||
role: &str,
|
||||
name: &str,
|
||||
nth: Option<usize>,
|
||||
) {
|
||||
self.map.insert(
|
||||
ref_id,
|
||||
RefEntry {
|
||||
backend_node_id: None,
|
||||
role: role.to_string(),
|
||||
name: name.to_string(),
|
||||
nth,
|
||||
selector: Some(selector),
|
||||
frame_id: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -51,6 +86,23 @@ impl RefMap {
|
||||
self.map.get(ref_id)
|
||||
}
|
||||
|
||||
pub fn entries_sorted(&self) -> Vec<(String, RefEntry)> {
|
||||
let mut entries = self
|
||||
.map
|
||||
.iter()
|
||||
.map(|(ref_id, entry)| (ref_id.clone(), entry.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
entries.sort_by_key(|(ref_id, _)| {
|
||||
ref_id
|
||||
.strip_prefix('e')
|
||||
.and_then(|n| n.parse::<usize>().ok())
|
||||
.unwrap_or(usize::MAX)
|
||||
});
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.map.clear();
|
||||
self.next_ref = 1;
|
||||
@@ -95,14 +147,19 @@ pub async fn resolve_element_center(
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<(f64, f64), String> {
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(f64, f64, String), String> {
|
||||
if let Some(ref_id) = parse_ref(selector_or_ref) {
|
||||
let entry = ref_map
|
||||
.get(&ref_id)
|
||||
.ok_or_else(|| format!("Unknown ref: {}", ref_id))?;
|
||||
|
||||
let effective_session_id =
|
||||
resolve_frame_session(entry.frame_id.as_deref(), session_id, iframe_sessions);
|
||||
|
||||
// Try cached backend_node_id first (fast path)
|
||||
if let Some(backend_node_id) = entry.backend_node_id {
|
||||
let result: DomGetBoxModelResult = client
|
||||
let result: Result<DomGetBoxModelResult, String> = client
|
||||
.send_command_typed(
|
||||
"DOM.getBoxModel",
|
||||
&DomGetBoxModelParams {
|
||||
@@ -110,19 +167,46 @@ pub async fn resolve_element_center(
|
||||
node_id: None,
|
||||
object_id: None,
|
||||
},
|
||||
Some(session_id),
|
||||
Some(effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
.await;
|
||||
|
||||
return Ok(box_model_center(&result.model));
|
||||
if let Ok(r) = result {
|
||||
let (x, y) = box_model_center(&r.model);
|
||||
return Ok((x, y, effective_session_id.to_string()));
|
||||
}
|
||||
// backend_node_id is stale; re-query the accessibility tree below
|
||||
}
|
||||
|
||||
// Fallback: use role/name to find via JS
|
||||
return resolve_by_role_name(client, session_id, &entry.role, &entry.name, entry.nth).await;
|
||||
// Fallback: re-query the accessibility tree to find a fresh node by role/name
|
||||
let fresh_id = find_node_id_by_role_name(
|
||||
client,
|
||||
session_id,
|
||||
&entry.role,
|
||||
&entry.name,
|
||||
entry.nth,
|
||||
entry.frame_id.as_deref(),
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
let result: DomGetBoxModelResult = client
|
||||
.send_command_typed(
|
||||
"DOM.getBoxModel",
|
||||
&DomGetBoxModelParams {
|
||||
backend_node_id: Some(fresh_id),
|
||||
node_id: None,
|
||||
object_id: None,
|
||||
},
|
||||
Some(effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
let (x, y) = box_model_center(&result.model);
|
||||
return Ok((x, y, effective_session_id.to_string()));
|
||||
}
|
||||
|
||||
// CSS selector
|
||||
resolve_by_selector(client, session_id, selector_or_ref).await
|
||||
let (x, y) = resolve_by_selector(client, session_id, selector_or_ref).await?;
|
||||
Ok((x, y, session_id.to_string()))
|
||||
}
|
||||
|
||||
pub async fn resolve_element_object_id(
|
||||
@@ -130,14 +214,19 @@ pub async fn resolve_element_object_id(
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
) -> Result<String, String> {
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(String, String), String> {
|
||||
if let Some(ref_id) = parse_ref(selector_or_ref) {
|
||||
let entry = ref_map
|
||||
.get(&ref_id)
|
||||
.ok_or_else(|| format!("Unknown ref: {}", ref_id))?;
|
||||
|
||||
let effective_session_id =
|
||||
resolve_frame_session(entry.frame_id.as_deref(), session_id, iframe_sessions);
|
||||
|
||||
// Try cached backend_node_id first (fast path)
|
||||
if let Some(backend_node_id) = entry.backend_node_id {
|
||||
let result: DomResolveNodeResult = client
|
||||
let result: Result<DomResolveNodeResult, String> = client
|
||||
.send_command_typed(
|
||||
"DOM.resolveNode",
|
||||
&DomResolveNodeParams {
|
||||
@@ -145,22 +234,49 @@ pub async fn resolve_element_object_id(
|
||||
node_id: None,
|
||||
object_group: Some("agent-browser".to_string()),
|
||||
},
|
||||
Some(session_id),
|
||||
Some(effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
.await;
|
||||
|
||||
return result
|
||||
.object
|
||||
.object_id
|
||||
.ok_or_else(|| format!("No objectId for ref {}", ref_id));
|
||||
if let Ok(r) = result {
|
||||
if let Some(object_id) = r.object.object_id {
|
||||
return Ok((object_id, effective_session_id.to_string()));
|
||||
}
|
||||
}
|
||||
// backend_node_id is stale; re-query the accessibility tree below
|
||||
}
|
||||
|
||||
// Fallback: re-query the accessibility tree to find a fresh node by role/name
|
||||
let fresh_id = find_node_id_by_role_name(
|
||||
client,
|
||||
session_id,
|
||||
&entry.role,
|
||||
&entry.name,
|
||||
entry.nth,
|
||||
entry.frame_id.as_deref(),
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
let result: DomResolveNodeResult = client
|
||||
.send_command_typed(
|
||||
"DOM.resolveNode",
|
||||
&DomResolveNodeParams {
|
||||
backend_node_id: Some(fresh_id),
|
||||
node_id: None,
|
||||
object_group: Some("agent-browser".to_string()),
|
||||
},
|
||||
Some(effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
let object_id = result
|
||||
.object
|
||||
.object_id
|
||||
.ok_or_else(|| format!("No objectId for ref {}", ref_id))?;
|
||||
return Ok((object_id, effective_session_id.to_string()));
|
||||
}
|
||||
|
||||
// CSS selector fallback
|
||||
let js = format!(
|
||||
"document.querySelector({})",
|
||||
serde_json::to_string(selector_or_ref).unwrap_or_default()
|
||||
);
|
||||
// Selector fallback (CSS or XPath)
|
||||
let js = build_find_element_js(selector_or_ref);
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.evaluate",
|
||||
@@ -173,63 +289,149 @@ pub async fn resolve_element_object_id(
|
||||
)
|
||||
.await?;
|
||||
|
||||
result
|
||||
let object_id = result
|
||||
.result
|
||||
.object_id
|
||||
.ok_or_else(|| format!("Element not found: {}", selector_or_ref))
|
||||
.ok_or_else(|| format!("Element not found: {}", selector_or_ref))?;
|
||||
Ok((object_id, session_id.to_string()))
|
||||
}
|
||||
|
||||
async fn resolve_by_role_name(
|
||||
/// Determine which CDP session and parameters to use for an AX tree query.
|
||||
/// Cross-origin iframes have a dedicated session (no frameId needed);
|
||||
/// same-origin iframes use the parent session with a frameId parameter.
|
||||
pub(super) fn resolve_ax_session<'a>(
|
||||
frame_id: Option<&str>,
|
||||
session_id: &'a str,
|
||||
iframe_sessions: &'a HashMap<String, String>,
|
||||
) -> (serde_json::Value, &'a str) {
|
||||
if let Some(frame_id) = frame_id {
|
||||
if let Some(iframe_sid) = iframe_sessions.get(frame_id) {
|
||||
(serde_json::json!({}), iframe_sid.as_str())
|
||||
} else {
|
||||
(serde_json::json!({ "frameId": frame_id }), session_id)
|
||||
}
|
||||
} else {
|
||||
(serde_json::json!({}), session_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the effective CDP session for an element's frame.
|
||||
/// If the element's frame_id has a dedicated cross-origin iframe session, return it.
|
||||
/// Otherwise, return the parent session.
|
||||
fn resolve_frame_session<'a>(
|
||||
frame_id: Option<&str>,
|
||||
session_id: &'a str,
|
||||
iframe_sessions: &'a HashMap<String, String>,
|
||||
) -> &'a str {
|
||||
frame_id
|
||||
.and_then(|fid| iframe_sessions.get(fid))
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or(session_id)
|
||||
}
|
||||
|
||||
/// Re-query the accessibility tree to find a node matching role+name+nth,
|
||||
/// returning its fresh backendDOMNodeId. This uses the same data source
|
||||
/// (Accessibility.getFullAXTree) that built the ref map during snapshot,
|
||||
/// so role/name matching is guaranteed to be consistent.
|
||||
async fn find_node_id_by_role_name(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
role: &str,
|
||||
name: &str,
|
||||
nth: Option<usize>,
|
||||
) -> Result<(f64, f64), String> {
|
||||
frame_id: Option<&str>,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<i64, String> {
|
||||
let (ax_params, effective_session_id) =
|
||||
resolve_ax_session(frame_id, session_id, iframe_sessions);
|
||||
let ax_tree: GetFullAXTreeResult = client
|
||||
.send_command_typed(
|
||||
"Accessibility.getFullAXTree",
|
||||
&ax_params,
|
||||
Some(effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let nth_index = nth.unwrap_or(0);
|
||||
let js = format!(
|
||||
let mut match_count: usize = 0;
|
||||
|
||||
for node in &ax_tree.nodes {
|
||||
if node.ignored.unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let node_role = extract_ax_string(&node.role);
|
||||
let node_name = extract_ax_string(&node.name);
|
||||
if node_role == role && node_name == name {
|
||||
if match_count == nth_index {
|
||||
return node.backend_d_o_m_node_id.ok_or_else(|| {
|
||||
format!(
|
||||
"AX node has no backendDOMNodeId for role={} name={}",
|
||||
role, name
|
||||
)
|
||||
});
|
||||
}
|
||||
match_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Could not locate element with role={} name={}",
|
||||
role, name
|
||||
))
|
||||
}
|
||||
|
||||
fn extract_ax_string(value: &Option<AXValue>) -> String {
|
||||
match value {
|
||||
Some(v) => match &v.value {
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(Value::Number(n)) => n.to_string(),
|
||||
Some(Value::Bool(b)) => b.to_string(),
|
||||
_ => String::new(),
|
||||
},
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a JS expression that finds a DOM element by CSS selector or XPath.
|
||||
fn build_find_element_js(selector: &str) -> String {
|
||||
if let Some(xpath) = selector.strip_prefix("xpath=") {
|
||||
format!(
|
||||
"document.evaluate({}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue",
|
||||
serde_json::to_string(xpath).unwrap_or_default()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"document.querySelector({})",
|
||||
serde_json::to_string(selector).unwrap_or_default()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a JS expression that counts matching DOM elements by CSS selector or XPath.
|
||||
fn build_count_elements_js(selector: &str) -> String {
|
||||
if let Some(xpath) = selector.strip_prefix("xpath=") {
|
||||
format!(
|
||||
"document.evaluate({}, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null).snapshotLength",
|
||||
serde_json::to_string(xpath).unwrap_or_default()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"document.querySelectorAll({}).length",
|
||||
serde_json::to_string(selector).unwrap_or_default()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_selector_js(selector: &str) -> String {
|
||||
let find_expr = build_find_element_js(selector);
|
||||
format!(
|
||||
r#"(() => {{
|
||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
|
||||
const matches = [];
|
||||
let node;
|
||||
while (node = walker.nextNode()) {{
|
||||
const r = node.getAttribute('role') || node.tagName.toLowerCase();
|
||||
const n = node.getAttribute('aria-label') || node.textContent.trim().slice(0, 100);
|
||||
if (r === {role} && n === {name}) matches.push(node);
|
||||
}}
|
||||
const el = matches[{nth}];
|
||||
const el = {find_expr};
|
||||
if (!el) return null;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {{ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }};
|
||||
}})()"#,
|
||||
role = serde_json::to_string(role).unwrap_or_default(),
|
||||
name = serde_json::to_string(name).unwrap_or_default(),
|
||||
nth = nth_index,
|
||||
);
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: js,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let val = result.result.value.unwrap_or(Value::Null);
|
||||
let x = val.get("x").and_then(|v| v.as_f64());
|
||||
let y = val.get("y").and_then(|v| v.as_f64());
|
||||
|
||||
match (x, y) {
|
||||
(Some(x), Some(y)) => Ok((x, y)),
|
||||
_ => Err(format!(
|
||||
"Could not locate element with role={} name={}",
|
||||
role, name
|
||||
)),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async fn resolve_by_selector(
|
||||
@@ -237,15 +439,7 @@ async fn resolve_by_selector(
|
||||
session_id: &str,
|
||||
selector: &str,
|
||||
) -> Result<(f64, f64), String> {
|
||||
let js = format!(
|
||||
r#"(() => {{
|
||||
const el = document.querySelector({sel});
|
||||
if (!el) return null;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {{ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }};
|
||||
}})()"#,
|
||||
sel = serde_json::to_string(selector).unwrap_or_default(),
|
||||
);
|
||||
let js = build_selector_js(selector);
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
@@ -285,8 +479,16 @@ pub async fn get_element_text(
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<String, String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
@@ -299,7 +501,7 @@ pub async fn get_element_text(
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -316,8 +518,16 @@ pub async fn get_element_attribute(
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
attribute: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<Value, String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
@@ -332,7 +542,7 @@ pub async fn get_element_attribute(
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -344,8 +554,16 @@ pub async fn is_element_visible(
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<bool, String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
@@ -365,7 +583,7 @@ pub async fn is_element_visible(
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -381,8 +599,16 @@ pub async fn is_element_enabled(
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<bool, String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
@@ -394,7 +620,7 @@ pub async fn is_element_enabled(
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -410,20 +636,61 @@ pub async fn is_element_checked(
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<bool, String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Mirrors Playwright's getChecked() with follow-label retargeting:
|
||||
// 1. If element is a native checkbox/radio input, return .checked
|
||||
// 2. If element has an ARIA checked role, return aria-checked
|
||||
// 3. Follow label → input association (label.control)
|
||||
// 4. Check for nested checkbox/radio input as last resort
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: "function() { return !!this.checked; }".to_string(),
|
||||
function_declaration: r#"function() {
|
||||
var el = this;
|
||||
// Native checkbox/radio input
|
||||
var tag = el.tagName && el.tagName.toUpperCase();
|
||||
if (tag === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio')) {
|
||||
return el.checked;
|
||||
}
|
||||
// ARIA role-based checked state
|
||||
var role = el.getAttribute && el.getAttribute('role');
|
||||
var ariaCheckedRoles = ['checkbox','radio','switch','menuitemcheckbox','menuitemradio','option','treeitem'];
|
||||
if (role && ariaCheckedRoles.indexOf(role) !== -1) {
|
||||
return el.getAttribute('aria-checked') === 'true';
|
||||
}
|
||||
// Follow label association (Playwright follow-label retarget)
|
||||
var label = el;
|
||||
if (tag !== 'LABEL') {
|
||||
label = el.closest && el.closest('label');
|
||||
}
|
||||
if (label && label.tagName && label.tagName.toUpperCase() === 'LABEL' && label.control) {
|
||||
var ctrl = label.control;
|
||||
if (ctrl.type === 'checkbox' || ctrl.type === 'radio') {
|
||||
return ctrl.checked;
|
||||
}
|
||||
}
|
||||
// Check for nested native input
|
||||
var input = el.querySelector && el.querySelector('input[type="checkbox"], input[type="radio"]');
|
||||
if (input) return input.checked;
|
||||
return false;
|
||||
}"#.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -439,8 +706,16 @@ pub async fn get_element_inner_text(
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<String, String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
@@ -452,7 +727,7 @@ pub async fn get_element_inner_text(
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -468,8 +743,16 @@ pub async fn get_element_inner_html(
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<String, String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
@@ -481,7 +764,7 @@ pub async fn get_element_inner_html(
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -497,8 +780,16 @@ pub async fn get_element_input_value(
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<String, String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
@@ -512,7 +803,7 @@ pub async fn get_element_input_value(
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -529,8 +820,16 @@ pub async fn set_element_value(
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
value: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let js = format!(
|
||||
"function() {{ this.value = {}; this.dispatchEvent(new Event('input', {{bubbles: true}})); this.dispatchEvent(new Event('change', {{bubbles: true}})); }}",
|
||||
@@ -547,7 +846,7 @@ pub async fn set_element_value(
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -559,8 +858,16 @@ pub async fn get_element_bounding_box(
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<Value, String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
@@ -576,7 +883,7 @@ pub async fn get_element_bounding_box(
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -591,10 +898,7 @@ pub async fn get_element_count(
|
||||
session_id: &str,
|
||||
selector: &str,
|
||||
) -> Result<i64, String> {
|
||||
let js = format!(
|
||||
"document.querySelectorAll({}).length",
|
||||
serde_json::to_string(selector).unwrap_or_default()
|
||||
);
|
||||
let js = build_count_elements_js(selector);
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
@@ -617,8 +921,16 @@ pub async fn get_element_styles(
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
properties: Option<Vec<String>>,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<Value, String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let js = match properties {
|
||||
Some(props) => {
|
||||
@@ -656,7 +968,7 @@ pub async fn get_element_styles(
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -701,6 +1013,47 @@ mod tests {
|
||||
assert!(map.get("e2").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_selector_js_css() {
|
||||
let js = build_selector_js("#submit-btn");
|
||||
assert!(js.contains("document.querySelector(\"#submit-btn\")"));
|
||||
assert!(!js.contains("document.evaluate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_selector_js_xpath() {
|
||||
let js = build_selector_js("xpath=//button[@id='ok']");
|
||||
assert!(js.contains("document.evaluate(\"//button[@id='ok']\", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null)"));
|
||||
assert!(!js.contains("document.querySelector"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_selector_js_xpath_empty() {
|
||||
let js = build_selector_js("xpath=");
|
||||
assert!(js.contains("document.evaluate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_selector_js_not_xpath_prefix() {
|
||||
// "xpath" without "=" should be treated as CSS selector
|
||||
let js = build_selector_js("xpath//div");
|
||||
assert!(js.contains("document.querySelector"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_count_elements_js_css() {
|
||||
let js = build_count_elements_js(".item");
|
||||
assert!(js.contains("document.querySelectorAll(\".item\").length"));
|
||||
assert!(!js.contains("document.evaluate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_count_elements_js_xpath() {
|
||||
let js = build_count_elements_js("xpath=//li");
|
||||
assert!(js.contains("document.evaluate(\"//li\", document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null).snapshotLength"));
|
||||
assert!(!js.contains("querySelectorAll"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_box_model_center() {
|
||||
let model = BoxModel {
|
||||
@@ -715,4 +1068,48 @@ mod tests {
|
||||
assert!((x - 60.0).abs() < 0.01);
|
||||
assert!((y - 40.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// resolve_frame_session tests (Issue #925)
|
||||
// Cross-origin iframe elements must resolve to the dedicated session.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_cross_origin_element_uses_dedicated_session() {
|
||||
let mut iframe_sessions = HashMap::new();
|
||||
iframe_sessions.insert(
|
||||
"cross-origin-frame".to_string(),
|
||||
"iframe-session".to_string(),
|
||||
);
|
||||
|
||||
let session = resolve_frame_session(
|
||||
Some("cross-origin-frame"),
|
||||
"parent-session",
|
||||
&iframe_sessions,
|
||||
);
|
||||
|
||||
assert_eq!(session, "iframe-session");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_same_origin_element_uses_parent_session() {
|
||||
let iframe_sessions = HashMap::new();
|
||||
|
||||
let session = resolve_frame_session(
|
||||
Some("same-origin-frame"),
|
||||
"parent-session",
|
||||
&iframe_sessions,
|
||||
);
|
||||
|
||||
assert_eq!(session, "parent-session");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_main_frame_element_uses_parent_session() {
|
||||
let iframe_sessions = HashMap::new();
|
||||
|
||||
let session = resolve_frame_session(None, "parent-session", &iframe_sessions);
|
||||
|
||||
assert_eq!(session, "parent-session");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
use std::io::Write;
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
use super::cdp::client::InspectProxyHandle;
|
||||
|
||||
/// Counter for unique attach IDs so concurrent connections don't collide.
|
||||
static ATTACH_ID: AtomicI64 = AtomicI64::new(-1000);
|
||||
|
||||
/// Lightweight HTTP + WebSocket server for `agent-browser inspect`.
|
||||
///
|
||||
/// Serves two purposes:
|
||||
/// - `GET /` redirects to Chrome's built-in DevTools frontend with `ws=` pointing to this server
|
||||
/// - WebSocket connections create a dedicated CDP session via `Target.attachToTarget` and proxy
|
||||
/// CDP messages through the daemon's existing browser-level connection, injecting/stripping
|
||||
/// `sessionId` so the DevTools frontend sees a page-level view
|
||||
pub struct InspectServer {
|
||||
port: u16,
|
||||
_handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl InspectServer {
|
||||
/// Start the inspect proxy server.
|
||||
///
|
||||
/// - `proxy_handle`: lightweight handle for sending/receiving raw CDP messages
|
||||
/// - `target_id`: the CDP target ID of the page to inspect
|
||||
/// - `chrome_host_port`: the Chrome debug server address (e.g. "127.0.0.1:9222")
|
||||
pub async fn start(
|
||||
proxy_handle: InspectProxyHandle,
|
||||
target_id: String,
|
||||
chrome_host_port: String,
|
||||
) -> Result<Self, String> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|e| format!("Failed to bind inspect server: {}", e))?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| format!("Failed to get local addr: {}", e))?
|
||||
.port();
|
||||
|
||||
let proxy = Arc::new(proxy_handle);
|
||||
|
||||
let handle = tokio::spawn(accept_loop(
|
||||
listener,
|
||||
proxy,
|
||||
target_id,
|
||||
chrome_host_port,
|
||||
port,
|
||||
));
|
||||
|
||||
Ok(Self {
|
||||
port,
|
||||
_handle: handle,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
pub fn shutdown(self) {
|
||||
self._handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async fn accept_loop(
|
||||
listener: TcpListener,
|
||||
proxy: Arc<InspectProxyHandle>,
|
||||
target_id: String,
|
||||
chrome_host_port: String,
|
||||
proxy_port: u16,
|
||||
) {
|
||||
loop {
|
||||
let (stream, _) = match listener.accept().await {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let proxy = proxy.clone();
|
||||
let tid = target_id.clone();
|
||||
let chp = chrome_host_port.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection(stream, proxy, tid, chp, proxy_port).await {
|
||||
let _ = writeln!(std::io::stderr(), "[inspect] connection error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_connection(
|
||||
stream: tokio::net::TcpStream,
|
||||
proxy: Arc<InspectProxyHandle>,
|
||||
target_id: String,
|
||||
chrome_host_port: String,
|
||||
proxy_port: u16,
|
||||
) -> Result<(), String> {
|
||||
// Peek at the request line to determine routing WITHOUT consuming bytes.
|
||||
// This is critical: tokio_tungstenite::accept_async needs to read the full
|
||||
// HTTP upgrade request itself, so we must not consume anything for WS paths.
|
||||
let mut peek_buf = [0u8; 32];
|
||||
let n = stream
|
||||
.peek(&mut peek_buf)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let peek = String::from_utf8_lossy(&peek_buf[..n]);
|
||||
|
||||
if peek.starts_with("GET /ws") {
|
||||
return handle_ws_proxy(stream, proxy, target_id).await;
|
||||
}
|
||||
|
||||
if peek.starts_with("GET / ") {
|
||||
let buf_reader = BufReader::new(stream);
|
||||
return handle_http_redirect(buf_reader, chrome_host_port, proxy_port).await;
|
||||
}
|
||||
|
||||
// Unknown request -- consume and respond 404
|
||||
let mut stream = stream;
|
||||
let mut discard = [0u8; 4096];
|
||||
let _ = stream.read(&mut discard).await;
|
||||
let resp = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
|
||||
stream
|
||||
.write_all(resp.as_bytes())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const MAX_HEADER_BYTES: usize = 8192;
|
||||
|
||||
async fn handle_http_redirect(
|
||||
buf_reader: BufReader<tokio::net::TcpStream>,
|
||||
chrome_host_port: String,
|
||||
proxy_port: u16,
|
||||
) -> Result<(), String> {
|
||||
let mut br = buf_reader;
|
||||
let mut total_bytes = 0usize;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let n = br.read_line(&mut line).await.map_err(|e| e.to_string())?;
|
||||
total_bytes += n;
|
||||
if line == "\r\n" || line == "\n" || line.is_empty() || total_bytes > MAX_HEADER_BYTES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let location = format!(
|
||||
"http://{}/devtools/devtools_app.html?ws=127.0.0.1:{}/ws",
|
||||
chrome_host_port, proxy_port
|
||||
);
|
||||
let body = format!(
|
||||
"<html><body>Redirecting to <a href=\"{url}\">{url}</a></body></html>",
|
||||
url = location
|
||||
);
|
||||
let resp = format!(
|
||||
"HTTP/1.1 302 Found\r\nLocation: {}\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
location,
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
let mut stream = br.into_inner();
|
||||
stream
|
||||
.write_all(resp.as_bytes())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_ws_proxy(
|
||||
stream: tokio::net::TcpStream,
|
||||
proxy: Arc<InspectProxyHandle>,
|
||||
target_id: String,
|
||||
) -> Result<(), String> {
|
||||
let ws_stream = tokio_tungstenite::accept_async(stream)
|
||||
.await
|
||||
.map_err(|e| format!("WebSocket handshake failed: {}", e))?;
|
||||
|
||||
// Create a dedicated CDP session for this DevTools connection.
|
||||
// Each connection gets its own session so domain enablements (DOM.enable, etc.)
|
||||
// always trigger fresh initial state dumps from Chrome.
|
||||
let attach_id = ATTACH_ID.fetch_sub(1, Ordering::SeqCst);
|
||||
let attach_cmd = format!(
|
||||
r#"{{"id":{},"method":"Target.attachToTarget","params":{{"targetId":"{}","flatten":true}}}}"#,
|
||||
attach_id, target_id
|
||||
);
|
||||
|
||||
// Subscribe BEFORE sending so we don't miss the response (tokio broadcast
|
||||
// receivers only deliver messages to receivers that already exist).
|
||||
let mut raw_rx = proxy.subscribe_raw();
|
||||
|
||||
proxy
|
||||
.send_raw(attach_cmd)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send attachToTarget: {}", e))?;
|
||||
|
||||
// Wait for the attachToTarget response to extract the session ID
|
||||
let session_id = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
while let Ok(raw_msg) = raw_rx.recv().await {
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&raw_msg.text) {
|
||||
if val.get("id").and_then(|v| v.as_i64()) == Some(attach_id) {
|
||||
if let Some(sid) = val
|
||||
.get("result")
|
||||
.and_then(|r| r.get("sessionId"))
|
||||
.and_then(|s| s.as_str())
|
||||
{
|
||||
return Ok(sid.to_string());
|
||||
}
|
||||
return Err("attachToTarget failed".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err("raw message channel closed".to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Timed out waiting for attachToTarget response".to_string())?
|
||||
.map_err(|e| format!("Failed to create DevTools session: {}", e))?;
|
||||
|
||||
let (ws_tx, mut ws_rx) = ws_stream.split();
|
||||
let ws_tx = Arc::new(Mutex::new(ws_tx));
|
||||
|
||||
let mut raw_rx = proxy.subscribe_raw();
|
||||
let ws_tx_clone = ws_tx.clone();
|
||||
let session_id_clone = session_id.clone();
|
||||
|
||||
// Chrome -> DevTools: forward messages matching our session, strip sessionId
|
||||
let mut chrome_to_devtools = tokio::spawn(async move {
|
||||
loop {
|
||||
let raw_msg = match raw_rx.recv().await {
|
||||
Ok(msg) => msg,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
let _ = writeln!(
|
||||
std::io::stderr(),
|
||||
"[inspect] warning: dropped {} CDP messages (channel lag)",
|
||||
n
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
if raw_msg.session_id.as_deref() != Some(&session_id_clone) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let stripped = strip_session_id(&raw_msg.text);
|
||||
|
||||
let mut tx = ws_tx_clone.lock().await;
|
||||
if tx.send(Message::Text(stripped)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// DevTools -> Chrome: inject sessionId and forward
|
||||
let proxy_for_send = proxy.clone();
|
||||
let session_id_for_send = session_id.clone();
|
||||
let mut devtools_to_chrome = tokio::spawn(async move {
|
||||
while let Some(Ok(msg)) = ws_rx.next().await {
|
||||
let text = match msg {
|
||||
Message::Text(t) => t,
|
||||
Message::Close(_) => break,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let injected = inject_session_id(&text, &session_id_for_send);
|
||||
if proxy_for_send.send_raw(injected).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tokio::select! {
|
||||
_ = &mut chrome_to_devtools => {
|
||||
devtools_to_chrome.abort();
|
||||
},
|
||||
_ = &mut devtools_to_chrome => {
|
||||
chrome_to_devtools.abort();
|
||||
},
|
||||
}
|
||||
|
||||
// Clean up the CDP session so Chrome doesn't leak attached targets
|
||||
let detach_cmd = format!(
|
||||
r#"{{"id":{},"method":"Target.detachFromTarget","params":{{"sessionId":"{}"}}}}"#,
|
||||
ATTACH_ID.fetch_sub(1, Ordering::SeqCst),
|
||||
session_id
|
||||
);
|
||||
let _ = proxy.send_raw(detach_cmd).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn inject_session_id(json: &str, session_id: &str) -> String {
|
||||
if let Ok(mut val) = serde_json::from_str::<serde_json::Value>(json) {
|
||||
if let Some(obj) = val.as_object_mut() {
|
||||
obj.insert(
|
||||
"sessionId".to_string(),
|
||||
serde_json::Value::String(session_id.to_string()),
|
||||
);
|
||||
}
|
||||
serde_json::to_string(&val).unwrap_or_else(|_| json.to_string())
|
||||
} else {
|
||||
json.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_session_id(json: &str) -> String {
|
||||
if let Ok(mut val) = serde_json::from_str::<serde_json::Value>(json) {
|
||||
if let Some(obj) = val.as_object_mut() {
|
||||
obj.remove("sessionId");
|
||||
}
|
||||
serde_json::to_string(&val).unwrap_or_else(|_| json.to_string())
|
||||
} else {
|
||||
json.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_inject_session_id() {
|
||||
let input = r#"{"id":1,"method":"DOM.getDocument"}"#;
|
||||
let result = inject_session_id(input, "abc123");
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).expect("valid JSON");
|
||||
assert_eq!(parsed["sessionId"], "abc123");
|
||||
assert_eq!(parsed["method"], "DOM.getDocument");
|
||||
assert_eq!(parsed["id"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_session_id_empty_object() {
|
||||
let result = inject_session_id("{}", "abc");
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).expect("valid JSON");
|
||||
assert_eq!(parsed["sessionId"], "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_session_id() {
|
||||
let input = r#"{"id":1,"result":{},"sessionId":"abc123"}"#;
|
||||
let result = strip_session_id(input);
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).expect("valid JSON");
|
||||
assert!(parsed.get("sessionId").is_none());
|
||||
assert_eq!(parsed["id"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_then_strip_roundtrip() {
|
||||
let input = r#"{"id":42,"method":"Runtime.evaluate"}"#;
|
||||
let injected = inject_session_id(input, "sess1");
|
||||
let stripped = strip_session_id(&injected);
|
||||
let original: serde_json::Value = serde_json::from_str(input).unwrap();
|
||||
let result: serde_json::Value = serde_json::from_str(&stripped).unwrap();
|
||||
assert_eq!(original, result);
|
||||
}
|
||||
}
|
||||
+559
-83
@@ -1,3 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
@@ -11,9 +13,17 @@ pub async fn click(
|
||||
selector_or_ref: &str,
|
||||
button: &str,
|
||||
click_count: i32,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let (x, y) = resolve_element_center(client, session_id, ref_map, selector_or_ref).await?;
|
||||
dispatch_click(client, session_id, x, y, button, click_count).await
|
||||
let (x, y, effective_session_id) = resolve_element_center(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
dispatch_click(client, &effective_session_id, x, y, button, click_count).await
|
||||
}
|
||||
|
||||
pub async fn dblclick(
|
||||
@@ -21,8 +31,18 @@ pub async fn dblclick(
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
click(client, session_id, ref_map, selector_or_ref, "left", 2).await
|
||||
click(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
"left",
|
||||
2,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn hover(
|
||||
@@ -30,8 +50,16 @@ pub async fn hover(
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let (x, y) = resolve_element_center(client, session_id, ref_map, selector_or_ref).await?;
|
||||
let (x, y, effective_session_id) = resolve_element_center(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.dispatchMouseEvent",
|
||||
@@ -46,7 +74,7 @@ pub async fn hover(
|
||||
delta_y: None,
|
||||
modifiers: None,
|
||||
},
|
||||
Some(session_id),
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
@@ -58,8 +86,16 @@ pub async fn fill(
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
value: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Focus the element
|
||||
client
|
||||
@@ -72,7 +108,7 @@ pub async fn fill(
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -92,11 +128,11 @@ pub async fn fill(
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Insert text
|
||||
// Insert text (keyboard input dispatched at page level, use parent session_id)
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.insertText",
|
||||
@@ -110,6 +146,7 @@ pub async fn fill(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn type_text(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
@@ -118,8 +155,16 @@ pub async fn type_text(
|
||||
text: &str,
|
||||
clear: bool,
|
||||
delay_ms: Option<u64>,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Focus
|
||||
client
|
||||
@@ -132,7 +177,7 @@ pub async fn type_text(
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -152,50 +197,73 @@ pub async fn type_text(
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
type_text_into_active_context(client, session_id, text, delay_ms).await
|
||||
}
|
||||
|
||||
pub async fn type_text_into_active_context(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
text: &str,
|
||||
delay_ms: Option<u64>,
|
||||
) -> Result<(), String> {
|
||||
let delay = delay_ms.unwrap_or(0);
|
||||
|
||||
for ch in text.chars() {
|
||||
let text_str = ch.to_string();
|
||||
let (key, code, key_code) = char_to_key_info(ch);
|
||||
if matches!(ch, '\n' | '\r' | '\t') {
|
||||
let (key, code, key_code) = char_to_key_info(ch);
|
||||
let text_str = key_text(&key);
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.dispatchKeyEvent",
|
||||
&DispatchKeyEventParams {
|
||||
event_type: "keyDown".to_string(),
|
||||
key: Some(key.clone()),
|
||||
code: Some(code.clone()),
|
||||
text: text_str.clone(),
|
||||
unmodified_text: text_str,
|
||||
windows_virtual_key_code: Some(key_code),
|
||||
native_virtual_key_code: Some(key_code),
|
||||
modifiers: None,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.dispatchKeyEvent",
|
||||
&DispatchKeyEventParams {
|
||||
event_type: "keyDown".to_string(),
|
||||
key: Some(key.clone()),
|
||||
code: Some(code.clone()),
|
||||
text: Some(text_str.clone()),
|
||||
unmodified_text: Some(text_str.clone()),
|
||||
windows_virtual_key_code: Some(key_code),
|
||||
native_virtual_key_code: Some(key_code),
|
||||
modifiers: None,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.dispatchKeyEvent",
|
||||
&DispatchKeyEventParams {
|
||||
event_type: "keyUp".to_string(),
|
||||
key: Some(key),
|
||||
code: Some(code),
|
||||
text: None,
|
||||
unmodified_text: None,
|
||||
windows_virtual_key_code: Some(key_code),
|
||||
native_virtual_key_code: Some(key_code),
|
||||
modifiers: None,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.dispatchKeyEvent",
|
||||
&DispatchKeyEventParams {
|
||||
event_type: "keyUp".to_string(),
|
||||
key: Some(key),
|
||||
code: Some(code),
|
||||
text: None,
|
||||
unmodified_text: None,
|
||||
windows_virtual_key_code: Some(key_code),
|
||||
native_virtual_key_code: Some(key_code),
|
||||
modifiers: None,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
// VS Code/Electron webviews reject repeated dispatchKeyEvent calls
|
||||
// carrying printable `text`. Insert printable characters directly
|
||||
// and reserve key events for controls like Enter and Tab.
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.insertText",
|
||||
&InsertTextParams {
|
||||
text: ch.to_string(),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if delay > 0 {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(delay)).await;
|
||||
@@ -206,8 +274,33 @@ pub async fn type_text(
|
||||
}
|
||||
|
||||
pub async fn press_key(client: &CdpClient, session_id: &str, key: &str) -> Result<(), String> {
|
||||
press_key_with_modifiers(client, session_id, key, None).await
|
||||
}
|
||||
|
||||
/// Dispatch a keyDown+keyUp sequence for `key` with an optional CDP modifier bitmask.
|
||||
///
|
||||
/// Modifier values follow the CDP `Input.dispatchKeyEvent` spec:
|
||||
/// 1 = Alt, 2 = Control, 4 = Meta (Cmd), 8 = Shift.
|
||||
///
|
||||
/// Callers that need a platform-appropriate modifier (e.g. Cmd on macOS,
|
||||
/// Ctrl elsewhere) must choose the value themselves -- see `cfg!(target_os)`.
|
||||
pub async fn press_key_with_modifiers(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
key: &str,
|
||||
modifiers: Option<i32>,
|
||||
) -> Result<(), String> {
|
||||
let (key_name, code, key_code) = named_key_info(key);
|
||||
|
||||
// Suppress text insertion when Control (2) or Meta (4) modifiers are active,
|
||||
// since these are command chords (e.g. Ctrl+A = select-all), not text input.
|
||||
let has_command_modifier = modifiers.is_some_and(|m| m & (2 | 4) != 0);
|
||||
let text = if has_command_modifier {
|
||||
None
|
||||
} else {
|
||||
key_text(&key_name)
|
||||
};
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.dispatchKeyEvent",
|
||||
@@ -215,11 +308,11 @@ pub async fn press_key(client: &CdpClient, session_id: &str, key: &str) -> Resul
|
||||
event_type: "keyDown".to_string(),
|
||||
key: Some(key_name.clone()),
|
||||
code: Some(code.clone()),
|
||||
text: None,
|
||||
unmodified_text: None,
|
||||
text: text.clone(),
|
||||
unmodified_text: text.clone(),
|
||||
windows_virtual_key_code: Some(key_code),
|
||||
native_virtual_key_code: Some(key_code),
|
||||
modifiers: None,
|
||||
modifiers,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
@@ -236,7 +329,7 @@ pub async fn press_key(client: &CdpClient, session_id: &str, key: &str) -> Resul
|
||||
unmodified_text: None,
|
||||
windows_virtual_key_code: Some(key_code),
|
||||
native_virtual_key_code: Some(key_code),
|
||||
modifiers: None,
|
||||
modifiers,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
@@ -252,9 +345,11 @@ pub async fn scroll(
|
||||
selector_or_ref: Option<&str>,
|
||||
delta_x: f64,
|
||||
delta_y: f64,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
if let Some(sel) = selector_or_ref {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, sel).await?;
|
||||
let (object_id, effective_session_id) =
|
||||
resolve_element_object_id(client, session_id, ref_map, sel, iframe_sessions).await?;
|
||||
let js = "function(dx, dy) { this.scrollBy(dx, dy); }".to_string();
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
@@ -275,7 +370,7 @@ pub async fn scroll(
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
@@ -301,8 +396,16 @@ pub async fn select_option(
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
values: &[String],
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let js = r#"function(vals) {
|
||||
const options = Array.from(this.options);
|
||||
@@ -326,7 +429,7 @@ pub async fn select_option(
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -338,11 +441,49 @@ pub async fn check(
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let is_checked =
|
||||
super::element::is_element_checked(client, session_id, ref_map, selector_or_ref).await?;
|
||||
let is_checked = super::element::is_element_checked(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
if !is_checked {
|
||||
click(client, session_id, ref_map, selector_or_ref, "left", 1).await?;
|
||||
click(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
"left",
|
||||
1,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Verify the click changed the state (Playwright parity: _setChecked re-checks).
|
||||
// If the coordinate-based click missed (e.g. hidden input, overlay), retry
|
||||
// with a JS .click() on the element and its associated input.
|
||||
if !super::element::is_element_checked(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
js_click_checkbox(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -352,22 +493,132 @@ pub async fn uncheck(
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let is_checked =
|
||||
super::element::is_element_checked(client, session_id, ref_map, selector_or_ref).await?;
|
||||
let is_checked = super::element::is_element_checked(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
if is_checked {
|
||||
click(client, session_id, ref_map, selector_or_ref, "left", 1).await?;
|
||||
click(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
"left",
|
||||
1,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Same verify-and-retry as check().
|
||||
if super::element::is_element_checked(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
js_click_checkbox(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fallback for when the coordinate-based CDP click did not toggle the
|
||||
/// checkbox/radio state. This mirrors how Playwright dispatches clicks
|
||||
/// through the DOM rather than via raw Input.dispatchMouseEvent coordinates.
|
||||
///
|
||||
/// Uses the same follow-label resolution as `is_element_checked`:
|
||||
/// 1. If the element is a native input → `.click()` it directly.
|
||||
/// 2. If the element is inside a `<label>` → `.click()` the label's `.control`.
|
||||
/// 3. If the element has a nested `<input>` → `.click()` that input.
|
||||
/// 4. Otherwise → `.click()` the element itself (handles ARIA role controls).
|
||||
async fn js_click_checkbox(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let js = r#"function() {
|
||||
var el = this;
|
||||
var tag = el.tagName && el.tagName.toUpperCase();
|
||||
// 1. Native input — click it directly
|
||||
if (tag === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio')) {
|
||||
el.click();
|
||||
return;
|
||||
}
|
||||
// 2. Follow label → control association
|
||||
var label = tag === 'LABEL' ? el : (el.closest && el.closest('label'));
|
||||
if (label && label.tagName && label.tagName.toUpperCase() === 'LABEL' && label.control) {
|
||||
label.control.click();
|
||||
return;
|
||||
}
|
||||
// 3. Nested native input
|
||||
var input = el.querySelector && el.querySelector('input[type="checkbox"], input[type="radio"]');
|
||||
if (input) {
|
||||
input.click();
|
||||
return;
|
||||
}
|
||||
// 4. ARIA role control — click the element itself
|
||||
el.click();
|
||||
}"#;
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: js.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn focus(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
@@ -379,7 +630,7 @@ pub async fn focus(
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -391,8 +642,16 @@ pub async fn clear(
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
@@ -410,7 +669,7 @@ pub async fn clear(
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -422,8 +681,16 @@ pub async fn select_all(
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
@@ -447,7 +714,7 @@ pub async fn select_all(
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -459,8 +726,16 @@ pub async fn scroll_into_view(
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
@@ -474,7 +749,7 @@ pub async fn scroll_into_view(
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -488,8 +763,16 @@ pub async fn dispatch_event(
|
||||
selector_or_ref: &str,
|
||||
event_type: &str,
|
||||
event_init: Option<&Value>,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let init_json = event_init
|
||||
.map(|v| serde_json::to_string(v).unwrap_or("{}".to_string()))
|
||||
@@ -511,7 +794,7 @@ pub async fn dispatch_event(
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -523,8 +806,16 @@ pub async fn highlight(
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
@@ -545,7 +836,7 @@ pub async fn highlight(
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -557,8 +848,16 @@ pub async fn tap_touch(
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let (x, y) = resolve_element_center(client, session_id, ref_map, selector_or_ref).await?;
|
||||
let (x, y, effective_session_id) = resolve_element_center(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
|
||||
client
|
||||
.send_command(
|
||||
@@ -567,7 +866,7 @@ pub async fn tap_touch(
|
||||
"type": "touchStart",
|
||||
"touchPoints": [{ "x": x, "y": y }],
|
||||
})),
|
||||
Some(session_id),
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -578,7 +877,7 @@ pub async fn tap_touch(
|
||||
"type": "touchEnd",
|
||||
"touchPoints": [],
|
||||
})),
|
||||
Some(session_id),
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -666,15 +965,75 @@ fn char_to_key_info(ch: char) -> (String, String, i32) {
|
||||
' ' => (" ".to_string(), "Space".to_string(), 32),
|
||||
_ => {
|
||||
let key = ch.to_string();
|
||||
let code = if ch.is_ascii_alphabetic() {
|
||||
format!("Key{}", ch.to_uppercase())
|
||||
if ch.is_ascii_alphabetic() {
|
||||
// For letters the Windows VK code equals the uppercase ASCII value.
|
||||
let upper = ch.to_ascii_uppercase();
|
||||
let code = format!("Key{}", upper);
|
||||
let key_code = upper as i32;
|
||||
(key, code, key_code)
|
||||
} else if ch.is_ascii_digit() {
|
||||
format!("Digit{}", ch)
|
||||
let code = format!("Digit{}", ch);
|
||||
let key_code = ch as i32;
|
||||
(key, code, key_code)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let key_code = ch as i32;
|
||||
(key, code, key_code)
|
||||
let (code, key_code) = punctuation_key_info(ch);
|
||||
(key, code.to_string(), key_code)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the DOM `KeyboardEvent.code` value and Windows virtual-key code for
|
||||
/// a punctuation / symbol character assuming a US keyboard layout.
|
||||
///
|
||||
/// The Windows virtual-key codes (VK_OEM_*) differ from ASCII values for
|
||||
/// punctuation. Using the raw ASCII code would misidentify characters – e.g.
|
||||
/// '.' (ASCII 46) collides with VK_DELETE (0x2E = 46), causing the period to
|
||||
/// be swallowed.
|
||||
fn punctuation_key_info(ch: char) -> (&'static str, i32) {
|
||||
match ch {
|
||||
// VK_OEM_1 (0xBA = 186) — ";:" key on US layout
|
||||
';' | ':' => ("Semicolon", 186),
|
||||
// VK_OEM_PLUS (0xBB = 187) — "=+" key
|
||||
'=' | '+' => ("Equal", 187),
|
||||
// VK_OEM_COMMA (0xBC = 188) — ",<" key
|
||||
',' | '<' => ("Comma", 188),
|
||||
// VK_OEM_MINUS (0xBD = 189) — "-_" key
|
||||
'-' | '_' => ("Minus", 189),
|
||||
// VK_OEM_PERIOD (0xBE = 190) — ".>" key
|
||||
'.' | '>' => ("Period", 190),
|
||||
// VK_OEM_2 (0xBF = 191) — "/?" key
|
||||
'/' | '?' => ("Slash", 191),
|
||||
// VK_OEM_3 (0xC0 = 192) — "`~" key
|
||||
'`' | '~' => ("Backquote", 192),
|
||||
// VK_OEM_4 (0xDB = 219) — "[{" key
|
||||
'[' | '{' => ("BracketLeft", 219),
|
||||
// VK_OEM_5 (0xDC = 220) — "\\|" key
|
||||
'\\' | '|' => ("Backslash", 220),
|
||||
// VK_OEM_6 (0xDD = 221) — "]}" key
|
||||
']' | '}' => ("BracketRight", 221),
|
||||
// VK_OEM_7 (0xDE = 222) — "'\""" key
|
||||
'\'' | '"' => ("Quote", 222),
|
||||
_ => ("", 0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the `text` value that CDP `Input.dispatchKeyEvent` needs on the
|
||||
/// `keyDown` event so that Chrome performs the default action for the key.
|
||||
/// For example Enter needs `"\r"` to actually submit a form, and Tab needs
|
||||
/// `"\t"` to move focus. Non-printable / navigation keys return `None`.
|
||||
fn key_text(key_name: &str) -> Option<String> {
|
||||
match key_name {
|
||||
"Enter" => Some("\r".to_string()),
|
||||
"Tab" => Some("\t".to_string()),
|
||||
" " => Some(" ".to_string()),
|
||||
_ => {
|
||||
// Single printable characters carry themselves as text.
|
||||
if key_name.len() == 1 {
|
||||
Some(key_name.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -705,3 +1064,120 @@ fn named_key_info(key: &str) -> (String, String, i32) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Verify that `char_to_key_info` returns the correct (key, code,
|
||||
/// windowsVirtualKeyCode) triple for every character in Playwright's
|
||||
/// USKeyboardLayout. The expected values below are taken verbatim from
|
||||
/// playwright-core/lib/server/usKeyboardLayout.js so that any drift from
|
||||
/// Playwright's behaviour is caught immediately.
|
||||
#[test]
|
||||
fn test_char_to_key_info_matches_playwright_layout() {
|
||||
// (character, expected_code, expected_vk_code)
|
||||
let cases: &[(char, &str, i32)] = &[
|
||||
// Letters – VK code must equal the uppercase ASCII value.
|
||||
('a', "KeyA", 65),
|
||||
('z', "KeyZ", 90),
|
||||
('A', "KeyA", 65),
|
||||
// Digits
|
||||
('0', "Digit0", 48),
|
||||
('9', "Digit9", 57),
|
||||
// Punctuation – these are the values from Playwright's layout.
|
||||
// The bug that prompted this test sent '.' as VK 46 (= VK_DELETE).
|
||||
('.', "Period", 190),
|
||||
(',', "Comma", 188),
|
||||
('/', "Slash", 191),
|
||||
(';', "Semicolon", 186),
|
||||
('\'', "Quote", 222),
|
||||
('[', "BracketLeft", 219),
|
||||
(']', "BracketRight", 221),
|
||||
('\\', "Backslash", 220),
|
||||
('`', "Backquote", 192),
|
||||
('-', "Minus", 189),
|
||||
('=', "Equal", 187),
|
||||
// Shifted variants produced by the same physical keys.
|
||||
('>', "Period", 190),
|
||||
('<', "Comma", 188),
|
||||
('?', "Slash", 191),
|
||||
(':', "Semicolon", 186),
|
||||
('"', "Quote", 222),
|
||||
('{', "BracketLeft", 219),
|
||||
('}', "BracketRight", 221),
|
||||
('|', "Backslash", 220),
|
||||
('~', "Backquote", 192),
|
||||
('_', "Minus", 189),
|
||||
('+', "Equal", 187),
|
||||
// Whitespace / control
|
||||
(' ', "Space", 32),
|
||||
('\n', "Enter", 13),
|
||||
('\t', "Tab", 9),
|
||||
];
|
||||
|
||||
for &(ch, expected_code, expected_vk) in cases {
|
||||
let (key, code, vk) = char_to_key_info(ch);
|
||||
assert_eq!(
|
||||
code, expected_code,
|
||||
"char {:?}: expected code {:?}, got {:?}",
|
||||
ch, expected_code, code
|
||||
);
|
||||
assert_eq!(
|
||||
vk, expected_vk,
|
||||
"char {:?}: expected VK {}, got {} (ASCII would be {})",
|
||||
ch, expected_vk, vk, ch as i32
|
||||
);
|
||||
// key should be the character itself (except control chars).
|
||||
if !ch.is_control() {
|
||||
assert_eq!(key, ch.to_string(), "char {:?}: key mismatch", ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression test: period must NEVER map to VK 46 (VK_DELETE).
|
||||
#[test]
|
||||
fn test_period_is_not_vk_delete() {
|
||||
let (_, _, vk) = char_to_key_info('.');
|
||||
assert_ne!(
|
||||
vk, 46,
|
||||
"Period must not use VK code 46 (VK_DELETE); expected 190 (VK_OEM_PERIOD)"
|
||||
);
|
||||
assert_eq!(vk, 190);
|
||||
}
|
||||
|
||||
/// Characters outside the US keyboard layout should return (key, "", 0)
|
||||
/// so that `type_text` falls back to `Input.insertText`.
|
||||
#[test]
|
||||
fn test_unmapped_chars_return_zero_keycode() {
|
||||
for ch in ['@', '#', '$', '%', '^', '&', '*', '(', ')', '€', '£', '你'] {
|
||||
let (key, code, vk) = char_to_key_info(ch);
|
||||
assert_eq!(
|
||||
code, "",
|
||||
"char {:?}: unmapped char should have empty code, got {:?}",
|
||||
ch, code
|
||||
);
|
||||
assert_eq!(
|
||||
vk, 0,
|
||||
"char {:?}: unmapped char should have VK 0, got {}",
|
||||
ch, vk
|
||||
);
|
||||
assert_eq!(key, ch.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_text_returns_correct_text_for_special_keys() {
|
||||
assert_eq!(key_text("Enter"), Some("\r".to_string()));
|
||||
assert_eq!(key_text("Tab"), Some("\t".to_string()));
|
||||
assert_eq!(key_text(" "), Some(" ".to_string()));
|
||||
// Single printable characters carry themselves.
|
||||
assert_eq!(key_text("a"), Some("a".to_string()));
|
||||
assert_eq!(key_text("Z"), Some("Z".to_string()));
|
||||
// Non-printable named keys return None.
|
||||
assert_eq!(key_text("Escape"), None);
|
||||
assert_eq!(key_text("ArrowUp"), None);
|
||||
assert_eq!(key_text("Backspace"), None);
|
||||
assert_eq!(key_text("Delete"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ pub mod diff;
|
||||
#[allow(dead_code)]
|
||||
pub mod element;
|
||||
#[allow(dead_code)]
|
||||
pub mod inspect_server;
|
||||
#[allow(dead_code)]
|
||||
pub mod interaction;
|
||||
#[allow(dead_code)]
|
||||
pub mod network;
|
||||
@@ -31,6 +33,8 @@ pub mod snapshot;
|
||||
#[allow(dead_code)]
|
||||
pub mod state;
|
||||
#[allow(dead_code)]
|
||||
pub mod stealth;
|
||||
#[allow(dead_code)]
|
||||
pub mod storage;
|
||||
#[allow(dead_code)]
|
||||
pub mod stream;
|
||||
|
||||
+287
-14
@@ -184,7 +184,7 @@ pub async fn install_domain_filter_script(
|
||||
const OrigWS = window.WebSocket;
|
||||
window.WebSocket = function(url, protocols) {{
|
||||
try {{
|
||||
const u = new URL(url);
|
||||
const u = new URL(url, location.href);
|
||||
if (!_isDomainAllowed(u.hostname)) throw new DOMException('WebSocket blocked: ' + u.hostname, 'SecurityError');
|
||||
}} catch(e) {{ if (e instanceof DOMException) throw e; }}
|
||||
return new OrigWS(url, protocols);
|
||||
@@ -233,15 +233,16 @@ pub async fn install_domain_filter_script(
|
||||
pub async fn install_domain_filter_fetch(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
handle_auth_requests: bool,
|
||||
) -> Result<(), String> {
|
||||
let mut params = json!({
|
||||
"patterns": [{ "urlPattern": "*" }]
|
||||
});
|
||||
if handle_auth_requests {
|
||||
params["handleAuthRequests"] = json!(true);
|
||||
}
|
||||
client
|
||||
.send_command(
|
||||
"Fetch.enable",
|
||||
Some(json!({
|
||||
"patterns": [{ "urlPattern": "*" }]
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.send_command("Fetch.enable", Some(params), Some(session_id))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -253,12 +254,103 @@ pub async fn install_domain_filter(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
allowed_domains: &[String],
|
||||
handle_auth_requests: bool,
|
||||
) -> Result<(), String> {
|
||||
install_domain_filter_script(client, session_id, allowed_domains).await?;
|
||||
install_domain_filter_fetch(client, session_id).await?;
|
||||
install_domain_filter_fetch(client, session_id, handle_auth_requests).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Console arg formatting (CDP RemoteObject → human-readable string)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Format a single CDP RemoteObject arg into a human-readable string.
|
||||
/// Priority: value → preview → description.
|
||||
pub fn format_console_arg(arg: &Value) -> Option<String> {
|
||||
let obj_type = arg.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let subtype = arg.get("subtype").and_then(|v| v.as_str());
|
||||
|
||||
if obj_type == "undefined" {
|
||||
return Some("undefined".to_string());
|
||||
}
|
||||
|
||||
if subtype == Some("null") {
|
||||
return Some("null".to_string());
|
||||
}
|
||||
|
||||
// Primitive value
|
||||
if let Some(v) = arg.get("value") {
|
||||
return Some(match v {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Null => "null".to_string(),
|
||||
other => other.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Skip preview for Map/Set — their description ("Map(1)", "Set(3)") is more useful
|
||||
// than their preview properties (which only show "size")
|
||||
if let Some(preview) = arg.get("preview") {
|
||||
let preview_subtype = preview.get("subtype").and_then(|v| v.as_str());
|
||||
if matches!(preview_subtype, Some("map" | "set" | "weakmap" | "weakset")) {
|
||||
return arg
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
let is_array = subtype == Some("array") || preview_subtype == Some("array");
|
||||
if let Some(props) = preview.get("properties").and_then(|v| v.as_array()) {
|
||||
let overflow = preview
|
||||
.get("overflow")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let formatted_props: Vec<String> = props
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
let value_str = p.get("value").and_then(|v| v.as_str())?;
|
||||
let prop_type = p.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let formatted_value = if prop_type == "string" {
|
||||
format!("\"{}\"", value_str)
|
||||
} else {
|
||||
value_str.to_string()
|
||||
};
|
||||
if is_array {
|
||||
Some(formatted_value)
|
||||
} else {
|
||||
let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
Some(format!("{}: {}", name, formatted_value))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let inner = if overflow {
|
||||
format!("{}, ...", formatted_props.join(", "))
|
||||
} else {
|
||||
formatted_props.join(", ")
|
||||
};
|
||||
|
||||
return if is_array {
|
||||
Some(format!("[{}]", inner))
|
||||
} else {
|
||||
Some(format!("{{{}}}", inner))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to description
|
||||
arg.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Format an array of CDP RemoteObject args into a single space-separated string.
|
||||
pub fn format_console_args(args: &[Value]) -> String {
|
||||
args.iter()
|
||||
.filter_map(format_console_arg)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Console and error tracking
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -267,6 +359,7 @@ pub async fn install_domain_filter(
|
||||
pub struct ConsoleEntry {
|
||||
pub level: String,
|
||||
pub text: String,
|
||||
pub args: Vec<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -292,13 +385,14 @@ impl EventTracker {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_console(&mut self, level: &str, text: &str) {
|
||||
pub fn add_console(&mut self, level: &str, text: &str, args: Vec<Value>) {
|
||||
if self.console_entries.len() >= self.max_entries {
|
||||
self.console_entries.remove(0);
|
||||
}
|
||||
self.console_entries.push(ConsoleEntry {
|
||||
level: level.to_string(),
|
||||
text: text.to_string(),
|
||||
args,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -320,13 +414,25 @@ impl EventTracker {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn clear_console(&mut self) {
|
||||
self.console_entries.clear();
|
||||
}
|
||||
|
||||
pub fn get_console_json(&self) -> Value {
|
||||
let entries: Vec<Value> = self
|
||||
let messages: Vec<Value> = self
|
||||
.console_entries
|
||||
.iter()
|
||||
.map(|e| json!({ "level": e.level, "text": e.text }))
|
||||
.map(|e| {
|
||||
let mut msg = json!({ "type": e.level, "text": e.text });
|
||||
if !e.args.is_empty() {
|
||||
msg.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("args".to_string(), Value::Array(e.args.clone()));
|
||||
}
|
||||
msg
|
||||
})
|
||||
.collect();
|
||||
json!({ "entries": entries })
|
||||
json!({ "messages": messages })
|
||||
}
|
||||
|
||||
pub fn get_errors_json(&self) -> Value {
|
||||
@@ -390,10 +496,177 @@ mod tests {
|
||||
#[test]
|
||||
fn test_event_tracker() {
|
||||
let mut tracker = EventTracker::new();
|
||||
tracker.add_console("log", "hello");
|
||||
tracker.add_console("log", "hello", vec![]);
|
||||
tracker.add_error("oops", Some("test.js"), Some(1), Some(5));
|
||||
|
||||
assert_eq!(tracker.console_entries.len(), 1);
|
||||
assert_eq!(tracker.error_entries.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_console_json_includes_args() {
|
||||
let mut tracker = EventTracker::new();
|
||||
let raw_args = vec![
|
||||
json!({"type": "string", "value": "hello"}),
|
||||
json!({"type": "number", "value": 42}),
|
||||
];
|
||||
tracker.add_console("log", "hello 42", raw_args);
|
||||
|
||||
let result = tracker.get_console_json();
|
||||
let messages = result.get("messages").unwrap().as_array().unwrap();
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages[0].get("text").unwrap(), "hello 42");
|
||||
let args = messages[0].get("args").unwrap().as_array().unwrap();
|
||||
assert_eq!(args.len(), 2);
|
||||
assert_eq!(args[0], json!({"type": "string", "value": "hello"}));
|
||||
assert_eq!(args[1], json!({"type": "number", "value": 42}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_console_json_empty_args_omits_field() {
|
||||
let mut tracker = EventTracker::new();
|
||||
tracker.add_console("log", "text only", vec![]);
|
||||
|
||||
let result = tracker.get_console_json();
|
||||
let messages = result.get("messages").unwrap().as_array().unwrap();
|
||||
assert!(messages[0].get("args").is_none());
|
||||
}
|
||||
|
||||
// -- format_console_arg: primitives --
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_string() {
|
||||
let arg = json!({"type": "string", "value": "hello"});
|
||||
assert_eq!(format_console_arg(&arg), Some("hello".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_number() {
|
||||
let arg = json!({"type": "number", "value": 42});
|
||||
assert_eq!(format_console_arg(&arg), Some("42".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_null() {
|
||||
let arg = json!({"type": "object", "subtype": "null", "value": null});
|
||||
assert_eq!(format_console_arg(&arg), Some("null".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_undefined() {
|
||||
let arg = json!({"type": "undefined"});
|
||||
assert_eq!(format_console_arg(&arg), Some("undefined".to_string()));
|
||||
}
|
||||
|
||||
// -- format_console_arg: objects with preview --
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_object_preview() {
|
||||
let arg = json!({
|
||||
"type": "object",
|
||||
"preview": {
|
||||
"properties": [
|
||||
{"name": "userId", "type": "string", "value": "abc123"},
|
||||
{"name": "count", "type": "number", "value": "42"}
|
||||
],
|
||||
"overflow": false
|
||||
}
|
||||
});
|
||||
assert_eq!(
|
||||
format_console_arg(&arg),
|
||||
Some("{userId: \"abc123\", count: 42}".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_object_preview_overflow() {
|
||||
let arg = json!({
|
||||
"type": "object",
|
||||
"preview": {
|
||||
"properties": [
|
||||
{"name": "a", "type": "number", "value": "1"}
|
||||
],
|
||||
"overflow": true
|
||||
}
|
||||
});
|
||||
assert_eq!(format_console_arg(&arg), Some("{a: 1, ...}".to_string()));
|
||||
}
|
||||
|
||||
// -- format_console_arg: arrays with preview --
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_array_preview() {
|
||||
let arg = json!({
|
||||
"type": "object",
|
||||
"subtype": "array",
|
||||
"preview": {
|
||||
"subtype": "array",
|
||||
"properties": [
|
||||
{"name": "0", "type": "number", "value": "1"},
|
||||
{"name": "1", "type": "number", "value": "2"},
|
||||
{"name": "2", "type": "number", "value": "3"}
|
||||
],
|
||||
"overflow": false
|
||||
}
|
||||
});
|
||||
assert_eq!(format_console_arg(&arg), Some("[1, 2, 3]".to_string()));
|
||||
}
|
||||
|
||||
// -- format_console_arg: map/set use description --
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_map_uses_description() {
|
||||
let arg = json!({
|
||||
"type": "object",
|
||||
"subtype": "map",
|
||||
"description": "Map(1)",
|
||||
"preview": {
|
||||
"subtype": "map",
|
||||
"properties": [{"name": "size", "type": "number", "value": "1"}]
|
||||
}
|
||||
});
|
||||
assert_eq!(format_console_arg(&arg), Some("Map(1)".to_string()));
|
||||
}
|
||||
|
||||
// -- format_console_arg: fallback --
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_description_fallback() {
|
||||
let arg = json!({"type": "object", "description": "RegExp"});
|
||||
assert_eq!(format_console_arg(&arg), Some("RegExp".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_no_value_no_preview_no_description() {
|
||||
let arg = json!({"type": "object"});
|
||||
assert_eq!(format_console_arg(&arg), None);
|
||||
}
|
||||
|
||||
// -- format_console_args --
|
||||
|
||||
#[test]
|
||||
fn test_format_console_args_join() {
|
||||
let args = vec![
|
||||
json!({"type": "string", "value": "user"}),
|
||||
json!({
|
||||
"type": "object",
|
||||
"preview": {
|
||||
"properties": [{"name": "id", "type": "number", "value": "1"}],
|
||||
"overflow": false
|
||||
}
|
||||
}),
|
||||
];
|
||||
assert_eq!(format_console_args(&args), "user {id: 1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_console_args_filters_none() {
|
||||
// An arg that returns None should be skipped, not produce empty string
|
||||
let args = vec![
|
||||
json!({"type": "string", "value": "before"}),
|
||||
json!({"type": "object"}), // no value, preview, or description → None
|
||||
json!({"type": "string", "value": "after"}),
|
||||
];
|
||||
assert_eq!(format_console_args(&args), "before after");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,38 @@ use serde_json::{json, Value};
|
||||
|
||||
use super::actions::{execute_command, DaemonState};
|
||||
|
||||
const ENCRYPTION_KEY_ENV: &str = "AGENT_BROWSER_ENCRYPTION_KEY";
|
||||
|
||||
struct TestKeyGuard {
|
||||
_lock: std::sync::MutexGuard<'static, ()>,
|
||||
original: Option<String>,
|
||||
}
|
||||
|
||||
impl TestKeyGuard {
|
||||
fn new() -> Self {
|
||||
let lock = super::auth::AUTH_TEST_MUTEX
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let original = std::env::var(ENCRYPTION_KEY_ENV).ok();
|
||||
// SAFETY: AUTH_TEST_MUTEX serializes all test access so no concurrent mutation.
|
||||
unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, "a".repeat(64)) };
|
||||
Self {
|
||||
_lock: lock,
|
||||
original,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestKeyGuard {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: AUTH_TEST_MUTEX is held via _lock.
|
||||
match &self.original {
|
||||
Some(val) => unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, val) },
|
||||
None => unsafe { std::env::remove_var(ENCRYPTION_KEY_ENV) },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All documented action names that should be implemented.
|
||||
const DOCUMENTED_ACTIONS: &[&str] = &[
|
||||
"launch",
|
||||
@@ -140,6 +172,7 @@ const DOCUMENTED_ACTIONS: &[&str] = &[
|
||||
"route",
|
||||
"unroute",
|
||||
"requests",
|
||||
"request_detail",
|
||||
"credentials",
|
||||
"auth_save",
|
||||
"auth_login",
|
||||
@@ -311,7 +344,7 @@ fn minimal_command(action: &str, id: &str) -> Value {
|
||||
obj.insert("script".to_string(), json!("h => h"));
|
||||
}
|
||||
"drag" => {
|
||||
obj.insert("selector".to_string(), json!("body"));
|
||||
obj.insert("source".to_string(), json!("body"));
|
||||
obj.insert("target".to_string(), json!("body"));
|
||||
}
|
||||
"swipe" => {
|
||||
@@ -424,6 +457,7 @@ async fn test_credentials_list_without_browser() {
|
||||
#[tokio::test]
|
||||
async fn test_auth_profile_name_validation() {
|
||||
use super::auth;
|
||||
let _key_guard = TestKeyGuard::new();
|
||||
let valid = auth::credentials_set("valid-name_123", "u", "p", None);
|
||||
assert!(valid.is_ok());
|
||||
let invalid = auth::credentials_set("invalid/name", "u", "p", None);
|
||||
@@ -439,6 +473,7 @@ async fn test_auth_profile_name_validation() {
|
||||
#[tokio::test]
|
||||
async fn test_auth_save_and_show() {
|
||||
use super::auth;
|
||||
let _key_guard = TestKeyGuard::new();
|
||||
let result = auth::auth_save(
|
||||
"parity-roundtrip",
|
||||
"https://example.com",
|
||||
@@ -499,6 +534,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]
|
||||
@@ -510,6 +546,11 @@ async fn test_tracked_request_struct() {
|
||||
headers: json!({"Accept": "text/html"}),
|
||||
timestamp: 12345,
|
||||
resource_type: "Document".to_string(),
|
||||
request_id: "1.1".to_string(),
|
||||
post_data: None,
|
||||
status: Some(200),
|
||||
response_headers: None,
|
||||
mime_type: Some("text/html".to_string()),
|
||||
};
|
||||
let serialized = serde_json::to_value(&tr).unwrap();
|
||||
assert_eq!(serialized["url"], "https://example.com/api");
|
||||
@@ -530,6 +571,11 @@ async fn test_request_tracking_state() {
|
||||
headers: json!({}),
|
||||
timestamp: 1,
|
||||
resource_type: "Document".to_string(),
|
||||
request_id: "1.1".to_string(),
|
||||
post_data: None,
|
||||
status: None,
|
||||
response_headers: None,
|
||||
mime_type: None,
|
||||
});
|
||||
state.tracked_requests.push(super::actions::TrackedRequest {
|
||||
url: "https://other.com".to_string(),
|
||||
@@ -537,6 +583,11 @@ async fn test_request_tracking_state() {
|
||||
headers: json!({}),
|
||||
timestamp: 2,
|
||||
resource_type: "XHR".to_string(),
|
||||
request_id: "1.2".to_string(),
|
||||
post_data: None,
|
||||
status: None,
|
||||
response_headers: None,
|
||||
mime_type: None,
|
||||
});
|
||||
assert_eq!(state.tracked_requests.len(), 2);
|
||||
|
||||
@@ -554,6 +605,30 @@ async fn test_request_tracking_state() {
|
||||
assert!(state.tracked_requests.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_matches_status_filter() {
|
||||
use super::actions::matches_status_filter;
|
||||
|
||||
// Exact match
|
||||
assert!(matches_status_filter(Some(200), "200"));
|
||||
assert!(!matches_status_filter(Some(201), "200"));
|
||||
|
||||
// Class match (Nxx)
|
||||
assert!(matches_status_filter(Some(200), "2xx"));
|
||||
assert!(matches_status_filter(Some(299), "2xx"));
|
||||
assert!(!matches_status_filter(Some(301), "2xx"));
|
||||
assert!(matches_status_filter(Some(404), "4xx"));
|
||||
|
||||
// Range match
|
||||
assert!(matches_status_filter(Some(400), "400-499"));
|
||||
assert!(matches_status_filter(Some(499), "400-499"));
|
||||
assert!(!matches_status_filter(Some(500), "400-499"));
|
||||
|
||||
// None status
|
||||
assert!(!matches_status_filter(None, "200"));
|
||||
assert!(!matches_status_filter(None, "2xx"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_addscript_and_addinitscript_separate_dispatch() {
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
@@ -135,6 +135,7 @@ impl ActionPolicy {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_utils::EnvGuard;
|
||||
|
||||
#[test]
|
||||
fn test_policy_allow_whitelist() {
|
||||
@@ -205,12 +206,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_confirm_actions_from_env() {
|
||||
env::set_var("AGENT_BROWSER_CONFIRM_ACTIONS", "navigate,click,fill");
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_CONFIRM_ACTIONS"]);
|
||||
_guard.set("AGENT_BROWSER_CONFIRM_ACTIONS", "navigate,click,fill");
|
||||
let ca = ConfirmActions::from_env().unwrap();
|
||||
assert!(ca.requires_confirmation("navigate"));
|
||||
assert!(ca.requires_confirmation("click"));
|
||||
assert!(ca.requires_confirmation("fill"));
|
||||
assert!(!ca.requires_confirmation("screenshot"));
|
||||
env::remove_var("AGENT_BROWSER_CONFIRM_ACTIONS");
|
||||
}
|
||||
}
|
||||
|
||||
+586
-44
@@ -1,28 +1,72 @@
|
||||
//! Browser provider connections for remote CDP sessions.
|
||||
//!
|
||||
//! Supports Browserbase, Browser Use, and Kernel providers. Each provider
|
||||
//! returns a CDP WebSocket URL for connecting via BrowserManager.
|
||||
//! Supports AgentCore, Browserbase, Browserless, Browser Use, and Kernel providers.
|
||||
//! Each provider returns a CDP WebSocket URL for connecting via BrowserManager.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::env;
|
||||
|
||||
/// Provider session info for cleanup on failure.
|
||||
#[derive(Debug)]
|
||||
pub struct ProviderSession {
|
||||
pub provider: String,
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ProviderConnection {
|
||||
pub ws_url: String,
|
||||
pub session: Option<ProviderSession>,
|
||||
/// If true, the WebSocket IS the page session (no Target.* commands).
|
||||
pub direct_page: bool,
|
||||
}
|
||||
|
||||
/// Connects to the specified browser provider and returns a CDP WebSocket URL
|
||||
/// along with session info for cleanup on failure.
|
||||
pub async fn connect_provider(
|
||||
provider_name: &str,
|
||||
) -> Result<(String, Option<ProviderSession>), String> {
|
||||
pub async fn connect_provider(provider_name: &str) -> Result<ProviderConnection, String> {
|
||||
match provider_name.to_lowercase().as_str() {
|
||||
"browserbase" => connect_browserbase().await,
|
||||
"browser-use" | "browseruse" => connect_browser_use().await,
|
||||
"kernel" => connect_kernel().await,
|
||||
"browserbase" => {
|
||||
let (url, session) = connect_browserbase().await?;
|
||||
Ok(ProviderConnection {
|
||||
ws_url: url,
|
||||
session,
|
||||
direct_page: false,
|
||||
})
|
||||
}
|
||||
"browserless" => {
|
||||
let (url, session) = connect_browserless().await?;
|
||||
Ok(ProviderConnection {
|
||||
ws_url: url,
|
||||
session,
|
||||
direct_page: false,
|
||||
})
|
||||
}
|
||||
"browser-use" | "browseruse" => {
|
||||
let (url, session) = connect_browser_use().await?;
|
||||
Ok(ProviderConnection {
|
||||
ws_url: url,
|
||||
session,
|
||||
direct_page: false,
|
||||
})
|
||||
}
|
||||
"kernel" => {
|
||||
let (url, session) = connect_kernel().await?;
|
||||
Ok(ProviderConnection {
|
||||
ws_url: url,
|
||||
session,
|
||||
direct_page: false,
|
||||
})
|
||||
}
|
||||
"agentcore" => {
|
||||
let (url, session) = connect_agentcore().await?;
|
||||
Ok(ProviderConnection {
|
||||
ws_url: url,
|
||||
session,
|
||||
direct_page: false,
|
||||
})
|
||||
}
|
||||
_ => Err(format!(
|
||||
"Unknown provider '{}'. Supported: browserbase, browser-use, kernel",
|
||||
"Unknown provider '{}'. Supported: browserbase, browserless, browser-use, kernel, agentcore",
|
||||
provider_name
|
||||
)),
|
||||
}
|
||||
@@ -35,11 +79,13 @@ pub async fn close_provider_session(session: &ProviderSession) {
|
||||
"browserbase" => {
|
||||
if let Ok(api_key) = env::var("BROWSERBASE_API_KEY") {
|
||||
let _ = client
|
||||
.delete(format!(
|
||||
.post(format!(
|
||||
"https://api.browserbase.com/v1/sessions/{}",
|
||||
session.session_id
|
||||
))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-BB-API-Key", &api_key)
|
||||
.json(&serde_json::json!({ "status": "REQUEST_RELEASE" }))
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
@@ -58,6 +104,10 @@ pub async fn close_provider_session(session: &ProviderSession) {
|
||||
.await;
|
||||
}
|
||||
}
|
||||
"browserless" => {
|
||||
// session_id holds the stop URL for browserless
|
||||
let _ = client.delete(&session.session_id).send().await;
|
||||
}
|
||||
"kernel" => {
|
||||
if let Ok(api_key) = env::var("KERNEL_API_KEY") {
|
||||
let endpoint = env::var("KERNEL_ENDPOINT")
|
||||
@@ -73,6 +123,10 @@ pub async fn close_provider_session(session: &ProviderSession) {
|
||||
.await;
|
||||
}
|
||||
}
|
||||
"agentcore" => {
|
||||
// AgentCore session cleanup is handled via signed DELETE request
|
||||
let _ = close_agentcore_session(&session.session_id).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -80,15 +134,13 @@ pub async fn close_provider_session(session: &ProviderSession) {
|
||||
async fn connect_browserbase() -> Result<(String, Option<ProviderSession>), String> {
|
||||
let api_key = env::var("BROWSERBASE_API_KEY")
|
||||
.map_err(|_| "BROWSERBASE_API_KEY environment variable is not set")?;
|
||||
let project_id = env::var("BROWSERBASE_PROJECT_ID")
|
||||
.map_err(|_| "BROWSERBASE_PROJECT_ID environment variable is not set")?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post("https://api.browserbase.com/v1/sessions")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-BB-API-Key", &api_key)
|
||||
.json(&json!({ "projectId": project_id }))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-bb-api-key", &api_key)
|
||||
.body("{}")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Browserbase request failed: {}", e))?;
|
||||
@@ -131,62 +183,98 @@ async fn connect_browserbase() -> Result<(String, Option<ProviderSession>), Stri
|
||||
))
|
||||
}
|
||||
|
||||
async fn connect_browser_use() -> Result<(String, Option<ProviderSession>), String> {
|
||||
let api_key = env::var("BROWSER_USE_API_KEY")
|
||||
.map_err(|_| "BROWSER_USE_API_KEY environment variable is not set")?;
|
||||
async fn connect_browserless() -> Result<(String, Option<ProviderSession>), String> {
|
||||
let api_key = env::var("BROWSERLESS_API_KEY")
|
||||
.map_err(|_| "BROWSERLESS_API_KEY environment variable is not set")?;
|
||||
|
||||
let api_url = env::var("BROWSERLESS_API_URL")
|
||||
.unwrap_or_else(|_| "https://production-sfo.browserless.io".to_string());
|
||||
let browser_type =
|
||||
env::var("BROWSERLESS_BROWSER_TYPE").unwrap_or_else(|_| "chromium".to_string());
|
||||
|
||||
let supported = ["chromium", "chrome"];
|
||||
if !supported.contains(&browser_type.as_str()) {
|
||||
return Err(format!(
|
||||
"BROWSERLESS_BROWSER_TYPE \"{}\" is not supported. Only {} are allowed.",
|
||||
browser_type,
|
||||
supported.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
let ttl: u64 = env::var("BROWSERLESS_TTL")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(300000);
|
||||
let stealth = env::var("BROWSERLESS_STEALTH")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(true);
|
||||
|
||||
let url = format!("{}/session", api_url.trim_end_matches('/'));
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post("https://api.browser-use.com/api/v2/browsers")
|
||||
.post(&url)
|
||||
.query(&[("token", &api_key)])
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Browser-Use-API-Key", &api_key)
|
||||
.json(&json!({}))
|
||||
.json(&json!({
|
||||
"ttl": ttl,
|
||||
"stealth": stealth,
|
||||
"browser": browser_type,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Browser Use request failed: {}", e))?;
|
||||
.map_err(|e| format!("Browserless request failed: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read Browser Use response: {}", e))?;
|
||||
.map_err(|e| format!("Failed to read Browserless response: {}", e))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!(
|
||||
"Browser Use API error ({}): {}",
|
||||
"Browserless API error ({}): {}",
|
||||
status.as_u16(),
|
||||
body
|
||||
));
|
||||
}
|
||||
|
||||
let json: Value =
|
||||
serde_json::from_str(&body).map_err(|e| format!("Invalid Browser Use response: {}", e))?;
|
||||
serde_json::from_str(&body).map_err(|e| format!("Invalid Browserless response: {}", e))?;
|
||||
|
||||
let session_id = json
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let ws_url = json
|
||||
.get("cdp_url")
|
||||
.or_else(|| json.get("cdpUrl"))
|
||||
let connect_url = json
|
||||
.get("connect")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.ok_or_else(|| "Browser Use response missing cdp_url or cdpUrl".to_string())?;
|
||||
.ok_or_else(|| "Browserless response missing 'connect' URL".to_string())?;
|
||||
|
||||
let stop_url = json
|
||||
.get("stop")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.ok_or_else(|| "Browserless response missing 'stop' URL".to_string())?;
|
||||
|
||||
Ok((
|
||||
ws_url,
|
||||
connect_url,
|
||||
Some(ProviderSession {
|
||||
provider: "browser-use".to_string(),
|
||||
session_id,
|
||||
provider: "browserless".to_string(),
|
||||
// Store the stop URL as the session_id for cleanup
|
||||
session_id: stop_url,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn connect_browser_use() -> Result<(String, Option<ProviderSession>), String> {
|
||||
let api_key = env::var("BROWSER_USE_API_KEY")
|
||||
.map_err(|_| "BROWSER_USE_API_KEY environment variable is not set")?;
|
||||
|
||||
let ws_url = format!("wss://connect.browser-use.com?apiKey={}", api_key);
|
||||
|
||||
Ok((ws_url, None))
|
||||
}
|
||||
|
||||
async fn connect_kernel() -> Result<(String, Option<ProviderSession>), String> {
|
||||
let api_key =
|
||||
env::var("KERNEL_API_KEY").map_err(|_| "KERNEL_API_KEY environment variable is not set")?;
|
||||
let api_key = env::var("KERNEL_API_KEY").ok();
|
||||
let endpoint =
|
||||
env::var("KERNEL_ENDPOINT").unwrap_or_else(|_| "https://api.onkernel.com".to_string());
|
||||
|
||||
@@ -218,10 +306,11 @@ async fn connect_kernel() -> Result<(String, Option<ProviderSession>), String> {
|
||||
}
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", format!("Bearer {}", api_key))
|
||||
let mut request = client.post(&url).header("Content-Type", "application/json");
|
||||
if let Some(ref key) = api_key {
|
||||
request = request.header("Authorization", format!("Bearer {}", key));
|
||||
}
|
||||
let response = request
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
@@ -272,3 +361,456 @@ async fn connect_kernel() -> Result<(String, Option<ProviderSession>), String> {
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AgentCore Provider (AWS Bedrock AgentCore Browser)
|
||||
// ============================================================================
|
||||
|
||||
mod agentcore {
|
||||
use super::*;
|
||||
|
||||
/// AgentCore-specific session info for Live View URL
|
||||
pub struct AgentCoreSessionInfo {
|
||||
pub session_id: String,
|
||||
pub browser_identifier: String,
|
||||
pub region: String,
|
||||
pub live_view_url: String,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static AGENTCORE_INFO: std::cell::RefCell<Option<AgentCoreSessionInfo>> = const { std::cell::RefCell::new(None) };
|
||||
static AGENTCORE_WS_HEADERS: std::cell::RefCell<Option<Vec<(String, String)>>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
pub fn set_agentcore_info(info: AgentCoreSessionInfo) {
|
||||
AGENTCORE_INFO.with(|cell| *cell.borrow_mut() = Some(info));
|
||||
}
|
||||
|
||||
pub fn get_agentcore_info() -> Option<AgentCoreSessionInfo> {
|
||||
AGENTCORE_INFO.with(|cell| {
|
||||
cell.borrow().as_ref().map(|i| AgentCoreSessionInfo {
|
||||
session_id: i.session_id.clone(),
|
||||
browser_identifier: i.browser_identifier.clone(),
|
||||
region: i.region.clone(),
|
||||
live_view_url: i.live_view_url.clone(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_agentcore_ws_headers(headers: Vec<(String, String)>) {
|
||||
AGENTCORE_WS_HEADERS.with(|cell| *cell.borrow_mut() = Some(headers));
|
||||
}
|
||||
|
||||
pub fn take_agentcore_ws_headers() -> Option<Vec<(String, String)>> {
|
||||
AGENTCORE_WS_HEADERS.with(|cell| cell.borrow_mut().take())
|
||||
}
|
||||
|
||||
pub async fn connect() -> Result<(String, Option<ProviderSession>), String> {
|
||||
let region = env::var("AGENTCORE_REGION")
|
||||
.or_else(|_| env::var("AWS_REGION"))
|
||||
.or_else(|_| env::var("AWS_DEFAULT_REGION"))
|
||||
.unwrap_or_else(|_| "us-east-1".to_string());
|
||||
let browser_id =
|
||||
env::var("AGENTCORE_BROWSER_ID").unwrap_or_else(|_| "aws.browser.v1".to_string());
|
||||
let timeout_secs: u64 = env::var("AGENTCORE_SESSION_TIMEOUT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(3600);
|
||||
|
||||
let host = format!("bedrock-agentcore.{}.amazonaws.com", region);
|
||||
let path = format!(
|
||||
"/browsers/{}/sessions/start",
|
||||
urlencoding::encode(&browser_id)
|
||||
);
|
||||
let url = format!("https://{}{}", host, path);
|
||||
|
||||
// Generate a unique session name
|
||||
let session_name = format!("agent-browser-{}", &uuid::Uuid::new_v4().to_string()[..8]);
|
||||
|
||||
let mut body_json = json!({
|
||||
"name": session_name,
|
||||
"sessionTimeoutSeconds": timeout_secs
|
||||
});
|
||||
if let Ok(profile_id) = env::var("AGENTCORE_PROFILE_ID") {
|
||||
if !profile_id.is_empty() {
|
||||
body_json.as_object_mut().unwrap().insert(
|
||||
"profileConfiguration".to_string(),
|
||||
json!({ "profileIdentifier": profile_id }),
|
||||
);
|
||||
}
|
||||
}
|
||||
let body = serde_json::to_string(&body_json)
|
||||
.map_err(|e| format!("Failed to serialize request body: {}", e))?;
|
||||
|
||||
let signed_headers = sign_request("PUT", &url, ®ion, Some(&body)).await?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let mut req = client.put(&url).body(body.clone());
|
||||
for (key, value) in &signed_headers {
|
||||
req = req.header(key.as_str(), value.as_str());
|
||||
}
|
||||
|
||||
let response = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("AgentCore request failed: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
let resp_body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read AgentCore response: {}", e))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!(
|
||||
"AgentCore API error ({}): {}",
|
||||
status.as_u16(),
|
||||
resp_body
|
||||
));
|
||||
}
|
||||
|
||||
let json: Value = serde_json::from_str(&resp_body)
|
||||
.map_err(|e| format!("Invalid AgentCore response: {}", e))?;
|
||||
|
||||
let session_id = json
|
||||
.get("sessionId")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "AgentCore response missing sessionId".to_string())?
|
||||
.to_string();
|
||||
|
||||
let browser_identifier = json
|
||||
.get("browserIdentifier")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&browser_id)
|
||||
.to_string();
|
||||
|
||||
let live_view_url = format!(
|
||||
"https://{}.console.aws.amazon.com/bedrock-agentcore/browser/{}/session/{}#",
|
||||
region, browser_identifier, session_id
|
||||
);
|
||||
|
||||
set_agentcore_info(AgentCoreSessionInfo {
|
||||
session_id: session_id.clone(),
|
||||
browser_identifier: browser_identifier.clone(),
|
||||
region: region.clone(),
|
||||
live_view_url: live_view_url.clone(),
|
||||
});
|
||||
|
||||
eprintln!("Session: {}", session_id);
|
||||
eprintln!("Live View: {}", live_view_url);
|
||||
|
||||
let ws_path = format!(
|
||||
"/browser-streams/{}/sessions/{}/automation",
|
||||
browser_identifier, session_id
|
||||
);
|
||||
let ws_url = format!("wss://{}{}", host, ws_path);
|
||||
|
||||
let ws_headers = sign_request(
|
||||
"GET",
|
||||
&format!("https://{}{}", host, ws_path),
|
||||
®ion,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
set_agentcore_ws_headers(ws_headers);
|
||||
|
||||
Ok((
|
||||
ws_url,
|
||||
Some(ProviderSession {
|
||||
provider: "agentcore".to_string(),
|
||||
session_id,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// Get AWS credentials from environment variables or AWS CLI
|
||||
fn get_aws_credentials() -> Result<(String, String, Option<String>), String> {
|
||||
// First try environment variables
|
||||
if let (Ok(access_key), Ok(secret_key)) = (
|
||||
env::var("AWS_ACCESS_KEY_ID"),
|
||||
env::var("AWS_SECRET_ACCESS_KEY"),
|
||||
) {
|
||||
return Ok((access_key, secret_key, env::var("AWS_SESSION_TOKEN").ok()));
|
||||
}
|
||||
|
||||
// Fall back to AWS CLI
|
||||
let mut cmd = std::process::Command::new("aws");
|
||||
cmd.args(["configure", "export-credentials", "--format", "env"]);
|
||||
|
||||
// Honor AWS_PROFILE
|
||||
if let Ok(profile) = env::var("AWS_PROFILE") {
|
||||
cmd.args(["--profile", &profile]);
|
||||
}
|
||||
|
||||
let output = cmd.output()
|
||||
.map_err(|e| format!("Failed to run aws CLI: {}. Install AWS CLI or set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!(
|
||||
"AWS CLI failed: {}. Run 'aws sso login' or set credentials",
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let mut access_key = None;
|
||||
let mut secret_key = None;
|
||||
let mut session_token = None;
|
||||
|
||||
for line in stdout.lines() {
|
||||
if let Some(val) = line.strip_prefix("export AWS_ACCESS_KEY_ID=") {
|
||||
access_key = Some(val.to_string());
|
||||
} else if let Some(val) = line.strip_prefix("export AWS_SECRET_ACCESS_KEY=") {
|
||||
secret_key = Some(val.to_string());
|
||||
} else if let Some(val) = line.strip_prefix("export AWS_SESSION_TOKEN=") {
|
||||
session_token = Some(val.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
match (access_key, secret_key) {
|
||||
(Some(ak), Some(sk)) => Ok((ak, sk, session_token)),
|
||||
_ => Err("Failed to parse credentials from AWS CLI output".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn sign_request(
|
||||
method: &str,
|
||||
url: &str,
|
||||
region: &str,
|
||||
body: Option<&str>,
|
||||
) -> Result<Vec<(String, String)>, String> {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
// Get credentials from environment or AWS CLI
|
||||
let (access_key, secret_key, session_token) = get_aws_credentials()?;
|
||||
|
||||
let parsed_url = url::Url::parse(url).map_err(|e| format!("Invalid URL: {}", e))?;
|
||||
let host = parsed_url.host_str().unwrap_or("");
|
||||
|
||||
// Get current time
|
||||
let now = chrono::Utc::now();
|
||||
let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
let date_stamp = now.format("%Y%m%d").to_string();
|
||||
|
||||
// Create canonical request
|
||||
let payload_hash = if let Some(b) = body {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
} else {
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string()
|
||||
// empty string hash
|
||||
};
|
||||
|
||||
let canonical_uri = parsed_url.path();
|
||||
let canonical_querystring = parsed_url.query().unwrap_or("");
|
||||
|
||||
let mut signed_headers = "content-type;host;x-amz-date".to_string();
|
||||
let mut canonical_headers = format!(
|
||||
"content-type:application/json\nhost:{}\nx-amz-date:{}\n",
|
||||
host, amz_date
|
||||
);
|
||||
|
||||
if let Some(ref token) = session_token {
|
||||
signed_headers = "content-type;host;x-amz-date;x-amz-security-token".to_string();
|
||||
canonical_headers = format!(
|
||||
"content-type:application/json\nhost:{}\nx-amz-date:{}\nx-amz-security-token:{}\n",
|
||||
host, amz_date, token
|
||||
);
|
||||
}
|
||||
|
||||
let canonical_request = format!(
|
||||
"{}\n{}\n{}\n{}\n{}\n{}",
|
||||
method,
|
||||
canonical_uri,
|
||||
canonical_querystring,
|
||||
canonical_headers,
|
||||
signed_headers,
|
||||
payload_hash
|
||||
);
|
||||
|
||||
// Create string to sign
|
||||
let algorithm = "AWS4-HMAC-SHA256";
|
||||
let credential_scope = format!("{}/{}/bedrock-agentcore/aws4_request", date_stamp, region);
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(canonical_request.as_bytes());
|
||||
let canonical_request_hash = hex::encode(hasher.finalize());
|
||||
|
||||
let string_to_sign = format!(
|
||||
"{}\n{}\n{}\n{}",
|
||||
algorithm, amz_date, credential_scope, canonical_request_hash
|
||||
);
|
||||
|
||||
// Calculate signature
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
let k_date = HmacSha256::new_from_slice(format!("AWS4{}", secret_key).as_bytes())
|
||||
.unwrap()
|
||||
.chain_update(date_stamp.as_bytes())
|
||||
.finalize()
|
||||
.into_bytes();
|
||||
|
||||
let k_region = HmacSha256::new_from_slice(&k_date)
|
||||
.unwrap()
|
||||
.chain_update(region.as_bytes())
|
||||
.finalize()
|
||||
.into_bytes();
|
||||
|
||||
let k_service = HmacSha256::new_from_slice(&k_region)
|
||||
.unwrap()
|
||||
.chain_update(b"bedrock-agentcore")
|
||||
.finalize()
|
||||
.into_bytes();
|
||||
|
||||
let k_signing = HmacSha256::new_from_slice(&k_service)
|
||||
.unwrap()
|
||||
.chain_update(b"aws4_request")
|
||||
.finalize()
|
||||
.into_bytes();
|
||||
|
||||
let signature = hex::encode(
|
||||
HmacSha256::new_from_slice(&k_signing)
|
||||
.unwrap()
|
||||
.chain_update(string_to_sign.as_bytes())
|
||||
.finalize()
|
||||
.into_bytes(),
|
||||
);
|
||||
|
||||
// Build authorization header
|
||||
let authorization = format!(
|
||||
"{} Credential={}/{}, SignedHeaders={}, Signature={}",
|
||||
algorithm, access_key, credential_scope, signed_headers, signature
|
||||
);
|
||||
|
||||
let mut headers = vec![
|
||||
("host".to_string(), host.to_string()),
|
||||
("content-type".to_string(), "application/json".to_string()),
|
||||
("x-amz-date".to_string(), amz_date),
|
||||
("authorization".to_string(), authorization),
|
||||
];
|
||||
|
||||
if let Some(token) = session_token {
|
||||
headers.push(("x-amz-security-token".to_string(), token));
|
||||
}
|
||||
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
pub async fn close_session(session_id: &str) -> Result<(), String> {
|
||||
let info = get_agentcore_info();
|
||||
let (region, browser_id) = match &info {
|
||||
Some(i) => (i.region.clone(), i.browser_identifier.clone()),
|
||||
None => {
|
||||
let region = env::var("AGENTCORE_REGION")
|
||||
.or_else(|_| env::var("AWS_REGION"))
|
||||
.or_else(|_| env::var("AWS_DEFAULT_REGION"))
|
||||
.unwrap_or_else(|_| "us-east-1".to_string());
|
||||
let browser_id = env::var("AGENTCORE_BROWSER_ID")
|
||||
.unwrap_or_else(|_| "aws.browser.v1".to_string());
|
||||
(region, browser_id)
|
||||
}
|
||||
};
|
||||
|
||||
let host = format!("bedrock-agentcore.{}.amazonaws.com", region);
|
||||
let path = format!(
|
||||
"/browsers/{}/sessions/stop",
|
||||
urlencoding::encode(&browser_id)
|
||||
);
|
||||
let url = format!("https://{}{}", host, path);
|
||||
|
||||
let body = serde_json::to_string(&json!({ "sessionId": session_id }))
|
||||
.map_err(|e| format!("Failed to serialize close request: {}", e))?;
|
||||
|
||||
let signed_headers = sign_request("PUT", &url, ®ion, Some(&body)).await?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let mut req = client.put(&url).body(body);
|
||||
for (key, value) in &signed_headers {
|
||||
req = req.header(key.as_str(), value.as_str());
|
||||
}
|
||||
|
||||
let _ = req.send().await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub use agentcore::{get_agentcore_info, take_agentcore_ws_headers};
|
||||
|
||||
async fn connect_agentcore() -> Result<(String, Option<ProviderSession>), String> {
|
||||
agentcore::connect().await
|
||||
}
|
||||
|
||||
async fn close_agentcore_session(session_id: &str) -> Result<(), String> {
|
||||
agentcore::close_session(session_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_connect_provider_unknown() {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
let result = rt.block_on(connect_provider("unknown-provider"));
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Unknown provider"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agentcore_env_defaults() {
|
||||
// Test that default values are used when env vars not set
|
||||
std::env::remove_var("AGENTCORE_REGION");
|
||||
std::env::remove_var("AGENTCORE_BROWSER_ID");
|
||||
std::env::remove_var("AGENTCORE_SESSION_TIMEOUT");
|
||||
|
||||
// These would be used in connect() - just verify they don't panic
|
||||
let region = std::env::var("AGENTCORE_REGION")
|
||||
.or_else(|_| std::env::var("AWS_REGION"))
|
||||
.unwrap_or_else(|_| "us-east-1".to_string());
|
||||
assert_eq!(region, "us-east-1");
|
||||
|
||||
let browser_id =
|
||||
std::env::var("AGENTCORE_BROWSER_ID").unwrap_or_else(|_| "aws.browser.v1".to_string());
|
||||
assert_eq!(browser_id, "aws.browser.v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agentcore_session_info_storage() {
|
||||
let info = agentcore::AgentCoreSessionInfo {
|
||||
session_id: "test-session".to_string(),
|
||||
browser_identifier: "aws.browser.v1".to_string(),
|
||||
region: "us-east-1".to_string(),
|
||||
live_view_url: "https://example.com".to_string(),
|
||||
};
|
||||
|
||||
agentcore::set_agentcore_info(info);
|
||||
let retrieved = get_agentcore_info();
|
||||
assert!(retrieved.is_some());
|
||||
let retrieved = retrieved.unwrap();
|
||||
assert_eq!(retrieved.session_id, "test-session");
|
||||
assert_eq!(retrieved.region, "us-east-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agentcore_ws_headers_storage() {
|
||||
let headers = vec![
|
||||
(
|
||||
"Authorization".to_string(),
|
||||
"AWS4-HMAC-SHA256...".to_string(),
|
||||
),
|
||||
("X-Amz-Date".to_string(), "20260304T180000Z".to_string()),
|
||||
];
|
||||
|
||||
agentcore::set_agentcore_ws_headers(headers);
|
||||
let taken = take_agentcore_ws_headers();
|
||||
assert!(taken.is_some());
|
||||
assert_eq!(taken.unwrap().len(), 2);
|
||||
|
||||
// Should be None after take
|
||||
let taken_again = take_agentcore_ws_headers();
|
||||
assert!(taken_again.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
+217
-97
@@ -1,12 +1,24 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::process::Stdio;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::types::{CaptureScreenshotParams, CaptureScreenshotResult};
|
||||
|
||||
const CAPTURE_INTERVAL_MS: u64 = 100;
|
||||
const CAPTURE_FPS: u32 = 10;
|
||||
|
||||
pub struct RecordingState {
|
||||
pub active: bool,
|
||||
pub output_path: String,
|
||||
pub temp_dir: PathBuf,
|
||||
pub frame_count: u64,
|
||||
pub capture_task: Option<tokio::task::JoinHandle<Result<(), String>>>,
|
||||
pub shared_frame_count: Option<Arc<AtomicU64>>,
|
||||
pub cancel_tx: Option<oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
impl RecordingState {
|
||||
@@ -14,8 +26,10 @@ impl RecordingState {
|
||||
Self {
|
||||
active: false,
|
||||
output_path: String::new(),
|
||||
temp_dir: PathBuf::new(),
|
||||
frame_count: 0,
|
||||
capture_task: None,
|
||||
shared_frame_count: None,
|
||||
cancel_tx: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,34 +39,13 @@ pub fn recording_start(state: &mut RecordingState, path: &str) -> Result<Value,
|
||||
return Err("Recording already active".to_string());
|
||||
}
|
||||
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
|
||||
let temp_dir = std::env::temp_dir().join(format!("agent-browser-recording-{}", timestamp));
|
||||
let _ = std::fs::create_dir_all(&temp_dir);
|
||||
|
||||
state.active = true;
|
||||
state.output_path = path.to_string();
|
||||
state.temp_dir = temp_dir;
|
||||
state.frame_count = 0;
|
||||
|
||||
Ok(json!({ "started": true, "path": path }))
|
||||
}
|
||||
|
||||
pub fn recording_add_frame(state: &mut RecordingState, frame_data: &[u8]) {
|
||||
if !state.active {
|
||||
return;
|
||||
}
|
||||
|
||||
let frame_path = state
|
||||
.temp_dir
|
||||
.join(format!("frame_{:06}.jpg", state.frame_count));
|
||||
let _ = std::fs::write(&frame_path, frame_data);
|
||||
state.frame_count += 1;
|
||||
}
|
||||
|
||||
pub fn recording_stop(state: &mut RecordingState) -> Result<Value, String> {
|
||||
if !state.active {
|
||||
return Err("No recording in progress".to_string());
|
||||
@@ -61,55 +54,183 @@ pub fn recording_stop(state: &mut RecordingState) -> Result<Value, String> {
|
||||
state.active = false;
|
||||
|
||||
if state.frame_count == 0 {
|
||||
let _ = std::fs::remove_dir_all(&state.temp_dir);
|
||||
return Err("No frames captured".to_string());
|
||||
}
|
||||
|
||||
let frame_pattern = state
|
||||
.temp_dir
|
||||
.join("frame_%06d.jpg")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
Ok(json!({ "path": &state.output_path, "frames": state.frame_count }))
|
||||
}
|
||||
|
||||
let output = &state.output_path;
|
||||
pub fn recording_restart(state: &mut RecordingState, path: &str) -> Result<Value, String> {
|
||||
let previous = if state.active {
|
||||
let stop_result = recording_stop(state);
|
||||
stop_result
|
||||
.ok()
|
||||
.and_then(|v| v.get("path").and_then(|p| p.as_str()).map(String::from))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Encode with ffmpeg
|
||||
let result = Command::new("ffmpeg")
|
||||
recording_start(state, path)?;
|
||||
|
||||
Ok(json!({
|
||||
"restarted": true,
|
||||
"previousPath": previous,
|
||||
"path": path,
|
||||
}))
|
||||
}
|
||||
|
||||
fn build_ffmpeg_command(output_path: &str) -> tokio::process::Command {
|
||||
let mut cmd = tokio::process::Command::new("ffmpeg");
|
||||
|
||||
cmd.args(["-y"])
|
||||
.args(["-avioflags", "direct"])
|
||||
.args([
|
||||
"-y",
|
||||
"-framerate",
|
||||
"30",
|
||||
"-i",
|
||||
&frame_pattern,
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-preset",
|
||||
"fast",
|
||||
output,
|
||||
"-fpsprobesize",
|
||||
"0",
|
||||
"-probesize",
|
||||
"32",
|
||||
"-analyzeduration",
|
||||
"0",
|
||||
])
|
||||
.output();
|
||||
.args([
|
||||
"-f",
|
||||
"image2pipe",
|
||||
"-c:v",
|
||||
"mjpeg",
|
||||
"-framerate",
|
||||
&CAPTURE_FPS.to_string(),
|
||||
"-i",
|
||||
"pipe:0",
|
||||
])
|
||||
.args(["-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2"]);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&state.temp_dir);
|
||||
|
||||
match result {
|
||||
Ok(output_result) => {
|
||||
if output_result.status.success() {
|
||||
Ok(json!({ "path": output, "frames": state.frame_count }))
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output_result.stderr);
|
||||
Err(format!(
|
||||
"ffmpeg failed: {}",
|
||||
stderr.chars().take(200).collect::<String>()
|
||||
))
|
||||
}
|
||||
}
|
||||
Err(e) => Err(format!(
|
||||
"ffmpeg not found or failed to execute: {}. Install ffmpeg to enable recording.",
|
||||
e
|
||||
)),
|
||||
if output_path.ends_with(".webm") {
|
||||
cmd.args(["-c:v", "libvpx", "-crf", "30", "-b:v", "1M"]);
|
||||
} else {
|
||||
cmd.args(["-c:v", "libx264", "-preset", "ultrafast"]);
|
||||
}
|
||||
|
||||
cmd.args(["-pix_fmt", "yuv420p", "-threads", "1"])
|
||||
.arg(output_path)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Spawn a background task that captures screenshots at a fixed interval
|
||||
/// and pipes them to ffmpeg in real-time.
|
||||
pub fn spawn_recording_task(
|
||||
client: Arc<CdpClient>,
|
||||
session_id: String,
|
||||
output_path: String,
|
||||
shared_count: Arc<AtomicU64>,
|
||||
cancel_rx: oneshot::Receiver<()>,
|
||||
) -> tokio::task::JoinHandle<Result<(), String>> {
|
||||
tokio::spawn(async move {
|
||||
let mut cancel_rx = std::pin::pin!(cancel_rx);
|
||||
|
||||
let mut ffmpeg = build_ffmpeg_command(&output_path).spawn().map_err(|e| {
|
||||
format!(
|
||||
"ffmpeg not found or failed to execute: {}. Install ffmpeg to enable recording.",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut stdin = ffmpeg
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| "Failed to open ffmpeg stdin".to_string())?;
|
||||
|
||||
let mut interval = tokio::time::interval(Duration::from_millis(CAPTURE_INTERVAL_MS));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
let params = CaptureScreenshotParams {
|
||||
format: Some("jpeg".to_string()),
|
||||
quality: Some(80),
|
||||
clip: None,
|
||||
from_surface: Some(true),
|
||||
capture_beyond_viewport: None,
|
||||
};
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = &mut cancel_rx => break,
|
||||
_ = interval.tick() => {}
|
||||
}
|
||||
|
||||
let result: Result<CaptureScreenshotResult, _> = client
|
||||
.send_command_typed("Page.captureScreenshot", ¶ms, Some(&session_id))
|
||||
.await;
|
||||
|
||||
let screenshot = match result {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
if e.contains("Target closed") || e.contains("not found") {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let bytes = match base64::Engine::decode(
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
&screenshot.data,
|
||||
) {
|
||||
Ok(b) => b,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if stdin.write_all(&bytes).await.is_err() {
|
||||
break;
|
||||
}
|
||||
shared_count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
drop(stdin);
|
||||
|
||||
let output = ffmpeg
|
||||
.wait_with_output()
|
||||
.await
|
||||
.map_err(|e| format!("ffmpeg wait failed: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!(
|
||||
"ffmpeg failed: {}",
|
||||
stderr.chars().take(300).collect::<String>()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn stop_recording_task(state: &mut RecordingState) -> Result<(), String> {
|
||||
if let Some(tx) = state.cancel_tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
|
||||
let counter = state.shared_frame_count.take();
|
||||
let handle = state.capture_task.take();
|
||||
|
||||
let result = if let Some(h) = handle {
|
||||
match h.await {
|
||||
Ok(Ok(())) => Ok(()),
|
||||
Ok(Err(e)) => Err(e),
|
||||
Err(e) => Err(format!("Recording task panicked: {}", e)),
|
||||
}
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
|
||||
if let Some(c) = counter {
|
||||
state.frame_count = c.load(Ordering::Relaxed);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -132,19 +253,15 @@ mod tests {
|
||||
assert!(state.active);
|
||||
assert_eq!(state.output_path, "/tmp/test.mp4");
|
||||
assert_eq!(state.frame_count, 0);
|
||||
// Cleanup
|
||||
let _ = std::fs::remove_dir_all(&state.temp_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recording_start_while_active() {
|
||||
let mut state = RecordingState::new();
|
||||
recording_start(&mut state, "/tmp/test1.mp4").unwrap();
|
||||
let temp_dir = state.temp_dir.clone();
|
||||
let result = recording_start(&mut state, "/tmp/test2.mp4");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("already active"));
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -166,38 +283,41 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recording_add_frame_inactive() {
|
||||
fn test_recording_restart_while_inactive() {
|
||||
let mut state = RecordingState::new();
|
||||
recording_add_frame(&mut state, b"fake-frame");
|
||||
assert_eq!(state.frame_count, 0);
|
||||
let result = recording_restart(&mut state, "/tmp/new.webm");
|
||||
assert!(result.is_ok());
|
||||
assert!(state.active);
|
||||
assert_eq!(state.output_path, "/tmp/new.webm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recording_add_frame_active() {
|
||||
fn test_recording_restart_while_active() {
|
||||
let mut state = RecordingState::new();
|
||||
recording_start(&mut state, "/tmp/test.mp4").unwrap();
|
||||
recording_add_frame(&mut state, b"fake-frame-1");
|
||||
recording_add_frame(&mut state, b"fake-frame-2");
|
||||
assert_eq!(state.frame_count, 2);
|
||||
let _ = std::fs::remove_dir_all(&state.temp_dir);
|
||||
recording_start(&mut state, "/tmp/old.webm").unwrap();
|
||||
state.frame_count = 10;
|
||||
let result = recording_restart(&mut state, "/tmp/new.webm").unwrap();
|
||||
assert!(state.active);
|
||||
assert_eq!(state.output_path, "/tmp/new.webm");
|
||||
assert_eq!(state.frame_count, 0);
|
||||
assert_eq!(result["previousPath"], "/tmp/old.webm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_ffmpeg_command_webm() {
|
||||
let cmd = build_ffmpeg_command("/tmp/out.webm");
|
||||
let args: Vec<&std::ffi::OsStr> = cmd.as_std().get_args().collect();
|
||||
let args_str: Vec<&str> = args.iter().filter_map(|a| a.to_str()).collect();
|
||||
assert!(args_str.contains(&"libvpx"));
|
||||
assert!(args_str.contains(&"/tmp/out.webm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_ffmpeg_command_mp4() {
|
||||
let cmd = build_ffmpeg_command("/tmp/out.mp4");
|
||||
let args: Vec<&std::ffi::OsStr> = cmd.as_std().get_args().collect();
|
||||
let args_str: Vec<&str> = args.iter().filter_map(|a| a.to_str()).collect();
|
||||
assert!(args_str.contains(&"libx264"));
|
||||
assert!(args_str.contains(&"/tmp/out.mp4"));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn recording_restart(state: &mut RecordingState, path: &str) -> Result<Value, String> {
|
||||
let previous = if state.active {
|
||||
let stop_result = recording_stop(state);
|
||||
stop_result
|
||||
.ok()
|
||||
.and_then(|v| v.get("path").and_then(|p| p.as_str()).map(String::from))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
recording_start(state, path)?;
|
||||
|
||||
Ok(json!({
|
||||
"restarted": true,
|
||||
"previousPath": previous,
|
||||
"path": path,
|
||||
}))
|
||||
}
|
||||
|
||||
+588
-44
@@ -1,16 +1,65 @@
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::types::*;
|
||||
use super::element::RefMap;
|
||||
|
||||
const ANNOTATION_OVERLAY_ID: &str = "__agent_browser_annotations__";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Rect {
|
||||
x: f64,
|
||||
y: f64,
|
||||
width: f64,
|
||||
height: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RawAnnotation {
|
||||
ref_id: String,
|
||||
number: u64,
|
||||
role: String,
|
||||
name: Option<String>,
|
||||
rect: Rect,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AnnotationBox {
|
||||
pub x: i64,
|
||||
pub y: i64,
|
||||
pub width: i64,
|
||||
pub height: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScreenshotAnnotation {
|
||||
pub ref_id: String,
|
||||
pub number: u64,
|
||||
pub role: String,
|
||||
pub name: Option<String>,
|
||||
pub box_: AnnotationBox,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScreenshotResult {
|
||||
pub path: String,
|
||||
pub base64: String,
|
||||
pub annotations: Vec<ScreenshotAnnotation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScreenshotOptions {
|
||||
pub selector: Option<String>,
|
||||
pub path: Option<String>,
|
||||
pub full_page: bool,
|
||||
pub format: String,
|
||||
pub quality: Option<i32>,
|
||||
pub annotate: bool,
|
||||
pub output_dir: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for ScreenshotOptions {
|
||||
@@ -21,16 +70,111 @@ impl Default for ScreenshotOptions {
|
||||
full_page: false,
|
||||
format: "png".to_string(),
|
||||
quality: None,
|
||||
annotate: false,
|
||||
output_dir: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for ScreenshotAnnotation {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
use serde::ser::SerializeStruct;
|
||||
|
||||
let mut state = serializer.serialize_struct("ScreenshotAnnotation", 5)?;
|
||||
state.serialize_field("ref", &self.ref_id)?;
|
||||
state.serialize_field("number", &self.number)?;
|
||||
state.serialize_field("role", &self.role)?;
|
||||
if let Some(name) = &self.name {
|
||||
state.serialize_field("name", name)?;
|
||||
}
|
||||
state.serialize_field("box", &self.box_)?;
|
||||
state.end()
|
||||
}
|
||||
}
|
||||
|
||||
/// Captures a screenshot via CDP and optionally overlays numbered annotations
|
||||
/// that mirror the Node.js screenshot `annotate` mode.
|
||||
pub async fn take_screenshot(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
options: &ScreenshotOptions,
|
||||
) -> Result<(String, String), String> {
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<ScreenshotResult, String> {
|
||||
let target_rect = if options.annotate {
|
||||
match options.selector.as_deref() {
|
||||
Some(selector) => {
|
||||
get_rect_for_selector(client, session_id, ref_map, selector, iframe_sessions)
|
||||
.await?
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let raw_annotations = if options.annotate {
|
||||
collect_annotations(client, session_id, ref_map).await?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let overlay_items = filter_annotations(raw_annotations, target_rect.as_ref());
|
||||
let overlay_injected = if options.annotate && !overlay_items.is_empty() {
|
||||
inject_annotation_overlay(client, session_id, &overlay_items).await?;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let base64 =
|
||||
capture_screenshot_base64(client, session_id, ref_map, options, iframe_sessions).await;
|
||||
|
||||
if overlay_injected {
|
||||
let _ = remove_annotation_overlay(client, session_id).await;
|
||||
}
|
||||
|
||||
let base64 = base64?;
|
||||
let annotations = if options.annotate {
|
||||
let scroll = if options.full_page {
|
||||
Some(get_scroll_offsets(client, session_id).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
project_annotations(&overlay_items, target_rect.as_ref(), scroll)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let ext = if options.format == "jpeg" {
|
||||
"jpg"
|
||||
} else {
|
||||
"png"
|
||||
};
|
||||
let path = save_screenshot(
|
||||
&base64,
|
||||
options.path.as_deref(),
|
||||
ext,
|
||||
options.output_dir.as_deref(),
|
||||
)?;
|
||||
|
||||
Ok(ScreenshotResult {
|
||||
path,
|
||||
base64,
|
||||
annotations,
|
||||
})
|
||||
}
|
||||
|
||||
async fn capture_screenshot_base64(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
options: &ScreenshotOptions,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<String, String> {
|
||||
let mut params = CaptureScreenshotParams {
|
||||
format: Some(options.format.clone()),
|
||||
quality: if options.format == "jpeg" {
|
||||
@@ -64,40 +208,14 @@ pub async fn take_screenshot(
|
||||
});
|
||||
}
|
||||
} else if let Some(ref selector) = options.selector {
|
||||
// Element screenshot via bounding box
|
||||
let object_id =
|
||||
super::element::resolve_element_object_id(client, session_id, ref_map, selector)
|
||||
.await?;
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: r#"function() {
|
||||
const rect = this.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
}"#
|
||||
.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(rect) = result.result.value {
|
||||
let x = rect.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let y = rect.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let w = rect.get("width").and_then(|v| v.as_f64()).unwrap_or(100.0);
|
||||
let h = rect.get("height").and_then(|v| v.as_f64()).unwrap_or(100.0);
|
||||
|
||||
if let Some(rect) =
|
||||
get_rect_for_selector(client, session_id, ref_map, selector, iframe_sessions).await?
|
||||
{
|
||||
params.clip = Some(Viewport {
|
||||
x,
|
||||
y,
|
||||
width: w,
|
||||
height: h,
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
scale: 1.0,
|
||||
});
|
||||
}
|
||||
@@ -107,16 +225,345 @@ pub async fn take_screenshot(
|
||||
.send_command_typed("Page.captureScreenshot", ¶ms, Some(session_id))
|
||||
.await?;
|
||||
|
||||
let ext = if options.format == "jpeg" {
|
||||
"jpg"
|
||||
} else {
|
||||
"png"
|
||||
};
|
||||
Ok(result.data)
|
||||
}
|
||||
|
||||
let save_path = match &options.path {
|
||||
Some(p) => p.clone(),
|
||||
async fn collect_annotations(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
) -> Result<Vec<RawAnnotation>, String> {
|
||||
let entries = ref_map.entries_sorted();
|
||||
if entries.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Collect entries that have backend_node_ids for batch resolution.
|
||||
let with_backend_ids: Vec<(String, super::element::RefEntry, i64)> = entries
|
||||
.iter()
|
||||
.filter_map(|(ref_id, entry)| {
|
||||
entry
|
||||
.backend_node_id
|
||||
.map(|bid| (ref_id.clone(), entry.clone(), bid))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if with_backend_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Batch-resolve all backend_node_ids to object IDs using concurrent CDP calls.
|
||||
let resolve_futures: Vec<_> = with_backend_ids
|
||||
.iter()
|
||||
.map(|(_, _, backend_node_id)| {
|
||||
client.send_command(
|
||||
"DOM.resolveNode",
|
||||
Some(serde_json::json!({
|
||||
"backendNodeId": backend_node_id,
|
||||
"objectGroup": "agent-browser-annotate"
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let resolve_results = futures_util::future::join_all(resolve_futures).await;
|
||||
|
||||
// Collect resolved object IDs paired with their ref info.
|
||||
let mut resolved: Vec<(String, super::element::RefEntry, String)> = Vec::new();
|
||||
for (i, result) in resolve_results.into_iter().enumerate() {
|
||||
if let Ok(val) = result {
|
||||
if let Some(oid) = val
|
||||
.get("object")
|
||||
.and_then(|o| o.get("objectId"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
let (ref_id, entry, _) = &with_backend_ids[i];
|
||||
resolved.push((ref_id.clone(), entry.clone(), oid.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if resolved.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Batch-get bounding rects for all resolved elements using concurrent CDP calls.
|
||||
let rect_futures: Vec<_> = resolved
|
||||
.iter()
|
||||
.map(|(_, _, object_id)| get_rect_for_object(client, session_id, object_id))
|
||||
.collect();
|
||||
|
||||
let rect_results = futures_util::future::join_all(rect_futures).await;
|
||||
|
||||
let mut annotations = Vec::new();
|
||||
for (i, rect_result) in rect_results.into_iter().enumerate() {
|
||||
let rect = match rect_result {
|
||||
Ok(Some(r)) if r.width > 0.0 && r.height > 0.0 => r,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let (ref_id, entry, _) = &resolved[i];
|
||||
let number = ref_id
|
||||
.strip_prefix('e')
|
||||
.and_then(|n| n.parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
annotations.push(RawAnnotation {
|
||||
ref_id: ref_id.clone(),
|
||||
number,
|
||||
role: entry.role.clone(),
|
||||
name: (!entry.name.is_empty()).then_some(entry.name.clone()),
|
||||
rect,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(annotations)
|
||||
}
|
||||
|
||||
async fn get_rect_for_selector(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<Option<Rect>, String> {
|
||||
let (object_id, effective_session_id) = super::element::resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
get_rect_for_object(client, &effective_session_id, &object_id).await
|
||||
}
|
||||
|
||||
async fn get_rect_for_object(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
object_id: &str,
|
||||
) -> Result<Option<Rect>, String> {
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: r#"function() {
|
||||
const rect = this.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
}"#
|
||||
.to_string(),
|
||||
object_id: Some(object_id.to_string()),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(result.result.value.as_ref().and_then(parse_rect))
|
||||
}
|
||||
|
||||
fn parse_rect(value: &Value) -> Option<Rect> {
|
||||
Some(Rect {
|
||||
x: value.get("x")?.as_f64()?,
|
||||
y: value.get("y")?.as_f64()?,
|
||||
width: value.get("width")?.as_f64()?,
|
||||
height: value.get("height")?.as_f64()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn filter_annotations(
|
||||
annotations: Vec<RawAnnotation>,
|
||||
target_rect: Option<&Rect>,
|
||||
) -> Vec<RawAnnotation> {
|
||||
let mut items = annotations
|
||||
.into_iter()
|
||||
.filter(|annotation| match target_rect {
|
||||
Some(target) => overlaps(&annotation.rect, target),
|
||||
None => true,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
items.sort_by_key(|annotation| annotation.number);
|
||||
items
|
||||
}
|
||||
|
||||
fn overlaps(left: &Rect, right: &Rect) -> bool {
|
||||
let left_x2 = left.x + left.width;
|
||||
let left_y2 = left.y + left.height;
|
||||
let right_x2 = right.x + right.width;
|
||||
let right_y2 = right.y + right.height;
|
||||
|
||||
left.x < right_x2 && left_x2 > right.x && left.y < right_y2 && left_y2 > right.y
|
||||
}
|
||||
|
||||
async fn inject_annotation_overlay(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
annotations: &[RawAnnotation],
|
||||
) -> Result<(), String> {
|
||||
let overlay_data = annotations
|
||||
.iter()
|
||||
.map(|annotation| {
|
||||
serde_json::json!({
|
||||
"number": annotation.number,
|
||||
"x": round(annotation.rect.x),
|
||||
"y": round(annotation.rect.y),
|
||||
"width": round(annotation.rect.width),
|
||||
"height": round(annotation.rect.height),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let expression = format!(
|
||||
r#"(() => {{
|
||||
var items = {items};
|
||||
var id = {overlay_id};
|
||||
var existing = document.getElementById(id);
|
||||
if (existing) existing.remove();
|
||||
var sx = window.scrollX || 0;
|
||||
var sy = window.scrollY || 0;
|
||||
var c = document.createElement('div');
|
||||
c.id = id;
|
||||
c.style.cssText = 'position:absolute;top:0;left:0;width:0;height:0;pointer-events:none;z-index:2147483647;';
|
||||
for (var i = 0; i < items.length; i++) {{
|
||||
var it = items[i];
|
||||
var dx = it.x + sx;
|
||||
var dy = it.y + sy;
|
||||
var b = document.createElement('div');
|
||||
b.style.cssText = 'position:absolute;left:' + dx + 'px;top:' + dy + 'px;width:' + it.width + 'px;height:' + it.height + 'px;border:2px solid rgba(255,0,0,0.8);box-sizing:border-box;pointer-events:none;';
|
||||
var l = document.createElement('div');
|
||||
l.textContent = String(it.number);
|
||||
var labelTop = dy < 14 ? '2px' : '-14px';
|
||||
l.style.cssText = 'position:absolute;top:' + labelTop + ';left:-2px;background:rgba(255,0,0,0.9);color:#fff;font:bold 11px/14px monospace;padding:0 4px;border-radius:2px;white-space:nowrap;';
|
||||
b.appendChild(l);
|
||||
c.appendChild(b);
|
||||
}}
|
||||
document.documentElement.appendChild(c);
|
||||
return true;
|
||||
}})()"#,
|
||||
items = serde_json::to_string(&overlay_data).unwrap_or_else(|_| "[]".to_string()),
|
||||
overlay_id =
|
||||
serde_json::to_string(ANNOTATION_OVERLAY_ID).unwrap_or_else(|_| "\"\"".to_string()),
|
||||
);
|
||||
|
||||
let _: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_annotation_overlay(client: &CdpClient, session_id: &str) -> Result<(), String> {
|
||||
let expression = format!(
|
||||
r#"(() => {{
|
||||
var el = document.getElementById({overlay_id});
|
||||
if (el) el.remove();
|
||||
return true;
|
||||
}})()"#,
|
||||
overlay_id =
|
||||
serde_json::to_string(ANNOTATION_OVERLAY_ID).unwrap_or_else(|_| "\"\"".to_string()),
|
||||
);
|
||||
|
||||
let _: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_scroll_offsets(client: &CdpClient, session_id: &str) -> Result<(f64, f64), String> {
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: "({x: window.scrollX || 0, y: window.scrollY || 0})".to_string(),
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let value = result.result.value.unwrap_or(Value::Null);
|
||||
let x = value.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let y = value.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
Ok((x, y))
|
||||
}
|
||||
|
||||
fn project_annotations(
|
||||
annotations: &[RawAnnotation],
|
||||
target_rect: Option<&Rect>,
|
||||
scroll: Option<(f64, f64)>,
|
||||
) -> Vec<ScreenshotAnnotation> {
|
||||
annotations
|
||||
.iter()
|
||||
.map(|annotation| {
|
||||
let rect = if let Some(target) = target_rect {
|
||||
Rect {
|
||||
x: annotation.rect.x - target.x,
|
||||
y: annotation.rect.y - target.y,
|
||||
width: annotation.rect.width,
|
||||
height: annotation.rect.height,
|
||||
}
|
||||
} else if let Some((scroll_x, scroll_y)) = scroll {
|
||||
Rect {
|
||||
x: annotation.rect.x + scroll_x,
|
||||
y: annotation.rect.y + scroll_y,
|
||||
width: annotation.rect.width,
|
||||
height: annotation.rect.height,
|
||||
}
|
||||
} else {
|
||||
annotation.rect.clone()
|
||||
};
|
||||
|
||||
ScreenshotAnnotation {
|
||||
ref_id: annotation.ref_id.clone(),
|
||||
number: annotation.number,
|
||||
role: annotation.role.clone(),
|
||||
name: annotation.name.clone(),
|
||||
box_: AnnotationBox {
|
||||
x: round(rect.x),
|
||||
y: round(rect.y),
|
||||
width: round(rect.width),
|
||||
height: round(rect.height),
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn save_screenshot(
|
||||
base64_data: &str,
|
||||
explicit_path: Option<&str>,
|
||||
ext: &str,
|
||||
output_dir: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let save_path = match explicit_path {
|
||||
Some(path) => path.to_string(),
|
||||
None => {
|
||||
let dir = get_screenshot_dir();
|
||||
let dir = match output_dir {
|
||||
Some(d) => PathBuf::from(d),
|
||||
None => get_screenshot_dir(),
|
||||
};
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -127,13 +574,17 @@ pub async fn take_screenshot(
|
||||
}
|
||||
};
|
||||
|
||||
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &result.data)
|
||||
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, base64_data)
|
||||
.map_err(|e| format!("Failed to decode screenshot: {}", e))?;
|
||||
|
||||
std::fs::write(&save_path, &bytes)
|
||||
.map_err(|e| format!("Failed to save screenshot to {}: {}", save_path, e))?;
|
||||
|
||||
Ok((save_path, result.data))
|
||||
Ok(save_path)
|
||||
}
|
||||
|
||||
fn round(value: f64) -> i64 {
|
||||
value.round() as i64
|
||||
}
|
||||
|
||||
fn get_screenshot_dir() -> PathBuf {
|
||||
@@ -145,3 +596,96 @@ fn get_screenshot_dir() -> PathBuf {
|
||||
.join("screenshots")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn filters_annotations_to_target_overlap() {
|
||||
let annotations = vec![
|
||||
RawAnnotation {
|
||||
ref_id: "e1".to_string(),
|
||||
number: 1,
|
||||
role: "button".to_string(),
|
||||
name: Some("Inside".to_string()),
|
||||
rect: Rect {
|
||||
x: 10.0,
|
||||
y: 10.0,
|
||||
width: 50.0,
|
||||
height: 20.0,
|
||||
},
|
||||
},
|
||||
RawAnnotation {
|
||||
ref_id: "e2".to_string(),
|
||||
number: 2,
|
||||
role: "button".to_string(),
|
||||
name: Some("Outside".to_string()),
|
||||
rect: Rect {
|
||||
x: 200.0,
|
||||
y: 200.0,
|
||||
width: 40.0,
|
||||
height: 20.0,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
let target = Rect {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 100.0,
|
||||
height: 100.0,
|
||||
};
|
||||
|
||||
let filtered = filter_annotations(annotations, Some(&target));
|
||||
assert_eq!(filtered.len(), 1);
|
||||
assert_eq!(filtered[0].ref_id, "e1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projects_selector_annotations_relative_to_target() {
|
||||
let annotations = vec![RawAnnotation {
|
||||
ref_id: "e1".to_string(),
|
||||
number: 1,
|
||||
role: "button".to_string(),
|
||||
name: Some("Inside".to_string()),
|
||||
rect: Rect {
|
||||
x: 25.0,
|
||||
y: 35.0,
|
||||
width: 40.0,
|
||||
height: 20.0,
|
||||
},
|
||||
}];
|
||||
|
||||
let target = Rect {
|
||||
x: 10.0,
|
||||
y: 15.0,
|
||||
width: 100.0,
|
||||
height: 100.0,
|
||||
};
|
||||
|
||||
let projected = project_annotations(&annotations, Some(&target), None);
|
||||
assert_eq!(projected[0].box_.x, 15);
|
||||
assert_eq!(projected[0].box_.y, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projects_full_page_annotations_to_document_space() {
|
||||
let annotations = vec![RawAnnotation {
|
||||
ref_id: "e1".to_string(),
|
||||
number: 1,
|
||||
role: "button".to_string(),
|
||||
name: Some("Bottom".to_string()),
|
||||
rect: Rect {
|
||||
x: 5.0,
|
||||
y: 12.0,
|
||||
width: 40.0,
|
||||
height: 20.0,
|
||||
},
|
||||
}];
|
||||
|
||||
let projected = project_annotations(&annotations, None, Some((10.0, 1000.0)));
|
||||
assert_eq!(projected[0].box_.x, 15);
|
||||
assert_eq!(projected[0].box_.y, 1012);
|
||||
}
|
||||
}
|
||||
|
||||
+744
-143
File diff suppressed because it is too large
Load Diff
+323
-43
@@ -1,12 +1,17 @@
|
||||
use aes_gcm::{aead::Aead, aead::KeyInit, Aes256Gcm};
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::types::EvaluateParams;
|
||||
use super::cdp::types::{
|
||||
AttachToTargetParams, AttachToTargetResult, CloseTargetParams, CreateTargetParams,
|
||||
CreateTargetResult, EvaluateParams,
|
||||
};
|
||||
use super::cookies::{self, Cookie};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@@ -32,16 +37,223 @@ pub struct StorageEntry {
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
fn collect_frame_origins(tree: &Value, origins: &mut HashSet<String>) {
|
||||
if let Some(frame) = tree.get("frame") {
|
||||
if let Some(url_str) = frame.get("url").and_then(|v| v.as_str()) {
|
||||
if let Ok(parsed) = url::Url::parse(url_str) {
|
||||
let origin = parsed.origin().ascii_serialization();
|
||||
if origin != "null" && !origin.is_empty() {
|
||||
origins.insert(origin);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(children) = tree.get("childFrames").and_then(|v| v.as_array()) {
|
||||
for child in children {
|
||||
collect_frame_origins(child, origins);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the JS-evaluated origin storage data into an OriginStorage struct.
|
||||
fn parse_origin_storage(data: &Value) -> Option<OriginStorage> {
|
||||
if !data.is_object() {
|
||||
return None;
|
||||
}
|
||||
let origin = data
|
||||
.get("origin")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if origin.is_empty() || origin == "null" {
|
||||
return None;
|
||||
}
|
||||
let local_storage: Vec<StorageEntry> = data
|
||||
.get("localStorage")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
let session_storage: Vec<StorageEntry> = data
|
||||
.get("sessionStorage")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
Some(OriginStorage {
|
||||
origin,
|
||||
local_storage,
|
||||
session_storage,
|
||||
})
|
||||
}
|
||||
|
||||
/// Evaluate the storage-collection JS snippet and parse the result.
|
||||
async fn eval_origin_storage(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
origin_js: &str,
|
||||
) -> Option<OriginStorage> {
|
||||
let result = client
|
||||
.send_command_typed::<_, super::cdp::types::EvaluateResult>(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: origin_js.to_string(),
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
let data = result.result.value.unwrap_or(Value::Null);
|
||||
parse_origin_storage(&data)
|
||||
}
|
||||
|
||||
/// Create a temporary CDP target, navigate it to each origin to collect localStorage,
|
||||
/// then close it. Uses Fetch interception to serve blank HTML instead of making real
|
||||
/// network requests.
|
||||
async fn collect_storage_via_temp_target(
|
||||
client: &CdpClient,
|
||||
origins: &[String],
|
||||
origin_js: &str,
|
||||
) -> Result<Vec<OriginStorage>, String> {
|
||||
let create_result: CreateTargetResult = client
|
||||
.send_command_typed(
|
||||
"Target.createTarget",
|
||||
&CreateTargetParams {
|
||||
url: "about:blank".to_string(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let target_id = create_result.target_id;
|
||||
|
||||
// Ensure the target is closed even if attach or later steps fail
|
||||
let result = collect_storage_in_target(client, &target_id, origins, origin_js).await;
|
||||
|
||||
let _ = client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Target.closeTarget",
|
||||
&CloseTargetParams { target_id },
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn collect_storage_in_target(
|
||||
client: &CdpClient,
|
||||
target_id: &str,
|
||||
origins: &[String],
|
||||
origin_js: &str,
|
||||
) -> Result<Vec<OriginStorage>, String> {
|
||||
let attach_result: AttachToTargetResult = client
|
||||
.send_command_typed(
|
||||
"Target.attachToTarget",
|
||||
&AttachToTargetParams {
|
||||
target_id: target_id.to_string(),
|
||||
flatten: true,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let temp_session = &attach_result.session_id;
|
||||
|
||||
client
|
||||
.send_command_no_params("Page.enable", Some(temp_session))
|
||||
.await?;
|
||||
client
|
||||
.send_command_no_params("Runtime.enable", Some(temp_session))
|
||||
.await?;
|
||||
|
||||
// Blank HTML response body, pre-encoded to avoid repeated base64 work per request
|
||||
let blank_html_b64 = base64::engine::general_purpose::STANDARD.encode("<html></html>");
|
||||
|
||||
let _ = client
|
||||
.send_command(
|
||||
"Fetch.enable",
|
||||
Some(json!({ "patterns": [{ "urlPattern": "*" }] })),
|
||||
Some(temp_session),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut event_rx = client.subscribe();
|
||||
let mut results = Vec::new();
|
||||
|
||||
for target_origin in origins {
|
||||
let nav_url = format!("{}/", target_origin.trim_end_matches('/'));
|
||||
if client
|
||||
.send_command(
|
||||
"Page.navigate",
|
||||
Some(json!({ "url": nav_url })),
|
||||
Some(temp_session),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fulfill intercepted requests with blank HTML until the page loads
|
||||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(5);
|
||||
let mut page_loaded = false;
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
match tokio::time::timeout(tokio::time::Duration::from_secs(2), event_rx.recv()).await {
|
||||
Ok(Ok(evt)) if evt.session_id.as_deref() == Some(temp_session) => {
|
||||
if evt.method == "Fetch.requestPaused" {
|
||||
if let Some(request_id) =
|
||||
evt.params.get("requestId").and_then(|v| v.as_str())
|
||||
{
|
||||
let _ = client
|
||||
.send_command(
|
||||
"Fetch.fulfillRequest",
|
||||
Some(json!({
|
||||
"requestId": request_id,
|
||||
"responseCode": 200,
|
||||
"responseHeaders": [
|
||||
{ "name": "Content-Type", "value": "text/html" }
|
||||
],
|
||||
"body": &blank_html_b64
|
||||
})),
|
||||
Some(temp_session),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
} else if evt.method == "Page.loadEventFired" {
|
||||
page_loaded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Ok(_)) => continue, // event for a different session
|
||||
Ok(Err(_)) => continue, // lagged or closed — retry within deadline
|
||||
Err(_) => break, // outer timeout elapsed
|
||||
}
|
||||
}
|
||||
|
||||
if !page_loaded {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(storage) = eval_origin_storage(client, temp_session, origin_js).await {
|
||||
if !storage.local_storage.is_empty() || !storage.session_storage.is_empty() {
|
||||
results.push(storage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
pub async fn save_state(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
path: Option<&str>,
|
||||
session_name: Option<&str>,
|
||||
session_id_str: &str,
|
||||
visited_origins: &HashSet<String>,
|
||||
) -> Result<String, String> {
|
||||
let cookies = cookies::get_cookies(client, session_id, None).await?;
|
||||
let cookies = cookies::get_all_cookies(client, session_id).await?;
|
||||
|
||||
// Get current origin's storage
|
||||
let origin_js = r#"(() => {
|
||||
const result = { origin: location.origin, localStorage: [], sessionStorage: [] };
|
||||
try {
|
||||
@@ -59,46 +271,38 @@ pub async fn save_state(
|
||||
return result;
|
||||
})()"#;
|
||||
|
||||
let origin_result: super::cdp::types::EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: origin_js.to_string(),
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let origin_data = origin_result.result.value.unwrap_or(Value::Null);
|
||||
let origins = if origin_data.is_object() {
|
||||
let origin = origin_data
|
||||
.get("origin")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let local_storage: Vec<StorageEntry> = origin_data
|
||||
.get("localStorage")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
let session_storage: Vec<StorageEntry> = origin_data
|
||||
.get("sessionStorage")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
if !origin.is_empty() && origin != "null" {
|
||||
vec![OriginStorage {
|
||||
origin,
|
||||
local_storage,
|
||||
session_storage,
|
||||
}]
|
||||
} else {
|
||||
vec![]
|
||||
// Merge visited origins with current frame tree origins
|
||||
let mut all_origins = visited_origins.clone();
|
||||
if let Ok(tree_result) = client
|
||||
.send_command_no_params("Page.getFrameTree", Some(session_id))
|
||||
.await
|
||||
{
|
||||
if let Some(tree) = tree_result.get("frameTree") {
|
||||
collect_frame_origins(tree, &mut all_origins);
|
||||
}
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
}
|
||||
|
||||
// 1. Collect localStorage from the current page
|
||||
let mut origins = Vec::new();
|
||||
let mut current_origin = String::new();
|
||||
|
||||
if let Some(storage) = eval_origin_storage(client, session_id, origin_js).await {
|
||||
current_origin = storage.origin.clone();
|
||||
if !storage.local_storage.is_empty() || !storage.session_storage.is_empty() {
|
||||
origins.push(storage);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Collect localStorage from remaining origins via a disposable temp target
|
||||
all_origins.remove(¤t_origin);
|
||||
if !all_origins.is_empty() {
|
||||
let remaining: Vec<String> = all_origins.into_iter().collect();
|
||||
if let Ok(temp_origins) =
|
||||
collect_storage_via_temp_target(client, &remaining, origin_js).await
|
||||
{
|
||||
origins.extend(temp_origins);
|
||||
}
|
||||
}
|
||||
|
||||
let state = StorageState { cookies, origins };
|
||||
let json_str = serde_json::to_string_pretty(&state)
|
||||
@@ -467,7 +671,7 @@ pub fn find_auto_state_file(session_name: &str) -> Option<String> {
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.unwrap_or(std::time::UNIX_EPOCH);
|
||||
if best_path.as_ref().map_or(true, |(_, t)| modified > *t) {
|
||||
if best_path.as_ref().is_none_or(|(_, t)| modified > *t) {
|
||||
best_path = Some((path.to_string_lossy().to_string(), modified));
|
||||
}
|
||||
}
|
||||
@@ -475,6 +679,41 @@ pub fn find_auto_state_file(session_name: &str) -> Option<String> {
|
||||
best_path.map(|(p, _)| p)
|
||||
}
|
||||
|
||||
/// Dispatch a state management command from its JSON payload.
|
||||
/// Returns `Some(result)` for recognised state_* actions, `None` otherwise.
|
||||
pub fn dispatch_state_command(cmd: &Value) -> Option<Result<Value, String>> {
|
||||
let action = cmd.get("action").and_then(|v| v.as_str())?;
|
||||
match action {
|
||||
"state_list" => Some(state_list()),
|
||||
"state_show" => Some(
|
||||
cmd.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "Missing 'path' parameter".to_string())
|
||||
.and_then(state_show),
|
||||
),
|
||||
"state_clear" => {
|
||||
let path = cmd.get("path").and_then(|v| v.as_str());
|
||||
Some(state_clear(path))
|
||||
}
|
||||
"state_clean" => {
|
||||
let days = cmd.get("days").and_then(|v| v.as_u64()).unwrap_or(30);
|
||||
Some(state_clean(days))
|
||||
}
|
||||
"state_rename" => Some(
|
||||
cmd.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "Missing 'path' parameter".to_string())
|
||||
.and_then(|path| {
|
||||
cmd.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "Missing 'name' parameter".to_string())
|
||||
.and_then(|name| state_rename(path, name))
|
||||
}),
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_sessions_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser").join("sessions")
|
||||
@@ -604,4 +843,45 @@ mod tests {
|
||||
assert_eq!(json["secure"], true);
|
||||
assert_eq!(json["sameSite"], "Strict");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dispatch_state_command_routes_state_list() {
|
||||
let cmd = serde_json::json!({ "action": "state_list" });
|
||||
let result = dispatch_state_command(&cmd);
|
||||
assert!(result.is_some());
|
||||
assert!(result.unwrap().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dispatch_state_command_returns_none_for_unknown() {
|
||||
let cmd = serde_json::json!({ "action": "navigate" });
|
||||
assert!(dispatch_state_command(&cmd).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dispatch_state_command_returns_none_for_missing_action() {
|
||||
let cmd = serde_json::json!({});
|
||||
assert!(dispatch_state_command(&cmd).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dispatch_state_show_missing_path() {
|
||||
let cmd = serde_json::json!({ "action": "state_show" });
|
||||
let result = dispatch_state_command(&cmd).unwrap();
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), "Missing 'path' parameter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dispatch_state_rename_missing_params() {
|
||||
let cmd = serde_json::json!({ "action": "state_rename" });
|
||||
let result = dispatch_state_command(&cmd).unwrap();
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), "Missing 'path' parameter");
|
||||
|
||||
let cmd = serde_json::json!({ "action": "state_rename", "path": "/tmp/test.json" });
|
||||
let result = dispatch_state_command(&cmd).unwrap();
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), "Missing 'name' parameter");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
//! Stealth anti-detection module.
|
||||
//!
|
||||
//! Injects browser-level patches to evade bot detection (creepjs, sannysoft,
|
||||
//! Cloudflare Turnstile, etc.) by normalizing fingerprint signals that betray
|
||||
//! headless or automated Chrome instances.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
|
||||
/// Full stealth JS payload compiled at build time (for --launch mode).
|
||||
const STEALTH_SCRIPTS_RAW: &str = include_str!("stealth_scripts.js");
|
||||
|
||||
/// Minimal stealth script for CDP-attach mode (connecting to user's real Chrome).
|
||||
/// Only removes navigator.webdriver — the browser's own fingerprint is already real.
|
||||
/// Minimal stealth script for CDP-attach mode.
|
||||
/// Emulation.setAutomationOverride handles navigator.webdriver at the native
|
||||
/// level, so no JS patching is needed in CdpAttach mode. An empty script
|
||||
/// avoids creating any detectable lie-props artifacts.
|
||||
const MINIMAL_STEALTH_SCRIPT: &str = "";
|
||||
|
||||
/// Chrome launch arguments that reduce automation fingerprint surface.
|
||||
pub const STEALTH_CHROMIUM_ARGS: &[&str] = &[
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--use-gl=angle",
|
||||
"--use-angle=default",
|
||||
];
|
||||
|
||||
/// Connection mode determines which stealth patches to apply.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum StealthMode {
|
||||
/// Connected to user's real Chrome — minimal patches only (webdriver removal).
|
||||
/// The browser already has a real fingerprint; heavy patches would create detectable lies.
|
||||
CdpAttach,
|
||||
/// Launched a new Chrome instance — apply full stealth patches.
|
||||
FullLaunch,
|
||||
}
|
||||
|
||||
/// Build the stealth JS payload for the given mode and locale.
|
||||
pub fn build_stealth_script(mode: StealthMode, locale: Option<&str>) -> String {
|
||||
if mode == StealthMode::CdpAttach {
|
||||
return MINIMAL_STEALTH_SCRIPT.to_string();
|
||||
}
|
||||
|
||||
// Full launch mode: inject all patches
|
||||
let locale = locale.unwrap_or("en-US");
|
||||
let base_lang = locale.split('-').next().unwrap_or(locale);
|
||||
let languages: Vec<&str> = if base_lang == locale {
|
||||
vec![locale]
|
||||
} else {
|
||||
vec![locale, base_lang]
|
||||
};
|
||||
let config_line = format!(
|
||||
r#"const __abStealth = {{ locale: "{}", languages: {}, allowWebGLContextFallback: false }};"#,
|
||||
locale,
|
||||
serde_json::to_string(&languages).unwrap_or_else(|_| r#"["en-US","en"]"#.to_string()),
|
||||
);
|
||||
|
||||
if let Some(rest) = STEALTH_SCRIPTS_RAW.strip_prefix(
|
||||
r#"const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false };"#,
|
||||
) {
|
||||
format!("{}{}", config_line, rest)
|
||||
} else {
|
||||
format!("{}\n{}", config_line, STEALTH_SCRIPTS_RAW)
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply stealth patches to a browser session.
|
||||
///
|
||||
/// In `CdpAttach` mode (user's real Chrome): only removes `navigator.webdriver`.
|
||||
/// In `FullLaunch` mode (new Chrome): injects all 32 patches + UA override.
|
||||
pub async fn apply_stealth(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
mode: StealthMode,
|
||||
locale: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
// First: disable the automation flag at the CDP protocol level.
|
||||
// This tells Chrome to natively set navigator.webdriver = false,
|
||||
// which is undetectable by lie-detection systems like CreepJS.
|
||||
// Falls back gracefully on older Chrome versions that don't support this.
|
||||
let _ = client
|
||||
.send_command(
|
||||
"Emulation.setAutomationOverride",
|
||||
Some(json!({ "enabled": false })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
|
||||
let script = build_stealth_script(mode, locale);
|
||||
|
||||
// Inject stealth scripts to run before page JS
|
||||
client
|
||||
.send_command(
|
||||
"Page.addScriptToEvaluateOnNewDocument",
|
||||
Some(json!({ "source": script })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// In full launch mode, also override UA to remove HeadlessChrome marker
|
||||
if mode == StealthMode::FullLaunch {
|
||||
let ua = get_browser_user_agent(client, session_id).await;
|
||||
if let Some(ua) = ua {
|
||||
let cleaned = ua.replace("HeadlessChrome", "Chrome");
|
||||
if cleaned != ua {
|
||||
client
|
||||
.send_command(
|
||||
"Emulation.setUserAgentOverride",
|
||||
Some(json!({
|
||||
"userAgent": cleaned,
|
||||
"acceptLanguage": locale.unwrap_or("en-US"),
|
||||
"platform": platform_string(),
|
||||
"userAgentMetadata": build_ua_metadata(&cleaned, locale),
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the browser's User-Agent string via CDP.
|
||||
async fn get_browser_user_agent(client: &CdpClient, session_id: &str) -> Option<String> {
|
||||
let result = client
|
||||
.send_command(
|
||||
"Runtime.evaluate",
|
||||
Some(json!({ "expression": "navigator.userAgent", "returnByValue": true })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
result
|
||||
.get("result")
|
||||
.and_then(|r| r.get("value"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
}
|
||||
|
||||
/// Also run stealth script on the current page (for already-loaded pages after CDP attach).
|
||||
pub async fn apply_stealth_to_current_page(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
mode: StealthMode,
|
||||
locale: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let script = build_stealth_script(mode, locale);
|
||||
client
|
||||
.send_command(
|
||||
"Runtime.evaluate",
|
||||
Some(json!({
|
||||
"expression": script,
|
||||
"returnByValue": true,
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Strip sourceURL comments from CDP expressions to avoid leaking
|
||||
/// automation-framework identifiers in stack traces.
|
||||
pub fn strip_source_url_labels(input: &str) -> String {
|
||||
// Remove //# sourceURL=... and //@ sourceURL=...
|
||||
let re_line = regex_lite::Regex::new(r"(?i)\n?\s*//[@#]\s*sourceURL=[^\n\r]*").unwrap();
|
||||
let output = re_line.replace_all(input, "");
|
||||
// Remove /*# sourceURL=...*/ block comments
|
||||
let re_block =
|
||||
regex_lite::Regex::new(r"(?is)\n?\s*/\*[@#]\s*sourceURL=[\s\S]*?\*/").unwrap();
|
||||
re_block.replace_all(&output, "").to_string()
|
||||
}
|
||||
|
||||
fn platform_string() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
"macOS"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"Win32"
|
||||
} else {
|
||||
"Linux"
|
||||
}
|
||||
}
|
||||
|
||||
fn platform_hint() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
"macOS"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"Windows"
|
||||
} else {
|
||||
"Linux"
|
||||
}
|
||||
}
|
||||
|
||||
fn platform_version_hint() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
"14.0.0"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"10.0.0"
|
||||
} else {
|
||||
"6.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ua_metadata(ua: &str, locale: Option<&str>) -> serde_json::Value {
|
||||
// Extract Chrome version from UA string
|
||||
let chrome_version = ua
|
||||
.split("Chrome/")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split_whitespace().next())
|
||||
.unwrap_or("130.0.0.0");
|
||||
let major = chrome_version.split('.').next().unwrap_or("130");
|
||||
|
||||
let _lang = locale.unwrap_or("en-US");
|
||||
|
||||
json!({
|
||||
"brands": [
|
||||
{ "brand": "Chromium", "version": major },
|
||||
{ "brand": "Google Chrome", "version": major },
|
||||
{ "brand": "Not?A_Brand", "version": "99" },
|
||||
],
|
||||
"fullVersionList": [
|
||||
{ "brand": "Chromium", "version": chrome_version },
|
||||
{ "brand": "Google Chrome", "version": chrome_version },
|
||||
{ "brand": "Not?A_Brand", "version": "99.0.0.0" },
|
||||
],
|
||||
"fullVersion": chrome_version,
|
||||
"platform": platform_hint(),
|
||||
"platformVersion": platform_version_hint(),
|
||||
"architecture": if cfg!(target_arch = "aarch64") { "arm" } else { "x86" },
|
||||
"model": "",
|
||||
"mobile": false,
|
||||
"bitness": "64",
|
||||
"wow64": false,
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+1434
-34
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Drag Probe</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font: 14px/1.4 sans-serif;
|
||||
background: #f4f4f4;
|
||||
}
|
||||
|
||||
#pad {
|
||||
position: relative;
|
||||
width: 800px;
|
||||
height: 500px;
|
||||
margin: 24px;
|
||||
border: 1px solid #999;
|
||||
background: white;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#target {
|
||||
position: absolute;
|
||||
left: 320px;
|
||||
top: 40px;
|
||||
width: 100px;
|
||||
height: 40px;
|
||||
background: #e34c26;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
#target.dragging {
|
||||
cursor: grabbing;
|
||||
background: #0d9488;
|
||||
}
|
||||
|
||||
#log {
|
||||
margin: 24px;
|
||||
white-space: pre-wrap;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="pad">
|
||||
<div id="target">drag me</div>
|
||||
</div>
|
||||
<pre id="log"></pre>
|
||||
<script>
|
||||
const target = document.getElementById("target");
|
||||
const logEl = document.getElementById("log");
|
||||
|
||||
window.__dragProbe = {
|
||||
dragging: false,
|
||||
events: [],
|
||||
finalLeft: 320,
|
||||
finalTop: 40,
|
||||
};
|
||||
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
|
||||
function pushEvent(event, extra = {}) {
|
||||
window.__dragProbe.events.push({
|
||||
type: event.type,
|
||||
button: event.button,
|
||||
buttons: event.buttons,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
target: event.target.id || event.target.tagName,
|
||||
...extra,
|
||||
});
|
||||
logEl.textContent = JSON.stringify(window.__dragProbe, null, 2);
|
||||
}
|
||||
|
||||
function onPointerLikeStart(event) {
|
||||
if (event.type === "mousedown") {
|
||||
const rect = target.getBoundingClientRect();
|
||||
offsetX = event.clientX - rect.left;
|
||||
offsetY = event.clientY - rect.top;
|
||||
window.__dragProbe.dragging = true;
|
||||
target.classList.add("dragging");
|
||||
event.preventDefault();
|
||||
}
|
||||
pushEvent(event, { phase: "start" });
|
||||
}
|
||||
|
||||
target.addEventListener("mousedown", (event) => {
|
||||
const rect = target.getBoundingClientRect();
|
||||
offsetX = event.clientX - rect.left;
|
||||
offsetY = event.clientY - rect.top;
|
||||
window.__dragProbe.dragging = true;
|
||||
target.classList.add("dragging");
|
||||
event.preventDefault();
|
||||
pushEvent(event, { phase: "start" });
|
||||
});
|
||||
target.addEventListener("pointerdown", onPointerLikeStart);
|
||||
|
||||
document.addEventListener("mousemove", (event) => {
|
||||
if (window.__dragProbe.dragging) {
|
||||
const left = event.clientX - offsetX;
|
||||
const top = event.clientY - offsetY;
|
||||
target.style.left = `${left}px`;
|
||||
target.style.top = `${top}px`;
|
||||
window.__dragProbe.finalLeft = left;
|
||||
window.__dragProbe.finalTop = top;
|
||||
}
|
||||
pushEvent(event);
|
||||
});
|
||||
document.addEventListener("pointermove", (event) => {
|
||||
pushEvent(event);
|
||||
});
|
||||
|
||||
document.addEventListener("mouseup", (event) => {
|
||||
if (window.__dragProbe.dragging) {
|
||||
window.__dragProbe.dragging = false;
|
||||
target.classList.remove("dragging");
|
||||
}
|
||||
pushEvent(event, { phase: "end" });
|
||||
});
|
||||
document.addEventListener("pointerup", (event) => {
|
||||
pushEvent(event, { phase: "end" });
|
||||
});
|
||||
target.addEventListener("dragstart", (event) => {
|
||||
pushEvent(event, { phase: "dragstart" });
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,91 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>HTML5 Drag Probe</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 24px;
|
||||
font: 14px/1.4 sans-serif;
|
||||
}
|
||||
|
||||
#source, #dest {
|
||||
width: 120px;
|
||||
height: 80px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #666;
|
||||
user-select: none;
|
||||
margin-right: 40px;
|
||||
}
|
||||
|
||||
#source {
|
||||
background: #f97316;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#dest {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin-top: 24px;
|
||||
white-space: pre-wrap;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="source" draggable="true">drag source</div>
|
||||
<div id="dest">drop zone</div>
|
||||
<pre id="log"></pre>
|
||||
<script>
|
||||
const source = document.getElementById("source");
|
||||
const dest = document.getElementById("dest");
|
||||
const logEl = document.getElementById("log");
|
||||
|
||||
window.__html5DragProbe = { events: [] };
|
||||
|
||||
function pushEvent(event, extra = {}) {
|
||||
window.__html5DragProbe.events.push({
|
||||
type: event.type,
|
||||
target: event.target.id || event.target.tagName,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
button: event.button,
|
||||
buttons: event.buttons,
|
||||
...extra,
|
||||
});
|
||||
logEl.textContent = JSON.stringify(window.__html5DragProbe, null, 2);
|
||||
}
|
||||
|
||||
for (const type of ["pointerdown", "mousedown", "dragstart", "drag", "dragend"]) {
|
||||
source.addEventListener(type, (event) => {
|
||||
if (type === "dragstart") {
|
||||
event.dataTransfer.setData("text/plain", "probe");
|
||||
}
|
||||
pushEvent(event);
|
||||
});
|
||||
}
|
||||
|
||||
for (const type of ["pointermove", "mousemove", "dragenter", "dragover", "drop", "pointerup", "mouseup"]) {
|
||||
document.addEventListener(type, (event) => {
|
||||
if (type === "dragover") {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (type === "drop") {
|
||||
pushEvent(event, { dropped: event.dataTransfer.getData("text/plain") });
|
||||
return;
|
||||
}
|
||||
pushEvent(event);
|
||||
});
|
||||
}
|
||||
|
||||
dest.addEventListener("dragover", (event) => event.preventDefault());
|
||||
dest.addEventListener("drop", (event) => {
|
||||
pushEvent(event, { dropped: event.dataTransfer.getData("text/plain") });
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,113 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Pointer Capture Probe</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 24px;
|
||||
font: 14px/1.4 sans-serif;
|
||||
}
|
||||
#crop {
|
||||
position: relative;
|
||||
width: 240px;
|
||||
height: 180px;
|
||||
border: 2px solid #fff;
|
||||
outline: 1px solid #555;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
#handle {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
top: -16px;
|
||||
left: -16px;
|
||||
padding-top: 13px;
|
||||
padding-left: 13px;
|
||||
box-sizing: content-box;
|
||||
background: rgba(255, 0, 0, 0.25);
|
||||
}
|
||||
#handle::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-top: 2px solid white;
|
||||
border-left: 2px solid white;
|
||||
}
|
||||
pre {
|
||||
margin-top: 24px;
|
||||
white-space: pre-wrap;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="crop" aria-label="crop area">
|
||||
<div id="handle" aria-label="crop handle topLeft" data-anchor="topLeft"></div>
|
||||
</div>
|
||||
<pre id="log"></pre>
|
||||
<script>
|
||||
const crop = document.getElementById("crop");
|
||||
const handle = document.getElementById("handle");
|
||||
const logEl = document.getElementById("log");
|
||||
|
||||
const state = {
|
||||
targetAnchor: null,
|
||||
dragging: false,
|
||||
moved: false,
|
||||
events: [],
|
||||
};
|
||||
window.__pointerCaptureProbe = state;
|
||||
|
||||
function sync() {
|
||||
logEl.textContent = JSON.stringify(state, null, 2);
|
||||
}
|
||||
|
||||
function push(event, extra = {}) {
|
||||
state.events.push({
|
||||
type: event.type,
|
||||
target: event.target.id || event.target.tagName,
|
||||
currentTarget: event.currentTarget.id || event.currentTarget.tagName,
|
||||
pointerId: event.pointerId,
|
||||
button: event.button,
|
||||
buttons: event.buttons,
|
||||
hasCapture: event.currentTarget.hasPointerCapture?.(event.pointerId) ?? false,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
...extra,
|
||||
});
|
||||
sync();
|
||||
}
|
||||
|
||||
crop.addEventListener("pointerdown", (event) => {
|
||||
state.targetAnchor = event.target.getAttribute("data-anchor");
|
||||
crop.setPointerCapture(event.pointerId);
|
||||
event.preventDefault();
|
||||
push(event, { phase: "down", targetAnchor: state.targetAnchor });
|
||||
});
|
||||
|
||||
crop.addEventListener("pointermove", (event) => {
|
||||
const hasCapture = crop.hasPointerCapture(event.pointerId);
|
||||
if (hasCapture && state.targetAnchor) {
|
||||
state.dragging = true;
|
||||
state.moved = true;
|
||||
}
|
||||
push(event, { phase: hasCapture ? "drag" : "hover", targetAnchor: state.targetAnchor });
|
||||
});
|
||||
|
||||
crop.addEventListener("pointerup", (event) => {
|
||||
const hadCapture = crop.hasPointerCapture(event.pointerId);
|
||||
state.dragging = false;
|
||||
push(event, { phase: "up", targetAnchor: state.targetAnchor, hadCapture });
|
||||
state.targetAnchor = null;
|
||||
});
|
||||
|
||||
handle.addEventListener("pointerdown", (event) => push(event, { listener: "handle" }));
|
||||
handle.addEventListener("pointermove", (event) => push(event, { listener: "handle" }));
|
||||
handle.addEventListener("pointerup", (event) => push(event, { listener: "handle" }));
|
||||
|
||||
sync();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -40,32 +40,45 @@ impl AppiumManager {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_ios_capabilities(
|
||||
device_udid: Option<&str>,
|
||||
device_name: Option<&str>,
|
||||
platform_version: Option<&str>,
|
||||
) -> Value {
|
||||
let mut caps = json!({
|
||||
"platformName": "iOS",
|
||||
"appium:automationName": "XCUITest",
|
||||
"browserName": "Safari",
|
||||
"appium:noReset": true,
|
||||
});
|
||||
|
||||
if let Some(name) = device_name {
|
||||
caps["appium:deviceName"] = json!(name);
|
||||
} else {
|
||||
caps["appium:deviceName"] = json!("iPhone");
|
||||
}
|
||||
|
||||
if let Some(ver) = platform_version {
|
||||
caps["appium:platformVersion"] = json!(ver);
|
||||
}
|
||||
|
||||
if let Some(udid) = device_udid {
|
||||
caps["appium:udid"] = json!(udid);
|
||||
}
|
||||
|
||||
caps
|
||||
}
|
||||
|
||||
pub async fn create_ios_session(
|
||||
&mut self,
|
||||
device_name: Option<&str>,
|
||||
platform_version: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
let mut caps = json!({
|
||||
"platformName": "iOS",
|
||||
"automationName": "XCUITest",
|
||||
"browserName": "Safari",
|
||||
"noReset": true,
|
||||
});
|
||||
|
||||
if let Some(name) = device_name {
|
||||
caps["deviceName"] = json!(name);
|
||||
} else {
|
||||
caps["deviceName"] = json!("iPhone");
|
||||
}
|
||||
|
||||
if let Some(ver) = platform_version {
|
||||
caps["platformVersion"] = json!(ver);
|
||||
}
|
||||
|
||||
if let Some(ref udid) = self.device_udid {
|
||||
caps["udid"] = json!(udid);
|
||||
}
|
||||
|
||||
let caps = Self::build_ios_capabilities(
|
||||
self.device_udid.as_deref(),
|
||||
device_name,
|
||||
platform_version,
|
||||
);
|
||||
self.client.create_session(caps).await
|
||||
}
|
||||
|
||||
@@ -198,4 +211,30 @@ mod tests {
|
||||
assert_eq!(APPIUM_DEFAULT_PORT, 4723);
|
||||
assert_eq!(APPIUM_STARTUP_TIMEOUT_SECS, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ios_capabilities_use_vendor_prefix() {
|
||||
let caps = AppiumManager::build_ios_capabilities(
|
||||
Some("TEST-UDID-123"),
|
||||
Some("iPhone 16 Pro"),
|
||||
Some("18.5"),
|
||||
);
|
||||
|
||||
// W3C standard capabilities must NOT have vendor prefix
|
||||
assert!(caps.get("platformName").is_some());
|
||||
assert!(caps.get("browserName").is_some());
|
||||
|
||||
// Non-standard capabilities MUST have appium: vendor prefix
|
||||
assert!(caps.get("appium:automationName").is_some());
|
||||
assert!(caps.get("appium:noReset").is_some());
|
||||
assert!(caps.get("appium:deviceName").is_some());
|
||||
assert!(caps.get("appium:platformVersion").is_some());
|
||||
assert!(caps.get("appium:udid").is_some());
|
||||
|
||||
// Must NOT have unprefixed non-standard capabilities
|
||||
assert!(caps.get("automationName").is_none());
|
||||
assert!(caps.get("noReset").is_none());
|
||||
assert!(caps.get("deviceName").is_none());
|
||||
assert!(caps.get("udid").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,32 +212,6 @@ impl WebDriverClient {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_client_new() {
|
||||
let client = WebDriverClient::new(4444);
|
||||
assert_eq!(client.base_url, "http://127.0.0.1:4444");
|
||||
assert!(client.session_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_id_none() {
|
||||
let client = WebDriverClient::new(4444);
|
||||
let result = client.session_id();
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("No active WebDriver session"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_custom_port() {
|
||||
let client = WebDriverClient::new(9515);
|
||||
assert_eq!(client.base_url, "http://127.0.0.1:9515");
|
||||
}
|
||||
}
|
||||
|
||||
async fn http_request(method: &str, url: &str, body: Option<&Value>) -> Result<Value, String> {
|
||||
let parsed = url::Url::parse(url).map_err(|e| format!("Invalid URL: {}", e))?;
|
||||
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
||||
@@ -316,3 +290,29 @@ async fn http_request(method: &str, url: &str, body: Option<&Value>) -> Result<V
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_client_new() {
|
||||
let client = WebDriverClient::new(4444);
|
||||
assert_eq!(client.base_url, "http://127.0.0.1:4444");
|
||||
assert!(client.session_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_id_none() {
|
||||
let client = WebDriverClient::new(4444);
|
||||
let result = client.session_id();
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("No active WebDriver session"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_custom_port() {
|
||||
let client = WebDriverClient::new(9515);
|
||||
assert_eq!(client.base_url, "http://127.0.0.1:9515");
|
||||
}
|
||||
}
|
||||
|
||||
+500
-43
@@ -23,6 +23,16 @@ pub struct OutputOptions {
|
||||
pub max_output: Option<usize>,
|
||||
}
|
||||
|
||||
impl OutputOptions {
|
||||
pub fn from_flags(flags: &crate::flags::Flags) -> Self {
|
||||
Self {
|
||||
json: flags.json,
|
||||
content_boundaries: flags.content_boundaries,
|
||||
max_output: flags.max_output,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_if_needed(content: &str, max: Option<usize>) -> String {
|
||||
let Some(limit) = max else {
|
||||
return content.to_string();
|
||||
@@ -64,6 +74,62 @@ fn print_with_boundaries(content: &str, origin: Option<&str>, opts: &OutputOptio
|
||||
}
|
||||
}
|
||||
|
||||
fn format_storage_value(value: &serde_json::Value) -> String {
|
||||
value
|
||||
.as_str()
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| serde_json::to_string(value).unwrap_or_default())
|
||||
}
|
||||
|
||||
fn format_storage_text(data: &serde_json::Value) -> Option<String> {
|
||||
if let Some(entries) = data.get("data").and_then(|v| v.as_object()) {
|
||||
if entries.is_empty() {
|
||||
return Some("No storage entries".to_string());
|
||||
}
|
||||
|
||||
let lines = entries
|
||||
.iter()
|
||||
.map(|(key, value)| format!("{}: {}", key, format_storage_value(value)))
|
||||
.collect::<Vec<_>>();
|
||||
return Some(lines.join("\n"));
|
||||
}
|
||||
|
||||
let key = data.get("key").and_then(|v| v.as_str())?;
|
||||
let value = data.get("value")?;
|
||||
Some(format!("{}: {}", key, format_storage_value(value)))
|
||||
}
|
||||
|
||||
fn format_stream_status_text(action: Option<&str>, data: &serde_json::Value) -> Option<String> {
|
||||
match action {
|
||||
Some("stream_disable") => data
|
||||
.get("disabled")
|
||||
.and_then(|v| v.as_bool())
|
||||
.filter(|disabled| *disabled)
|
||||
.map(|_| "Streaming disabled".to_string()),
|
||||
Some("stream_enable") | Some("stream_status") => {
|
||||
let enabled = data.get("enabled").and_then(|v| v.as_bool())?;
|
||||
if !enabled {
|
||||
return Some("Streaming disabled".to_string());
|
||||
}
|
||||
|
||||
let port = data.get("port").and_then(|v| v.as_u64())?;
|
||||
let connected = data
|
||||
.get("connected")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let screencasting = data
|
||||
.get("screencasting")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
Some(format!(
|
||||
"Streaming enabled on ws://127.0.0.1:{port}\nConnected: {connected}\nScreencasting: {screencasting}"
|
||||
))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &OutputOptions) {
|
||||
if opts.json {
|
||||
if opts.content_boundaries {
|
||||
@@ -87,6 +153,7 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
} else {
|
||||
println!("{}", serde_json::to_string(resp).unwrap_or_default());
|
||||
}
|
||||
// JSON mode includes the warning field in the JSON payload already
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -96,10 +163,69 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
color::error_indicator(),
|
||||
resp.error.as_deref().unwrap_or("Unknown error")
|
||||
);
|
||||
// Still print dialog warning after errors, since a pending dialog
|
||||
// is the most common cause of commands timing out
|
||||
if let Some(ref warning) = resp.warning {
|
||||
eprintln!("{} {}", color::warning_indicator(), warning);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(data) = &resp.data {
|
||||
// Dialog status response
|
||||
if action == Some("dialog") {
|
||||
if let Some(has_dialog) = data.get("hasDialog").and_then(|v| v.as_bool()) {
|
||||
if has_dialog {
|
||||
let dtype = data
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let message = data.get("message").and_then(|v| v.as_str()).unwrap_or("");
|
||||
println!(
|
||||
"{} JavaScript {} dialog is open: \"{}\"",
|
||||
color::warning_indicator(),
|
||||
dtype,
|
||||
message
|
||||
);
|
||||
if let Some(default_prompt) = data.get("defaultPrompt").and_then(|v| v.as_str())
|
||||
{
|
||||
println!(" Default prompt text: \"{}\"", default_prompt);
|
||||
}
|
||||
println!(" Use `dialog accept [text]` or `dialog dismiss` to resolve it");
|
||||
} else {
|
||||
println!("{} No dialog is currently open", color::success_indicator());
|
||||
}
|
||||
print_warning(resp);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Some(output) = format_stream_status_text(action, data) {
|
||||
println!("{}", output);
|
||||
return;
|
||||
}
|
||||
if action == Some("storage_get") {
|
||||
if let Some(output) = format_storage_text(data) {
|
||||
println!("{}", output);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Inspect response (check before generic URL handler since it also has a "url" field)
|
||||
if action == Some("inspect") {
|
||||
let opened = data
|
||||
.get("opened")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
if opened {
|
||||
if let Some(url) = data.get("url").and_then(|v| v.as_str()) {
|
||||
println!("{} Opened DevTools: {}", color::success_indicator(), url);
|
||||
} else {
|
||||
println!("{} Opened DevTools", color::success_indicator());
|
||||
}
|
||||
} else if let Some(err) = data.get("error").and_then(|v| v.as_str()) {
|
||||
eprintln!("Could not open DevTools: {}", err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Navigation response
|
||||
if let Some(url) = data.get("url").and_then(|v| v.as_str()) {
|
||||
if let Some(title) = data.get("title").and_then(|v| v.as_str()) {
|
||||
@@ -110,6 +236,10 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
println!("{}", url);
|
||||
return;
|
||||
}
|
||||
if let Some(cdp_url) = data.get("cdpUrl").and_then(|v| v.as_str()) {
|
||||
println!("{}", cdp_url);
|
||||
return;
|
||||
}
|
||||
// Diff responses -- route by action to avoid fragile shape probing
|
||||
if let Some(obj) = data.as_object() {
|
||||
match action {
|
||||
@@ -322,16 +452,25 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
.get("resourceType")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
println!("{} {} ({})", method, url, resource_type);
|
||||
let request_id = req.get("requestId").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let status = req.get("status").and_then(|v| v.as_i64());
|
||||
match status {
|
||||
Some(s) => println!(
|
||||
"[{}] {} {} ({}) {}",
|
||||
request_id, method, url, resource_type, s
|
||||
),
|
||||
None => println!("[{}] {} {} ({})", request_id, method, url, resource_type),
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Cleared (cookies or request log)
|
||||
// Cleared (cookies, console, or request log)
|
||||
if let Some(cleared) = data.get("cleared").and_then(|v| v.as_bool()) {
|
||||
if cleared {
|
||||
let label = match action {
|
||||
Some("cookies_clear") => "Cookies cleared",
|
||||
Some("console") => "Console log cleared",
|
||||
_ => "Request log cleared",
|
||||
};
|
||||
println!("{} {}", color::success_indicator(), label);
|
||||
@@ -404,13 +543,16 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
println!("{} {}", color::success_indicator(), label);
|
||||
return;
|
||||
}
|
||||
// Recording start (has "started" field)
|
||||
// Started actions (profiling, HAR, recording)
|
||||
if let Some(started) = data.get("started").and_then(|v| v.as_bool()) {
|
||||
if started {
|
||||
match action {
|
||||
Some("profiler_start") => {
|
||||
println!("{} Profiling started", color::success_indicator());
|
||||
}
|
||||
Some("har_start") => {
|
||||
println!("{} HAR recording started", color::success_indicator());
|
||||
}
|
||||
_ => {
|
||||
if let Some(path) = data.get("path").and_then(|v| v.as_str()) {
|
||||
println!("{} Recording started: {}", color::success_indicator(), path);
|
||||
@@ -539,9 +681,12 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
data.get("eventCount").and_then(|c| c.as_u64()).unwrap_or(0)
|
||||
),
|
||||
"har_stop" => println!(
|
||||
"{} HAR saved to {}",
|
||||
"{} HAR saved to {} ({} requests)",
|
||||
color::success_indicator(),
|
||||
color::green(path)
|
||||
color::green(path),
|
||||
data.get("requestCount")
|
||||
.and_then(|c| c.as_u64())
|
||||
.unwrap_or(0)
|
||||
),
|
||||
"download" | "waitfordownload" => println!(
|
||||
"{} Download saved to {}",
|
||||
@@ -812,6 +957,14 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
// Default success
|
||||
println!("{} Done", color::success_indicator());
|
||||
}
|
||||
|
||||
print_warning(resp);
|
||||
}
|
||||
|
||||
fn print_warning(resp: &Response) {
|
||||
if let Some(ref warning) = resp.warning {
|
||||
eprintln!("{} {}", color::warning_indicator(), warning);
|
||||
}
|
||||
}
|
||||
|
||||
/// Print command-specific help. Returns true if help was printed, false if command unknown.
|
||||
@@ -1292,12 +1445,18 @@ Modes:
|
||||
--url <pattern> Wait for URL to match pattern
|
||||
--load <state> Wait for load state (load, domcontentloaded, networkidle)
|
||||
--fn <expression> Wait for JavaScript expression to be truthy
|
||||
--text <text> Wait for text to appear on page
|
||||
--text <text> Wait for text to appear on page (substring match)
|
||||
--download [path] Wait for a download to complete (optionally save to path)
|
||||
|
||||
Download Options (with --download):
|
||||
--timeout <ms> Timeout in milliseconds for download to start
|
||||
|
||||
Wait for text to disappear:
|
||||
Use --fn or --state hidden to wait for text or elements to go away:
|
||||
wait --fn "!document.body.innerText.includes('Loading...')"
|
||||
wait "#spinner" --state hidden
|
||||
wait @e5 --state detached
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
@@ -1311,6 +1470,7 @@ Examples:
|
||||
agent-browser wait --text "Welcome back"
|
||||
agent-browser wait --download ./file.pdf
|
||||
agent-browser wait --download ./report.xlsx --timeout 30000
|
||||
agent-browser wait --fn "!document.body.innerText.includes('Loading...')"
|
||||
"##
|
||||
}
|
||||
|
||||
@@ -1319,7 +1479,7 @@ Examples:
|
||||
r##"
|
||||
agent-browser screenshot - Take a screenshot
|
||||
|
||||
Usage: agent-browser screenshot [path]
|
||||
Usage: agent-browser screenshot [selector] [path]
|
||||
|
||||
Captures a screenshot of the current page. If no path is provided,
|
||||
saves to a temporary directory with a generated filename.
|
||||
@@ -1330,6 +1490,13 @@ 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.
|
||||
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)
|
||||
(or AGENT_BROWSER_SCREENSHOT_QUALITY env)
|
||||
--screenshot-format <fmt> Image format: png (default) or jpeg
|
||||
(or AGENT_BROWSER_SCREENSHOT_FORMAT env)
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
@@ -1342,6 +1509,8 @@ Examples:
|
||||
agent-browser screenshot --annotate # Labeled screenshot + legend
|
||||
agent-browser screenshot --annotate ./page.png # Save annotated screenshot
|
||||
agent-browser screenshot --annotate --json # JSON output with annotations
|
||||
agent-browser screenshot --screenshot-dir ./shots # Save to custom directory
|
||||
agent-browser screenshot --screenshot-format jpeg --screenshot-quality 80
|
||||
"##
|
||||
}
|
||||
"pdf" => {
|
||||
@@ -1375,7 +1544,6 @@ Designed for AI agents to understand page structure.
|
||||
|
||||
Options:
|
||||
-i, --interactive Only include interactive elements
|
||||
-C, --cursor Include cursor-interactive elements (cursor:pointer, onclick, tabindex)
|
||||
-c, --compact Remove empty structural elements
|
||||
-d, --depth <n> Limit tree depth
|
||||
-s, --selector <sel> Scope snapshot to CSS selector
|
||||
@@ -1387,7 +1555,6 @@ Global Options:
|
||||
Examples:
|
||||
agent-browser snapshot
|
||||
agent-browser snapshot -i
|
||||
agent-browser snapshot -i -C # Interactive + cursor-interactive elements
|
||||
agent-browser snapshot --compact --depth 5
|
||||
agent-browser snapshot -s "#main-content"
|
||||
"##
|
||||
@@ -1429,12 +1596,15 @@ Examples:
|
||||
r##"
|
||||
agent-browser close - Close the browser
|
||||
|
||||
Usage: agent-browser close
|
||||
Usage: agent-browser close [options]
|
||||
|
||||
Closes the browser instance for the current session.
|
||||
|
||||
Aliases: quit, exit
|
||||
|
||||
Options:
|
||||
--all Close all active sessions
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
@@ -1442,6 +1612,26 @@ Global Options:
|
||||
Examples:
|
||||
agent-browser close
|
||||
agent-browser close --session mysession
|
||||
agent-browser close --all
|
||||
"##
|
||||
}
|
||||
|
||||
// === Inspect ===
|
||||
"inspect" => {
|
||||
r##"
|
||||
agent-browser inspect - Open Chrome DevTools for the active page
|
||||
|
||||
Starts a local WebSocket proxy and opens Chrome's DevTools frontend in your
|
||||
default browser. The proxy routes DevTools traffic through the daemon's
|
||||
existing CDP connection, so both DevTools and agent-browser commands work
|
||||
simultaneously.
|
||||
|
||||
Usage: agent-browser inspect
|
||||
|
||||
Examples:
|
||||
agent-browser open example.com
|
||||
agent-browser inspect # opens DevTools in your browser
|
||||
agent-browser click "Submit" # commands still work while DevTools is open
|
||||
"##
|
||||
}
|
||||
|
||||
@@ -1464,6 +1654,7 @@ Subcommands:
|
||||
count <selector> Count matching elements
|
||||
box <selector> Get bounding box (x, y, width, height)
|
||||
styles <selector> Get computed styles of elements
|
||||
cdp-url Get Chrome DevTools Protocol WebSocket URL
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
@@ -1590,7 +1781,7 @@ Usage: agent-browser set <setting> [args]
|
||||
Configures various browser settings and emulation options.
|
||||
|
||||
Settings:
|
||||
viewport <w> <h> Set viewport size
|
||||
viewport <w> <h> [scale] Set viewport size (scale = deviceScaleFactor, e.g. 2 for retina)
|
||||
device <name> Emulate device (e.g., "iPhone 12")
|
||||
geo <lat> <lng> Set geolocation
|
||||
offline [on|off] Toggle offline mode
|
||||
@@ -1605,6 +1796,7 @@ Global Options:
|
||||
|
||||
Examples:
|
||||
agent-browser set viewport 1920 1080
|
||||
agent-browser set viewport 1920 1080 2 # 2x retina
|
||||
agent-browser set device "iPhone 12"
|
||||
agent-browser set geo 37.7749 -122.4194
|
||||
agent-browser set offline on
|
||||
@@ -1632,6 +1824,11 @@ Subcommands:
|
||||
requests [options] List captured requests
|
||||
--clear Clear request log
|
||||
--filter <pattern> Filter by URL pattern
|
||||
--type <types> Filter by resource type (comma-separated: xhr,fetch,document)
|
||||
--method <method> Filter by HTTP method (GET, POST, etc.)
|
||||
--status <code> Filter by status (200, 2xx, 400-499)
|
||||
request <requestId> View full request/response detail (including body)
|
||||
har <start|stop> [path] Record and export a HAR file
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
@@ -1643,7 +1840,12 @@ Examples:
|
||||
agent-browser network unroute
|
||||
agent-browser network requests
|
||||
agent-browser network requests --filter "api"
|
||||
agent-browser network requests --type xhr,fetch
|
||||
agent-browser network requests --method POST --status 2xx
|
||||
agent-browser network requests --clear
|
||||
agent-browser network request 1234.5
|
||||
agent-browser network har start
|
||||
agent-browser network har stop ./capture.har
|
||||
"##
|
||||
}
|
||||
|
||||
@@ -1816,7 +2018,7 @@ Usage: agent-browser auth <subcommand> [args]
|
||||
|
||||
Subcommands:
|
||||
save <name> Save credentials for a login profile
|
||||
login <name> Login using saved credentials
|
||||
login <name> Login using saved credentials (waits for form fields)
|
||||
list List saved profiles (names and URLs only)
|
||||
show <name> Show profile metadata (no passwords)
|
||||
delete <name> Delete a saved profile
|
||||
@@ -1830,6 +2032,10 @@ Save Options:
|
||||
--password-selector <s> Custom CSS selector for password field
|
||||
--submit-selector <s> Custom CSS selector for submit button
|
||||
|
||||
Login behavior:
|
||||
auth login waits for form selectors to appear before filling/clicking.
|
||||
Selector wait timeout follows the default action timeout.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
@@ -1870,13 +2076,14 @@ Examples:
|
||||
r##"
|
||||
agent-browser dialog - Handle browser dialogs
|
||||
|
||||
Usage: agent-browser dialog <response> [text]
|
||||
Usage: agent-browser dialog <accept|dismiss|status> [text]
|
||||
|
||||
Respond to browser dialogs (alert, confirm, prompt).
|
||||
Respond to or check for browser dialogs (alert, confirm, prompt).
|
||||
|
||||
Operations:
|
||||
accept [text] Accept dialog, optionally with prompt text
|
||||
dismiss Dismiss/cancel dialog
|
||||
status Check if a dialog is currently open
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
@@ -1886,6 +2093,7 @@ Examples:
|
||||
agent-browser dialog accept
|
||||
agent-browser dialog accept "my input"
|
||||
agent-browser dialog dismiss
|
||||
agent-browser dialog status
|
||||
"##
|
||||
}
|
||||
|
||||
@@ -1896,7 +2104,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
|
||||
@@ -1963,7 +2171,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.
|
||||
|
||||
@@ -2053,6 +2261,33 @@ Examples:
|
||||
"##
|
||||
}
|
||||
|
||||
// === Clipboard ===
|
||||
"clipboard" => {
|
||||
r##"
|
||||
agent-browser clipboard - Read and write clipboard
|
||||
|
||||
Usage: agent-browser clipboard <operation> [text]
|
||||
|
||||
Read from or write to the browser clipboard.
|
||||
|
||||
Operations:
|
||||
read Read text from clipboard
|
||||
write <text> Write text to clipboard
|
||||
copy Copy current selection (simulates Ctrl+C)
|
||||
paste Paste from clipboard (simulates Ctrl+V)
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser clipboard read
|
||||
agent-browser clipboard write "Hello, World!"
|
||||
agent-browser clipboard copy
|
||||
agent-browser clipboard paste
|
||||
"##
|
||||
}
|
||||
|
||||
// === State ===
|
||||
"state" => {
|
||||
r##"
|
||||
@@ -2141,6 +2376,56 @@ Examples:
|
||||
"##
|
||||
}
|
||||
|
||||
// === Upgrade ===
|
||||
"upgrade" => {
|
||||
r##"
|
||||
agent-browser upgrade - Upgrade to the latest version
|
||||
|
||||
Usage: agent-browser upgrade
|
||||
|
||||
Detects the current installation method (npm, Homebrew, or Cargo) and runs
|
||||
the appropriate update command. Displays the version change on success, or
|
||||
informs you if you are already on the latest version.
|
||||
|
||||
Examples:
|
||||
agent-browser upgrade
|
||||
"##
|
||||
}
|
||||
|
||||
// === Dashboard ===
|
||||
"dashboard" => {
|
||||
r##"
|
||||
agent-browser dashboard - Observability dashboard
|
||||
|
||||
Usage: agent-browser dashboard [start|stop|install] [options]
|
||||
|
||||
Manage the observability dashboard, a local web UI that shows live
|
||||
browser viewports and command activity feeds for all sessions.
|
||||
|
||||
Subcommands:
|
||||
start [--port <n>] Start the dashboard server (default port: 4848)
|
||||
stop Stop the dashboard server
|
||||
install Download and install the dashboard to ~/.agent-browser/dashboard/
|
||||
|
||||
Running 'agent-browser dashboard' with no subcommand is equivalent to 'dashboard start'.
|
||||
|
||||
The dashboard runs as a standalone background process, independent of
|
||||
browser sessions. All sessions automatically stream to the dashboard.
|
||||
|
||||
Options:
|
||||
--port <n> Port for the dashboard server (default: 4848)
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
|
||||
Examples:
|
||||
agent-browser dashboard install
|
||||
agent-browser dashboard start
|
||||
agent-browser dashboard start --port 8080
|
||||
agent-browser dashboard stop
|
||||
"##
|
||||
}
|
||||
|
||||
// === Connect ===
|
||||
"connect" => {
|
||||
r##"
|
||||
@@ -2181,6 +2466,39 @@ Examples:
|
||||
"##
|
||||
}
|
||||
|
||||
// === Runtime streaming ===
|
||||
"stream" => {
|
||||
r##"
|
||||
agent-browser stream - Manage live WebSocket browser streaming
|
||||
|
||||
Usage:
|
||||
agent-browser stream enable [--port <port>]
|
||||
agent-browser stream disable
|
||||
agent-browser stream status
|
||||
|
||||
Enables or disables the session-scoped WebSocket stream server without restarting
|
||||
an already-running daemon. If --port is omitted, agent-browser binds an
|
||||
available localhost port automatically and reports it back.
|
||||
|
||||
Notes:
|
||||
- 'stream enable' creates the WebSocket server.
|
||||
- WebSocket clients trigger frame streaming automatically.
|
||||
- 'screencast_start' and 'screencast_stop' still control explicit CDP screencasts.
|
||||
- Streaming is always enabled. Set AGENT_BROWSER_STREAM_PORT to bind to a
|
||||
specific port instead of the default OS-assigned port.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
agent-browser stream status
|
||||
agent-browser stream enable
|
||||
agent-browser stream enable --port 9223
|
||||
agent-browser stream disable
|
||||
"##
|
||||
}
|
||||
|
||||
// === iOS Commands ===
|
||||
"tap" => {
|
||||
r##"
|
||||
@@ -2302,6 +2620,38 @@ Examples:
|
||||
"##
|
||||
}
|
||||
|
||||
"batch" => {
|
||||
r##"
|
||||
agent-browser batch - Execute multiple commands from stdin
|
||||
|
||||
Usage: echo '<json>' | agent-browser batch [options]
|
||||
|
||||
Reads a JSON array of commands from stdin and executes them sequentially.
|
||||
Each command is an array of strings matching normal CLI arguments.
|
||||
Results are printed in order, separated by blank lines (or as a JSON array
|
||||
with --json).
|
||||
|
||||
Options:
|
||||
--bail Stop on first error (default: continue all commands)
|
||||
--json Output results as a JSON array
|
||||
|
||||
Input Format:
|
||||
A JSON array of string arrays. Each inner array is one command:
|
||||
[
|
||||
["open", "https://example.com"],
|
||||
["snapshot", "-i"],
|
||||
["click", "@e1"],
|
||||
["fill", "@e2", "test@example.com"],
|
||||
["screenshot", "result.png"]
|
||||
]
|
||||
|
||||
Examples:
|
||||
echo '[["open", "https://example.com"], ["snapshot"]]' | agent-browser batch
|
||||
echo '[["open", "https://example.com"], ["get", "title"]]' | agent-browser batch --json
|
||||
agent-browser batch --bail < commands.json
|
||||
"##
|
||||
}
|
||||
|
||||
_ => return false,
|
||||
};
|
||||
println!("{}", help.trim());
|
||||
@@ -2340,7 +2690,7 @@ Core Commands:
|
||||
snapshot Accessibility tree with refs (for AI)
|
||||
eval <js> Run JavaScript
|
||||
connect <port|url> Connect to browser via CDP
|
||||
close Close browser
|
||||
close [--all] Close browser (--all closes every session)
|
||||
|
||||
Navigation:
|
||||
back Go back
|
||||
@@ -2348,7 +2698,7 @@ Navigation:
|
||||
reload Reload page
|
||||
|
||||
Get Info: agent-browser get <what> [selector]
|
||||
text, html, value, attr <name>, title, url, count, box, styles
|
||||
text, html, value, attr <name>, title, url, count, box, styles, cdp-url
|
||||
|
||||
Check State: agent-browser is <what> <selector>
|
||||
visible, enabled, checked
|
||||
@@ -2368,6 +2718,7 @@ Network: agent-browser network <action>
|
||||
route <url> [--abort|--body <json>]
|
||||
unroute [url]
|
||||
requests [--clear] [--filter <pattern>]
|
||||
har <start|stop> [path]
|
||||
|
||||
Storage:
|
||||
cookies [get|set|clear] Manage cookies (set supports --url, --domain, --path, --httpOnly, --secure, --sameSite, --expires)
|
||||
@@ -2382,17 +2733,28 @@ 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
|
||||
console [--clear] View console logs
|
||||
errors [--clear] View page errors
|
||||
highlight <sel> Highlight element
|
||||
inspect Open Chrome DevTools for the active page
|
||||
clipboard <op> [text] Read/write clipboard (read, write, copy, paste)
|
||||
|
||||
Streaming:
|
||||
stream enable [--port <n>] Start runtime WebSocket streaming for this session
|
||||
stream disable Stop runtime WebSocket streaming
|
||||
stream status Show streaming status and active port
|
||||
|
||||
Batch:
|
||||
batch [--bail] Execute commands from stdin (JSON array of string arrays)
|
||||
--bail stops on first error (default: continue all)
|
||||
|
||||
Auth Vault:
|
||||
auth save <name> [opts] Save auth profile (--url, --username, --password/--password-stdin)
|
||||
auth login <name> Login using saved credentials
|
||||
auth login <name> Login using saved credentials (waits for form fields)
|
||||
auth list List saved auth profiles
|
||||
auth show <name> Show auth profile metadata
|
||||
auth delete <name> Delete auth profile
|
||||
@@ -2405,9 +2767,16 @@ Sessions:
|
||||
session Show current session name
|
||||
session list List active sessions
|
||||
|
||||
Dashboard:
|
||||
dashboard [start] Start the dashboard server (default port: 4848)
|
||||
dashboard start --port <n> Start on a specific port
|
||||
dashboard stop Stop the dashboard server
|
||||
|
||||
Setup:
|
||||
install Install browser binaries
|
||||
install --with-deps Also install system dependencies (Linux)
|
||||
upgrade Upgrade to the latest version
|
||||
dashboard install Install the observability dashboard
|
||||
|
||||
Snapshot Options:
|
||||
-i, --interactive Only interactive elements
|
||||
@@ -2415,40 +2784,50 @@ Snapshot Options:
|
||||
-d, --depth <n> Limit tree depth
|
||||
-s, --selector <sel> Scope to CSS selector
|
||||
|
||||
Authentication:
|
||||
--profile <path> Persist login sessions across restarts (cookies, IndexedDB, cache)
|
||||
(or AGENT_BROWSER_PROFILE env)
|
||||
--session-name <name> Auto-save/restore cookies and localStorage by name
|
||||
(or AGENT_BROWSER_SESSION_NAME env)
|
||||
--state <path> Load saved auth state (cookies + storage) from JSON file
|
||||
(or AGENT_BROWSER_STATE env)
|
||||
--auto-connect Connect to a running Chrome (DEFAULT - shares cookies/sessions)
|
||||
Tip: enable CDP via chrome://inspect/#remote-debugging
|
||||
--launch, --new Launch a fresh browser instead of connecting to existing
|
||||
--headers <json> HTTP headers scoped to URL's origin (e.g., Authorization bearer token)
|
||||
|
||||
Options:
|
||||
--session <name> Isolated session (or AGENT_BROWSER_SESSION env)
|
||||
--profile <path> Persistent browser profile (or AGENT_BROWSER_PROFILE env)
|
||||
--state <path> Load storage state from JSON file (or AGENT_BROWSER_STATE env)
|
||||
--headers <json> HTTP headers scoped to URL's origin (for auth)
|
||||
--executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH)
|
||||
--extension <path> Load browser extensions (repeatable)
|
||||
--args <args> Browser launch args, comma or newline separated (or AGENT_BROWSER_ARGS)
|
||||
e.g., --args "--no-sandbox,--disable-blink-features=AutomationControlled"
|
||||
--user-agent <ua> Custom User-Agent (or AGENT_BROWSER_USER_AGENT)
|
||||
--proxy <server> Proxy server URL (or AGENT_BROWSER_PROXY)
|
||||
e.g., --proxy "http://user:pass@127.0.0.1:7890"
|
||||
--proxy-bypass <hosts> Bypass proxy for these hosts (or AGENT_BROWSER_PROXY_BYPASS)
|
||||
--proxy <server> Proxy server URL (or AGENT_BROWSER_PROXY, HTTP_PROXY, HTTPS_PROXY, ALL_PROXY)
|
||||
Supports authenticated proxies: --proxy "http://user:pass@127.0.0.1:7890"
|
||||
--proxy-bypass <hosts> Bypass proxy for these hosts (or AGENT_BROWSER_PROXY_BYPASS, NO_PROXY)
|
||||
e.g., --proxy-bypass "localhost,*.internal.com"
|
||||
--ignore-https-errors Ignore HTTPS certificate errors
|
||||
--allow-file-access Allow file:// URLs to access local files (Chromium only)
|
||||
-p, --provider <name> Browser provider: ios, browserbase, kernel, browseruse
|
||||
-p, --provider <name> Browser provider: ios, browserbase, kernel, browseruse, browserless, agentcore
|
||||
--device <name> iOS device name (e.g., "iPhone 15 Pro")
|
||||
--json JSON output
|
||||
--full, -f Full page screenshot
|
||||
--annotate Annotated screenshot with numbered labels and legend
|
||||
--headed Show browser window (not headless)
|
||||
--screenshot-dir <path> Default screenshot output directory (or AGENT_BROWSER_SCREENSHOT_DIR)
|
||||
--screenshot-quality <n> JPEG quality 0-100; ignored for PNG (or AGENT_BROWSER_SCREENSHOT_QUALITY)
|
||||
--screenshot-format <fmt> Screenshot format: png, jpeg (or AGENT_BROWSER_SCREENSHOT_FORMAT)
|
||||
--headed Show browser window (not headless) (or AGENT_BROWSER_HEADED env)
|
||||
--cdp <port> Connect via CDP (Chrome DevTools Protocol)
|
||||
--auto-connect Auto-discover and connect to running Chrome
|
||||
--color-scheme <scheme> Color scheme: dark, light, no-preference (or AGENT_BROWSER_COLOR_SCHEME)
|
||||
--download-path <path> Default download directory (or AGENT_BROWSER_DOWNLOAD_PATH)
|
||||
--session-name <name> Auto-save/restore session state (cookies, localStorage)
|
||||
--content-boundaries Wrap page output in boundary markers (or AGENT_BROWSER_CONTENT_BOUNDARIES)
|
||||
--max-output <chars> Truncate page output to N chars (or AGENT_BROWSER_MAX_OUTPUT)
|
||||
--allowed-domains <list> Restrict navigation domains (or AGENT_BROWSER_ALLOWED_DOMAINS)
|
||||
--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)
|
||||
--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)
|
||||
--no-auto-dialog Disable automatic dismissal of alert/beforeunload dialogs (or AGENT_BROWSER_NO_AUTO_DIALOG)
|
||||
--config <path> Use a custom config file (or AGENT_BROWSER_CONFIG env)
|
||||
--debug Debug output
|
||||
--version, -V Show version
|
||||
@@ -2482,20 +2861,20 @@ Environment:
|
||||
AGENT_BROWSER_EXTENSIONS Comma-separated browser extension paths
|
||||
AGENT_BROWSER_HEADED Show browser window (not headless)
|
||||
AGENT_BROWSER_JSON JSON output
|
||||
AGENT_BROWSER_FULL Full page screenshot
|
||||
AGENT_BROWSER_ANNOTATE Annotated screenshot with numbered labels and legend
|
||||
AGENT_BROWSER_DEBUG Debug output
|
||||
AGENT_BROWSER_IGNORE_HTTPS_ERRORS Ignore HTTPS certificate errors
|
||||
AGENT_BROWSER_PROVIDER Browser provider (ios, browserbase, kernel, browseruse)
|
||||
AGENT_BROWSER_PROVIDER Browser provider (ios, browserbase, kernel, browseruse, browserless, agentcore)
|
||||
AGENT_BROWSER_AUTO_CONNECT Auto-discover and connect to running Chrome
|
||||
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_STREAM_PORT Override WebSocket streaming port (default: OS-assigned)
|
||||
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
|
||||
@@ -2504,14 +2883,20 @@ Environment:
|
||||
AGENT_BROWSER_ACTION_POLICY Path to action policy JSON file
|
||||
AGENT_BROWSER_CONFIRM_ACTIONS Action categories requiring confirmation
|
||||
AGENT_BROWSER_CONFIRM_INTERACTIVE Enable interactive confirmation prompts
|
||||
AGENT_BROWSER_NATIVE Use native Rust daemon (experimental, no Node.js/Playwright)
|
||||
AGENT_BROWSER_NO_AUTO_DIALOG Disable automatic dismissal of alert/beforeunload dialogs
|
||||
AGENT_BROWSER_ENGINE Browser engine: chrome (default), lightpanda
|
||||
HTTP_PROXY / HTTPS_PROXY Standard proxy env vars (fallback if AGENT_BROWSER_PROXY not set)
|
||||
ALL_PROXY SOCKS proxy (fallback for proxy)
|
||||
NO_PROXY Bypass proxy for hosts (fallback for proxy-bypass)
|
||||
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):
|
||||
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
|
||||
Install:
|
||||
npm install -g agent-browser # npm
|
||||
brew install agent-browser # Homebrew
|
||||
cargo install agent-browser # Cargo
|
||||
agent-browser install # Download Chrome (first time)
|
||||
|
||||
Examples:
|
||||
agent-browser open example.com
|
||||
@@ -2525,6 +2910,8 @@ Examples:
|
||||
agent-browser wait --load networkidle # Wait for slow pages to load
|
||||
agent-browser --cdp 9222 snapshot # Connect via CDP port
|
||||
agent-browser --auto-connect snapshot # Auto-discover running Chrome
|
||||
agent-browser stream enable # Start runtime streaming on an auto-selected port
|
||||
agent-browser stream status # Inspect runtime streaming state
|
||||
agent-browser --color-scheme dark open example.com # Dark mode
|
||||
agent-browser --profile ~/.myapp open example.com # Persistent profile
|
||||
agent-browser --session-name myapp open example.com # Auto-save/restore state
|
||||
@@ -2625,3 +3012,73 @@ fn print_screenshot_diff(data: &serde_json::Map<String, serde_json::Value>) {
|
||||
pub fn print_version() {
|
||||
println!("agent-browser {}", env!("CARGO_PKG_VERSION"));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::format_storage_text;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_format_stream_status_text_for_enabled_stream() {
|
||||
let data = json!({
|
||||
"enabled": true,
|
||||
"port": 9223,
|
||||
"connected": true,
|
||||
"screencasting": false
|
||||
});
|
||||
|
||||
let rendered = super::format_stream_status_text(Some("stream_status"), &data).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
rendered,
|
||||
"Streaming enabled on ws://127.0.0.1:9223\nConnected: true\nScreencasting: false"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_stream_status_text_for_disabled_stream() {
|
||||
let data =
|
||||
json!({ "enabled": false, "port": null, "connected": false, "screencasting": false });
|
||||
|
||||
let rendered = super::format_stream_status_text(Some("stream_status"), &data).unwrap();
|
||||
|
||||
assert_eq!(rendered, "Streaming disabled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_storage_text_for_all_entries() {
|
||||
let data = json!({
|
||||
"data": {
|
||||
"token": "abc123",
|
||||
"user": "alice"
|
||||
}
|
||||
});
|
||||
|
||||
let rendered = format_storage_text(&data).unwrap();
|
||||
|
||||
assert_eq!(rendered, "token: abc123\nuser: alice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_storage_text_for_key_lookup() {
|
||||
let data = json!({
|
||||
"key": "token",
|
||||
"value": "abc123"
|
||||
});
|
||||
|
||||
let rendered = format_storage_text(&data).unwrap();
|
||||
|
||||
assert_eq!(rendered, "token: abc123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_storage_text_for_empty_store() {
|
||||
let data = json!({
|
||||
"data": {}
|
||||
});
|
||||
|
||||
let rendered = format_storage_text(&data).unwrap();
|
||||
|
||||
assert_eq!(rendered, "No storage entries");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
/// Global mutex shared across all test modules to prevent parallel tests from
|
||||
/// interfering with each other when mutating environment variables.
|
||||
pub static ENV_MUTEX: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// RAII guard that locks [`ENV_MUTEX`] and restores environment variables on drop.
|
||||
pub struct EnvGuard<'a> {
|
||||
_lock: MutexGuard<'a, ()>,
|
||||
vars: Vec<(String, Option<String>)>,
|
||||
}
|
||||
|
||||
impl<'a> EnvGuard<'a> {
|
||||
pub fn new(var_names: &[&str]) -> Self {
|
||||
let lock = ENV_MUTEX.lock().unwrap();
|
||||
let vars = var_names
|
||||
.iter()
|
||||
.map(|&name| (name.to_string(), std::env::var(name).ok()))
|
||||
.collect();
|
||||
Self { _lock: lock, vars }
|
||||
}
|
||||
|
||||
pub fn set(&self, name: &str, value: &str) {
|
||||
debug_assert!(
|
||||
self.vars.iter().any(|(n, _)| n == name),
|
||||
"EnvGuard::set called with unregistered var: {name}"
|
||||
);
|
||||
std::env::set_var(name, value);
|
||||
}
|
||||
|
||||
pub fn remove(&self, name: &str) {
|
||||
debug_assert!(
|
||||
self.vars.iter().any(|(n, _)| n == name),
|
||||
"EnvGuard::remove called with unregistered var: {name}"
|
||||
);
|
||||
std::env::remove_var(name);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
for (name, value) in &self.vars {
|
||||
match value {
|
||||
Some(v) => std::env::set_var(name, v),
|
||||
None => std::env::remove_var(name),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
use crate::color;
|
||||
use std::path::Path;
|
||||
use std::process::{exit, Command, Stdio};
|
||||
|
||||
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const NPM_REGISTRY_URL: &str = "https://registry.npmjs.org/agent-browser/latest";
|
||||
|
||||
enum InstallMethod {
|
||||
Npm,
|
||||
Pnpm,
|
||||
Yarn,
|
||||
Bun,
|
||||
Homebrew,
|
||||
Cargo,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
async fn fetch_latest_version() -> Result<String, String> {
|
||||
let resp = reqwest::get(NPM_REGISTRY_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))?;
|
||||
|
||||
body.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "No version field in registry response".to_string())
|
||||
}
|
||||
|
||||
/// Parse the `.install-method` marker written by postinstall.js.
|
||||
fn read_install_method_marker(exe_dir: &Path) -> Option<InstallMethod> {
|
||||
let contents = std::fs::read_to_string(exe_dir.join(".install-method")).ok()?;
|
||||
match contents.trim() {
|
||||
"npm" => Some(InstallMethod::Npm),
|
||||
"pnpm" => Some(InstallMethod::Pnpm),
|
||||
"yarn" => Some(InstallMethod::Yarn),
|
||||
"bun" => Some(InstallMethod::Bun),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_install_method() -> InstallMethod {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
// Resolve symlinks to find the real binary location
|
||||
let real_path = exe.canonicalize().unwrap_or(exe);
|
||||
|
||||
// Preferred: read the marker file written at install time
|
||||
if let Some(dir) = real_path.parent() {
|
||||
if let Some(method) = read_install_method_marker(dir) {
|
||||
return method;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: infer from executable path
|
||||
let path_str = real_path.to_string_lossy();
|
||||
|
||||
if path_str.contains("/.cargo/bin/") || path_str.contains("\\.cargo\\bin\\") {
|
||||
return InstallMethod::Cargo;
|
||||
}
|
||||
|
||||
if path_str.contains("/Cellar/agent-browser/")
|
||||
|| path_str.contains("/homebrew/")
|
||||
|| path_str.contains("/linuxbrew/")
|
||||
{
|
||||
return InstallMethod::Homebrew;
|
||||
}
|
||||
|
||||
if path_str.contains("/pnpm/") || path_str.contains("/pnpm-global/") {
|
||||
return InstallMethod::Pnpm;
|
||||
}
|
||||
|
||||
if path_str.contains("/.yarn/") || path_str.contains("/yarn/global/") {
|
||||
return InstallMethod::Yarn;
|
||||
}
|
||||
|
||||
if path_str.contains("/.bun/") {
|
||||
return InstallMethod::Bun;
|
||||
}
|
||||
|
||||
if path_str.contains("node_modules/agent-browser")
|
||||
|| path_str.contains("node_modules\\agent-browser")
|
||||
{
|
||||
return InstallMethod::Npm;
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: probe package managers via subprocess
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
{
|
||||
if command_succeeds("brew", &["list", "agent-browser"]) {
|
||||
return InstallMethod::Homebrew;
|
||||
}
|
||||
}
|
||||
|
||||
if command_output_contains(
|
||||
"pnpm",
|
||||
&["list", "-g", "agent-browser", "--depth=0"],
|
||||
"agent-browser",
|
||||
) {
|
||||
return InstallMethod::Pnpm;
|
||||
}
|
||||
|
||||
if command_output_contains("yarn", &["global", "list", "--depth=0"], "agent-browser") {
|
||||
return InstallMethod::Yarn;
|
||||
}
|
||||
|
||||
if command_output_contains("bun", &["pm", "ls", "-g"], "agent-browser") {
|
||||
return InstallMethod::Bun;
|
||||
}
|
||||
|
||||
if command_succeeds("npm", &["list", "-g", "agent-browser", "--depth=0"]) {
|
||||
return InstallMethod::Npm;
|
||||
}
|
||||
|
||||
InstallMethod::Unknown
|
||||
}
|
||||
|
||||
fn command_succeeds(cmd: &str, args: &[&str]) -> bool {
|
||||
Command::new(cmd)
|
||||
.args(args)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn command_output_contains(cmd: &str, args: &[&str], needle: &str) -> bool {
|
||||
Command::new(cmd)
|
||||
.args(args)
|
||||
.stderr(Stdio::null())
|
||||
.output()
|
||||
.map(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).contains(needle))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn run_upgrade_command(method: &InstallMethod) -> bool {
|
||||
let (cmd, args, display): (&str, &[&str], &str) = match method {
|
||||
InstallMethod::Npm => (
|
||||
"npm",
|
||||
&["install", "-g", "agent-browser@latest"],
|
||||
"npm install -g agent-browser@latest",
|
||||
),
|
||||
InstallMethod::Pnpm => (
|
||||
"pnpm",
|
||||
&["add", "-g", "agent-browser@latest"],
|
||||
"pnpm add -g agent-browser@latest",
|
||||
),
|
||||
// NOTE: `yarn global` is Yarn Classic (v1) only; Yarn Berry (v2+) removed it.
|
||||
// Users on Yarn v2+ won't reach this path — detection falls through to Unknown.
|
||||
InstallMethod::Yarn => (
|
||||
"yarn",
|
||||
&["global", "add", "agent-browser@latest"],
|
||||
"yarn global add agent-browser@latest",
|
||||
),
|
||||
InstallMethod::Bun => (
|
||||
"bun",
|
||||
&["install", "-g", "agent-browser@latest"],
|
||||
"bun install -g agent-browser@latest",
|
||||
),
|
||||
InstallMethod::Homebrew => (
|
||||
"brew",
|
||||
&["upgrade", "agent-browser"],
|
||||
"brew upgrade agent-browser",
|
||||
),
|
||||
InstallMethod::Cargo => (
|
||||
"cargo",
|
||||
&["install", "agent-browser", "--force"],
|
||||
"cargo install agent-browser --force",
|
||||
),
|
||||
InstallMethod::Unknown => return false,
|
||||
};
|
||||
|
||||
println!("Running: {}", display);
|
||||
Command::new(cmd)
|
||||
.args(args)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn run_upgrade() {
|
||||
let current = CURRENT_VERSION;
|
||||
|
||||
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 latest = match rt.block_on(fetch_latest_version()) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{} Could not check latest version: {}",
|
||||
color::warning_indicator(),
|
||||
e
|
||||
);
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
|
||||
if !latest.is_empty() && current == latest.as_str() {
|
||||
println!(
|
||||
"{} agent-browser is already at the latest version (v{})",
|
||||
color::success_indicator(),
|
||||
current
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let method = detect_install_method();
|
||||
|
||||
let method_name = match &method {
|
||||
InstallMethod::Npm => "npm",
|
||||
InstallMethod::Pnpm => "pnpm",
|
||||
InstallMethod::Yarn => "yarn",
|
||||
InstallMethod::Bun => "bun",
|
||||
InstallMethod::Homebrew => "Homebrew",
|
||||
InstallMethod::Cargo => "Cargo",
|
||||
InstallMethod::Unknown => "",
|
||||
};
|
||||
|
||||
if matches!(method, InstallMethod::Unknown) {
|
||||
eprintln!(
|
||||
"{} Could not detect installation method.",
|
||||
color::error_indicator()
|
||||
);
|
||||
eprintln!(" To update manually, run one of:");
|
||||
eprintln!(" npm install -g agent-browser@latest # npm");
|
||||
eprintln!(" pnpm add -g agent-browser@latest # pnpm");
|
||||
eprintln!(" yarn global add agent-browser@latest # yarn");
|
||||
eprintln!(" bun install -g agent-browser@latest # bun");
|
||||
eprintln!(" brew upgrade agent-browser # Homebrew");
|
||||
eprintln!(" cargo install agent-browser --force # Cargo");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
println!("Detected installation via {}.", method_name);
|
||||
|
||||
if !latest.is_empty() {
|
||||
println!(
|
||||
"{}",
|
||||
color::cyan(&format!(
|
||||
"Upgrading agent-browser... v{} → v{}",
|
||||
current, latest
|
||||
))
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
color::cyan(&format!("Upgrading agent-browser (v{})...", current))
|
||||
);
|
||||
}
|
||||
|
||||
let success = run_upgrade_command(&method);
|
||||
|
||||
if success {
|
||||
if !latest.is_empty() {
|
||||
println!(
|
||||
"{} Done! v{} → v{}",
|
||||
color::success_indicator(),
|
||||
current,
|
||||
latest
|
||||
);
|
||||
} else {
|
||||
println!("{} Done!", color::success_indicator());
|
||||
}
|
||||
} else {
|
||||
eprintln!("{} Upgrade failed.", color::error_indicator());
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
# Multi-platform Rust cross-compilation image
|
||||
FROM rust:1.85-bookworm
|
||||
FROM rust:1.94-bookworm
|
||||
|
||||
# Install cross-compilation toolchains
|
||||
RUN apt-get update && apt-get install -y \
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -1,83 +0,0 @@
|
||||
import type { MDXComponents } from "mdx/types";
|
||||
import Link from "next/link";
|
||||
import { CodeBlock } from "@/components/code-block";
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function extractText(children: React.ReactNode): string {
|
||||
if (typeof children === "string") return children;
|
||||
if (typeof children === "number") return String(children);
|
||||
if (Array.isArray(children)) return children.map(extractText).join("");
|
||||
if (children && typeof children === "object") {
|
||||
const obj = children as unknown as Record<string, unknown>;
|
||||
if ("props" in obj) {
|
||||
const props = obj.props as { children?: React.ReactNode } | undefined;
|
||||
return extractText(props?.children);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function useMDXComponents(components: MDXComponents): MDXComponents {
|
||||
return {
|
||||
...components,
|
||||
h2: ({ children }: { children?: React.ReactNode }) => {
|
||||
const id = slugify(extractText(children));
|
||||
return <h2 id={id}>{children}</h2>;
|
||||
},
|
||||
h3: ({ children }: { children?: React.ReactNode }) => {
|
||||
const id = slugify(extractText(children));
|
||||
return <h3 id={id}>{children}</h3>;
|
||||
},
|
||||
a: ({
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
href?: string;
|
||||
children?: React.ReactNode;
|
||||
}) => {
|
||||
if (href?.startsWith("/")) {
|
||||
return <Link href={href}>{children}</Link>;
|
||||
}
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
code: ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
if (className) {
|
||||
return <code className={className}>{children}</code>;
|
||||
}
|
||||
return <code>{children}</code>;
|
||||
},
|
||||
pre: async ({ children }: { children?: React.ReactNode }) => {
|
||||
const codeElement = children as React.ReactElement<{
|
||||
className?: string;
|
||||
children?: string;
|
||||
}>;
|
||||
const className = codeElement?.props?.className || "";
|
||||
const lang = className.replace("language-", "") || "bash";
|
||||
const code = codeElement?.props?.children || "";
|
||||
|
||||
return (
|
||||
<CodeBlock
|
||||
code={typeof code === "string" ? code : String(code)}
|
||||
lang={lang}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import createMDX from "@next/mdx";
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"],
|
||||
serverExternalPackages: ["just-bash", "bash-tool"],
|
||||
};
|
||||
|
||||
const withMDX = createMDX({});
|
||||
|
||||
export default withMDX(nextConfig);
|
||||
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"name": "docs",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "portless agent-browser next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.80",
|
||||
"@mdx-js/loader": "^3.1.1",
|
||||
"@mdx-js/mdx": "^3.1.1",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@next/mdx": "^16.1.6",
|
||||
"@streamdown/code": "^1.0.2",
|
||||
"@upstash/ratelimit": "^2.0.8",
|
||||
"@upstash/redis": "^1.36.2",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"@vercel/speed-insights": "^1.3.1",
|
||||
"ai": "^6.0.78",
|
||||
"bash-tool": "^1.3.14",
|
||||
"clsx": "^2.1.1",
|
||||
"geist": "^1.7.0",
|
||||
"just-bash": "^2.9.6",
|
||||
"next": "16.1.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"shiki": "^3.21.0",
|
||||
"streamdown": "^2.1.0",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.1",
|
||||
"tailwindcss": "^4",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
Generated
-8139
File diff suppressed because it is too large
Load Diff
@@ -1,7 +0,0 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,119 +0,0 @@
|
||||
import { readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { convertToModelMessages, stepCountIs, streamText } from "ai";
|
||||
import type { ModelMessage, UIMessage } from "ai";
|
||||
import { createBashTool } from "bash-tool";
|
||||
import { headers } from "next/headers";
|
||||
import { allDocsPages } from "@/lib/docs-navigation";
|
||||
import { mdxToCleanMarkdown } from "@/lib/mdx-to-markdown";
|
||||
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
||||
|
||||
const SYSTEM_PROMPT = `You are a helpful documentation assistant for agent-browser, a headless browser automation CLI designed for AI agents.
|
||||
|
||||
GitHub repository: https://github.com/vercel-labs/agent-browser
|
||||
Documentation: https://agent-browser.dev
|
||||
npm package: agent-browser
|
||||
|
||||
You have access to the full agent-browser documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/ directory.
|
||||
|
||||
When answering questions:
|
||||
- Use the bash tool to list files (ls /workspace/) or search for content (grep -r "keyword" /workspace/)
|
||||
- Use the readFile tool to read specific documentation pages (e.g. readFile with path "/workspace/index.md")
|
||||
- Do NOT use bash to write, create, modify, or delete files (no tee, cat >, sed -i, echo >, cp, mv, rm, mkdir, touch, etc.) — you are read-only
|
||||
- Always base your answers on the actual documentation content
|
||||
- Be concise and accurate
|
||||
- If the docs don't cover a topic, say so honestly
|
||||
- Do NOT include source references or file paths in your response
|
||||
- Do NOT use emojis in your responses`;
|
||||
|
||||
async function loadDocsFiles(): Promise<Record<string, string>> {
|
||||
const files: Record<string, string> = {};
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
allDocsPages.map(async (page) => {
|
||||
const slug = page.href === "/" ? "" : page.href.replace(/^\//, "");
|
||||
const filePath = slug
|
||||
? join(process.cwd(), "src", "app", slug, "page.mdx")
|
||||
: join(process.cwd(), "src", "app", "page.mdx");
|
||||
|
||||
const raw = await readFile(filePath, "utf-8");
|
||||
const md = mdxToCleanMarkdown(raw);
|
||||
const fileName = slug ? `/${slug}.md` : "/index.md";
|
||||
return { fileName, md };
|
||||
}),
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === "fulfilled") {
|
||||
files[result.value.fileName] = result.value.md;
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function addCacheControl(messages: ModelMessage[]): ModelMessage[] {
|
||||
if (messages.length === 0) return messages;
|
||||
return messages.map((message, index) => {
|
||||
if (index === messages.length - 1) {
|
||||
return {
|
||||
...message,
|
||||
providerOptions: {
|
||||
...message.providerOptions,
|
||||
anthropic: { cacheControl: { type: "ephemeral" } },
|
||||
},
|
||||
};
|
||||
}
|
||||
return message;
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const headersList = await headers();
|
||||
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
|
||||
|
||||
const [minuteResult, dailyResult] = await Promise.all([
|
||||
minuteRateLimit.limit(ip),
|
||||
dailyRateLimit.limit(ip),
|
||||
]);
|
||||
|
||||
if (!minuteResult.success || !dailyResult.success) {
|
||||
const isMinuteLimit = !minuteResult.success;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Rate limit exceeded",
|
||||
message: isMinuteLimit
|
||||
? "Too many requests. Please wait a moment before trying again."
|
||||
: "Daily limit reached. Please try again tomorrow.",
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { messages }: { messages: UIMessage[] } = await req.json();
|
||||
|
||||
const docsFiles = await loadDocsFiles();
|
||||
const {
|
||||
tools: { bash, readFile },
|
||||
} = await createBashTool({ files: docsFiles });
|
||||
|
||||
const result = streamText({
|
||||
model: DEFAULT_MODEL,
|
||||
system: SYSTEM_PROMPT,
|
||||
messages: await convertToModelMessages(messages),
|
||||
stopWhen: stepCountIs(5),
|
||||
tools: { bash, readFile },
|
||||
prepareStep: ({ messages: stepMessages }) => ({
|
||||
messages: addCacheControl(stepMessages),
|
||||
}),
|
||||
});
|
||||
|
||||
return result.toUIMessageStreamResponse();
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { mdxToCleanMarkdown } from "@/lib/mdx-to-markdown";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const docPath = searchParams.get("path");
|
||||
|
||||
if (!docPath) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing ?path= parameter" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const normalized = docPath
|
||||
.replace(/^\//, "")
|
||||
.replace(/\.\./g, "")
|
||||
.replace(/[^a-zA-Z0-9/_-]/g, "");
|
||||
|
||||
const slug = normalized;
|
||||
const filePath = slug
|
||||
? join(process.cwd(), "src", "app", ...slug.split("/"), "page.mdx")
|
||||
: join(process.cwd(), "src", "app", "page.mdx");
|
||||
|
||||
try {
|
||||
const raw = await readFile(filePath, "utf-8");
|
||||
const markdown = mdxToCleanMarkdown(raw);
|
||||
|
||||
return new NextResponse(markdown, {
|
||||
headers: {
|
||||
"Content-Type": "text/markdown; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Page not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
|
||||
export const metadata = pageMetadata("cdp-mode")
|
||||
|
||||
# CDP Mode
|
||||
|
||||
Connect to an existing browser via Chrome DevTools Protocol:
|
||||
|
||||
```bash
|
||||
# Start Chrome with: google-chrome --remote-debugging-port=9222
|
||||
|
||||
# Connect once, then run commands without --cdp
|
||||
agent-browser connect 9222
|
||||
agent-browser snapshot
|
||||
agent-browser tab
|
||||
agent-browser close
|
||||
|
||||
# Or pass --cdp on each command
|
||||
agent-browser --cdp 9222 snapshot
|
||||
```
|
||||
|
||||
## Remote WebSocket URLs
|
||||
|
||||
Connect to remote browser services via WebSocket URL:
|
||||
|
||||
```bash
|
||||
# Connect to remote browser service
|
||||
agent-browser --cdp "wss://browser-service.com/cdp?token=..." snapshot
|
||||
|
||||
# Works with any CDP-compatible service
|
||||
agent-browser --cdp "ws://localhost:9222/devtools/browser/abc123" open example.com
|
||||
```
|
||||
|
||||
The `--cdp` flag accepts either:
|
||||
|
||||
- A port number (e.g., `9222`) for local connections via `http://localhost:{port}`
|
||||
- A full WebSocket URL (e.g., `wss://...` or `ws://...`) for remote browser services
|
||||
|
||||
## Auto-Connect
|
||||
|
||||
Use `--auto-connect` to automatically discover and connect to a running Chrome instance without specifying a port:
|
||||
|
||||
```bash
|
||||
# Auto-discover running Chrome with remote debugging
|
||||
agent-browser --auto-connect open example.com
|
||||
agent-browser --auto-connect snapshot
|
||||
|
||||
# Or via environment variable
|
||||
AGENT_BROWSER_AUTO_CONNECT=1 agent-browser snapshot
|
||||
```
|
||||
|
||||
Auto-connect discovers Chrome by:
|
||||
|
||||
1. Reading Chrome's `DevToolsActivePort` file from the default user data directory
|
||||
2. Falling back to probing common debugging ports (9222, 9229)
|
||||
|
||||
This is useful when:
|
||||
|
||||
- Chrome 144+ has remote debugging enabled via `chrome://inspect/#remote-debugging` (which uses a dynamic port)
|
||||
- You want a zero-configuration connection to your existing browser
|
||||
- You don't want to track which port Chrome is using
|
||||
|
||||
## 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:
|
||||
|
||||
```bash
|
||||
agent-browser --cdp 9222 --color-scheme dark open https://example.com
|
||||
agent-browser --cdp 9222 snapshot # stays in dark mode
|
||||
```
|
||||
|
||||
Or set it globally via config or environment variable:
|
||||
|
||||
```bash
|
||||
AGENT_BROWSER_COLOR_SCHEME=dark agent-browser --cdp 9222 open https://example.com
|
||||
```
|
||||
|
||||
## Use cases
|
||||
|
||||
This enables control of:
|
||||
|
||||
- Electron apps
|
||||
- Chrome/Chromium with remote debugging
|
||||
- WebView2 applications
|
||||
- Remote browser services (via WebSocket URL)
|
||||
- Any browser exposing a CDP endpoint
|
||||
|
||||
## Global options
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Option</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>--session <name></code></td><td>Use isolated session</td></tr>
|
||||
<tr><td><code>--profile <path></code></td><td>Persistent browser profile directory</td></tr>
|
||||
<tr><td><code>-p <provider></code></td><td>Cloud browser provider (<code>browserbase</code>, <code>browseruse</code>, <code>kernel</code>)</td></tr>
|
||||
<tr><td><code>--headers <json></code></td><td>HTTP headers scoped to origin</td></tr>
|
||||
<tr><td><code>--executable-path</code></td><td>Custom browser executable</td></tr>
|
||||
<tr><td><code>--args <args></code></td><td>Browser launch args (comma-separated)</td></tr>
|
||||
<tr><td><code>--user-agent <ua></code></td><td>Custom User-Agent string</td></tr>
|
||||
<tr><td><code>--proxy <url></code></td><td>Proxy server URL</td></tr>
|
||||
<tr><td><code>--proxy-bypass <hosts></code></td><td>Hosts to bypass proxy</td></tr>
|
||||
<tr><td><code>--json</code></td><td>JSON output for scripts</td></tr>
|
||||
<tr><td><code>--full, -f</code></td><td>Full page screenshot</td></tr>
|
||||
<tr><td><code>--name, -n</code></td><td>Locator name filter</td></tr>
|
||||
<tr><td><code>--exact</code></td><td>Exact text match</td></tr>
|
||||
<tr><td><code>--headed</code></td><td>Show browser window</td></tr>
|
||||
<tr><td><code>{"--cdp <port|url>"}</code></td><td>CDP connection (port or WebSocket URL)</td></tr>
|
||||
<tr><td><code>--auto-connect</code></td><td>Auto-discover and connect to running Chrome</td></tr>
|
||||
<tr><td><code>--color-scheme <scheme></code></td><td>Persistent color scheme (<code>dark</code>, <code>light</code>, <code>no-preference</code>)</td></tr>
|
||||
<tr><td><code>--debug</code></td><td>Debug output</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Cloud providers
|
||||
|
||||
Use cloud browser infrastructure when local browsers aren't available:
|
||||
|
||||
```bash
|
||||
# Browserbase
|
||||
export BROWSERBASE_API_KEY="your-api-key"
|
||||
export BROWSERBASE_PROJECT_ID="your-project-id"
|
||||
agent-browser -p browserbase open https://example.com
|
||||
|
||||
# Browser Use
|
||||
export BROWSER_USE_API_KEY="your-api-key"
|
||||
agent-browser -p browseruse open https://example.com
|
||||
|
||||
# Kernel
|
||||
export KERNEL_API_KEY="your-api-key"
|
||||
agent-browser -p kernel open https://example.com
|
||||
|
||||
# Or via environment variable
|
||||
export AGENT_BROWSER_PROVIDER=browserbase
|
||||
agent-browser open https://example.com
|
||||
```
|
||||
|
||||
The `-p` flag takes precedence over `AGENT_BROWSER_PROVIDER`.
|
||||
@@ -1,572 +0,0 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
|
||||
export const metadata = pageMetadata("changelog")
|
||||
|
||||
# Changelog
|
||||
|
||||
## v0.16.0
|
||||
|
||||
<p className="text-[#888] text-sm">March 2026</p>
|
||||
|
||||
### New Features
|
||||
|
||||
- **Native Rust daemon (experimental).** A pure Rust daemon that communicates with Chrome directly via the Chrome DevTools Protocol (CDP), eliminating Node.js and Playwright dependencies entirely. Enable with `--native`, `AGENT_BROWSER_NATIVE=1`, or `"native": true` in your config file. Supports 150+ commands with full parity to the default Node.js daemon.
|
||||
|
||||
```bash
|
||||
# Via flag
|
||||
agent-browser --native open example.com
|
||||
|
||||
# Via environment variable
|
||||
export AGENT_BROWSER_NATIVE=1
|
||||
agent-browser open example.com
|
||||
```
|
||||
|
||||
Or add to `agent-browser.json`:
|
||||
|
||||
```json
|
||||
{"native": true}
|
||||
```
|
||||
|
||||
### Architecture
|
||||
|
||||
<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's Supported
|
||||
|
||||
All core commands work in native mode: navigation, interaction (click, fill, type, press, hover, scroll, drag), observation (snapshot, screenshot, eval), state management (cookies, storage, state save/load), tabs, emulation (viewport, device, timezone, locale, geolocation), streaming, diffing, recording, and profiling.
|
||||
|
||||
The native daemon also includes a WebDriver backend for Safari and iOS support.
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- Firefox and WebKit are not yet supported (Chromium and Safari only)
|
||||
- Playwright trace format is not available (uses Chrome's built-in tracing)
|
||||
- HAR export is not available
|
||||
- Network route interception uses CDP Fetch domain instead of Playwright's route API
|
||||
- The native and Node.js daemons share the same session socket. Use `agent-browser close` before switching between modes.
|
||||
|
||||
See the [Native Mode](/native-mode) page for full details.
|
||||
|
||||
---
|
||||
|
||||
## v0.15.0
|
||||
|
||||
<p className="text-[#888] text-sm">February 2026</p>
|
||||
|
||||
### New Features
|
||||
|
||||
- **Authentication vault** -- Store credentials locally (always AES-256-GCM encrypted) and reference them by name. The LLM never sees passwords. Commands: `auth save`, `auth login`, `auth list`, `auth show`, `auth delete`. Passwords can be piped via stdin (`--password-stdin`) to avoid shell history exposure.
|
||||
- **Content boundary markers** -- `--content-boundaries` wraps page-sourced output in structural delimiters with a per-process CSPRNG nonce, so LLMs can distinguish trusted tool output from untrusted page content. In `--json` mode, a `_boundary` object is injected with `nonce` and `origin` fields.
|
||||
- **Domain allowlist** -- `--allowed-domains` restricts navigation, sub-resource requests, WebSocket connections, and EventSource streams to trusted domains. Supports exact match and wildcard prefix patterns (e.g., `*.example.com`).
|
||||
- **Action policy** -- `--action-policy` gates actions using a static JSON policy file with `allow`/`deny` lists across 13 action categories. Auth vault operations bypass policy enforcement.
|
||||
- **Action confirmation** -- `--confirm-actions` requires explicit approval for sensitive action categories. New `confirm` and `deny` commands for orchestrator use. `--confirm-interactive` enables human-in-the-loop terminal prompts (auto-denies if stdin is not a TTY). Pending confirmations auto-deny after 60 seconds.
|
||||
- **Output length limits** -- `--max-output` truncates large page outputs to prevent LLM context flooding.
|
||||
- **`--download-path` option** -- Set a default download directory via flag, `AGENT_BROWSER_DOWNLOAD_PATH` env var, or `downloadPath` config key. Without it, downloads go to a temporary directory deleted when the browser closes.
|
||||
- **`--selector` flag for scroll** -- Scroll within a specific container element instead of the page: `agent-browser scroll down 500 --selector "div.scroll-container"`
|
||||
|
||||
```bash
|
||||
# Auth vault
|
||||
echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin
|
||||
agent-browser auth login github
|
||||
|
||||
# Security flags
|
||||
agent-browser --content-boundaries --allowed-domains "example.com,*.example.com" --max-output 50000 open https://example.com
|
||||
|
||||
# Download path
|
||||
agent-browser --download-path ./downloads open https://example.com
|
||||
|
||||
# Scroll within container
|
||||
agent-browser scroll down 500 --selector "div.content"
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Six new environment variables for security configuration: `AGENT_BROWSER_CONTENT_BOUNDARIES`, `AGENT_BROWSER_MAX_OUTPUT`, `AGENT_BROWSER_ALLOWED_DOMAINS`, `AGENT_BROWSER_ACTION_POLICY`, `AGENT_BROWSER_CONFIRM_ACTIONS`, `AGENT_BROWSER_CONFIRM_INTERACTIVE`.
|
||||
|
||||
---
|
||||
|
||||
## v0.14.0
|
||||
|
||||
<p className="text-[#888] text-sm">February 2026</p>
|
||||
|
||||
### New Features
|
||||
|
||||
- **`keyboard` command** -- Type with real keystrokes, insert text, and press shortcuts at the currently focused element without needing a selector (`keyboard type`, `keyboard inserttext`).
|
||||
- **`--color-scheme` flag** -- Persistent dark/light mode preference across browser sessions via flag or `AGENT_BROWSER_COLOR_SCHEME` env var.
|
||||
|
||||
```bash
|
||||
agent-browser keyboard type "Hello world"
|
||||
agent-browser keyboard inserttext "pasted text"
|
||||
agent-browser --color-scheme dark open https://example.com
|
||||
```
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed IPC EAGAIN errors (os error 35/11) with backpressure-aware socket writes, command serialization, and lowered default Playwright timeout to 25s (configurable via `AGENT_BROWSER_DEFAULT_TIMEOUT`).
|
||||
- Fixed remote debugging (CDP) reconnection.
|
||||
- Fixed state load failing when no browser is running.
|
||||
- Fixed `--annotate` flag warning appearing when not explicitly passed via CLI.
|
||||
|
||||
---
|
||||
|
||||
## v0.13.0
|
||||
|
||||
<p className="text-[#888] text-sm">February 2026</p>
|
||||
|
||||
### New Features
|
||||
|
||||
- **Diff commands** -- Compare snapshots, screenshots, and URLs between page states. Run visual pixel diffs against baseline images, compare accessibility tree snapshots with customizable depth and selectors, and diff two URLs side-by-side with optional screenshot comparison.
|
||||
|
||||
```bash
|
||||
agent-browser diff snapshot
|
||||
agent-browser diff screenshot --baseline before.png
|
||||
agent-browser diff url https://staging.example.com https://prod.example.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## v0.12.0
|
||||
|
||||
<p className="text-[#888] text-sm">February 2026</p>
|
||||
|
||||
### New Features
|
||||
|
||||
- **Annotated screenshots** -- `--annotate` flag overlays numbered labels on interactive elements and prints a legend mapping each label to its element ref. Enables multimodal AI models to reason about visual layout while using the same `@eN` refs for subsequent interactions. Also settable via `AGENT_BROWSER_ANNOTATE` env var.
|
||||
|
||||
```bash
|
||||
agent-browser screenshot --annotate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## v0.11.1
|
||||
|
||||
<p className="text-[#888] text-sm">February 2026</p>
|
||||
|
||||
### Documentation
|
||||
|
||||
- Added documentation for command chaining with `&&` across README, CLI help output, docs, and skill files.
|
||||
|
||||
---
|
||||
|
||||
## v0.11.0
|
||||
|
||||
<p className="text-[#888] text-sm">February 2026</p>
|
||||
|
||||
### New Features
|
||||
|
||||
- **Configuration file support** -- Automatic loading from user (`~/.agent-browser/config.json`) and project (`./agent-browser.json`) directories with priority-based merging.
|
||||
- **Profiler commands** -- Chrome DevTools profiling with `profiler start` and `profiler stop`.
|
||||
- **Browser extension loading** -- `--extension` flag to load browser extensions.
|
||||
- **Storage state management** -- `state save` and `state load` commands for auth state persistence.
|
||||
- **iOS device emulation** -- `--device` flag for device emulation.
|
||||
- **Enhanced click** -- `--new-tab` option for click commands.
|
||||
- **Enhanced find** -- Additional actions and filtering options.
|
||||
- **CDP WebSocket URLs** -- `--cdp` now accepts WebSocket URLs in addition to ports.
|
||||
|
||||
---
|
||||
|
||||
## v0.10.0
|
||||
|
||||
<p className="text-[#888] text-sm">February 2026</p>
|
||||
|
||||
### New Features
|
||||
|
||||
- **Session persistence** - Automatic save/restore of cookies and localStorage across browser restarts using `--session-name` flag
|
||||
- **Encrypted state** - Optional AES-256-GCM encryption for saved session state data
|
||||
- **State management commands** - New commands for listing, showing, renaming, clearing, and cleaning up session state files
|
||||
- **New tab on click** - Added `--new-tab` option for click commands to open links in new tabs
|
||||
|
||||
```bash
|
||||
# Persist session state
|
||||
agent-browser --session-name myapp open https://example.com
|
||||
|
||||
# Manage saved states
|
||||
agent-browser state list
|
||||
agent-browser state show myapp
|
||||
agent-browser state clear myapp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## v0.9.4
|
||||
|
||||
<p className="text-[#888] text-sm">February 2026</p>
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed all Clippy lint warnings in the Rust CLI
|
||||
|
||||
---
|
||||
|
||||
## v0.9.3
|
||||
|
||||
<p className="text-[#888] text-sm">February 2026</p>
|
||||
|
||||
### Improvements
|
||||
|
||||
- Added support for custom executable path in CLI browser launch options
|
||||
- Documentation site UI improvements including a new chat component with sheet-based interface
|
||||
|
||||
---
|
||||
|
||||
## v0.9.2
|
||||
|
||||
<p className="text-[#888] text-sm">February 2026</p>
|
||||
|
||||
### Improvements
|
||||
|
||||
- Migrated documentation site to MDX for improved content authoring
|
||||
- Added AI-powered docs chat feature
|
||||
- Updated README with Homebrew installation instructions for macOS users
|
||||
|
||||
---
|
||||
|
||||
## v0.9.1
|
||||
|
||||
<p className="text-[#888] text-sm">February 2026</p>
|
||||
|
||||
### New Features
|
||||
|
||||
- **`--allow-file-access` flag** - Enable opening and interacting with local `file://` URLs (PDFs, HTML files) by passing Chromium flags that allow JavaScript access to local files
|
||||
- **`-C`/`--cursor` flag for snapshots** - Include cursor-interactive elements like divs with onclick handlers or `cursor:pointer` styles
|
||||
|
||||
```bash
|
||||
agent-browser --allow-file-access open file:///path/to/document.pdf
|
||||
agent-browser snapshot -C
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## v0.9.0
|
||||
|
||||
<p className="text-[#888] text-sm">February 2026</p>
|
||||
|
||||
### New Features
|
||||
|
||||
- **iOS Simulator support** - Mobile Safari testing via Appium with real device and simulator support
|
||||
|
||||
```bash
|
||||
# List available iOS simulators
|
||||
agent-browser device list
|
||||
|
||||
# Launch on iOS device
|
||||
agent-browser -p ios --device "iPhone 16 Pro" open https://example.com
|
||||
|
||||
# Touch interactions
|
||||
agent-browser tap @e1
|
||||
agent-browser swipe up
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## v0.8.10
|
||||
|
||||
<p className="text-[#888] text-sm">January 2026</p>
|
||||
|
||||
### Improvements
|
||||
|
||||
- Added `--stdin` flag for eval command to read JavaScript from stdin, enabling heredoc usage for multiline scripts
|
||||
- Fixed binary permission issues on macOS/Linux when postinstall scripts don't run
|
||||
|
||||
---
|
||||
|
||||
## v0.8.9
|
||||
|
||||
<p className="text-[#888] text-sm">January 2026</p>
|
||||
|
||||
### Improvements
|
||||
|
||||
- Added `--stdin` flag for eval command to read JavaScript from stdin
|
||||
|
||||
---
|
||||
|
||||
## v0.8.8
|
||||
|
||||
<p className="text-[#888] text-sm">January 2026</p>
|
||||
|
||||
### Improvements
|
||||
|
||||
- Added base64 encoding support for the eval command with `-b`/`--base64` flag to avoid shell escaping issues
|
||||
- Updated documentation with AI agent setup instructions
|
||||
|
||||
---
|
||||
|
||||
## v0.8.7
|
||||
|
||||
<p className="text-[#888] text-sm">January 2026</p>
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed browser launch options not being passed correctly when using persistent profiles
|
||||
- Added pre-flight checks for socket path length limits and directory write permissions
|
||||
- Improved error handling to properly exit with failure status when browser launch fails
|
||||
|
||||
---
|
||||
|
||||
## v0.8.6
|
||||
|
||||
<p className="text-[#888] text-sm">January 2026</p>
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Improved daemon connection reliability with automatic retry logic for transient errors
|
||||
- CLI now cleans up stale socket and PID files before starting a new daemon
|
||||
|
||||
---
|
||||
|
||||
## v0.8.5
|
||||
|
||||
<p className="text-[#888] text-sm">January 2026</p>
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed version synchronization to automatically update Cargo.lock alongside Cargo.toml during releases
|
||||
- Made the CLI binary executable in the npm package
|
||||
|
||||
---
|
||||
|
||||
## v0.8.4
|
||||
|
||||
<p className="text-[#888] text-sm">January 2026</p>
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed "Daemon not found" error when running through AI agents by resolving symlinks in the executable path
|
||||
|
||||
---
|
||||
|
||||
## v0.8.3
|
||||
|
||||
<p className="text-[#888] text-sm">January 2026</p>
|
||||
|
||||
### Improvements
|
||||
|
||||
- Replaced shell-based CLI wrappers with a cross-platform Node.js wrapper to enable npx support on Windows
|
||||
- Added postinstall logic to patch npm bin entry on global installs for zero-overhead native binary invocation
|
||||
- Added CI tests to verify global installation across all platforms
|
||||
|
||||
---
|
||||
|
||||
## v0.8.2
|
||||
|
||||
<p className="text-[#888] text-sm">January 2026</p>
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed the Windows CMD wrapper to use the native binary directly instead of routing through Node.js
|
||||
- Added retry logic to CI install command for transient browser installation failures
|
||||
|
||||
---
|
||||
|
||||
## v0.8.1
|
||||
|
||||
<p className="text-[#888] text-sm">January 2026</p>
|
||||
|
||||
### Improvements
|
||||
|
||||
- Improved release workflow to validate binary file sizes and ensure binaries are executable after npm install
|
||||
- Updated documentation site with a new mobile navigation system
|
||||
|
||||
---
|
||||
|
||||
## v0.8.0
|
||||
|
||||
<p className="text-[#888] text-sm">January 2026</p>
|
||||
|
||||
### New Features
|
||||
|
||||
- **Kernel cloud browser provider** - Connect to Kernel (kernel.sh) for remote browser infrastructure with stealth mode and persistent profiles
|
||||
|
||||
```bash
|
||||
# Via -p flag
|
||||
agent-browser -p kernel open https://example.com
|
||||
|
||||
# Via environment variable
|
||||
export AGENT_BROWSER_PROVIDER=kernel
|
||||
export KERNEL_API_KEY=your-api-key
|
||||
agent-browser open https://example.com
|
||||
|
||||
# With persistent profile
|
||||
export KERNEL_PROFILE_NAME=my-profile
|
||||
agent-browser open https://example.com
|
||||
```
|
||||
|
||||
- **Ignore HTTPS certificate errors** - New flag for working with self-signed certificates and development environments
|
||||
|
||||
```bash
|
||||
agent-browser --ignore-https-errors open https://localhost:3000
|
||||
```
|
||||
|
||||
- **Enhanced cookie management** - Extended `cookies set` command with additional flags for setting cookies before page load
|
||||
|
||||
```bash
|
||||
agent-browser cookies set session_id "abc123" --url https://app.example.com --httpOnly --secure
|
||||
agent-browser cookies set token "xyz" --domain .example.com --path /api --expires 1735689600
|
||||
```
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed tab list command not recognizing new pages opened via clicks or `target="_blank"` links
|
||||
- Fixed `check` command hanging indefinitely
|
||||
- Fixed `set device` not applying deviceScaleFactor - HiDPI screenshots now work correctly
|
||||
- Fixed state load and profile persistence not working in v0.7.6
|
||||
- Screenshots now save to temp directory when no path is provided
|
||||
|
||||
### Security
|
||||
|
||||
- Daemon and stream server now reject cross-origin connections
|
||||
|
||||
---
|
||||
|
||||
## v0.7.1
|
||||
|
||||
<p className="text-[#888] text-sm">January 2026</p>
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Fix native binary distribution** - Native binaries for all platforms (Linux x64/arm64, macOS x64/arm64, Windows x64) are now included in the npm package. Previously, the release workflow published to npm before building binaries, causing "No binary found" errors on installation.
|
||||
|
||||
---
|
||||
|
||||
## v0.7.0
|
||||
|
||||
<p className="text-[#888] text-sm">January 2026</p>
|
||||
|
||||
### New Features
|
||||
|
||||
- **Cloud browser providers** - Connect to Browserbase or Browser Use for remote browser infrastructure
|
||||
|
||||
```bash
|
||||
# Via -p flag (recommended)
|
||||
agent-browser -p browserbase open https://example.com
|
||||
agent-browser -p browseruse open https://example.com
|
||||
|
||||
# Via environment variable
|
||||
export AGENT_BROWSER_PROVIDER=browserbase
|
||||
agent-browser open https://example.com
|
||||
```
|
||||
|
||||
- **Persistent browser profiles** - Store cookies, localStorage, and login sessions across browser restarts
|
||||
|
||||
```bash
|
||||
agent-browser --profile ~/.myapp-profile open myapp.com
|
||||
# Login persists across restarts
|
||||
```
|
||||
|
||||
- **Remote CDP WebSocket URLs** - Connect to remote browser services via WebSocket
|
||||
|
||||
```bash
|
||||
agent-browser --cdp "wss://browser-service.com/cdp?token=..." snapshot
|
||||
```
|
||||
|
||||
- **`download` command** - Trigger downloads and wait for completion
|
||||
|
||||
```bash
|
||||
agent-browser download @e1 ./file.pdf
|
||||
agent-browser wait --download ./output.zip --timeout 30000
|
||||
```
|
||||
|
||||
- **Browser launch configuration** - Fine-grained control over browser startup
|
||||
|
||||
```bash
|
||||
agent-browser --args "--disable-gpu,--no-sandbox" open example.com
|
||||
agent-browser --user-agent "Custom UA" open example.com
|
||||
agent-browser --proxy-bypass "localhost,*.internal" open example.com
|
||||
```
|
||||
|
||||
- **Enhanced skills** - Hierarchical structure with references and templates for Claude Code
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Screenshot command now supports refs and has improved error messages
|
||||
- WebSocket URLs work in `connect` command
|
||||
- Fixed socket file location (uses `~/.agent-browser` instead of TMPDIR)
|
||||
- Windows binary path fix (.exe extension)
|
||||
- State load and path-based actions now show correct output messages
|
||||
|
||||
### Documentation
|
||||
|
||||
- Added Claude Code marketplace plugin installation instructions
|
||||
- Updated skill documentation with references and templates
|
||||
- Improved error documentation
|
||||
|
||||
---
|
||||
|
||||
## v0.6.0
|
||||
|
||||
<p className="text-[#888] text-sm">January 2026</p>
|
||||
|
||||
### New Features
|
||||
|
||||
- **Video recording** - Record browser sessions to WebM using Playwright's native recording
|
||||
|
||||
```bash
|
||||
agent-browser record start ./demo.webm
|
||||
agent-browser click @e1
|
||||
agent-browser record stop
|
||||
```
|
||||
|
||||
- **`connect` command** - Connect to a browser via CDP and persist the connection for subsequent commands
|
||||
|
||||
```bash
|
||||
agent-browser connect 9222
|
||||
agent-browser snapshot # No --cdp needed after connect
|
||||
```
|
||||
|
||||
- **`--proxy` flag** - Configure browser proxy with optional authentication
|
||||
|
||||
```bash
|
||||
agent-browser --proxy http://user:pass@proxy.com:8080 open example.com
|
||||
```
|
||||
|
||||
- **`get styles` command** - Extract computed styles from elements
|
||||
|
||||
```bash
|
||||
agent-browser get styles "button"
|
||||
```
|
||||
|
||||
- **Claude marketplace plugin** - Added `.claude-plugin/marketplace.json` for Claude Code integration
|
||||
- **Enhanced network output** - `network requests` now shows method, URL, and resource type
|
||||
- **`--version` flag** - Display CLI version
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix Windows daemon startup and port calculation
|
||||
- Support `libasound2t64` on newer Ubuntu versions (24.04+)
|
||||
- Prevent CDP timeout on empty URL tabs
|
||||
- Output screenshot as base64 when no path provided
|
||||
- Resolve refs in `get value` command
|
||||
- Support URL parameter in `tab new` command
|
||||
- Allow `about:`, `data:`, and `file:` URL schemes
|
||||
- Detect stale unix socket by attempting connection
|
||||
- Respect `AGENT_BROWSER_HEADED` environment variable
|
||||
- Handle SIGPIPE to prevent panic when piping to `head`/`tail`
|
||||
- Fix null path validation in screenshot command
|
||||
|
||||
### Protocol Alignment
|
||||
|
||||
These changes align the CLI with the daemon protocol for consistency:
|
||||
|
||||
- `select` command now uses `values` field (supports multiple selections)
|
||||
- `frame main` uses `mainframe` action
|
||||
- `mouse wheel` uses `wheel` action
|
||||
- `set media` uses `emulatemedia` action
|
||||
- Console output uses `messages` field
|
||||
|
||||
### Documentation
|
||||
|
||||
- Expanded SKILL.md with comprehensive command reference
|
||||
- Updated README with new commands and options
|
||||
- Updated CDP mode documentation with `connect` workflow
|
||||
@@ -1,333 +0,0 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
|
||||
export const metadata = pageMetadata("commands")
|
||||
|
||||
# Commands
|
||||
|
||||
## Core
|
||||
|
||||
```bash
|
||||
agent-browser open <url> # Navigate (aliases: goto, navigate)
|
||||
agent-browser click <sel> # Click element (--new-tab to open in new tab)
|
||||
agent-browser dblclick <sel> # Double-click
|
||||
agent-browser fill <sel> <text> # Clear and fill
|
||||
agent-browser type <sel> <text> # Type into element
|
||||
agent-browser press <key> # Press key (Enter, Tab, Control+a) (alias: key)
|
||||
agent-browser keyboard type <text> # Type at current focus (no selector needed)
|
||||
agent-browser keyboard inserttext <text> # Insert text without key events
|
||||
agent-browser keydown <key> # Hold key down
|
||||
agent-browser keyup <key> # Release key
|
||||
agent-browser hover <sel> # Hover element
|
||||
agent-browser focus <sel> # Focus element
|
||||
agent-browser select <sel> <val> # Select dropdown option
|
||||
agent-browser check <sel> # Check checkbox
|
||||
agent-browser uncheck <sel> # Uncheck checkbox
|
||||
agent-browser scroll <dir> [px] # Scroll (up/down/left/right, --selector <sel>)
|
||||
agent-browser scrollintoview <sel> # Scroll element into view
|
||||
agent-browser drag <src> <dst> # Drag and drop
|
||||
agent-browser upload <sel> <files> # Upload files
|
||||
agent-browser screenshot [path] # Screenshot (--full for full page)
|
||||
agent-browser screenshot --annotate # Annotated screenshot with numbered element labels
|
||||
agent-browser pdf <path> # Save page as PDF
|
||||
agent-browser snapshot # Accessibility tree with refs
|
||||
agent-browser eval <js> # Run JavaScript
|
||||
agent-browser connect <port|url> # Connect to browser via CDP
|
||||
agent-browser close # Close browser (aliases: quit, exit)
|
||||
```
|
||||
|
||||
## Get info
|
||||
|
||||
```bash
|
||||
agent-browser get text <sel> # Get text content
|
||||
agent-browser get html <sel> # Get innerHTML
|
||||
agent-browser get value <sel> # Get input value
|
||||
agent-browser get attr <sel> <attr> # Get attribute
|
||||
agent-browser get title # Get page title
|
||||
agent-browser get url # Get current URL
|
||||
agent-browser get count <sel> # Count matching elements
|
||||
agent-browser get box <sel> # Get bounding box
|
||||
agent-browser get styles <sel> # Get computed styles
|
||||
```
|
||||
|
||||
## Check state
|
||||
|
||||
```bash
|
||||
agent-browser is visible <sel> # Check if visible
|
||||
agent-browser is enabled <sel> # Check if enabled
|
||||
agent-browser is checked <sel> # Check if checked
|
||||
```
|
||||
|
||||
## Find elements
|
||||
|
||||
Semantic locators with actions (`click`, `fill`, `type`, `hover`, `focus`, `check`, `uncheck`, `text`):
|
||||
|
||||
```bash
|
||||
agent-browser find role <role> <action> [value]
|
||||
agent-browser find text <text> <action>
|
||||
agent-browser find label <label> <action> [value]
|
||||
agent-browser find placeholder <ph> <action> [value]
|
||||
agent-browser find alt <text> <action>
|
||||
agent-browser find title <text> <action>
|
||||
agent-browser find testid <id> <action> [value]
|
||||
agent-browser find first <sel> <action> [value]
|
||||
agent-browser find last <sel> <action> [value]
|
||||
agent-browser find nth <n> <sel> <action> [value]
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
- `--name <name>` -- filter role by accessible name
|
||||
- `--exact` -- require exact text match
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
agent-browser find role button click --name "Submit"
|
||||
agent-browser find label "Email" fill "test@test.com"
|
||||
agent-browser find alt "Logo" click
|
||||
agent-browser find first ".item" click
|
||||
agent-browser find last ".item" text
|
||||
agent-browser find nth 2 ".card" hover
|
||||
```
|
||||
|
||||
## Wait
|
||||
|
||||
```bash
|
||||
agent-browser wait <selector> # Wait for element
|
||||
agent-browser wait <ms> # Wait for time
|
||||
agent-browser wait --text "Welcome" # Wait for text
|
||||
agent-browser wait --url "**/dash" # Wait for URL pattern
|
||||
agent-browser wait --load networkidle # Wait for load state
|
||||
agent-browser wait --fn "condition" # Wait for JS condition
|
||||
agent-browser wait --download [path] # Wait for download
|
||||
```
|
||||
|
||||
## Downloads
|
||||
|
||||
```bash
|
||||
agent-browser download <sel> <path> # Click element to trigger download
|
||||
agent-browser wait --download [path] # Wait for any download to complete
|
||||
```
|
||||
|
||||
Use `--download-path <dir>` (or `AGENT_BROWSER_DOWNLOAD_PATH` env) to set a default download directory. Without it, downloads go to a temporary directory that is deleted when the browser closes.
|
||||
|
||||
## Mouse
|
||||
|
||||
```bash
|
||||
agent-browser mouse move <x> <y> # Move mouse
|
||||
agent-browser mouse down [button] # Press button
|
||||
agent-browser mouse up [button] # Release button
|
||||
agent-browser mouse wheel <dy> [dx] # Scroll wheel
|
||||
```
|
||||
|
||||
## Settings
|
||||
|
||||
```bash
|
||||
agent-browser set viewport <w> <h> # Set viewport size
|
||||
agent-browser set device <name> # Emulate device ("iPhone 14")
|
||||
agent-browser set geo <lat> <lng> # Set geolocation
|
||||
agent-browser set offline [on|off] # Toggle offline mode
|
||||
agent-browser set headers <json> # Extra HTTP headers
|
||||
agent-browser set credentials <u> <p> # HTTP basic auth
|
||||
agent-browser set media [dark|light] # Emulate color scheme (persists for session)
|
||||
```
|
||||
|
||||
Use `--color-scheme` for persistent dark/light mode across all commands:
|
||||
|
||||
```bash
|
||||
agent-browser --color-scheme dark open https://example.com
|
||||
```
|
||||
|
||||
## Cookies & storage
|
||||
|
||||
```bash
|
||||
agent-browser cookies # Get all cookies
|
||||
agent-browser cookies set <name> <val> # Set cookie
|
||||
agent-browser cookies clear # Clear cookies
|
||||
|
||||
agent-browser storage local # Get all localStorage
|
||||
agent-browser storage local <key> # Get specific key
|
||||
agent-browser storage local set <k> <v> # Set value
|
||||
agent-browser storage local clear # Clear all
|
||||
|
||||
agent-browser storage session # Same for sessionStorage
|
||||
```
|
||||
|
||||
## Network
|
||||
|
||||
```bash
|
||||
agent-browser network route <url> # Intercept requests
|
||||
agent-browser network route <url> --abort # Block requests
|
||||
agent-browser network route <url> --body <json> # Mock response
|
||||
agent-browser network unroute [url] # Remove routes
|
||||
agent-browser network requests # View tracked requests
|
||||
agent-browser network requests --clear # Clear request log
|
||||
agent-browser network requests --filter <pat> # Filter by URL pattern
|
||||
```
|
||||
|
||||
## Tabs & frames
|
||||
|
||||
```bash
|
||||
agent-browser tab # List tabs
|
||||
agent-browser tab new [url] # New tab
|
||||
agent-browser tab <n> # Switch to tab
|
||||
agent-browser tab close [n] # Close tab
|
||||
agent-browser window new # Open new browser window
|
||||
agent-browser frame <sel> # Switch to iframe
|
||||
agent-browser frame main # Back to main frame
|
||||
```
|
||||
|
||||
## Dialogs
|
||||
|
||||
```bash
|
||||
agent-browser dialog accept [text] # Accept dialog (with optional prompt text)
|
||||
agent-browser dialog dismiss # Dismiss dialog
|
||||
```
|
||||
|
||||
## Debug
|
||||
|
||||
```bash
|
||||
agent-browser trace start [path] # Start trace
|
||||
agent-browser trace stop [path] # Stop and save trace
|
||||
agent-browser profiler start # Start Chrome DevTools profiling
|
||||
agent-browser profiler stop [path] # Stop and save profile (.json)
|
||||
agent-browser record start <path> # Start video recording (WebM)
|
||||
agent-browser record stop # Stop and save video
|
||||
agent-browser record restart <path> # Stop current and start new recording
|
||||
agent-browser console # View console messages
|
||||
agent-browser console --clear # Clear console log
|
||||
agent-browser errors # View page errors
|
||||
agent-browser errors --clear # Clear error log
|
||||
agent-browser highlight <sel> # Highlight element
|
||||
```
|
||||
|
||||
## Auth vault
|
||||
|
||||
```bash
|
||||
agent-browser auth save <name> [opts] # Save auth profile
|
||||
agent-browser auth login <name> # Login using saved credentials
|
||||
agent-browser auth list # List saved profiles (names and URLs only)
|
||||
agent-browser auth show <name> # Show profile metadata (no passwords)
|
||||
agent-browser auth delete <name> # Delete a saved profile
|
||||
```
|
||||
|
||||
Save options:
|
||||
|
||||
- `--url <url>` -- login page URL (required)
|
||||
- `--username <user>` -- username (required)
|
||||
- `--password <pass>` -- password (required unless `--password-stdin`)
|
||||
- `--password-stdin` -- read password from stdin (recommended to avoid shell history exposure)
|
||||
- `--username-selector <sel>` -- custom CSS selector for username field
|
||||
- `--password-selector <sel>` -- custom CSS selector for password field
|
||||
- `--submit-selector <sel>` -- custom CSS selector for submit button
|
||||
|
||||
```bash
|
||||
echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin
|
||||
agent-browser auth login github
|
||||
agent-browser auth list
|
||||
```
|
||||
|
||||
## Confirmation
|
||||
|
||||
When `--confirm-actions` is set, certain action categories return a `confirmation_required` response instead of executing immediately. Use `confirm` or `deny` to approve or reject the action.
|
||||
|
||||
```bash
|
||||
agent-browser confirm <confirmation-id> # Approve a pending action
|
||||
agent-browser deny <confirmation-id> # Deny a pending action
|
||||
```
|
||||
|
||||
Pending confirmations auto-deny after 60 seconds.
|
||||
|
||||
```bash
|
||||
agent-browser --confirm-actions eval,download eval "document.title"
|
||||
# Returns confirmation_required with ID
|
||||
agent-browser confirm c_8f3a1234
|
||||
```
|
||||
|
||||
## State management
|
||||
|
||||
```bash
|
||||
agent-browser state save <path> # Save auth state to file
|
||||
agent-browser state load <path> # Load auth state from file
|
||||
agent-browser state list # List saved state files
|
||||
agent-browser state show <file> # Show state summary
|
||||
agent-browser state rename <old> <new> # Rename state file
|
||||
agent-browser state clear [name] # Clear states for session name
|
||||
agent-browser state clear --all # Clear all saved states
|
||||
agent-browser state clean --older-than <days> # Delete old states
|
||||
```
|
||||
|
||||
## Sessions
|
||||
|
||||
```bash
|
||||
agent-browser session # Show current session name
|
||||
agent-browser session list # List active sessions
|
||||
```
|
||||
|
||||
## Navigation
|
||||
|
||||
```bash
|
||||
agent-browser back # Go back
|
||||
agent-browser forward # Go forward
|
||||
agent-browser reload # Reload page
|
||||
```
|
||||
|
||||
## Global options
|
||||
|
||||
```bash
|
||||
--session <name> # Isolated browser session
|
||||
--session-name <name> # Auto-save/restore session state (cookies, localStorage)
|
||||
--profile <path> # Persistent browser profile directory
|
||||
--state <path> # Load storage state from JSON file
|
||||
--headers <json> # HTTP headers scoped to URL's origin
|
||||
--executable-path <path> # Custom browser executable
|
||||
--extension <path> # Load browser extension (repeatable)
|
||||
--args <args> # Browser launch args (comma separated)
|
||||
--user-agent <ua> # Custom User-Agent string
|
||||
--proxy <url> # Proxy server URL
|
||||
--proxy-bypass <hosts> # Hosts to bypass proxy
|
||||
--ignore-https-errors # Ignore HTTPS certificate errors
|
||||
--allow-file-access # Allow file:// URLs to access local files (Chromium only)
|
||||
-p, --provider <name> # Browser provider (ios, browserbase, kernel, browseruse)
|
||||
--device <name> # iOS device name (e.g., "iPhone 15 Pro")
|
||||
--json # JSON output (for scripts)
|
||||
--full, -f # Full page screenshot
|
||||
--annotate # Annotated screenshot with numbered element labels
|
||||
--headed # Show browser window (not headless)
|
||||
--cdp <port|url> # Connect via Chrome DevTools Protocol (port or WebSocket URL)
|
||||
--auto-connect # Auto-discover and connect to running Chrome
|
||||
--color-scheme <scheme> # Color scheme: dark, light, no-preference
|
||||
--download-path <path> # Default download directory
|
||||
--content-boundaries # Wrap page output in boundary markers for LLM safety
|
||||
--max-output <chars> # Truncate page output to N characters
|
||||
--allowed-domains <list> # Comma-separated allowed domain patterns
|
||||
--action-policy <path> # Path to action policy JSON file
|
||||
--confirm-actions <list> # Action categories requiring confirmation
|
||||
--confirm-interactive # Interactive confirmation prompts (auto-denies if stdin is not a TTY)
|
||||
--config <path> # Use a custom config file
|
||||
--debug # Debug output
|
||||
```
|
||||
|
||||
## Command chaining
|
||||
|
||||
Chain commands with `&&` in a single shell invocation. The browser persists via a background daemon, so chaining works naturally and is more efficient than separate calls:
|
||||
|
||||
```bash
|
||||
agent-browser open example.com && agent-browser wait --load networkidle && agent-browser snapshot -i
|
||||
agent-browser fill @e1 "user@example.com" && agent-browser fill @e2 "pass" && agent-browser click @e3
|
||||
agent-browser open example.com && agent-browser wait --load networkidle && agent-browser screenshot page.png
|
||||
```
|
||||
|
||||
Use `&&` when you don't need to read intermediate output. Run commands separately when you need to parse output first (e.g., snapshot to discover refs, then interact with those refs).
|
||||
|
||||
## Local files
|
||||
|
||||
Open local files (PDFs, HTML) using `file://` URLs:
|
||||
|
||||
```bash
|
||||
agent-browser --allow-file-access open file:///path/to/document.pdf
|
||||
agent-browser --allow-file-access open file:///path/to/page.html
|
||||
agent-browser screenshot output.png
|
||||
```
|
||||
|
||||
The `--allow-file-access` flag enables JavaScript to access other local files. Chromium only.
|
||||
@@ -1,199 +0,0 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
|
||||
export const metadata = pageMetadata("configuration")
|
||||
|
||||
# Configuration
|
||||
|
||||
Create an `agent-browser.json` file to set persistent defaults instead of repeating flags on every command.
|
||||
|
||||
## Config File Locations
|
||||
|
||||
agent-browser checks two locations, merged in priority order:
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Priority</th><th>Location</th><th>Scope</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>1 (lowest)</td><td><code>~/.agent-browser/config.json</code></td><td>User-level defaults</td></tr>
|
||||
<tr><td>2</td><td><code>./agent-browser.json</code></td><td>Project-level overrides</td></tr>
|
||||
<tr><td>3</td><td><code>AGENT_BROWSER_*</code> env vars</td><td>Override config values</td></tr>
|
||||
<tr><td>4 (highest)</td><td>CLI flags</td><td>Override everything</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Project-level values override user-level values. Environment variables override both. CLI flags always win.
|
||||
|
||||
Use `--config <path>` or the `AGENT_BROWSER_CONFIG` environment variable to load a specific config file instead of the default locations:
|
||||
|
||||
```bash
|
||||
agent-browser --config ./ci-config.json open example.com
|
||||
AGENT_BROWSER_CONFIG=./ci-config.json agent-browser open example.com
|
||||
```
|
||||
|
||||
## Example Config
|
||||
|
||||
```json
|
||||
{
|
||||
"headed": true,
|
||||
"proxy": "http://localhost:8080",
|
||||
"profile": "./browser-data",
|
||||
"userAgent": "my-agent/1.0",
|
||||
"ignoreHttpsErrors": true
|
||||
}
|
||||
```
|
||||
|
||||
## All Options
|
||||
|
||||
Every CLI flag can be set in the config file using its camelCase equivalent:
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Config Key</th><th>CLI Flag</th><th>Type</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>headed</code></td><td><code>--headed</code></td><td>boolean</td></tr>
|
||||
<tr><td><code>json</code></td><td><code>--json</code></td><td>boolean</td></tr>
|
||||
<tr><td><code>full</code></td><td><code>--full, -f</code></td><td>boolean</td></tr>
|
||||
<tr><td><code>debug</code></td><td><code>--debug</code></td><td>boolean</td></tr>
|
||||
<tr><td><code>session</code></td><td><code>--session</code></td><td>string</td></tr>
|
||||
<tr><td><code>sessionName</code></td><td><code>--session-name</code></td><td>string</td></tr>
|
||||
<tr><td><code>executablePath</code></td><td><code>--executable-path</code></td><td>string</td></tr>
|
||||
<tr><td><code>extensions</code></td><td><code>--extension</code></td><td>string[]</td></tr>
|
||||
<tr><td><code>profile</code></td><td><code>--profile</code></td><td>string</td></tr>
|
||||
<tr><td><code>state</code></td><td><code>--state</code></td><td>string</td></tr>
|
||||
<tr><td><code>proxy</code></td><td><code>--proxy</code></td><td>string</td></tr>
|
||||
<tr><td><code>proxyBypass</code></td><td><code>--proxy-bypass</code></td><td>string</td></tr>
|
||||
<tr><td><code>args</code></td><td><code>--args</code></td><td>string</td></tr>
|
||||
<tr><td><code>userAgent</code></td><td><code>--user-agent</code></td><td>string</td></tr>
|
||||
<tr><td><code>provider</code></td><td><code>-p, --provider</code></td><td>string</td></tr>
|
||||
<tr><td><code>device</code></td><td><code>--device</code></td><td>string</td></tr>
|
||||
<tr><td><code>ignoreHttpsErrors</code></td><td><code>--ignore-https-errors</code></td><td>boolean</td></tr>
|
||||
<tr><td><code>allowFileAccess</code></td><td><code>--allow-file-access</code></td><td>boolean</td></tr>
|
||||
<tr><td><code>cdp</code></td><td><code>--cdp</code></td><td>string</td></tr>
|
||||
<tr><td><code>autoConnect</code></td><td><code>--auto-connect</code></td><td>boolean</td></tr>
|
||||
<tr><td><code>colorScheme</code></td><td><code>--color-scheme</code></td><td>string (<code>dark</code>, <code>light</code>, <code>no-preference</code>)</td></tr>
|
||||
<tr><td><code>downloadPath</code></td><td><code>--download-path</code></td><td>string</td></tr>
|
||||
<tr><td><code>contentBoundaries</code></td><td><code>--content-boundaries</code></td><td>boolean</td></tr>
|
||||
<tr><td><code>maxOutput</code></td><td><code>--max-output</code></td><td>number</td></tr>
|
||||
<tr><td><code>allowedDomains</code></td><td><code>--allowed-domains</code></td><td>string[]</td></tr>
|
||||
<tr><td><code>actionPolicy</code></td><td><code>--action-policy</code></td><td>string</td></tr>
|
||||
<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>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>
|
||||
|
||||
## Common Configurations
|
||||
|
||||
### Local Development
|
||||
|
||||
```json
|
||||
{
|
||||
"headed": true,
|
||||
"profile": "./browser-data"
|
||||
}
|
||||
```
|
||||
|
||||
### Behind a Proxy
|
||||
|
||||
```json
|
||||
{
|
||||
"proxy": "http://proxy.corp.example.com:8080",
|
||||
"proxyBypass": "localhost,*.internal.com",
|
||||
"ignoreHttpsErrors": true
|
||||
}
|
||||
```
|
||||
|
||||
### CI / Devcontainer
|
||||
|
||||
```json
|
||||
{
|
||||
"args": "--no-sandbox,--disable-gpu",
|
||||
"ignoreHttpsErrors": true
|
||||
}
|
||||
```
|
||||
|
||||
### iOS Testing
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": "ios",
|
||||
"device": "iPhone 16 Pro"
|
||||
}
|
||||
```
|
||||
|
||||
### AI Agent Security
|
||||
|
||||
```json
|
||||
{
|
||||
"contentBoundaries": true,
|
||||
"maxOutput": 50000,
|
||||
"allowedDomains": ["your-app.com", "*.your-app.com"],
|
||||
"actionPolicy": "./policy.json"
|
||||
}
|
||||
```
|
||||
|
||||
## Overriding Boolean Options
|
||||
|
||||
Boolean flags accept an optional `true`/`false` value to override config settings:
|
||||
|
||||
```bash
|
||||
agent-browser --headed false open example.com
|
||||
```
|
||||
|
||||
A bare flag is equivalent to passing `true`:
|
||||
|
||||
```bash
|
||||
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`.
|
||||
|
||||
## Extensions Merging
|
||||
|
||||
Extensions from user-level and project-level configs are **concatenated**, not replaced. For example, if `~/.agent-browser/config.json` specifies `["/ext1"]` and `./agent-browser.json` specifies `["/ext2"]`, the result is `["/ext1", "/ext2"]`.
|
||||
|
||||
The `AGENT_BROWSER_EXTENSIONS` environment variable and CLI `--extension` flags follow the standard priority rules (env replaces config, CLI appends).
|
||||
|
||||
## Environment Variables
|
||||
|
||||
These environment variables configure additional daemon and runtime behavior:
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Variable</th><th>Description</th><th>Default</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>AGENT_BROWSER_AUTO_CONNECT</code></td><td>Auto-discover and connect to a running Chrome instance.</td><td>(disabled)</td></tr>
|
||||
<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_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_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_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>
|
||||
<tr><td><code>AGENT_BROWSER_CONTENT_BOUNDARIES</code></td><td>Wrap page output in boundary markers for LLM safety.</td><td>(disabled)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_MAX_OUTPUT</code></td><td>Max characters for page output (truncates beyond limit).</td><td>(unlimited)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_ALLOWED_DOMAINS</code></td><td>Comma-separated allowed domain patterns (e.g., <code>example.com,*.example.com</code>).</td><td>(unrestricted)</td></tr>
|
||||
<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_NATIVE</code></td><td>Use the experimental native Rust daemon instead of Node.js/Playwright.</td><td>(disabled)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Auto-discovered config files** (`~/.agent-browser/config.json`, `./agent-browser.json`) that are missing are silently ignored.
|
||||
- **`--config <path>`** with a missing or malformed file exits with an error.
|
||||
- **Malformed JSON** in auto-discovered files prints a warning to stderr and continues without that file.
|
||||
- **Unknown keys** are silently ignored for forward compatibility.
|
||||
|
||||
> **Tip:** If your project-level `agent-browser.json` contains environment-specific values (paths, proxies), consider adding it to `.gitignore`.
|
||||
@@ -1,179 +0,0 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
|
||||
export const metadata = pageMetadata("diffing")
|
||||
|
||||
import { DiffDemo } from "@/components/diff-demo"
|
||||
|
||||
# Diffing
|
||||
|
||||
Compare page states to detect changes -- structurally via accessibility tree snapshots, visually via pixel comparison, or across two different URLs.
|
||||
|
||||
<DiffDemo />
|
||||
|
||||
## Commands
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Command</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>diff snapshot</code></td><td>Compare current snapshot to last snapshot in session</td></tr>
|
||||
<tr><td><code>diff snapshot --baseline <file></code></td><td>Compare current snapshot to a saved file</td></tr>
|
||||
<tr><td><code>diff screenshot --baseline <file></code></td><td>Visual pixel diff against a baseline image</td></tr>
|
||||
<tr><td><code>diff url <url1> <url2></code></td><td>Compare two pages (snapshot + optional screenshot)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Snapshot diff
|
||||
|
||||
Compares the accessibility tree between two points in time using a line-level text diff.
|
||||
|
||||
```bash
|
||||
# Compare against the last snapshot taken in this session
|
||||
agent-browser diff snapshot
|
||||
|
||||
# Compare against a saved baseline file
|
||||
agent-browser diff snapshot --baseline before.txt
|
||||
|
||||
# Scope to a specific part of the page
|
||||
agent-browser diff snapshot --selector "#main" --compact
|
||||
```
|
||||
|
||||
Without `--baseline`, the command automatically compares against the most recent snapshot taken in the current session. This is the primary use case for agents verifying that an action had the intended effect.
|
||||
|
||||
### Options
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Flag</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>-b, --baseline <file></code></td><td>Path to a saved snapshot file to compare against</td></tr>
|
||||
<tr><td><code>-s, --selector <sel></code></td><td>Scope the current snapshot to a CSS selector or @ref</td></tr>
|
||||
<tr><td><code>-c, --compact</code></td><td>Use compact snapshot format</td></tr>
|
||||
<tr><td><code>-d, --depth <n></code></td><td>Limit snapshot tree depth</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
### Output
|
||||
|
||||
The diff uses `+` for added lines and `-` for removed lines, similar to unified diff format. A summary line shows the count of additions, removals, and unchanged lines.
|
||||
|
||||
```
|
||||
- button "Submit" [ref=e2]
|
||||
+ button "Submit" [ref=e2] [disabled]
|
||||
3 additions, 2 removals, 41 unchanged
|
||||
```
|
||||
|
||||
## Screenshot diff
|
||||
|
||||
Compares the current page screenshot against a baseline image at the pixel level. Produces a diff image with changed pixels highlighted in red.
|
||||
|
||||
```bash
|
||||
# Basic visual diff
|
||||
agent-browser diff screenshot --baseline before.png
|
||||
|
||||
# Save diff image to a specific path
|
||||
agent-browser diff screenshot --baseline before.png --output diff.png
|
||||
|
||||
# Adjust threshold and scope to element
|
||||
agent-browser diff screenshot --baseline before.png --threshold 0.2 --selector "#hero"
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Flag</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>-b, --baseline <file></code></td><td>Baseline PNG/JPEG image to compare against (required)</td></tr>
|
||||
<tr><td><code>-o, --output <file></code></td><td>Path for the generated diff image (default: temp dir)</td></tr>
|
||||
<tr><td><code>-t, --threshold <0-1></code></td><td>Color distance threshold (default: 0.1). Higher = more tolerant</td></tr>
|
||||
<tr><td><code>-s, --selector <sel></code></td><td>Scope the current screenshot to an element</td></tr>
|
||||
<tr><td><code>--full</code></td><td>Take a full-page screenshot</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
### Output
|
||||
|
||||
Reports the diff image path, number of different pixels, and mismatch percentage. The diff image shows unchanged pixels dimmed with changed pixels in red.
|
||||
|
||||
If the baseline and current images have different dimensions, the command reports a dimension mismatch instead of attempting pixel comparison.
|
||||
|
||||
## URL diff
|
||||
|
||||
Compares two pages by navigating to each in sequence and diffing the results.
|
||||
|
||||
```bash
|
||||
# Compare two URLs (snapshot diff)
|
||||
agent-browser diff url https://staging.example.com https://prod.example.com
|
||||
|
||||
# Include visual comparison
|
||||
agent-browser diff url https://v1.example.com https://v2.example.com --screenshot
|
||||
|
||||
# Full-page screenshot comparison
|
||||
agent-browser diff url https://v1.example.com https://v2.example.com --screenshot --full
|
||||
```
|
||||
|
||||
The command navigates to the first URL, captures state, then navigates to the second URL and captures again. Snapshot diff is always included. Screenshot diff requires the `--screenshot` flag.
|
||||
|
||||
After completion, the browser remains on the second URL.
|
||||
|
||||
### Options
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Flag</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>--screenshot</code></td><td>Also perform visual screenshot comparison</td></tr>
|
||||
<tr><td><code>--full</code></td><td>Use full-page screenshots</td></tr>
|
||||
<tr><td><code>--wait-until <strategy></code></td><td>Navigation wait strategy: <code>load</code>, <code>domcontentloaded</code>, <code>networkidle</code> (default: <code>load</code>)</td></tr>
|
||||
<tr><td><code>-s, --selector <sel></code></td><td>Scope snapshots to a CSS selector or @ref</td></tr>
|
||||
<tr><td><code>-c, --compact</code></td><td>Use compact snapshot format</td></tr>
|
||||
<tr><td><code>-d, --depth <n></code></td><td>Limit snapshot tree depth</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Use cases
|
||||
|
||||
### Verifying agent actions
|
||||
|
||||
The most common use case: confirm that an action (click, fill, submit) changed the page as expected.
|
||||
|
||||
```bash
|
||||
agent-browser snapshot -i # Take interactive-only snapshot (baseline)
|
||||
agent-browser fill @e3 "test@example.com"
|
||||
agent-browser diff snapshot # Compare current snapshot to the baseline
|
||||
```
|
||||
|
||||
### Monitoring for changes
|
||||
|
||||
Periodically compare a page against a saved baseline to detect updates.
|
||||
|
||||
```bash
|
||||
# Save baseline
|
||||
agent-browser open https://example.com && agent-browser snapshot > baseline.txt
|
||||
|
||||
# Later, check for changes
|
||||
agent-browser open https://example.com && agent-browser diff snapshot --baseline baseline.txt
|
||||
```
|
||||
|
||||
### Visual regression testing
|
||||
|
||||
Compare screenshots before and after a deploy to catch unintended visual changes.
|
||||
|
||||
```bash
|
||||
agent-browser open https://staging.example.com && agent-browser screenshot baseline.png
|
||||
# ... deploy happens ...
|
||||
agent-browser open https://staging.example.com && agent-browser diff screenshot --baseline baseline.png
|
||||
```
|
||||
|
||||
### Comparing environments
|
||||
|
||||
Diff staging against production to verify parity.
|
||||
|
||||
```bash
|
||||
agent-browser diff url https://staging.example.com https://prod.example.com --screenshot
|
||||
```
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
@@ -1,333 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
@source "../../node_modules/streamdown/dist/index.js";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
--font-mono: var(--font-geist-mono), ui-monospace, "SF Mono", "Cascadia Mono", "Segoe UI Mono", Menlo, Consolas, monospace;
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-border: var(--border);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: #fff;
|
||||
--foreground: #171717;
|
||||
--border: #e5e5e5;
|
||||
--muted: #f5f5f5;
|
||||
--muted-foreground: #737373;
|
||||
--primary: #171717;
|
||||
--primary-foreground: #fff;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #f5f5f5;
|
||||
--border: #262626;
|
||||
--muted: #262626;
|
||||
--muted-foreground: #a3a3a3;
|
||||
--primary: #f5f5f5;
|
||||
--primary-foreground: #0a0a0a;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: #000;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
::selection {
|
||||
background-color: #fff;
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
/* Article tables */
|
||||
article table {
|
||||
width: 100%;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
article th {
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #737373;
|
||||
}
|
||||
|
||||
article td {
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: #525252;
|
||||
}
|
||||
|
||||
:is(.dark) article th {
|
||||
border-bottom-color: #262626;
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
:is(.dark) article td {
|
||||
border-bottom-color: rgba(38, 38, 38, 0.5);
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
pre {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.875rem;
|
||||
overflow-x: auto;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
pre:not(.shiki) {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.code-block pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
pre {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
:not(pre) > code {
|
||||
background: var(--muted);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
/* Shiki dual theme support */
|
||||
.shiki,
|
||||
.shiki span {
|
||||
color: var(--shiki-light) !important;
|
||||
background-color: var(--shiki-light-bg) !important;
|
||||
}
|
||||
|
||||
.dark .shiki,
|
||||
.dark .shiki span {
|
||||
color: var(--shiki-dark) !important;
|
||||
background-color: var(--shiki-dark-bg) !important;
|
||||
}
|
||||
|
||||
/* Prose */
|
||||
.prose {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.prose h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-top: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.prose h2:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.prose h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.prose p {
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.65;
|
||||
color: #525252;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
:is(.dark) .prose p {
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
.prose ul, .prose ol {
|
||||
margin-bottom: 1rem;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.prose ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.prose ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.prose li {
|
||||
margin-bottom: 0.25rem;
|
||||
color: #525252;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
:is(.dark) .prose li {
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
.prose li strong {
|
||||
color: var(--foreground);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.prose a {
|
||||
color: var(--foreground);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: #d4d4d4;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.prose a:hover {
|
||||
text-decoration-color: var(--foreground);
|
||||
}
|
||||
|
||||
:is(.dark) .prose a {
|
||||
text-decoration-color: #525252;
|
||||
}
|
||||
|
||||
:is(.dark) .prose a:hover {
|
||||
text-decoration-color: var(--foreground);
|
||||
}
|
||||
|
||||
.prose strong {
|
||||
font-weight: 500;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.prose blockquote {
|
||||
margin-bottom: 1rem;
|
||||
border-left: 2px solid #e5e5e5;
|
||||
padding-left: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: #737373;
|
||||
}
|
||||
|
||||
:is(.dark) .prose blockquote {
|
||||
border-left-color: #525252;
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
.prose table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1.5rem 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.prose th, .prose td {
|
||||
text-align: left;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.prose th {
|
||||
font-weight: 500;
|
||||
color: var(--muted-foreground);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.prose td {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.prose td code {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* Tool call shimmer animation */
|
||||
@keyframes tool-shimmer {
|
||||
0% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.animate-tool-shimmer {
|
||||
animation: tool-shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Override prose text color in chat so agent responses use primary foreground */
|
||||
.docs-chat-content p,
|
||||
.docs-chat-content li,
|
||||
.docs-chat-content td,
|
||||
.docs-chat-content th,
|
||||
.docs-chat-content strong,
|
||||
.docs-chat-content code {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* Reset global pre styles inside chat so Streamdown's own styling takes effect */
|
||||
.docs-chat-content pre {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: revert-layer;
|
||||
}
|
||||
|
||||
/* Fix list rendering in chat content */
|
||||
.docs-chat-content ul,
|
||||
.docs-chat-content ol {
|
||||
list-style-position: outside;
|
||||
padding-left: 1.25em;
|
||||
}
|
||||
|
||||
.docs-chat-content li > p {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.docs-chat-content li {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
|
||||
export const metadata = pageMetadata("installation")
|
||||
|
||||
# Installation
|
||||
|
||||
## Global installation (recommended)
|
||||
|
||||
Installs the native Rust binary for maximum performance:
|
||||
|
||||
```bash
|
||||
npm install -g agent-browser
|
||||
agent-browser install # Download Chromium
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
Then use via `npx` or `package.json` scripts:
|
||||
|
||||
```bash
|
||||
npx agent-browser open example.com
|
||||
```
|
||||
|
||||
## Homebrew (macOS)
|
||||
|
||||
```bash
|
||||
brew install agent-browser
|
||||
agent-browser install # Download Chromium
|
||||
```
|
||||
|
||||
## From source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/vercel-labs/agent-browser
|
||||
cd agent-browser
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm build:native
|
||||
./bin/agent-browser install
|
||||
pnpm link --global
|
||||
```
|
||||
|
||||
## Linux dependencies
|
||||
|
||||
On Linux, install system dependencies:
|
||||
|
||||
```bash
|
||||
agent-browser install --with-deps
|
||||
# or manually: npx playwright install-deps chromium
|
||||
```
|
||||
|
||||
## Custom browser
|
||||
|
||||
Use a custom browser executable instead of bundled Chromium:
|
||||
|
||||
- **Serverless** - Use `@sparticuz/chromium` (~50MB vs ~684MB)
|
||||
- **System browser** - Use existing Chrome installation
|
||||
- **Custom builds** - Use modified browser builds
|
||||
|
||||
```bash
|
||||
# Via flag
|
||||
agent-browser --executable-path /path/to/chromium open example.com
|
||||
|
||||
# Via environment variable
|
||||
AGENT_BROWSER_EXECUTABLE_PATH=/path/to/chromium agent-browser open example.com
|
||||
```
|
||||
|
||||
### Serverless example
|
||||
|
||||
```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
|
||||
}
|
||||
```
|
||||
|
||||
## AI agent setup
|
||||
|
||||
agent-browser works with any AI agent out of the box. For richer context:
|
||||
|
||||
### AI coding assistants (recommended)
|
||||
|
||||
Install the skill for your AI coding assistant:
|
||||
|
||||
```bash
|
||||
npx skills add vercel-labs/agent-browser
|
||||
```
|
||||
|
||||
This works with Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, Goose, OpenCode, and Windsurf. The skill is fetched from the repository and stays up to date automatically.
|
||||
|
||||
> **Do not** copy `SKILL.md` from `node_modules` -- it will become stale as new features are added. Always use `npx skills add` or reference the repository version.
|
||||
|
||||
### AGENTS.md / CLAUDE.md
|
||||
|
||||
Add to your instructions file:
|
||||
|
||||
```markdown
|
||||
## Browser Automation
|
||||
|
||||
Use `agent-browser` for web automation. Run `agent-browser --help` for all commands.
|
||||
|
||||
Core workflow:
|
||||
1. `agent-browser open <url>` - Navigate to page
|
||||
2. `agent-browser snapshot -i` - Get interactive elements with refs (@e1, @e2)
|
||||
3. `agent-browser click @e1` / `fill @e2 "text"` - Interact using refs
|
||||
4. Re-snapshot after page changes
|
||||
```
|
||||
@@ -1,211 +0,0 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
|
||||
export const metadata = pageMetadata("ios")
|
||||
|
||||
# iOS Simulator
|
||||
|
||||
Control real Mobile Safari in the iOS Simulator for authentic mobile
|
||||
web testing. Uses Appium with XCUITest for native automation.
|
||||
|
||||
## Requirements
|
||||
|
||||
- macOS with Xcode installed
|
||||
- iOS Simulator runtimes (download via Xcode)
|
||||
- Appium with XCUITest driver
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# Install Appium globally
|
||||
npm install -g appium
|
||||
|
||||
# Install the XCUITest driver for iOS
|
||||
appium driver install xcuitest
|
||||
```
|
||||
|
||||
## List available devices
|
||||
|
||||
See all iOS simulators available on your system:
|
||||
|
||||
```bash
|
||||
agent-browser device list
|
||||
|
||||
# Output:
|
||||
# Available iOS Simulators:
|
||||
#
|
||||
# ○ iPhone 16 Pro (iOS 18.0)
|
||||
# F21EEC0D-7618-419F-811B-33AF27A8B2FD
|
||||
# ○ iPhone 16 Pro Max (iOS 18.0)
|
||||
# 50402807-C9B8-4D37-9F13-2E00E782C744
|
||||
# ○ iPad Pro 13-inch (M4) (iOS 18.0)
|
||||
# 3A6C6436-B909-4593-866D-91D1062BB070
|
||||
# ...
|
||||
```
|
||||
|
||||
## Basic usage
|
||||
|
||||
Use the `-p ios` flag to enable iOS mode. The workflow is
|
||||
identical to desktop:
|
||||
|
||||
```bash
|
||||
# Launch Safari on iPhone 16 Pro
|
||||
agent-browser -p ios --device "iPhone 16 Pro" open https://example.com
|
||||
|
||||
# Get snapshot with refs (same as desktop)
|
||||
agent-browser -p ios snapshot -i
|
||||
|
||||
# Interact using refs
|
||||
agent-browser -p ios tap @e1
|
||||
agent-browser -p ios fill @e2 "text"
|
||||
|
||||
# Take screenshot
|
||||
agent-browser -p ios screenshot mobile.png
|
||||
|
||||
# Close session (shuts down simulator)
|
||||
agent-browser -p ios close
|
||||
```
|
||||
|
||||
## Mobile-specific commands
|
||||
|
||||
```bash
|
||||
# Swipe gestures
|
||||
agent-browser -p ios swipe up
|
||||
agent-browser -p ios swipe down
|
||||
agent-browser -p ios swipe left
|
||||
agent-browser -p ios swipe right
|
||||
|
||||
# Swipe with distance (pixels)
|
||||
agent-browser -p ios swipe up 500
|
||||
|
||||
# Tap (alias for click, semantically clearer for touch)
|
||||
agent-browser -p ios tap @e1
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
Configure iOS mode via environment variables:
|
||||
|
||||
```bash
|
||||
export AGENT_BROWSER_PROVIDER=ios
|
||||
export AGENT_BROWSER_IOS_DEVICE="iPhone 16 Pro"
|
||||
|
||||
# Now all commands use iOS
|
||||
agent-browser open https://example.com
|
||||
agent-browser snapshot -i
|
||||
agent-browser tap @e1
|
||||
```
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Variable</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>AGENT_BROWSER_PROVIDER</code></td><td>Set to <code>ios</code> to enable iOS mode</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_IOS_DEVICE</code></td><td>Device name (e.g., "iPhone 16 Pro")</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_IOS_UDID</code></td><td>Device UDID (alternative to device name)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Supported devices
|
||||
|
||||
All iOS Simulators available in Xcode are supported, including:
|
||||
|
||||
- All iPhone models (iPhone 15, 16, 17, SE, etc.)
|
||||
- All iPad models (iPad Pro, iPad Air, iPad mini, etc.)
|
||||
- Multiple iOS versions (17.x, 18.x, etc.)
|
||||
|
||||
**Real devices** are also supported via USB connection (see below).
|
||||
|
||||
## Real device support
|
||||
|
||||
Appium can control Safari on real iOS devices connected via USB. This
|
||||
requires additional one-time setup.
|
||||
|
||||
### 1. Get your device UDID
|
||||
|
||||
```bash
|
||||
# List connected devices
|
||||
xcrun xctrace list devices
|
||||
|
||||
# Or via system profiler
|
||||
system_profiler SPUSBDataType | grep -A 5 "iPhone\|iPad"
|
||||
```
|
||||
|
||||
### 2. Sign WebDriverAgent (one-time)
|
||||
|
||||
WebDriverAgent needs to be signed with your Apple Developer
|
||||
certificate to run on real devices.
|
||||
|
||||
```bash
|
||||
# Open the WebDriverAgent Xcode project
|
||||
cd ~/.appium/node_modules/appium-xcuitest-driver/node_modules/appium-webdriveragent
|
||||
open WebDriverAgent.xcodeproj
|
||||
```
|
||||
|
||||
In Xcode:
|
||||
|
||||
1. Select the `WebDriverAgentRunner` target
|
||||
2. Go to Signing & Capabilities
|
||||
3. Select your Team (requires Apple Developer account, free tier works)
|
||||
4. Let Xcode manage signing automatically
|
||||
|
||||
### 3. Use with agent-browser
|
||||
|
||||
```bash
|
||||
# Connect device via USB, then use the UDID
|
||||
agent-browser -p ios --device "<DEVICE_UDID>" open https://example.com
|
||||
|
||||
# Or use the device name if unique
|
||||
agent-browser -p ios --device "John's iPhone" open https://example.com
|
||||
```
|
||||
|
||||
### Real device notes
|
||||
|
||||
- First run installs WebDriverAgent to the device (may require Trust prompt on device)
|
||||
- Device must be unlocked and connected via USB
|
||||
- Slightly slower initial connection than simulator
|
||||
- Tests against real Safari performance and behavior
|
||||
- On first install, go to Settings → General → VPN & Device Management to trust the developer certificate
|
||||
|
||||
## Performance notes
|
||||
|
||||
- **First launch:** Takes 30-60 seconds to boot the simulator and start Appium
|
||||
- **Subsequent commands:** Fast (simulator stays running)
|
||||
- **Close command:** Shuts down simulator and Appium server
|
||||
|
||||
## Differences from desktop
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Feature</th><th>Desktop</th><th>iOS</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Browser</td><td>Chromium/Firefox/WebKit</td><td>Safari only</td></tr>
|
||||
<tr><td>Tabs</td><td>Supported</td><td>Single tab only</td></tr>
|
||||
<tr><td>PDF export</td><td>Supported</td><td>Not supported</td></tr>
|
||||
<tr><td>Screencast</td><td>Supported</td><td>Not supported</td></tr>
|
||||
<tr><td>Swipe gestures</td><td>Not native</td><td>Native support</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Appium not found
|
||||
|
||||
```bash
|
||||
# Make sure Appium is installed globally
|
||||
npm install -g appium
|
||||
appium driver install xcuitest
|
||||
|
||||
# Verify installation
|
||||
appium --version
|
||||
```
|
||||
|
||||
### No simulators available
|
||||
|
||||
Open Xcode and download iOS Simulator runtimes from **Settings → Platforms**.
|
||||
|
||||
### Simulator won't boot
|
||||
|
||||
Try booting the simulator manually from Xcode or the Simulator app to
|
||||
ensure it works, then retry with agent-browser.
|
||||
@@ -1,93 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter, Geist_Mono } from "next/font/google";
|
||||
import { GeistPixelSquare } from "geist/font/pixel";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Header } from "@/components/header";
|
||||
import { DocsSidebar } from "@/components/docs-sidebar";
|
||||
import { DocsMobileNav } from "@/components/docs-mobile-nav";
|
||||
import { CopyPageButton } from "@/components/copy-page-button";
|
||||
import { DocsChat } from "@/components/docs-chat";
|
||||
import { cookies } from "next/headers";
|
||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
||||
import { Analytics } from "@vercel/analytics/next";
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://agent-browser.dev"),
|
||||
title: {
|
||||
default: "agent-browser | Headless Browser Automation for AI",
|
||||
template: "%s | agent-browser",
|
||||
},
|
||||
description: "Headless browser automation CLI for AI agents",
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "en_US",
|
||||
url: "https://agent-browser.dev",
|
||||
siteName: "agent-browser",
|
||||
title: "agent-browser | Headless Browser Automation for AI",
|
||||
description: "Headless browser automation CLI for AI agents",
|
||||
images: [{ url: "/og", width: 1200, height: 630, alt: "agent-browser" }],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "agent-browser | Headless Browser Automation for AI",
|
||||
description: "Headless browser automation CLI for AI agents",
|
||||
images: ["/og"],
|
||||
},
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const cookieStore = await cookies();
|
||||
const chatOpen = cookieStore.get("docs-chat-open")?.value === "true";
|
||||
const chatWidth = Number(cookieStore.get("docs-chat-width")?.value) || 400;
|
||||
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
{chatOpen && (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `@media(min-width:640px){body{padding-right:${chatWidth}px}}`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</head>
|
||||
<body
|
||||
className={`${inter.variable} ${geistMono.variable} ${GeistPixelSquare.variable} bg-white text-neutral-900 antialiased dark:bg-neutral-950 dark:text-neutral-100`}
|
||||
>
|
||||
<ThemeProvider>
|
||||
<Header />
|
||||
<DocsMobileNav />
|
||||
<div className="max-w-5xl mx-auto px-6 py-8 lg:py-12 flex gap-16">
|
||||
<aside className="w-48 shrink-0 hidden lg:block sticky top-28 h-[calc(100vh-7rem)] overflow-y-auto">
|
||||
<DocsSidebar />
|
||||
</aside>
|
||||
<div className="flex-1 min-w-0 max-w-2xl pb-20">
|
||||
<div className="flex justify-end mb-4">
|
||||
<CopyPageButton />
|
||||
</div>
|
||||
<article className="prose">{children}</article>
|
||||
</div>
|
||||
</div>
|
||||
<DocsChat defaultOpen={chatOpen} defaultWidth={chatWidth} />
|
||||
</ThemeProvider>
|
||||
<SpeedInsights />
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
|
||||
export const metadata = pageMetadata("native-mode")
|
||||
|
||||
# Native Mode (Experimental)
|
||||
|
||||
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.
|
||||
|
||||
## 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)
|
||||
- **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
|
||||
```
|
||||
@@ -1,16 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPageTitle, renderOgImage } from "../og-image";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ slug: string[] }> },
|
||||
) {
|
||||
const { slug } = await params;
|
||||
const title = getPageTitle(slug.join("/"));
|
||||
|
||||
if (!title) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return renderOgImage(title);
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
export { getPageTitle } from "@/lib/page-titles";
|
||||
|
||||
let fontCache: { geistRegular: Buffer; geistPixelSquare: Buffer } | null =
|
||||
null;
|
||||
|
||||
async function loadFonts() {
|
||||
if (fontCache) return fontCache;
|
||||
const [geistRegular, geistPixelSquare] = await Promise.all([
|
||||
readFile(join(process.cwd(), "public/Geist-Regular.ttf")),
|
||||
readFile(join(process.cwd(), "public/GeistPixel-Square.ttf")),
|
||||
]);
|
||||
fontCache = { geistRegular, geistPixelSquare };
|
||||
return fontCache;
|
||||
}
|
||||
|
||||
export async function renderOgImage(title: string) {
|
||||
const { geistRegular, geistPixelSquare } = await loadFonts();
|
||||
|
||||
return new ImageResponse(
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
backgroundColor: "black",
|
||||
padding: "60px 80px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "16px",
|
||||
}}
|
||||
>
|
||||
<svg width="36" height="36" viewBox="0 0 16 16" fill="white">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M8 1L16 15H0L8 1Z" />
|
||||
</svg>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 36,
|
||||
color: "#666",
|
||||
fontFamily: "Geist",
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
/
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 36,
|
||||
fontFamily: "GeistPixelSquare",
|
||||
fontWeight: 400,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
agent-browser
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flex: 1,
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{title.split("\n").map((line, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
fontSize: 72,
|
||||
fontFamily: "Geist",
|
||||
fontWeight: 400,
|
||||
color: "white",
|
||||
letterSpacing: "-0.02em",
|
||||
textAlign: "center",
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{line}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
fonts: [
|
||||
{
|
||||
name: "Geist",
|
||||
data: geistRegular.buffer as ArrayBuffer,
|
||||
style: "normal",
|
||||
weight: 400,
|
||||
},
|
||||
{
|
||||
name: "GeistPixelSquare",
|
||||
data: geistPixelSquare.buffer as ArrayBuffer,
|
||||
style: "normal",
|
||||
weight: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { getPageTitle, renderOgImage } from "./og-image";
|
||||
|
||||
export async function GET() {
|
||||
const title = getPageTitle("")!;
|
||||
return renderOgImage(title);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
|
||||
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.
|
||||
|
||||
```bash
|
||||
npm install -g agent-browser # all platforms (fastest, native Rust CLI)
|
||||
brew install agent-browser # macOS
|
||||
|
||||
# or try without installing
|
||||
npx agent-browser open example.com
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Agent-first** - Compact text output uses fewer tokens than JSON, designed for AI context efficiency
|
||||
- **Ref-based** - Snapshot returns accessibility tree with refs for deterministic element selection
|
||||
- **Fast** - Native Rust CLI for instant command parsing
|
||||
- **Complete** - 50+ commands for navigation, forms, screenshots, network, storage
|
||||
- **Sessions** - Multiple isolated browser instances with separate auth
|
||||
- **Cross-platform** - macOS, Linux, Windows with native binaries
|
||||
|
||||
## Works with
|
||||
|
||||
Claude Code, Cursor, GitHub Copilot, OpenAI Codex, Google Gemini, opencode, and any agent that can run shell commands.
|
||||
|
||||
## Example
|
||||
|
||||
```bash
|
||||
# Navigate and get snapshot
|
||||
agent-browser open example.com
|
||||
agent-browser snapshot -i
|
||||
|
||||
# Output:
|
||||
# - heading "Example Domain" [ref=e1]
|
||||
# - link "More information..." [ref=e2]
|
||||
|
||||
# Interact using refs
|
||||
agent-browser click @e2
|
||||
agent-browser screenshot page.png
|
||||
agent-browser close
|
||||
```
|
||||
|
||||
## Why refs?
|
||||
|
||||
The `snapshot` command returns a compact accessibility tree where each element
|
||||
has a unique ref like `@e1`, `@e2`. This provides:
|
||||
|
||||
- **Context-efficient** - Text output uses ~200-400 tokens vs ~3000-5000 for full DOM
|
||||
- **Deterministic** - Ref points to exact element from snapshot
|
||||
- **Fast** - No DOM re-query needed
|
||||
- **AI-friendly** - LLMs parse text output naturally
|
||||
|
||||
## Architecture
|
||||
|
||||
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
|
||||
|
||||
Daemon starts automatically and persists between commands.
|
||||
|
||||
## Platforms
|
||||
|
||||
Native Rust binaries for macOS (ARM64, x64), Linux (ARM64, x64), and Windows (x64).
|
||||
@@ -1,114 +0,0 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
|
||||
export const metadata = pageMetadata("profiler")
|
||||
|
||||
# Profiler
|
||||
|
||||
Capture Chrome DevTools performance profiles during browser automation.
|
||||
Use profiles to diagnose slow page loads, expensive JavaScript, layout thrashing,
|
||||
and other performance bottlenecks in agentic workflows.
|
||||
|
||||
## Basic usage
|
||||
|
||||
```bash
|
||||
# Start profiling
|
||||
agent-browser profiler start
|
||||
|
||||
# Perform actions
|
||||
agent-browser navigate https://example.com
|
||||
agent-browser click "#button"
|
||||
|
||||
# Stop and save profile
|
||||
agent-browser profiler stop ./trace.json
|
||||
```
|
||||
|
||||
The output JSON file can be loaded into Chrome DevTools, Perfetto UI, or any
|
||||
tool that accepts Chrome Trace Event format.
|
||||
|
||||
## Commands
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Command</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>profiler start</code></td><td>Start recording a performance profile</td></tr>
|
||||
<tr><td><code>profiler start --categories <list></code></td><td>Start with custom trace categories</td></tr>
|
||||
<tr><td><code>profiler stop [path]</code></td><td>Stop profiling and save to file</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Trace categories
|
||||
|
||||
The `--categories` flag accepts a comma-separated list of Chrome trace categories.
|
||||
|
||||
```bash
|
||||
agent-browser profiler start --categories "devtools.timeline,v8.execute,blink.user_timing"
|
||||
```
|
||||
|
||||
Default categories include `devtools.timeline`, `v8.execute`, `blink`,
|
||||
`blink.user_timing`, `latencyInfo`, `renderer.scheduler`, `toplevel`, and
|
||||
several `disabled-by-default-*` categories for detailed CPU profiling and
|
||||
call stack analysis.
|
||||
|
||||
### Common categories
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Category</th><th>What it captures</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>devtools.timeline</code></td><td>Standard DevTools performance events</td></tr>
|
||||
<tr><td><code>v8.execute</code></td><td>Time spent running JavaScript</td></tr>
|
||||
<tr><td><code>blink</code></td><td>Renderer events (layout, paint, style)</td></tr>
|
||||
<tr><td><code>blink.user_timing</code></td><td><code>performance.mark()</code> and <code>performance.measure()</code> calls</td></tr>
|
||||
<tr><td><code>latencyInfo</code></td><td>Input-to-display latency</td></tr>
|
||||
<tr><td><code>disabled-by-default-v8.cpu_profiler</code></td><td>Sampling-based JS CPU profiling</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Output format
|
||||
|
||||
The output is a JSON file in Chrome Trace Event format:
|
||||
|
||||
```json
|
||||
{
|
||||
"traceEvents": [
|
||||
{
|
||||
"cat": "devtools.timeline",
|
||||
"name": "RunTask",
|
||||
"ph": "X",
|
||||
"ts": 12345,
|
||||
"dur": 100,
|
||||
"pid": 1,
|
||||
"tid": 1
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"clock-domain": "LINUX_CLOCK_MONOTONIC"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `metadata.clock-domain` field reflects the host platform (Linux or macOS).
|
||||
On Windows it is omitted.
|
||||
|
||||
## Viewing profiles
|
||||
|
||||
- **Chrome DevTools** -- Performance panel > Load profile
|
||||
- **Perfetto** -- https://ui.perfetto.dev/ (drag and drop the JSON file)
|
||||
- **Trace Viewer** -- `chrome://tracing` in any Chromium browser
|
||||
|
||||
## Use cases
|
||||
|
||||
- **Page load analysis** -- Profile navigation to identify slow resources, long tasks, or layout shifts
|
||||
- **Interaction profiling** -- Measure the cost of clicks, form fills, and other user interactions
|
||||
- **CI regression checks** -- Capture profiles per build and compare trace data over time
|
||||
- **Agent workflow optimization** -- Find which steps in an agentic flow are most expensive
|
||||
|
||||
## Limitations
|
||||
|
||||
- Only works with Chromium-based browsers (Chrome, Edge). Not supported on Firefox or WebKit.
|
||||
- Trace data accumulates in memory while profiling is active (capped at 5 million events). Stop profiling promptly after the area of interest.
|
||||
- Data collection on stop has a 30-second timeout. If the browser is unresponsive, the stop command may fail.
|
||||
- When no output path is provided, the profile is saved to an auto-generated path under the agent-browser temp directory.
|
||||
@@ -1,94 +0,0 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
|
||||
export const metadata = pageMetadata("quick-start")
|
||||
|
||||
# Quick Start
|
||||
|
||||
## Core workflow
|
||||
|
||||
Every browser automation follows this pattern:
|
||||
|
||||
```bash
|
||||
# 1. Navigate
|
||||
agent-browser open example.com
|
||||
|
||||
# 2. Snapshot to get element refs
|
||||
agent-browser snapshot -i
|
||||
# Output:
|
||||
# @e1 [heading] "Example Domain"
|
||||
# @e2 [link] "More information..."
|
||||
|
||||
# 3. Interact using refs
|
||||
agent-browser click @e2
|
||||
|
||||
# 4. Re-snapshot after page changes
|
||||
agent-browser snapshot -i
|
||||
```
|
||||
|
||||
## Common commands
|
||||
|
||||
```bash
|
||||
agent-browser open example.com
|
||||
agent-browser snapshot -i # Get interactive elements with refs
|
||||
agent-browser click @e2 # Click by ref
|
||||
agent-browser fill @e3 "test@example.com" # Fill input by ref
|
||||
agent-browser get text @e1 # Get text content
|
||||
agent-browser screenshot # Save to temp directory
|
||||
agent-browser screenshot page.png # Save to specific path
|
||||
agent-browser close
|
||||
```
|
||||
|
||||
## Traditional selectors
|
||||
|
||||
CSS selectors and semantic locators also supported:
|
||||
|
||||
```bash
|
||||
agent-browser click "#submit"
|
||||
agent-browser fill "#email" "test@example.com"
|
||||
agent-browser find role button click --name "Submit"
|
||||
```
|
||||
|
||||
## Headed mode
|
||||
|
||||
Show browser window for debugging:
|
||||
|
||||
```bash
|
||||
agent-browser open example.com --headed
|
||||
```
|
||||
|
||||
## Wait for content
|
||||
|
||||
```bash
|
||||
agent-browser wait @e1 # Wait for element
|
||||
agent-browser wait --load networkidle # Wait for network idle
|
||||
agent-browser wait --url "**/dashboard" # Wait for URL pattern
|
||||
agent-browser wait 2000 # Wait milliseconds
|
||||
```
|
||||
|
||||
## Command chaining
|
||||
|
||||
Chain commands with `&&` in a single shell call. The browser persists via a background daemon, so chaining is safe and efficient:
|
||||
|
||||
```bash
|
||||
# Open, wait, and snapshot in one call
|
||||
agent-browser open example.com && agent-browser wait --load networkidle && agent-browser snapshot -i
|
||||
|
||||
# Chain multiple interactions
|
||||
agent-browser fill @e1 "user@example.com" && agent-browser fill @e2 "pass" && agent-browser click @e3
|
||||
|
||||
# Navigate and capture
|
||||
agent-browser open example.com && agent-browser wait --load networkidle && agent-browser screenshot page.png
|
||||
```
|
||||
|
||||
Use `&&` when you don't need intermediate output. Run commands separately when you need to parse output first (e.g., snapshot to discover refs before interacting).
|
||||
|
||||
## JSON output
|
||||
|
||||
For programmatic parsing in scripts:
|
||||
|
||||
```bash
|
||||
agent-browser snapshot --json
|
||||
agent-browser get text @e1 --json
|
||||
```
|
||||
|
||||
Note: The default text output is more compact and preferred for AI agents.
|
||||
@@ -1,243 +0,0 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("security")
|
||||
|
||||
# Security
|
||||
|
||||
agent-browser includes security features to protect against credential exposure, prompt injection via untrusted page content, and unauthorized browser actions.
|
||||
|
||||
All security features are opt-in. By default, agent-browser imposes no restrictions on navigation, actions, or output. Enable these features as needed for your deployment -- existing workflows are unaffected until you explicitly activate a feature.
|
||||
|
||||
## Threat Model
|
||||
|
||||
These features are designed to mitigate the following threats when an LLM-based agent drives a browser:
|
||||
|
||||
- **Credential exposure** -- Passwords stored in the auth vault are never included in LLM context. The CLI handles vault operations locally; credentials do not pass through the daemon's IPC channel.
|
||||
- **Prompt injection via page content** -- Malicious pages can embed text that looks like tool output or system instructions. Content boundary markers (`--content-boundaries`) let the orchestrator distinguish trusted tool output from untrusted page content.
|
||||
- **Unauthorized navigation / data exfiltration** -- A compromised or manipulated agent could navigate to attacker-controlled domains to exfiltrate data. The domain allowlist (`--allowed-domains`) blocks navigations, sub-resource requests, WebSocket connections, EventSource streams, and `sendBeacon` calls to non-allowed domains.
|
||||
- **Unauthorized destructive actions** -- Action policy (`--action-policy`) and confirmation gating (`--confirm-actions`) prevent the agent from performing dangerous operations (eval, downloads, uploads) without explicit approval.
|
||||
- **Context flooding** -- Large page outputs can overwhelm an LLM's context window. Output truncation (`--max-output`) caps the size of page-sourced content.
|
||||
|
||||
### Known limitations
|
||||
|
||||
- **WebSocket/EventSource blocking is best-effort.** It works by overriding browser constructors via an init script. If the `eval` action category is allowed, page scripts could theoretically restore the original constructors. Deny `eval` via `--action-policy` for maximum protection.
|
||||
- **Domain filter timing on remote connections.** When connecting to a pre-existing browser via CDP or a cloud provider, pages may have already loaded content before the domain filter is installed. agent-browser navigates disallowed pages to `about:blank` after the filter is active, but resources loaded before that point are not retroactively blocked.
|
||||
- **Content boundaries are defense-in-depth.** They rely on the LLM and orchestrator respecting the structural markers. A sufficiently capable adversarial page could attempt to mimic the boundary format, though the per-process CSPRNG nonce makes this impractical to predict.
|
||||
- **Confirmation timeout.** Pending confirmations auto-deny after 60 seconds. Orchestrators must respond within that window.
|
||||
- **Non-TTY auto-deny.** When `--confirm-interactive` is set but stdin is not a terminal (e.g., piped input), actions are automatically denied to prevent accidental approval in non-interactive contexts.
|
||||
|
||||
## Authentication Vault
|
||||
|
||||
Store credentials locally and reference them by name. The LLM never sees passwords.
|
||||
|
||||
```bash
|
||||
# Save credentials (encrypted if AGENT_BROWSER_ENCRYPTION_KEY is set)
|
||||
# Recommended: pipe password via stdin to avoid shell history / process listing exposure
|
||||
echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin
|
||||
|
||||
# Or pass directly (a warning will be shown)
|
||||
agent-browser auth save github --url https://github.com/login --username user --password pass
|
||||
|
||||
# Login using saved credentials
|
||||
agent-browser auth login github
|
||||
|
||||
# List saved profiles (names and URLs only, no secrets)
|
||||
agent-browser auth list
|
||||
|
||||
# Show profile metadata
|
||||
agent-browser auth show github
|
||||
|
||||
# Delete a profile
|
||||
agent-browser auth delete github
|
||||
```
|
||||
|
||||
Custom selectors can be specified if auto-detection fails:
|
||||
|
||||
```bash
|
||||
agent-browser auth save myapp \
|
||||
--url https://app.example.com/login \
|
||||
--username user --password pass \
|
||||
--username-selector "#email" \
|
||||
--password-selector "#password" \
|
||||
--submit-selector "button.login"
|
||||
```
|
||||
|
||||
Profiles are stored in `~/.agent-browser/auth/` and always encrypted with AES-256-GCM. If `AGENT_BROWSER_ENCRYPTION_KEY` is not set, a key is auto-generated at `~/.agent-browser/.encryption-key` on first use. Back up this file or set the environment variable explicitly for portability.
|
||||
|
||||
File permissions are enforced on both Unix (`chmod 600`/`700`) and Windows (`icacls` restricted to the current user) to prevent other users from reading encryption keys or auth profiles.
|
||||
|
||||
## Content Boundary Markers
|
||||
|
||||
When `--content-boundaries` is enabled, all page-sourced output is wrapped in structural markers so LLMs can distinguish tool output from untrusted page content:
|
||||
|
||||
```
|
||||
--- AGENT_BROWSER_PAGE_CONTENT nonce=a1b2c3d4 origin=https://example.com ---
|
||||
[snapshot / text / html / eval output here]
|
||||
--- END_AGENT_BROWSER_PAGE_CONTENT nonce=a1b2c3d4 ---
|
||||
```
|
||||
|
||||
The nonce is a random value generated per CLI process invocation, making it unpredictable to page content that might attempt to spoof the boundary.
|
||||
|
||||
Enable via flag or environment variable:
|
||||
|
||||
```bash
|
||||
agent-browser --content-boundaries snapshot
|
||||
# or
|
||||
export AGENT_BROWSER_CONTENT_BOUNDARIES=1
|
||||
```
|
||||
|
||||
Affected output types: `snapshot`, `get text`, `get html`, `eval`, `console`.
|
||||
|
||||
In `--json` mode, boundary metadata is injected into the JSON response as a `_boundary` object containing `nonce` and `origin` fields, allowing orchestrators to verify provenance programmatically:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": { "snapshot": "...", "origin": "https://example.com" },
|
||||
"_boundary": { "nonce": "a1b2c3d4e5f6...", "origin": "https://example.com" }
|
||||
}
|
||||
```
|
||||
|
||||
## Domain Allowlist
|
||||
|
||||
Restrict which domains the browser can interact with, preventing redirect-based attacks and data exfiltration:
|
||||
|
||||
```bash
|
||||
agent-browser --allowed-domains "example.com,*.example.com,github.com" open https://example.com
|
||||
# or
|
||||
export AGENT_BROWSER_ALLOWED_DOMAINS="example.com,*.example.com"
|
||||
```
|
||||
|
||||
Supports exact match (`github.com`) and wildcard prefix (`*.example.com`, which also matches the bare domain `example.com`). Both page navigations and sub-resource requests (scripts, images, fetch, XHR, etc.) to non-allowed domains are blocked, preventing data exfiltration. WebSocket and EventSource connections are also blocked via constructor-level patching. Non-http(s) sub-resources (data URIs, blobs) are still allowed. When a request is blocked, the command returns an error.
|
||||
|
||||
> **Note:** The WebSocket/EventSource blocking is best-effort -- it works by overriding the browser constructors via an init script. If the `eval` action category is allowed, page scripts could theoretically restore the original constructors. For maximum protection, deny the `eval` category via `--action-policy` when using `--allowed-domains`.
|
||||
|
||||
Config file:
|
||||
|
||||
```json
|
||||
{
|
||||
"allowedDomains": ["example.com", "*.example.com", "github.com"]
|
||||
}
|
||||
```
|
||||
|
||||
> **CDN and third-party resources:** The domain filter blocks all sub-resource requests (scripts, stylesheets, images, fonts, fetch/XHR) to non-allowed domains. Most websites load assets from CDN domains. Include these in your allowlist or pages will break. For example:
|
||||
>
|
||||
> ```bash
|
||||
> --allowed-domains "myapp.com,*.myapp.com,cdn.jsdelivr.net,fonts.googleapis.com,fonts.gstatic.com"
|
||||
> ```
|
||||
|
||||
## Action Policy
|
||||
|
||||
Gate actions using a static policy file. The policy is enforced by the daemon -- denied actions fail immediately.
|
||||
|
||||
```bash
|
||||
agent-browser --action-policy ./policy.json open https://example.com
|
||||
# or
|
||||
export AGENT_BROWSER_ACTION_POLICY=./policy.json
|
||||
```
|
||||
|
||||
Example policy (permissive with specific denials):
|
||||
|
||||
```json
|
||||
{
|
||||
"default": "allow",
|
||||
"deny": ["eval", "download", "upload"]
|
||||
}
|
||||
```
|
||||
|
||||
Example policy (restrictive):
|
||||
|
||||
```json
|
||||
{
|
||||
"default": "deny",
|
||||
"allow": ["navigate", "snapshot", "click", "scroll", "wait", "get"]
|
||||
}
|
||||
```
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Category</th><th>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>navigate</code></td><td>open, back, forward, reload, tab new</td></tr>
|
||||
<tr><td><code>click</code></td><td>click, dblclick, tap</td></tr>
|
||||
<tr><td><code>fill</code></td><td>fill, type, keyboard type/inserttext, select, check, uncheck</td></tr>
|
||||
<tr><td><code>eval</code></td><td>eval, evalhandle, addscript, addinitscript, addstyle, expose, setcontent</td></tr>
|
||||
<tr><td><code>download</code></td><td>download, waitfordownload</td></tr>
|
||||
<tr><td><code>upload</code></td><td>upload</td></tr>
|
||||
<tr><td><code>snapshot</code></td><td>snapshot, screenshot, pdf, diff</td></tr>
|
||||
<tr><td><code>scroll</code></td><td>scroll, scrollintoview</td></tr>
|
||||
<tr><td><code>wait</code></td><td>wait, waitforurl, waitforloadstate, waitforfunction</td></tr>
|
||||
<tr><td><code>get</code></td><td>get text/html/url/title, count, isvisible, getbyrole, getbytext, getbylabel, etc.</td></tr>
|
||||
<tr><td><code>interact</code></td><td>hover, focus, drag, press, keydown, keyup, mousemove, dispatch</td></tr>
|
||||
<tr><td><code>network</code></td><td>network route/unroute, requests</td></tr>
|
||||
<tr><td><code>state</code></td><td>state save/load, cookies set, storage set</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Auth vault operations (`auth save`, `auth login`, `auth list`, `auth show`, `auth delete`) and other internal/meta operations bypass action policy enforcement since they are trusted local operations. Domain allowlist restrictions still apply to `auth login` navigations.
|
||||
|
||||
## Action Confirmation
|
||||
|
||||
For actions that require explicit approval, use `--confirm-actions` to specify categories that require confirmation:
|
||||
|
||||
```bash
|
||||
# Orchestrator mode: returns confirmation_required response
|
||||
agent-browser --confirm-actions eval,download eval "document.title"
|
||||
|
||||
# Then approve or deny:
|
||||
agent-browser confirm c_8f3a1234
|
||||
agent-browser deny c_8f3a1234
|
||||
```
|
||||
|
||||
For interactive (human-in-the-loop) confirmation:
|
||||
|
||||
```bash
|
||||
agent-browser --confirm-actions eval,download --confirm-interactive eval "document.title"
|
||||
# Prompts: Allow? [y/N]
|
||||
```
|
||||
|
||||
Pending confirmations auto-deny after 60 seconds.
|
||||
|
||||
> **Non-TTY behavior:** When `--confirm-interactive` is set but stdin is not a TTY (e.g., piped input or running inside an automated pipeline), actions are automatically denied. This prevents accidental approval in non-interactive contexts.
|
||||
|
||||
## Output Length Limits
|
||||
|
||||
Prevent context flooding by truncating large page outputs:
|
||||
|
||||
```bash
|
||||
agent-browser --max-output 50000 get text body
|
||||
# or
|
||||
export AGENT_BROWSER_MAX_OUTPUT=50000
|
||||
```
|
||||
|
||||
Affected output types: `snapshot`, `get text`, `get html`, `eval`, `console`.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Variable</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>AGENT_BROWSER_CONTENT_BOUNDARIES</code></td><td>Wrap page output in boundary markers</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_MAX_OUTPUT</code></td><td>Max characters for page output</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_ALLOWED_DOMAINS</code></td><td>Comma-separated allowed domain patterns</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_ACTION_POLICY</code></td><td>Path to action policy JSON file</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_CONFIRM_ACTIONS</code></td><td>Comma-separated action categories requiring confirmation</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_CONFIRM_INTERACTIVE</code></td><td>Enable interactive confirmation prompts</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_ENCRYPTION_KEY</code></td><td>64-char hex key for AES-256-GCM encryption (auth vault + sessions)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Recommended Configuration
|
||||
|
||||
For production AI agent deployments:
|
||||
|
||||
```json
|
||||
{
|
||||
"contentBoundaries": true,
|
||||
"maxOutput": 50000,
|
||||
"allowedDomains": ["your-app.com", "*.your-app.com"],
|
||||
"actionPolicy": "./policy.json"
|
||||
}
|
||||
```
|
||||
@@ -1,58 +0,0 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
|
||||
export const metadata = pageMetadata("selectors")
|
||||
|
||||
# Selectors
|
||||
|
||||
## Refs (recommended)
|
||||
|
||||
Refs provide deterministic element selection from snapshots. Best for AI agents.
|
||||
|
||||
```bash
|
||||
# 1. Get snapshot with refs
|
||||
agent-browser snapshot
|
||||
# Output:
|
||||
# - heading "Example Domain" [ref=e1] [level=1]
|
||||
# - button "Submit" [ref=e2]
|
||||
# - textbox "Email" [ref=e3]
|
||||
# - link "Learn more" [ref=e4]
|
||||
|
||||
# 2. Use refs to interact
|
||||
agent-browser click @e2 # Click the button
|
||||
agent-browser fill @e3 "test@example.com" # Fill the textbox
|
||||
agent-browser get text @e1 # Get heading text
|
||||
agent-browser hover @e4 # Hover the link
|
||||
```
|
||||
|
||||
### Why refs?
|
||||
|
||||
- **Deterministic** - Ref points to exact element from snapshot
|
||||
- **Fast** - No DOM re-query needed
|
||||
- **AI-friendly** - LLMs can reliably parse and use refs
|
||||
|
||||
## CSS selectors
|
||||
|
||||
```bash
|
||||
agent-browser click "#id"
|
||||
agent-browser click ".class"
|
||||
agent-browser click "div > button"
|
||||
agent-browser click "[data-testid='submit']"
|
||||
```
|
||||
|
||||
## Text & XPath
|
||||
|
||||
```bash
|
||||
agent-browser click "text=Submit"
|
||||
agent-browser click "xpath=//button[@type='submit']"
|
||||
```
|
||||
|
||||
## Semantic locators
|
||||
|
||||
Find elements by role, label, or other semantic properties:
|
||||
|
||||
```bash
|
||||
agent-browser find role button click --name "Submit"
|
||||
agent-browser find label "Email" fill "test@test.com"
|
||||
agent-browser find placeholder "Search..." fill "query"
|
||||
agent-browser find testid "submit-btn" click
|
||||
```
|
||||
@@ -1,196 +0,0 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
|
||||
export const metadata = pageMetadata("sessions")
|
||||
|
||||
# Sessions
|
||||
|
||||
Run multiple isolated browser instances:
|
||||
|
||||
```bash
|
||||
# Different sessions
|
||||
agent-browser --session agent1 open site-a.com
|
||||
agent-browser --session agent2 open site-b.com
|
||||
|
||||
# Or via environment variable
|
||||
AGENT_BROWSER_SESSION=agent1 agent-browser click "#btn"
|
||||
|
||||
# List active sessions
|
||||
agent-browser session list
|
||||
# Output:
|
||||
# Active sessions:
|
||||
# -> default
|
||||
# agent1
|
||||
|
||||
# Show current session
|
||||
agent-browser session
|
||||
```
|
||||
|
||||
## Session isolation
|
||||
|
||||
Each session has its own:
|
||||
|
||||
- Browser instance
|
||||
- Cookies and storage
|
||||
- Navigation history
|
||||
- Authentication state
|
||||
|
||||
## Persistent profiles
|
||||
|
||||
By default, browser state is lost when the browser closes. Use `--profile` to persist state across restarts:
|
||||
|
||||
```bash
|
||||
# Use a persistent profile directory
|
||||
agent-browser --profile ~/.myapp-profile open myapp.com
|
||||
|
||||
# Login once, then reuse the authenticated session
|
||||
agent-browser --profile ~/.myapp-profile open myapp.com/dashboard
|
||||
|
||||
# Or via environment variable
|
||||
AGENT_BROWSER_PROFILE=~/.myapp-profile agent-browser open myapp.com
|
||||
```
|
||||
|
||||
The profile directory stores:
|
||||
|
||||
- Cookies and localStorage
|
||||
- IndexedDB data
|
||||
- Service workers
|
||||
- Browser cache
|
||||
- Login sessions
|
||||
|
||||
## Session persistence
|
||||
|
||||
Use `--session-name` to automatically save and restore cookies and localStorage across browser restarts:
|
||||
|
||||
```bash
|
||||
# Auto-save/load state for "twitter" session
|
||||
agent-browser --session-name twitter open twitter.com
|
||||
|
||||
# Login once, then state persists automatically
|
||||
agent-browser --session-name twitter click "#login"
|
||||
|
||||
# Or via environment variable
|
||||
export AGENT_BROWSER_SESSION_NAME=twitter
|
||||
agent-browser open twitter.com
|
||||
```
|
||||
|
||||
State files are stored in `~/.agent-browser/sessions/` and automatically loaded on daemon start.
|
||||
|
||||
### Session name rules
|
||||
|
||||
Session names must contain only alphanumeric characters, hyphens, and underscores:
|
||||
|
||||
```bash
|
||||
# Valid session names
|
||||
agent-browser --session-name my-project open example.com
|
||||
agent-browser --session-name test_session_v2 open example.com
|
||||
|
||||
# Invalid (will be rejected)
|
||||
agent-browser --session-name "../bad" open example.com # path traversal
|
||||
agent-browser --session-name "my session" open example.com # spaces
|
||||
agent-browser --session-name "foo/bar" open example.com # slashes
|
||||
```
|
||||
|
||||
## State encryption
|
||||
|
||||
Encrypt saved state files (cookies, localStorage) using AES-256-GCM:
|
||||
|
||||
```bash
|
||||
# Generate a 256-bit key (64 hex characters)
|
||||
openssl rand -hex 32
|
||||
|
||||
# Set the encryption key
|
||||
export AGENT_BROWSER_ENCRYPTION_KEY=<your-64-char-hex-key>
|
||||
|
||||
# State files are now encrypted automatically
|
||||
agent-browser --session-name secure-session open example.com
|
||||
|
||||
# List states shows encryption status
|
||||
agent-browser state list
|
||||
```
|
||||
|
||||
## State auto-expiration
|
||||
|
||||
Automatically delete old state files to prevent accumulation:
|
||||
|
||||
```bash
|
||||
# Set expiration (default: 30 days)
|
||||
export AGENT_BROWSER_STATE_EXPIRE_DAYS=7
|
||||
|
||||
# Manually clean old states
|
||||
agent-browser state clean --older-than 7
|
||||
```
|
||||
|
||||
## State management commands
|
||||
|
||||
```bash
|
||||
# List all saved states
|
||||
agent-browser state list
|
||||
|
||||
# Show state summary (cookies, origins, domains)
|
||||
agent-browser state show my-session-default.json
|
||||
|
||||
# Rename a state file
|
||||
agent-browser state rename old-name new-name
|
||||
|
||||
# Clear states for a specific session name
|
||||
agent-browser state clear my-session
|
||||
|
||||
# Clear all saved states
|
||||
agent-browser state clear --all
|
||||
|
||||
# Manual save/load (for custom paths)
|
||||
agent-browser state save ./backup.json
|
||||
agent-browser state load ./backup.json
|
||||
```
|
||||
|
||||
## Authenticated sessions
|
||||
|
||||
Use `--headers` to set HTTP headers for a specific origin:
|
||||
|
||||
```bash
|
||||
# Headers scoped to api.example.com only
|
||||
agent-browser open api.example.com --headers '{"Authorization": "Bearer <token>"}'
|
||||
|
||||
# Requests to api.example.com include the auth header
|
||||
agent-browser snapshot -i --json
|
||||
agent-browser click @e2
|
||||
|
||||
# Navigate to another domain - headers NOT sent
|
||||
agent-browser open other-site.com
|
||||
```
|
||||
|
||||
Useful for:
|
||||
|
||||
- **Skipping login flows** - Authenticate via headers
|
||||
- **Switching users** - Different auth tokens per session
|
||||
- **API testing** - Access protected endpoints
|
||||
- **Security** - Headers scoped to origin, not leaked
|
||||
|
||||
## Multiple origins
|
||||
|
||||
```bash
|
||||
agent-browser open api.example.com --headers '{"Authorization": "Bearer token1"}'
|
||||
agent-browser open api.acme.com --headers '{"Authorization": "Bearer token2"}'
|
||||
```
|
||||
|
||||
## Global headers
|
||||
|
||||
For headers on all domains:
|
||||
|
||||
```bash
|
||||
agent-browser set headers '{"X-Custom-Header": "value"}'
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Variable</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>AGENT_BROWSER_SESSION</code></td><td>Browser session ID (default: "default")</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_SESSION_NAME</code></td><td>Auto-save/load state persistence name</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_ENCRYPTION_KEY</code></td><td>64-char hex key for AES-256-GCM encryption</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_STATE_EXPIRE_DAYS</code></td><td>Auto-delete states older than N days (default: 30)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -1,60 +0,0 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
|
||||
export const metadata = pageMetadata("skills")
|
||||
|
||||
# Skills
|
||||
|
||||
agent-browser ships with skills that teach AI coding agents how to use it for specific workflows. Install a skill and your agent in Cursor, Claude Code, or Codex can automate browser tasks without manual guidance.
|
||||
|
||||
## Available Skills
|
||||
|
||||
- **agent-browser** — General browser automation: navigation, snapshots, forms, screenshots, data extraction, sessions, authentication, diffing, and the full command reference.
|
||||
- **dogfood** — Systematic exploratory testing. Navigates an app like a real user, finds bugs and UX issues, and produces a structured report with screenshots and repro videos.
|
||||
- **electron** — Automate any Electron app (VS Code, Slack, Discord, Figma, etc.) by connecting to its built-in Chrome DevTools Protocol port. This is how agent-browser drives native desktop apps like the Slack macOS app.
|
||||
- **slack** — Browser-based Slack automation. Check unreads, navigate channels, search conversations, send messages, and extract data — no API tokens needed.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npx skills add vercel-labs/agent-browser --skill agent-browser
|
||||
npx skills add vercel-labs/agent-browser --skill dogfood
|
||||
npx skills add vercel-labs/agent-browser --skill electron
|
||||
npx skills add vercel-labs/agent-browser --skill slack
|
||||
```
|
||||
|
||||
After installing, your AI agent will automatically activate the right skill when it encounters a matching request.
|
||||
|
||||
## agent-browser
|
||||
|
||||
The core skill. Teaches agents the full agent-browser API: the navigate-snapshot-interact-re-snapshot workflow, all commands, command chaining, authentication (auth vault and state persistence), sessions, diffing, JavaScript evaluation, annotated screenshots, semantic locators, and configuration.
|
||||
|
||||
Example agent interactions:
|
||||
|
||||
- "Open example.com and fill out the contact form"
|
||||
- "Take a screenshot of the dashboard after logging in"
|
||||
- "Compare staging and production versions of the homepage"
|
||||
|
||||
## dogfood
|
||||
|
||||
A structured workflow for exploratory testing. The agent opens a target URL, systematically explores the app (navigating pages, testing forms, clicking buttons, checking console errors), and documents every issue it finds with:
|
||||
|
||||
- Numbered repro steps
|
||||
- Step-by-step screenshots
|
||||
- Repro videos for interactive bugs
|
||||
- Severity classification
|
||||
|
||||
The output is a markdown report in an output directory, ready to hand to the responsible team. Run it with a single prompt like "dogfood vercel.com" or "QA http://localhost:3000 — focus on the billing page".
|
||||
|
||||
## electron
|
||||
|
||||
Electron apps (VS Code, Slack, Discord, Figma, Notion, Spotify, etc.) are built on Chromium and expose a Chrome DevTools Protocol (CDP) port that agent-browser can connect to. This skill teaches agents how to launch or connect to any Electron app, then use the standard snapshot-interact workflow to automate it.
|
||||
|
||||
Electron apps are built on Chromium, so they expose a Chrome DevTools Protocol (CDP) port that agent-browser can connect to. Launch the app with `--remote-debugging-port`, connect, and use the standard snapshot-interact workflow. This is the foundation that the **slack** skill builds on.
|
||||
|
||||
## slack
|
||||
|
||||
Browser-based Slack automation. Connects to an existing Slack session (via `agent-browser connect 9222`) or opens Slack in a new browser, then uses snapshots and element refs to navigate the UI. Covers checking unreads, navigating channels and DMs, searching conversations, extracting message data, and taking screenshots — all without needing Slack API tokens or bot setup.
|
||||
|
||||
## Source
|
||||
|
||||
All skill files are in the [`skills/`](https://github.com/vercel-labs/agent-browser/tree/main/skills) directory of the repository.
|
||||
@@ -1,120 +0,0 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
|
||||
export const metadata = pageMetadata("snapshots")
|
||||
|
||||
# Snapshots
|
||||
|
||||
The `snapshot` command returns a compact accessibility tree with refs for element interaction.
|
||||
|
||||
## Options
|
||||
|
||||
Filter output to reduce size:
|
||||
|
||||
```bash
|
||||
agent-browser snapshot # Full accessibility tree
|
||||
agent-browser snapshot -i # Interactive elements only (recommended)
|
||||
agent-browser snapshot -i -C # Include cursor-interactive elements
|
||||
agent-browser snapshot -c # Compact (remove empty elements)
|
||||
agent-browser snapshot -d 3 # Limit depth to 3 levels
|
||||
agent-browser snapshot -s "#main" # Scope to CSS selector
|
||||
agent-browser snapshot -i -c -d 5 # Combine options
|
||||
```
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Option</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>-i, --interactive</code></td><td>Only interactive elements (buttons, links, inputs)</td></tr>
|
||||
<tr><td><code>-C, --cursor</code></td><td>Include cursor-interactive elements (cursor:pointer, onclick, tabindex)</td></tr>
|
||||
<tr><td><code>-c, --compact</code></td><td>Remove empty structural elements</td></tr>
|
||||
<tr><td><code>-d, --depth</code></td><td>Limit tree depth</td></tr>
|
||||
<tr><td><code>-s, --selector</code></td><td>Scope to CSS selector</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Cursor-interactive elements
|
||||
|
||||
Many modern web apps use custom clickable elements (divs, spans) instead of standard buttons or links.
|
||||
The `-C` flag detects these by looking for:
|
||||
|
||||
- `cursor: pointer` CSS style
|
||||
- `onclick` attribute or handler
|
||||
- `tabindex` attribute (keyboard focusable)
|
||||
|
||||
```bash
|
||||
agent-browser snapshot -i -C
|
||||
# Output includes:
|
||||
# @e1 [button] "Submit"
|
||||
# @e2 [link] "Learn more"
|
||||
# Cursor-interactive elements:
|
||||
# @e3 [clickable] "Menu Item" [cursor:pointer, onclick]
|
||||
# @e4 [clickable] "Card" [cursor:pointer]
|
||||
```
|
||||
|
||||
## Output format
|
||||
|
||||
The default text output is compact and AI-friendly:
|
||||
|
||||
```bash
|
||||
agent-browser snapshot -i
|
||||
# Output:
|
||||
# @e1 [heading] "Example Domain" [level=1]
|
||||
# @e2 [button] "Submit"
|
||||
# @e3 [input type="email"] placeholder="Email"
|
||||
# @e4 [link] "Learn more"
|
||||
```
|
||||
|
||||
## Using refs
|
||||
|
||||
Refs from the snapshot map directly to commands:
|
||||
|
||||
```bash
|
||||
agent-browser click @e2 # Click the Submit button
|
||||
agent-browser fill @e3 "a@b.com" # Fill the email input
|
||||
agent-browser get text @e1 # Get heading text
|
||||
```
|
||||
|
||||
## Ref lifecycle
|
||||
|
||||
Refs are invalidated when the page changes. Always re-snapshot after navigation or DOM updates:
|
||||
|
||||
```bash
|
||||
agent-browser click @e4 # Navigates to new page
|
||||
agent-browser snapshot -i # Get fresh refs
|
||||
agent-browser click @e1 # Use new refs
|
||||
```
|
||||
|
||||
## Annotated screenshots
|
||||
|
||||
For visual context alongside text snapshots, use `screenshot --annotate` to overlay numbered labels on interactive elements. Each label `[N]` maps to ref `@eN`:
|
||||
|
||||
```bash
|
||||
agent-browser screenshot --annotate ./page.png
|
||||
# -> Screenshot saved to ./page.png
|
||||
# [1] @e1 button "Submit"
|
||||
# [2] @e2 link "Home"
|
||||
# [3] @e3 textbox "Email"
|
||||
agent-browser click @e2
|
||||
```
|
||||
|
||||
Annotated screenshots also cache refs, so you can interact with elements immediately. This is useful when the text snapshot is insufficient -- unlabeled icons, canvas content, or visual layout verification.
|
||||
|
||||
## Best practices
|
||||
|
||||
1. Use `-i` to reduce output to actionable elements
|
||||
2. Re-snapshot after page changes to get updated refs
|
||||
3. Scope with `-s` for specific page sections
|
||||
4. Use `-d` to limit depth on complex pages
|
||||
5. Use `screenshot --annotate` when visual context is needed alongside refs
|
||||
|
||||
## JSON output
|
||||
|
||||
For programmatic parsing in scripts:
|
||||
|
||||
```bash
|
||||
agent-browser snapshot --json
|
||||
# {"success":true,"data":{"snapshot":"...","refs":{"e1":{"role":"heading","name":"Title"},...}}}
|
||||
```
|
||||
|
||||
Note: JSON uses more tokens than text output. The default text format is preferred for AI agents.
|
||||
@@ -1,232 +0,0 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
|
||||
export const metadata = pageMetadata("streaming")
|
||||
|
||||
# Streaming
|
||||
|
||||
Stream the browser viewport via WebSocket for live preview or "pair browsing"
|
||||
where a human can watch and interact alongside an AI agent.
|
||||
|
||||
## Enable streaming
|
||||
|
||||
Set the `AGENT_BROWSER_STREAM_PORT` environment variable to start
|
||||
a WebSocket server:
|
||||
|
||||
```bash
|
||||
AGENT_BROWSER_STREAM_PORT=9223 agent-browser open example.com
|
||||
```
|
||||
|
||||
The server streams viewport frames and accepts input events (mouse, keyboard, touch).
|
||||
|
||||
## WebSocket protocol
|
||||
|
||||
Connect to `ws://localhost:9223` to receive frames and send input.
|
||||
|
||||
### Frame messages
|
||||
|
||||
The server sends frame messages with base64-encoded images:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "frame",
|
||||
"data": "<base64-encoded-jpeg>",
|
||||
"metadata": {
|
||||
"deviceWidth": 1280,
|
||||
"deviceHeight": 720,
|
||||
"pageScaleFactor": 1,
|
||||
"offsetTop": 0,
|
||||
"scrollOffsetX": 0,
|
||||
"scrollOffsetY": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Status messages
|
||||
|
||||
Connection and screencast status:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "status",
|
||||
"connected": true,
|
||||
"screencasting": true,
|
||||
"viewportWidth": 1280,
|
||||
"viewportHeight": 720
|
||||
}
|
||||
```
|
||||
|
||||
## Input injection
|
||||
|
||||
Send input events to control the browser remotely.
|
||||
|
||||
### Mouse events
|
||||
|
||||
```json
|
||||
// Click
|
||||
{
|
||||
"type": "input_mouse",
|
||||
"eventType": "mousePressed",
|
||||
"x": 100,
|
||||
"y": 200,
|
||||
"button": "left",
|
||||
"clickCount": 1
|
||||
}
|
||||
|
||||
// Release
|
||||
{
|
||||
"type": "input_mouse",
|
||||
"eventType": "mouseReleased",
|
||||
"x": 100,
|
||||
"y": 200,
|
||||
"button": "left"
|
||||
}
|
||||
|
||||
// Move
|
||||
{
|
||||
"type": "input_mouse",
|
||||
"eventType": "mouseMoved",
|
||||
"x": 150,
|
||||
"y": 250
|
||||
}
|
||||
|
||||
// Scroll
|
||||
{
|
||||
"type": "input_mouse",
|
||||
"eventType": "mouseWheel",
|
||||
"x": 100,
|
||||
"y": 200,
|
||||
"deltaX": 0,
|
||||
"deltaY": 100
|
||||
}
|
||||
```
|
||||
|
||||
### Keyboard events
|
||||
|
||||
```json
|
||||
// Key down
|
||||
{
|
||||
"type": "input_keyboard",
|
||||
"eventType": "keyDown",
|
||||
"key": "Enter",
|
||||
"code": "Enter"
|
||||
}
|
||||
|
||||
// Key up
|
||||
{
|
||||
"type": "input_keyboard",
|
||||
"eventType": "keyUp",
|
||||
"key": "Enter",
|
||||
"code": "Enter"
|
||||
}
|
||||
|
||||
// Type character
|
||||
{
|
||||
"type": "input_keyboard",
|
||||
"eventType": "char",
|
||||
"text": "a"
|
||||
}
|
||||
|
||||
// With modifiers (1=Alt, 2=Ctrl, 4=Meta, 8=Shift)
|
||||
{
|
||||
"type": "input_keyboard",
|
||||
"eventType": "keyDown",
|
||||
"key": "c",
|
||||
"code": "KeyC",
|
||||
"modifiers": 2
|
||||
}
|
||||
```
|
||||
|
||||
### Touch events
|
||||
|
||||
```json
|
||||
// Touch start
|
||||
{
|
||||
"type": "input_touch",
|
||||
"eventType": "touchStart",
|
||||
"touchPoints": [{ "x": 100, "y": 200 }]
|
||||
}
|
||||
|
||||
// Touch move
|
||||
{
|
||||
"type": "input_touch",
|
||||
"eventType": "touchMove",
|
||||
"touchPoints": [{ "x": 150, "y": 250 }]
|
||||
}
|
||||
|
||||
// Touch end
|
||||
{
|
||||
"type": "input_touch",
|
||||
"eventType": "touchEnd",
|
||||
"touchPoints": []
|
||||
}
|
||||
|
||||
// Multi-touch (pinch zoom)
|
||||
{
|
||||
"type": "input_touch",
|
||||
"eventType": "touchStart",
|
||||
"touchPoints": [
|
||||
{ "x": 100, "y": 200, "id": 0 },
|
||||
{ "x": 200, "y": 200, "id": 1 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Programmatic API
|
||||
|
||||
For advanced use, control streaming directly via the TypeScript API:
|
||||
|
||||
```typescript
|
||||
import { BrowserManager } from 'agent-browser';
|
||||
|
||||
const browser = new BrowserManager();
|
||||
await browser.launch({ headless: true });
|
||||
await browser.navigate('https://example.com');
|
||||
|
||||
// Start screencast with callback
|
||||
await browser.startScreencast((frame) => {
|
||||
console.log('Frame:', frame.metadata.deviceWidth, 'x', frame.metadata.deviceHeight);
|
||||
// frame.data is base64-encoded image
|
||||
}, {
|
||||
format: 'jpeg', // or 'png'
|
||||
quality: 80, // 0-100, jpeg only
|
||||
maxWidth: 1280,
|
||||
maxHeight: 720,
|
||||
everyNthFrame: 1
|
||||
});
|
||||
|
||||
// Inject mouse event
|
||||
await browser.injectMouseEvent({
|
||||
type: 'mousePressed',
|
||||
x: 100,
|
||||
y: 200,
|
||||
button: 'left',
|
||||
clickCount: 1
|
||||
});
|
||||
|
||||
// Inject keyboard event
|
||||
await browser.injectKeyboardEvent({
|
||||
type: 'keyDown',
|
||||
key: 'Enter',
|
||||
code: 'Enter'
|
||||
});
|
||||
|
||||
// Inject touch event
|
||||
await browser.injectTouchEvent({
|
||||
type: 'touchStart',
|
||||
touchPoints: [{ x: 100, y: 200 }]
|
||||
});
|
||||
|
||||
// Check if screencasting
|
||||
console.log('Active:', browser.isScreencasting());
|
||||
|
||||
// Stop screencast
|
||||
await browser.stopScreencast();
|
||||
```
|
||||
|
||||
## Use cases
|
||||
|
||||
- **Pair browsing** - Human watches and assists AI agent in real-time
|
||||
- **Remote preview** - View browser output in a separate UI
|
||||
- **Recording** - Capture frames for video generation
|
||||
- **Mobile testing** - Inject touch events for mobile emulation
|
||||
- **Accessibility testing** - Manual interaction during automated tests
|
||||
@@ -1,25 +0,0 @@
|
||||
import { codeToHtml } from "shiki";
|
||||
import { CopyButton } from "./copy-button";
|
||||
|
||||
interface CodeBlockProps {
|
||||
code: string;
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
export async function CodeBlock({ code, lang = "bash" }: CodeBlockProps) {
|
||||
const trimmedCode = code.trim();
|
||||
const html = await codeToHtml(trimmedCode, {
|
||||
lang,
|
||||
themes: {
|
||||
light: "github-light-default",
|
||||
dark: "github-dark-default",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="code-block relative group">
|
||||
<CopyButton code={trimmedCode} />
|
||||
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
interface CopyButtonProps {
|
||||
code: string;
|
||||
}
|
||||
|
||||
export function CopyButton({ code }: CopyButtonProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error("Failed to copy to clipboard:", error);
|
||||
// Optionally, you could set an error state or show a toast notification here
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="absolute top-2 right-2 p-1.5 rounded text-[#666] hover:text-[#999] hover:bg-[#333] opacity-0 group-hover:opacity-100 transition-all"
|
||||
aria-label="Copy code"
|
||||
>
|
||||
{copied ? (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export function CopyPageButton() {
|
||||
const pathname = usePathname();
|
||||
const [state, setState] = useState<"idle" | "loading" | "copied">("idle");
|
||||
|
||||
const handleCopy = async () => {
|
||||
setState("loading");
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/docs-markdown?path=${encodeURIComponent(pathname)}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch markdown");
|
||||
}
|
||||
const markdown = await response.text();
|
||||
await navigator.clipboard.writeText(markdown);
|
||||
setState("copied");
|
||||
setTimeout(() => setState("idle"), 2000);
|
||||
} catch {
|
||||
setState("idle");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
disabled={state === "loading"}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs text-muted-foreground hover:text-foreground border border-border rounded-md hover:bg-muted transition-colors disabled:opacity-50"
|
||||
aria-label="Copy page as Markdown"
|
||||
>
|
||||
{state === "copied" ? (
|
||||
<>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
Copy Page
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
"use client";
|
||||
|
||||
function DiffLine({ line }: { line: string }) {
|
||||
if (line.startsWith("+ ")) {
|
||||
return <div className="text-green-400">{line}</div>;
|
||||
}
|
||||
if (line.startsWith("- ")) {
|
||||
return <div className="text-red-400">{line}</div>;
|
||||
}
|
||||
return <div className="opacity-50">{line}</div>;
|
||||
}
|
||||
|
||||
function CommandLine({ children }: { children: string }) {
|
||||
return (
|
||||
<div>
|
||||
<span className="opacity-40">$ </span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Terminal({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="rounded border font-mono text-[0.8125rem] leading-[1.7] overflow-x-auto"
|
||||
style={{
|
||||
background: "var(--card)",
|
||||
borderColor: "var(--border)",
|
||||
padding: "0.875rem",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PageMockup({
|
||||
label,
|
||||
buttonColor,
|
||||
diffMode,
|
||||
}: {
|
||||
label: string;
|
||||
buttonColor: string;
|
||||
diffMode?: boolean;
|
||||
}) {
|
||||
const dimOpacity = diffMode ? 0.15 : 1;
|
||||
return (
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="text-[0.6875rem] font-medium mb-1.5 text-center"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<svg
|
||||
viewBox="0 0 160 120"
|
||||
className="w-full rounded border"
|
||||
style={{ borderColor: "var(--border)" }}
|
||||
>
|
||||
<rect width="160" height="120" fill={diffMode ? "#1a1a1a" : "#111"} />
|
||||
|
||||
{/* Nav bar */}
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="160"
|
||||
height="16"
|
||||
fill="#222"
|
||||
opacity={dimOpacity}
|
||||
/>
|
||||
<rect
|
||||
x="8"
|
||||
y="5"
|
||||
width="24"
|
||||
height="6"
|
||||
rx="1"
|
||||
fill="#555"
|
||||
opacity={dimOpacity}
|
||||
/>
|
||||
<rect
|
||||
x="120"
|
||||
y="5"
|
||||
width="12"
|
||||
height="6"
|
||||
rx="1"
|
||||
fill="#444"
|
||||
opacity={dimOpacity}
|
||||
/>
|
||||
<rect
|
||||
x="136"
|
||||
y="5"
|
||||
width="12"
|
||||
height="6"
|
||||
rx="1"
|
||||
fill="#444"
|
||||
opacity={dimOpacity}
|
||||
/>
|
||||
|
||||
{/* Heading */}
|
||||
<rect
|
||||
x="20"
|
||||
y="26"
|
||||
width="80"
|
||||
height="6"
|
||||
rx="1"
|
||||
fill="#666"
|
||||
opacity={dimOpacity}
|
||||
/>
|
||||
|
||||
{/* Subtext */}
|
||||
<rect
|
||||
x="30"
|
||||
y="38"
|
||||
width="60"
|
||||
height="4"
|
||||
rx="1"
|
||||
fill="#444"
|
||||
opacity={dimOpacity}
|
||||
/>
|
||||
|
||||
{/* Input field */}
|
||||
<rect
|
||||
x="30"
|
||||
y="52"
|
||||
width="100"
|
||||
height="14"
|
||||
rx="2"
|
||||
fill="#1a1a1a"
|
||||
stroke="#333"
|
||||
strokeWidth="0.5"
|
||||
opacity={dimOpacity}
|
||||
/>
|
||||
|
||||
{/* Button -- this is what changes */}
|
||||
{diffMode ? (
|
||||
<>
|
||||
<rect
|
||||
x="55"
|
||||
y="76"
|
||||
width="50"
|
||||
height="14"
|
||||
rx="2"
|
||||
fill="#ef4444"
|
||||
opacity="0.85"
|
||||
/>
|
||||
<rect
|
||||
x="55"
|
||||
y="76"
|
||||
width="50"
|
||||
height="14"
|
||||
rx="2"
|
||||
fill="none"
|
||||
stroke="#ef4444"
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray="3 2"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<rect
|
||||
x="55"
|
||||
y="76"
|
||||
width="50"
|
||||
height="14"
|
||||
rx="2"
|
||||
fill={buttonColor}
|
||||
/>
|
||||
)}
|
||||
<text
|
||||
x="80"
|
||||
y="85.5"
|
||||
textAnchor="middle"
|
||||
fill="white"
|
||||
fontSize="6"
|
||||
fontFamily="system-ui, sans-serif"
|
||||
opacity={diffMode ? 0.9 : 1}
|
||||
>
|
||||
Submit
|
||||
</text>
|
||||
|
||||
{/* Footer line */}
|
||||
<rect
|
||||
x="40"
|
||||
y="102"
|
||||
width="80"
|
||||
height="3"
|
||||
rx="1"
|
||||
fill="#333"
|
||||
opacity={dimOpacity}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const snapshotDiffLines = [
|
||||
" heading \"Sign Up\" [ref=e1]",
|
||||
" text \"Create your account\" [ref=e2]",
|
||||
"- textbox \"Email\" [ref=e3]",
|
||||
"+ textbox \"Email\" [ref=e3]: \"test@example.com\"",
|
||||
"- button \"Submit\" [ref=e4]",
|
||||
"+ button \"Submit\" [ref=e4] [disabled]",
|
||||
"+ status \"Sending...\" [ref=e7]",
|
||||
" link \"Already have an account?\" [ref=e5]",
|
||||
];
|
||||
|
||||
export function DiffDemo() {
|
||||
return (
|
||||
<div className="grid gap-8 my-8">
|
||||
{/* Panel 1: Snapshot diff */}
|
||||
<div>
|
||||
<div
|
||||
className="text-xs font-medium uppercase tracking-wider mb-3"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
>
|
||||
Verify an action changed the page
|
||||
</div>
|
||||
<Terminal>
|
||||
<div className="opacity-60 mb-2">
|
||||
<CommandLine>agent-browser snapshot -i</CommandLine>
|
||||
<CommandLine>
|
||||
agent-browser fill @e3 "test@example.com"
|
||||
</CommandLine>
|
||||
<CommandLine>agent-browser click @e4</CommandLine>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<CommandLine>agent-browser diff snapshot</CommandLine>
|
||||
</div>
|
||||
<div
|
||||
className="border-t pt-3"
|
||||
style={{ borderColor: "var(--border)" }}
|
||||
>
|
||||
{snapshotDiffLines.map((line, i) => (
|
||||
<DiffLine key={i} line={line} />
|
||||
))}
|
||||
<div className="mt-2 opacity-60">
|
||||
<span className="text-green-400">3</span> additions,{" "}
|
||||
<span className="text-red-400">2</span> removals,{" "}
|
||||
<span>3</span> unchanged
|
||||
</div>
|
||||
</div>
|
||||
</Terminal>
|
||||
</div>
|
||||
|
||||
{/* Panel 2: Screenshot diff */}
|
||||
<div>
|
||||
<div
|
||||
className="text-xs font-medium uppercase tracking-wider mb-3"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
>
|
||||
Catch a visual regression
|
||||
</div>
|
||||
<Terminal>
|
||||
<div className="mb-3">
|
||||
<CommandLine>
|
||||
agent-browser diff screenshot --baseline before-deploy.png
|
||||
</CommandLine>
|
||||
</div>
|
||||
<div
|
||||
className="border-t pt-3"
|
||||
style={{ borderColor: "var(--border)" }}
|
||||
>
|
||||
<div className="text-red-400">
|
||||
✗ 2.37% pixels differ
|
||||
</div>
|
||||
<div className="opacity-50">
|
||||
Diff image: ~/.agent-browser/tmp/diffs/diff-1708473621.png
|
||||
</div>
|
||||
<div className="opacity-50">
|
||||
<span className="text-red-400">1,137</span> different /{" "}
|
||||
48,000 total pixels
|
||||
</div>
|
||||
</div>
|
||||
</Terminal>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<PageMockup label="Baseline" buttonColor="#3b82f6" />
|
||||
<PageMockup label="Current" buttonColor="#22c55e" />
|
||||
<PageMockup label="Diff" buttonColor="#ef4444" diffMode />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,538 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useRef,
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { Streamdown } from "streamdown";
|
||||
import Link from "next/link";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
|
||||
const STORAGE_KEY = "docs-chat-messages";
|
||||
const transport = new DefaultChatTransport({ api: "/api/docs-chat" });
|
||||
|
||||
const DESKTOP_DEFAULT_WIDTH = 400;
|
||||
const DESKTOP_MIN_WIDTH = 300;
|
||||
const DESKTOP_MAX_WIDTH = 700;
|
||||
|
||||
function setCookie(name: string, value: string) {
|
||||
document.cookie = `${name}=${encodeURIComponent(value)};path=/;max-age=${60 * 60 * 24 * 365};samesite=lax`;
|
||||
}
|
||||
|
||||
const TOOL_LABELS: Record<
|
||||
string,
|
||||
{ label: string; pastLabel: string; argKey?: string }
|
||||
> = {
|
||||
readFile: { label: "Reading", pastLabel: "Read", argKey: "path" },
|
||||
bash: { label: "Running", pastLabel: "Ran", argKey: "command" },
|
||||
};
|
||||
|
||||
function isToolPart(part: { type: string }): part is {
|
||||
type: string;
|
||||
toolCallId: string;
|
||||
toolName?: string;
|
||||
state: string;
|
||||
input?: Record<string, unknown>;
|
||||
output?: unknown;
|
||||
errorText?: string;
|
||||
} {
|
||||
return part.type.startsWith("tool-") || part.type === "dynamic-tool";
|
||||
}
|
||||
|
||||
function getToolName(part: { type: string; toolName?: string }): string {
|
||||
if (part.type === "dynamic-tool") return part.toolName ?? "tool";
|
||||
return part.type.replace(/^tool-/, "");
|
||||
}
|
||||
|
||||
function ToolCallDisplay({
|
||||
part,
|
||||
}: {
|
||||
part: {
|
||||
type: string;
|
||||
toolCallId: string;
|
||||
toolName?: string;
|
||||
state: string;
|
||||
input?: Record<string, unknown>;
|
||||
output?: unknown;
|
||||
errorText?: string;
|
||||
};
|
||||
}) {
|
||||
const toolName = getToolName(part);
|
||||
const config = TOOL_LABELS[toolName] ?? {
|
||||
label: toolName,
|
||||
pastLabel: toolName,
|
||||
};
|
||||
const isDone = part.state === "output-available";
|
||||
const isError = part.state === "output-error";
|
||||
const isRunning = !isDone && !isError;
|
||||
const displayLabel = isRunning ? config.label : config.pastLabel;
|
||||
|
||||
const args = (part.input ?? {}) as Record<string, unknown>;
|
||||
const argValue = config.argKey ? args[config.argKey] : undefined;
|
||||
const argPreview =
|
||||
argValue != null
|
||||
? String(argValue)
|
||||
.replace(/^\/workspace\//, "/")
|
||||
.replace(/\.md$/, "")
|
||||
.replace(/\/index$/, "") || "/"
|
||||
: "";
|
||||
|
||||
// Link to the docs page if it's a readFile path
|
||||
const docsLink =
|
||||
toolName === "readFile" && argPreview.startsWith("/") ? argPreview : null;
|
||||
|
||||
const argEl = argPreview ? (
|
||||
docsLink ? (
|
||||
<Link href={docsLink} className="truncate underline underline-offset-2">
|
||||
{argPreview}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="truncate">{argPreview}</span>
|
||||
)
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="text-xs py-0.5 min-w-0">
|
||||
{isRunning ? (
|
||||
<span className="inline-flex items-center gap-1 font-mono text-muted-foreground animate-tool-shimmer min-w-0 max-w-full">
|
||||
<span className="shrink-0">{displayLabel}</span>
|
||||
{argEl}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 font-mono text-muted-foreground/60 min-w-0 max-w-full">
|
||||
<span className="shrink-0">{displayLabel}</span>
|
||||
{argEl}
|
||||
{isError && <span className="text-destructive">failed</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SUGGESTIONS = [
|
||||
"What is agent-browser?",
|
||||
"How do I install it?",
|
||||
"What commands are available?",
|
||||
"How do snapshots work?",
|
||||
"How do I use CDP mode?",
|
||||
];
|
||||
|
||||
export function DocsChat({
|
||||
defaultOpen = false,
|
||||
defaultWidth = DESKTOP_DEFAULT_WIDTH,
|
||||
}: {
|
||||
defaultOpen?: boolean;
|
||||
defaultWidth?: number;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const [input, setInput] = useState("");
|
||||
const [isDesktop, setIsDesktop] = useState(false);
|
||||
const [hasMounted, setHasMounted] = useState(false);
|
||||
const [desktopWidth, setDesktopWidth] = useState(
|
||||
Math.min(DESKTOP_MAX_WIDTH, Math.max(DESKTOP_MIN_WIDTH, defaultWidth)),
|
||||
);
|
||||
const messagesScrollRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const restoredRef = useRef(false);
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
const { messages, sendMessage, status, setMessages, error } = useChat({
|
||||
transport,
|
||||
});
|
||||
|
||||
const isLoading = status === "streaming" || status === "submitted";
|
||||
const showMessages = messages.length > 0 || !!error || isLoading;
|
||||
|
||||
// Detect desktop vs mobile. Close sidebar on mobile if it was open from cookie.
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(min-width: 640px)");
|
||||
setIsDesktop(mq.matches);
|
||||
setHasMounted(true);
|
||||
// If on mobile but sidebar was open from cookie, close it
|
||||
if (!mq.matches && defaultOpen) {
|
||||
setOpen(false);
|
||||
}
|
||||
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Persist open state to cookie (only after mount to avoid overwriting on mobile)
|
||||
useEffect(() => {
|
||||
if (hasMounted) {
|
||||
setCookie("docs-chat-open", String(open));
|
||||
}
|
||||
}, [open, hasMounted]);
|
||||
|
||||
// Push page content on desktop when pane is open.
|
||||
// Use padding on body so the page scrollbar stays at the viewport edge (behind the sidebar)
|
||||
// instead of appearing right next to the sidebar's scrollbar.
|
||||
useEffect(() => {
|
||||
const body = document.body;
|
||||
if (isDesktop && open) {
|
||||
body.style.paddingRight = `${desktopWidth}px`;
|
||||
if (!isDraggingRef.current) {
|
||||
body.style.transition = "padding-right 150ms ease";
|
||||
}
|
||||
} else if (isDesktop) {
|
||||
body.style.paddingRight = "0px";
|
||||
body.style.transition = "padding-right 150ms ease";
|
||||
}
|
||||
return () => {
|
||||
body.style.paddingRight = "0px";
|
||||
body.style.transition = "";
|
||||
};
|
||||
}, [isDesktop, open, desktopWidth]);
|
||||
|
||||
// Resize handle drag
|
||||
const handleResizePointerDown = useCallback(
|
||||
(e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
isDraggingRef.current = true;
|
||||
document.documentElement.style.transition = "none";
|
||||
const startX = e.clientX;
|
||||
const startWidth = desktopWidth;
|
||||
|
||||
const onPointerMove = (ev: globalThis.PointerEvent) => {
|
||||
const delta = startX - ev.clientX;
|
||||
const newWidth = Math.min(
|
||||
DESKTOP_MAX_WIDTH,
|
||||
Math.max(DESKTOP_MIN_WIDTH, startWidth + delta),
|
||||
);
|
||||
setDesktopWidth(newWidth);
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
isDraggingRef.current = false;
|
||||
document.documentElement.style.transition = "";
|
||||
document.removeEventListener("pointermove", onPointerMove);
|
||||
document.removeEventListener("pointerup", onPointerUp);
|
||||
};
|
||||
|
||||
document.addEventListener("pointermove", onPointerMove);
|
||||
document.addEventListener("pointerup", onPointerUp);
|
||||
},
|
||||
[desktopWidth],
|
||||
);
|
||||
|
||||
// Persist width to cookie
|
||||
useEffect(() => {
|
||||
setCookie("docs-chat-width", String(desktopWidth));
|
||||
}, [desktopWidth]);
|
||||
|
||||
// Restore messages from sessionStorage on mount
|
||||
useEffect(() => {
|
||||
if (restoredRef.current) return;
|
||||
restoredRef.current = true;
|
||||
try {
|
||||
const stored = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
setMessages(parsed);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}, [setMessages]);
|
||||
|
||||
// Save completed messages to sessionStorage
|
||||
useEffect(() => {
|
||||
if (!restoredRef.current) return;
|
||||
if (isLoading) return;
|
||||
if (messages.length === 0) {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
|
||||
} catch {
|
||||
// ignore quota errors
|
||||
}
|
||||
}, [messages, isLoading]);
|
||||
|
||||
// Cmd+K to open sidebar and focus prompt, Escape to close
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => {
|
||||
if (!prev) {
|
||||
setTimeout(() => inputRef.current?.focus(), 200);
|
||||
}
|
||||
return !prev;
|
||||
});
|
||||
}
|
||||
if (e.key === "Escape" && open && isDesktop) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [open, isDesktop]);
|
||||
|
||||
// Auto-focus input when opened
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 200);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Auto-open when error occurs
|
||||
useEffect(() => {
|
||||
if (error) setOpen(true);
|
||||
}, [error]);
|
||||
|
||||
// Scroll to bottom when messages change or error occurs
|
||||
useEffect(() => {
|
||||
const el = messagesScrollRef.current;
|
||||
if (!el) return;
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}, [messages, error]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim() || isLoading) return;
|
||||
sendMessage({ text: input });
|
||||
setInput("");
|
||||
},
|
||||
[input, isLoading, sendMessage],
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setMessages([]);
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}, [setMessages]);
|
||||
|
||||
const hasVisibleContent = (
|
||||
parts: (typeof messages)[number]["parts"],
|
||||
): boolean => {
|
||||
return parts.some(
|
||||
(p) => (p.type === "text" && p.text.length > 0) || isToolPart(p),
|
||||
);
|
||||
};
|
||||
|
||||
// Shared chat panel content used by both desktop and mobile
|
||||
const chatPanel = (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b shrink-0">
|
||||
<span className="text-sm font-medium">agent-browser Docs</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{showMessages && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Clear conversation"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Close panel"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content: suggestions or messages */}
|
||||
{showMessages ? (
|
||||
<div
|
||||
ref={messagesScrollRef}
|
||||
className="flex-1 min-h-0 p-4 space-y-4 overflow-y-auto"
|
||||
>
|
||||
{messages.map((message) => {
|
||||
if (!hasVisibleContent(message.parts)) return null;
|
||||
return (
|
||||
<div key={message.id}>
|
||||
{message.role === "user" ? (
|
||||
<div className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">
|
||||
{message.parts
|
||||
.filter(
|
||||
(p): p is Extract<typeof p, { type: "text" }> =>
|
||||
p.type === "text",
|
||||
)
|
||||
.map((p) => p.text)
|
||||
.join("")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{message.parts.map((part, i) => {
|
||||
if (part.type === "text" && part.text) {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="docs-chat-content text-sm text-foreground leading-relaxed prose prose-sm dark:prose-invert max-w-none"
|
||||
>
|
||||
<Streamdown>{part.text}</Streamdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isToolPart(part)) {
|
||||
return (
|
||||
<ToolCallDisplay key={part.toolCallId} part={part} />
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{error && (
|
||||
<div className="text-sm text-destructive/80 bg-destructive/10 rounded-md px-3 py-2">
|
||||
{(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(error.message);
|
||||
return parsed.message || parsed.error || error.message;
|
||||
} catch {
|
||||
return (
|
||||
error.message || "Something went wrong. Please try again."
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="flex flex-wrap gap-2 p-4">
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
sendMessage({ text: s });
|
||||
}}
|
||||
className="text-xs px-3 py-1.5 rounded-full border bg-secondary font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input bar */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex items-end gap-2 px-4 py-3 border-t shrink-0"
|
||||
>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
e.target.style.height = "auto";
|
||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||
}}
|
||||
rows={1}
|
||||
enterKeyHint="send"
|
||||
placeholder="Ask a question..."
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
}}
|
||||
className="flex-1 bg-transparent text-base sm:text-sm text-foreground outline-none disabled:opacity-50 resize-none max-h-32 leading-relaxed placeholder:text-muted-foreground"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !input.trim()}
|
||||
className="bg-primary text-primary-foreground rounded-full p-1.5 hover:bg-primary/90 transition-colors disabled:opacity-30 shrink-0"
|
||||
aria-label="Send message"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="12" y1="19" x2="12" y2="5" />
|
||||
<polyline points="5 12 12 5 19 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Ask AI trigger button */}
|
||||
{!open && (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="fixed z-50 bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:translate-x-0 sm:right-4 flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground shadow-lg hover:opacity-90 transition-opacity text-sm font-medium"
|
||||
aria-label="Ask AI"
|
||||
>
|
||||
Ask AI
|
||||
<kbd className="hidden sm:inline-flex items-center gap-0.5 text-xs opacity-60 font-mono">
|
||||
<span>⌘</span>K
|
||||
</kbd>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Desktop: resizable side pane -- always rendered, hidden on mobile via CSS */}
|
||||
<aside
|
||||
className={`hidden sm:flex fixed top-0 right-0 bottom-0 z-40 border-l bg-background transition-transform duration-150 ease-in-out ${open ? "translate-x-0" : "translate-x-full"}`}
|
||||
style={{ width: desktopWidth }}
|
||||
aria-hidden={!open}
|
||||
>
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
onPointerDown={handleResizePointerDown}
|
||||
className="absolute top-0 bottom-0 left-0 w-1.5 cursor-col-resize hover:bg-ring/30 active:bg-ring/50 transition-colors z-10"
|
||||
/>
|
||||
<div className="flex flex-col flex-1 min-w-0">{chatPanel}</div>
|
||||
</aside>
|
||||
|
||||
{/* Mobile: Sheet overlay/drawer -- only after mount to avoid flash on desktop */}
|
||||
{hasMounted && !isDesktop && (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
showCloseButton={false}
|
||||
overlayClassName="bg-background!"
|
||||
className="inset-0! w-full! h-full! max-w-none! p-0 flex flex-col"
|
||||
style={{ backgroundColor: "var(--background)", opacity: 1 }}
|
||||
>
|
||||
<SheetTitle className="sr-only">AI Chat</SheetTitle>
|
||||
{chatPanel}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetContent,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { navigation, allDocsPages } from "@/lib/docs-navigation";
|
||||
|
||||
export function DocsMobileNav() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
const currentPage = useMemo(() => {
|
||||
const page = allDocsPages.find((p) => p.href === pathname);
|
||||
return page ?? allDocsPages[0];
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger className="lg:hidden sticky top-14 z-40 w-full px-6 py-3 bg-background/80 backdrop-blur-sm border-b border-border flex items-center justify-between focus:outline-none">
|
||||
<div className="text-sm font-medium">{currentPage?.name}</div>
|
||||
<div className="w-8 h-8 flex items-center justify-center">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<line x1="8" y1="6" x2="21" y2="6" />
|
||||
<line x1="8" y1="12" x2="21" y2="12" />
|
||||
<line x1="8" y1="18" x2="21" y2="18" />
|
||||
<line x1="3" y1="6" x2="3.01" y2="6" />
|
||||
<line x1="3" y1="12" x2="3.01" y2="12" />
|
||||
<line x1="3" y1="18" x2="3.01" y2="18" />
|
||||
</svg>
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" showCloseButton={false} className="overflow-y-auto p-6">
|
||||
<SheetTitle className="mb-6">Table of Contents</SheetTitle>
|
||||
<nav className="space-y-6">
|
||||
{navigation.map((section, sectionIndex) => (
|
||||
<div key={section.title ?? sectionIndex}>
|
||||
{section.title && (
|
||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">
|
||||
{section.title}
|
||||
</h4>
|
||||
)}
|
||||
<ul className="space-y-1">
|
||||
{section.items.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className={`text-sm block py-2 transition-colors ${
|
||||
pathname === item.href
|
||||
? "text-primary font-medium"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user