Compare commits

...

No commits in common. "main" and "v1.11.2-npm" have entirely different histories.

154 changed files with 616 additions and 25524 deletions

12
.gitattributes vendored
View File

@ -1,12 +0,0 @@
# Shell scripts must always use LF line endings
# CRLF breaks shebang lines in Linux containers (e.g. Docker builds)
*.sh text eol=lf
# JS/TS and other text files use native line endings
*.js text
*.ts text
*.json text
*.yml text
*.yaml text
*.md text
*.toml text

2
.github/CODEOWNERS vendored
View File

@ -1,2 +0,0 @@
# Require review for workflow changes
.github/ @skyfallsin

1
.github/FUNDING.yml vendored
View File

@ -1 +0,0 @@
github: skyfallsin

View File

@ -1,121 +0,0 @@
name: Auto-close old version reports
on:
issues:
types: [opened]
jobs:
check-version:
if: contains(join(github.event.issue.labels.*.name, ','), 'auto-report')
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Check reporter version
uses: actions/github-script@v7
with:
script: |
const MIN_VERSION = [1, 7, 4];
const body = context.payload.issue.body || '';
// Parse "- **version:** X.Y.Z" from issue body
const match = body.match(/\*\*[Vv]ersion:\*\*\s*v?(\d+\.\d+\.\d+)/);
if (!match) {
console.log('No version found in issue body, closing.');
} else {
const parts = match[1].split('.').map(Number);
let dominated = false;
for (let i = 0; i < MIN_VERSION.length; i++) {
if ((parts[i] || 0) > MIN_VERSION[i]) { dominated = false; break; }
if ((parts[i] || 0) < MIN_VERSION[i]) { dominated = true; break; }
}
if (!dominated) {
console.log(`Version ${match[1]} meets minimum ${MIN_VERSION.join('.')}.`);
const labels = context.payload.issue.labels.map(l => l.name);
// Auto-close likely-sleep issues (CPU ratio near zero = OS suspend, not real stall)
if (labels.includes('likely-sleep')) {
console.log('likely-sleep label detected, closing.');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: 'Closing — classified as OS sleep/suspend (CPU/wall ratio near zero).',
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: 'closed',
state_reason: 'not_planned',
});
return;
}
// Auto-close stalls with no active tabs (no user work affected)
if (labels.includes('stuck')) {
const tabMatch = body.match(/\*\*active tabs:\*\*\s*(\d+)/);
if (tabMatch && parseInt(tabMatch[1], 10) === 0) {
console.log('Stall with 0 active tabs, closing.');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: 'Closing — event loop stall with no active tabs (no user work affected). If you hit this during active browsing, please re-open.',
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: 'closed',
state_reason: 'not_planned',
});
return;
}
}
// Auto-close memory leaks with 0 sessions + 0 tabs (self-healing restart handles these)
if (labels.includes('memory-leak')) {
const ctxMatch = body.match(/\*\*browser contexts:\*\*\s*(\d+)/);
const tabMatch = body.match(/\*\*active tabs:\*\*\s*(\d+)/);
if (ctxMatch && tabMatch &&
parseInt(ctxMatch[1], 10) === 0 && parseInt(tabMatch[1], 10) === 0) {
console.log('Memory leak with 0 contexts + 0 tabs, closing (self-healing).');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: 'Closing — native memory growth detected with no active sessions. The memory pressure restart mechanism automatically reclaims this when idle. This is expected Firefox/Playwright behavior (jemalloc fragmentation, CDP buffers). If you experience OOM crashes during active use, please re-open.',
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: 'closed',
state_reason: 'not_planned',
});
return;
}
}
console.log('Keeping open.');
return;
}
console.log(`Version ${match[1]} below minimum ${MIN_VERSION.join('.')}, closing.`);
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `Closing — reported from v${match ? match[1] : 'unknown'}, minimum supported is v${MIN_VERSION.join('.')}. Please upgrade to get improved crash diagnostics.`,
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: 'closed',
state_reason: 'not_planned',
});

View File

@ -1,90 +0,0 @@
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
unit:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [24]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- name: Install dependencies
run: npm ci
- name: Build plugin (TypeScript → JS)
run: npm run build
- name: Run unit + plugin tests (Jest)
run: |
npm install --no-save jest-junit
node --experimental-vm-modules node_modules/.bin/jest \
--testPathPattern='tests/unit|plugins' \
--testPathIgnorePatterns='security\.test|tabRecycling\.test|cookies\.test' \
--forceExit
env:
CI: true
e2e:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [24]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- name: Install dependencies
run: |
npm ci
npm install --no-save jest-junit
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Install browser
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps firefox
- name: Install browser system deps (cache hit)
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps firefox
- name: Run e2e + browser-dependent unit tests
run: |
xvfb-run --auto-servernum \
node --experimental-vm-modules node_modules/.bin/jest \
--config jest.config.e2e.cjs \
--runInBand --forceExit
env:
CI: true
- name: Run browser-dependent unit tests
run: |
xvfb-run --auto-servernum \
node --experimental-vm-modules node_modules/.bin/jest \
--testPathPattern='tests/unit/(security|tabRecycling|cookies)\.test' \
--runInBand --forceExit
env:
CI: true

View File

@ -1,27 +0,0 @@
name: Publish to ClawHub
on:
push:
tags:
- 'v*'
pull_request:
workflow_dispatch:
permissions:
contents: read
id-token: write
jobs:
dry-run:
if: github.event_name == 'pull_request'
uses: openclaw/clawhub/.github/workflows/package-publish.yml@main
with:
dry_run: true
publish:
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/')
uses: openclaw/clawhub/.github/workflows/package-publish.yml@main
with:
dry_run: false
secrets:
clawhub_token: ${{ secrets.CLAWHUB_TOKEN }}

View File

@ -1,53 +0,0 @@
name: Publish Docker image
on:
release:
types: [published]
workflow_dispatch:
inputs:
version:
description: 'Version tag (e.g. 1.8.0)'
required: true
concurrency:
group: docker-publish
cancel-in-progress: false
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set version
run: |
if [ -n "${{ github.event.inputs.version }}" ]; then
echo "VERSION=${{ github.event.inputs.version }}" >> $GITHUB_ENV
else
echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
fi
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/setup-buildx-action@v3
- uses: docker/setup-qemu-action@v3
- uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.ci
platforms: linux/amd64,linux/arm64
push: true
tags: |
ghcr.io/jo-inc/camofox-browser:${{ env.VERSION }}
ghcr.io/jo-inc/camofox-browser:latest
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@ -1,99 +0,0 @@
name: Publish to npm
on:
push:
tags:
- 'v*'
concurrency:
group: npm-publish
cancel-in-progress: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- name: Unit + plugin tests (Jest)
run: |
npm install --no-save jest-junit
node --experimental-vm-modules node_modules/.bin/jest \
--testPathPattern='tests/unit|plugins' \
--testPathIgnorePatterns='security\.test|tabRecycling\.test|cookies\.test' \
--forceExit
env:
CI: true
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Install browser
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps firefox
- name: Install browser system deps (cache hit)
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps firefox
- name: E2E tests
run: |
xvfb-run --auto-servernum \
node --experimental-vm-modules node_modules/.bin/jest \
--config jest.config.e2e.cjs \
--runInBand --forceExit
env:
CI: true
- name: Browser-dependent unit tests
run: |
xvfb-run --auto-servernum \
node --experimental-vm-modules node_modules/.bin/jest \
--testPathPattern='tests/unit/(security|tabRecycling|cookies)\.test' \
--runInBand --forceExit
env:
CI: true
publish:
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
registry-url: 'https://registry.npmjs.org'
- name: Verify tag matches package.json version
run: |
TAG_VERSION="${GITHUB_REF#refs/tags/v}"
PKG_VERSION=$(node -p "require('./package.json').version")
if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
echo "::error::Tag version ($TAG_VERSION) != package.json version ($PKG_VERSION)"
exit 1
fi
echo "✅ Publishing v${PKG_VERSION}"
- run: npm ci --ignore-scripts
- name: Build plugin (TypeScript → JS)
run: npm run build
- name: Publish with provenance
run: npm publish --provenance --access public

View File

@ -1,29 +0,0 @@
name: Deploy Telemetry Endpoint
on:
push:
branches: [master]
paths:
- 'workers/crash-reporter/**'
workflow_dispatch: # manual trigger for initial deploy / redeploy
jobs:
deploy:
runs-on: ubuntu-latest
name: Deploy to Cloudflare Workers
steps:
- uses: actions/checkout@v4
- name: Inject source verification hashes
run: |
COMMIT=$(git rev-parse --short HEAD)
HASH=$(sha256sum workers/crash-reporter/index.ts | cut -d' ' -f1)
sed -i "s/__COMMIT_SHA__/$COMMIT/" workers/crash-reporter/index.ts
sed -i "s/__SOURCE_SHA256__/$HASH/" workers/crash-reporter/index.ts
- name: Deploy
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: workers/crash-reporter

44
.gitignore vendored
View File

@ -1,44 +0,0 @@
node_modules/
.env
.env.local
.env.*.local
# Logs
logs/
*.log
npm-debug.log*
# Runtime
*.pid
*.seed
*.pid.lock
# Coverage
coverage/
.nyc_output/
# Build
dist/
build/
/plugin.js.map
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# .github/ — removed to allow CI workflow tracking
fly.toml
# Camoufox cache
.camoufox/
# Test artifacts
test-results/
playwright-report/

View File

@ -1,43 +0,0 @@
# Contributing to camofox-browser
## Environment Variable Security
**Do not pass the host environment to child processes.** This is a hard rule.
When spawning child processes (e.g., the server from the plugin), only pass an explicit whitelist of environment variables. Never use `...process.env` or equivalent spreads.
```typescript
// WRONG — leaks all host secrets to the child process
spawn("node", [serverPath], {
env: { ...process.env, CAMOFOX_PORT: "9377" },
});
// RIGHT — only what the child actually needs
spawn("node", [serverPath], {
env: {
PATH: process.env.PATH,
HOME: process.env.HOME,
NODE_ENV: process.env.NODE_ENV,
CAMOFOX_PORT: "9377",
},
});
```
If the child process needs a new env var, add it to the whitelist explicitly in both `plugin.ts` and `tests/helpers/startServer.js`.
**Do not use `dotenv` or load `.env` files.** The server reads its configuration from explicitly passed environment variables only. Users running camofox alongside other tools may have `.env` files with secrets that should never be loaded into this process.
## Testing
```bash
npm test # e2e tests
npm run test:live # live site tests (requires RUN_LIVE_TESTS=1)
npm run test:debug # with server output (DEBUG_SERVER=1)
```
## Code Style
- No comments explaining what the code does — keep it readable without them
- Use `const` by default, `let` only when reassignment is needed
- Error responses: `{ error: "message" }` with appropriate HTTP status codes
- All tab operations require `userId` for session isolation

View File

