feat: OpenAPI spec + swagger-stripey docs at /docs (#78)

Auto-generated OpenAPI 3.0 spec from JSDoc annotations on all 31 routes via swagger-jsdoc. Docs at /docs using swagger-stripey. CI split into parallel unit + e2e jobs (133s → 80s).

Supersedes #69. Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
This commit is contained in:
Pradeep Elankumaran
2026-04-25 11:52:02 -07:00
committed by GitHub
parent 3b8385d3bd
commit 1bdd925371
30 changed files with 7551 additions and 177 deletions
+60 -10
View File
@@ -7,12 +7,11 @@ on:
branches: [master]
jobs:
test:
unit:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [24]
steps:
- uses: actions/checkout@v4
@@ -22,19 +21,70 @@ jobs:
node-version: ${{ matrix.node-version }}
cache: npm
- name: Install dependencies and browser
run: |
npm ci
npm install --no-save jest-junit
npx playwright install --with-deps firefox
- name: Install dependencies
run: npm ci
- name: Run tests (node:test)
run: node --test tests/unit/reporter.test.js
- name: Run tests (Jest + browser)
- name: Run unit + plugin tests (Jest)
run: |
npm install --no-save jest-junit
node --experimental-vm-modules node_modules/.bin/jest \
--testPathPattern='tests/unit|plugins' \
--testPathIgnorePatterns='reporter\.test|security\.test|tabRecycling\.test|cookies\.test' \
--forceExit
env:
CI: true
e2e:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [24]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- name: Install dependencies
run: |
npm ci
npm install --no-save jest-junit
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Install browser
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps firefox
- name: Install browser system deps (cache hit)
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps firefox
- name: Run e2e + browser-dependent unit tests
run: |
xvfb-run --auto-servernum \
node --experimental-vm-modules node_modules/.bin/jest --runInBand --forceExit \
--testPathIgnorePatterns="live|reporter\.test"
node --experimental-vm-modules node_modules/.bin/jest \
--config jest.config.e2e.cjs \
--runInBand --forceExit
env:
CI: true
- name: Run browser-dependent unit tests
run: |
xvfb-run --auto-servernum \
node --experimental-vm-modules node_modules/.bin/jest \
--testPathPattern='tests/unit/(security|tabRecycling|cookies)\.test' \
--runInBand --forceExit
env:
CI: true
+64
View File
@@ -144,6 +144,7 @@ docker run -p 9377:9377 camofox-browser
## Key Files
- `server.js` - Camoufox engine (routes + browser logic only — NO `process.env` or `child_process`)
- `lib/openapi.js` - OpenAPI spec generation via swagger-jsdoc + docs route setup
- `lib/config.js` - All `process.env` reads centralized here
- `plugins/youtube/youtube.js` - YouTube transcript extraction via yt-dlp (`child_process` isolated here)
- `lib/launcher.js` - Subprocess spawning (`child_process` isolated here)
@@ -166,6 +167,69 @@ docker run -p 9377:9377 camofox-browser
- `lib/tmp-cleanup.js` - Orphaned temp file cleanup
- `Dockerfile` - Production container with default plugin deps pre-installed
## OpenAPI Spec (REQUIRED for route changes)
The API spec is auto-generated from `@openapi` JSDoc comments in `server.js` via [swagger-jsdoc](https://github.com/Surnet/swagger-jsdoc). It's served at `GET /openapi.json` (machine-readable) and `GET /docs` ([swagger-stripey](https://github.com/skyfallsin/swagger-stripey) three-panel UI).
**When adding, modifying, or removing a route, you MUST update the `@openapi` JSDoc block above it.**
Every route handler in `server.js` has a JSDoc comment block directly above it like:
```js
/**
* @openapi
* /tabs/{tabId}/click:
* post:
* tags: [Interaction]
* summary: Click an element
* parameters:
* - name: tabId
* in: path
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [userId]
* properties:
* userId:
* type: string
* ref:
* type: string
* responses:
* 200:
* description: Click result.
* content:
* application/json:
* schema:
* type: object
* 404:
* description: Tab not found.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.post('/tabs/:tabId/click', async (req, res) => {
```
**Rules:**
- New routes: add a `@openapi` JSDoc block immediately above the `app.get/post/delete(...)` call
- Path params use `{tabId}` syntax (not `:tabId`) in the JSDoc YAML
- Tag must be one of: `System`, `Tabs`, `Navigation`, `Interaction`, `Content`, `Sessions`, `Browser`, `Legacy`
- Every operation must have `tags`, `summary`, and `responses`
- Include `requestBody` for POST/PUT/DELETE routes that accept JSON
- Include `parameters` for path params and required query params
- Mark backward-compat endpoints with `deprecated: true`
- Removing a route: delete the `@openapi` block along with the handler
- **After any route change, run `npm run generate-openapi`** to regenerate the committed `openapi.json`. The test suite will fail if it's stale.
- Run `npx jest tests/unit/openapi.test.js` to verify coverage — the test fails if any route is missing from the spec, if a stale route exists, or if `openapi.json` is out of date
- Reusable schemas go in `components.schemas` in `lib/openapi.js` (the `swaggerDefinition`); reference them via `$ref: '#/components/schemas/Name'`
## OpenClaw Scanner Isolation (CRITICAL)
OpenClaw's skill-scanner flags plugins that have `process.env` + network calls (e.g. `app.post`, `fetch`, `http.request`) in the same file, or `child_process` + network calls in the same file. These patterns suggest potential credential exfiltration.
+1
View File
@@ -51,6 +51,7 @@ This project wraps that engine in a REST API built for agents: accessibility sna
- **DOM Image Extraction** - list `<img>` src/alt and optionally return inline data URLs
- **Deploy Anywhere** - Docker, Fly.io, Railway
- **VNC Interactive Login** - log into sites visually via noVNC, export storage state for agent reuse
- **OpenAPI Docs** - auto-generated spec at [`/openapi.json`](http://localhost:9377/openapi.json) and interactive docs at [`/docs`](http://localhost:9377/docs)
## Optional Dependencies
+837
View File
@@ -0,0 +1,837 @@
<!DOCTYPE html>
<!-- Docs engine: https://github.com/skyfallsin/swagger-stripey -->
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<title id="page-title">API Reference</title>
<script id="docs-config" type="application/json">
{
"specUrl": "./openapi.json",
"logoUrl": "./fox.png",
"title": "camofox-browser",
"subtitle": "API Reference",
"accent": "#9B30FF",
"accentLight": "#B366FF",
"methodGet": "#9B30FF"
}
</script>
<style>
/* ========================================================
Design tokens — dark (default)
======================================================== */
:root, [data-theme="dark"] {
--accent: #9B30FF;
--accent-light: #B366FF;
--accent-subtle: rgba(155,48,255,0.08);
--bg-primary: #070E1A;
--bg-secondary: #0B1628;
--bg-tertiary: #0F1D33;
--bg-code: #0a1120;
--text-primary: #E8F0FF;
--text-secondary: #8BA3C7;
--text-muted: #5a7394;
--border: #1a2a42;
--border-light: #243550;
--method-get: #9B30FF;
--method-post: #2563EB;
--method-delete: #DC2626;
--method-put: #D97706;
--method-patch: #0891B2;
--code-str: #22C55E;
--status-2xx: #22C55E;
--status-4xx: #F59E0B;
--status-5xx: #EF4444;
--deprecated-bg: rgba(220,38,38,0.15);
--deprecated-fg: #f87171;
--toggle-bg: var(--bg-tertiary);
--toggle-fg: var(--text-secondary);
}
/* ========================================================
Light theme
======================================================== */
[data-theme="light"] {
--accent: #7C22DB;
--accent-light: #9333EA;
--accent-subtle: rgba(124,34,219,0.06);
--bg-primary: #FFFFFF;
--bg-secondary: #F8F9FC;
--bg-tertiary: #F0F2F6;
--bg-code: #F5F6FA;
--text-primary: #1A1D26;
--text-secondary: #5C6370;
--text-muted: #9CA3AF;
--border: #E2E5EC;
--border-light: #ECEEF3;
--method-get: #7C22DB;
--code-str: #16A34A;
--status-2xx: #16A34A;
--status-4xx: #D97706;
--status-5xx: #DC2626;
--deprecated-bg: rgba(220,38,38,0.08);
--deprecated-fg: #DC2626;
--toggle-bg: #E2E5EC;
--toggle-fg: #5C6370;
}
/* ========================================================
Shared tokens
======================================================== */
:root {
--font-body: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', 'SF Mono', 'Menlo', 'Consolas', monospace;
--sidebar-width: 260px;
--code-panel-width: 42%;
--header-height: 56px;
}
/* ========================================================
Reset & base
======================================================== */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; scroll-padding-top: calc(var(--header-height) + 24px); }
body {
font-family: var(--font-body);
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* ========================================================
Header
======================================================== */
.header {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
display: flex; align-items: center; gap: 14px;
padding: 0 24px; height: var(--header-height);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
}
.header img { height: 32px; width: auto; }
.header .title {
font-family: var(--font-mono);
font-size: 16px; font-weight: 600;
color: var(--text-primary); letter-spacing: -0.5px;
}
.header .subtitle {
font-size: 12px; color: var(--text-secondary);
margin-left: 12px; font-weight: 400;
}
.header .header-right {
margin-left: auto; display: flex; align-items: center; gap: 12px;
}
.header .version {
font-family: var(--font-mono);
font-size: 11px; color: var(--text-muted);
background: var(--bg-tertiary); padding: 3px 10px;
border-radius: 4px; border: 1px solid var(--border);
}
/* Theme toggle */
.theme-toggle {
background: var(--toggle-bg); border: 1px solid var(--border);
border-radius: 6px; padding: 5px 10px;
cursor: pointer; font-size: 14px; line-height: 1;
color: var(--toggle-fg); transition: all 0.2s;
display: flex; align-items: center; gap: 4px;
}
.theme-toggle:hover { border-color: var(--accent); color: var(--accent); }
/* Mobile menu toggle */
.menu-toggle {
display: none;
background: var(--toggle-bg); border: 1px solid var(--border);
border-radius: 6px; padding: 6px 10px;
cursor: pointer; font-size: 18px; line-height: 1;
color: var(--toggle-fg);
}
.menu-toggle:hover { border-color: var(--accent); color: var(--accent); }
/* ========================================================
Sidebar
======================================================== */
.sidebar {
position: fixed; top: var(--header-height); left: 0; bottom: 0;
width: var(--sidebar-width); overflow-y: auto;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
padding: 16px 0;
transition: transform 0.25s ease;
z-index: 90;
}
.sidebar::-webkit-scrollbar { width: 4px; }
.sidebar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.sidebar .tag-group { margin-bottom: 4px; }
.sidebar .tag-name {
display: block; padding: 6px 20px;
font-size: 11px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.8px;
color: var(--text-muted);
}
.sidebar .nav-item {
display: flex; align-items: center; gap: 8px;
padding: 5px 20px 5px 24px;
text-decoration: none; color: var(--text-secondary);
font-size: 13px; transition: all 0.15s;
border-left: 2px solid transparent;
}
.sidebar .nav-item:hover {
color: var(--text-primary);
background: var(--accent-subtle);
}
.sidebar .nav-item.active {
color: var(--text-primary);
border-left-color: var(--accent);
background: var(--accent-subtle);
}
.sidebar .method-badge {
font-family: var(--font-mono);
font-size: 9px; font-weight: 600;
text-transform: uppercase;
padding: 1px 5px; border-radius: 3px;
min-width: 36px; text-align: center;
color: #fff; flex-shrink: 0;
}
.sidebar .method-badge.get { background: var(--method-get); }
.sidebar .method-badge.post { background: var(--method-post); }
.sidebar .method-badge.delete { background: var(--method-delete); }
.sidebar .method-badge.put { background: var(--method-put); }
.sidebar .method-badge.patch { background: var(--method-patch); }
/* Mobile overlay when sidebar is open */
.sidebar-overlay {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.5); z-index: 89;
}
/* ========================================================
Main content — two-panel (Stripe layout)
======================================================== */
.main {
margin-left: var(--sidebar-width);
margin-top: var(--header-height);
}
.endpoint {
display: flex; min-height: 100vh;
border-bottom: 1px solid var(--border);
}
.endpoint:last-child { min-height: auto; }
/* Left panel — description */
.endpoint .desc-panel {
flex: 1; min-width: 0;
padding: 40px 48px;
max-width: calc(100% - var(--code-panel-width));
}
/* Right panel — code examples */
.endpoint .code-panel {
width: var(--code-panel-width); flex-shrink: 0;
background: var(--bg-code);
border-left: 1px solid var(--border);
padding: 40px 32px;
position: sticky; top: var(--header-height);
max-height: calc(100vh - var(--header-height));
overflow-y: auto;
}
.endpoint .code-panel::-webkit-scrollbar { width: 4px; }
.endpoint .code-panel::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
/* Endpoint title */
.endpoint-title {
display: flex; align-items: center; gap: 12px;
margin-bottom: 8px; flex-wrap: wrap;
}
.endpoint-title .method {
font-family: var(--font-mono);
font-size: 12px; font-weight: 600;
text-transform: uppercase;
padding: 3px 8px; border-radius: 4px;
color: #fff;
}
.endpoint-title .method.get { background: var(--method-get); }
.endpoint-title .method.post { background: var(--method-post); }
.endpoint-title .method.delete { background: var(--method-delete); }
.endpoint-title .method.put { background: var(--method-put); }
.endpoint-title .method.patch { background: var(--method-patch); }
.endpoint-title .path {
font-family: var(--font-mono);
font-size: 15px; font-weight: 500;
color: var(--text-primary);
word-break: break-all;
}
.endpoint-title .path .param { color: var(--accent-light); }
.endpoint .summary {
font-size: 22px; font-weight: 600;
color: var(--text-primary);
margin-bottom: 12px; line-height: 1.3;
}
.endpoint .description {
color: var(--text-secondary);
font-size: 14px; line-height: 1.7;
margin-bottom: 28px;
}
.deprecated-badge {
display: inline-block;
font-size: 10px; font-weight: 600;
text-transform: uppercase;
padding: 2px 8px; border-radius: 3px;
background: var(--deprecated-bg);
color: var(--deprecated-fg); margin-left: 8px;
}
/* Parameters table */
.params-section { margin-bottom: 28px; }
.params-section h3 {
font-size: 13px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.5px;
color: var(--text-muted);
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.param-row {
display: flex; gap: 16px;
padding: 10px 0;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.param-row:last-child { border-bottom: none; }
.param-name {
font-family: var(--font-mono);
font-weight: 500; min-width: 140px;
color: var(--text-primary);
}
.param-name .required {
color: var(--accent-light);
font-size: 10px; margin-left: 4px;
}
.param-name .in-badge {
display: block; font-size: 10px;
color: var(--text-muted); font-weight: 400;
margin-top: 2px;
}
.param-desc { color: var(--text-secondary); flex: 1; }
.param-type {
font-family: var(--font-mono);
font-size: 11px; color: var(--accent-light);
}
/* Response status codes */
.responses-section { margin-bottom: 28px; }
.response-row {
display: flex; align-items: baseline; gap: 12px;
padding: 8px 0;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.response-row:last-child { border-bottom: none; }
.status-code {
font-family: var(--font-mono);
font-weight: 600; min-width: 40px;
}
.status-code.s2xx { color: var(--status-2xx); }
.status-code.s4xx { color: var(--status-4xx); }
.status-code.s5xx { color: var(--status-5xx); }
.response-desc { color: var(--text-secondary); }
/* Code panel content */
.code-block {
position: relative;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 16px; margin-bottom: 16px;
overflow-x: auto;
}
.code-block .label {
font-size: 11px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.5px;
color: var(--text-muted);
margin-bottom: 10px;
}
.code-block .copy-btn {
position: absolute; top: 10px; right: 10px;
background: var(--bg-secondary); border: 1px solid var(--border);
border-radius: 4px; padding: 4px 8px;
cursor: pointer; font-size: 11px; line-height: 1;
color: var(--text-muted); transition: all 0.15s;
opacity: 0;
}
.code-block:hover .copy-btn { opacity: 1; }
.code-block .copy-btn:hover { color: var(--text-primary); border-color: var(--accent); }
.code-block .copy-btn.copied { color: var(--status-2xx); border-color: var(--status-2xx); opacity: 1; }
.code-block pre {
font-family: var(--font-mono);
font-size: 12px; line-height: 1.6;
color: var(--text-secondary);
white-space: pre-wrap; word-break: break-all;
}
.code-block pre .str { color: var(--code-str); }
.code-block pre .key { color: var(--accent-light); }
.code-block pre .comment { color: var(--text-muted); }
.code-block pre .method-h { color: var(--accent); font-weight: 600; }
.code-block pre .url-h { color: var(--text-primary); }
/* Body schema tree */
.schema-tree { font-size: 13px; }
.schema-prop {
padding: 4px 0;
display: flex; gap: 8px; align-items: baseline;
flex-wrap: wrap;
}
.schema-prop .prop-name {
font-family: var(--font-mono);
color: var(--text-primary); font-weight: 500;
}
.schema-prop .prop-type {
font-family: var(--font-mono);
font-size: 11px; color: var(--accent-light);
}
.schema-prop .prop-required {
font-size: 10px; color: var(--accent-light);
}
.schema-prop .prop-desc {
color: var(--text-secondary); font-size: 12px;
}
/* Intro section */
.intro-section {
padding: 48px;
border-bottom: 1px solid var(--border);
max-width: calc(100% - var(--code-panel-width));
}
.intro-section h2 {
font-size: 28px; font-weight: 600;
margin-bottom: 16px;
}
.intro-section p {
color: var(--text-secondary);
font-size: 15px; line-height: 1.7;
max-width: 640px;
}
.intro-section .base-url {
display: inline-block; margin-top: 20px;
font-family: var(--font-mono);
font-size: 14px; padding: 8px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--accent-light);
}
/* ========================================================
Responsive — tablet (< 1024px): stack code below
======================================================== */
@media (max-width: 1024px) {
:root { --code-panel-width: 0%; }
.endpoint { flex-direction: column; min-height: auto; }
.endpoint .desc-panel {
max-width: 100%; padding: 32px 24px;
}
.endpoint .code-panel {
width: 100%; position: static; max-height: none;
border-left: none; border-top: 1px solid var(--border);
padding: 24px;
}
.intro-section { max-width: 100%; padding: 32px 24px; }
}
/* ========================================================
Responsive — mobile (< 768px): collapsible sidebar
======================================================== */
@media (max-width: 768px) {
:root { --sidebar-width: 280px; }
.menu-toggle { display: block; }
.sidebar {
transform: translateX(-100%);
}
.sidebar.open {
transform: translateX(0);
box-shadow: 4px 0 20px rgba(0,0,0,0.3);
}
.sidebar-overlay.open { display: block; }
.main { margin-left: 0; }
.intro-section { max-width: 100%; }
.header .subtitle { display: none; }
.endpoint .desc-panel { padding: 24px 16px; }
.endpoint .code-panel { padding: 16px; }
.endpoint .summary { font-size: 18px; }
.endpoint-title .path { font-size: 13px; }
.param-row { flex-direction: column; gap: 4px; }
.param-name { min-width: unset; }
}
</style>
</head>
<body>
<div class="header" id="header"></div>
<div class="sidebar-overlay" id="sidebar-overlay"></div>
<nav class="sidebar" id="sidebar"></nav>
<div class="main" id="main"></div>
<script>
// ============================================================
// Configuration
// ============================================================
const docsConfig = window.docsConfig || {};
try {
const configEl = document.getElementById('docs-config');
if (configEl) Object.assign(docsConfig, JSON.parse(configEl.textContent));
} catch(e) {}
const conf = {
specUrl: docsConfig.specUrl || './openapi.json',
logoUrl: docsConfig.logoUrl || null,
title: docsConfig.title || null,
subtitle: docsConfig.subtitle || 'API Reference',
defaultTheme: docsConfig.defaultTheme || 'dark',
theme: {
'--accent': docsConfig.accent || null,
'--accent-light': docsConfig.accentLight || null,
'--bg-primary': docsConfig.bgPrimary || null,
'--bg-secondary': docsConfig.bgSecondary || null,
'--bg-tertiary': docsConfig.bgTertiary || null,
'--text-primary': docsConfig.textPrimary || null,
'--text-secondary': docsConfig.textSecondary || null,
'--font-body': docsConfig.fontBody || null,
'--font-mono': docsConfig.fontMono || null,
'--method-get': docsConfig.methodGet || null,
'--method-post': docsConfig.methodPost || null,
'--method-delete': docsConfig.methodDelete || null,
},
};
// Apply theme overrides (only in dark mode — light mode uses its own)
for (const [prop, val] of Object.entries(conf.theme)) {
if (val) document.documentElement.style.setProperty(prop, val);
}
// ============================================================
// Theme toggle
// ============================================================
function getStoredTheme() {
try { return localStorage.getItem('api-docs-theme'); } catch { return null; }
}
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
try { localStorage.setItem('api-docs-theme', theme); } catch {}
const btn = document.getElementById('theme-btn');
if (btn) btn.textContent = theme === 'dark' ? '☀️' : '🌙';
}
setTheme(getStoredTheme() || conf.defaultTheme);
// ============================================================
// Mobile sidebar
// ============================================================
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('open');
document.getElementById('sidebar-overlay').classList.toggle('open');
}
function closeSidebar() {
document.getElementById('sidebar').classList.remove('open');
document.getElementById('sidebar-overlay').classList.remove('open');
}
// ============================================================
// Render helpers
// ============================================================
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function copyCode(btn) {
const pre = btn.parentElement.querySelector('pre');
const text = pre.textContent;
navigator.clipboard.writeText(text).then(() => {
btn.textContent = 'Copied!';
btn.classList.add('copied');
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 1500);
});
}
function opId(method, path) { return `${method}-${path.replace(/[^a-zA-Z0-9]/g, '-')}`; }
function highlightPath(path) { return esc(path).replace(/\{([^}]+)\}/g, '<span class="param">{$1}</span>'); }
function statusClass(code) {
const n = parseInt(code);
if (n >= 200 && n < 300) return 's2xx';
if (n >= 400 && n < 500) return 's4xx';
return 's5xx';
}
function renderSchemaProps(schema, depth = 0) {
if (!schema || !schema.properties) return '';
const required = new Set(schema.required || []);
let html = '';
for (const [name, prop] of Object.entries(schema.properties)) {
const type = prop.type || (prop.$ref ? prop.$ref.split('/').pop() : 'object');
html += `<div class="schema-prop" style="padding-left:${depth*16}px">
<span class="prop-name">${esc(name)}</span>
<span class="prop-type">${esc(type)}${prop.enum ? ' enum' : ''}</span>
${required.has(name) ? '<span class="prop-required">required</span>' : ''}
${prop.description ? `<span class="prop-desc">— ${esc(prop.description)}</span>` : ''}
</div>`;
if (prop.properties) html += renderSchemaProps(prop, depth + 1);
if (prop.items?.properties) html += renderSchemaProps(prop.items, depth + 1);
}
return html;
}
function buildCurlExample(method, path, op, servers) {
const base = servers?.[0]?.url || 'http://localhost:9377';
const url = base + path.replace(/\{(\w+)\}/g, ':$1');
let curl = `<span class="comment"># ${esc(op.summary || `${method.toUpperCase()} ${path}`)}</span>\ncurl`;
if (method !== 'get') curl += ` -X <span class="method-h">${method.toUpperCase()}</span>`;
curl += ` <span class="url-h">${esc(url)}</span>`;
const bodySchema = op.requestBody?.content?.['application/json']?.schema;
if (bodySchema?.properties) {
curl += ` \\\n -H <span class="str">"Content-Type: application/json"</span>`;
const props = {};
for (const [k, v] of Object.entries(bodySchema.properties)) {
if (v.type === 'string') props[k] = `<${k}>`;
else if (v.type === 'boolean') props[k] = true;
else if (v.type === 'integer' || v.type === 'number') props[k] = 0;
else if (v.type === 'array') props[k] = [];
else props[k] = {};
}
const body = JSON.stringify(props, null, 2)
.replace(/"([^"]+)":/g, '<span class="key">"$1"</span>:')
.replace(/"<([^>]+)>"/g, '<span class="str">"&lt;$1&gt;"</span>');
curl += ` \\\n -d '${body}'`;
}
return curl;
}
function buildResponseExample(op) {
const resp200 = op.responses?.['200'];
if (!resp200) return null;
const schema = resp200.content?.['application/json']?.schema;
if (!schema?.properties) return null;
const obj = {};
for (const [k, v] of Object.entries(schema.properties)) {
if (v.type === 'string') obj[k] = v.example || '';
else if (v.type === 'boolean') obj[k] = true;
else if (v.type === 'integer' || v.type === 'number') obj[k] = 0;
else if (v.type === 'array') obj[k] = [];
else if (v.type === 'object' && v.properties) {
const inner = {};
for (const [ik, iv] of Object.entries(v.properties)) {
if (iv.type === 'string') inner[ik] = iv.example || '';
else if (iv.type === 'boolean') inner[ik] = true;
else inner[ik] = null;
}
obj[k] = inner;
} else obj[k] = null;
}
return JSON.stringify(obj, null, 2)
.replace(/"([^"]+)":/g, '<span class="key">"$1"</span>:')
.replace(/: "([^"]*)"/g, ': <span class="str">"$1"</span>');
}
// ============================================================
// Main render
// ============================================================
async function render() {
const res = await fetch(conf.specUrl);
const spec = await res.json();
const title = conf.title || spec.info?.title || 'API';
document.title = `${title} — ${conf.subtitle}`;
document.getElementById('page-title').textContent = document.title;
// Header
const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark';
document.getElementById('header').innerHTML = `
<button class="menu-toggle" onclick="toggleSidebar()" aria-label="Menu">☰</button>
${conf.logoUrl ? `<img src="${esc(conf.logoUrl)}" alt="">` : ''}
<span class="title">${esc(title)}</span>
<span class="subtitle">${esc(conf.subtitle)}</span>
<div class="header-right">
<span class="version">v${esc(spec.info?.version || '?')}</span>
<button class="theme-toggle" id="theme-btn"
onclick="setTheme(document.documentElement.getAttribute('data-theme')==='dark'?'light':'dark')"
aria-label="Toggle theme">${currentTheme === 'dark' ? '☀️' : '🌙'}</button>
</div>
`;
// Group operations by tag
const tagMap = new Map();
const tagOrder = (spec.tags || []).map(t => t.name);
for (const [path, methods] of Object.entries(spec.paths || {})) {
for (const [method, op] of Object.entries(methods)) {
if (method.startsWith('x-')) continue;
const tag = op.tags?.[0] || 'Other';
if (!tagMap.has(tag)) tagMap.set(tag, []);
tagMap.get(tag).push({ method, path, op });
}
}
const sortedTags = [...tagMap.keys()].sort((a, b) => {
const ai = tagOrder.indexOf(a), bi = tagOrder.indexOf(b);
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
});
// Sidebar
let sidebarHtml = '';
for (const tag of sortedTags) {
sidebarHtml += `<div class="tag-group"><span class="tag-name">${esc(tag)}</span>`;
for (const { method, path, op } of tagMap.get(tag)) {
const id = opId(method, path);
const label = op.summary || path;
sidebarHtml += `<a class="nav-item" href="#${id}" data-id="${id}" onclick="closeSidebar()">
<span class="method-badge ${method}">${method}</span>
<span>${esc(label)}</span>
</a>`;
}
sidebarHtml += '</div>';
}
document.getElementById('sidebar').innerHTML = sidebarHtml;
// Main content
let mainHtml = '';
if (spec.info?.description) {
const base = spec.servers?.[0]?.url || '';
mainHtml += `<div class="intro-section">
<h2>${esc(conf.subtitle)}</h2>
<p>${esc(spec.info.description)}</p>
${base ? `<div class="base-url">${esc(base)}</div>` : ''}
</div>`;
}
for (const tag of sortedTags) {
for (const { method, path, op } of tagMap.get(tag)) {
const id = opId(method, path);
const allParams = op.parameters || [];
const pathParams = allParams.filter(p => p.in === 'path');
const queryParams = allParams.filter(p => p.in === 'query');
const bodySchema = op.requestBody?.content?.['application/json']?.schema;
const curl = buildCurlExample(method, path, op, spec.servers);
const respExample = buildResponseExample(op);
mainHtml += `<div class="endpoint" id="${id}">
<div class="desc-panel">
<div class="endpoint-title">
<span class="method ${method}">${method.toUpperCase()}</span>
<span class="path">${highlightPath(path)}</span>
${op.deprecated ? '<span class="deprecated-badge">Deprecated</span>' : ''}
</div>
<div class="summary">${esc(op.summary || '')}</div>
${op.description ? `<div class="description">${esc(op.description)}</div>` : ''}`;
if (pathParams.length) {
mainHtml += `<div class="params-section"><h3>Path Parameters</h3>`;
for (const p of pathParams) {
mainHtml += `<div class="param-row">
<div class="param-name">${esc(p.name)}${p.required ? '<span class="required">required</span>' : ''}
<span class="in-badge">${esc(p.in)}</span></div>
<div class="param-desc">
<div class="param-type">${esc(p.schema?.type || 'string')}</div>
${p.description ? esc(p.description) : ''}</div>
</div>`;
}
mainHtml += '</div>';
}
if (queryParams.length) {
mainHtml += `<div class="params-section"><h3>Query Parameters</h3>`;
for (const p of queryParams) {
mainHtml += `<div class="param-row">
<div class="param-name">${esc(p.name)}${p.required ? '<span class="required">required</span>' : ''}
<span class="in-badge">${esc(p.in)}</span></div>
<div class="param-desc">
<div class="param-type">${esc(p.schema?.type || 'string')}${p.schema?.enum ? ` — ${p.schema.enum.join(', ')}` : ''}</div>
${p.description ? esc(p.description) : ''}</div>
</div>`;
}
mainHtml += '</div>';
}
if (bodySchema) {
mainHtml += `<div class="params-section"><h3>Request Body</h3>
<div class="schema-tree">${renderSchemaProps(bodySchema)}</div></div>`;
}
if (op.responses) {
mainHtml += `<div class="responses-section"><h3>Responses</h3>`;
for (const [code, resp] of Object.entries(op.responses)) {
if (code.startsWith('x-')) continue;
const desc = resp.description || resp.$ref || '';
mainHtml += `<div class="response-row">
<span class="status-code ${statusClass(code)}">${esc(code)}</span>
<span class="response-desc">${esc(desc)}</span>
</div>`;
}
mainHtml += '</div>';
}
mainHtml += `</div>`; // close desc-panel
mainHtml += `<div class="code-panel">
<div class="code-block">
<button class="copy-btn" onclick="copyCode(this)">Copy</button>
<div class="label">Request</div>
<pre>${curl}</pre>
</div>`;
if (respExample) {
mainHtml += `<div class="code-block">
<button class="copy-btn" onclick="copyCode(this)">Copy</button>
<div class="label">Response</div>
<pre>${respExample}</pre>
</div>`;
}
mainHtml += `</div></div>`;
}
}
document.getElementById('main').innerHTML = mainHtml;
// Active nav tracking via IntersectionObserver
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
const link = document.querySelector(`.nav-item[data-id="${entry.target.id}"]`);
if (link) {
link.classList.add('active');
link.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}
}
}, { rootMargin: '-80px 0px -60% 0px', threshold: 0 });
document.querySelectorAll('.endpoint[id]').forEach(el => observer.observe(el));
// Close sidebar on overlay click
document.getElementById('sidebar-overlay').addEventListener('click', closeSidebar);
}
render().catch(err => {
document.getElementById('main').innerHTML =
`<div class="intro-section"><h2>Error</h2><p>${esc(err.message)}</p></div>`;
});
</script>
</body>
</html>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

