Compare commits
No commits in common. "main" and "v1.11.2-npm" have entirely different histories.
main
...
v1.11.2-np
|
|
@ -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
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
# Require review for workflow changes
|
||||
.github/ @skyfallsin
|
||||
|
|
@ -1 +0,0 @@
|
|||
github: skyfallsin
|
||||
|
|
@ -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',
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
@ -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 }}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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/
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
78
Makefile
78
Makefile
|
|
@ -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
171
build.ps1
|
|
@ -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 }
|
||||
}
|
||||
BIN
camofox-og.png
BIN
camofox-og.png
Binary file not shown.
|
Before Width: | Height: | Size: 167 KiB |
|
|
@ -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.
|
||||
837
docs/api.html
837
docs/api.html
|
|
@ -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">"<$1>"</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>
|
||||
BIN
docs/fox.png
BIN
docs/fox.png
Binary file not shown.
|
Before Width: | Height: | Size: 90 KiB |
2654
docs/openapi.json
2654
docs/openapi.json
File diff suppressed because it is too large
Load Diff
|
|
@ -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' }]] : [])
|
||||
]
|
||||
};
|
||||
|
|
@ -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' }]] : [])
|
||||
]
|
||||
};
|
||||
BIN
jo-logo.png
BIN
jo-logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 39 KiB |
2843
openapi.json
2843
openapi.json
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -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"] });
|
||||
}
|
||||
}
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
10
railway.toml
10
railway.toml
|
|
@ -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
|
||||
85
release.sh
85
release.sh
|
|
@ -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"
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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('');
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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
Loading…
Reference in New Issue