@ -1,86 +0,0 @@
# CI/CD Dockerfile — downloads binaries during build (no bind mounts needed).
# For local builds with pre-downloaded binaries, use the default Dockerfile + Makefile.
FROM node:20-slim
ARG CAMOUFOX_VERSION=135.0.1
ARG CAMOUFOX_RELEASE=beta.24
ARG TARGETARCH
# Install dependencies for Camoufox (Firefox-based)
RUN apt-get update && apt-get install -y \
# Firefox dependencies
libgtk-3-0 \
libdbus-glib-1-2 \
libxt6 \
libasound2 \
libx11-xcb1 \
libxcomposite1 \
libxcursor1 \
libxdamage1 \
libxfixes3 \
libxi6 \
libxrandr2 \
libxrender1 \
libxss1 \
libxtst6 \
# Mesa OpenGL/EGL for WebGL support (software rendering via llvmpipe)
libegl1-mesa \
libgl1-mesa-dri \
libgbm1 \
# Xvfb virtual display
xvfb \
# Fonts
fonts-liberation \
fonts-noto-color-emoji \
fontconfig \
# Utils
ca-certificates \
curl \
unzip \
# yt-dlp runtime dependency
python3-minimal \
&& rm -rf /var/lib/apt/lists/*
# Download and install Camoufox
RUN set -eux; \
case "${TARGETARCH}" in \
amd64) CAMOUFOX_ARCH="x86_64"; YTDLP_SUFFIX="" ;; \
arm64) CAMOUFOX_ARCH="arm64"; YTDLP_SUFFIX="_aarch64" ;; \
*) echo "Unsupported arch: ${TARGETARCH}" && exit 1 ;; \
esac; \
mkdir -p /root/.cache/camoufox; \
curl -fSL "https://github.com/daijro/camoufox/releases/download/v${CAMOUFOX_VERSION}-${CAMOUFOX_RELEASE}/camoufox-${CAMOUFOX_VERSION}-${CAMOUFOX_RELEASE}-lin.${CAMOUFOX_ARCH}.zip" \
-o /tmp/camoufox.zip; \
(unzip -q /tmp/camoufox.zip -d /root/.cache/camoufox || true); \
chmod -R 755 /root/.cache/camoufox; \
echo "{\"version\":\"${CAMOUFOX_VERSION}\",\"release\":\"${CAMOUFOX_RELEASE}\"}" > /root/.cache/camoufox/version.json; \
test -f /root/.cache/camoufox/camoufox-bin && echo "Camoufox installed successfully"; \
rm /tmp/camoufox.zip; \
curl -fSL "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux${YTDLP_SUFFIX}" \
-o /usr/local/bin/yt-dlp; \
chmod 755 /usr/local/bin/yt-dlp
WORKDIR /app
COPY package.json package-lock.json ./
COPY scripts/ ./scripts/
RUN PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm ci --production
COPY server.js ./
COPY camofox.config.json ./
COPY lib/ ./lib/
COPY plugins/ ./plugins/
COPY scripts/ ./scripts/
# Install default plugin dependencies
# Note: sh is used explicitly (./scripts/) because COPY from Windows
# does not preserve +x permission bits in Docker build contexts.
RUN sh scripts/install-plugin-deps.sh
ENV NODE_ENV=production
ENV CAMOFOX_PORT=9377
EXPOSE 9377
CMD ["sh", "-c", "node --max-old-space-size=${MAX_OLD_SPACE_SIZE:-128} server.js"]

View File

@ -1,78 +0,0 @@
VERSION ?= 135.0.1
RELEASE ?= beta.24
# Auto-detect host architecture; map arm64 (macOS) → aarch64
UNAME_ARCH := $(shell uname -m)
ifeq ($(UNAME_ARCH),arm64)
ARCH ?= aarch64
else
ARCH ?= $(UNAME_ARCH)
endif
# Map ARCH to the platform suffixes used by upstream release filenames
ifeq ($(ARCH),aarch64)
CAMOUFOX_ARCH := arm64
YTDLP_ARCH := _aarch64
else
CAMOUFOX_ARCH := x86_64
YTDLP_ARCH :=
endif
IMAGE := camofox-browser:$(VERSION)-$(ARCH)
CAMOUFOX_ZIP := dist/camoufox-$(ARCH).zip
YTDLP_BIN := dist/yt-dlp-$(ARCH)
CAMOUFOX_URL := https://github.com/daijro/camoufox/releases/download/v$(VERSION)-$(RELEASE)/camoufox-$(VERSION)-$(RELEASE)-lin.$(CAMOUFOX_ARCH).zip
YTDLP_URL := https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux$(YTDLP_ARCH)
.PHONY: build build-arm64 build-x86 fetch fetch-arm64 fetch-x86 up down reset clean
## Build the Docker image for the current ARCH (default: x86_64)
build: fetch
docker build --no-cache \
--build-arg ARCH=$(ARCH) \
--build-arg CAMOUFOX_VERSION=$(VERSION) \
--build-arg CAMOUFOX_RELEASE=$(RELEASE) \
-t $(IMAGE) .
## Convenience targets
build-arm64:
$(MAKE) build ARCH=aarch64
build-x86:
$(MAKE) build ARCH=x86_64
## Download both binaries into dist/ for the current ARCH
fetch: $(CAMOUFOX_ZIP) $(YTDLP_BIN)
fetch-arm64:
$(MAKE) fetch ARCH=aarch64
fetch-x86:
$(MAKE) fetch ARCH=x86_64
$(CAMOUFOX_ZIP):
mkdir -p dist
curl -fSL "$(CAMOUFOX_URL)" -o $@
$(YTDLP_BIN):
mkdir -p dist
curl -fSL "$(YTDLP_URL)" -o $@
up:
@if ! docker image inspect $(IMAGE) > /dev/null 2>&1; then \
$(MAKE) build; \
fi
docker run -d --restart unless-stopped --name camofox-browser -p 9377:9377 $(IMAGE)
down:
docker stop camofox-browser && docker rm camofox-browser
reset:
-docker stop camofox-browser 2>/dev/null
-docker rm camofox-browser 2>/dev/null
-docker rmi $(IMAGE) 2>/dev/null
$(MAKE) build
clean:
rm -rf dist

171
build.ps1
View File

@ -1,171 +0,0 @@
#!/usr/bin/env pwsh
<#
.SYNOPSIS
Build and manage camofox-browser on Windows (PowerShell alternative to Makefile).
.DESCRIPTION
Provides the same targets as the Makefile for Windows users without make:
build - Download Camoufox + yt-dlp, then build the Docker image.
up - Build (if needed) and run the container.
down - Stop and remove the container.
reset - Full rebuild from scratch.
clean - Remove downloaded binaries.
fetch - Download Camoufox + yt-dlp binaries only.
.PARAMETER Target
The action to perform: build, up, down, reset, clean, fetch (default: build).
.PARAMETER Arch
Target architecture: x86_64 or aarch64 (default: x86_64).
.PARAMETER CamoufoxVersion
Camoufox version (default: 135.0.1).
.PARAMETER CamoufoxRelease
Camoufox release channel (default: beta.24).
.PARAMETER ContainerName
Docker container name (default: camofox-browser).
.PARAMETER HostPort
Host port to map (default: 9377).
.EXAMPLE
.\build.ps1 up # Build + run
.\build.ps1 down # Stop container
.\build.ps1 fetch # Download binaries only
#>
param(
[ValidateSet('build', 'up', 'down', 'reset', 'clean', 'fetch')]
[string]$Target = 'build',
[ValidateSet('x86_64', 'aarch64')]
[string]$Arch = 'x86_64',
[string]$CamoufoxVersion = '135.0.1',
[string]$CamoufoxRelease = 'beta.24',
[string]$ContainerName = 'camofox-browser',
[int]$HostPort = 9377
)
$ErrorActionPreference = 'Stop'
$ProjectRoot = Split-Path -Parent $PSCommandPath
$DistDir = Join-Path $ProjectRoot 'dist'
$CamoufoxZip = Join-Path $DistDir "camoufox-$Arch.zip"
$YtDlpBin = Join-Path $DistDir "yt-dlp-$Arch"
$ImageTag = "camofox-browser:$CamoufoxVersion-$Arch"
$ContainerPort = 9377
# Map architecture to upstream release filenames
if ($Arch -eq 'aarch64') {
$CamoufoxArch = 'arm64'
$YtDlpSuffix = '_aarch64'
} else {
$CamoufoxArch = 'x86_64'
$YtDlpSuffix = ''
}
$CamoufoxUrl = "https://github.com/daijro/camoufox/releases/download/v$CamoufoxVersion-$CamoufoxRelease/camoufox-$CamoufoxVersion-$CamoufoxRelease-lin.$CamoufoxArch.zip"
$YtDlpUrl = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux$YtDlpSuffix"
function Write-Step {
param([string]$Message)
Write-Host ">>> $Message" -ForegroundColor Cyan
}
function Invoke-Fetch {
Write-Step "Creating dist directory..."
New-Item -ItemType Directory -Path $DistDir -Force | Out-Null
if (-not (Test-Path $CamoufoxZip)) {
Write-Step "Downloading Camoufox browser ($CamoufoxArch)..."
Write-Host " URL: $CamoufoxUrl"
curl.exe -L -o $CamoufoxZip $CamoufoxUrl
Write-Host " Downloaded: $(Get-Item $CamoufoxZip | Select-Object -ExpandProperty Length) bytes"
} else {
Write-Host " [SKIP] Camoufox already downloaded"
}
if (-not (Test-Path $YtDlpBin)) {
Write-Step "Downloading yt-dlp ($Arch)..."
Write-Host " URL: $YtDlpUrl"
curl.exe -L -o $YtDlpBin $YtDlpUrl
Write-Host " Downloaded: $(Get-Item $YtDlpBin | Select-Object -ExpandProperty Length) bytes"
} else {
Write-Host " [SKIP] yt-dlp already downloaded"
}
}
function Invoke-Build {
Invoke-Fetch
Write-Step "Building Docker image: $ImageTag"
docker build `
--build-arg "ARCH=$Arch" `
--build-arg "CAMOUFOX_VERSION=$CamoufoxVersion" `
--build-arg "CAMOUFOX_RELEASE=$CamoufoxRelease" `
-t $ImageTag `
-f (Join-Path $ProjectRoot 'Dockerfile') `
$ProjectRoot
}
function Invoke-Up {
# Check if image exists
$imageExists = docker images -q $ImageTag 2>$null
if (-not $imageExists) {
Write-Step "Image not found — building first..."
Invoke-Build
}
# Stop & remove existing container
docker stop $ContainerName 2>$null | Out-Null
docker rm $ContainerName 2>$null | Out-Null
Write-Step "Starting container: $ContainerName on port $HostPort"
docker run -d `
--restart unless-stopped `
--name $ContainerName `
-p "${HostPort}:${ContainerPort}" `
$ImageTag
Write-Host "Container started. Server should be available at http://localhost:$HostPort" -ForegroundColor Green
Write-Host "Check logs: docker logs $ContainerName" -ForegroundColor Gray
}
function Invoke-Down {
Write-Step "Stopping container: $ContainerName"
docker stop $ContainerName 2>$null
docker rm $ContainerName 2>$null
Write-Host "Container stopped and removed." -ForegroundColor Green
}
function Invoke-Reset {
Invoke-Down
Write-Step "Removing Docker image: $ImageTag"
docker rmi $ImageTag 2>$null
Invoke-Build
Invoke-Up
}
function Invoke-Clean {
Write-Step "Removing dist directory..."
if (Test-Path $DistDir) {
Remove-Item -Recurse -Force $DistDir
Write-Host "Removed: $DistDir" -ForegroundColor Green
} else {
Write-Host "Nothing to clean." -ForegroundColor Yellow
}
}
# --- Main dispatch ---
switch ($Target) {
'build' { Invoke-Build }
'up' { Invoke-Up }
'down' { Invoke-Down }
'reset' { Invoke-Reset }
'clean' { Invoke-Clean }
'fetch' { Invoke-Fetch }
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

View File

@ -1,33 +0,0 @@
# Camofox Browser v1.4.0
## What's New
### 🖼️ Download & Image Capture
- **File downloads**: Camofox now captures files downloaded during browser sessions — PDFs, CSVs, images, and more. Your agent can access them programmatically after a download completes.
- **Page image extraction**: A new endpoint pulls all visible images from a page, useful for extracting product photos, charts, or screenshots from web apps.
### 🧠 JavaScript Evaluation
- **Run JavaScript on any page**: The new `camofox_evaluate` tool lets agents execute JavaScript directly in a tab's page context — read page state, call web app APIs, or inject scripts. *(Thanks @faith0811!)*
### ⚡ Reliability Improvements
- **Faster page interactions**: Clicks, typing, and scrolling are more responsive. Stale element references (from dynamic pages like SPAs) are now auto-refreshed instead of failing.
- **No more cold starts**: The browser pre-warms on startup, so the first page load is just as fast as every other one.
- **Better error recovery**: Tabs that stop responding are automatically cleaned up. Dead browser sessions recover without restarting the server.
- **Google Search is faster**: Results pages load ~2x quicker with a new direct extraction method.
### 🔧 Under the Hood
- Converted the codebase to modern JavaScript modules (ESM). No changes needed on your end — the plugin API is the same.
- Version numbers in `package.json` and `openclaw.plugin.json` now stay in sync automatically.
## Thank You
Thanks to our contributors who made this release possible:
- **@Microck** — download capture & image extraction
- **@faith0811** — JavaScript evaluation endpoint
We welcome contributions! If you'd like to get involved, check out the [repo](https://github.com/jo-inc/camofox-browser).
## Upgrading
Update your plugin to v1.4.0. No configuration changes needed — everything is backward compatible.

View File

@ -1,837 +0,0 @@
<!DOCTYPE html>
<!-- Docs engine: https://github.com/skyfallsin/swagger-stripey -->
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<title id="page-title">API Reference</title>
<script id="docs-config" type="application/json">
{
"specUrl": "./openapi.json",
"logoUrl": "./fox.png",
"title": "camofox-browser",
"subtitle": "API Reference",
"accent": "#9B30FF",
"accentLight": "#B366FF",
"methodGet": "#9B30FF"
}
</script>
<style>
/* ========================================================
Design tokens — dark (default)
======================================================== */
:root, [data-theme="dark"] {
--accent: #9B30FF;
--accent-light: #B366FF;
--accent-subtle: rgba(155,48,255,0.08);
--bg-primary: #070E1A;
--bg-secondary: #0B1628;
--bg-tertiary: #0F1D33;
--bg-code: #0a1120;
--text-primary: #E8F0FF;
--text-secondary: #8BA3C7;
--text-muted: #5a7394;
--border: #1a2a42;
--border-light: #243550;
--method-get: #9B30FF;
--method-post: #2563EB;
--method-delete: #DC2626;
--method-put: #D97706;
--method-patch: #0891B2;
--code-str: #22C55E;
--status-2xx: #22C55E;
--status-4xx: #F59E0B;
--status-5xx: #EF4444;
--deprecated-bg: rgba(220,38,38,0.15);
--deprecated-fg: #f87171;
--toggle-bg: var(--bg-tertiary);
--toggle-fg: var(--text-secondary);
}
/* ========================================================
Light theme
======================================================== */
[data-theme="light"] {
--accent: #7C22DB;
--accent-light: #9333EA;
--accent-subtle: rgba(124,34,219,0.06);
--bg-primary: #FFFFFF;
--bg-secondary: #F8F9FC;
--bg-tertiary: #F0F2F6;
--bg-code: #F5F6FA;
--text-primary: #1A1D26;
--text-secondary: #5C6370;
--text-muted: #9CA3AF;
--border: #E2E5EC;
--border-light: #ECEEF3;
--method-get: #7C22DB;
--code-str: #16A34A;
--status-2xx: #16A34A;
--status-4xx: #D97706;
--status-5xx: #DC2626;
--deprecated-bg: rgba(220,38,38,0.08);
--deprecated-fg: #DC2626;
--toggle-bg: #E2E5EC;
--toggle-fg: #5C6370;
}
/* ========================================================
Shared tokens
======================================================== */
:root {
--font-body: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', 'SF Mono', 'Menlo', 'Consolas', monospace;
--sidebar-width: 260px;
--code-panel-width: 42%;
--header-height: 56px;
}
/* ========================================================
Reset & base
======================================================== */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; scroll-padding-top: calc(var(--header-height) + 24px); }
body {
font-family: var(--font-body);
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* ========================================================
Header
======================================================== */
.header {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
display: flex; align-items: center; gap: 14px;
padding: 0 24px; height: var(--header-height);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
}
.header img { height: 32px; width: auto; }
.header .title {
font-family: var(--font-mono);
font-size: 16px; font-weight: 600;
color: var(--text-primary); letter-spacing: -0.5px;
}
.header .subtitle {
font-size: 12px; color: var(--text-secondary);
margin-left: 12px; font-weight: 400;
}
.header .header-right {
margin-left: auto; display: flex; align-items: center; gap: 12px;
}
.header .version {
font-family: var(--font-mono);
font-size: 11px; color: var(--text-muted);
background: var(--bg-tertiary); padding: 3px 10px;
border-radius: 4px; border: 1px solid var(--border);
}
/* Theme toggle */
.theme-toggle {
background: var(--toggle-bg); border: 1px solid var(--border);
border-radius: 6px; padding: 5px 10px;
cursor: pointer; font-size: 14px; line-height: 1;
color: var(--toggle-fg); transition: all 0.2s;
display: flex; align-items: center; gap: 4px;
}
.theme-toggle:hover { border-color: var(--accent); color: var(--accent); }
/* Mobile menu toggle */
.menu-toggle {
display: none;
background: var(--toggle-bg); border: 1px solid var(--border);
border-radius: 6px; padding: 6px 10px;
cursor: pointer; font-size: 18px; line-height: 1;
color: var(--toggle-fg);
}
.menu-toggle:hover { border-color: var(--accent); color: var(--accent); }
/* ========================================================
Sidebar
======================================================== */
.sidebar {
position: fixed; top: var(--header-height); left: 0; bottom: 0;
width: var(--sidebar-width); overflow-y: auto;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
padding: 16px 0;
transition: transform 0.25s ease;
z-index: 90;
}
.sidebar::-webkit-scrollbar { width: 4px; }
.sidebar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.sidebar .tag-group { margin-bottom: 4px; }
.sidebar .tag-name {
display: block; padding: 6px 20px;
font-size: 11px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.8px;
color: var(--text-muted);
}
.sidebar .nav-item {
display: flex; align-items: center; gap: 8px;
padding: 5px 20px 5px 24px;
text-decoration: none; color: var(--text-secondary);
font-size: 13px; transition: all 0.15s;
border-left: 2px solid transparent;
}
.sidebar .nav-item:hover {
color: var(--text-primary);
background: var(--accent-subtle);
}
.sidebar .nav-item.active {
color: var(--text-primary);
border-left-color: var(--accent);
background: var(--accent-subtle);
}
.sidebar .method-badge {
font-family: var(--font-mono);
font-size: 9px; font-weight: 600;
text-transform: uppercase;
padding: 1px 5px; border-radius: 3px;
min-width: 36px; text-align: center;
color: #fff; flex-shrink: 0;
}
.sidebar .method-badge.get { background: var(--method-get); }
.sidebar .method-badge.post { background: var(--method-post); }
.sidebar .method-badge.delete { background: var(--method-delete); }
.sidebar .method-badge.put { background: var(--method-put); }
.sidebar .method-badge.patch { background: var(--method-patch); }
/* Mobile overlay when sidebar is open */
.sidebar-overlay {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.5); z-index: 89;
}
/* ========================================================
Main content — two-panel (Stripe layout)
======================================================== */
.main {
margin-left: var(--sidebar-width);
margin-top: var(--header-height);
}
.endpoint {
display: flex; min-height: 100vh;
border-bottom: 1px solid var(--border);
}
.endpoint:last-child { min-height: auto; }
/* Left panel — description */
.endpoint .desc-panel {
flex: 1; min-width: 0;
padding: 40px 48px;
max-width: calc(100% - var(--code-panel-width));
}
/* Right panel — code examples */
.endpoint .code-panel {
width: var(--code-panel-width); flex-shrink: 0;
background: var(--bg-code);
border-left: 1px solid var(--border);
padding: 40px 32px;
position: sticky; top: var(--header-height);
max-height: calc(100vh - var(--header-height));
overflow-y: auto;
}
.endpoint .code-panel::-webkit-scrollbar { width: 4px; }
.endpoint .code-panel::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
/* Endpoint title */
.endpoint-title {
display: flex; align-items: center; gap: 12px;
margin-bottom: 8px; flex-wrap: wrap;
}
.endpoint-title .method {
font-family: var(--font-mono);
font-size: 12px; font-weight: 600;
text-transform: uppercase;
padding: 3px 8px; border-radius: 4px;
color: #fff;
}
.endpoint-title .method.get { background: var(--method-get); }
.endpoint-title .method.post { background: var(--method-post); }
.endpoint-title .method.delete { background: var(--method-delete); }
.endpoint-title .method.put { background: var(--method-put); }
.endpoint-title .method.patch { background: var(--method-patch); }
.endpoint-title .path {
font-family: var(--font-mono);
font-size: 15px; font-weight: 500;
color: var(--text-primary);
word-break: break-all;
}
.endpoint-title .path .param { color: var(--accent-light); }
.endpoint .summary {
font-size: 22px; font-weight: 600;
color: var(--text-primary);
margin-bottom: 12px; line-height: 1.3;
}
.endpoint .description {
color: var(--text-secondary);
font-size: 14px; line-height: 1.7;
margin-bottom: 28px;
}
.deprecated-badge {
display: inline-block;
font-size: 10px; font-weight: 600;
text-transform: uppercase;
padding: 2px 8px; border-radius: 3px;
background: var(--deprecated-bg);
color: var(--deprecated-fg); margin-left: 8px;
}
/* Parameters table */
.params-section { margin-bottom: 28px; }
.params-section h3 {
font-size: 13px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.5px;
color: var(--text-muted);
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.param-row {
display: flex; gap: 16px;
padding: 10px 0;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.param-row:last-child { border-bottom: none; }
.param-name {
font-family: var(--font-mono);
font-weight: 500; min-width: 140px;
color: var(--text-primary);
}
.param-name .required {
color: var(--accent-light);
font-size: 10px; margin-left: 4px;
}
.param-name .in-badge {
display: block; font-size: 10px;
color: var(--text-muted); font-weight: 400;
margin-top: 2px;
}
.param-desc { color: var(--text-secondary); flex: 1; }
.param-type {
font-family: var(--font-mono);
font-size: 11px; color: var(--accent-light);
}
/* Response status codes */
.responses-section { margin-bottom: 28px; }
.response-row {
display: flex; align-items: baseline; gap: 12px;
padding: 8px 0;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.response-row:last-child { border-bottom: none; }
.status-code {
font-family: var(--font-mono);
font-weight: 600; min-width: 40px;
}
.status-code.s2xx { color: var(--status-2xx); }
.status-code.s4xx { color: var(--status-4xx); }
.status-code.s5xx { color: var(--status-5xx); }
.response-desc { color: var(--text-secondary); }
/* Code panel content */
.code-block {
position: relative;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 16px; margin-bottom: 16px;
overflow-x: auto;
}
.code-block .label {
font-size: 11px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.5px;
color: var(--text-muted);
margin-bottom: 10px;
}
.code-block .copy-btn {
position: absolute; top: 10px; right: 10px;
background: var(--bg-secondary); border: 1px solid var(--border);
border-radius: 4px; padding: 4px 8px;
cursor: pointer; font-size: 11px; line-height: 1;
color: var(--text-muted); transition: all 0.15s;
opacity: 0;
}
.code-block:hover .copy-btn { opacity: 1; }
.code-block .copy-btn:hover { color: var(--text-primary); border-color: var(--accent); }
.code-block .copy-btn.copied { color: var(--status-2xx); border-color: var(--status-2xx); opacity: 1; }
.code-block pre {
font-family: var(--font-mono);
font-size: 12px; line-height: 1.6;
color: var(--text-secondary);
white-space: pre-wrap; word-break: break-all;
}
.code-block pre .str { color: var(--code-str); }
.code-block pre .key { color: var(--accent-light); }
.code-block pre .comment { color: var(--text-muted); }
.code-block pre .method-h { color: var(--accent); font-weight: 600; }
.code-block pre .url-h { color: var(--text-primary); }
/* Body schema tree */
.schema-tree { font-size: 13px; }
.schema-prop {
padding: 4px 0;
display: flex; gap: 8px; align-items: baseline;
flex-wrap: wrap;
}
.schema-prop .prop-name {
font-family: var(--font-mono);
color: var(--text-primary); font-weight: 500;
}
.schema-prop .prop-type {
font-family: var(--font-mono);
font-size: 11px; color: var(--accent-light);
}
.schema-prop .prop-required {
font-size: 10px; color: var(--accent-light);
}
.schema-prop .prop-desc {
color: var(--text-secondary); font-size: 12px;
}
/* Intro section */
.intro-section {
padding: 48px;
border-bottom: 1px solid var(--border);
max-width: calc(100% - var(--code-panel-width));
}
.intro-section h2 {
font-size: 28px; font-weight: 600;
margin-bottom: 16px;
}
.intro-section p {
color: var(--text-secondary);
font-size: 15px; line-height: 1.7;
max-width: 640px;
}
.intro-section .base-url {
display: inline-block; margin-top: 20px;
font-family: var(--font-mono);
font-size: 14px; padding: 8px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--accent-light);
}
/* ========================================================
Responsive — tablet (< 1024px): stack code below
======================================================== */
@media (max-width: 1024px) {
:root { --code-panel-width: 0%; }
.endpoint { flex-direction: column; min-height: auto; }
.endpoint .desc-panel {
max-width: 100%; padding: 32px 24px;
}
.endpoint .code-panel {
width: 100%; position: static; max-height: none;
border-left: none; border-top: 1px solid var(--border);
padding: 24px;
}
.intro-section { max-width: 100%; padding: 32px 24px; }
}
/* ========================================================
Responsive — mobile (< 768px): collapsible sidebar
======================================================== */
@media (max-width: 768px) {
:root { --sidebar-width: 280px; }
.menu-toggle { display: block; }
.sidebar {
transform: translateX(-100%);
}
.sidebar.open {
transform: translateX(0);
box-shadow: 4px 0 20px rgba(0,0,0,0.3);
}
.sidebar-overlay.open { display: block; }
.main { margin-left: 0; }
.intro-section { max-width: 100%; }
.header .subtitle { display: none; }
.endpoint .desc-panel { padding: 24px 16px; }
.endpoint .code-panel { padding: 16px; }
.endpoint .summary { font-size: 18px; }
.endpoint-title .path { font-size: 13px; }
.param-row { flex-direction: column; gap: 4px; }
.param-name { min-width: unset; }
}
</style>
</head>
<body>
<div class="header" id="header"></div>
<div class="sidebar-overlay" id="sidebar-overlay"></div>
<nav class="sidebar" id="sidebar"></nav>
<div class="main" id="main"></div>
<script>
// ============================================================
// Configuration
// ============================================================
const docsConfig = window.docsConfig || {};
try {
const configEl = document.getElementById('docs-config');
if (configEl) Object.assign(docsConfig, JSON.parse(configEl.textContent));
} catch(e) {}
const conf = {
specUrl: docsConfig.specUrl || './openapi.json',
logoUrl: docsConfig.logoUrl || null,
title: docsConfig.title || null,
subtitle: docsConfig.subtitle || 'API Reference',
defaultTheme: docsConfig.defaultTheme || 'dark',
theme: {
'--accent': docsConfig.accent || null,
'--accent-light': docsConfig.accentLight || null,
'--bg-primary': docsConfig.bgPrimary || null,
'--bg-secondary': docsConfig.bgSecondary || null,
'--bg-tertiary': docsConfig.bgTertiary || null,
'--text-primary': docsConfig.textPrimary || null,
'--text-secondary': docsConfig.textSecondary || null,
'--font-body': docsConfig.fontBody || null,
'--font-mono': docsConfig.fontMono || null,
'--method-get': docsConfig.methodGet || null,
'--method-post': docsConfig.methodPost || null,
'--method-delete': docsConfig.methodDelete || null,
},
};
// Apply theme overrides (only in dark mode — light mode uses its own)
for (const [prop, val] of Object.entries(conf.theme)) {
if (val) document.documentElement.style.setProperty(prop, val);
}
// ============================================================
// Theme toggle
// ============================================================
function getStoredTheme() {
try { return localStorage.getItem('api-docs-theme'); } catch { return null; }
}
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
try { localStorage.setItem('api-docs-theme', theme); } catch {}
const btn = document.getElementById('theme-btn');
if (btn) btn.textContent = theme === 'dark' ? '☀️' : '🌙';
}
setTheme(getStoredTheme() || conf.defaultTheme);
// ============================================================
// Mobile sidebar
// ============================================================
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('open');
document.getElementById('sidebar-overlay').classList.toggle('open');
}
function closeSidebar() {
document.getElementById('sidebar').classList.remove('open');
document.getElementById('sidebar-overlay').classList.remove('open');
}
// ============================================================
// Render helpers
// ============================================================
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function copyCode(btn) {
const pre = btn.parentElement.querySelector('pre');
const text = pre.textContent;
navigator.clipboard.writeText(text).then(() => {
btn.textContent = 'Copied!';
btn.classList.add('copied');
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 1500);
});
}
function opId(method, path) { return `${method}-${path.replace(/[^a-zA-Z0-9]/g, '-')}`; }
function highlightPath(path) { return esc(path).replace(/\{([^}]+)\}/g, '<span class="param">{$1}</span>'); }
function statusClass(code) {
const n = parseInt(code);
if (n >= 200 && n < 300) return 's2xx';
if (n >= 400 && n < 500) return 's4xx';
return 's5xx';
}
function renderSchemaProps(schema, depth = 0) {
if (!schema || !schema.properties) return '';
const required = new Set(schema.required || []);
let html = '';
for (const [name, prop] of Object.entries(schema.properties)) {
const type = prop.type || (prop.$ref ? prop.$ref.split('/').pop() : 'object');
html += `<div class="schema-prop" style="padding-left:${depth*16}px">
<span class="prop-name">${esc(name)}</span>
<span class="prop-type">${esc(type)}${prop.enum ? ' enum' : ''}</span>
${required.has(name) ? '<span class="prop-required">required</span>' : ''}
${prop.description ? `<span class="prop-desc">— ${esc(prop.description)}</span>` : ''}
</div>`;
if (prop.properties) html += renderSchemaProps(prop, depth + 1);
if (prop.items?.properties) html += renderSchemaProps(prop.items, depth + 1);
}
return html;
}
function buildCurlExample(method, path, op, servers) {
const base = servers?.[0]?.url || 'http://localhost:9377';
const url = base + path.replace(/\{(\w+)\}/g, ':$1');
let curl = `<span class="comment"># ${esc(op.summary || `${method.toUpperCase()} ${path}`)}</span>\ncurl`;
if (method !== 'get') curl += ` -X <span class="method-h">${method.toUpperCase()}</span>`;
curl += ` <span class="url-h">${esc(url)}</span>`;
const bodySchema = op.requestBody?.content?.['application/json']?.schema;
if (bodySchema?.properties) {
curl += ` \\\n -H <span class="str">"Content-Type: application/json"</span>`;
const props = {};
for (const [k, v] of Object.entries(bodySchema.properties)) {
if (v.type === 'string') props[k] = `<${k}>`;
else if (v.type === 'boolean') props[k] = true;
else if (v.type === 'integer' || v.type === 'number') props[k] = 0;
else if (v.type === 'array') props[k] = [];
else props[k] = {};
}
const body = JSON.stringify(props, null, 2)
.replace(/"([^"]+)":/g, '<span class="key">"$1"</span>:')
.replace(/"<([^>]+)>"/g, '<span class="str">"&lt;$1&gt;"</span>');
curl += ` \\\n -d '${body}'`;
}
return curl;
}
function buildResponseExample(op) {
const resp200 = op.responses?.['200'];
if (!resp200) return null;
const schema = resp200.content?.['application/json']?.schema;
if (!schema?.properties) return null;
const obj = {};
for (const [k, v] of Object.entries(schema.properties)) {
if (v.type === 'string') obj[k] = v.example || '';
else if (v.type === 'boolean') obj[k] = true;
else if (v.type === 'integer' || v.type === 'number') obj[k] = 0;
else if (v.type === 'array') obj[k] = [];
else if (v.type === 'object' && v.properties) {
const inner = {};
for (const [ik, iv] of Object.entries(v.properties)) {
if (iv.type === 'string') inner[ik] = iv.example || '';
else if (iv.type === 'boolean') inner[ik] = true;
else inner[ik] = null;
}
obj[k] = inner;
} else obj[k] = null;
}
return JSON.stringify(obj, null, 2)
.replace(/"([^"]+)":/g, '<span class="key">"$1"</span>:')
.replace(/: "([^"]*)"/g, ': <span class="str">"$1"</span>');
}
// ============================================================
// Main render
// ============================================================
async function render() {
const res = await fetch(conf.specUrl);
const spec = await res.json();
const title = conf.title || spec.info?.title || 'API';
document.title = `${title} — ${conf.subtitle}`;
document.getElementById('page-title').textContent = document.title;
// Header
const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark';
document.getElementById('header').innerHTML = `
<button class="menu-toggle" onclick="toggleSidebar()" aria-label="Menu"></button>
${conf.logoUrl ? `<img src="${esc(conf.logoUrl)}" alt="">` : ''}
<span class="title">${esc(title)}</span>
<span class="subtitle">${esc(conf.subtitle)}</span>
<div class="header-right">
<span class="version">v${esc(spec.info?.version || '?')}</span>
<button class="theme-toggle" id="theme-btn"
onclick="setTheme(document.documentElement.getAttribute('data-theme')==='dark'?'light':'dark')"
aria-label="Toggle theme">${currentTheme === 'dark' ? '☀️' : '🌙'}</button>
</div>
`;
// Group operations by tag
const tagMap = new Map();
const tagOrder = (spec.tags || []).map(t => t.name);
for (const [path, methods] of Object.entries(spec.paths || {})) {
for (const [method, op] of Object.entries(methods)) {
if (method.startsWith('x-')) continue;
const tag = op.tags?.[0] || 'Other';
if (!tagMap.has(tag)) tagMap.set(tag, []);
tagMap.get(tag).push({ method, path, op });
}
}
const sortedTags = [...tagMap.keys()].sort((a, b) => {
const ai = tagOrder.indexOf(a), bi = tagOrder.indexOf(b);
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
});
// Sidebar
let sidebarHtml = '';
for (const tag of sortedTags) {
sidebarHtml += `<div class="tag-group"><span class="tag-name">${esc(tag)}</span>`;
for (const { method, path, op } of tagMap.get(tag)) {
const id = opId(method, path);
const label = op.summary || path;
sidebarHtml += `<a class="nav-item" href="#${id}" data-id="${id}" onclick="closeSidebar()">
<span class="method-badge ${method}">${method}</span>
<span>${esc(label)}</span>
</a>`;
}
sidebarHtml += '</div>';
}
document.getElementById('sidebar').innerHTML = sidebarHtml;
// Main content
let mainHtml = '';
if (spec.info?.description) {
const base = spec.servers?.[0]?.url || '';
mainHtml += `<div class="intro-section">
<h2>${esc(conf.subtitle)}</h2>
<p>${esc(spec.info.description)}</p>
${base ? `<div class="base-url">${esc(base)}</div>` : ''}
</div>`;
}
for (const tag of sortedTags) {
for (const { method, path, op } of tagMap.get(tag)) {
const id = opId(method, path);
const allParams = op.parameters || [];
const pathParams = allParams.filter(p => p.in === 'path');
const queryParams = allParams.filter(p => p.in === 'query');
const bodySchema = op.requestBody?.content?.['application/json']?.schema;
const curl = buildCurlExample(method, path, op, spec.servers);
const respExample = buildResponseExample(op);
mainHtml += `<div class="endpoint" id="${id}">
<div class="desc-panel">
<div class="endpoint-title">
<span class="method ${method}">${method.toUpperCase()}</span>
<span class="path">${highlightPath(path)}</span>
${op.deprecated ? '<span class="deprecated-badge">Deprecated</span>' : ''}
</div>
<div class="summary">${esc(op.summary || '')}</div>
${op.description ? `<div class="description">${esc(op.description)}</div>` : ''}`;
if (pathParams.length) {
mainHtml += `<div class="params-section"><h3>Path Parameters</h3>`;
for (const p of pathParams) {
mainHtml += `<div class="param-row">
<div class="param-name">${esc(p.name)}${p.required ? '<span class="required">required</span>' : ''}
<span class="in-badge">${esc(p.in)}</span></div>
<div class="param-desc">
<div class="param-type">${esc(p.schema?.type || 'string')}</div>
${p.description ? esc(p.description) : ''}</div>
</div>`;
}
mainHtml += '</div>';
}
if (queryParams.length) {
mainHtml += `<div class="params-section"><h3>Query Parameters</h3>`;
for (const p of queryParams) {
mainHtml += `<div class="param-row">
<div class="param-name">${esc(p.name)}${p.required ? '<span class="required">required</span>' : ''}
<span class="in-badge">${esc(p.in)}</span></div>
<div class="param-desc">
<div class="param-type">${esc(p.schema?.type || 'string')}${p.schema?.enum ? ` — ${p.schema.enum.join(', ')}` : ''}</div>
${p.description ? esc(p.description) : ''}</div>
</div>`;
}
mainHtml += '</div>';
}
if (bodySchema) {
mainHtml += `<div class="params-section"><h3>Request Body</h3>
<div class="schema-tree">${renderSchemaProps(bodySchema)}</div></div>`;
}
if (op.responses) {
mainHtml += `<div class="responses-section"><h3>Responses</h3>`;
for (const [code, resp] of Object.entries(op.responses)) {
if (code.startsWith('x-')) continue;
const desc = resp.description || resp.$ref || '';
mainHtml += `<div class="response-row">
<span class="status-code ${statusClass(code)}">${esc(code)}</span>
<span class="response-desc">${esc(desc)}</span>
</div>`;
}
mainHtml += '</div>';
}
mainHtml += `</div>`; // close desc-panel
mainHtml += `<div class="code-panel">
<div class="code-block">
<button class="copy-btn" onclick="copyCode(this)">Copy</button>
<div class="label">Request</div>
<pre>${curl}</pre>
</div>`;
if (respExample) {
mainHtml += `<div class="code-block">
<button class="copy-btn" onclick="copyCode(this)">Copy</button>
<div class="label">Response</div>
<pre>${respExample}</pre>
</div>`;
}
mainHtml += `</div></div>`;
}
}
document.getElementById('main').innerHTML = mainHtml;
// Active nav tracking via IntersectionObserver
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
const link = document.querySelector(`.nav-item[data-id="${entry.target.id}"]`);
if (link) {
link.classList.add('active');
link.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}
}
}, { rootMargin: '-80px 0px -60% 0px', threshold: 0 });
document.querySelectorAll('.endpoint[id]').forEach(el => observer.observe(el));
// Close sidebar on overlay click
document.getElementById('sidebar-overlay').addEventListener('click', closeSidebar);
}
render().catch(err => {
document.getElementById('main').innerHTML =
`<div class="intro-section"><h2>Error</h2><p>${esc(err.message)}</p></div>`;
});
</script>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

