mirror of
https://github.com/jo-inc/camofox-browser.git
synced 2026-10-02 04:14:41 +08:00
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:
+44
@@ -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/
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 167 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 161 KiB |
@@ -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' }]] : [])
|
||||
]
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+6146
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user