Initial release: camofox-browser v1.0.12

Headless browser server for AI agents with C++ anti-detection.
Powered by Camoufox (Firefox fork with fingerprint spoofing).

- REST API with element refs, accessibility snapshots, session isolation
- Search macros for Google, YouTube, Amazon, Reddit, and more
- OpenClaw plugin support
- Docker ready with pre-baked Camoufox binary
This commit is contained in:
Pradeep Elankumaran
2026-02-10 10:06:23 -08:00
commit baea0785c0
29 changed files with 10576 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
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/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
.github/
fly.toml
railway.toml
# Camoufox cache
.camoufox/
# Test artifacts
test-results/
playwright-report/
+144
View File
@@ -0,0 +1,144 @@
# camofox-browser Agent Guide
Headless browser automation server for AI agents. Run locally or deploy to any cloud provider.
## Quick Start for Agents
```bash
# Install and start
npm install && npm start
# Server runs on http://localhost:9377
```
## Core Workflow
1. **Create a tab** → Get `tabId`
2. **Navigate** → Go to URL or use search macro
3. **Get snapshot** → Receive page content with element refs (`e1`, `e2`, etc.)
4. **Interact** → Click/type using refs
5. **Repeat** steps 3-4 as needed
## API Reference
### Create Tab
```bash
POST /tabs
{"userId": "agent1", "sessionKey": "task1", "url": "https://example.com"}
```
Returns: `{"tabId": "abc123", "url": "...", "title": "..."}`
### Navigate
```bash
POST /tabs/:tabId/navigate
{"userId": "agent1", "url": "https://google.com"}
# Or use macro:
{"userId": "agent1", "macro": "@google_search", "query": "weather today"}
```
### Get Snapshot
```bash
GET /tabs/:tabId/snapshot?userId=agent1
```
Returns accessibility tree with refs:
```
[heading] Example Domain
[paragraph] This domain is for use in examples.
[link e1] More information...
```
### Click Element
```bash
POST /tabs/:tabId/click
{"userId": "agent1", "ref": "e1"}
# Or CSS selector:
{"userId": "agent1", "selector": "button.submit"}
```
### Type Text
```bash
POST /tabs/:tabId/type
{"userId": "agent1", "ref": "e2", "text": "hello world"}
# Add enter: {"userId": "agent1", "ref": "e2", "text": "search query", "pressEnter": true}
```
### Scroll
```bash
POST /tabs/:tabId/scroll
{"userId": "agent1", "direction": "down", "amount": 500}
```
### Navigation
```bash
POST /tabs/:tabId/back {"userId": "agent1"}
POST /tabs/:tabId/forward {"userId": "agent1"}
POST /tabs/:tabId/refresh {"userId": "agent1"}
```
### Get Links
```bash
GET /tabs/:tabId/links?userId=agent1&limit=50
```
### Close Tab
```bash
DELETE /tabs/:tabId?userId=agent1
```
## Search Macros
Use these instead of constructing URLs:
| Macro | Site |
|-------|------|
| `@google_search` | Google |
| `@youtube_search` | YouTube |
| `@amazon_search` | Amazon |
| `@reddit_search` | Reddit |
| `@wikipedia_search` | Wikipedia |
| `@twitter_search` | Twitter/X |
| `@yelp_search` | Yelp |
| `@linkedin_search` | LinkedIn |
## Element Refs
Refs like `e1`, `e2` are stable identifiers for page elements:
1. Call `/snapshot` to get current refs
2. Use ref in `/click` or `/type`
3. Refs reset on navigation - get new snapshot after
## Session Management
- `userId` isolates cookies/storage between users
- `sessionKey` groups tabs by conversation/task (legacy: `listItemId` also accepted)
- Sessions timeout after 30 minutes of inactivity
- Delete all user data: `DELETE /sessions/:userId`
## Running Engines
### Camoufox (Default)
```bash
npm start
# Or: ./run.sh
```
Firefox-based with anti-detection. Bypasses Google captcha.
## Testing
```bash
npm test # E2E tests
npm run test:live # Live Google tests
npm run test:debug # With server output
```
## Docker
```bash
docker build -t camofox-browser .
docker run -p 9377:9377 camofox-browser
```
## Key Files
- `server.js` - Camoufox engine
- `Dockerfile` - Production container
+60
View File
@@ -0,0 +1,60 @@
FROM node:20-slim
# Pinned Camoufox version for reproducible builds
# Update these when upgrading Camoufox
ARG CAMOUFOX_VERSION=135.0.1
ARG CAMOUFOX_RELEASE=beta.24
ARG CAMOUFOX_URL=https://github.com/daijro/camoufox/releases/download/v${CAMOUFOX_VERSION}-${CAMOUFOX_RELEASE}/camoufox-${CAMOUFOX_VERSION}-${CAMOUFOX_RELEASE}-lin.x86_64.zip
# 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 \
# Fonts
fonts-liberation \
fonts-noto-color-emoji \
fontconfig \
# Utils
ca-certificates \
curl \
unzip \
&& rm -rf /var/lib/apt/lists/*
# Pre-bake Camoufox browser binary into image
# This avoids downloading at runtime and pins the version
# Note: unzip returns exit code 1 for warnings (Unicode filenames), so we use || true and verify
RUN mkdir -p /root/.cache/camoufox \
&& curl -L -o /tmp/camoufox.zip "${CAMOUFOX_URL}" \
&& (unzip -q /tmp/camoufox.zip -d /root/.cache/camoufox || true) \
&& rm /tmp/camoufox.zip \
&& 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"
WORKDIR /app
COPY package.json ./
RUN npm install --production
COPY server.js ./
COPY lib/ ./lib/
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
CMD ["node", "server.js"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Jo, Inc
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+142
View File
@@ -0,0 +1,142 @@
<div align="center">
<img src="camofox.png" alt="camofox-browser" width="200" />
<h1>camofox-browser</h1>
<p><strong>Headless browser server for AI agents with C++ anti-detection</strong></p>
<p>
<a href="https://github.com/jo-inc/camofox-browser/actions"><img src="https://img.shields.io/badge/build-passing-brightgreen" alt="Build" /></a>
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-blue" alt="License" /></a>
<a href="https://camoufox.com"><img src="https://img.shields.io/badge/engine-Camoufox-red" alt="Camoufox" /></a>
<a href="https://hub.docker.com"><img src="https://img.shields.io/badge/docker-ready-blue" alt="Docker" /></a>
</p>
<p>
Powered by <a href="https://camoufox.com">Camoufox</a> — a Firefox fork with fingerprint spoofing at the C++ level.<br/>
The same engine behind <a href="https://askjo.ai">askjo.ai</a>'s web scraping.
</p>
</div>
---
## Why
AI agents need to browse the real web. Playwright gets blocked. Headless Chrome gets fingerprinted. Stealth plugins become the fingerprint.
Camoufox patches Firefox at the **C++ implementation level** — `navigator.hardwareConcurrency`, WebGL renderers, AudioContext, screen geometry, WebRTC — all spoofed before JavaScript ever sees them. No shims, no wrappers, no tells.
This project wraps that engine in a REST API built for agents: accessibility snapshots instead of bloated HTML, stable element refs for clicking, and search macros for common sites.
## Features
- **C++ Anti-Detection** — bypasses Google, Cloudflare, and most bot detection
- **Element Refs** — stable `e1`, `e2`, `e3` identifiers for reliable interaction
- **Token-Efficient** — accessibility snapshots are 90% smaller than raw HTML
- **Session Isolation** — separate cookies/storage per user
- **Search Macros** — `@google_search`, `@youtube_search`, `@amazon_search`, and 10 more
- **Docker Ready** — production Dockerfile with pre-baked Camoufox binary
## Quick Start
### OpenClaw Plugin
```bash
openclaw plugins install @askjo/camofox-browser
```
**Tools:** `camofox_create_tab` · `camofox_snapshot` · `camofox_click` · `camofox_type` · `camofox_navigate` · `camofox_scroll` · `camofox_screenshot` · `camofox_close_tab` · `camofox_list_tabs`
### Standalone
```bash
git clone https://github.com/jo-inc/camofox-browser
cd camofox-browser
npm install
npm start # downloads Camoufox on first run (~300MB)
```
### Docker
```bash
docker build -t camofox-browser .
docker run -p 3000:3000 camofox-browser
```
## Usage
```bash
# Create a tab
curl -X POST http://localhost:3000/tabs \
-d '{"userId": "agent1", "sessionKey": "task1", "url": "https://example.com"}'
# Get page snapshot with element refs
curl "http://localhost:3000/tabs/TAB_ID/snapshot?userId=agent1"
# → { "snapshot": "[button e1] Submit [link e2] Learn more", ... }
# Click by ref
curl -X POST http://localhost:3000/tabs/TAB_ID/click \
-d '{"userId": "agent1", "ref": "e1"}'
# Search with macros
curl -X POST http://localhost:3000/tabs/TAB_ID/navigate \
-d '{"userId": "agent1", "macro": "@google_search", "query": "best coffee beans"}'
```
## API
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/tabs` | Create tab |
| `GET` | `/tabs/:id/snapshot` | Accessibility snapshot with refs |
| `POST` | `/tabs/:id/click` | Click element by ref |
| `POST` | `/tabs/:id/type` | Type into element |
| `POST` | `/tabs/:id/navigate` | Navigate to URL or macro |
| `POST` | `/tabs/:id/scroll` | Scroll page |
| `GET` | `/tabs/:id/screenshot` | Screenshot |
| `GET` | `/tabs/:id/links` | All links on page |
| `POST` | `/tabs/:id/back` | Go back |
| `POST` | `/tabs/:id/forward` | Go forward |
| `POST` | `/tabs/:id/refresh` | Refresh |
| `DELETE` | `/tabs/:id` | Close tab |
| `GET` | `/tabs?userId=X` | List tabs |
| `DELETE` | `/sessions/:userId` | Close all user tabs |
| `GET` | `/health` | Health check |
## Search Macros
`@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`
## Architecture
```
Browser Instance
└── User Session (BrowserContext) — isolated cookies/storage
├── Tab Group (sessionKey: "conv1")
│ ├── Tab (google.com)
│ └── Tab (github.com)
└── Tab Group (sessionKey: "conv2")
└── Tab (amazon.com)
```
Sessions auto-expire after 30 minutes of inactivity.
## Testing
```bash
npm test # e2e tests
npm run test:live # live Google tests
npm run test:debug # with server output
```
## npm
```bash
npm install @askjo/camofox-browser
```
## Credits
- [Camoufox](https://camoufox.com) — Firefox-based browser with C++ anti-detection
- [OpenClaw](https://openclaw.ai) — Open-source AI agent framework
- [Amp](https://ampcode.com) — AI coding agent
## License
MIT
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

+41
View File
@@ -0,0 +1,41 @@
module.exports = {
testEnvironment: 'node',
testTimeout: 60000, // 60 seconds per test
// Run tests sequentially to avoid resource conflicts
maxWorkers: 1,
// Test file patterns
testMatch: [
'**/tests/**/*.test.js'
],
// Ignore patterns
testPathIgnorePatterns: [
'/node_modules/'
],
// Setup and teardown
globalSetup: undefined,
globalTeardown: undefined,
// Verbose output
verbose: true,
// Fail fast on first error (useful for CI)
bail: process.env.CI ? 1 : 0,
// Coverage settings (optional)
collectCoverage: false,
coverageDirectory: 'coverage',
coveragePathIgnorePatterns: [
'/node_modules/',
'/tests/'
],
// Reporter settings
reporters: [
'default',
...(process.env.CI ? [['jest-junit', { outputDirectory: 'test-results' }]] : [])
]
};
+30
View File
@@ -0,0 +1,30 @@
const MACROS = {
'@google_search': (query) => `https://www.google.com/search?q=${encodeURIComponent(query || '')}`,
'@youtube_search': (query) => `https://www.youtube.com/results?search_query=${encodeURIComponent(query || '')}`,
'@amazon_search': (query) => `https://www.amazon.com/s?k=${encodeURIComponent(query || '')}`,
'@reddit_search': (query) => `https://www.reddit.com/search/?q=${encodeURIComponent(query || '')}`,
'@wikipedia_search': (query) => `https://en.wikipedia.org/wiki/Special:Search?search=${encodeURIComponent(query || '')}`,
'@twitter_search': (query) => `https://twitter.com/search?q=${encodeURIComponent(query || '')}`,
'@yelp_search': (query) => `https://www.yelp.com/search?find_desc=${encodeURIComponent(query || '')}`,
'@spotify_search': (query) => `https://open.spotify.com/search/${encodeURIComponent(query || '')}`,
'@netflix_search': (query) => `https://www.netflix.com/search?q=${encodeURIComponent(query || '')}`,
'@linkedin_search': (query) => `https://www.linkedin.com/search/results/all/?keywords=${encodeURIComponent(query || '')}`,
'@instagram_search': (query) => `https://www.instagram.com/explore/tags/${encodeURIComponent(query || '')}`,
'@tiktok_search': (query) => `https://www.tiktok.com/search?q=${encodeURIComponent(query || '')}`,
'@twitch_search': (query) => `https://www.twitch.tv/search?term=${encodeURIComponent(query || '')}`
};
function expandMacro(macro, query) {
const macroFn = MACROS[macro];
return macroFn ? macroFn(query) : null;
}
function getSupportedMacros() {
return Object.keys(MACROS);
}
module.exports = {
expandMacro,
getSupportedMacros,
MACROS
};
+39
View File
@@ -0,0 +1,39 @@
{
"id": "camofox-browser",
"name": "Camofox Browser",
"description": "Anti-detection browser automation for AI agents using Camoufox (Firefox-based)",
"version": "1.0.11",
"configSchema": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "Camoufox browser server URL"
},
"port": {
"type": "number",
"description": "Server port (used if url not set)",
"default": 9377
},
"autoStart": {
"type": "boolean",
"description": "Auto-start the camofox-browser server with the Gateway",
"default": true
}
},
"additionalProperties": false
},
"uiHints": {
"url": {
"label": "Server URL",
"placeholder": "http://localhost:9377"
},
"port": {
"label": "Server Port",
"placeholder": "9377"
},
"autoStart": {
"label": "Auto-start server with Gateway"
}
}
}
+6146
View File
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
{
"name": "@askjo/camofox-browser",
"version": "1.0.12",
"description": "Headless browser automation server and OpenClaw plugin for AI agents - anti-detection, element refs, and session isolation",
"main": "server.js",
"license": "MIT",
"author": "Jo Inc <oss@askjo.ai>",
"homepage": "https://github.com/jo-inc/camofox-browser#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/jo-inc/camofox-browser.git"
},
"bugs": {
"url": "https://github.com/jo-inc/camofox-browser/issues"
},
"keywords": [
"browser",
"automation",
"headless",
"scraping",
"camofox",
"camoufox",
"anti-detection",
"ai-agent",
"openclaw",
"clawdbot",
"moltbot",
"playwright",
"firefox"
],
"engines": {
"node": ">=18"
},
"files": [
"server.js",
"lib/",
"plugin.ts",
"openclaw.plugin.json",
"run.sh",
"Dockerfile",
"README.md",
"LICENSE"
],
"openclaw": {
"extensions": ["plugin.ts"]
},
"scripts": {
"start": "node server.js",
"test": "jest --runInBand --forceExit",
"test:e2e": "jest --runInBand --forceExit tests/e2e",
"test:live": "RUN_LIVE_TESTS=1 jest --runInBand --forceExit tests/live",
"test:debug": "DEBUG_SERVER=1 jest --runInBand --forceExit",
"postinstall": "npx camoufox-js fetch || true"
},
"dependencies": {
"camoufox-js": "^0.8.5",
"dotenv": "^17.2.3",
"express": "^4.18.2",
"playwright": "^1.50.0",
"playwright-core": "^1.58.0",
"playwright-extra": "^4.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2"
},
"devDependencies": {
"jest": "^29.7.0"
}
}
+647
View File
@@ -0,0 +1,647 @@
/**
* 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 { spawn, ChildProcess } from "child_process";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
// Get plugin directory - works in both ESM and CJS contexts
const getPluginDir = (): string => {
try {
// ESM context
return dirname(fileURLToPath(import.meta.url));
} catch {
// CJS context
return __dirname;
}
};
interface PluginConfig {
url?: string;
autoStart?: boolean;
port?: number;
}
interface ToolResult {
content: Array<{ type: string; text: string }>;
}
interface HealthCheckResult {
status: "ok" | "warn" | "error";
message?: string;
details?: Record<string, unknown>;
}
interface CliContext {
program: {
command: (name: string) => {
description: (desc: string) => CliContext["program"];
option: (flags: string, desc: string, defaultValue?: string) => CliContext["program"];
argument: (name: string, desc: string) => CliContext["program"];
action: (handler: (...args: unknown[]) => void | Promise<void>) => CliContext["program"];
command: (name: string) => CliContext["program"];
};
};
config: PluginConfig;
logger: {
info: (msg: string) => void;
error: (msg: string) => void;
};
}
interface ToolContext {
sessionKey?: string;
agentId?: string;
workspaceDir?: string;
sandboxed?: boolean;
}
type ToolDefinition = {
name: string;
description: string;
parameters: object;
execute: (id: string, params: Record<string, unknown>) => Promise<ToolResult>;
};
type ToolFactory = (ctx: ToolContext) => ToolDefinition | ToolDefinition[] | null | undefined;
interface PluginApi {
registerTool: (
tool: ToolDefinition | ToolFactory,
options?: { optional?: boolean }
) => void;
registerCommand: (cmd: {
name: string;
description: string;
handler: (args: string[]) => Promise<void>;
}) => void;
registerCli?: (
registrar: (ctx: CliContext) => void | Promise<void>,
opts?: { commands?: string[] }
) => void;
registerRpc?: (
name: string,
handler: (params: Record<string, unknown>) => Promise<unknown>
) => void;
registerHealthCheck?: (
name: string,
check: () => Promise<HealthCheckResult>
) => void;
config: PluginConfig;
log: {
info: (msg: string) => void;
error: (msg: string) => void;
};
}
let serverProcess: ChildProcess | null = null;
async function startServer(
pluginDir: string,
port: number,
log: PluginApi["log"]
): Promise<ChildProcess> {
const serverPath = join(pluginDir, "server.js");
const proc = spawn("node", [serverPath], {
cwd: pluginDir,
env: { ...process.env, PORT: String(port) },
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
proc.stdout?.on("data", (data: Buffer) => {
const msg = data.toString().trim();
if (msg) log?.info?.(`[server] ${msg}`);
});
proc.stderr?.on("data", (data: Buffer) => {
const msg = data.toString().trim();
if (msg) log?.error?.(`[server] ${msg}`);
});
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
}
}
throw new Error("Server failed to start within 15 seconds");
}
async function checkServerRunning(baseUrl: string): Promise<boolean> {
try {
const res = await fetch(`${baseUrl}/health`);
return res.ok;
} catch {
return false;
}
}
async function fetchApi(
baseUrl: string,
path: string,
options: RequestInit = {}
): Promise<unknown> {
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: unknown): ToolResult {
return {
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
};
}
export default function register(api: PluginApi) {
const port = api.config.port || 9377;
const baseUrl = api.config.url || `http://localhost:${port}`;
const autoStart = api.config.autoStart !== false; // default true
const pluginDir = getPluginDir();
// 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);
} catch (err) {
api.log?.error?.(`Failed to auto-start server: ${(err as Error).message}`);
}
}
})();
}
api.registerTool((ctx: ToolContext) => ({
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 || "openclaw";
const result = await fetchApi(baseUrl, "/tabs", {
method: "POST",
body: JSON.stringify({ ...params, userId, sessionKey }),
});
return toToolResult(result);
},
}));
api.registerTool((ctx: ToolContext) => ({
name: "camofox_snapshot",
description:
"Get accessibility snapshot of a Camoufox page with element refs (e1, e2, etc.) for interaction. Use with camofox_create_tab.",
parameters: {
type: "object",
properties: {
tabId: { type: "string", description: "Tab identifier" },
},
required: ["tabId"],
},
async execute(_id, params) {
const { tabId } = params as { tabId: string };
const userId = ctx.agentId || "openclaw";
const result = await fetchApi(baseUrl, `/tabs/${tabId}/snapshot?userId=${userId}`);
return toToolResult(result);
},
}));
api.registerTool((ctx: ToolContext) => ({
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 as { tabId: string } & Record<string, unknown>;
const userId = ctx.agentId || "openclaw";
const result = await fetchApi(baseUrl, `/tabs/${tabId}/click`, {
method: "POST",
body: JSON.stringify({ ...rest, userId }),
});
return toToolResult(result);
},
}));
api.registerTool((ctx: ToolContext) => ({
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 as { tabId: string } & Record<string, unknown>;
const userId = ctx.agentId || "openclaw";
const result = await fetchApi(baseUrl, `/tabs/${tabId}/type`, {
method: "POST",
body: JSON.stringify({ ...rest, userId }),
});
return toToolResult(result);
},
}));
api.registerTool((ctx: ToolContext) => ({
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 as { tabId: string } & Record<string, unknown>;
const userId = ctx.agentId || "openclaw";
const result = await fetchApi(baseUrl, `/tabs/${tabId}/navigate`, {
method: "POST",
body: JSON.stringify({ ...rest, userId }),
});
return toToolResult(result);
},
}));
api.registerTool((ctx: ToolContext) => ({
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 as { tabId: string } & Record<string, unknown>;
const userId = ctx.agentId || "openclaw";
const result = await fetchApi(baseUrl, `/tabs/${tabId}/scroll`, {
method: "POST",
body: JSON.stringify({ ...rest, userId }),
});
return toToolResult(result);
},
}));
api.registerTool((ctx: ToolContext) => ({
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 as { tabId: string };
const userId = ctx.agentId || "openclaw";
const result = await fetchApi(baseUrl, `/tabs/${tabId}/screenshot?userId=${userId}`);
return toToolResult(result);
},
}));
api.registerTool((ctx: ToolContext) => ({
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 as { tabId: string };
const userId = ctx.agentId || "openclaw";
const result = await fetchApi(baseUrl, `/tabs/${tabId}?userId=${userId}`, {
method: "DELETE",
});
return toToolResult(result);
},
}));
api.registerTool((ctx: ToolContext) => ({
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 || "openclaw";
const result = await fetchApi(baseUrl, `/tabs?userId=${userId}`);
return toToolResult(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);
} catch (err) {
api.log?.error?.(`Failed to start server: ${(err as Error).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")) as {
status: string;
engine?: string;
activeTabs?: number;
};
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 as Error).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")) as {
status: string;
engine?: string;
activeTabs?: number;
};
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);
console.log(`Camoufox server started at ${baseUrl}`);
} catch (err) {
console.error(`Failed to start server: ${(err as Error).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: { user?: string }) => {
try {
const endpoint = opts.user ? `/tabs?userId=${opts.user}` : "/tabs";
const tabs = (await fetchApi(baseUrl, endpoint)) as Array<{
tabId: string;
userId: string;
url: string;
title: string;
}>;
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 as Error).message}`);
}
});
},
{ commands: ["camofox"] }
);
}
}
Executable
+37
View File
@@ -0,0 +1,37 @@
#!/bin/bash
# Local development script for camofox-browser
# Usage: ./run.sh [-p port]
# Example: ./run.sh -p 3001
PORT=3000
while getopts "p:" opt; do
case $opt in
p) PORT="$OPTARG" ;;
*) echo "Usage: $0 [-p port]"; exit 1 ;;
esac
done
export PORT
# Install deps if needed
if [ ! -d "node_modules" ]; then
echo "Installing dependencies..."
npm install
fi
# Check if camoufox browser is installed
if ! npx camoufox-js --version &> /dev/null 2>&1; then
echo "Fetching Camoufox browser..."
npx camoufox-js fetch
fi
# Install nodemon globally if not available
if ! command -v nodemon &> /dev/null; then
echo "Installing nodemon..."
npm install -g nodemon
fi
echo "Starting camofox-browser on http://localhost:$PORT (with auto-reload)"
echo "Logs: /tmp/camofox-browser.log"
nodemon --watch server.js --exec "node server.js" 2>&1 | while IFS= read -r line; do
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $line"
done | tee -a /tmp/camofox-browser.log
+1288
View File
File diff suppressed because it is too large Load Diff
+103
View File
@@ -0,0 +1,103 @@
const { startServer, stopServer, getServerUrl } = require('../helpers/startServer');
const { startTestSite, stopTestSite, getTestSiteUrl } = require('../helpers/testSite');
const { createClient } = require('../helpers/client');
describe('Concurrency', () => {
let serverUrl;
let testSiteUrl;
beforeAll(async () => {
const port = await startServer();
serverUrl = getServerUrl();
const testPort = await startTestSite();
testSiteUrl = getTestSiteUrl();
}, 120000);
afterAll(async () => {
await stopTestSite();
await stopServer();
}, 30000);
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();
}
});
});
+129
View File
@@ -0,0 +1,129 @@
const { startServer, stopServer, getServerUrl } = require('../helpers/startServer');
const { startTestSite, stopTestSite, getTestSiteUrl } = require('../helpers/testSite');
const { createClient } = require('../helpers/client');
describe('Form Submission', () => {
let serverUrl;
let testSiteUrl;
beforeAll(async () => {
const port = await startServer();
serverUrl = getServerUrl();
const testPort = await startTestSite();
testSiteUrl = getTestSiteUrl();
}, 120000);
afterAll(async () => {
await stopTestSite();
await stopServer();
}, 30000);
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();
}
});
});
+92
View File
@@ -0,0 +1,92 @@
const { startServer, stopServer, getServerUrl } = require('../helpers/startServer');
const { startTestSite, stopTestSite, getTestSiteUrl } = require('../helpers/testSite');
const { createClient } = require('../helpers/client');
describe('Macro Navigation', () => {
let serverUrl;
let testSiteUrl;
beforeAll(async () => {
const port = await startServer();
serverUrl = getServerUrl();
const testPort = await startTestSite();
testSiteUrl = getTestSiteUrl();
}, 120000);
afterAll(async () => {
await stopTestSite();
await stopServer();
}, 30000);
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();
}
});
});
+128
View File
@@ -0,0 +1,128 @@
const { startServer, stopServer, getServerUrl } = require('../helpers/startServer');
const { startTestSite, stopTestSite, getTestSiteUrl } = require('../helpers/testSite');
const { createClient } = require('../helpers/client');
describe('Navigation', () => {
let serverUrl;
let testSiteUrl;
beforeAll(async () => {
const port = await startServer();
serverUrl = getServerUrl();
const testPort = await startTestSite();
testSiteUrl = getTestSiteUrl();
}, 120000);
afterAll(async () => {
await stopTestSite();
await stopServer();
}, 30000);
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();
}
});
});
+81
View File
@@ -0,0 +1,81 @@
const { startServer, stopServer, getServerUrl } = require('../helpers/startServer');
const { startTestSite, stopTestSite, getTestSiteUrl } = require('../helpers/testSite');
const { createClient } = require('../helpers/client');
describe('Scroll', () => {
let serverUrl;
let testSiteUrl;
beforeAll(async () => {
const port = await startServer();
serverUrl = getServerUrl();
const testPort = await startTestSite();
testSiteUrl = getTestSiteUrl();
}, 120000);
afterAll(async () => {
await stopTestSite();
await stopServer();
}, 30000);
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();
}
});
});
+141
View File
@@ -0,0 +1,141 @@
const { startServer, stopServer, getServerUrl } = require('../helpers/startServer');
const { startTestSite, stopTestSite, getTestSiteUrl } = require('../helpers/testSite');
const { createClient } = require('../helpers/client');
describe('Snapshot and Links', () => {
let serverUrl;
let testSiteUrl;
beforeAll(async () => {
const port = await startServer();
serverUrl = getServerUrl();
const testPort = await startTestSite();
testSiteUrl = getTestSiteUrl();
}, 120000);
afterAll(async () => {
await stopTestSite();
await stopServer();
}, 30000);
test('get snapshot returns page content', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab(`${testSiteUrl}/pageA`);
const snapshot = await client.getSnapshot(tabId);
expect(snapshot.url).toContain('/pageA');
expect(snapshot.snapshot).toBeDefined();
expect(typeof snapshot.snapshot).toBe('string');
expect(snapshot.snapshot).toContain('Page A');
expect(snapshot.refsCount).toBeDefined();
} finally {
await client.cleanup();
}
});
test('snapshot contains element refs', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab(`${testSiteUrl}/links`);
const snapshot = await client.getSnapshot(tabId);
// Snapshot should contain ref markers like [e1], [e2], etc.
expect(snapshot.snapshot).toMatch(/\[e\d+\]/);
} finally {
await client.cleanup();
}
});
test('get links returns all links on page', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab(`${testSiteUrl}/links`);
const result = await client.getLinks(tabId);
expect(result.links).toBeDefined();
expect(Array.isArray(result.links)).toBe(true);
expect(result.links.length).toBe(5);
// Check link structure
const link = result.links[0];
expect(link.url).toContain('example.com');
expect(link.text).toBeDefined();
// Check pagination info
expect(result.pagination).toBeDefined();
expect(result.pagination.total).toBe(5);
} finally {
await client.cleanup();
}
});
test('get links supports pagination', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab(`${testSiteUrl}/links`);
// Get first 2 links
const page1 = await client.getLinks(tabId, { limit: 2, offset: 0 });
expect(page1.links.length).toBe(2);
expect(page1.pagination.hasMore).toBe(true);
// Get next 2 links
const page2 = await client.getLinks(tabId, { limit: 2, offset: 2 });
expect(page2.links.length).toBe(2);
expect(page2.pagination.hasMore).toBe(true);
// Get last link
const page3 = await client.getLinks(tabId, { limit: 2, offset: 4 });
expect(page3.links.length).toBe(1);
expect(page3.pagination.hasMore).toBe(false);
// Links should be different
expect(page1.links[0].url).not.toBe(page2.links[0].url);
} finally {
await client.cleanup();
}
});
test('screenshot returns PNG buffer', 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);
// Check PNG magic bytes
const bytes = new Uint8Array(buffer);
expect(bytes[0]).toBe(0x89);
expect(bytes[1]).toBe(0x50); // P
expect(bytes[2]).toBe(0x4E); // N
expect(bytes[3]).toBe(0x47); // G
} finally {
await client.cleanup();
}
});
test('snapshot for non-existent tab returns 404', async () => {
const client = createClient(serverUrl);
try {
await expect(client.getSnapshot('non-existent-tab-id')).rejects.toMatchObject({
status: 404
});
} finally {
await client.cleanup();
}
});
});
+149
View File
@@ -0,0 +1,149 @@
const { startServer, stopServer, getServerUrl } = require('../helpers/startServer');
const { startTestSite, stopTestSite, getTestSiteUrl } = require('../helpers/testSite');
const { createClient } = require('../helpers/client');
describe('Tab Lifecycle', () => {
let serverUrl;
let testSiteUrl;
beforeAll(async () => {
const port = await startServer();
serverUrl = getServerUrl();
const testPort = await startTestSite();
testSiteUrl = getTestSiteUrl();
}, 120000);
afterAll(async () => {
await stopTestSite();
await stopServer();
}, 30000);
test('health check returns camoufox engine', async () => {
const client = createClient(serverUrl);
const health = await client.health();
expect(health.ok).toBe(true);
expect(health.engine).toBe('camoufox');
});
test('create tab without URL', async () => {
const client = createClient(serverUrl);
try {
const result = await client.createTab();
expect(result.tabId).toBeDefined();
expect(typeof result.tabId).toBe('string');
expect(result.url).toBe('about:blank');
} finally {
await client.cleanup();
}
});
test('create tab with URL', async () => {
const client = createClient(serverUrl);
try {
const result = await client.createTab(`${testSiteUrl}/pageA`);
expect(result.tabId).toBeDefined();
expect(result.url).toContain('/pageA');
const snapshot = await client.getSnapshot(result.tabId);
expect(snapshot.snapshot).toContain('Page A');
} finally {
await client.cleanup();
}
});
test('create multiple tabs in same group', async () => {
const client = createClient(serverUrl);
try {
const tab1 = await client.createTab(`${testSiteUrl}/pageA`);
const tab2 = await client.createTab(`${testSiteUrl}/pageB`);
expect(tab1.tabId).not.toBe(tab2.tabId);
const snapshot1 = await client.getSnapshot(tab1.tabId);
const snapshot2 = await client.getSnapshot(tab2.tabId);
expect(snapshot1.snapshot).toContain('Page A');
expect(snapshot2.snapshot).toContain('Page B');
} finally {
await client.cleanup();
}
});
test('close individual tab', async () => {
const client = createClient(serverUrl);
try {
const tab1 = await client.createTab(`${testSiteUrl}/pageA`);
const tab2 = await client.createTab(`${testSiteUrl}/pageB`);
await client.closeTab(tab1.tabId);
// Tab 1 should be gone
await expect(client.getSnapshot(tab1.tabId)).rejects.toThrow();
// Tab 2 should still work
const snapshot2 = await client.getSnapshot(tab2.tabId);
expect(snapshot2.snapshot).toContain('Page B');
} finally {
await client.cleanup();
}
});
test('close tab group', async () => {
const client = createClient(serverUrl);
try {
const tab1 = await client.createTab(`${testSiteUrl}/pageA`);
const tab2 = await client.createTab(`${testSiteUrl}/pageB`);
await client.closeTabGroup();
// Both tabs should be gone
await expect(client.getSnapshot(tab1.tabId)).rejects.toThrow();
await expect(client.getSnapshot(tab2.tabId)).rejects.toThrow();
} finally {
await client.cleanup();
}
});
test('close session clears all tabs', async () => {
const client = createClient(serverUrl);
const tab = await client.createTab(`${testSiteUrl}/pageA`);
await client.closeSession();
// Tab should be gone after session close
await expect(client.getSnapshot(tab.tabId)).rejects.toThrow();
});
test('tab stats are tracked correctly', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab(`${testSiteUrl}/pageA`);
// Make some operations
await client.getSnapshot(tabId);
await client.navigate(tabId, `${testSiteUrl}/pageB`);
await client.getSnapshot(tabId);
const stats = await client.getStats(tabId);
expect(stats.tabId).toBe(tabId);
expect(stats.sessionKey).toBe(client.sessionKey);
expect(stats.url).toContain('/pageB');
expect(stats.toolCalls).toBeGreaterThan(0);
expect(stats.visitedUrls).toContain(`${testSiteUrl}/pageA`);
} finally {
await client.cleanup();
}
});
});
+147
View File
@@ -0,0 +1,147 @@
const { startServer, stopServer, getServerUrl } = require('../helpers/startServer');
const { startTestSite, stopTestSite, getTestSiteUrl } = require('../helpers/testSite');
const { createClient } = require('../helpers/client');
describe('Typing and Enter', () => {
let serverUrl;
let testSiteUrl;
beforeAll(async () => {
const port = await startServer();
serverUrl = getServerUrl();
const testPort = await startTestSite();
testSiteUrl = getTestSiteUrl();
}, 120000);
afterAll(async () => {
await stopTestSite();
await stopServer();
}, 30000);
test('type text into input field', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab(`${testSiteUrl}/typing`);
// Type using selector
const result = await client.type(tabId, {
selector: '#input',
text: 'Hello World'
});
expect(result.ok).toBe(true);
// Verify the text appears in preview
const snapshot = await client.waitForSnapshotContains(tabId, 'Preview: Hello World');
expect(snapshot.snapshot).toContain('Hello World');
} finally {
await client.cleanup();
}
});
test('type text using ref', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab(`${testSiteUrl}/typing`);
// Get snapshot to find the input ref
const snapshot = await client.getSnapshot(tabId);
// Find a ref for the textbox (look for pattern like [e1] textbox)
const match = snapshot.snapshot.match(/\[(e\d+)\].*textbox/i);
if (!match) {
// Try by selector if ref not found
await client.type(tabId, { selector: '#input', text: 'Test via selector' });
} else {
const ref = match[1];
await client.type(tabId, { ref, text: 'Test via ref' });
}
const updatedSnapshot = await client.getSnapshot(tabId);
expect(updatedSnapshot.snapshot).toMatch(/Preview: Test via (ref|selector)/);
} finally {
await client.cleanup();
}
});
test('type and press Enter navigates page', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab(`${testSiteUrl}/enter`);
// Type and press Enter
await client.type(tabId, {
selector: '#searchInput',
text: 'test query',
pressEnter: true
});
// Wait for navigation to /entered
const snapshot = await client.waitForUrl(tabId, '/entered');
expect(snapshot.url).toContain('/entered');
expect(snapshot.url).toContain('value=test%20query');
expect(snapshot.snapshot).toContain('Entered: test query');
} finally {
await client.cleanup();
}
});
test('type without Enter does not navigate', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab(`${testSiteUrl}/enter`);
// Type without pressing Enter
await client.type(tabId, {
selector: '#searchInput',
text: 'should not navigate',
pressEnter: false
});
// Wait a bit to ensure no navigation happened
await new Promise(r => setTimeout(r, 500));
const snapshot = await client.getSnapshot(tabId);
expect(snapshot.url).toContain('/enter');
expect(snapshot.url).not.toContain('/entered');
} finally {
await client.cleanup();
}
});
test('type replaces existing content', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab(`${testSiteUrl}/typing`);
// Type first text
await client.type(tabId, {
selector: '#input',
text: 'First text'
});
let snapshot = await client.waitForSnapshotContains(tabId, 'Preview: First text');
expect(snapshot.snapshot).toContain('First text');
// Type second text (should replace due to clear behavior)
await client.type(tabId, {
selector: '#input',
text: 'Second text',
clear: true
});
snapshot = await client.waitForSnapshotContains(tabId, 'Preview: Second text');
expect(snapshot.snapshot).toContain('Second text');
expect(snapshot.snapshot).not.toContain('First text');
} finally {
await client.cleanup();
}
});
});
+223
View File
@@ -0,0 +1,223 @@
const crypto = require('crypto');
class BrowserClient {
constructor(baseUrl) {
this.baseUrl = baseUrl;
this.userId = crypto.randomUUID();
this.sessionKey = crypto.randomUUID();
this.listItemId = this.sessionKey; // Legacy alias
this.tabs = [];
this.timeout = 30000; // 30 second default timeout
}
async request(method, path, body = null, options = {}) {
const url = `${this.baseUrl}${path}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), options.timeout || this.timeout);
try {
const fetchOptions = {
method,
headers: { 'Content-Type': 'application/json' },
signal: controller.signal
};
if (body) {
fetchOptions.body = JSON.stringify(body);
}
const response = await fetch(url, fetchOptions);
clearTimeout(timeoutId);
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
const data = await response.json();
if (!response.ok) {
const error = new Error(data.error || `HTTP ${response.status}`);
error.status = response.status;
error.data = data;
throw error;
}
return data;
} else if (contentType?.includes('image/')) {
return await response.arrayBuffer();
} else {
return await response.text();
}
} catch (err) {
clearTimeout(timeoutId);
if (err.name === 'AbortError') {
throw new Error(`Request timeout after ${options.timeout || this.timeout}ms: ${method} ${path}`);
}
throw err;
}
}
// Health check
async health() {
return this.request('GET', '/health');
}
// Tab management
async createTab(url = null) {
const body = { userId: this.userId, sessionKey: this.sessionKey };
if (url) body.url = url;
const result = await this.request('POST', '/tabs', body);
if (result.tabId) {
this.tabs.push(result.tabId);
}
return result;
}
async navigate(tabId, urlOrMacro) {
// Support both regular URLs and @macro syntax
if (urlOrMacro.startsWith('@')) {
const [macro, ...queryParts] = urlOrMacro.split(' ');
const query = queryParts.join(' ');
return this.request('POST', `/tabs/${tabId}/navigate`, { userId: this.userId, macro, query });
}
return this.request('POST', `/tabs/${tabId}/navigate`, { userId: this.userId, url: urlOrMacro });
}
async getSnapshot(tabId) {
return this.request('GET', `/tabs/${tabId}/snapshot?userId=${this.userId}`);
}
async click(tabId, options) {
return this.request('POST', `/tabs/${tabId}/click`, { userId: this.userId, ...options });
}
async type(tabId, options) {
const { pressEnter, clear, ...typeOptions } = options;
// Handle clear by selecting all first
if (clear && (options.selector || options.ref)) {
// Click to focus, then select all and type to replace
await this.click(tabId, { selector: options.selector, ref: options.ref });
await this.press(tabId, 'Control+a');
}
const result = await this.request('POST', `/tabs/${tabId}/type`, { userId: this.userId, ...typeOptions });
// Handle Enter key press after typing
if (pressEnter) {
await this.press(tabId, 'Enter');
}
return result;
}
async press(tabId, key) {
return this.request('POST', `/tabs/${tabId}/press`, { userId: this.userId, key });
}
async scroll(tabId, options) {
return this.request('POST', `/tabs/${tabId}/scroll`, { userId: this.userId, ...options });
}
async back(tabId) {
return this.request('POST', `/tabs/${tabId}/back`, { userId: this.userId });
}
async forward(tabId) {
return this.request('POST', `/tabs/${tabId}/forward`, { userId: this.userId });
}
async refresh(tabId) {
return this.request('POST', `/tabs/${tabId}/refresh`, { userId: this.userId });
}
async getLinks(tabId, options = {}) {
const params = new URLSearchParams({ userId: this.userId });
if (options.limit) params.append('limit', options.limit);
if (options.offset) params.append('offset', options.offset);
return this.request('GET', `/tabs/${tabId}/links?${params}`);
}
async getStats(tabId) {
return this.request('GET', `/tabs/${tabId}/stats?userId=${this.userId}`);
}
async screenshot(tabId, fullPage = false) {
return this.request('GET', `/tabs/${tabId}/screenshot?userId=${this.userId}&fullPage=${fullPage}`);
}
async closeTab(tabId) {
const result = await this.request('DELETE', `/tabs/${tabId}`, { userId: this.userId });
this.tabs = this.tabs.filter(t => t !== tabId);
return result;
}
async closeTabGroup(sessionKey = null) {
return this.request('DELETE', `/tabs/group/${sessionKey || this.sessionKey}`, { userId: this.userId });
}
async closeSession() {
return this.request('DELETE', `/sessions/${this.userId}`);
}
// Cleanup all tabs created by this client
async cleanup() {
for (const tabId of [...this.tabs]) {
try {
await this.closeTab(tabId);
} catch (e) {
// Tab may already be closed
}
}
try {
await this.closeSession();
} catch (e) {
// Session may already be closed
}
}
// Wait for snapshot to contain specific text (polling)
async waitForSnapshotContains(tabId, text, options = {}) {
const maxWait = options.maxWait || 10000;
const interval = options.interval || 500;
const startTime = Date.now();
while (Date.now() - startTime < maxWait) {
const snapshot = await this.getSnapshot(tabId);
if (snapshot.snapshot && snapshot.snapshot.includes(text)) {
return snapshot;
}
await new Promise(r => setTimeout(r, interval));
}
throw new Error(`Timeout waiting for snapshot to contain "${text}"`);
}
// Wait for URL to match pattern
async waitForUrl(tabId, pattern, options = {}) {
const maxWait = options.maxWait || 10000;
const interval = options.interval || 500;
const startTime = Date.now();
while (Date.now() - startTime < maxWait) {
const snapshot = await this.getSnapshot(tabId);
const url = snapshot.url;
if (typeof pattern === 'string' && url.includes(pattern)) {
return snapshot;
} else if (pattern instanceof RegExp && pattern.test(url)) {
return snapshot;
}
await new Promise(r => setTimeout(r, interval));
}
throw new Error(`Timeout waiting for URL to match ${pattern}`);
}
}
function createClient(baseUrl) {
return new BrowserClient(baseUrl);
}
module.exports = {
BrowserClient,
createClient
};
+91
View File
@@ -0,0 +1,91 @@
const { spawn } = require('child_process');
const path = require('path');
let serverProcess = null;
let serverPort = null;
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) {
// Server not ready yet
}
await new Promise(r => setTimeout(r, interval));
}
throw new Error(`Server failed to start on port ${port} after ${maxRetries} attempts`);
}
async function startServer(port = 0) {
const usePort = port || Math.floor(3100 + Math.random() * 900);
const serverPath = path.join(__dirname, '../../server.js');
serverProcess = spawn('node', [serverPath], {
env: { ...process.env, PORT: usePort.toString(), DEBUG_RESPONSES: 'false' },
stdio: ['ignore', 'pipe', 'pipe'],
detached: false
});
serverProcess.stdout.on('data', (data) => {
if (process.env.DEBUG_SERVER) {
console.log(`[server] ${data.toString().trim()}`);
}
});
serverProcess.stderr.on('data', (data) => {
if (process.env.DEBUG_SERVER) {
console.error(`[server:err] ${data.toString().trim()}`);
}
});
serverProcess.on('error', (err) => {
console.error('Failed to start server:', err);
});
serverPort = usePort;
await waitForServer(usePort);
console.log(`camofox-browser server started on port ${usePort}`);
return usePort;
}
async function stopServer() {
if (serverProcess) {
return new Promise((resolve) => {
serverProcess.on('close', () => {
serverProcess = null;
serverPort = null;
resolve();
});
serverProcess.kill('SIGTERM');
setTimeout(() => {
if (serverProcess) {
serverProcess.kill('SIGKILL');
}
}, 5000);
});
}
}
function getServerUrl() {
if (!serverPort) throw new Error('Server not started');
return `http://localhost:${serverPort}`;
}
function getServerPort() {
return serverPort;
}
module.exports = {
startServer,
stopServer,
getServerUrl,
getServerPort
};
+238
View File
@@ -0,0 +1,238 @@
const express = require('express');
let server = null;
let port = null;
function createTestApp() {
const app = express();
app.use(express.urlencoded({ extended: true }));
// Simple pages for navigation tests
app.get('/pageA', (req, res) => {
res.send(`
<!DOCTYPE html>
<html><head><title>Page A</title></head>
<body>
<h1>Welcome to Page A</h1>
<p>This is the first test page.</p>
<a href="/pageB">Go to Page B</a>
</body></html>
`);
});
app.get('/pageB', (req, res) => {
res.send(`
<!DOCTYPE html>
<html><head><title>Page B</title></head>
<body>
<h1>Welcome to Page B</h1>
<p>This is the second test page.</p>
<a href="/pageA">Go to Page A</a>
</body></html>
`);
});
// Page with multiple links for links extraction test
app.get('/links', (req, res) => {
res.send(`
<!DOCTYPE html>
<html><head><title>Links Page</title></head>
<body>
<h1>Links Collection</h1>
<ul>
<li><a href="https://example.com/link1">Example Link 1</a></li>
<li><a href="https://example.com/link2">Example Link 2</a></li>
<li><a href="https://example.com/link3">Example Link 3</a></li>
<li><a href="https://example.com/link4">Example Link 4</a></li>
<li><a href="https://example.com/link5">Example Link 5</a></li>
</ul>
</body></html>
`);
});
// Page for typing tests with live preview
app.get('/typing', (req, res) => {
res.send(`
<!DOCTYPE html>
<html><head><title>Typing Test</title></head>
<body>
<h1>Typing Test Page</h1>
<input type="text" id="input" placeholder="Type here..." />
<div id="preview"></div>
<script>
document.getElementById('input').addEventListener('input', (e) => {
document.getElementById('preview').textContent = 'Preview: ' + e.target.value;
});
</script>
</body></html>
`);
});
// Page for Enter key test - redirects on Enter
app.get('/enter', (req, res) => {
res.send(`
<!DOCTYPE html>
<html><head><title>Enter Test</title></head>
<body>
<h1>Press Enter Test</h1>
<input type="text" id="searchInput" placeholder="Type and press Enter..." />
<script>
document.getElementById('searchInput').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const value = e.target.value;
window.location.href = '/entered?value=' + encodeURIComponent(value);
}
});
</script>
</body></html>
`);
});
app.get('/entered', (req, res) => {
const value = req.query.value || '';
res.send(`
<!DOCTYPE html>
<html><head><title>Entered</title></head>
<body>
<h1>Entry Received</h1>
<p id="result">Entered: ${value}</p>
</body></html>
`);
});
// Form submission test
app.get('/form', (req, res) => {
res.send(`
<!DOCTYPE html>
<html><head><title>Form Test</title></head>
<body>
<h1>Form Submission Test</h1>
<form action="/submitted" method="POST">
<label for="username">Username:</label>
<input type="text" id="username" name="username" />
<br/>
<label for="email">Email:</label>
<input type="email" id="email" name="email" />
<br/>
<button type="submit" id="submitBtn">Submit</button>
</form>
</body></html>
`);
});
app.post('/submitted', (req, res) => {
const { username, email } = req.body;
res.send(`
<!DOCTYPE html>
<html><head><title>Submitted</title></head>
<body>
<h1>Form Submitted Successfully</h1>
<p id="username">Username: ${username || ''}</p>
<p id="email">Email: ${email || ''}</p>
</body></html>
`);
});
// Page with refresh counter (to verify refresh actually works)
let refreshCount = 0;
app.get('/refresh-test', (req, res) => {
refreshCount++;
res.send(`
<!DOCTYPE html>
<html><head><title>Refresh Test</title></head>
<body>
<h1>Refresh Counter</h1>
<p id="count">Count: ${refreshCount}</p>
</body></html>
`);
});
// Reset refresh counter (for test isolation)
app.post('/reset-refresh-count', (req, res) => {
refreshCount = 0;
res.json({ ok: true });
});
// Page with clickable button
app.get('/click', (req, res) => {
res.send(`
<!DOCTYPE html>
<html><head><title>Click Test</title></head>
<body>
<h1>Click Test Page</h1>
<button id="clickMe">Click Me</button>
<div id="result"></div>
<script>
document.getElementById('clickMe').addEventListener('click', () => {
document.getElementById('result').textContent = 'Button was clicked!';
});
</script>
</body></html>
`);
});
// Echo endpoint for macro expansion testing - echoes the full request URL
app.get('/echo-url', (req, res) => {
res.send(`
<!DOCTYPE html>
<html><head><title>Echo URL</title></head>
<body>
<h1>URL Echo</h1>
<pre id="url">${req.originalUrl}</pre>
<pre id="query">${JSON.stringify(req.query)}</pre>
</body></html>
`);
});
// Page with scrollable content
app.get('/scroll', (req, res) => {
const items = Array.from({ length: 100 }, (_, i) => `<p id="item${i}">Item ${i}</p>`).join('\n');
res.send(`
<!DOCTYPE html>
<html><head><title>Scroll Test</title></head>
<body style="height: 5000px;">
<h1>Scroll Test Page</h1>
${items}
<div id="bottom">Bottom of page</div>
</body></html>
`);
});
return app;
}
async function startTestSite(preferredPort = 0) {
const app = createTestApp();
return new Promise((resolve, reject) => {
server = app.listen(preferredPort, () => {
port = server.address().port;
console.log(`Test site running on port ${port}`);
resolve(port);
});
server.on('error', reject);
});
}
async function stopTestSite() {
if (server) {
return new Promise((resolve) => {
server.close(() => {
server = null;
port = null;
resolve();
});
});
}
}
function getTestSiteUrl() {
if (!port) throw new Error('Test site not started');
return `http://localhost:${port}`;
}
module.exports = {
startTestSite,
stopTestSite,
getTestSiteUrl
};
+93
View File
@@ -0,0 +1,93 @@
const { startServer, stopServer, getServerUrl } = require('../helpers/startServer');
const { createClient } = require('../helpers/client');
// Live Google tests are opt-in due to potential captchas/rate limiting
const SKIP_LIVE_TESTS = !process.env.RUN_LIVE_TESTS;
describe('Live Google Search', () => {
let serverUrl;
beforeAll(async () => {
if (SKIP_LIVE_TESTS) return;
const port = await startServer();
serverUrl = getServerUrl();
}, 120000);
afterAll(async () => {
if (SKIP_LIVE_TESTS) return;
await stopServer();
}, 30000);
(SKIP_LIVE_TESTS ? test.skip : test)('Google search via @google_search macro', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab();
// Use the @google_search macro
const result = await client.navigate(tabId, '@google_search Camoufox playwright browser');
expect(result.ok).toBe(true);
expect(result.url).toContain('google.com');
// Get snapshot - should contain search results
const snapshot = await client.getSnapshot(tabId);
expect(snapshot.snapshot).toBeDefined();
expect(snapshot.snapshot.length).toBeGreaterThan(100);
// Should contain at least one of the search terms
const containsSearchTerm =
snapshot.snapshot.toLowerCase().includes('camoufox') ||
snapshot.snapshot.toLowerCase().includes('playwright') ||
snapshot.snapshot.toLowerCase().includes('browser');
expect(containsSearchTerm).toBe(true);
// Get links - should have search result links
const linksResult = await client.getLinks(tabId, { limit: 20 });
expect(linksResult.links.length).toBeGreaterThan(0);
} finally {
await client.cleanup();
}
}, 120000); // 2 minute timeout for live test
(SKIP_LIVE_TESTS ? test.skip : test)('click on Google search result', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab();
// Search for something specific
await client.navigate(tabId, '@google_search playwright documentation');
// Get snapshot to find a result link
const snapshot = await client.getSnapshot(tabId);
// Look for playwright.dev link in refs
const playwriteMatch = snapshot.snapshot.match(/\[(e\d+)\].*playwright\.dev/i);
if (playwriteMatch) {
const ref = playwriteMatch[1];
// Click the link
await client.click(tabId, { ref });
// Wait for navigation
await new Promise(r => setTimeout(r, 3000));
const newSnapshot = await client.getSnapshot(tabId);
expect(newSnapshot.url).toContain('playwright');
} else {
// If no playwright.dev link found, test still passes
// (Google results can vary)
console.log('No playwright.dev link found in search results, skipping click test');
}
} finally {
await client.cleanup();
}
}, 120000);
});
+132
View File
@@ -0,0 +1,132 @@
const { startServer, stopServer, getServerUrl } = require('../helpers/startServer');
const { createClient } = require('../helpers/client');
// Live macro tests are opt-in due to external site dependencies
const SKIP_LIVE_TESTS = !process.env.RUN_LIVE_TESTS;
describe('Live Macro URL Expansion', () => {
let serverUrl;
beforeAll(async () => {
if (SKIP_LIVE_TESTS) return;
const port = await startServer();
serverUrl = getServerUrl();
}, 120000);
afterAll(async () => {
if (SKIP_LIVE_TESTS) return;
await stopServer();
}, 30000);
(SKIP_LIVE_TESTS ? test.skip : test)('@google_search expands to correct URL', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab();
const result = await client.navigate(tabId, '@google_search test query');
expect(result.ok).toBe(true);
expect(result.url).toContain('google.com/search');
expect(result.url).toContain('q=test');
} finally {
await client.cleanup();
}
}, 60000);
(SKIP_LIVE_TESTS ? test.skip : test)('@youtube_search expands to correct URL', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab();
const result = await client.navigate(tabId, '@youtube_search funny cats');
expect(result.ok).toBe(true);
expect(result.url).toContain('youtube.com/results');
expect(result.url).toContain('search_query=funny');
} finally {
await client.cleanup();
}
}, 60000);
(SKIP_LIVE_TESTS ? test.skip : test)('@amazon_search expands to correct URL', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab();
const result = await client.navigate(tabId, '@amazon_search laptop stand');
expect(result.ok).toBe(true);
expect(result.url).toContain('amazon.com/s');
expect(result.url).toMatch(/k=laptop[\+%20]stand/);
} finally {
await client.cleanup();
}
}, 60000);
(SKIP_LIVE_TESTS ? test.skip : test)('@wikipedia_search expands to correct URL', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab();
const result = await client.navigate(tabId, '@wikipedia_search JavaScript');
expect(result.ok).toBe(true);
expect(result.url).toContain('wikipedia.org');
// Wikipedia may redirect to article or stay on search
expect(result.url).toMatch(/JavaScript|search/i);
} finally {
await client.cleanup();
}
}, 60000);
(SKIP_LIVE_TESTS ? test.skip : test)('@reddit_search expands to correct URL', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab();
const result = await client.navigate(tabId, '@reddit_search programming');
expect(result.ok).toBe(true);
expect(result.url).toContain('reddit.com/search');
expect(result.url).toContain('q=programming');
} finally {
await client.cleanup();
}
}, 60000);
(SKIP_LIVE_TESTS ? test.skip : test)('special characters are URL encoded (live)', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab();
const result = await client.navigate(tabId, '@google_search hello & world');
expect(result.ok).toBe(true);
// & should be encoded as %26
expect(result.url).toContain('q=hello%20%26%20world');
} finally {
await client.cleanup();
}
}, 60000);
(SKIP_LIVE_TESTS ? test.skip : test)('unknown macro returns error', async () => {
const client = createClient(serverUrl);
try {
const { tabId } = await client.createTab();
// Unknown macro with no fallback URL should fail
await expect(client.navigate(tabId, '@unknown_macro test'))
.rejects.toThrow(/url or macro required/);
} finally {
await client.cleanup();
}
}, 60000);
});
+123
View File
@@ -0,0 +1,123 @@
const { expandMacro, getSupportedMacros, MACROS } = require('../../lib/macros');
describe('Macro URL Expansion (unit)', () => {
test('all macros are defined', () => {
const macros = getSupportedMacros();
expect(macros).toContain('@google_search');
expect(macros).toContain('@youtube_search');
expect(macros).toContain('@amazon_search');
expect(macros).toContain('@reddit_search');
expect(macros).toContain('@wikipedia_search');
expect(macros).toContain('@twitter_search');
expect(macros).toContain('@yelp_search');
expect(macros).toContain('@spotify_search');
expect(macros).toContain('@netflix_search');
expect(macros).toContain('@linkedin_search');
expect(macros).toContain('@instagram_search');
expect(macros).toContain('@tiktok_search');
expect(macros).toContain('@twitch_search');
expect(macros.length).toBe(13);
});
test('@google_search expands correctly', () => {
expect(expandMacro('@google_search', 'test query'))
.toBe('https://www.google.com/search?q=test%20query');
});
test('@youtube_search expands correctly', () => {
expect(expandMacro('@youtube_search', 'funny cats'))
.toBe('https://www.youtube.com/results?search_query=funny%20cats');
});
test('@amazon_search expands correctly', () => {
expect(expandMacro('@amazon_search', 'laptop stand'))
.toBe('https://www.amazon.com/s?k=laptop%20stand');
});
test('@reddit_search expands correctly', () => {
expect(expandMacro('@reddit_search', 'programming'))
.toBe('https://www.reddit.com/search/?q=programming');
});
test('@wikipedia_search expands correctly', () => {
expect(expandMacro('@wikipedia_search', 'JavaScript'))
.toBe('https://en.wikipedia.org/wiki/Special:Search?search=JavaScript');
});
test('@twitter_search expands correctly', () => {
expect(expandMacro('@twitter_search', 'breaking news'))
.toBe('https://twitter.com/search?q=breaking%20news');
});
test('@yelp_search expands correctly', () => {
expect(expandMacro('@yelp_search', 'italian restaurant'))
.toBe('https://www.yelp.com/search?find_desc=italian%20restaurant');
});
test('@spotify_search expands correctly', () => {
expect(expandMacro('@spotify_search', 'jazz music'))
.toBe('https://open.spotify.com/search/jazz%20music');
});
test('@netflix_search expands correctly', () => {
expect(expandMacro('@netflix_search', 'comedy'))
.toBe('https://www.netflix.com/search?q=comedy');
});
test('@linkedin_search expands correctly', () => {
expect(expandMacro('@linkedin_search', 'software engineer'))
.toBe('https://www.linkedin.com/search/results/all/?keywords=software%20engineer');
});
test('@instagram_search expands correctly', () => {
expect(expandMacro('@instagram_search', 'travel'))
.toBe('https://www.instagram.com/explore/tags/travel');
});
test('@tiktok_search expands correctly', () => {
expect(expandMacro('@tiktok_search', 'dance'))
.toBe('https://www.tiktok.com/search?q=dance');
});
test('@twitch_search expands correctly', () => {
expect(expandMacro('@twitch_search', 'gaming'))
.toBe('https://www.twitch.tv/search?term=gaming');
});
test('special characters are URL encoded', () => {
expect(expandMacro('@google_search', 'hello & world'))
.toBe('https://www.google.com/search?q=hello%20%26%20world');
expect(expandMacro('@google_search', 'test?param=value'))
.toBe('https://www.google.com/search?q=test%3Fparam%3Dvalue');
expect(expandMacro('@google_search', 'C++ programming'))
.toBe('https://www.google.com/search?q=C%2B%2B%20programming');
});
test('empty query is handled', () => {
expect(expandMacro('@google_search', ''))
.toBe('https://www.google.com/search?q=');
expect(expandMacro('@google_search', null))
.toBe('https://www.google.com/search?q=');
expect(expandMacro('@google_search', undefined))
.toBe('https://www.google.com/search?q=');
});
test('unknown macro returns null', () => {
expect(expandMacro('@unknown_macro', 'test')).toBeNull();
expect(expandMacro('@fake_search', 'query')).toBeNull();
expect(expandMacro('google_search', 'no @ prefix')).toBeNull();
});
test('unicode characters are encoded', () => {
expect(expandMacro('@google_search', '日本語'))
.toBe('https://www.google.com/search?q=%E6%97%A5%E6%9C%AC%E8%AA%9E');
expect(expandMacro('@google_search', 'café'))
.toBe('https://www.google.com/search?q=caf%C3%A9');
});
});