File diff suppressed because it is too large Load Diff

BIN
fox.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

View File

@ -1,45 +0,0 @@
module.exports = {
// Disable transforms — we use native ESM via --experimental-vm-modules
transform: {},
testEnvironment: 'node',
testTimeout: 60000, // 60 seconds per test
// Run tests sequentially to avoid resource conflicts
maxWorkers: 1,
// Test file patterns
testMatch: [
'**/tests/**/*.test.js',
'**/plugins/**/*.test.js',
'**/scripts/**/*.test.js'
],
// Ignore patterns
testPathIgnorePatterns: [
'/node_modules/'
],
// Setup and teardown
globalSetup: undefined,
globalTeardown: undefined,
// Verbose output
verbose: true,
// Don't bail — run full suite even if a test fails
bail: 0,
// Coverage settings (optional)
collectCoverage: false,
coverageDirectory: 'coverage',
coveragePathIgnorePatterns: [
'/node_modules/',
'/tests/'
],
// Reporter settings
reporters: [
'default',
...(process.env.CI ? [['jest-junit', { outputDirectory: 'test-results' }]] : [])
]
};

View File

@ -1,22 +0,0 @@
module.exports = {
transform: {},
testEnvironment: 'node',
testTimeout: 60000,
// e2e tests run sequentially (shared browser state)
maxWorkers: 1,
testMatch: ['**/tests/e2e/*.test.js'],
testPathIgnorePatterns: ['/node_modules/', 'live'],
globalSetup: './tests/e2e/globalSetup.js',
globalTeardown: './tests/e2e/globalTeardown.js',
verbose: true,
bail: 0,
reporters: [
'default',
...(process.env.CI ? [['jest-junit', { outputDirectory: 'test-results', outputName: 'e2e-results.xml' }]] : [])
]
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

File diff suppressed because it is too large Load Diff

5994
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

616
package/plugin.js Normal file
View File

@ -0,0 +1,616 @@
/**
* Camoufox Browser - OpenClaw Plugin
*
* Provides browser automation tools using the Camoufox anti-detection browser.
* Server auto-starts when plugin loads (configurable via autoStart: false).
*/
import { dirname, resolve } from "path";
import { fileURLToPath } from "url";
import { randomUUID } from "crypto";
import { loadConfig } from "./lib/config.js";
import { launchServer } from "./lib/launcher.js";
import { readCookieFile } from "./lib/cookies.js";
// Get plugin directory - works in both ESM and CJS contexts
const getPluginDir = () => {
try {
// ESM context
return dirname(fileURLToPath(import.meta.url));
}
catch {
// CJS context
return __dirname;
}
};
let serverProcess = null;
async function startServer(pluginDir, port, log, pluginCfg) {
const cfg = loadConfig();
const env = { ...cfg.serverEnv };
if (pluginCfg?.maxSessions != null)
env.MAX_SESSIONS = String(pluginCfg.maxSessions);
if (pluginCfg?.maxTabsPerSession != null)
env.MAX_TABS_PER_SESSION = String(pluginCfg.maxTabsPerSession);
if (pluginCfg?.sessionTimeoutMs != null)
env.SESSION_TIMEOUT_MS = String(pluginCfg.sessionTimeoutMs);
if (pluginCfg?.browserIdleTimeoutMs != null)
env.BROWSER_IDLE_TIMEOUT_MS = String(pluginCfg.browserIdleTimeoutMs);
const proc = launchServer({ pluginDir, port, env, log, nodeArgs: pluginCfg?.maxOldSpaceSize != null ? [`--max-old-space-size=${pluginCfg.maxOldSpaceSize}`] : undefined });
proc.on("error", (err) => {
log?.error?.(`Server process error: ${err.message}`);
serverProcess = null;
});
proc.on("exit", (code) => {
if (code !== 0 && code !== null) {
log?.error?.(`Server exited with code ${code}`);
}
serverProcess = null;
});
// Wait for server to be ready
const baseUrl = `http://localhost:${port}`;
for (let i = 0; i < 30; i++) {
await new Promise((r) => setTimeout(r, 500));
try {
const res = await fetch(`${baseUrl}/health`);
if (res.ok) {
log.info(`Camoufox server ready on port ${port}`);
return proc;
}
}
catch {
// Server not ready yet
}
}
proc.kill();
throw new Error("Server failed to start within 15 seconds");
}
async function checkServerRunning(baseUrl) {
try {
const res = await fetch(`${baseUrl}/health`);
return res.ok;
}
catch {
return false;
}
}
async function fetchApi(baseUrl, path, options = {}) {
const url = `${baseUrl}${path}`;
const res = await fetch(url, {
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
if (!res.ok) {
const text = await res.text();
throw new Error(`${res.status}: ${text}`);
}
return res.json();
}
function toToolResult(data) {
return {
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
};
}
export default function register(api) {
const cfg = api.pluginConfig ?? api.config;
const port = cfg.port || 9377;
const baseUrl = cfg.url || `http://localhost:${port}`;
const autoStart = cfg.autoStart !== false; // default true
const pluginDir = getPluginDir();
const fallbackUserId = `camofox-${randomUUID()}`;
// Auto-start server if configured (default: true)
if (autoStart) {
(async () => {
const alreadyRunning = await checkServerRunning(baseUrl);
if (alreadyRunning) {
api.log?.info?.(`Camoufox server already running at ${baseUrl}`);
}
else {
try {
serverProcess = await startServer(pluginDir, port, api.log, cfg);
}
catch (err) {
api.log?.error?.(`Failed to auto-start server: ${err.message}`);
}
}
})();
}
api.registerTool((ctx) => ({
name: "camofox_create_tab",
description: "PREFERRED: Create a new browser tab using Camoufox anti-detection browser. Use camofox tools instead of Chrome/built-in browser - they bypass bot detection on Google, Amazon, LinkedIn, etc. Returns tabId for subsequent operations.",
parameters: {
type: "object",
properties: {
url: { type: "string", description: "Initial URL to navigate to" },
},
required: ["url"],
},
async execute(_id, params) {
const sessionKey = ctx.sessionKey || "default";
const userId = ctx.agentId || fallbackUserId;
const result = await fetchApi(baseUrl, "/tabs", {
method: "POST",
body: JSON.stringify({ ...params, userId, sessionKey }),
});
return toToolResult(result);
},
}));
api.registerTool((ctx) => ({
name: "camofox_snapshot",
description: "Get accessibility snapshot of a Camoufox page with element refs (e1, e2, etc.) for interaction, plus a visual screenshot. " +
"Large pages are truncated with pagination links preserved at the bottom. " +
"If the response includes hasMore=true and nextOffset, call again with that offset to see more content.",
parameters: {
type: "object",
properties: {
tabId: { type: "string", description: "Tab identifier" },
offset: { type: "number", description: "Character offset for paginated snapshots. Use nextOffset from a previous truncated response." },
},
required: ["tabId"],
},
async execute(_id, params) {
const { tabId, offset } = params;
const userId = ctx.agentId || fallbackUserId;
const qs = offset ? `&offset=${offset}` : '';
const result = await fetchApi(baseUrl, `/tabs/${tabId}/snapshot?userId=${userId}&includeScreenshot=true${qs}`);
const content = [
{ type: "text", text: JSON.stringify({ url: result.url, refsCount: result.refsCount, snapshot: result.snapshot, truncated: result.truncated, totalChars: result.totalChars, hasMore: result.hasMore, nextOffset: result.nextOffset }, null, 2) },
];
const screenshot = result.screenshot;
if (screenshot?.data) {
content.push({ type: "image", data: screenshot.data, mimeType: screenshot.mimeType || "image/png" });
}
return { content };
},
}));
api.registerTool((ctx) => ({
name: "camofox_click",
description: "Click an element in a Camoufox tab by ref (e.g., e1) or CSS selector.",
parameters: {
type: "object",
properties: {
tabId: { type: "string", description: "Tab identifier" },
ref: { type: "string", description: "Element ref from snapshot (e.g., e1)" },
selector: { type: "string", description: "CSS selector (alternative to ref)" },
},
required: ["tabId"],
},
async execute(_id, params) {
const { tabId, ...rest } = params;
const userId = ctx.agentId || fallbackUserId;
const result = await fetchApi(baseUrl, `/tabs/${tabId}/click`, {
method: "POST",
body: JSON.stringify({ ...rest, userId }),
});
return toToolResult(result);
},
}));
api.registerTool((ctx) => ({
name: "camofox_type",
description: "Type text into an element in a Camoufox tab.",
parameters: {
type: "object",
properties: {
tabId: { type: "string", description: "Tab identifier" },
ref: { type: "string", description: "Element ref from snapshot (e.g., e2)" },
selector: { type: "string", description: "CSS selector (alternative to ref)" },
text: { type: "string", description: "Text to type" },
pressEnter: { type: "boolean", description: "Press Enter after typing" },
},
required: ["tabId", "text"],
},
async execute(_id, params) {
const { tabId, ...rest } = params;
const userId = ctx.agentId || fallbackUserId;
const result = await fetchApi(baseUrl, `/tabs/${tabId}/type`, {
method: "POST",
body: JSON.stringify({ ...rest, userId }),
});
return toToolResult(result);
},
}));
api.registerTool((ctx) => ({
name: "camofox_navigate",
description: "Navigate a Camoufox tab to a URL or use a search macro (@google_search, @youtube_search, etc.). Preferred over Chrome for sites with bot detection.",
parameters: {
type: "object",
properties: {
tabId: { type: "string", description: "Tab identifier" },
url: { type: "string", description: "URL to navigate to" },
macro: {
type: "string",
description: "Search macro (e.g., @google_search, @youtube_search)",
enum: [
"@google_search",
"@youtube_search",
"@amazon_search",
"@reddit_search",
"@wikipedia_search",
"@twitter_search",
"@yelp_search",
"@spotify_search",
"@netflix_search",
"@linkedin_search",
"@instagram_search",
"@tiktok_search",
"@twitch_search",
],
},
query: { type: "string", description: "Search query (when using macro)" },
},
required: ["tabId"],
},
async execute(_id, params) {
const { tabId, ...rest } = params;
const userId = ctx.agentId || fallbackUserId;
const result = await fetchApi(baseUrl, `/tabs/${tabId}/navigate`, {
method: "POST",
body: JSON.stringify({ ...rest, userId }),
});
return toToolResult(result);
},
}));
api.registerTool((ctx) => ({
name: "camofox_scroll",
description: "Scroll a Camoufox page.",
parameters: {
type: "object",
properties: {
tabId: { type: "string", description: "Tab identifier" },
direction: { type: "string", enum: ["up", "down", "left", "right"] },
amount: { type: "number", description: "Pixels to scroll" },
},
required: ["tabId", "direction"],
},
async execute(_id, params) {
const { tabId, ...rest } = params;
const userId = ctx.agentId || fallbackUserId;
const result = await fetchApi(baseUrl, `/tabs/${tabId}/scroll`, {
method: "POST",
body: JSON.stringify({ ...rest, userId }),
});
return toToolResult(result);
},
}));
api.registerTool((ctx) => ({
name: "camofox_screenshot",
description: "Take a screenshot of a Camoufox page.",
parameters: {
type: "object",
properties: {
tabId: { type: "string", description: "Tab identifier" },
},
required: ["tabId"],
},
async execute(_id, params) {
const { tabId } = params;
const userId = ctx.agentId || fallbackUserId;
const url = `${baseUrl}/tabs/${tabId}/screenshot?userId=${userId}`;
const res = await fetch(url);
if (!res.ok) {
const text = await res.text();
throw new Error(`${res.status}: ${text}`);
}
// Guard: if server returns JSON/text instead of image (e.g. error with 200),
// return as text to avoid crashing the client with base64-encoded JSON.
const contentType = res.headers.get('content-type') || '';
if (!contentType.startsWith('image/')) {
const text = await res.text();
return { content: [{ type: "text", text: `Screenshot failed: ${text}` }] };
}
const arrayBuffer = await res.arrayBuffer();
const base64 = Buffer.from(arrayBuffer).toString("base64");
return {
content: [
{
type: "image",
data: base64,
mimeType: contentType || "image/png",
},
],
};
},
}));
api.registerTool((ctx) => ({
name: "camofox_close_tab",
description: "Close a Camoufox browser tab.",
parameters: {
type: "object",
properties: {
tabId: { type: "string", description: "Tab identifier" },
},
required: ["tabId"],
},
async execute(_id, params) {
const { tabId } = params;
const userId = ctx.agentId || fallbackUserId;
const result = await fetchApi(baseUrl, `/tabs/${tabId}?userId=${userId}`, {
method: "DELETE",
});
return toToolResult(result);
},
}));
api.registerTool((ctx) => ({
name: "camofox_evaluate",
description: "Execute JavaScript in a Camoufox tab's page context. Returns the result of the expression. Use for injecting scripts, reading page state, or calling web app APIs.",
parameters: {
type: "object",
properties: {
tabId: { type: "string", description: "Tab identifier" },
expression: { type: "string", description: "JavaScript expression to evaluate in the page context" },
},
required: ["tabId", "expression"],
},
async execute(_id, params) {
const { tabId, expression } = params;
const userId = ctx.agentId || fallbackUserId;
const result = await fetchApi(baseUrl, `/tabs/${tabId}/evaluate`, {
method: "POST",
body: JSON.stringify({ userId, expression }),
});
return toToolResult(result);
},
}));
api.registerTool((ctx) => ({
name: "camofox_list_tabs",
description: "List all open Camoufox tabs for a user.",
parameters: {
type: "object",
properties: {},
required: [],
},
async execute(_id, _params) {
const userId = ctx.agentId || fallbackUserId;
const result = await fetchApi(baseUrl, `/tabs?userId=${userId}`);
return toToolResult(result);
},
}));
api.registerTool((ctx) => ({
name: "camofox_import_cookies",
description: "Import cookies into the current Camoufox user session (Netscape cookie file). Use to authenticate to sites like LinkedIn without interactive login.",
parameters: {
type: "object",
properties: {
cookiesPath: { type: "string", description: "Path to Netscape-format cookies.txt file" },
domainSuffix: {
type: "string",
description: "Only import cookies whose domain ends with this suffix",
},
},
required: ["cookiesPath"],
},
async execute(_id, params) {
const { cookiesPath, domainSuffix } = params;
const userId = ctx.agentId || fallbackUserId;
const envCfg = loadConfig();
const cookiesDir = resolve(envCfg.cookiesDir);
const pwCookies = await readCookieFile({
cookiesDir,
cookiesPath,
domainSuffix,
});
if (!envCfg.apiKey) {
throw new Error("CAMOFOX_API_KEY is not set. Cookie import is disabled unless you set CAMOFOX_API_KEY for both the server and the OpenClaw plugin environment.");
}
const result = await fetchApi(baseUrl, `/sessions/${encodeURIComponent(userId)}/cookies`, {
method: "POST",
headers: {
Authorization: `Bearer ${envCfg.apiKey}`,
},
body: JSON.stringify({ cookies: pwCookies }),
});
return toToolResult({ imported: pwCookies.length, userId, result });
},
}));
api.registerCommand({
name: "camofox",
description: "Camoufox browser server control (status, start, stop)",
handler: async (args) => {
const subcommand = args[0] || "status";
switch (subcommand) {
case "status":
try {
const health = await fetchApi(baseUrl, "/health");
api.log?.info?.(`Camoufox server at ${baseUrl}: ${JSON.stringify(health)}`);
}
catch {
api.log?.error?.(`Camoufox server at ${baseUrl}: not reachable`);
}
break;
case "start":
if (serverProcess) {
api.log?.info?.("Camoufox server already running (managed)");
return;
}
if (await checkServerRunning(baseUrl)) {
api.log?.info?.(`Camoufox server already running at ${baseUrl}`);
return;
}
try {
serverProcess = await startServer(pluginDir, port, api.log, cfg);
}
catch (err) {
api.log?.error?.(`Failed to start server: ${err.message}`);
}
break;
case "stop":
if (serverProcess) {
serverProcess.kill();
serverProcess = null;
api.log?.info?.("Stopped camofox-browser server");
}
else {
api.log?.info?.("No managed server process running");
}
break;
default:
api.log?.error?.(`Unknown subcommand: ${subcommand}. Use: status, start, stop`);
}
},
});
// Register health check for openclaw doctor/status
if (api.registerHealthCheck) {
api.registerHealthCheck("camofox-browser", async () => {
try {
const health = (await fetchApi(baseUrl, "/health"));
return {
status: "ok",
message: `Server running (${health.engine || "camoufox"})`,
details: {
url: baseUrl,
engine: health.engine,
activeTabs: health.activeTabs,
managed: serverProcess !== null,
},
};
}
catch {
return {
status: serverProcess ? "warn" : "error",
message: serverProcess
? "Server starting..."
: `Server not reachable at ${baseUrl}`,
details: {
url: baseUrl,
managed: serverProcess !== null,
hint: "Run: openclaw camofox start",
},
};
}
});
}
// Register RPC methods for gateway integration
if (api.registerRpc) {
api.registerRpc("camofox.health", async () => {
try {
const health = await fetchApi(baseUrl, "/health");
return { status: "ok", ...health };
}
catch (err) {
return { status: "error", error: err.message };
}
});
api.registerRpc("camofox.status", async () => {
const running = await checkServerRunning(baseUrl);
return {
running,
managed: serverProcess !== null,
pid: serverProcess?.pid || null,
url: baseUrl,
port,
};
});
}
// Register CLI subcommands (openclaw camofox ...)
if (api.registerCli) {
api.registerCli(({ program }) => {
const camofox = program
.command("camofox")
.description("Camoufox anti-detection browser automation");
camofox
.command("status")
.description("Show server status")
.action(async () => {
try {
const health = (await fetchApi(baseUrl, "/health"));
console.log(`Camoufox server: ${health.status}`);
console.log(` URL: ${baseUrl}`);
console.log(` Engine: ${health.engine || "camoufox"}`);
console.log(` Active tabs: ${health.activeTabs ?? 0}`);
console.log(` Managed: ${serverProcess !== null}`);
}
catch {
console.log(`Camoufox server: not reachable`);
console.log(` URL: ${baseUrl}`);
console.log(` Managed: ${serverProcess !== null}`);
console.log(` Hint: Run 'openclaw camofox start' to start the server`);
}
});
camofox
.command("start")
.description("Start the camofox server")
.action(async () => {
if (serverProcess) {
console.log("Camoufox server already running (managed by plugin)");
return;
}
if (await checkServerRunning(baseUrl)) {
console.log(`Camoufox server already running at ${baseUrl}`);
return;
}
try {
console.log(`Starting camofox server on port ${port}...`);
serverProcess = await startServer(pluginDir, port, api.log, cfg);
console.log(`Camoufox server started at ${baseUrl}`);
}
catch (err) {
console.error(`Failed to start server: ${err.message}`);
process.exit(1);
}
});
camofox
.command("stop")
.description("Stop the camofox server")
.action(async () => {
if (serverProcess) {
serverProcess.kill();
serverProcess = null;
console.log("Stopped camofox server");
}
else {
console.log("No managed server process running");
}
});
camofox
.command("configure")
.description("Configure camofox plugin settings")
.action(async () => {
console.log("Camoufox Browser Configuration");
console.log("================================");
console.log("");
console.log("Current settings:");
console.log(` Server URL: ${baseUrl}`);
console.log(` Port: ${port}`);
console.log(` Auto-start: ${autoStart}`);
console.log("");
console.log("Plugin config (openclaw.json):");
console.log("");
console.log(" plugins:");
console.log(" entries:");
console.log(" camofox-browser:");
console.log(" enabled: true");
console.log(" config:");
console.log(" port: 9377");
console.log(" autoStart: true");
console.log("");
console.log("To use camofox as the ONLY browser tool, disable the built-in:");
console.log("");
console.log(" tools:");
console.log(' deny: ["browser"]');
console.log("");
console.log("This removes OpenClaw's built-in browser tool, leaving camofox tools.");
});
camofox
.command("tabs")
.description("List active browser tabs")
.option("--user <userId>", "Filter by user ID")
.action(async (opts) => {
try {
const endpoint = opts.user ? `/tabs?userId=${opts.user}` : "/tabs";
const tabs = (await fetchApi(baseUrl, endpoint));
if (tabs.length === 0) {
console.log("No active tabs");
return;
}
console.log(`Active tabs (${tabs.length}):`);
for (const tab of tabs) {
console.log(` ${tab.tabId} [${tab.userId}] ${tab.title || tab.url}`);
}
}
catch (err) {
console.error(`Failed to list tabs: ${err.message}`);
}
});
}, { commands: ["camofox"] });
}
}

View File

@ -1,117 +0,0 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { jest } from '@jest/globals';
import {
getUserPersistencePaths,
loadPersistedStorageState,
persistStorageState,
} from '../../lib/persistence.js';
describe('profile persistence helpers', () => {
let tmpDir;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'camofox-persistence-'));
});
afterEach(async () => {
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
test('getUserPersistencePaths is deterministic and stays under root', () => {
const first = getUserPersistencePaths(tmpDir, 'agent/profile:default');
const second = getUserPersistencePaths(tmpDir, 'agent/profile:default');
expect(first).toEqual(second);
expect(first.userDir.startsWith(tmpDir)).toBe(true);
expect(first.storageStatePath.startsWith(first.userDir)).toBe(true);
expect(first.metaPath.startsWith(first.userDir)).toBe(true);
expect(path.basename(first.userDir)).not.toContain('/');
expect(path.basename(first.userDir)).not.toContain(':');
});
test('loadPersistedStorageState returns undefined when no state exists', async () => {
await expect(loadPersistedStorageState(tmpDir, 'user-1')).resolves.toBeUndefined();
});
test('persistStorageState writes storage state and metadata, then load returns the storage path', async () => {
const storageState = {
cookies: [{ name: 'session', value: 'abc', domain: '.example.com', path: '/' }],
origins: [{ origin: 'https://app.example.com', localStorage: [{ name: 'foo', value: 'bar' }] }],
};
const context = {
storageState: jest.fn(async ({ path: targetPath }) => {
await fs.writeFile(targetPath, JSON.stringify(storageState, null, 2));
}),
};
const result = await persistStorageState({
profileDir: tmpDir,
userId: 'user-1',
context,
logger: { warn: jest.fn() },
});
expect(result.persisted).toBe(true);
expect(context.storageState).toHaveBeenCalledTimes(1);
const loadedPath = await loadPersistedStorageState(tmpDir, 'user-1');
expect(loadedPath).toBe(result.storageStatePath);
const meta = JSON.parse(await fs.readFile(result.metaPath, 'utf8'));
expect(meta.userId).toBe('user-1');
expect(meta.storageStatePath).toBe(result.storageStatePath);
});
test('loadPersistedStorageState ignores invalid JSON files', async () => {
const { storageStatePath } = getUserPersistencePaths(tmpDir, 'user-2');
await fs.mkdir(path.dirname(storageStatePath), { recursive: true });
await fs.writeFile(storageStatePath, '{not-json');
await expect(loadPersistedStorageState(tmpDir, 'user-2', { warn: jest.fn() })).resolves.toBeUndefined();
});
test('a failed persist leaves the previous storage-state intact and cleans up tmp files', async () => {
const originalState = {
cookies: [{ name: 'orig', value: 'v1', domain: '.example.com', path: '/' }],
};
const goodContext = {
storageState: jest.fn(async ({ path: targetPath }) => {
await fs.writeFile(targetPath, JSON.stringify(originalState, null, 2));
}),
};
const first = await persistStorageState({
profileDir: tmpDir,
userId: 'user-3',
context: goodContext,
logger: { warn: jest.fn() },
});
expect(first.persisted).toBe(true);
const failingContext = {
storageState: jest.fn(async () => {
throw new Error('simulated crash mid-write');
}),
};
const second = await persistStorageState({
profileDir: tmpDir,
userId: 'user-3',
context: failingContext,
logger: { warn: jest.fn() },
});
expect(second.persisted).toBe(false);
const { userDir, storageStatePath } = getUserPersistencePaths(tmpDir, 'user-3');
const loaded = await loadPersistedStorageState(tmpDir, 'user-3');
expect(loaded).toBe(storageStatePath);
const parsed = JSON.parse(await fs.readFile(storageStatePath, 'utf8'));
expect(parsed).toEqual(originalState);
const leftovers = (await fs.readdir(userDir)).filter((name) => name.includes('.tmp-'));
expect(leftovers).toEqual([]);
});
});

View File

@ -1,98 +0,0 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { jest } from '@jest/globals';
import { createPluginEvents } from '../../lib/plugins.js';
import { register } from './index.js';
describe('persistence plugin', () => {
let tmpDir, events, ctx, mockApp;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'camofox-persist-plugin-'));
events = createPluginEvents();
mockApp = {};
ctx = {
events,
config: { cookiesDir: path.join(tmpDir, 'cookies') },
log: jest.fn(),
};
});
afterEach(async () => {
if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true });
});
test('skips registration when no profileDir configured', async () => {
await register(mockApp, ctx, {});
expect(ctx.log).toHaveBeenCalledWith('warn', expect.stringContaining('no profileDir'));
});
test('restores persisted state on session:creating', async () => {
await register(mockApp, ctx, { profileDir: tmpDir });
// Simulate a prior persisted state
const { getUserPersistencePaths } = await import('../../lib/persistence.js');
const { userDir, storageStatePath } = getUserPersistencePaths(tmpDir, 'user-1');
await fs.mkdir(userDir, { recursive: true });
await fs.writeFile(storageStatePath, JSON.stringify({
cookies: [{ name: 'sid', value: 'abc', domain: '.example.com', path: '/' }],
origins: [],
}));
const contextOptions = { viewport: { width: 1280, height: 720 } };
await events.emitAsync('session:creating', { userId: 'user-1', contextOptions });
expect(contextOptions.storageState).toBe(storageStatePath);
});
test('checkpoints on session:cookies:import', async () => {
await register(mockApp, ctx, { profileDir: tmpDir });
const mockContext = {
storageState: jest.fn(async ({ path: p }) => {
await fs.writeFile(p, JSON.stringify({ cookies: [{ name: 'x', value: 'y', domain: '.test.com', path: '/' }] }));
}),
};
// Simulate session created then cookie import
await events.emitAsync('session:created', { userId: 'user-2', context: mockContext });
await events.emitAsync('session:cookies:import', { userId: 'user-2' });
expect(mockContext.storageState).toHaveBeenCalled();
// Verify file was written
const { getUserPersistencePaths } = await import('../../lib/persistence.js');
const { storageStatePath } = getUserPersistencePaths(tmpDir, 'user-2');
const saved = JSON.parse(await fs.readFile(storageStatePath, 'utf8'));
expect(saved.cookies[0].name).toBe('x');
});
test('checkpoints on session:destroying', async () => {
await register(mockApp, ctx, { profileDir: tmpDir });
const mockContext = {
storageState: jest.fn(async ({ path: p }) => {
await fs.writeFile(p, JSON.stringify({ cookies: [], origins: [] }));
}),
};
await events.emitAsync('session:created', { userId: 'user-3', context: mockContext });
await events.emitAsync('session:destroying', { userId: 'user-3', reason: 'test' });
expect(mockContext.storageState).toHaveBeenCalled();
});
test('env var CAMOFOX_PROFILE_DIR overrides pluginConfig', async () => {
const envDir = path.join(tmpDir, 'env-override');
const orig = process.env.CAMOFOX_PROFILE_DIR;
process.env.CAMOFOX_PROFILE_DIR = envDir;
try {
await register(mockApp, ctx, { profileDir: '/should/not/use' });
expect(ctx.log).toHaveBeenCalledWith('info', 'persistence plugin enabled', { profileDir: envDir });
} finally {
if (orig === undefined) delete process.env.CAMOFOX_PROFILE_DIR;
else process.env.CAMOFOX_PROFILE_DIR = orig;
}
});
});