+2209
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
module.exports = {
transform: {},
testEnvironment: 'node',
testTimeout: 60000,
// e2e tests run sequentially (shared browser state)
maxWorkers: 1,
testMatch: ['**/tests/e2e/*.test.js'],
testPathIgnorePatterns: ['/node_modules/', 'live'],
globalSetup: './tests/e2e/globalSetup.js',
globalTeardown: './tests/e2e/globalTeardown.js',
verbose: true,
bail: 0,
reporters: [
'default',
...(process.env.CI ? [['jest-junit', { outputDirectory: 'test-results', outputName: 'e2e-results.xml' }]] : [])
]
};
+100
View File
@@ -0,0 +1,100 @@
/**
* OpenAPI spec generation via swagger-jsdoc + docs UI (swagger-stripey).
*
* swagger-jsdoc scans JSDoc `@openapi` comments on route handlers in server.js
* (and any file passed in `apis`) to build the spec at startup.
* Docs UI lives in docs/api.html (swagger-stripey: Stripe-style 3-panel renderer).
*
* Usage:
* import { mountDocs } from './lib/openapi.js';
* // After all routes are registered:
* mountDocs(app);
*/
import swaggerJsdoc from 'swagger-jsdoc';
import express from 'express';
import { readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
let version = 'unknown';
try {
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
version = pkg.version;
} catch { /* ignore */ }
const swaggerDefinition = {
openapi: '3.0.3',
info: {
title: 'camofox-browser',
version,
description:
'Anti-detection browser automation server for AI agents. ' +
'Accessibility snapshots, element refs, session isolation, cookie import, proxy rotation, and structured logs.',
license: { name: 'MIT', url: 'https://opensource.org/licenses/MIT' },
contact: { name: 'Jo Inc', url: 'https://askjo.ai', email: 'oss@askjo.ai' },
},
servers: [{ url: 'http://localhost:9377', description: 'Local development' }],
tags: [
{ name: 'System', description: 'Server health, metrics, and status.' },
{ name: 'Tabs', description: 'Create, list, inspect, and destroy browser tabs.' },
{ name: 'Navigation', description: 'Navigate tabs to URLs or via search macros.' },
{ name: 'Interaction', description: 'Click, type, scroll, press keys, evaluate JS.' },
{ name: 'Content', description: 'Accessibility snapshots, screenshots, links, images, downloads.' },
{ name: 'Sessions', description: 'Per-user session state: cookies, teardown.' },
{ name: 'Browser', description: 'Global browser lifecycle (start/stop).' },
{ name: 'Legacy', description: 'OpenClaw-compatible endpoints (deprecated).' },
],
components: {
securitySchemes: {
BearerAuth: {
type: 'http',
scheme: 'bearer',
description: 'Bearer token matching CAMOFOX_API_KEY.',
},
},
schemas: {
Error: {
type: 'object',
required: ['error'],
properties: { error: { type: 'string' } },
},
},
},
};
/**
* Mount GET /openapi.json and GET /docs on the Express app.
* Call AFTER all routes are registered so swagger-jsdoc can scan them.
*
* @param {import('express').Application} app
* @param {Object} [opts]
* @param {string[]} [opts.apis] - Glob patterns for files with @openapi JSDoc (default: ['./server.js'])
*/
export function mountDocs(app, opts = {}) {
const apis = opts.apis || ['./server.js'];
const spec = swaggerJsdoc({
definition: swaggerDefinition,
apis,
});
app.get('/openapi.json', (_req, res) => {
res.json(spec);
});
// Serve docs static assets (api.html, fox.png, openapi.json)
const docsDir = join(__dirname, '..', 'docs');
app.use('/docs', express.static(docsDir, { index: 'api.html' }));
// Also serve fox.png at root for backward compat with old Swagger UI HTML
app.get('/fox.png', (_req, res) => {
res.sendFile(join(docsDir, 'fox.png'));
});
return spec;
}
export { swaggerDefinition };
+2209
View File
File diff suppressed because it is too large Load Diff
+245 -1
View File
@@ -16,7 +16,8 @@
"playwright-core": "^1.58.0",
"playwright-extra": "^4.3.6",
"prom-client": "^15.1.3",
"puppeteer-extra-plugin-stealth": "^2.11.2"
"puppeteer-extra-plugin-stealth": "^2.11.2",
"swagger-jsdoc": "^6.2.8"
},
"devDependencies": {
"jest": "^29.7.0",
@@ -26,6 +27,68 @@
"node": ">=18"
}
},
"node_modules/@apidevtools/json-schema-ref-parser": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz",
"integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==",
"license": "MIT",
"dependencies": {
"@jsdevtools/ono": "^7.1.3",
"@types/json-schema": "^7.0.6",
"call-me-maybe": "^1.0.1",
"js-yaml": "^4.1.0"
}
},
"node_modules/@apidevtools/json-schema-ref-parser/node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/@apidevtools/json-schema-ref-parser/node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/@apidevtools/openapi-schemas": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz",
"integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/@apidevtools/swagger-methods": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz",
"integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==",
"license": "MIT"
},
"node_modules/@apidevtools/swagger-parser": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz",
"integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==",
"license": "MIT",
"dependencies": {
"@apidevtools/json-schema-ref-parser": "^9.0.6",
"@apidevtools/openapi-schemas": "^2.0.4",
"@apidevtools/swagger-methods": "^3.0.2",
"@jsdevtools/ono": "^7.1.3",
"call-me-maybe": "^1.0.1",
"z-schema": "^5.0.1"
},
"peerDependencies": {
"openapi-types": ">=7"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -976,6 +1039,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@jsdevtools/ono": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
"license": "MIT"
},
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
@@ -1115,6 +1184,12 @@
"@types/istanbul-lib-report": "*"
}
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
@@ -1608,6 +1683,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/call-me-maybe": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
"integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==",
"license": "MIT"
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -2020,6 +2101,18 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/doctrine": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
"integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
"license": "Apache-2.0",
"dependencies": {
"esutils": "^2.0.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/dot-prop": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz",
@@ -2178,6 +2271,15 @@
"node": ">=4"
}
},
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@@ -3979,6 +4081,13 @@
"node": ">=8"
}
},
"node_modules/lodash.get": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
"integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==",
"deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.",
"license": "MIT"
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
@@ -3986,6 +4095,12 @@
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
"node_modules/lodash.mergewith": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz",
"integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -4404,6 +4519,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/openapi-types": {
"version": "12.1.3",
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
"license": "MIT",
"peer": true
},
"node_modules/ow": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/ow/-/ow-0.28.2.tgz",
@@ -5697,6 +5819,80 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/swagger-jsdoc": {
"version": "6.2.8",
"resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz",
"integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==",
"license": "MIT",
"dependencies": {
"commander": "6.2.0",
"doctrine": "3.0.0",
"glob": "7.1.6",
"lodash.mergewith": "^4.6.2",
"swagger-parser": "^10.0.3",
"yaml": "2.0.0-1"
},
"bin": {
"swagger-jsdoc": "bin/swagger-jsdoc.js"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/swagger-jsdoc/node_modules/commander": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz",
"integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/swagger-jsdoc/node_modules/glob": {
"version": "7.1.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/swagger-jsdoc/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/swagger-parser": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz",
"integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==",
"license": "MIT",
"dependencies": {
"@apidevtools/swagger-parser": "10.0.3"
},
"engines": {
"node": ">=10"
}
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
@@ -6021,6 +6217,15 @@
"node": ">=0.10.0"
}
},
"node_modules/validator": {
"version": "13.15.35",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz",
"integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -6133,6 +6338,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.0.0-1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz",
"integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==",
"license": "ISC",
"engines": {
"node": ">= 6"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
@@ -6174,6 +6388,36 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/z-schema": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz",
"integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==",
"license": "MIT",
"dependencies": {
"lodash.get": "^4.4.2",
"lodash.isequal": "^4.5.0",
"validator": "^13.7.0"
},
"bin": {
"z-schema": "bin/z-schema"
},
"engines": {
"node": ">=8.0.0"
},
"optionalDependencies": {
"commander": "^9.4.1"
}
},
"node_modules/z-schema/node_modules/commander": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": "^12.20.0 || >=14"
}
}
}
}
+3 -1
View File
@@ -60,6 +60,7 @@
"test:live": "RUN_LIVE_TESTS=1 NODE_OPTIONS='--experimental-vm-modules' jest --runInBand --forceExit tests/live",
"test:debug": "DEBUG_SERVER=1 NODE_OPTIONS='--experimental-vm-modules' jest --runInBand --forceExit",
"plugin": "node scripts/plugin.js",
"generate-openapi": "node scripts/generate-openapi.js",
"version:sync": "node scripts/sync-version.js",
"version": "node scripts/sync-version.js && git add openclaw.plugin.json",
"postinstall": "npx camoufox-js fetch || true"
@@ -71,7 +72,8 @@
"playwright-core": "^1.58.0",
"playwright-extra": "^4.3.6",
"prom-client": "^15.1.3",
"puppeteer-extra-plugin-stealth": "^2.11.2"
"puppeteer-extra-plugin-stealth": "^2.11.2",
"swagger-jsdoc": "^6.2.8"
},
"devDependencies": {
"jest": "^29.7.0",
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env node
/**
* Generate openapi.json from JSDoc annotations in server.js.
* Run: node scripts/generate-openapi.js
*/
import { writeFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import swaggerJsdoc from 'swagger-jsdoc';
import { swaggerDefinition } from '../lib/openapi.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
const spec = swaggerJsdoc({
definition: swaggerDefinition,
apis: [join(root, 'server.js')],
});
const out = join(root, 'openapi.json');
writeFileSync(out, JSON.stringify(spec, null, 2) + '\n');
console.log(`Wrote ${Object.keys(spec.paths).length} paths to openapi.json`);
+1355 -4
View File
File diff suppressed because it is too large Load Diff
+7 -13
View File
@@ -1,23 +1,17 @@
import { startServer, stopServer, getServerUrl } from '../helpers/startServer.js';
import { startTestSite, stopTestSite, getTestSiteUrl } from '../helpers/testSite.js';
import { createClient } from '../helpers/client.js';
import { getSharedEnv } from './sharedEnv.js';
describe('Concurrency', () => {
let serverUrl;
let testSiteUrl;
beforeAll(async () => {
const port = await startServer();
serverUrl = getServerUrl();
const testPort = await startTestSite();
testSiteUrl = getTestSiteUrl();
}, 120000);
beforeAll(() => {
const env = getSharedEnv();
serverUrl = env.serverUrl;
testSiteUrl = env.testSiteUrl;
});
afterAll(async () => {
await stopTestSite();
await stopServer();
}, 30000);
// Server lifecycle managed by globalSetup/globalTeardown
test('concurrent operations on same tab are serialized', async () => {
const client = createClient(serverUrl);
+7 -13
View File
@@ -1,23 +1,17 @@
import { startServer, stopServer, getServerUrl } from '../helpers/startServer.js';
import { startTestSite, stopTestSite, getTestSiteUrl } from '../helpers/testSite.js';
import { createClient } from '../helpers/client.js';
import { getSharedEnv } from './sharedEnv.js';
describe('Downloads and Images', () => {
let serverUrl;
let testSiteUrl;
beforeAll(async () => {
await startServer();
serverUrl = getServerUrl();
beforeAll(() => {
const env = getSharedEnv();
serverUrl = env.serverUrl;
testSiteUrl = env.testSiteUrl;
});
await startTestSite();
testSiteUrl = getTestSiteUrl();
}, 120000);
afterAll(async () => {
await stopTestSite();
await stopServer();
}, 30000);
// Server lifecycle managed by globalSetup/globalTeardown
test('GET /tabs/:tabId/images returns image sources', async () => {
const client = createClient(serverUrl);
+7 -13
View File
@@ -1,7 +1,6 @@
import { jest } from '@jest/globals';
import { startServer, stopServer, getServerUrl } from '../helpers/startServer.js';
import { startTestSite, stopTestSite, getTestSiteUrl } from '../helpers/testSite.js';
import { createClient } from '../helpers/client.js';
import { getSharedEnv } from './sharedEnv.js';
jest.retryTimes(2, { logErrorsBeforeRetry: true });
@@ -9,18 +8,13 @@ describe('Form Submission', () => {
let serverUrl;
let testSiteUrl;
beforeAll(async () => {
const port = await startServer();
serverUrl = getServerUrl();
const testPort = await startTestSite();
testSiteUrl = getTestSiteUrl();
}, 120000);
beforeAll(() => {
const env = getSharedEnv();
serverUrl = env.serverUrl;
testSiteUrl = env.testSiteUrl;
});
afterAll(async () => {
await stopTestSite();
await stopServer();
}, 30000);
// Server lifecycle managed by globalSetup/globalTeardown
test('fill form fields and submit via button click', async () => {
const client = createClient(serverUrl);
+69
View File
@@ -0,0 +1,69 @@
/**
* Jest globalSetup for e2e tests.
* Starts ONE camofox server + test site shared across ALL e2e test files.
* Writes connection URLs to a temp file so test files can read them.
*/
import fs from 'fs';
import path from 'path';
import os from 'os';
import { fileURLToPath } from 'node:url';
import { launchServer } from '../../lib/launcher.js';
import { loadConfig } from '../../lib/config.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const ENV_FILE = path.join(os.tmpdir(), 'camofox-e2e-env.json');
async function waitForServer(port, maxRetries = 30, interval = 1000) {
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch(`http://localhost:${port}/health`);
if (response.ok) return true;
} catch (e) { /* not ready */ }
await new Promise(r => setTimeout(r, interval));
}
throw new Error(`Server failed to start on port ${port} after ${maxRetries} attempts`);
}
export default async function globalSetup() {
// --- Start camofox server ---
const serverPort = Math.floor(3100 + Math.random() * 900);
const cfg = loadConfig();
const pluginDir = path.resolve(__dirname, '../..');
const log = {
info: (msg) => console.log(msg),
error: (msg) => console.error(msg),
};
const serverProcess = launchServer({
pluginDir,
port: serverPort,
env: { ...cfg.serverEnv, DEBUG_RESPONSES: 'false', DISPLAY: process.env.DISPLAY },
log,
});
serverProcess.on('error', (err) => {
console.error('Failed to start server:', err);
});
await waitForServer(serverPort);
console.log(`[globalSetup] camofox server on port ${serverPort}`);
// --- Start test site (express) ---
const { startTestSite, getTestSiteUrl } = await import('../helpers/testSite.js');
await startTestSite();
const testSiteUrl = getTestSiteUrl();
console.log(`[globalSetup] test site at ${testSiteUrl}`);
// Write env to temp file for test workers to read
fs.writeFileSync(ENV_FILE, JSON.stringify({
serverUrl: `http://localhost:${serverPort}`,
testSiteUrl,
serverPid: serverProcess.pid,
}));
// Store for globalTeardown (same process, globalThis persists)
globalThis.__CAMOFOX_SERVER_PROCESS__ = serverProcess;
globalThis.__CAMOFOX_ENV_FILE__ = ENV_FILE;
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Jest globalTeardown for e2e tests.
* Stops the shared camofox server + test site.
*/
import fs from 'fs';
export default async function globalTeardown() {
// Stop test site
try {
const { stopTestSite } = await import('../helpers/testSite.js');
await stopTestSite();
} catch (e) {
console.error('[globalTeardown] test site stop error:', e.message);
}
// Kill camofox server
const proc = globalThis.__CAMOFOX_SERVER_PROCESS__;
if (proc) {
await new Promise((resolve) => {
proc.on('close', resolve);
proc.kill('SIGTERM');
setTimeout(() => {
if (!proc.killed) proc.kill('SIGKILL');
}, 5000);
});
}
// Clean up temp file
const envFile = globalThis.__CAMOFOX_ENV_FILE__;
if (envFile) {
try { fs.unlinkSync(envFile); } catch (e) { /* ignore */ }
}
console.log('[globalTeardown] done');
}
+7 -13
View File
@@ -1,23 +1,17 @@
import { startServer, stopServer, getServerUrl } from '../helpers/startServer.js';
import { startTestSite, stopTestSite, getTestSiteUrl } from '../helpers/testSite.js';
import { createClient } from '../helpers/client.js';
import { getSharedEnv } from './sharedEnv.js';
describe('Macro Navigation', () => {
let serverUrl;
let testSiteUrl;
beforeAll(async () => {
const port = await startServer();
serverUrl = getServerUrl();
const testPort = await startTestSite();
testSiteUrl = getTestSiteUrl();
}, 120000);
beforeAll(() => {
const env = getSharedEnv();
serverUrl = env.serverUrl;
testSiteUrl = env.testSiteUrl;
});
afterAll(async () => {
await stopTestSite();
await stopServer();
}, 30000);
// Server lifecycle managed by globalSetup/globalTeardown
test('unknown macro returns error when no fallback URL', async () => {
const client = createClient(serverUrl);
+7 -13
View File
@@ -1,23 +1,17 @@
import { startServer, stopServer, getServerUrl } from '../helpers/startServer.js';
import { startTestSite, stopTestSite, getTestSiteUrl } from '../helpers/testSite.js';
import { createClient } from '../helpers/client.js';
import { getSharedEnv } from './sharedEnv.js';
describe('Navigation', () => {
let serverUrl;
let testSiteUrl;
beforeAll(async () => {
const port = await startServer();
serverUrl = getServerUrl();
const testPort = await startTestSite();
testSiteUrl = getTestSiteUrl();
}, 120000);
beforeAll(() => {
const env = getSharedEnv();
serverUrl = env.serverUrl;
testSiteUrl = env.testSiteUrl;
});
afterAll(async () => {
await stopTestSite();
await stopServer();
}, 30000);
// Server lifecycle managed by globalSetup/globalTeardown
test('navigate to URL', async () => {
const client = createClient(serverUrl);
+7 -13
View File
@@ -1,23 +1,17 @@
import { startServer, stopServer, getServerUrl } from '../helpers/startServer.js';
import { startTestSite, stopTestSite, getTestSiteUrl } from '../helpers/testSite.js';
import { createClient } from '../helpers/client.js';
import { getSharedEnv } from './sharedEnv.js';
describe('Screenshot', () => {
let serverUrl;
let testSiteUrl;
beforeAll(async () => {
const port = await startServer(9379);
serverUrl = getServerUrl();
beforeAll(() => {
const env = getSharedEnv();
serverUrl = env.serverUrl;
testSiteUrl = env.testSiteUrl;
});
const testPort = await startTestSite();
testSiteUrl = getTestSiteUrl();
}, 120000);
afterAll(async () => {
await stopTestSite();
await stopServer();
}, 30000);
// Server lifecycle managed by globalSetup/globalTeardown
test('screenshot returns raw PNG binary with correct magic bytes', async () => {
const client = createClient(serverUrl);
+7 -13
View File
@@ -1,23 +1,17 @@
import { startServer, stopServer, getServerUrl } from '../helpers/startServer.js';
import { startTestSite, stopTestSite, getTestSiteUrl } from '../helpers/testSite.js';
import { createClient } from '../helpers/client.js';
import { getSharedEnv } from './sharedEnv.js';
describe('Scroll', () => {
let serverUrl;
let testSiteUrl;
beforeAll(async () => {
const port = await startServer();
serverUrl = getServerUrl();
const testPort = await startTestSite();
testSiteUrl = getTestSiteUrl();
}, 120000);
beforeAll(() => {
const env = getSharedEnv();
serverUrl = env.serverUrl;
testSiteUrl = env.testSiteUrl;
});
afterAll(async () => {
await stopTestSite();
await stopServer();
}, 30000);
// Server lifecycle managed by globalSetup/globalTeardown
test('scroll down page', async () => {
const client = createClient(serverUrl);
+18
View File
@@ -0,0 +1,18 @@
/**
* Helper to read shared server URLs from globalSetup.
* Used by e2e test files instead of starting their own server.
*/
import fs from 'fs';
import path from 'path';
import os from 'os';
const ENV_FILE = path.join(os.tmpdir(), 'camofox-e2e-env.json');
let cached = null;
export function getSharedEnv() {
if (!cached) {
cached = JSON.parse(fs.readFileSync(ENV_FILE, 'utf-8'));
}
return cached;
}
+7 -12
View File
@@ -1,23 +1,18 @@
import { startServer, stopServer, getServerUrl } from '../helpers/startServer.js';
import { startTestSite, stopTestSite, getTestSiteUrl } from '../helpers/testSite.js';
import { createClient } from '../helpers/client.js';
import { MAX_SNAPSHOT_CHARS } from '../../lib/snapshot.js';
import { getSharedEnv } from './sharedEnv.js';
describe('Snapshot truncation (e2e)', () => {
let serverUrl;
let testSiteUrl;
beforeAll(async () => {
await startServer();
serverUrl = getServerUrl();
await startTestSite();
testSiteUrl = getTestSiteUrl();
}, 120000);
beforeAll(() => {
const env = getSharedEnv();
serverUrl = env.serverUrl;
testSiteUrl = env.testSiteUrl;
});
afterAll(async () => {
await stopTestSite();
await stopServer();
}, 30000);
// Server lifecycle managed by globalSetup/globalTeardown
test('small page snapshot is not truncated', async () => {
const client = createClient(serverUrl);
+7 -13
View File
@@ -1,23 +1,17 @@
import { startServer, stopServer, getServerUrl } from '../helpers/startServer.js';
import { startTestSite, stopTestSite, getTestSiteUrl } from '../helpers/testSite.js';
import { createClient } from '../helpers/client.js';
import { getSharedEnv } from './sharedEnv.js';
describe('Snapshot and Links', () => {
let serverUrl;
let testSiteUrl;
beforeAll(async () => {
const port = await startServer();
serverUrl = getServerUrl();
const testPort = await startTestSite();
testSiteUrl = getTestSiteUrl();
}, 120000);
beforeAll(() => {
const env = getSharedEnv();
serverUrl = env.serverUrl;
testSiteUrl = env.testSiteUrl;
});
afterAll(async () => {
await stopTestSite();
await stopServer();
}, 30000);
// Server lifecycle managed by globalSetup/globalTeardown
test('get snapshot returns page content', async () => {
const client = createClient(serverUrl);
+7 -13
View File
@@ -1,24 +1,18 @@
import { startServer, stopServer, getServerUrl } from '../helpers/startServer.js';
import { startTestSite, stopTestSite, getTestSiteUrl } from '../helpers/testSite.js';
import { createClient } from '../helpers/client.js';
import { PNG } from 'pngjs';
import { getSharedEnv } from './sharedEnv.js';
describe('Snapshot with includeScreenshot', () => {
let serverUrl;
let testSiteUrl;
beforeAll(async () => {
await startServer(9381);
serverUrl = getServerUrl();
beforeAll(() => {
const env = getSharedEnv();
serverUrl = env.serverUrl;
testSiteUrl = env.testSiteUrl;
});
await startTestSite();
testSiteUrl = getTestSiteUrl();
}, 120000);
afterAll(async () => {
await stopTestSite();
await stopServer();
}, 30000);
// Server lifecycle managed by globalSetup/globalTeardown
test('snapshot without includeScreenshot has no screenshot field', async () => {
const client = createClient(serverUrl);
+7 -13
View File
@@ -1,23 +1,17 @@
import { startServer, stopServer, getServerUrl } from '../helpers/startServer.js';
import { startTestSite, stopTestSite, getTestSiteUrl } from '../helpers/testSite.js';
import { createClient } from '../helpers/client.js';
import { getSharedEnv } from './sharedEnv.js';
describe('Tab Lifecycle', () => {
let serverUrl;
let testSiteUrl;
beforeAll(async () => {
const port = await startServer();
serverUrl = getServerUrl();
const testPort = await startTestSite();
testSiteUrl = getTestSiteUrl();
}, 120000);
beforeAll(() => {
const env = getSharedEnv();
serverUrl = env.serverUrl;
testSiteUrl = env.testSiteUrl;
});
afterAll(async () => {
await stopTestSite();
await stopServer();
}, 30000);
// Server lifecycle managed by globalSetup/globalTeardown
test('health check returns camoufox engine', async () => {
const client = createClient(serverUrl);
+7 -13
View File
@@ -1,23 +1,17 @@
import { startServer, stopServer, getServerUrl } from '../helpers/startServer.js';
import { startTestSite, stopTestSite, getTestSiteUrl } from '../helpers/testSite.js';
import { createClient } from '../helpers/client.js';
import { getSharedEnv } from './sharedEnv.js';
describe('Typing and Enter', () => {
let serverUrl;
let testSiteUrl;
beforeAll(async () => {
const port = await startServer();
serverUrl = getServerUrl();
const testPort = await startTestSite();
testSiteUrl = getTestSiteUrl();
}, 120000);
beforeAll(() => {
const env = getSharedEnv();
serverUrl = env.serverUrl;
testSiteUrl = env.testSiteUrl;
});
afterAll(async () => {
await stopTestSite();
await stopServer();
}, 30000);
// Server lifecycle managed by globalSetup/globalTeardown
test('type text into input field', async () => {
const client = createClient(serverUrl);
+14 -6
View File
@@ -59,15 +59,23 @@ class BrowserClient {
}
// Tab management
async createTab(url = null) {
async createTab(url = null, { retries = 2 } = {}) {
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);
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const result = await this.request('POST', '/tabs', body);
if (result.tabId) {
this.tabs.push(result.tabId);
}
return result;
} catch (err) {
const retriable = err.status === 500 && /closed|disposed|terminated/i.test(err.message);
if (!retriable || attempt === retries) throw err;
await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
}
}
return result;
}
async navigate(tabId, urlOrMacro) {
+202
View File
@@ -0,0 +1,202 @@
/**
* Tests for auto-generated OpenAPI spec.
*
* Verifies:
* 1. Every server.js route appears in the spec (no drift)
* 2. No stale routes in the spec that aren't in server.js
* 3. Spec structure is valid OpenAPI 3.0.x
* 4. info.version matches package.json
* 5. Every operation has responses and tags
* 6. Enriched routes have proper metadata
*/
import { readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import swaggerJsdoc from 'swagger-jsdoc';
import { swaggerDefinition } from '../../lib/openapi.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const serverPath = join(__dirname, '..', '..', 'server.js');
const serverSrc = readFileSync(serverPath, 'utf8');
const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8'));
// Build spec from JSDoc in server.js
const spec = swaggerJsdoc({
definition: swaggerDefinition,
apis: [serverPath],
});
/**
* Extract all app.get/post/delete routes from server.js source.
* Returns Set of "METHOD /path" strings (Express format with :params).
*/
function parseServerRoutes(source) {
const routes = new Set();
const re = /app\.(get|post|put|patch|delete)\(\s*['"`]([^'"`]+)['"`]/g;
let m;
while ((m = re.exec(source)) !== null) {
routes.add(`${m[1].toUpperCase()} ${m[2]}`);
}
return routes;
}
const serverRoutes = parseServerRoutes(serverSrc);
describe('OpenAPI spec', () => {
test('is valid OpenAPI 3.0.x shape', () => {
expect(spec.openapi).toMatch(/^3\.0\.\d+$/);
expect(spec.info).toBeDefined();
expect(spec.info.title).toBe('camofox-browser');
expect(spec.paths).toBeDefined();
expect(typeof spec.paths).toBe('object');
expect(spec.components).toBeDefined();
});
test('info.version matches package.json', () => {
expect(spec.info.version).toBe(pkg.version);
});
test('every server.js route appears in the spec', () => {
const missing = [];
for (const route of serverRoutes) {
const [method, expressPath] = route.split(' ');
const oaPath = expressPath.replace(/:(\w+)/g, '{$1}');
const pathObj = spec.paths[oaPath];
if (!pathObj || !pathObj[method.toLowerCase()]) {
missing.push(route);
}
}
expect(missing).toEqual([]);
});
test('no stale routes in spec that are not in server.js', () => {
const stale = [];
for (const [oaPath, methods] of Object.entries(spec.paths)) {
for (const method of Object.keys(methods)) {
if (method.startsWith('x-')) continue; // skip extensions
const expressPath = oaPath.replace(/\{(\w+)\}/g, ':$1');
const key = `${method.toUpperCase()} ${expressPath}`;
if (!serverRoutes.has(key)) {
stale.push(key);
}
}
}
expect(stale).toEqual([]);
});
test('spec covers at least 30 routes from server.js', () => {
expect(serverRoutes.size).toBeGreaterThanOrEqual(30);
let covered = 0;
for (const route of serverRoutes) {
const [method, expressPath] = route.split(' ');
const oaPath = expressPath.replace(/:(\w+)/g, '{$1}');
if (spec.paths[oaPath]?.[method.toLowerCase()]) covered++;
}
expect(covered).toBe(serverRoutes.size);
});
test('every operation has at least one response', () => {
const noResponses = [];
for (const [path, methods] of Object.entries(spec.paths)) {
for (const [method, op] of Object.entries(methods)) {
if (method.startsWith('x-')) continue;
if (!op.responses || Object.keys(op.responses).length === 0) {
noResponses.push(`${method.toUpperCase()} ${path}`);
}
}
}
expect(noResponses).toEqual([]);
});
test('every operation has at least one tag', () => {
for (const [path, methods] of Object.entries(spec.paths)) {
for (const [method, op] of Object.entries(methods)) {
if (method.startsWith('x-')) continue;
expect(op.tags?.length).toBeGreaterThanOrEqual(1);
}
}
});
test('parameterized routes have path parameters', () => {
const navOp = spec.paths['/tabs/{tabId}/navigate']?.post;
expect(navOp).toBeDefined();
const tabIdParam = navOp.parameters?.find(p => p.name === 'tabId' && p.in === 'path');
expect(tabIdParam).toBeDefined();
expect(tabIdParam.required).toBe(true);
expect(tabIdParam.schema.type).toBe('string');
});
test('POST /tabs has request body and proper tag', () => {
const createTab = spec.paths['/tabs']?.post;
expect(createTab).toBeDefined();
expect(createTab.summary).toBe('Create a new tab');
expect(createTab.tags).toContain('Tabs');
expect(createTab.requestBody).toBeDefined();
expect(createTab.requestBody.content['application/json']).toBeDefined();
});
test('legacy routes are marked deprecated', () => {
const legacyPaths = {
'/act': 'post',
'/navigate': 'post',
'/snapshot': 'get',
'/tabs/open': 'post',
};
for (const [path, method] of Object.entries(legacyPaths)) {
const op = spec.paths[path]?.[method];
expect(op).toBeDefined();
expect(op.deprecated).toBe(true);
}
});
test('Error schema is defined in components', () => {
expect(spec.components.schemas.Error).toBeDefined();
expect(spec.components.schemas.Error.required).toContain('error');
});
test('tags include well-known categories', () => {
const tagNames = spec.tags.map(t => t.name);
for (const expected of ['System', 'Tabs', 'Navigation', 'Interaction', 'Content', 'Sessions', 'Legacy', 'Browser']) {
expect(tagNames).toContain(expected);
}
});
test('security scheme BearerAuth is defined', () => {
expect(spec.components.securitySchemes.BearerAuth).toBeDefined();
expect(spec.components.securitySchemes.BearerAuth.type).toBe('http');
expect(spec.components.securitySchemes.BearerAuth.scheme).toBe('bearer');
});
test('cookie import route has security requirement', () => {
const op = spec.paths['/sessions/{userId}/cookies']?.post;
expect(op).toBeDefined();
expect(op.security).toEqual([{ BearerAuth: [] }]);
});
test('$ref references resolve to existing component schemas', () => {
const schemaNames = Object.keys(spec.components?.schemas || {});
const refs = [];
JSON.stringify(spec, (key, val) => {
if (key === '$ref' && typeof val === 'string') refs.push(val);
return val;
});
const unresolved = refs.filter(ref => {
const match = ref.match(/^#\/components\/schemas\/(.+)$/);
return match && !schemaNames.includes(match[1]);
});
expect(unresolved).toEqual([]);
});
test('openapi.json in repo root is up to date', () => {
let committed;
try {
committed = JSON.parse(readFileSync(join(__dirname, '..', '..', 'openapi.json'), 'utf8'));
} catch {
throw new Error('openapi.json not found — run: npm run generate-openapi');
}
expect(committed).toEqual(spec);
});
});