mirror of
https://github.com/jo-inc/camofox-browser.git
synced 2026-10-02 04:14:41 +08:00
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:
+60
-10
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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">"<$1>"</span>');
|
||||
curl += ` \\\n -d '${body}'`;
|
||||
}
|
||||
return curl;
|
||||
}
|
||||
|
||||
function buildResponseExample(op) {
|
||||
const resp200 = op.responses?.['200'];
|
||||
if (!resp200) return null;
|
||||
const schema = resp200.content?.['application/json']?.schema;
|
||||
if (!schema?.properties) return null;
|
||||
|
||||
const obj = {};
|
||||
for (const [k, v] of Object.entries(schema.properties)) {
|
||||
if (v.type === 'string') obj[k] = v.example || '';
|
||||
else if (v.type === 'boolean') obj[k] = true;
|
||||
else if (v.type === 'integer' || v.type === 'number') obj[k] = 0;
|
||||
else if (v.type === 'array') obj[k] = [];
|
||||
else if (v.type === 'object' && v.properties) {
|
||||
const inner = {};
|
||||
for (const [ik, iv] of Object.entries(v.properties)) {
|
||||
if (iv.type === 'string') inner[ik] = iv.example || '';
|
||||
else if (iv.type === 'boolean') inner[ik] = true;
|
||||
else inner[ik] = null;
|
||||
}
|
||||
obj[k] = inner;
|
||||
} else obj[k] = null;
|
||||
}
|
||||
|
||||
return JSON.stringify(obj, null, 2)
|
||||
.replace(/"([^"]+)":/g, '<span class="key">"$1"</span>:')
|
||||
.replace(/: "([^"]*)"/g, ': <span class="str">"$1"</span>');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Main render
|
||||
// ============================================================
|
||||
async function render() {
|
||||
const res = await fetch(conf.specUrl);
|
||||
const spec = await res.json();
|
||||
|
||||
const title = conf.title || spec.info?.title || 'API';
|
||||
document.title = `${title} — ${conf.subtitle}`;
|
||||
document.getElementById('page-title').textContent = document.title;
|
||||
|
||||
// Header
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||
document.getElementById('header').innerHTML = `
|
||||
<button class="menu-toggle" onclick="toggleSidebar()" aria-label="Menu">☰</button>
|
||||
${conf.logoUrl ? `<img src="${esc(conf.logoUrl)}" alt="">` : ''}
|
||||
<span class="title">${esc(title)}</span>
|
||||
<span class="subtitle">${esc(conf.subtitle)}</span>
|
||||
<div class="header-right">
|
||||
<span class="version">v${esc(spec.info?.version || '?')}</span>
|
||||
<button class="theme-toggle" id="theme-btn"
|
||||
onclick="setTheme(document.documentElement.getAttribute('data-theme')==='dark'?'light':'dark')"
|
||||
aria-label="Toggle theme">${currentTheme === 'dark' ? '☀️' : '🌙'}</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Group operations by tag
|
||||
const tagMap = new Map();
|
||||
const tagOrder = (spec.tags || []).map(t => t.name);
|
||||
|
||||
for (const [path, methods] of Object.entries(spec.paths || {})) {
|
||||
for (const [method, op] of Object.entries(methods)) {
|
||||
if (method.startsWith('x-')) continue;
|
||||
const tag = op.tags?.[0] || 'Other';
|
||||
if (!tagMap.has(tag)) tagMap.set(tag, []);
|
||||
tagMap.get(tag).push({ method, path, op });
|
||||
}
|
||||
}
|
||||
|
||||
const sortedTags = [...tagMap.keys()].sort((a, b) => {
|
||||
const ai = tagOrder.indexOf(a), bi = tagOrder.indexOf(b);
|
||||
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
|
||||
});
|
||||
|
||||
// Sidebar
|
||||
let sidebarHtml = '';
|
||||
for (const tag of sortedTags) {
|
||||
sidebarHtml += `<div class="tag-group"><span class="tag-name">${esc(tag)}</span>`;
|
||||
for (const { method, path, op } of tagMap.get(tag)) {
|
||||
const id = opId(method, path);
|
||||
const label = op.summary || path;
|
||||
sidebarHtml += `<a class="nav-item" href="#${id}" data-id="${id}" onclick="closeSidebar()">
|
||||
<span class="method-badge ${method}">${method}</span>
|
||||
<span>${esc(label)}</span>
|
||||
</a>`;
|
||||
}
|
||||
sidebarHtml += '</div>';
|
||||
}
|
||||
document.getElementById('sidebar').innerHTML = sidebarHtml;
|
||||
|
||||
// Main content
|
||||
let mainHtml = '';
|
||||
|
||||
if (spec.info?.description) {
|
||||
const base = spec.servers?.[0]?.url || '';
|
||||
mainHtml += `<div class="intro-section">
|
||||
<h2>${esc(conf.subtitle)}</h2>
|
||||
<p>${esc(spec.info.description)}</p>
|
||||
${base ? `<div class="base-url">${esc(base)}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
for (const tag of sortedTags) {
|
||||
for (const { method, path, op } of tagMap.get(tag)) {
|
||||
const id = opId(method, path);
|
||||
const allParams = op.parameters || [];
|
||||
const pathParams = allParams.filter(p => p.in === 'path');
|
||||
const queryParams = allParams.filter(p => p.in === 'query');
|
||||
const bodySchema = op.requestBody?.content?.['application/json']?.schema;
|
||||
const curl = buildCurlExample(method, path, op, spec.servers);
|
||||
const respExample = buildResponseExample(op);
|
||||
|
||||
mainHtml += `<div class="endpoint" id="${id}">
|
||||
<div class="desc-panel">
|
||||
<div class="endpoint-title">
|
||||
<span class="method ${method}">${method.toUpperCase()}</span>
|
||||
<span class="path">${highlightPath(path)}</span>
|
||||
${op.deprecated ? '<span class="deprecated-badge">Deprecated</span>' : ''}
|
||||
</div>
|
||||
<div class="summary">${esc(op.summary || '')}</div>
|
||||
${op.description ? `<div class="description">${esc(op.description)}</div>` : ''}`;
|
||||
|
||||
if (pathParams.length) {
|
||||
mainHtml += `<div class="params-section"><h3>Path Parameters</h3>`;
|
||||
for (const p of pathParams) {
|
||||
mainHtml += `<div class="param-row">
|
||||
<div class="param-name">${esc(p.name)}${p.required ? '<span class="required">required</span>' : ''}
|
||||
<span class="in-badge">${esc(p.in)}</span></div>
|
||||
<div class="param-desc">
|
||||
<div class="param-type">${esc(p.schema?.type || 'string')}</div>
|
||||
${p.description ? esc(p.description) : ''}</div>
|
||||
</div>`;
|
||||
}
|
||||
mainHtml += '</div>';
|
||||
}
|
||||
|
||||
if (queryParams.length) {
|
||||
mainHtml += `<div class="params-section"><h3>Query Parameters</h3>`;
|
||||
for (const p of queryParams) {
|
||||
mainHtml += `<div class="param-row">
|
||||
<div class="param-name">${esc(p.name)}${p.required ? '<span class="required">required</span>' : ''}
|
||||
<span class="in-badge">${esc(p.in)}</span></div>
|
||||
<div class="param-desc">
|
||||
<div class="param-type">${esc(p.schema?.type || 'string')}${p.schema?.enum ? ` — ${p.schema.enum.join(', ')}` : ''}</div>
|
||||
${p.description ? esc(p.description) : ''}</div>
|
||||
</div>`;
|
||||
}
|
||||
mainHtml += '</div>';
|
||||
}
|
||||
|
||||
if (bodySchema) {
|
||||
mainHtml += `<div class="params-section"><h3>Request Body</h3>
|
||||
<div class="schema-tree">${renderSchemaProps(bodySchema)}</div></div>`;
|
||||
}
|
||||
|
||||
if (op.responses) {
|
||||
mainHtml += `<div class="responses-section"><h3>Responses</h3>`;
|
||||
for (const [code, resp] of Object.entries(op.responses)) {
|
||||
if (code.startsWith('x-')) continue;
|
||||
const desc = resp.description || resp.$ref || '';
|
||||
mainHtml += `<div class="response-row">
|
||||
<span class="status-code ${statusClass(code)}">${esc(code)}</span>
|
||||
<span class="response-desc">${esc(desc)}</span>
|
||||
</div>`;
|
||||
}
|
||||
mainHtml += '</div>';
|
||||
}
|
||||
|
||||
mainHtml += `</div>`; // close desc-panel
|
||||
|
||||
mainHtml += `<div class="code-panel">
|
||||
<div class="code-block">
|
||||
<button class="copy-btn" onclick="copyCode(this)">Copy</button>
|
||||
<div class="label">Request</div>
|
||||
<pre>${curl}</pre>
|
||||
</div>`;
|
||||
if (respExample) {
|
||||
mainHtml += `<div class="code-block">
|
||||
<button class="copy-btn" onclick="copyCode(this)">Copy</button>
|
||||
<div class="label">Response</div>
|
||||
<pre>${respExample}</pre>
|
||||
</div>`;
|
||||
}
|
||||
mainHtml += `</div></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('main').innerHTML = mainHtml;
|
||||
|
||||
// Active nav tracking via IntersectionObserver
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
|
||||
const link = document.querySelector(`.nav-item[data-id="${entry.target.id}"]`);
|
||||
if (link) {
|
||||
link.classList.add('active');
|
||||
link.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}, { rootMargin: '-80px 0px -60% 0px', threshold: 0 });
|
||||
|
||||
document.querySelectorAll('.endpoint[id]').forEach(el => observer.observe(el));
|
||||
|
||||
// Close sidebar on overlay click
|
||||
document.getElementById('sidebar-overlay').addEventListener('click', closeSidebar);
|
||||
}
|
||||
|
||||
render().catch(err => {
|
||||
document.getElementById('main').innerHTML =
|
||||
`<div class="intro-section"><h2>Error</h2><p>${esc(err.message)}</p></div>`;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
+2209
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Generated
+245
-1
@@ -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
@@ -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",
|
||||
|
||||
@@ -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`);
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user