View File

@ -1,204 +0,0 @@
import { EventEmitter } from 'node:events';
import { jest } from '@jest/globals';
// Mock the launcher module -- index.js no longer imports child_process directly
const mockWatcher = () => {
const proc = new EventEmitter();
proc.pid = 12345;
proc.exitCode = null;
proc.kill = jest.fn();
return proc;
};
const mockStartWatcher = jest.fn(mockWatcher);
const mockResolveVncConfig = jest.fn((pluginConfig = {}) => ({
enabled: pluginConfig.enabled || false,
resolution: pluginConfig.resolution
? (pluginConfig.resolution.split('x').length > 2 ? pluginConfig.resolution : `${pluginConfig.resolution}x24`)
: '1920x1080x24',
vncPassword: pluginConfig.password || '',
viewOnly: pluginConfig.viewOnly || false,
vncPort: pluginConfig.vncPort || '5900',
novncPort: pluginConfig.novncPort || '6080',
}));
jest.unstable_mockModule('./vnc-launcher.js', () => ({
resolveVncConfig: mockResolveVncConfig,
startWatcher: mockStartWatcher,
}));
// Mock auth middleware
jest.unstable_mockModule('../../lib/auth.js', () => ({
requireAuth: () => (_req, _res, next) => next(),
}));
// Minimal VirtualDisplay mock (real class has side-effects that break in test)
class MockVirtualDisplay {
get xvfb_args() {
return ['-screen', '0', '1x1x24', '-ac', '-nolisten', 'tcp'];
}
}
const { register } = await import('./index.js');
describe('vnc plugin', () => {
let events, ctx, mockApp, routes;
beforeEach(() => {
events = new EventEmitter();
events.setMaxListeners(50);
routes = {};
mockApp = {
get: jest.fn((path, ...handlers) => { routes[`GET ${path}`] = handlers; }),
};
ctx = {
events,
config: {},
log: jest.fn(),
sessions: new Map(),
safeError: (err) => typeof err === 'string' ? err : (err?.message || 'Internal error'),
VirtualDisplay: MockVirtualDisplay,
createVirtualDisplay: () => new MockVirtualDisplay(),
};
mockStartWatcher.mockClear();
mockStartWatcher.mockImplementation(mockWatcher);
mockResolveVncConfig.mockClear();
mockResolveVncConfig.mockImplementation((pluginConfig = {}) => ({
enabled: pluginConfig.enabled || false,
resolution: pluginConfig.resolution
? (pluginConfig.resolution.split('x').length > 2 ? pluginConfig.resolution : `${pluginConfig.resolution}x24`)
: '1920x1080x24',
vncPassword: pluginConfig.password || '',
viewOnly: pluginConfig.viewOnly || false,
vncPort: pluginConfig.vncPort || '5900',
novncPort: pluginConfig.novncPort || '6080',
}));
});
test('does not register when disabled', async () => {
await register(mockApp, ctx, {});
expect(mockStartWatcher).not.toHaveBeenCalled();
expect(mockApp.get).not.toHaveBeenCalled();
});
test('registers when pluginConfig.enabled is true', async () => {
await register(mockApp, ctx, { enabled: true });
expect(mockStartWatcher).toHaveBeenCalled();
expect(mockApp.get).toHaveBeenCalledWith(
'/sessions/:userId/storage_state',
expect.any(Function),
expect.any(Function),
);
});
test('passes resolved config to startWatcher', async () => {
await register(mockApp, ctx, { enabled: true, password: 'secret', vncPort: 5901 });
expect(mockStartWatcher).toHaveBeenCalledWith(
expect.objectContaining({
vncPassword: 'secret',
vncPort: 5901,
log: ctx.log,
events,
}),
);
});
test('overrides createVirtualDisplay with custom resolution', async () => {
await register(mockApp, ctx, { enabled: true, resolution: '1280x720' });
const vd = ctx.createVirtualDisplay();
const args = vd.xvfb_args;
const screenIdx = args.indexOf('0');
expect(args[screenIdx + 1]).toBe('1280x720x24');
});
test('appends x24 depth to WxH resolution', async () => {
await register(mockApp, ctx, { enabled: true, resolution: '1920x1080' });
const vd = ctx.createVirtualDisplay();
const args = vd.xvfb_args;
const screenIdx = args.indexOf('0');
expect(args[screenIdx + 1]).toBe('1920x1080x24');
});
test('preserves explicit depth in resolution', async () => {
await register(mockApp, ctx, { enabled: true, resolution: '1920x1080x32' });
const vd = ctx.createVirtualDisplay();
const args = vd.xvfb_args;
const screenIdx = args.indexOf('0');
expect(args[screenIdx + 1]).toBe('1920x1080x32');
});
test('storage_state endpoint returns 404 for unknown user', async () => {
await register(mockApp, ctx, { enabled: true });
const handler = routes['GET /sessions/:userId/storage_state'].at(-1);
const req = { params: { userId: 'unknown' }, reqId: 'test' };
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(404);
});
test('storage_state endpoint returns state for active session', async () => {
await register(mockApp, ctx, { enabled: true });
const mockState = { cookies: [{ name: 'sid', value: 'abc' }], origins: [] };
ctx.sessions.set('user-1', {
context: { storageState: jest.fn(async () => mockState) },
});
const handler = routes['GET /sessions/:userId/storage_state'].at(-1);
const req = { params: { userId: 'user-1' }, reqId: 'test' };
const res = { json: jest.fn() };
await handler(req, res);
expect(res.json).toHaveBeenCalledWith(mockState);
});
test('storage_state endpoint uses safeError on failure', async () => {
await register(mockApp, ctx, { enabled: true });
ctx.sessions.set('user-1', {
context: { storageState: jest.fn(async () => { throw new Error('context destroyed'); }) },
});
const handler = routes['GET /sessions/:userId/storage_state'].at(-1);
const req = { params: { userId: 'user-1' }, reqId: 'test' };
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(500);
// safeError returns the message string -- not the raw Error object
expect(res.json).toHaveBeenCalledWith({ error: 'context destroyed' });
});
test('emits vnc:storage:exported and session:storage:export on export', async () => {
await register(mockApp, ctx, { enabled: true });
ctx.sessions.set('user-1', {
context: { storageState: jest.fn(async () => ({ cookies: [], origins: [] })) },
});
const exported = [];
events.on('vnc:storage:exported', (e) => exported.push(e));
events.on('session:storage:export', (e) => exported.push(e));
const handler = routes['GET /sessions/:userId/storage_state'].at(-1);
await handler(
{ params: { userId: 'user-1' }, reqId: 'test' },
{ json: jest.fn() },
);
expect(exported).toHaveLength(2);
expect(exported[0]).toMatchObject({ userId: 'user-1' });
});
test('watcher is killed on server:shutdown', async () => {
await register(mockApp, ctx, { enabled: true });
const proc = mockStartWatcher.mock.results[0].value;
events.emit('server:shutdown');
expect(proc.kill).toHaveBeenCalledWith('SIGTERM');
});
});

View File

@ -1,41 +0,0 @@
import { parseJson3, parseVtt, parseXml } from './youtube.js';
describe('YouTube transcript parsers', () => {
test('parseJson3 extracts timestamped text', () => {
const json3 = JSON.stringify({
events: [
{ tStartMs: 0, segs: [{ utf8: 'Hello' }] },
{ tStartMs: 65000, segs: [{ utf8: 'World' }] },
],
});
const result = parseJson3(json3);
expect(result).toBe('[00:00] Hello\n[01:05] World');
});
test('parseVtt extracts text from VTT', () => {
const vtt = `WEBVTT
00:00:01.000 --> 00:00:04.000
Hello there
00:01:05.000 --> 00:01:09.000
General Kenobi`;
const result = parseVtt(vtt);
expect(result).toContain('[00:01] Hello there');
expect(result).toContain('[01:05] General Kenobi');
});
test('parseXml extracts text from XML captions', () => {
const xml = '<text start="0" dur="3">First line</text><text start="65.5" dur="2">Second line</text>';
const result = parseXml(xml);
expect(result).toBe('[00:00] First line\n[01:05] Second line');
});
test('parseJson3 handles empty events', () => {
expect(parseJson3(JSON.stringify({ events: [] }))).toBe('');
});
test('parseJson3 handles malformed JSON', () => {
expect(parseJson3('not json')).toBeNull();
});
});

View File

@ -1,10 +0,0 @@
[build]
builder = "DOCKERFILE"
dockerfilePath = "Dockerfile.ci"
[deploy]
startCommand = "sh -c 'CAMOFOX_PORT=${PORT:-9377} node --max-old-space-size=${MAX_OLD_SPACE_SIZE:-128} server.js'"
healthcheckPath = "/health"
healthcheckTimeout = 120
restartPolicyType = "ON_FAILURE"
restartPolicyMaxRetries = 10

View File

@ -1,85 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Release script for @askjo/camofox-browser
# Usage: ./release.sh [patch|minor|major]
# Defaults to patch if no argument given.
#
# This script:
# 1. Runs pre-flight checks (clean tree, on master, up to date)
# 2. Runs tests locally
# 3. Bumps version via npm version (which syncs openclaw.plugin.json)
# 4. Pushes commit + tag to origin
# 5. GitHub Actions publishes to npm with provenance
#
# The actual npm publish happens in CI (.github/workflows/publish.yml).
BUMP="${1:-patch}"
if [[ "$BUMP" != "patch" && "$BUMP" != "minor" && "$BUMP" != "major" ]]; then
echo "Usage: ./release.sh [patch|minor|major]"
exit 1
fi
cd "$(dirname "$0")"
# --- Pre-flight checks ---
echo "🔍 Pre-flight checks..."
# Clean working tree
if [[ -n "$(git status --porcelain)" ]]; then
echo "❌ Working tree is dirty. Commit or stash changes first."
exit 1
fi
# On master
BRANCH=$(git branch --show-current)
if [[ "$BRANCH" != "master" ]]; then
echo "❌ Not on master (on $BRANCH). Switch to master first."
exit 1
fi
# Up to date with remote
git fetch origin master --quiet
LOCAL=$(git rev-parse HEAD)
REMOTE=$(git rev-parse origin/master)
if [[ "$LOCAL" != "$REMOTE" ]]; then
echo "❌ Local master ($LOCAL) differs from origin ($REMOTE). Pull/push first."
exit 1
fi
# --- Tests ---
echo ""
echo "🧪 Running tests..."
JEST_OUTPUT=$(NODE_OPTIONS='--experimental-vm-modules' npx jest --runInBand --forceExit --testPathPattern='tests/unit' 2>&1)
echo "$JEST_OUTPUT" | tail -5
if echo "$JEST_OUTPUT" | grep -q 'Tests:.*failed'; then
echo "❌ Tests failed"
exit 1
fi
echo ""
# --- Version bump ---
CURRENT=$(node -p "require('./package.json').version")
echo "📦 Current version: $CURRENT"
echo "📦 Bumping: $BUMP"
echo ""
# npm version bumps package.json, runs the "version" lifecycle script
# (which syncs openclaw.plugin.json), creates a git commit and tag
npm version "$BUMP" --message "v%s"
NEW_VERSION=$(node -p "require('./package.json').version")
echo ""
echo "📦 New version: $NEW_VERSION"
# --- Push (triggers CI publish) ---
echo ""
echo "📤 Pushing commit and tag (CI will publish to npm)..."
git push origin master --follow-tags
echo ""
echo "✅ Release v${NEW_VERSION} triggered"
echo " CI will publish @askjo/camofox-browser@${NEW_VERSION} with provenance"
echo " Watch: https://github.com/jo-inc/camofox-browser/actions"
echo " Package: https://www.npmjs.com/package/@askjo/camofox-browser"

View File

@ -1,117 +0,0 @@
/**
* Tests for scripts/plugin.js -- plugin install, remove, list.
*/
import fs from 'fs';
import path from 'path';
import { execSync } from './exec.js';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, '..');
const SCRIPT = path.join(ROOT, 'scripts', 'plugin.js');
const PLUGINS_DIR = path.join(ROOT, 'plugins');
const CONFIG_PATH = path.join(ROOT, 'camofox.config.json');
const run = (args) => execSync(`node ${SCRIPT} ${args}`, { cwd: ROOT, encoding: 'utf-8' });
// Save/restore config around tests
let originalConfig;
beforeAll(() => { originalConfig = fs.readFileSync(CONFIG_PATH, 'utf-8'); });
afterAll(() => { fs.writeFileSync(CONFIG_PATH, originalConfig); });
// Clean up test plugins after each test
afterEach(() => {
const testDir = path.join(PLUGINS_DIR, 'test-plugin');
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
// Restore config
fs.writeFileSync(CONFIG_PATH, originalConfig);
});
describe('plugin list', () => {
test('lists youtube as enabled', () => {
const out = run('list');
expect(out).toContain('youtube');
expect(out).toContain('[ok]');
});
});
describe('plugin install (local)', () => {
const tmpDir = path.join(ROOT, '.tmp-test-plugin');
beforeEach(() => {
fs.mkdirSync(tmpDir, { recursive: true });
fs.writeFileSync(path.join(tmpDir, 'index.js'),
'export function register(app, ctx) { app.get("/test", (req, res) => res.json({})); }');
});
afterEach(() => {
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true });
const installed = path.join(PLUGINS_DIR, '.tmp-test-plugin');
if (fs.existsSync(installed)) fs.rmSync(installed, { recursive: true });
});
test('copies plugin dir and updates config', () => {
const out = run(`install ${tmpDir}`);
expect(out).toContain('Installed');
// Plugin dir exists
const installed = path.join(PLUGINS_DIR, '.tmp-test-plugin');
expect(fs.existsSync(installed)).toBe(true);
expect(fs.existsSync(path.join(installed, 'index.js'))).toBe(true);
// Config updated
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
if (Array.isArray(config.plugins)) {
expect(config.plugins).toContain('.tmp-test-plugin');
} else {
expect(config.plugins['.tmp-test-plugin']).toBeDefined();
}
});
test('rejects duplicate install', () => {
run(`install ${tmpDir}`);
expect(() => run(`install ${tmpDir}`)).toThrow();
});
});
describe('plugin remove', () => {
const tmpDir = path.join(ROOT, '.tmp-test-plugin-rm');
beforeEach(() => {
fs.mkdirSync(tmpDir, { recursive: true });
fs.writeFileSync(path.join(tmpDir, 'index.js'),
'export function register(app, ctx) {}');
run(`install ${tmpDir}`);
});
afterEach(() => {
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true });
const installed = path.join(PLUGINS_DIR, '.tmp-test-plugin-rm');
if (fs.existsSync(installed)) fs.rmSync(installed, { recursive: true });
});
test('removes plugin dir and config entry', () => {
const out = run('remove .tmp-test-plugin-rm');
expect(out).toContain('Removed');
const installed = path.join(PLUGINS_DIR, '.tmp-test-plugin-rm');
expect(fs.existsSync(installed)).toBe(false);
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
expect(config.plugins).not.toContain('.tmp-test-plugin-rm');
});
test('errors on unknown plugin', () => {
expect(() => run('remove nonexistent-plugin-xyz')).toThrow();
});
});
describe('plugin help', () => {
test('shows usage with no args', () => {
const out = run('');
expect(out).toContain('Usage');
expect(out).toContain('install');
expect(out).toContain('remove');
});
});

View File

@ -1,53 +0,0 @@
import { afterEach, describe, expect, test } from '@jest/globals';
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { spawnSync } from 'child_process';
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import { externalExecutableFromEnv } from './postinstall.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const tempDirs = [];
function makeExecutable() {
const dir = mkdtempSync(join(tmpdir(), 'camofox-postinstall-test-'));
tempDirs.push(dir);
const executable = join(dir, 'camoufox-bin');
writeFileSync(executable, '#!/bin/sh\nexit 0\n');
chmodSync(executable, 0o755);
return executable;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe('postinstall external executable handling', () => {
test('uses CAMOUFOX_EXECUTABLE before compatibility aliases', () => {
expect(externalExecutableFromEnv({
CAMOUFOX_EXECUTABLE: '/primary',
CAMOUFOX_EXECUTABLE_PATH: '/compat',
CAMOFOX_EXECUTABLE_PATH: '/legacy',
})).toEqual({ name: 'CAMOUFOX_EXECUTABLE', value: '/primary' });
});
test('skips bundled download when an external executable is configured', () => {
const executable = makeExecutable();
const result = spawnSync(process.execPath, ['scripts/postinstall.js'], {
cwd: join(__dirname, '..'),
encoding: 'utf8',
env: {
...process.env,
CAMOUFOX_EXECUTABLE: executable,
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1',
},
});
expect(result.status).toBe(0);
expect(result.stdout).toContain('skipping bundled Camoufox download');
expect(result.stderr).toBe('');
});
});

View File

@ -1,97 +0,0 @@
import { createClient } from '../helpers/client.js';
import { getSharedEnv } from './sharedEnv.js';
describe('Concurrency', () => {
let serverUrl;
let testSiteUrl;
beforeAll(() => {
const env = getSharedEnv();
serverUrl = env.serverUrl;
testSiteUrl = env.testSiteUrl;
});
// Server lifecycle managed by globalSetup/globalTeardown
test('concurrent operations on same tab are serialized', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab(`${testSiteUrl}/pageA`);
// Fire multiple operations concurrently on the same tab
const operations = [
client.getSnapshot(tabId),
client.navigate(tabId, `${testSiteUrl}/pageB`),
client.getSnapshot(tabId),
];
// All should complete without errors (tab locking serializes them)
const results = await Promise.all(operations);
expect(results.length).toBe(3);
// Each result should be valid (no crashes)
results.forEach(r => expect(r).toBeDefined());
} finally {
await client.cleanup();
}
});
test('parallel operations on different tabs work', async () => {
const client = createClient(serverUrl);
try {
// Create two tabs
const tab1 = await client.createTab(`${testSiteUrl}/pageA`);
const tab2 = await client.createTab(`${testSiteUrl}/pageB`);
// Run operations on both tabs in parallel
const [snap1, snap2] = await Promise.all([
client.getSnapshot(tab1.tabId),
client.getSnapshot(tab2.tabId),
]);
// Both should return valid snapshots
expect(snap1.snapshot).toContain('Page A');
expect(snap2.snapshot).toContain('Page B');
} finally {
await client.cleanup();
}
});
test('multiple clients can work independently', async () => {
const client1 = createClient(serverUrl);
const client2 = createClient(serverUrl);
try {
// Each client creates their own tab
const [tab1, tab2] = await Promise.all([
client1.createTab(`${testSiteUrl}/pageA`),
client2.createTab(`${testSiteUrl}/pageB`),
]);
// Verify they are independent
expect(tab1.tabId).not.toBe(tab2.tabId);
expect(client1.userId).not.toBe(client2.userId);
// Both can operate independently
const [snap1, snap2] = await Promise.all([
client1.getSnapshot(tab1.tabId),
client2.getSnapshot(tab2.tabId),
]);
expect(snap1.snapshot).toContain('Page A');
expect(snap2.snapshot).toContain('Page B');
// Closing one client's session doesn't affect the other
await client1.closeSession();
// Client 2 still works
const snap2After = await client2.getSnapshot(tab2.tabId);
expect(snap2After.snapshot).toContain('Page B');
} finally {
await client1.cleanup();
await client2.cleanup();
}
});
});

View File

@ -1,73 +0,0 @@
import { createClient } from '../helpers/client.js';
import { getSharedEnv } from './sharedEnv.js';
describe('Downloads and Images', () => {
let serverUrl;
let testSiteUrl;
beforeAll(() => {
const env = getSharedEnv();
serverUrl = env.serverUrl;
testSiteUrl = env.testSiteUrl;
});
// Server lifecycle managed by globalSetup/globalTeardown
test('GET /tabs/:tabId/images returns image sources', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab(`${testSiteUrl}/images-page`);
const result = await client.getImages(tabId, { includeData: true, maxBytes: 1024 * 1024, limit: 10 });
expect(result.images).toBeDefined();
expect(Array.isArray(result.images)).toBe(true);
expect(result.images.length).toBeGreaterThan(0);
const first = result.images[0];
expect(first.src).toMatch(/^data:image\/png;base64,/);
expect(first.alt).toBe('Sample');
expect(first.dataUrl).toMatch(/^data:image\/png;base64,/);
expect(first.bytes).toBeGreaterThan(0);
} finally {
await client.cleanup();
}
});
test('GET /tabs/:tabId/downloads captures browser downloads', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab(`${testSiteUrl}/download-page`);
// Prefer selector click (stable for test site)
await client.click(tabId, { selector: '#downloadLink' });
// Poll downloads until captured
let downloads = [];
for (let i = 0; i < 40; i++) {
const result = await client.getDownloads(tabId, { includeData: true, maxBytes: 1024 * 1024, consume: false });
downloads = Array.isArray(result.downloads) ? result.downloads : [];
if (downloads.length > 0) break;
await new Promise((r) => setTimeout(r, 250));
}
expect(downloads.length).toBeGreaterThan(0);
const first = downloads[0];
expect(first.suggestedFilename).toBe('hello.txt');
expect(first.bytes).toBeGreaterThan(0);
expect(first.dataBase64).toBeDefined();
expect(typeof first.dataBase64).toBe('string');
// consume should clear
const consumed = await client.getDownloads(tabId, { includeData: false, consume: true });
expect(consumed.downloads).toBeDefined();
const empty = await client.getDownloads(tabId, { includeData: false, consume: false });
expect(Array.isArray(empty.downloads)).toBe(true);
expect(empty.downloads.length).toBe(0);
} finally {
await client.cleanup();
}
});
});

View File

@ -1,126 +0,0 @@
import { jest } from '@jest/globals';
import { createClient } from '../helpers/client.js';
import { getSharedEnv } from './sharedEnv.js';
jest.retryTimes(2, { logErrorsBeforeRetry: true });
describe('Form Submission', () => {
let serverUrl;
let testSiteUrl;
beforeAll(() => {
const env = getSharedEnv();
serverUrl = env.serverUrl;
testSiteUrl = env.testSiteUrl;
});
// Server lifecycle managed by globalSetup/globalTeardown
test('fill form fields and submit via button click', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab(`${testSiteUrl}/form`);
// Fill username
await client.type(tabId, {
selector: '#username',
text: 'testuser'
});
// Fill email
await client.type(tabId, {
selector: '#email',
text: 'test@example.com'
});
// Click submit button
await client.click(tabId, {
selector: '#submitBtn'
});
// Wait for form submission and navigation
const snapshot = await client.waitForUrl(tabId, '/submitted');
expect(snapshot.url).toContain('/submitted');
expect(snapshot.snapshot).toContain('Form Submitted Successfully');
expect(snapshot.snapshot).toContain('Username: testuser');
expect(snapshot.snapshot).toContain('Email: test@example.com');
} finally {
await client.cleanup();
}
});
test('click button on page', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab(`${testSiteUrl}/click`);
// Initial state
let snapshot = await client.getSnapshot(tabId);
expect(snapshot.snapshot).not.toContain('Button was clicked!');
// Click the button
await client.click(tabId, {
selector: '#clickMe'
});
// Verify click effect
snapshot = await client.waitForSnapshotContains(tabId, 'Button was clicked!');
expect(snapshot.snapshot).toContain('Button was clicked!');
} finally {
await client.cleanup();
}
});
test('click using ref', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab(`${testSiteUrl}/click`);
// Get snapshot to find button ref
const snapshot = await client.getSnapshot(tabId);
// Find ref for "Click Me" button
const match = snapshot.snapshot.match(/\[(e\d+)\].*button.*Click Me/i);
if (match) {
const ref = match[1];
await client.click(tabId, { ref });
const updatedSnapshot = await client.waitForSnapshotContains(tabId, 'Button was clicked!');
expect(updatedSnapshot.snapshot).toContain('Button was clicked!');
} else {
// Fallback to selector
await client.click(tabId, { selector: '#clickMe' });
const updatedSnapshot = await client.waitForSnapshotContains(tabId, 'Button was clicked!');
expect(updatedSnapshot.snapshot).toContain('Button was clicked!');
}
} finally {
await client.cleanup();
}
});
test('click link to navigate', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab(`${testSiteUrl}/pageA`);
// Click the link to page B
await client.click(tabId, {
selector: 'a[href="/pageB"]'
});
// Wait for navigation
const snapshot = await client.waitForUrl(tabId, '/pageB');
expect(snapshot.url).toContain('/pageB');
expect(snapshot.snapshot).toContain('Page B');
} finally {
await client.cleanup();
}
});
});

View File

@ -1,70 +0,0 @@
/**
* Jest globalSetup for e2e tests.
* Starts ONE camofox server + test site shared across ALL e2e test files.
* Writes connection URLs to a temp file so test files can read them.
*/
import fs from 'fs';
import path from 'path';
import os from 'os';
import { fileURLToPath } from 'node:url';
import { launchServer } from '../../lib/launcher.js';
import { loadConfig } from '../../lib/config.js';
import { DISPLAY } from '../helpers/test-env.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const ENV_FILE = path.join(os.tmpdir(), 'camofox-e2e-env.json');
async function waitForServer(port, maxRetries = 30, interval = 1000) {
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch(`http://localhost:${port}/health`);
if (response.ok) return true;
} catch (e) { /* not ready */ }
await new Promise(r => setTimeout(r, interval));
}
throw new Error(`Server failed to start on port ${port} after ${maxRetries} attempts`);
}
export default async function globalSetup() {
// --- Start camofox server ---
const serverPort = Math.floor(3100 + Math.random() * 900);
const cfg = loadConfig();
const pluginDir = path.resolve(__dirname, '../..');
const log = {
info: (msg) => console.log(msg),
error: (msg) => console.error(msg),
};
const serverProcess = launchServer({
pluginDir,
port: serverPort,
env: { ...cfg.serverEnv, DEBUG_RESPONSES: 'false', DISPLAY },
log,
});
serverProcess.on('error', (err) => {
console.error('Failed to start server:', err);
});
await waitForServer(serverPort);
console.log(`[globalSetup] camofox server on port ${serverPort}`);
// --- Start test site (express) ---
const { startTestSite, getTestSiteUrl } = await import('../helpers/testSite.js');
await startTestSite();
const testSiteUrl = getTestSiteUrl();
console.log(`[globalSetup] test site at ${testSiteUrl}`);
// Write env to temp file for test workers to read
fs.writeFileSync(ENV_FILE, JSON.stringify({
serverUrl: `http://localhost:${serverPort}`,
testSiteUrl,
serverPid: serverProcess.pid,
}));
// Store for globalTeardown (same process, globalThis persists)
globalThis.__CAMOFOX_SERVER_PROCESS__ = serverProcess;
globalThis.__CAMOFOX_ENV_FILE__ = ENV_FILE;
}

View File

@ -1,35 +0,0 @@
/**
* Jest globalTeardown for e2e tests.
* Stops the shared camofox server + test site.
*/
import fs from 'fs';
export default async function globalTeardown() {
// Stop test site
try {
const { stopTestSite } = await import('../helpers/testSite.js');
await stopTestSite();
} catch (e) {
console.error('[globalTeardown] test site stop error:', e.message);
}
// Kill camofox server
const proc = globalThis.__CAMOFOX_SERVER_PROCESS__;
if (proc) {
await new Promise((resolve) => {
proc.on('close', resolve);
proc.kill('SIGTERM');
setTimeout(() => {
if (!proc.killed) proc.kill('SIGKILL');
}, 5000);
});
}
// Clean up temp file
const envFile = globalThis.__CAMOFOX_ENV_FILE__;
if (envFile) {
try { fs.unlinkSync(envFile); } catch (e) { /* ignore */ }
}
console.log('[globalTeardown] done');
}

View File

@ -1,86 +0,0 @@
import { createClient } from '../helpers/client.js';
import { getSharedEnv } from './sharedEnv.js';
describe('Macro Navigation', () => {
let serverUrl;
let testSiteUrl;
beforeAll(() => {
const env = getSharedEnv();
serverUrl = env.serverUrl;
testSiteUrl = env.testSiteUrl;
});
// Server lifecycle managed by globalSetup/globalTeardown
test('unknown macro returns error when no fallback URL', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab();
await expect(client.navigate(tabId, '@nonexistent_macro test query'))
.rejects.toThrow(/url or macro required/);
} finally {
await client.cleanup();
}
});
test('client parses @macro syntax correctly', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab();
// Navigate to a real URL first so we have a valid tab
await client.navigate(tabId, `${testSiteUrl}/pageA`);
// Now try an unknown macro - if client parsing works,
// server will receive {macro: "@unknown", query: "with spaces"}
// and return "url or macro required" error
await expect(client.navigate(tabId, '@unknown with spaces'))
.rejects.toThrow(/url or macro required/);
} finally {
await client.cleanup();
}
});
test('regular URL still works after macro changes', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab();
// Regular URL should still work
const result = await client.navigate(tabId, `${testSiteUrl}/pageA`);
expect(result.ok).toBe(true);
expect(result.url).toContain('/pageA');
const snapshot = await client.getSnapshot(tabId);
expect(snapshot.snapshot).toContain('Page A');
} finally {
await client.cleanup();
}
});
test('navigate API accepts macro and query params directly', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab();
// Test the raw API with macro param directly (bypass client parsing)
// Unknown macro should fail
await expect(
client.request('POST', `/tabs/${tabId}/navigate`, {
userId: client.userId,
macro: '@fake_macro',
query: 'test'
})
).rejects.toThrow(/url or macro required/);
} finally {
await client.cleanup();
}
});
});

View File

@ -1,122 +0,0 @@
import { createClient } from '../helpers/client.js';
import { getSharedEnv } from './sharedEnv.js';
describe('Navigation', () => {
let serverUrl;
let testSiteUrl;
beforeAll(() => {
const env = getSharedEnv();
serverUrl = env.serverUrl;
testSiteUrl = env.testSiteUrl;
});
// Server lifecycle managed by globalSetup/globalTeardown
test('navigate to URL', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab();
const result = await client.navigate(tabId, `${testSiteUrl}/pageA`);
expect(result.ok).toBe(true);
expect(result.url).toContain('/pageA');
const snapshot = await client.getSnapshot(tabId);
expect(snapshot.snapshot).toContain('Welcome to Page A');
} finally {
await client.cleanup();
}
});
test('navigate back', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab(`${testSiteUrl}/pageA`);
await client.navigate(tabId, `${testSiteUrl}/pageB`);
// Verify we're on page B
let snapshot = await client.getSnapshot(tabId);
expect(snapshot.snapshot).toContain('Page B');
// Go back
const result = await client.back(tabId);
expect(result.ok).toBe(true);
expect(result.url).toContain('/pageA');
snapshot = await client.getSnapshot(tabId);
expect(snapshot.snapshot).toContain('Page A');
} finally {
await client.cleanup();
}
});
test('navigate forward', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab(`${testSiteUrl}/pageA`);
await client.navigate(tabId, `${testSiteUrl}/pageB`);
await client.back(tabId);
// Verify we're back on page A
let snapshot = await client.getSnapshot(tabId);
expect(snapshot.snapshot).toContain('Page A');
// Go forward
const result = await client.forward(tabId);
expect(result.ok).toBe(true);
expect(result.url).toContain('/pageB');
snapshot = await client.getSnapshot(tabId);
expect(snapshot.snapshot).toContain('Page B');
} finally {
await client.cleanup();
}
});
test('refresh page', async () => {
const client = createClient(serverUrl);
try {
// Use the refresh counter page
const { tabId } = await client.createTab(`${testSiteUrl}/refresh-test`);
let snapshot = await client.getSnapshot(tabId);
const initialMatch = snapshot.snapshot.match(/Count: (\d+)/);
const initialCount = initialMatch ? parseInt(initialMatch[1]) : 0;
// Refresh the page
const result = await client.refresh(tabId);
expect(result.ok).toBe(true);
snapshot = await client.getSnapshot(tabId);
const newMatch = snapshot.snapshot.match(/Count: (\d+)/);
const newCount = newMatch ? parseInt(newMatch[1]) : 0;
// Count should have incremented
expect(newCount).toBe(initialCount + 1);
} finally {
await client.cleanup();
}
});
test('navigation updates visited URLs', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab(`${testSiteUrl}/pageA`);
await client.navigate(tabId, `${testSiteUrl}/pageB`);
const stats = await client.getStats(tabId);
expect(stats.visitedUrls).toContain(`${testSiteUrl}/pageA`);
expect(stats.visitedUrls).toContain(`${testSiteUrl}/pageB`);
} finally {
await client.cleanup();
}
});
});

View File

@ -1,196 +0,0 @@
import { createClient } from '../helpers/client.js';
import { getSharedEnv } from './sharedEnv.js';
describe('Screenshot', () => {
let serverUrl;
let testSiteUrl;
beforeAll(() => {
const env = getSharedEnv();
serverUrl = env.serverUrl;
testSiteUrl = env.testSiteUrl;
});
// Server lifecycle managed by globalSetup/globalTeardown
test('screenshot returns raw PNG binary with correct magic bytes', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab(`${testSiteUrl}/pageA`);
const buffer = await client.screenshot(tabId);
expect(buffer).toBeDefined();
expect(buffer.byteLength).toBeGreaterThan(0);
// PNG magic bytes: 0x89 P N G
const bytes = new Uint8Array(buffer);
expect(bytes[0]).toBe(0x89);
expect(bytes[1]).toBe(0x50);
expect(bytes[2]).toBe(0x4e);
expect(bytes[3]).toBe(0x47);
} finally {
await client.cleanup();
}
});
test('screenshot response has image/png content type', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab(`${testSiteUrl}/pageA`);
// Raw fetch to check headers directly
const res = await fetch(
`${serverUrl}/tabs/${tabId}/screenshot?userId=${client.userId}`
);
expect(res.ok).toBe(true);
expect(res.headers.get('content-type')).toBe('image/png');
const buffer = await res.arrayBuffer();
expect(buffer.byteLength).toBeGreaterThan(0);
} finally {
await client.cleanup();
}
});
test('screenshot response is NOT valid JSON', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab(`${testSiteUrl}/pageA`);
const res = await fetch(
`${serverUrl}/tabs/${tabId}/screenshot?userId=${client.userId}`
);
expect(res.ok).toBe(true);
// This is the exact bug: fetchApi() called res.json() on PNG binary.
// Verify that parsing as JSON throws.
const text = await res.clone().text();
expect(() => JSON.parse(text)).toThrow();
} finally {
await client.cleanup();
}
});
test('screenshot can be base64-encoded for LLM image content blocks', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab(`${testSiteUrl}/pageA`);
const res = await fetch(
`${serverUrl}/tabs/${tabId}/screenshot?userId=${client.userId}`
);
expect(res.ok).toBe(true);
const arrayBuffer = await res.arrayBuffer();
const base64 = Buffer.from(arrayBuffer).toString('base64');
// base64 should be a non-empty string
expect(typeof base64).toBe('string');
expect(base64.length).toBeGreaterThan(0);
// Round-trip: decode back and verify PNG magic bytes
const decoded = Buffer.from(base64, 'base64');
expect(decoded[0]).toBe(0x89);
expect(decoded[1]).toBe(0x50);
expect(decoded[2]).toBe(0x4e);
expect(decoded[3]).toBe(0x47);
} finally {
await client.cleanup();
}
});
test('fullPage screenshot is larger than viewport screenshot', async () => {
const client = createClient(serverUrl);
try {
// Use the scroll page which has lots of content (5000px tall)
const { tabId } = await client.createTab(`${testSiteUrl}/scroll`);
// Viewport screenshot
const viewportRes = await fetch(
`${serverUrl}/tabs/${tabId}/screenshot?userId=${client.userId}&fullPage=false`
);
const viewportBuf = await viewportRes.arrayBuffer();
// Full page screenshot
const fullPageRes = await fetch(
`${serverUrl}/tabs/${tabId}/screenshot?userId=${client.userId}&fullPage=true`
);
const fullPageBuf = await fullPageRes.arrayBuffer();
// Both should be valid PNGs
expect(new Uint8Array(viewportBuf)[0]).toBe(0x89);
expect(new Uint8Array(fullPageBuf)[0]).toBe(0x89);
// Full page should be larger (more pixels to encode)
expect(fullPageBuf.byteLength).toBeGreaterThan(viewportBuf.byteLength);
} finally {
await client.cleanup();
}
});
test('screenshot of non-existent tab returns 410 JSON error', async () => {
const client = createClient(serverUrl);
try {
const res = await fetch(
`${serverUrl}/tabs/00000000-0000-0000-0000-000000000000/screenshot?userId=${client.userId}`
);
expect(res.status).toBe(410);
expect(res.headers.get('content-type')).toContain('application/json');
const data = await res.json();
expect(data.error).toBeDefined();
} finally {
await client.cleanup();
}
});
test('screenshot works after navigation', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab(`${testSiteUrl}/pageA`);
await client.navigate(tabId, `${testSiteUrl}/pageB`);
const buffer = await client.screenshot(tabId);
expect(buffer).toBeDefined();
expect(buffer.byteLength).toBeGreaterThan(0);
const bytes = new Uint8Array(buffer);
expect(bytes[0]).toBe(0x89);
} finally {
await client.cleanup();
}
});
test('screenshot works after click interaction', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab(`${testSiteUrl}/click`);
// Get snapshot to build refs, then click the button
const snapshot = await client.getSnapshot(tabId);
await client.click(tabId, { selector: '#clickMe' });
const buffer = await client.screenshot(tabId);
expect(buffer).toBeDefined();
expect(buffer.byteLength).toBeGreaterThan(0);
const bytes = new Uint8Array(buffer);
expect(bytes[0]).toBe(0x89);
} finally {
await client.cleanup();
}
});
});

View File

@ -1,75 +0,0 @@
import { createClient } from '../helpers/client.js';
import { getSharedEnv } from './sharedEnv.js';
describe('Scroll', () => {
let serverUrl;
let testSiteUrl;
beforeAll(() => {
const env = getSharedEnv();
serverUrl = env.serverUrl;
testSiteUrl = env.testSiteUrl;
});
// Server lifecycle managed by globalSetup/globalTeardown
test('scroll down page', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab(`${testSiteUrl}/scroll`);
// Scroll down
const result = await client.scroll(tabId, {
direction: 'down',
amount: 500
});
expect(result.ok).toBe(true);
} finally {
await client.cleanup();
}
});
test('scroll to bottom of page', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab(`${testSiteUrl}/scroll`);
// Scroll to bottom
const result = await client.scroll(tabId, {
direction: 'down',
amount: 10000 // Large number to reach bottom
});
expect(result.ok).toBe(true);
// The snapshot might now include "Bottom of page" text
// (depending on viewport and scroll behavior)
} finally {
await client.cleanup();
}
});
test('scroll up page', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab(`${testSiteUrl}/scroll`);
// First scroll down
await client.scroll(tabId, { direction: 'down', amount: 1000 });
// Then scroll up
const result = await client.scroll(tabId, {
direction: 'up',
amount: 500
});
expect(result.ok).toBe(true);
} finally {
await client.cleanup();
}
});
});

Some files were not shown because too many files have changed in this diff Show More