diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 00000000..7866a898 --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,90 @@ +# HTTP API Reference + +Codeman's HTTP API is a **stable contract** as of 1.0 — see +[`versioning-policy.md`](versioning-policy.md) for the SemVer guarantee. This page +defines the response envelope, status codes, error codes, versioning, and the SSE +event channel. + +## Versioning + +- The stable, public surface is served under **`/api/v1/...`**. Pin external + clients to this prefix. +- The unversioned **`/api/...`** paths are a permanent alias of the current + version (what the bundled web UI uses). They are kept working, but new external + integrations should use `/api/v1`. +- Breaking changes to the contract ship under a new prefix (`/api/v2`); `/api/v1` + keeps its semantics. Additive changes (new endpoints, new optional fields, new + error codes) are non-breaking and may appear in a minor release. +- The implementation rewrites `/api/v1/*` → `/api/*` at the server level + (`rewriteApiV1Url` in `src/web/server.ts`). + +## Response envelope + +Every JSON response uses one uniform envelope, applied centrally by a +`preSerialization` hook (`src/web/server.ts`) — handlers return bare data and the +hook wraps it: + +**Success** — HTTP `2xx`: + +```json +{ "success": true, "data": } +``` + +`data` is the endpoint's payload (object, array, or value). Endpoints with no +payload return `{ "success": true, "data": {} }`. + +**Error** — HTTP `4xx`/`5xx`: + +```json +{ "success": false, "error": "human-readable message", "errorCode": "NOT_FOUND" } +``` + +`ApiResponse` in `src/types/api.ts` is the canonical type. + +> Non-JSON endpoints are exempt from the envelope: `GET /api/sessions/:id/file-raw`, +> `GET /api/sessions/:id/tail-file` (SSE), `GET /api/download`, +> `GET /api/screenshots/:name`, `GET /q/:code` (QR redirect), and the +> `GET /ws/sessions/:id/terminal` WebSocket upgrade. + +## Error codes → HTTP status + +The single source of truth is `ErrorStatus` / `httpStatusForErrorCode()` in +`src/types/api.ts`. Clients should branch on `errorCode` (stable) and may rely on +the HTTP status. + +| `errorCode` | HTTP | Meaning | +|-------------|------|---------| +| `INVALID_INPUT` | 400 | Malformed request / failed validation | +| `UNAUTHORIZED` | 401 | Authentication required or failed | +| `NOT_FOUND` | 404 | Resource does not exist | +| `SESSION_BUSY` | 409 | Session is busy | +| `CONFLICT` | 409 | Conflicts with current state (e.g. already running) | +| `ALREADY_EXISTS` | 409 | Resource already exists | +| `OPERATION_FAILED` | 422 | Well-formed but could not be completed | +| `RATE_LIMITED` | 429 | Too many requests | +| `INTERNAL_ERROR` | 500 | Unexpected server error | + +Adding a new error code is non-breaking; removing or renaming one is a major change. + +## Authentication + +Optional HTTP Basic (`CODEMAN_USERNAME`/`CODEMAN_PASSWORD`) → opaque +`codeman_session` cookie. When enabled, unauthenticated requests get +`401 UNAUTHORIZED`; rate-limited requests get `429 RATE_LIMITED`. See +[`security-architecture.md`](security-architecture.md). + +## SSE event channel + +`GET /api/events` is a Server-Sent Events stream (`text/event-stream`); each +message is `event: ` + `data: `. The event-name registry +(`src/web/sse-events.ts`, mirrored in `src/web/public/constants.js`) is part of +the stable contract — event names are not renamed without a major bump. An +optional `?sessions=` filter suppresses only the high-volume terminal +stream; lifecycle/metadata events are delivered to all clients regardless. + +## Consuming from JavaScript + +The bundled frontend reads responses through `_apiJson()` +(`src/web/public/api-client.js`), which unwraps `{success:true,data}` → `data` and +returns `null` on a non-2xx / `{success:false}` response. External clients should +do the same: check the HTTP status (or `body.success`), then read `body.data`. diff --git a/docs/versioning-policy.md b/docs/versioning-policy.md index 14269891..01a72d49 100644 --- a/docs/versioning-policy.md +++ b/docs/versioning-policy.md @@ -8,9 +8,9 @@ surfaces are covered by SemVer and which are explicitly not. It exists because "1.0" is a commitment to stability, and an undocumented public surface invites incompatible client assumptions we would then be pressured to keep. -> **Status:** draft for the 1.0 cut. The central decision below — that the HTTP/SSE -> API is *not* SemVer-covered — should be confirmed by the maintainer before 1.0, -> since it determines whether a number of in-flight cleanups are "breaking." +> **Status:** finalized for the 1.0 cut. The HTTP/SSE API **is** part of the stable +> surface — served under `/api/v1` with a uniform response envelope and +> conventional HTTP status codes. See [`api-reference.md`](api-reference.md). ## What SemVer covers (the public, stable surface) @@ -19,9 +19,10 @@ A **MAJOR** bump is required to break any of these after 1.0: 1. **The CLI.** Command names, documented flags, and their behavior for `codeman ` (published to npm as `aicodeman`; invoked as `codeman`). This is the package's actual public entry point (`bin`). - - *Note:* the npm package name vs. invoked command name (`aicodeman` vs - `codeman`) is a known inconsistency to resolve **before** 1.0 — renaming - either after 1.0 is itself a breaking change. + - The package is published to npm as `aicodeman` and installs **both** the + `aicodeman` and `codeman` commands (`bin` aliases); `codeman` is the + canonical command used throughout the docs. Renaming either after 1.0 is a + breaking change. 2. **The published `xterm-zerolag-input` library**, but on **its own version line** — it is versioned and released independently of the Codeman app. Its 1.0 status is a separate decision; the Codeman app reaching 1.0 does *not* @@ -31,26 +32,26 @@ A **MAJOR** bump is required to break any of these after 1.0: `CODEMAN_INSTANCE`, `CODEMAN_ALLOWED_HOSTS`, `CODEMAN_DATA_DIR`, `CODEMAN_TMUX_SOCKET`, and the `--host` / `--port` / `--https` CLI flags. Removing or changing the meaning of one of these is breaking. +4. **The HTTP API and SSE event channel**, served under **`/api/v1`** with the + uniform `{success:true,data}` / `{success:false,error,errorCode}` envelope and + conventional HTTP status codes. Endpoint paths, the response envelope, error + `errorCode` values, and SSE event names are stable — see + [`api-reference.md`](api-reference.md). *Additive* changes (new endpoints, new + optional fields, new error codes, new SSE events) are non-breaking; breaking + changes ship under a new prefix (`/api/v2`). The unversioned `/api/...` alias + is kept working for the bundled UI. ## What SemVer does NOT cover (internal surfaces — may change in any release) These may change in a **MINOR** (or even PATCH) release without a MAJOR bump: -1. **The HTTP API and SSE event registry** (`/api/...`, the ~120 SSE event - types). This is an **internal protocol between the server and its own bundled - web frontend**, not a published client API. There is no OpenAPI spec, no - published client, and no versioned `/api/v1` namespace. If you script against - these endpoints, **pin to an exact Codeman version** — they can change between - minors (response shapes, status codes, event names). Standardizing the error - envelope and HTTP status codes is explicitly reserved as a non-breaking - internal change under this policy. -2. **The `~/.codeman/` state file formats** (`state.json`, `settings.json`, +1. **The `~/.codeman/` state file formats** (`state.json`, `settings.json`, `mux-sessions.json`, etc.). We make a **best-effort** to migrate existing data forward (and have done so across renames), but the on-disk schema is not a stable contract — do not write tooling that depends on its exact shape. -3. **Internal TypeScript modules.** The npm package is CLI-only; `import`ing it +2. **Internal TypeScript modules.** The npm package is CLI-only; `import`ing it programmatically is not supported (there is no stable library entry point). -4. **Experimental / opt-in features**, regardless of the app's version: +3. **Experimental / opt-in features**, regardless of the app's version: Gesture Control (beta), Agent Teams (`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`), and anything labeled experimental in the UI or docs. These may change or be removed at any time. diff --git a/src/types/api.ts b/src/types/api.ts index 6961b315..9c98d2d3 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -25,12 +25,18 @@ export enum ApiErrorCode { NOT_FOUND = 'NOT_FOUND', /** Invalid input provided */ INVALID_INPUT = 'INVALID_INPUT', + /** Authentication required or failed */ + UNAUTHORIZED = 'UNAUTHORIZED', /** Session is currently busy */ SESSION_BUSY = 'SESSION_BUSY', - /** Operation failed */ - OPERATION_FAILED = 'OPERATION_FAILED', + /** Request conflicts with current state (e.g. already running) */ + CONFLICT = 'CONFLICT', /** Resource already exists */ ALREADY_EXISTS = 'ALREADY_EXISTS', + /** Too many requests / rate limited */ + RATE_LIMITED = 'RATE_LIMITED', + /** Operation could not be completed (well-formed but unprocessable) */ + OPERATION_FAILED = 'OPERATION_FAILED', /** Internal server error */ INTERNAL_ERROR = 'INTERNAL_ERROR', } @@ -41,12 +47,37 @@ export enum ApiErrorCode { const ErrorMessages: Record = { [ApiErrorCode.NOT_FOUND]: 'The requested resource was not found', [ApiErrorCode.INVALID_INPUT]: 'Invalid input provided', + [ApiErrorCode.UNAUTHORIZED]: 'Authentication required', [ApiErrorCode.SESSION_BUSY]: 'Session is currently busy', - [ApiErrorCode.OPERATION_FAILED]: 'The operation failed', + [ApiErrorCode.CONFLICT]: 'Request conflicts with the current state', [ApiErrorCode.ALREADY_EXISTS]: 'Resource already exists', + [ApiErrorCode.RATE_LIMITED]: 'Too many requests', + [ApiErrorCode.OPERATION_FAILED]: 'The operation failed', [ApiErrorCode.INTERNAL_ERROR]: 'An internal error occurred', }; +/** + * Maps each API error code to its HTTP status. Single source of truth for the + * stable HTTP contract (see docs/api-reference.md). Applied centrally so every + * error response carries a conventional 4xx/5xx status, not 200. + */ +const ErrorStatus: Record = { + [ApiErrorCode.INVALID_INPUT]: 400, + [ApiErrorCode.UNAUTHORIZED]: 401, + [ApiErrorCode.NOT_FOUND]: 404, + [ApiErrorCode.SESSION_BUSY]: 409, + [ApiErrorCode.CONFLICT]: 409, + [ApiErrorCode.ALREADY_EXISTS]: 409, + [ApiErrorCode.OPERATION_FAILED]: 422, + [ApiErrorCode.RATE_LIMITED]: 429, + [ApiErrorCode.INTERNAL_ERROR]: 500, +}; + +/** HTTP status for an API error code (defaults to 400 for unknown codes). */ +export function httpStatusForErrorCode(code: ApiErrorCode): number { + return ErrorStatus[code] ?? 400; +} + /** * Hook event types triggered by Claude Code's hooks system */ diff --git a/src/web/public/api-client.js b/src/web/public/api-client.js index e220a170..fea0d2b5 100644 --- a/src/web/public/api-client.js +++ b/src/web/public/api-client.js @@ -44,11 +44,20 @@ Object.assign(CodemanApp.prototype, { async _apiJson(path, opts = {}) { const res = await this._api(path, opts); if (!res || !res.ok) return null; + let body; try { - return await res.json(); + body = await res.json(); } catch { return null; } + // Uniform API envelope (stable HTTP contract): unwrap { success:true, data } → data; + // { success:false } → null (errors also surface as a non-ok HTTP status above). + // Legacy/bare bodies pass through unchanged. + if (body && typeof body === 'object') { + if (body.success === false) return null; + if (body.success === true && 'data' in body) return body.data; + } + return body; }, /** diff --git a/src/web/public/app.js b/src/web/public/app.js index 03b2d218..19e21371 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -597,7 +597,7 @@ class CodemanApp { // Fetch tunnel status for header indicator (desktop only) this.loadTunnelStatus(); // Share a single settings fetch between both consumers - const settingsPromise = fetch('/api/settings').then(r => r.ok ? r.json() : null).catch(() => null); + const settingsPromise = fetch('/api/settings').then(r => r.ok ? r.json() : null).then(env => env?.data ?? null).catch(() => null); this.loadQuickStartCases(null, settingsPromise); this._initRunMode(); this.setupEventListeners(); @@ -1531,13 +1531,13 @@ class CodemanApp { try { // Source 1: Transcript JSONL (best quality — clean structured text from Claude) const res = await fetch(`/api/sessions/${this.activeSessionId}/last-response`); - const data = await res.json(); + const data = (await res.json())?.data ?? {}; let lastResponse = data.text || ''; // Source 2: Terminal buffer fallback — strip ANSI, drop Claude CLI chrome if (!lastResponse) { const termRes = await fetch(`/api/sessions/${this.activeSessionId}/terminal`); - const termData = await termRes.json(); + const termData = (await termRes.json())?.data ?? {}; if (termData.terminalBuffer) { lastResponse = this._cleanTerminalBuffer(termData.terminalBuffer); } @@ -1567,7 +1567,7 @@ class CodemanApp { if (moreBtn) moreBtn.textContent = '...'; try { const res = await fetch(`/api/sessions/${this.activeSessionId}/last-response?context=full`); - const data = await res.json(); + const data = (await res.json())?.data ?? {}; const messages = data.messages || []; const body = document.getElementById('responseViewerBody'); const title = document.getElementById('responseViewerTitle'); @@ -1618,7 +1618,7 @@ class CodemanApp { if (this._isLoadingBuffer) return; try { const res = await fetch(`/api/sessions/${this.activeSessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`); - const data = await res.json(); + const data = (await res.json())?.data ?? {}; if (data.terminalBuffer) { this.terminal.clear(); this.terminal.reset(); @@ -1648,7 +1648,7 @@ class CodemanApp { // Fetch buffer, clear terminal, write buffer, resize (no Ctrl+L needed) try { const res = await fetch(`/api/sessions/${data.id}/terminal`); - const termData = await res.json(); + const termData = (await res.json())?.data ?? {}; this.terminal.clear(); this.terminal.reset(); @@ -2254,7 +2254,7 @@ class CodemanApp { try { const res = await fetch('/api/status'); const data = await res.json(); - this.handleInit(data); + this.handleInit(data?.data ?? {}); } catch (err) { console.error('Failed to load state:', err); } @@ -2938,7 +2938,7 @@ class CodemanApp { _crashDiag.log('FETCH_START'); const res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`); if (this._isStaleSelect(selectGen)) return; - const data = await res.json(); + const data = (await res.json())?.data ?? {}; _crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`); if (data.terminalBuffer) { diff --git a/src/web/public/image-input.js b/src/web/public/image-input.js index c50a6667..0402d62d 100644 --- a/src/web/public/image-input.js +++ b/src/web/public/image-input.js @@ -149,7 +149,7 @@ Object.assign(CodemanApp.prototype, { } const data = await resp.json(); - return data.path; + return data.data.path; }, // Decode an image File through the browser and re-encode it to a format the diff --git a/src/web/public/orchestrator-panel.js b/src/web/public/orchestrator-panel.js index c7f2f4b9..ddbd3e48 100644 --- a/src/web/public/orchestrator-panel.js +++ b/src/web/public/orchestrator-panel.js @@ -182,7 +182,7 @@ Object.assign(CodemanApp.prototype, { body: JSON.stringify({ goal, config }), }); const data = await res.json(); - if (data.ok) { + if (data.data?.ok) { this.orchestratorState = { state: 'planning', plan: null }; this.showOrchestratorPanel(); this.renderOrchestratorPanel(); @@ -259,8 +259,8 @@ Object.assign(CodemanApp.prototype, { try { const res = await fetch('/api/orchestrator/status'); const data = await res.json(); - if (data.ok) { - this.orchestratorState = data; + if (data.data?.ok) { + this.orchestratorState = data.data; this.renderOrchestratorPanel(); } } catch (err) { diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 440f5101..499bcf7f 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -251,7 +251,7 @@ Object.assign(CodemanApp.prototype, { const response = await fetch('/api/token-stats'); const data = await response.json(); if (data.success) { - this.renderTokenStats(data); + this.renderTokenStats(data.data); document.getElementById('tokenStatsModal').classList.add('active'); } else { this.showToast('Failed to load token stats', 'error'); @@ -2881,7 +2881,7 @@ Object.assign(CodemanApp.prototype, { try { const res = await fetch('/api/mux-sessions'); const data = await res.json(); - this.muxSessions = data.sessions || []; + this.muxSessions = data.data?.sessions || []; this.renderMuxSessions(); } catch (err) { console.error('Failed to load mux sessions:', err); @@ -3109,8 +3109,8 @@ Object.assign(CodemanApp.prototype, { const res = await fetch('/api/mux-sessions/reconcile', { method: 'POST' }); const data = await res.json(); - if (data.dead && data.dead.length > 0) { - this.showToast(`Found ${data.dead.length} dead mux session(s)`, 'warning'); + if (data.data?.dead && data.data.dead.length > 0) { + this.showToast(`Found ${data.data.dead.length} dead mux session(s)`, 'warning'); await this.loadMuxSessions(); } else { this.showToast('All mux sessions are alive', 'success'); @@ -3220,7 +3220,7 @@ Object.assign(CodemanApp.prototype, { try { const res = await fetch('/api/system/stats'); const stats = await res.json(); - this.updateSystemStatsDisplay(stats); + this.updateSystemStatsDisplay(stats.data); } catch (err) { // Silently fail - system stats are not critical } diff --git a/src/web/public/ralph-panel.js b/src/web/public/ralph-panel.js index dbd74afa..25898f4f 100644 --- a/src/web/public/ralph-panel.js +++ b/src/web/public/ralph-panel.js @@ -996,14 +996,14 @@ Object.assign(CodemanApp.prototype, { return; } - const history = data.history || []; + const history = data.data.history || []; if (history.length === 0) { this.showToast('No plan history available', 'info'); return; } // Show history dropdown modal - this.showPlanHistoryModal(history, data.currentVersion); + this.showPlanHistoryModal(history, data.data.currentVersion); } catch (err) { this.showToast('Failed to load plan history: ' + err.message, 'error'); } diff --git a/src/web/public/ralph-wizard.js b/src/web/public/ralph-wizard.js index b9bc6501..9e81420d 100644 --- a/src/web/public/ralph-wizard.js +++ b/src/web/public/ralph-wizard.js @@ -151,11 +151,11 @@ Object.assign(CodemanApp.prototype, { const res = await fetch(`/api/cases/${encodeURIComponent(caseName)}/fix-plan`); const data = await res.json(); - if (data.success && data.exists && data.todos?.length > 0) { + if (data.success && data.data.exists && data.data.todos?.length > 0) { this.ralphWizardConfig.existingPlan = { - todos: data.todos, - stats: data.stats, - content: data.content, + todos: data.data.todos, + stats: data.data.stats, + content: data.data.content, }; this.updateExistingPlanUI(); } else { @@ -1054,8 +1054,8 @@ Object.assign(CodemanApp.prototype, { this.showToast(data.error || 'Failed to start', 'error'); return; } - this.ralphClosedSessions.delete(data.sessionId); - await this.selectSession(data.sessionId); + this.ralphClosedSessions.delete(data.data.sessionId); + await this.selectSession(data.data.sessionId); this.showToast(`Ralph Loop started in ${config.caseName}`, 'success'); } catch (err) { console.error('Failed to start Ralph loop:', err); diff --git a/src/web/public/respawn-ui.js b/src/web/public/respawn-ui.js index 8e605742..fbdb9fcd 100644 --- a/src/web/public/respawn-ui.js +++ b/src/web/public/respawn-ui.js @@ -1041,7 +1041,7 @@ Object.assign(CodemanApp.prototype, { return; } - this.runSummaryData = data.summary; + this.runSummaryData = data.data.summary; this.renderRunSummary(); } catch (err) { console.error('Failed to load run summary:', err); diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 33606a75..d4200a39 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -50,15 +50,15 @@ Object.assign(CodemanApp.prototype, { let lastUsedCase = null; try { const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null); - if (settings) { - lastUsedCase = settings.lastUsedCase || null; + if (settings && settings.data) { + lastUsedCase = settings.data.lastUsedCase || null; } } catch { // Ignore settings load errors } const res = await fetch('/api/cases'); - const cases = await res.json(); + const cases = (await res.json()).data; this.cases = cases; console.log('[loadQuickStartCases] Loaded cases:', cases.map(c => c.name), 'lastUsedCase:', lastUsedCase); @@ -125,7 +125,7 @@ Object.assign(CodemanApp.prototype, { async updateDirDisplayForCase(caseName) { try { const res = await fetch(`/api/cases/${caseName}`); - const data = await res.json(); + const data = (await res.json()).data; if (data.path) { document.getElementById('dirDisplay').textContent = data.path; document.getElementById('dirInput').value = data.path; @@ -304,7 +304,7 @@ Object.assign(CodemanApp.prototype, { try { // Get case path first const caseRes = await fetch(`/api/cases/${caseName}`); - let caseData = await caseRes.json(); + let caseData = (await caseRes.json()).data; // Create the case if it doesn't exist if (!caseData.path) { @@ -373,7 +373,7 @@ Object.assign(CodemanApp.prototype, { const sessionIds = []; for (const result of createResults) { if (!result.success) throw new Error(result.error); - sessionIds.push(result.session.id); + sessionIds.push(result.data.session.id); } firstSessionId = sessionIds[0]; @@ -452,7 +452,7 @@ Object.assign(CodemanApp.prototype, { try { // Get the case path const caseRes = await fetch(`/api/cases/${caseName}`); - let caseData = await caseRes.json(); + let caseData = (await caseRes.json()).data; // Create the case if it doesn't exist if (!caseData.path) { @@ -501,7 +501,7 @@ Object.assign(CodemanApp.prototype, { const sessionIds = []; for (const result of createResults) { if (!result.success) throw new Error(result.error); - sessionIds.push(result.session.id); + sessionIds.push(result.data.session.id); } // Step 2: Start all shells in parallel @@ -545,7 +545,7 @@ Object.assign(CodemanApp.prototype, { try { // Check if OpenCode is available const statusRes = await fetch('/api/opencode/status'); - const status = await statusRes.json(); + const status = (await statusRes.json()).data; if (!status.available) { this.terminal.writeln('\x1b[1;31m OpenCode CLI not found.\x1b[0m'); this.terminal.writeln('\x1b[90m Install with: curl -fsSL https://opencode.ai/install | bash\x1b[0m'); @@ -570,8 +570,8 @@ Object.assign(CodemanApp.prototype, { // Switch to the new session (don't pre-set activeSessionId — selectSession // early-returns when IDs match, skipping buffer load and sendResize) - if (data.sessionId) { - await this.selectSession(data.sessionId); + if (data.data.sessionId) { + await this.selectSession(data.data.sessionId); } this.terminal.focus(); @@ -789,8 +789,8 @@ Object.assign(CodemanApp.prototype, { try { const res = await fetch(`/api/sessions/${sessionId}/respawn/config`); const data = await res.json(); - if (data.success && data.config) { - const c = data.config; + if (data.success && data.data && data.data.config) { + const c = data.data.config; document.getElementById('modalRespawnPrompt').value = c.updatePrompt || 'update all the docs and CLAUDE.md'; document.getElementById('modalRespawnSendClear').checked = c.sendClear ?? true; document.getElementById('modalRespawnSendInit').checked = c.sendInit ?? true; diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 06ff4560..43b45058 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -185,9 +185,9 @@ Object.assign(CodemanApp.prototype, { try { // Get VAPID public key from server const keyData = await this._apiJson('/api/push/vapid-key'); - if (!keyData?.success) throw new Error('Failed to get VAPID key'); + if (!keyData) throw new Error('Failed to get VAPID key'); - const applicationServerKey = urlBase64ToUint8Array(keyData.data.publicKey); + const applicationServerKey = urlBase64ToUint8Array(keyData.publicKey); const subscription = await this._swRegistration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey, @@ -204,11 +204,11 @@ Object.assign(CodemanApp.prototype, { pushPreferences: this._buildPushPreferences(), }, }); - if (!data?.success) throw new Error('Failed to register subscription'); + if (!data) throw new Error('Failed to register subscription'); this._pushSubscription = subscription; - this._pushSubscriptionId = data.data.id; - localStorage.setItem('codeman-push-subscription-id', data.data.id); + this._pushSubscriptionId = data.id; + localStorage.setItem('codeman-push-subscription-id', data.id); this._updatePushUI(true); this.showToast('Push notifications enabled', 'success'); } catch (err) { @@ -598,7 +598,10 @@ Object.assign(CodemanApp.prototype, { let data = null; try { const res = await fetch('/api/system/update/status'); - if (res.ok) data = await res.json(); + if (res.ok) { + const env = await res.json(); + data = env && env.success === true ? env.data : env; + } } catch { /* server restarting — keep polling */ } if (!data) { @@ -652,7 +655,8 @@ Object.assign(CodemanApp.prototype, { async loadTunnelStatus() { try { const res = await fetch('/api/tunnel/status'); - const status = await res.json(); + const env = await res.json(); + const status = env?.success === true ? env.data : env; const active = status.running && status.url; this._tunnelUrl = active ? status.url : null; this._updateTunnelUrlDisplay(this._tunnelUrl); @@ -721,7 +725,8 @@ Object.assign(CodemanApp.prototype, { if (!res.ok) throw new Error('Tunnel not running'); return res.json(); }) - .then(data => { + .then(env => { + const data = env?.success === true ? env.data : env; const container = document.getElementById('tunnelQrContainer'); if (container && data.svg) container.innerHTML = data.svg; // Show auth badge, countdown, and regenerate button when auth is enabled @@ -754,7 +759,8 @@ Object.assign(CodemanApp.prototype, { // Fetch URL for display fetch('/api/tunnel/status') .then(r => r.json()) - .then(status => { + .then(env => { + const status = env?.success === true ? env.data : env; const urlEl = document.getElementById('tunnelQrUrl'); if (urlEl && status.url) { urlEl.textContent = status.url; @@ -786,7 +792,8 @@ Object.assign(CodemanApp.prototype, { _refreshTunnelQrFromApi() { fetch('/api/tunnel/qr') .then(res => res.ok ? res.json() : null) - .then(data => { + .then(env => { + const data = env?.success === true ? env.data : env; if (!data?.svg) return; const container = document.getElementById('tunnelQrContainer'); if (container) container.innerHTML = data.svg; @@ -901,7 +908,8 @@ Object.assign(CodemanApp.prototype, { this._tunnelPollTimer = setTimeout(async () => { try { const res = await fetch('/api/tunnel/status'); - const status = await res.json(); + const env = await res.json(); + const status = env?.success === true ? env.data : env; if (status.running && status.url) { // Tunnel is up — update UI this._dismissTunnelConnecting(); @@ -960,7 +968,7 @@ Object.assign(CodemanApp.prototype, { } fetch('/api/tunnel/qr') .then(r => { if (!r.ok) throw new Error(); return r.json(); }) - .then(data => { if (data.svg) qrInner.innerHTML = data.svg; }) + .then(env => { const data = env?.success === true ? env.data : env; if (data.svg) qrInner.innerHTML = data.svg; }) .catch(() => { qrInner.innerHTML = '
QR unavailable
'; }); } else { clearTimeout(this._welcomeQrShrinkTimer); @@ -1032,7 +1040,8 @@ Object.assign(CodemanApp.prototype, { // Fetch tunnel info try { const res = await fetch('/api/tunnel/info'); - const info = await res.json(); + const env = await res.json(); + const info = env?.success === true ? env.data : env; this._renderTunnelPanel(info); } catch { const body = document.getElementById('tunnelPanelBody'); @@ -1166,7 +1175,8 @@ Object.assign(CodemanApp.prototype, { this.showToast('All sessions revoked', 'success'); // Refresh panel const res = await fetch('/api/tunnel/info'); - const info = await res.json(); + const env = await res.json(); + const info = env?.success === true ? env.data : env; this._renderTunnelPanel(info); } catch { this.showToast('Failed to revoke sessions', 'error'); @@ -1261,7 +1271,8 @@ Object.assign(CodemanApp.prototype, { try { const res = await fetch(`/api/session-lifecycle?${params}`); - const data = await res.json(); + const env = await res.json(); + const data = env?.success === true ? env.data : env; const tbody = document.getElementById('lifecycleTableBody'); const empty = document.getElementById('lifecycleEmpty'); @@ -1850,7 +1861,7 @@ Object.assign(CodemanApp.prototype, { async loadAppSettingsFromServer(settingsPromise = null) { try { - const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null); + const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null).then(env => env?.success === true ? env.data : env); if (settings) { // Extract notification prefs before merging app settings const { notificationPreferences, voiceSettings, respawnPresets, runMode, ...appSettings } = settings; @@ -1942,7 +1953,8 @@ Object.assign(CodemanApp.prototype, { try { const res = await fetch('/api/subagent-window-states'); if (res.ok) { - states = await res.json(); + const env = await res.json(); + states = env?.success === true ? env.data : env; // Also update localStorage localStorage.setItem('codeman-subagent-window-states', JSON.stringify(states)); } @@ -2009,7 +2021,8 @@ Object.assign(CodemanApp.prototype, { try { const res = await fetch('/api/subagent-parents'); if (res.ok) { - mapData = await res.json(); + const env = await res.json(); + mapData = env?.success === true ? env.data : env; // Update localStorage as cache localStorage.setItem('codeman-subagent-parents', JSON.stringify(mapData)); } diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index dbe36b1f..7aae408e 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -868,7 +868,7 @@ Object.assign(CodemanApp.prototype, { async _fetchHistorySessions() { const res = await fetch('/api/history/sessions'); const data = await res.json(); - const sessions = data.sessions || []; + const sessions = data.data?.sessions || []; if (sessions.length === 0) return []; const byProject = new Map(); @@ -1046,7 +1046,7 @@ Object.assign(CodemanApp.prototype, { // Prefer already-loaded this.cases to avoid an extra request. const casesPromise = Array.isArray(this.cases) && this.cases.length > 0 ? Promise.resolve(this.cases) - : fetch('/api/cases').then((r) => (r.ok ? r.json() : [])).catch(() => []); + : fetch('/api/cases').then((r) => (r.ok ? r.json() : null)).then((d) => d?.data || []).catch(() => []); const [allSessions, cases] = await Promise.all([ this._fetchHistorySessions(30), casesPromise, @@ -1173,8 +1173,8 @@ Object.assign(CodemanApp.prototype, { const url = `/api/history/sessions?projectKey=${encodeURIComponent(projectKey)}&offset=${offset}&limit=${limit}`; const res = await fetch(url); const data = await res.json(); - const sessions = data.sessions || []; - state.total = typeof data.total === 'number' ? data.total : sessions.length + offset; + const sessions = data.data?.sessions || []; + state.total = typeof data.data?.total === 'number' ? data.data.total : sessions.length + offset; if (offset === 0 && sessions.length === 0) { const empty = document.createElement('div'); @@ -1261,7 +1261,7 @@ Object.assign(CodemanApp.prototype, { const createData = await createRes.json(); if (!createData.success) throw new Error(createData.error); - const newSessionId = createData.session.id; + const newSessionId = createData.data.session.id; // Start interactive await fetch(`/api/sessions/${newSessionId}/interactive`, { method: 'POST' }); diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 06901ae7..48ab869f 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -262,7 +262,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config const fixPlanPath = join(casePath, '@fix_plan.md'); if (!existsSync(fixPlanPath)) { - return { success: true, exists: false, content: null, todos: [] }; + return { exists: false, content: null, todos: [] }; } try { @@ -339,7 +339,6 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config const stats = { total: todos.length, pending, inProgress, completed }; return { - success: true, exists: true, content, todos, diff --git a/src/web/routes/clipboard-routes.ts b/src/web/routes/clipboard-routes.ts index f36b87b6..fe8b90be 100644 --- a/src/web/routes/clipboard-routes.ts +++ b/src/web/routes/clipboard-routes.ts @@ -19,6 +19,6 @@ export function registerClipboardRoutes(app: FastifyInstance, ctx: EventPort): v sessionId: body.sessionId ?? null, timestamp: Date.now(), }); - return { success: true }; + return {}; }); } diff --git a/src/web/routes/hook-event-routes.ts b/src/web/routes/hook-event-routes.ts index 471dea4a..ff83e9d2 100644 --- a/src/web/routes/hook-event-routes.ts +++ b/src/web/routes/hook-event-routes.ts @@ -66,6 +66,6 @@ export function registerHookEventRoutes( summaryTracker.recordHookEvent(event, safeData); } - return { success: true }; + return {}; }); } diff --git a/src/web/routes/mux-routes.ts b/src/web/routes/mux-routes.ts index b1264ae3..14b93760 100644 --- a/src/web/routes/mux-routes.ts +++ b/src/web/routes/mux-routes.ts @@ -19,7 +19,7 @@ export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void { app.delete('/api/mux-sessions/:sessionId', async (req) => { const { sessionId } = req.params as { sessionId: string }; const success = await ctx.mux.killSession(sessionId); - return { success }; + return { killed: success }; }); app.post('/api/mux-sessions/reconcile', async () => { @@ -29,11 +29,11 @@ export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void { app.post('/api/mux-sessions/stats/start', async () => { ctx.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS); - return { success: true }; + return {}; }); app.post('/api/mux-sessions/stats/stop', async () => { ctx.mux.stopStatsCollection(); - return { success: true }; + return {}; }); } diff --git a/src/web/routes/push-routes.ts b/src/web/routes/push-routes.ts index a0ac9e03..0fb27b93 100644 --- a/src/web/routes/push-routes.ts +++ b/src/web/routes/push-routes.ts @@ -35,7 +35,7 @@ export function registerPushRoutes(app: FastifyInstance, ctx: InfraPort): void { if (!updated) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subscription not found'); } - return { success: true }; + return {}; }); app.delete('/api/push/subscribe/:id', async (req) => { @@ -44,6 +44,6 @@ export function registerPushRoutes(app: FastifyInstance, ctx: InfraPort): void { if (!removed) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subscription not found'); } - return { success: true }; + return {}; }); } diff --git a/src/web/routes/ralph-routes.ts b/src/web/routes/ralph-routes.ts index 9a9c6937..615858f4 100644 --- a/src/web/routes/ralph-routes.ts +++ b/src/web/routes/ralph-routes.ts @@ -101,7 +101,7 @@ export function registerRalphRoutes( state: session.ralphLoopState, }); - return { success: true }; + return {}; }); // Reset circuit breaker for Ralph tracker @@ -110,7 +110,7 @@ export function registerRalphRoutes( const session = findSessionOrFail(ctx, id); session.ralphTracker.resetCircuitBreaker(); - return { success: true }; + return {}; }); // Get Ralph status block and circuit breaker state diff --git a/src/web/routes/respawn-routes.ts b/src/web/routes/respawn-routes.ts index 02bdd169..15749437 100644 --- a/src/web/routes/respawn-routes.ts +++ b/src/web/routes/respawn-routes.ts @@ -62,16 +62,16 @@ export function registerRespawnRoutes( const controller = ctx.respawnControllers.get(id); if (controller) { - return { success: true, config: controller.getConfig(), active: true }; + return { config: controller.getConfig(), active: true }; } // Return pre-saved config from mux-sessions.json const preConfig = ctx.mux.getSession(id)?.respawnConfig; if (preConfig) { - return { success: true, config: preConfig, active: false }; + return { config: preConfig, active: false }; } - return { success: true, config: null, active: false }; + return { config: null, active: false }; }); // ═══════════════════════════════════════════════════════════════ @@ -114,7 +114,7 @@ export function registerRespawnRoutes( ctx.broadcast(SseEvent.RespawnStarted, { sessionId: id, status: controller.getStatus() }); - return { success: true, status: controller.getStatus() }; + return { status: controller.getStatus() }; }); // ========== Stop Respawn ========== @@ -150,7 +150,7 @@ export function registerRespawnRoutes( ctx.broadcast(SseEvent.RespawnStopped, { sessionId: id }); - return { success: true }; + return {}; }); // ========== Update Respawn Config ========== @@ -169,7 +169,7 @@ export function registerRespawnRoutes( ctx.saveRespawnConfig(id, controller.getConfig()); ctx.persistSessionState(session); ctx.broadcast(SseEvent.RespawnConfigUpdated, { sessionId: id, config: controller.getConfig() }); - return { success: true, config: controller.getConfig() }; + return { config: controller.getConfig() }; } // No controller running - save as pre-config for when respawn starts @@ -206,7 +206,7 @@ export function registerRespawnRoutes( ctx.mux.updateRespawnConfig(id, merged); ctx.persistSessionState(session); ctx.broadcast(SseEvent.RespawnConfigUpdated, { sessionId: id, config: merged }); - return { success: true, config: merged }; + return { config: merged }; }); // ═══════════════════════════════════════════════════════════════ @@ -332,7 +332,6 @@ export function registerRespawnRoutes( ctx.broadcast(SseEvent.RespawnStarted, { sessionId: id, status: controller.getStatus() }); return { - success: true, message: 'Respawn enabled on existing session', respawnStatus: controller.getStatus(), }; diff --git a/src/web/routes/scheduled-routes.ts b/src/web/routes/scheduled-routes.ts index a219eefa..614f3824 100644 --- a/src/web/routes/scheduled-routes.ts +++ b/src/web/routes/scheduled-routes.ts @@ -15,7 +15,7 @@ export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort & return Array.from(ctx.scheduledRuns.values()); }); - app.post('/api/scheduled', async (req): Promise<{ success: boolean; run: ScheduledRun } | ApiResponse> => { + app.post('/api/scheduled', async (req): Promise<{ run: ScheduledRun } | ApiResponse> => { const { prompt, workingDir, durationMinutes } = parseBody(ScheduledRunSchema, req.body, 'Invalid request body'); // Validate workingDir exists and is a directory @@ -31,7 +31,7 @@ export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort & } const run = await ctx.startScheduledRun(prompt, workingDir || process.cwd(), durationMinutes ?? 60); - return { success: true, run }; + return { run }; }); app.delete('/api/scheduled/:id', async (req) => { @@ -43,7 +43,7 @@ export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort & } await ctx.stopScheduledRun(id); - return { success: true }; + return {}; }); app.get('/api/scheduled/:id', async (req) => { diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 41be3668..362b50a5 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -16,7 +16,6 @@ import { createErrorResponse, getErrorMessage, type ApiResponse, - type QuickStartResponse, type SessionColor, } from '../../types.js'; import { Session } from '../../session.js'; @@ -211,7 +210,7 @@ export function registerSessionRoutes( ctx.authSessions?.delete(sessionToken); } reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' }); - return { success: true }; + return {}; }); // ═══════════════════════════════════════════════════════════════ @@ -349,7 +348,7 @@ export function registerSessionRoutes( // Avoids serializing 2-3MB of terminal+text buffers per session creation. const lightState = ctx.getSessionStateWithRespawn(session); ctx.broadcast(SseEvent.SessionCreated, lightState); - return { success: true, session: lightState }; + return { session: lightState }; }); // ========== Rename Session ========== @@ -364,7 +363,7 @@ export function registerSessionRoutes( // Also update the mux session name if applicable ctx.mux.updateSessionName(id, session.name); persistAndBroadcastSession(ctx, session); - return { success: true, name: session.name }; + return { name: session.name }; }); // ========== Set Session Color ========== @@ -381,12 +380,12 @@ export function registerSessionRoutes( session.setColor(body.color as SessionColor); persistAndBroadcastSession(ctx, session); - return { success: true, color: session.color }; + return { color: session.color }; }); // ========== Delete Session ========== - app.delete('/api/sessions/:id', async (req): Promise => { + app.delete('/api/sessions/:id', async (req) => { const { id } = req.params as { id: string }; const query = req.query as { killMux?: string }; const killMux = query.killMux !== 'false'; // Default to true @@ -396,7 +395,7 @@ export function registerSessionRoutes( } await ctx.cleanupSession(id, killMux, 'user_delete'); - return { success: true }; + return {}; }); // ========== Delete All Sessions ========== @@ -473,13 +472,13 @@ export function registerSessionRoutes( // Create a fresh tracker if one doesn't exist (shouldn't happen normally) const newTracker = new RunSummaryTracker(id, session.name); ctx.runSummaryTrackers.set(id, newTracker); - return { success: true, summary: newTracker.getSummary() }; + return { summary: newTracker.getSummary() }; } // Update session name in case it changed tracker.setSessionName(session.name); - return { success: true, summary: tracker.getSummary() }; + return { summary: tracker.getSummary() }; }); // ========== Get Active Tools ========== @@ -502,7 +501,7 @@ export function registerSessionRoutes( // ========== Run Prompt ========== - app.post('/api/sessions/:id/run', async (req): Promise => { + app.post('/api/sessions/:id/run', async (req) => { const { id } = req.params as { id: string }; const { prompt } = parseBody(RunPromptSchema, req.body); const session = findSessionOrFail(ctx, id); @@ -517,12 +516,12 @@ export function registerSessionRoutes( }); ctx.broadcast(SseEvent.SessionRunning, { id, prompt }); - return { success: true }; + return {}; }); // ========== Start Interactive Mode ========== - app.post('/api/sessions/:id/interactive', async (req): Promise => { + app.post('/api/sessions/:id/interactive', async (req) => { const { id } = req.params as { id: string }; const session = findSessionOrFail(ctx, id); @@ -554,7 +553,7 @@ export function registerSessionRoutes( ctx.broadcast(SseEvent.SessionInteractive, { id }); ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) }); - return { success: true }; + return {}; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } @@ -562,7 +561,7 @@ export function registerSessionRoutes( // ========== Start Shell Mode ========== - app.post('/api/sessions/:id/shell', async (req): Promise => { + app.post('/api/sessions/:id/shell', async (req) => { const { id } = req.params as { id: string }; const session = findSessionOrFail(ctx, id); @@ -580,7 +579,7 @@ export function registerSessionRoutes( }); ctx.broadcast(SseEvent.SessionInteractive, { id, mode: 'shell' }); ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) }); - return { success: true }; + return {}; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } @@ -592,7 +591,7 @@ export function registerSessionRoutes( // ========== Send Input ========== - app.post('/api/sessions/:id/input', async (req): Promise => { + app.post('/api/sessions/:id/input', async (req) => { const { id } = req.params as { id: string }; const { input, useMux } = parseBody(SessionInputWithLimitSchema, req.body); const session = findSessionOrFail(ctx, id); @@ -624,7 +623,7 @@ export function registerSessionRoutes( } else { session.write(inputStr); } - return { success: true }; + return {}; }); // ========== Send Named Key (tmux send-keys -H) ========== @@ -632,7 +631,7 @@ export function registerSessionRoutes( // Uses send-keys -H (hex) to inject 0x0a (line feed) which Claude Code's // Ink input recognizes as "insert newline" vs 0x0d (carriage return = submit). - app.post('/api/sessions/:id/send-key', async (req): Promise => { + app.post('/api/sessions/:id/send-key', async (req) => { const { id } = req.params as { id: string }; const body = req.body as Record; const key = typeof body?.key === 'string' ? body.key : ''; @@ -671,18 +670,18 @@ export function registerSessionRoutes( console.error('[Server] send-key failed:', err); return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'tmux send-keys failed'); } - return { success: true }; + return {}; }); // ========== Resize Terminal ========== - app.post('/api/sessions/:id/resize', async (req): Promise => { + app.post('/api/sessions/:id/resize', async (req) => { const { id } = req.params as { id: string }; const { cols, rows } = parseBody(ResizeSchema, req.body); const session = findSessionOrFail(ctx, id); session.resize(cols, rows); - return { success: true }; + return {}; }); // ========== Get Last Response (from transcript JSONL) ========== @@ -1084,7 +1083,7 @@ export function registerSessionRoutes( const result = await session.runPrompt(prompt); // Clean up session after completion to prevent memory leak await ctx.cleanupSession(session.id, true, 'run_prompt_complete'); - return { success: true, sessionId: session.id, ...result }; + return { sessionId: session.id, ...result }; } catch (err) { // Clean up session on error too await ctx.cleanupSession(session.id, true, 'run_prompt_error'); @@ -1094,7 +1093,7 @@ export function registerSessionRoutes( // ========== Quick Start ========== - app.post('/api/quick-start', async (req): Promise => { + app.post('/api/quick-start', async (req) => { // Prevent unbounded session creation if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) { return createErrorResponse( @@ -1263,7 +1262,6 @@ export function registerSessionRoutes( } return { - success: true, sessionId: session.id, casePath, caseName, @@ -1725,6 +1723,6 @@ export function registerSessionRoutes( await fh.close(); } - return { success: true, path: filepath, filename }; + return { path: filepath, filename }; }); } diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index a472f599..6d593305 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -238,7 +238,7 @@ export function registerSystemRoutes( app.post('/api/tunnel/qr/regenerate', async () => { ctx.tunnelManager.regenerateQrToken(); - return { success: true }; + return {}; }); // ========== Auth Session Revocation ========== @@ -251,7 +251,7 @@ export function registerSystemRoutes( // Revoke all sessions (nuclear option) ctx.authSessions?.clear(); } - return { success: true }; + return {}; }); // ═══════════════════════════════════════════════════════════════ @@ -288,7 +288,7 @@ export function registerSystemRoutes( const child = spawn('bash', [scriptPath, url], { detached: true, stdio: 'ignore' }); child.on('error', (err) => app.log.error({ err }, 'span-displays launch failed')); child.unref(); - return { success: true, url }; + return { url }; } catch (err) { return reply.code(500).send(createErrorResponse(ApiErrorCode.INTERNAL_ERROR, getErrorMessage(err))); } @@ -313,7 +313,7 @@ export function registerSystemRoutes( app.post('/api/system/update', async (_req, reply) => { const result = await startUpdate(); if (result.ok) { - return { success: true, updateId: result.updateId, toTag: result.toTag, toVersion: result.toVersion }; + return { updateId: result.updateId, toTag: result.toTag, toVersion: result.toVersion }; } const map = { 'in-flight': { http: 409, api: ApiErrorCode.ALREADY_EXISTS }, @@ -354,7 +354,7 @@ export function registerSystemRoutes( for (const s of result.cleaned) { lifecycleLog.log({ event: 'stale_cleaned', sessionId: s.id, name: s.name }); } - return { success: true, cleanedSessions: result.count }; + return { cleanedSessions: result.count }; }); app.get('/api/session-lifecycle', async (req) => { @@ -371,7 +371,7 @@ export function registerSystemRoutes( since: query.since ? Number(query.since) : undefined, limit: query.limit ? Math.min(Number(query.limit), 1000) : 200, }); - return { success: true, entries }; + return { entries }; }); // ========== Stats ========== @@ -391,7 +391,6 @@ export function registerSystemRoutes( app.get('/api/stats', async () => { const activeSessionTokens = collectActiveTokens(); return { - success: true, stats: ctx.store.getAggregateStats(activeSessionTokens), raw: ctx.store.getGlobalStats(), }; @@ -400,7 +399,6 @@ export function registerSystemRoutes( app.get('/api/token-stats', async () => { const activeSessionTokens = collectActiveTokens(); return { - success: true, daily: ctx.store.getDailyStats(30), totals: ctx.store.getAggregateStats(activeSessionTokens), }; @@ -413,13 +411,13 @@ export function registerSystemRoutes( // ========== Config ========== app.get('/api/config', async () => { - return { success: true, config: ctx.store.getConfig() }; + return { config: ctx.store.getConfig() }; }); app.put('/api/config', async (req) => { const configData = parseBody(ConfigUpdateSchema, req.body, 'Invalid config'); ctx.store.setConfig(configData as Partial>); - return { success: true, config: ctx.store.getConfig() }; + return { config: ctx.store.getConfig() }; }); // ========== Debug/Memory ========== @@ -535,7 +533,7 @@ export function registerSystemRoutes( } } - return { success: true }; + return {}; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } @@ -568,7 +566,7 @@ export function registerSystemRoutes( } await fs.writeFile(SETTINGS_PATH, JSON.stringify(existingSettings, null, 2)); - return { success: true }; + return {}; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } @@ -580,7 +578,6 @@ export function registerSystemRoutes( const { id } = req.params as { id: string }; const session = findSessionOrFail(ctx, id); return { - success: true, nice: session.niceConfig, }; }); @@ -596,7 +593,6 @@ export function registerSystemRoutes( ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) }); return { - success: true, nice: session.niceConfig, note: 'Nice priority only affects newly created mux sessions, not currently running ones.', }; @@ -620,7 +616,7 @@ export function registerSystemRoutes( mkdirSync(dir, { recursive: true }); } await fs.writeFile(windowStatesPath, JSON.stringify(states, null, 2)); - return { success: true }; + return {}; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } @@ -640,7 +636,7 @@ export function registerSystemRoutes( mkdirSync(dir, { recursive: true }); } await fs.writeFile(parentMapPath, JSON.stringify(parentMap, null, 2)); - return { success: true }; + return {}; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } @@ -799,7 +795,7 @@ export function registerSystemRoutes( const filepath = join(SCREENSHOTS_DIR, filename); await fs.writeFile(filepath, filePart.data); - return { success: true, path: filepath, filename }; + return { path: filepath, filename }; }); app.get('/api/screenshots', async () => { diff --git a/src/web/server.ts b/src/web/server.ts index b9f7f75f..a0a1c1c0 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -90,8 +90,21 @@ import { reconcileUpdateOnBoot } from './self-update.js'; // Load version from package.json const require = createRequire(import.meta.url); const { version: APP_VERSION } = require('../../package.json'); + +/** + * `/api/v1/*` is the versioned public alias of the (unversioned) `/api/*` routes. + * Rewriting at the server level lets external clients pin to a stable surface while + * the bundled frontend keeps using `/api/*`. See docs/api-reference.md. + */ +function rewriteApiV1Url(url: string): string { + if (url === '/api/v1') return '/api'; + if (url.startsWith('/api/v1/')) return '/api/' + url.slice('/api/v1/'.length); + return url; +} import { getErrorMessage, + httpStatusForErrorCode, + ApiErrorCode, type PersistedRespawnConfig, type NiceConfig, type ImageDetectedEvent, @@ -274,11 +287,12 @@ export class WebServer extends EventEmitter { this.windowTitle = `codeman:${this.titleHostname}`; this.indexHtmlTemplate = readFileSync(join(__dirname, 'public', 'index.html'), 'utf-8'); + const rewriteUrl = (req: { url?: string }): string => rewriteApiV1Url(req.url || ''); if (https) { const { key, cert } = getOrCreateSelfSignedCert(); - this.app = Fastify({ logger: false, https: { key, cert } }); + this.app = Fastify({ logger: false, https: { key, cert }, rewriteUrl }); } else { - this.app = Fastify({ logger: false }); + this.app = Fastify({ logger: false, rewriteUrl }); } this.mux = createMultiplexer(); this.sse = new SseStreamManager( @@ -561,6 +575,27 @@ export class WebServer extends EventEmitter { // Cookie plugin (needed for auth session tokens) await this.app.register(fastifyCookie); + // Uniform response envelope (stable HTTP contract — docs/api-reference.md): + // wrap bare JSON payloads as { success:true, data } and map { success:false } + // error envelopes to a conventional HTTP status (instead of 200). Skips + // non-JSON responses (buffers/streams) and non-/api routes. + this.app.addHook('preSerialization', (req, reply, payload: unknown, done) => { + if (!req.url.startsWith('/api')) return done(null, payload); + if (payload === null || typeof payload !== 'object') return done(null, payload); + if (Buffer.isBuffer(payload) || typeof (payload as { pipe?: unknown }).pipe === 'function') { + return done(null, payload); + } + const p = payload as { success?: unknown; errorCode?: unknown }; + if (p.success === false) { + if (reply.statusCode === 200 && typeof p.errorCode === 'string') { + reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode)); + } + return done(null, payload); + } + if (p.success === true) return done(null, payload); + return done(null, { success: true, data: payload }); + }); + // Anti-DNS-rebinding Host allowlist + cross-site (CSRF) Origin guard. Registered // before auth so forged cross-site / rebound requests are rejected up front, even // on the default no-password install. See docs/reports/security-review-2026-06-09.md. diff --git a/test/edge-cases.test.ts b/test/edge-cases.test.ts index 56201851..d3c1a4bd 100644 --- a/test/edge-cases.test.ts +++ b/test/edge-cases.test.ts @@ -190,7 +190,7 @@ describe('Edge Cases and Error Handling', () => { // Should succeed with valid characters, even if long if (data.success) { - createdCases.push(longName); + createdCases.push(data.data.caseName); } // Either succeeds or fails gracefully expect(data).toHaveProperty('success'); @@ -202,8 +202,8 @@ describe('Edge Cases and Error Handling', () => { const response = await fetch(`${baseUrl}/api/sessions/non-existent/respawn`); const data = await response.json(); - expect(data.enabled).toBe(false); - expect(data.status).toBeNull(); + expect(data.data.enabled).toBe(false); + expect(data.data.status).toBeNull(); }); it('should handle starting respawn on non-existent session', async () => { @@ -287,17 +287,17 @@ describe('Concurrent Session Handling', () => { // All should succeed for (const result of results) { expect(result.success).toBe(true); - expect(result.session.id).toBeDefined(); + expect(result.data.session.id).toBeDefined(); } // Verify sessions are listed const listRes = await fetch(`${baseUrl}/api/sessions`); const sessions = await listRes.json(); - expect(sessions.length).toBeGreaterThanOrEqual(5); + expect(sessions.data.length).toBeGreaterThanOrEqual(5); // Clean up - delete all created sessions for (const result of results) { - await fetch(`${baseUrl}/api/sessions/${result.session.id}`, { + await fetch(`${baseUrl}/api/sessions/${result.data.session.id}`, { method: 'DELETE', }); } @@ -317,7 +317,7 @@ describe('Concurrent Session Handling', () => { expect(createData.success).toBe(true); // Delete immediately - const deleteRes = await fetch(`${baseUrl}/api/sessions/${createData.session.id}`, { + const deleteRes = await fetch(`${baseUrl}/api/sessions/${createData.data.session.id}`, { method: 'DELETE', }); const deleteData = await deleteRes.json(); @@ -341,8 +341,8 @@ describe('Concurrent Session Handling', () => { for (const result of results) { expect(result.success).toBe(true); - if (result.caseName) { - createdCases.push(result.caseName); + if (result.data.caseName) { + createdCases.push(result.data.caseName); } } diff --git a/test/file-link-click.test.ts b/test/file-link-click.test.ts index d1587ff5..0cdd745a 100644 --- a/test/file-link-click.test.ts +++ b/test/file-link-click.test.ts @@ -147,7 +147,7 @@ describe('File Link Click Tests', () => { const data = await response.json(); expect(data.success).toBe(true); - createdSessions.push(data.session.id); + createdSessions.push(data.data.sessionId); // Wait for session to appear in UI await new Promise((r) => setTimeout(r, 2000)); @@ -347,7 +347,7 @@ describe('File Link Click Tests', () => { }); const data = await response.json(); expect(data.success).toBe(true); - sessionId = data.sessionId; // quick-start returns sessionId directly + sessionId = data.data.sessionId; // quick-start returns sessionId under data envelope createdSessions.push(sessionId); } diff --git a/test/hooks-config.test.ts b/test/hooks-config.test.ts index 697f7a27..513d14ad 100644 --- a/test/hooks-config.test.ts +++ b/test/hooks-config.test.ts @@ -32,21 +32,21 @@ describe('generateHooksConfig', () => { it('should configure idle_prompt matcher', () => { const config = generateHooksConfig(); const notifHooks = config.hooks.Notification as Array<{ matcher?: string }>; - const idleHook = notifHooks.find(h => h.matcher === 'idle_prompt'); + const idleHook = notifHooks.find((h) => h.matcher === 'idle_prompt'); expect(idleHook).toBeDefined(); }); it('should configure permission_prompt matcher', () => { const config = generateHooksConfig(); const notifHooks = config.hooks.Notification as Array<{ matcher?: string }>; - const permHook = notifHooks.find(h => h.matcher === 'permission_prompt'); + const permHook = notifHooks.find((h) => h.matcher === 'permission_prompt'); expect(permHook).toBeDefined(); }); it('should configure elicitation_dialog matcher', () => { const config = generateHooksConfig(); const notifHooks = config.hooks.Notification as Array<{ matcher?: string }>; - const elicitHook = notifHooks.find(h => h.matcher === 'elicitation_dialog'); + const elicitHook = notifHooks.find((h) => h.matcher === 'elicitation_dialog'); expect(elicitHook).toBeDefined(); }); @@ -146,7 +146,7 @@ describe('writeHooksConfig', () => { mkdirSync(claudeDir, { recursive: true }); writeFileSync( join(claudeDir, 'settings.local.json'), - JSON.stringify({ existingKey: 'existingValue', permissions: { allow: ['Read'] } }, null, 2), + JSON.stringify({ existingKey: 'existingValue', permissions: { allow: ['Read'] } }, null, 2) ); await writeHooksConfig(testDir); @@ -160,10 +160,7 @@ describe('writeHooksConfig', () => { it('should overwrite existing hooks key', async () => { const claudeDir = join(testDir, '.claude'); mkdirSync(claudeDir, { recursive: true }); - writeFileSync( - join(claudeDir, 'settings.local.json'), - JSON.stringify({ hooks: { oldHook: [] } }, null, 2), - ); + writeFileSync(join(claudeDir, 'settings.local.json'), JSON.stringify({ hooks: { oldHook: [] } }, null, 2)); await writeHooksConfig(testDir); @@ -214,7 +211,7 @@ describe('Hook Event API', () => { body: JSON.stringify({}), }); const createData = await createRes.json(); - testSessionId = createData.session.id; + testSessionId = createData.data.session.id; }); afterAll(async () => { @@ -396,7 +393,7 @@ describe('Hook Data Sanitization', () => { body: JSON.stringify({}), }); const createData = await createRes.json(); - testSessionId = createData.session.id; + testSessionId = createData.data.session.id; }); afterAll(async () => { @@ -641,7 +638,7 @@ describe('Hook Config Generation - Extended', () => { it('should include all event types', () => { const config = generateHooksConfig(); const notifHooks = config.hooks.Notification as Array<{ matcher?: string }>; - const matchers = notifHooks.map(n => n.matcher); + const matchers = notifHooks.map((n) => n.matcher); expect(matchers).toContain('idle_prompt'); expect(matchers).toContain('permission_prompt'); expect(matchers).toContain('elicitation_dialog'); @@ -694,7 +691,10 @@ describe('Hook Config Generation - Extended', () => { it('should have consistent structure across all notification hooks', () => { const config = generateHooksConfig(); - const notifHooks = config.hooks.Notification as Array<{ matcher: string; hooks: Array<{ type: string; command: string; timeout: number }> }>; + const notifHooks = config.hooks.Notification as Array<{ + matcher: string; + hooks: Array<{ type: string; command: string; timeout: number }>; + }>; for (const hook of notifHooks) { expect(hook.matcher).toBeDefined(); diff --git a/test/integration-flows.test.ts b/test/integration-flows.test.ts index 48abde03..727c7ebb 100644 --- a/test/integration-flows.test.ts +++ b/test/integration-flows.test.ts @@ -58,24 +58,24 @@ describe('Integration Flows', () => { const quickStartData = await quickStartRes.json(); expect(quickStartData.success).toBe(true); - expect(quickStartData.sessionId).toBeDefined(); - expect(quickStartData.caseName).toBe(caseName); - createdSessions.push(quickStartData.sessionId); + expect(quickStartData.data.sessionId).toBeDefined(); + expect(quickStartData.data.caseName).toBe(caseName); + createdSessions.push(quickStartData.data.sessionId); // Step 2: Verify session is in interactive mode - const sessionRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.sessionId}`); + const sessionRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.data.sessionId}`); const sessionData = await sessionRes.json(); - expect(sessionData.id).toBe(quickStartData.sessionId); - expect(sessionData.workingDir).toContain(caseName); - expect(['busy', 'idle', 'running']).toContain(sessionData.status); // May transition quickly in test mode + expect(sessionData.data.id).toBe(quickStartData.data.sessionId); + expect(sessionData.data.workingDir).toContain(caseName); + expect(['busy', 'idle', 'running']).toContain(sessionData.data.status); // May transition quickly in test mode // Step 3: Verify case was created with CLAUDE.md const caseRes = await fetch(`${baseUrl}/api/cases/${caseName}`); const caseData = await caseRes.json(); - expect(caseData.name).toBe(caseName); - expect(caseData.hasClaudeMd).toBe(true); + expect(caseData.data.name).toBe(caseName); + expect(caseData.data.hasClaudeMd).toBe(true); }); it('should reuse existing case when quick starting with existing case name', async () => { @@ -90,10 +90,10 @@ describe('Integration Flows', () => { }); const firstData = await firstRes.json(); expect(firstData.success).toBe(true); - createdSessions.push(firstData.sessionId); + createdSessions.push(firstData.data.sessionId); // Delete the session but keep the case - await fetch(`${baseUrl}/api/sessions/${firstData.sessionId}`, { method: 'DELETE' }); + await fetch(`${baseUrl}/api/sessions/${firstData.data.sessionId}`, { method: 'DELETE' }); // Second quick start - should reuse the case const secondRes = await fetch(`${baseUrl}/api/quick-start`, { @@ -104,9 +104,9 @@ describe('Integration Flows', () => { const secondData = await secondRes.json(); expect(secondData.success).toBe(true); - expect(secondData.caseName).toBe(caseName); - expect(secondData.casePath).toBe(firstData.casePath); - createdSessions.push(secondData.sessionId); + expect(secondData.data.caseName).toBe(caseName); + expect(secondData.data.casePath).toBe(firstData.data.casePath); + createdSessions.push(secondData.data.sessionId); }); }); @@ -132,20 +132,20 @@ describe('Integration Flows', () => { }); const sessionData = await sessionRes.json(); expect(sessionData.success).toBe(true); - createdSessions.push(sessionData.session.id); + createdSessions.push(sessionData.data.session.id); // Step 3: Start interactive mode - const interactiveRes = await fetch(`${baseUrl}/api/sessions/${sessionData.session.id}/interactive`, { + const interactiveRes = await fetch(`${baseUrl}/api/sessions/${sessionData.data.session.id}/interactive`, { method: 'POST', }); const interactiveData = await interactiveRes.json(); expect(interactiveData.success).toBe(true); // Verify session state - const verifyRes = await fetch(`${baseUrl}/api/sessions/${sessionData.session.id}`); + const verifyRes = await fetch(`${baseUrl}/api/sessions/${sessionData.data.session.id}`); const verifyData = await verifyRes.json(); - expect(['busy', 'idle', 'running']).toContain(verifyData.status); - expect(verifyData.workingDir).toContain(caseName); + expect(['busy', 'idle', 'running']).toContain(verifyData.data.status); + expect(verifyData.data.workingDir).toContain(caseName); }); }); @@ -162,13 +162,13 @@ describe('Integration Flows', () => { }); const quickStartData = await quickStartRes.json(); expect(quickStartData.success).toBe(true); - createdSessions.push(quickStartData.sessionId); + createdSessions.push(quickStartData.data.sessionId); // Wait for Claude to start up await new Promise((resolve) => setTimeout(resolve, 2000)); // Send input - const inputRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.sessionId}/input`, { + const inputRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.data.sessionId}/input`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input: '/help\n' }), @@ -180,9 +180,9 @@ describe('Integration Flows', () => { await new Promise((resolve) => setTimeout(resolve, 1000)); // Check terminal buffer has content - const terminalRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.sessionId}/terminal`); + const terminalRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.data.sessionId}/terminal`); const terminalData = await terminalRes.json(); - expect(terminalData.terminalBuffer.length).toBeGreaterThan(0); + expect(terminalData.data.terminalBuffer.length).toBeGreaterThan(0); }); it('should handle terminal resize', async () => { @@ -197,15 +197,21 @@ describe('Integration Flows', () => { }); const quickStartData = await quickStartRes.json(); expect(quickStartData.success).toBe(true); - createdSessions.push(quickStartData.sessionId); + createdSessions.push(quickStartData.data.sessionId); - // Resize terminal - const resizeRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.sessionId}/resize`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ cols: 200, rows: 50 }), - }); - const resizeData = await resizeRes.json(); + // Resize terminal (retry briefly — a just-quick-started session can be + // momentarily busy, which would return SESSION_BUSY; this is a transient race). + let resizeData; + for (let attempt = 0; attempt < 5; attempt++) { + const resizeRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.data.sessionId}/resize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cols: 200, rows: 50 }), + }); + resizeData = await resizeRes.json(); + if (resizeData.success) break; + await new Promise((r) => setTimeout(r, 100)); + } expect(resizeData.success).toBe(true); }); }); @@ -225,14 +231,14 @@ describe('Integration Flows', () => { expect(quickStartData.success).toBe(true); // Delete the session - const deleteRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.sessionId}`, { + const deleteRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.data.sessionId}`, { method: 'DELETE', }); const deleteData = await deleteRes.json(); expect(deleteData.success).toBe(true); // Verify session is gone - const verifyRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.sessionId}`); + const verifyRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.data.sessionId}`); const verifyData = await verifyRes.json(); expect(verifyData.error).toContain('not found'); }); @@ -251,20 +257,20 @@ describe('Integration Flows', () => { }); const quickStartData = await quickStartRes.json(); expect(quickStartData.success).toBe(true); - createdSessions.push(quickStartData.sessionId); + createdSessions.push(quickStartData.data.sessionId); // Get full status const statusRes = await fetch(`${baseUrl}/api/status`); const statusData = await statusRes.json(); - expect(statusData.sessions).toBeDefined(); - expect(Array.isArray(statusData.sessions)).toBe(true); - expect(statusData.scheduledRuns).toBeDefined(); - expect(statusData.respawnStatus).toBeDefined(); - expect(statusData.timestamp).toBeDefined(); + expect(statusData.data.sessions).toBeDefined(); + expect(Array.isArray(statusData.data.sessions)).toBe(true); + expect(statusData.data.scheduledRuns).toBeDefined(); + expect(statusData.data.respawnStatus).toBeDefined(); + expect(statusData.data.timestamp).toBeDefined(); // Verify our session is in the list - const ourSession = statusData.sessions.find((s: any) => s.id === quickStartData.sessionId); + const ourSession = statusData.data.sessions.find((s: any) => s.id === quickStartData.data.sessionId); expect(ourSession).toBeDefined(); expect(ourSession.workingDir).toContain(caseName); }); @@ -341,7 +347,7 @@ describe('SSE Event Flow', () => { }); const quickStartData = await quickStartRes.json(); expect(quickStartData.success).toBe(true); - createdSessions.push(quickStartData.sessionId); + createdSessions.push(quickStartData.data.sessionId); // Wait for events await new Promise((resolve) => setTimeout(resolve, 500)); diff --git a/test/operation-lightspeed.test.ts b/test/operation-lightspeed.test.ts index c1c3a351..34056cb4 100644 --- a/test/operation-lightspeed.test.ts +++ b/test/operation-lightspeed.test.ts @@ -88,10 +88,10 @@ async function createSession(baseUrl: string): Promise { body: JSON.stringify({ workingDir: '/tmp' }), }); const data = await res.json(); - if (!data.session?.id) { + if (!data.data?.session?.id) { throw new Error(`Failed to create session: ${JSON.stringify(data)}`); } - return data.session.id; + return data.data.session.id; } // Helper to delete a session @@ -392,9 +392,9 @@ describe('Operation Lightspeed', () => { expect(res.status).toBe(200); // terminalBuffer may be empty for a fresh session, but field should exist - expect(data).toHaveProperty('terminalBuffer'); - expect(data).toHaveProperty('truncated'); - expect(data.truncated).toBe(false); + expect(data.data).toHaveProperty('terminalBuffer'); + expect(data.data).toHaveProperty('truncated'); + expect(data.data.truncated).toBe(false); await deleteSession(baseUrl, sessionId); }); @@ -407,7 +407,7 @@ describe('Operation Lightspeed', () => { const data = await res.json(); expect(res.status).toBe(200); - expect(data).toHaveProperty('terminalBuffer'); + expect(data.data).toHaveProperty('terminalBuffer'); await deleteSession(baseUrl, sessionId); }); @@ -431,7 +431,7 @@ describe('Operation Lightspeed', () => { // All should succeed for (const data of results) { - expect(data).toHaveProperty('terminalBuffer'); + expect(data.data).toHaveProperty('terminalBuffer'); } // Cleanup @@ -692,7 +692,8 @@ describe('Operation Lightspeed', () => { const sessionId = await createSession(baseUrl); const res = await fetch(`${baseUrl}/api/sessions`); - const sessions = await res.json(); + const body = await res.json(); + const sessions = body.data; expect(Array.isArray(sessions)).toBe(true); const session = sessions.find((s: any) => s.id === sessionId); @@ -718,7 +719,8 @@ describe('Operation Lightspeed', () => { await new Promise((resolve) => setTimeout(resolve, 1100)); const res = await fetch(`${baseUrl}/api/sessions`); - const sessions = await res.json(); + const body = await res.json(); + const sessions = body.data; // All 3 should be present in the response const foundIds = sessions.map((s: any) => s.id); @@ -911,10 +913,10 @@ describe('Operation Lightspeed', () => { expect(res.status).toBe(200); // Local echo overlay needs session status to know when to show/hide - expect(data).toHaveProperty('status'); - expect(typeof data.status).toBe('string'); + expect(data.data).toHaveProperty('status'); + expect(typeof data.data.status).toBe('string'); // Fresh session starts as 'starting' - expect(['starting', 'running', 'idle', 'error']).toContain(data.status); + expect(['starting', 'running', 'idle', 'error']).toContain(data.data.status); await deleteSession(baseUrl, sessionId); }); @@ -926,9 +928,9 @@ describe('Operation Lightspeed', () => { const data = await res.json(); expect(res.status).toBe(200); - expect(data).toHaveProperty('fullSize'); - expect(typeof data.fullSize).toBe('number'); - expect(data.fullSize).toBeGreaterThanOrEqual(0); + expect(data.data).toHaveProperty('fullSize'); + expect(typeof data.data.fullSize).toBe('number'); + expect(data.data.fullSize).toBeGreaterThanOrEqual(0); await deleteSession(baseUrl, sessionId); }); @@ -942,9 +944,9 @@ describe('Operation Lightspeed', () => { ]); // tail=0 means "don't tail" — should return same as no tail param - expect(fullRes.truncated).toBe(false); - expect(tailZeroRes.truncated).toBe(false); - expect(fullRes.terminalBuffer).toBe(tailZeroRes.terminalBuffer); + expect(fullRes.data.truncated).toBe(false); + expect(tailZeroRes.data.truncated).toBe(false); + expect(fullRes.data.terminalBuffer).toBe(tailZeroRes.data.terminalBuffer); await deleteSession(baseUrl, sessionId); }); @@ -957,8 +959,8 @@ describe('Operation Lightspeed', () => { const data = await res.json(); expect(res.status).toBe(200); - expect(data).toHaveProperty('terminalBuffer'); - expect(data.truncated).toBe(false); // Can't truncate if tail > fullSize + expect(data.data).toHaveProperty('terminalBuffer'); + expect(data.data.truncated).toBe(false); // Can't truncate if tail > fullSize await deleteSession(baseUrl, sessionId); }); @@ -971,7 +973,7 @@ describe('Operation Lightspeed', () => { // Should handle gracefully (either return full buffer or error cleanly) expect(res.status).toBe(200); - expect(data).toHaveProperty('terminalBuffer'); + expect(data.data).toHaveProperty('terminalBuffer'); await deleteSession(baseUrl, sessionId); }); @@ -984,7 +986,7 @@ describe('Operation Lightspeed', () => { // NaN tail should be handled (parseInt('abc') = NaN, which is falsy) expect(res.status).toBe(200); - expect(data).toHaveProperty('terminalBuffer'); + expect(data.data).toHaveProperty('terminalBuffer'); await deleteSession(baseUrl, sessionId); }); @@ -1235,7 +1237,7 @@ describe('Operation Lightspeed', () => { ) ); - const ids = results.map((r) => r.session.id); + const ids = results.map((r) => r.data.session.id); expect(ids.length).toBe(5); expect(new Set(ids).size).toBe(5); // All unique diff --git a/test/qr-auth.test.ts b/test/qr-auth.test.ts index 077cac45..1d96fa08 100644 --- a/test/qr-auth.test.ts +++ b/test/qr-auth.test.ts @@ -86,8 +86,7 @@ describe('QR Token Manager (unit)', () => { // Manually expire the token by manipulating its createdAt // Access the private map — this is a unit test, we need to verify the TTL logic - const tokenMap = (tm as unknown as { qrTokensByCode: Map }) - .qrTokensByCode; + const tokenMap = (tm as unknown as { qrTokensByCode: Map }).qrTokensByCode; const record = tokenMap.get(code)!; record.createdAt = Date.now() - 91_000; // 91 seconds ago (beyond 90s grace) @@ -99,8 +98,7 @@ describe('QR Token Manager (unit)', () => { const code = tm.getCurrentShortCode()!; // Set createdAt to 80 seconds ago (within 90s grace) - const tokenMap = (tm as unknown as { qrTokensByCode: Map }) - .qrTokensByCode; + const tokenMap = (tm as unknown as { qrTokensByCode: Map }).qrTokensByCode; const record = tokenMap.get(code)!; record.createdAt = Date.now() - 80_000; @@ -172,8 +170,7 @@ describe('QR Token Manager (unit)', () => { it('should accept token at exactly grace period (90000ms)', () => { const code = tm.getCurrentShortCode()!; - const tokenMap = (tm as unknown as { qrTokensByCode: Map }) - .qrTokensByCode; + const tokenMap = (tm as unknown as { qrTokensByCode: Map }).qrTokensByCode; const record = tokenMap.get(code)!; record.createdAt = Date.now() - 90_000; // Condition is `> QR_TOKEN_GRACE_MS` (strict >), so exactly 90000 should pass @@ -182,8 +179,7 @@ describe('QR Token Manager (unit)', () => { it('should reject token at grace period + 1ms (90001ms)', () => { const code = tm.getCurrentShortCode()!; - const tokenMap = (tm as unknown as { qrTokensByCode: Map }) - .qrTokensByCode; + const tokenMap = (tm as unknown as { qrTokensByCode: Map }).qrTokensByCode; const record = tokenMap.get(code)!; record.createdAt = Date.now() - 90_001; expect(tm.consumeToken(code)).toBe(false); @@ -308,8 +304,7 @@ describe('QR Auth Integration', () => { beforeEach(() => { // Reset QR failure counter to prevent cross-test contamination // (all requests come from 127.0.0.1) - const qrFailures = (server as unknown as { qrAuthFailures: { clear(): void } | null }) - .qrAuthFailures; + const qrFailures = (server as unknown as { qrAuthFailures: { clear(): void } | null }).qrAuthFailures; if (qrFailures) qrFailures.clear(); }); @@ -568,9 +563,11 @@ describe('QR Auth Integration', () => { const setCookie = res.headers.get('set-cookie')!; const token = setCookie.match(/codeman_session=([^;]+)/)![1]; - const authSessions = (server as unknown as { - authSessions: { get(k: string): { method: string } | undefined } | null; - }).authSessions; + const authSessions = ( + server as unknown as { + authSessions: { get(k: string): { method: string } | undefined } | null; + } + ).authSessions; const record = authSessions?.get(token); expect(record).toBeDefined(); expect(record!.method).toBe('qr'); @@ -631,9 +628,9 @@ describe('QR SVG Endpoint (GET /api/tunnel/qr)', () => { }); expect(res.status).toBe(200); const data = await res.json(); - expect(data.authEnabled).toBe(true); - expect(data.svg).toContain(''); + expect(data.data.authEnabled).toBe(true); + expect(data.data.svg).toContain(''); } finally { tm.stopTokenRotation(); simulateTunnelStopped(tm); @@ -653,9 +650,9 @@ describe('QR SVG Endpoint (GET /api/tunnel/qr)', () => { }); expect(res.status).toBe(200); const data = await res.json(); - expect(data.authEnabled).toBe(false); - expect(data.svg).toContain(''); + expect(data.data.authEnabled).toBe(false); + expect(data.data.svg).toContain(''); } finally { process.env.CODEMAN_PASSWORD = savedPass; simulateTunnelStopped(tm); @@ -709,8 +706,8 @@ describe('QR SVG Endpoint (GET /api/tunnel/qr)', () => { }); expect(res.status).toBe(200); const data = await res.json(); - expect(data.svg).toContain(' { }); const data2 = await res2.json(); - expect(data1.svg).toBe(data2.svg); + expect(data1.data.svg).toBe(data2.data.svg); } finally { tm.stopTokenRotation(); simulateTunnelStopped(tm); @@ -756,7 +753,7 @@ describe('QR SVG Endpoint (GET /api/tunnel/qr)', () => { }); const data2 = await res2.json(); - expect(data1.svg).not.toBe(data2.svg); + expect(data1.data.svg).not.toBe(data2.data.svg); } finally { tm.stopTokenRotation(); simulateTunnelStopped(tm); diff --git a/test/quick-start.test.ts b/test/quick-start.test.ts index 6b16dc74..330fe5d9 100644 --- a/test/quick-start.test.ts +++ b/test/quick-start.test.ts @@ -47,14 +47,14 @@ describe('Quick Start API', () => { const data = await response.json(); expect(data.success).toBe(true); - expect(data.sessionId).toBeDefined(); - expect(data.caseName).toBe(testCaseName); - expect(data.casePath).toBe(join(CASES_DIR, testCaseName)); + expect(data.data.sessionId).toBeDefined(); + expect(data.data.caseName).toBe(testCaseName); + expect(data.data.casePath).toBe(join(CASES_DIR, testCaseName)); // Verify case folder was created - expect(existsSync(data.casePath)).toBe(true); - expect(existsSync(join(data.casePath, 'CLAUDE.md'))).toBe(true); - expect(existsSync(join(data.casePath, 'src'))).toBe(true); + expect(existsSync(data.data.casePath)).toBe(true); + expect(existsSync(join(data.data.casePath, 'CLAUDE.md'))).toBe(true); + expect(existsSync(join(data.data.casePath, 'src'))).toBe(true); }); it('should use existing case without recreating it', async () => { @@ -74,7 +74,7 @@ describe('Quick Start API', () => { const data = await response.json(); expect(data.success).toBe(true); - expect(data.caseName).toBe(testCaseName); + expect(data.data.caseName).toBe(testCaseName); // Case should exist but CLAUDE.md won't be created since case already exists expect(existsSync(casePath)).toBe(true); }); @@ -118,7 +118,7 @@ describe('Quick Start API', () => { const data = await response.json(); expect(data.success).toBe(true); - expect(data.caseName).toBe(testCaseName); + expect(data.data.caseName).toBe(testCaseName); }); it('should default to "testcase" when no caseName provided', async () => { @@ -137,7 +137,7 @@ describe('Quick Start API', () => { const data = await response.json(); expect(data.success).toBe(true); - expect(data.caseName).toBe('testcase'); + expect(data.data.caseName).toBe('testcase'); }); }); }); @@ -167,10 +167,10 @@ describe('Session Management', () => { const data = await response.json(); expect(data.success).toBe(true); - expect(data.session).toBeDefined(); - expect(data.session.id).toBeDefined(); - expect(data.session.workingDir).toBe('/tmp'); - expect(data.session.status).toBe('idle'); + expect(data.data.session).toBeDefined(); + expect(data.data.session.id).toBeDefined(); + expect(data.data.session.workingDir).toBe('/tmp'); + expect(data.data.session.status).toBe('idle'); }); }); @@ -179,7 +179,8 @@ describe('Session Management', () => { const response = await fetch(`${baseUrl}/api/sessions`); const data = await response.json(); - expect(Array.isArray(data)).toBe(true); + expect(data.success).toBe(true); + expect(Array.isArray(data.data)).toBe(true); }); }); @@ -188,12 +189,13 @@ describe('Session Management', () => { const response = await fetch(`${baseUrl}/api/status`); const data = await response.json(); - expect(data).toHaveProperty('sessions'); - expect(data).toHaveProperty('scheduledRuns'); - expect(data).toHaveProperty('respawnStatus'); - expect(data).toHaveProperty('timestamp'); - expect(Array.isArray(data.sessions)).toBe(true); - expect(Array.isArray(data.scheduledRuns)).toBe(true); + expect(data.success).toBe(true); + expect(data.data).toHaveProperty('sessions'); + expect(data.data).toHaveProperty('scheduledRuns'); + expect(data.data).toHaveProperty('respawnStatus'); + expect(data.data).toHaveProperty('timestamp'); + expect(Array.isArray(data.data.sessions)).toBe(true); + expect(Array.isArray(data.data.scheduledRuns)).toBe(true); }); }); }); @@ -224,7 +226,8 @@ describe('Case Management', () => { const response = await fetch(`${baseUrl}/api/cases`); const data = await response.json(); - expect(Array.isArray(data)).toBe(true); + expect(data.success).toBe(true); + expect(Array.isArray(data.data)).toBe(true); }); }); @@ -298,9 +301,10 @@ describe('Case Management', () => { const response = await fetch(`${baseUrl}/api/cases/${testCaseName}`); const data = await response.json(); - expect(data.name).toBe(testCaseName); - expect(data.path).toBeDefined(); - expect(data.hasClaudeMd).toBe(true); + expect(data.success).toBe(true); + expect(data.data.name).toBe(testCaseName); + expect(data.data.path).toBeDefined(); + expect(data.data.hasClaudeMd).toBe(true); }); it('should return error for non-existent case', async () => { diff --git a/test/ralph-integration.test.ts b/test/ralph-integration.test.ts index 27521066..46316339 100644 --- a/test/ralph-integration.test.ts +++ b/test/ralph-integration.test.ts @@ -61,7 +61,7 @@ describe('Ralph Integration Tests', () => { const data = await res.json(); expect(res.status).toBe(200); - expect(Array.isArray(data)).toBe(true); + expect(Array.isArray(data.data)).toBe(true); }); it('should create a new session via quick-start', async () => { @@ -76,8 +76,8 @@ describe('Ralph Integration Tests', () => { const data = await res.json(); expect(data.success).toBe(true); - expect(data.sessionId).toBeDefined(); - createdSessions.push(data.sessionId); + expect(data.data.sessionId).toBeDefined(); + createdSessions.push(data.data.sessionId); }); it('should get session details by ID', async () => { @@ -91,15 +91,15 @@ describe('Ralph Integration Tests', () => { body: JSON.stringify({ caseName }), }); const createData = await createRes.json(); - createdSessions.push(createData.sessionId); + createdSessions.push(createData.data.sessionId); // Get session details - const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}`); + const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}`); const data = await res.json(); expect(res.status).toBe(200); - expect(data.id).toBe(createData.sessionId); - expect(data.workingDir).toContain(caseName); + expect(data.data.id).toBe(createData.data.sessionId); + expect(data.data.workingDir).toContain(caseName); }); it('should return error for non-existent session', async () => { @@ -122,7 +122,7 @@ describe('Ralph Integration Tests', () => { body: JSON.stringify({ caseName }), }); const createData = await createRes.json(); - const sessionId = createData.sessionId; + const sessionId = createData.data.sessionId; // Delete session const deleteRes = await fetch(`${baseUrl}/api/sessions/${sessionId}`, { @@ -152,13 +152,13 @@ describe('Ralph Integration Tests', () => { const data = await res.json(); expect(data.success).toBe(true); - expect(data.sessionId).toBeDefined(); - createdSessions.push(data.sessionId); + expect(data.data.sessionId).toBeDefined(); + createdSessions.push(data.data.sessionId); // Verify mode - const sessionRes = await fetch(`${baseUrl}/api/sessions/${data.sessionId}`); + const sessionRes = await fetch(`${baseUrl}/api/sessions/${data.data.sessionId}`); const sessionData = await sessionRes.json(); - expect(sessionData.mode).toBe('shell'); + expect(sessionData.data.mode).toBe('shell'); }); }); @@ -175,9 +175,9 @@ describe('Ralph Integration Tests', () => { body: JSON.stringify({ caseName }), }); const createData = await createRes.json(); - createdSessions.push(createData.sessionId); + createdSessions.push(createData.data.sessionId); - const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-state`); + const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-state`); const data = await res.json(); expect(res.status).toBe(200); @@ -206,7 +206,7 @@ describe('Ralph Integration Tests', () => { const data = await res.json(); expect(res.status).toBe(200); - expect(Array.isArray(data)).toBe(true); + expect(Array.isArray(data.data)).toBe(true); }); it('should create a new case', async () => { @@ -244,7 +244,7 @@ describe('Ralph Integration Tests', () => { }); const data = await res.json(); - expect(res.status).toBe(200); + expect(res.status).toBe(409); expect(data.success).toBe(false); expect(data.error).toContain('already exists'); }); @@ -265,15 +265,15 @@ describe('Ralph Integration Tests', () => { const data = await res.json(); expect(res.status).toBe(200); - expect(data.name).toBe(caseName); - expect(data.path).toContain(caseName); + expect(data.data.name).toBe(caseName); + expect(data.data.path).toContain(caseName); }); it('should return error for non-existent case', async () => { const res = await fetch(`${baseUrl}/api/cases/non-existent-case-12345`); const data = await res.json(); - expect(res.status).toBe(200); + expect(res.status).toBe(404); expect(data.error).toBe('Case not found'); }); }); @@ -286,18 +286,18 @@ describe('Ralph Integration Tests', () => { const data = await res.json(); expect(res.status).toBe(200); - expect(data.sessions).toBeDefined(); - expect(data.scheduledRuns).toBeDefined(); - expect(data.respawnStatus).toBeDefined(); - expect(data.timestamp).toBeDefined(); + expect(data.data.sessions).toBeDefined(); + expect(data.data.scheduledRuns).toBeDefined(); + expect(data.data.respawnStatus).toBeDefined(); + expect(data.data.timestamp).toBeDefined(); }); it('should include sessions array in status', async () => { const res = await fetch(`${baseUrl}/api/status`); const data = await res.json(); - expect(Array.isArray(data.sessions)).toBe(true); - expect(typeof data.timestamp).toBe('number'); + expect(Array.isArray(data.data.sessions)).toBe(true); + expect(typeof data.data.timestamp).toBe('number'); }); }); @@ -309,9 +309,9 @@ describe('Ralph Integration Tests', () => { const data = await res.json(); expect(res.status).toBe(200); - expect(data.sessions).toBeDefined(); - expect(Array.isArray(data.sessions)).toBe(true); - expect(typeof data.muxAvailable).toBe('boolean'); + expect(data.data.sessions).toBeDefined(); + expect(Array.isArray(data.data.sessions)).toBe(true); + expect(typeof data.data.muxAvailable).toBe('boolean'); }); }); @@ -328,9 +328,9 @@ describe('Ralph Integration Tests', () => { body: JSON.stringify({ caseName }), }); const createData = await createRes.json(); - createdSessions.push(createData.sessionId); + createdSessions.push(createData.data.sessionId); - const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/input`, { + const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/input`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input: 'test input' }), @@ -351,9 +351,9 @@ describe('Ralph Integration Tests', () => { body: JSON.stringify({ caseName }), }); const createData = await createRes.json(); - createdSessions.push(createData.sessionId); + createdSessions.push(createData.data.sessionId); - const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/input`, { + const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/input`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input: null }), @@ -393,12 +393,12 @@ describe('Ralph Integration Tests', () => { }); const createData = await createRes.json(); expect(createData.success).toBe(true); - createdSessions.push(createData.sessionId); + createdSessions.push(createData.data.sessionId); // Wait for session to be ready await new Promise((r) => setTimeout(r, 200)); - const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/resize`, { + const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/resize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cols: 120, rows: 40 }), @@ -420,12 +420,12 @@ describe('Ralph Integration Tests', () => { }); const createData = await createRes.json(); expect(createData.success).toBe(true); - createdSessions.push(createData.sessionId); + createdSessions.push(createData.data.sessionId); // Wait for session to be ready await new Promise((r) => setTimeout(r, 200)); - const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/resize`, { + const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/resize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cols: -1, rows: 40 }), @@ -452,9 +452,9 @@ describe('Ralph Integration Tests', () => { }); const createData = await createRes.json(); expect(createData.success).toBe(true); - createdSessions.push(createData.sessionId); + createdSessions.push(createData.data.sessionId); - const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/auto-compact`, { + const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/auto-compact`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true, threshold: 100000 }), @@ -489,9 +489,9 @@ describe('Ralph Integration Tests', () => { }); const createData = await createRes.json(); expect(createData.success).toBe(true); - createdSessions.push(createData.sessionId); + createdSessions.push(createData.data.sessionId); - const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/auto-compact`, { + const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/auto-compact`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true, threshold: -100 }), @@ -516,9 +516,9 @@ describe('Ralph Integration Tests', () => { }); const createData = await createRes.json(); expect(createData.success).toBe(true); - createdSessions.push(createData.sessionId); + createdSessions.push(createData.data.sessionId); - const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/auto-clear`, { + const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/auto-clear`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true, threshold: 150000 }), @@ -553,9 +553,9 @@ describe('Ralph Integration Tests', () => { }); const createData = await createRes.json(); expect(createData.success).toBe(true); - createdSessions.push(createData.sessionId); + createdSessions.push(createData.data.sessionId); - const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/auto-clear`, { + const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/auto-clear`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true, threshold: -50 }), @@ -582,9 +582,9 @@ describe('Ralph Integration Tests', () => { }); const createData = await createRes.json(); expect(createData.success).toBe(true); - createdSessions.push(createData.sessionId); + createdSessions.push(createData.data.sessionId); - const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-config`, { + const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-config`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true }), @@ -606,9 +606,9 @@ describe('Ralph Integration Tests', () => { }); const createData = await createRes.json(); expect(createData.success).toBe(true); - createdSessions.push(createData.sessionId); + createdSessions.push(createData.data.sessionId); - const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-config`, { + const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-config`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reset: true }), @@ -643,9 +643,9 @@ describe('Ralph Integration Tests', () => { }); const createData = await createRes.json(); expect(createData.success).toBe(true); - createdSessions.push(createData.sessionId); + createdSessions.push(createData.data.sessionId); - const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-config`, { + const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-config`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reset: 'full' }), @@ -667,9 +667,9 @@ describe('Ralph Integration Tests', () => { }); const createData = await createRes.json(); expect(createData.success).toBe(true); - createdSessions.push(createData.sessionId); + createdSessions.push(createData.data.sessionId); - const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-config`, { + const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-config`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true, completionPhrase: 'DONE' }), @@ -680,7 +680,7 @@ describe('Ralph Integration Tests', () => { expect(data.success).toBe(true); // Verify the state was updated - const stateRes = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-state`); + const stateRes = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-state`); const stateData = await stateRes.json(); expect(stateData.success).toBe(true); @@ -698,9 +698,9 @@ describe('Ralph Integration Tests', () => { }); const createData = await createRes.json(); expect(createData.success).toBe(true); - createdSessions.push(createData.sessionId); + createdSessions.push(createData.data.sessionId); - const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-config`, { + const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-config`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disableAutoEnable: true }), @@ -726,9 +726,9 @@ describe('Ralph Integration Tests', () => { }); const createData = await createRes.json(); expect(createData.success).toBe(true); - createdSessions.push(createData.sessionId); + createdSessions.push(createData.data.sessionId); - const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-state`); + const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-state`); const data = await res.json(); expect(res.status).toBe(200); @@ -750,17 +750,17 @@ describe('Ralph Integration Tests', () => { }); const createData = await createRes.json(); expect(createData.success).toBe(true); - createdSessions.push(createData.sessionId); + createdSessions.push(createData.data.sessionId); // Enable ralph tracking - await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-config`, { + await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-config`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true, completionPhrase: 'TASK_DONE' }), }); // Check state - const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-state`); + const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-state`); const data = await res.json(); expect(data.success).toBe(true); @@ -779,17 +779,17 @@ describe('Ralph Integration Tests', () => { }); const createData = await createRes.json(); expect(createData.success).toBe(true); - createdSessions.push(createData.sessionId); + createdSessions.push(createData.data.sessionId); // Enable first - await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-config`, { + await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-config`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true }), }); // Then reset - const resetRes = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-config`, { + const resetRes = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-config`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reset: true }), @@ -799,7 +799,7 @@ describe('Ralph Integration Tests', () => { expect(resetData.success).toBe(true); // Check that todos are cleared - const stateRes = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-state`); + const stateRes = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-state`); const stateData = await stateRes.json(); expect(stateData.success).toBe(true); @@ -817,24 +817,24 @@ describe('Ralph Integration Tests', () => { }); const createData = await createRes.json(); expect(createData.success).toBe(true); - createdSessions.push(createData.sessionId); + createdSessions.push(createData.data.sessionId); // Enable tracking - await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-config`, { + await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-config`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true }), }); // Soft reset (keep enabled) - await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-config`, { + await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-config`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reset: true }), }); // Check state - const stateRes = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-state`); + const stateRes = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-state`); const stateData = await stateRes.json(); expect(stateData.success).toBe(true); @@ -852,24 +852,24 @@ describe('Ralph Integration Tests', () => { }); const createData = await createRes.json(); expect(createData.success).toBe(true); - createdSessions.push(createData.sessionId); + createdSessions.push(createData.data.sessionId); // Enable tracking - await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-config`, { + await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-config`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true }), }); // Full reset - await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-config`, { + await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-config`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reset: 'full' }), }); // Check state - const stateRes = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-state`); + const stateRes = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-state`); const stateData = await stateRes.json(); expect(stateData.success).toBe(true); @@ -891,13 +891,13 @@ describe('Ralph Integration Tests', () => { }); const createData = await createRes.json(); expect(createData.success).toBe(true); - createdSessions.push(createData.sessionId); + createdSessions.push(createData.data.sessionId); - const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/respawn`); + const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/respawn`); const data = await res.json(); expect(res.status).toBe(200); - expect(data.enabled).toBe(false); + expect(data.data.enabled).toBe(false); }); it('should return error for respawn start on non-existent session', async () => { @@ -924,14 +924,14 @@ describe('Ralph Integration Tests', () => { }); const createData = await createRes.json(); expect(createData.success).toBe(true); - createdSessions.push(createData.sessionId); + createdSessions.push(createData.data.sessionId); - const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/respawn/stop`, { + const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/respawn/stop`, { method: 'POST', }); const data = await res.json(); - expect(res.status).toBe(200); + expect(res.status).toBe(404); expect(data.success).toBe(false); expect(data.errorCode).toBe('NOT_FOUND'); }); @@ -947,9 +947,9 @@ describe('Ralph Integration Tests', () => { }); const createData = await createRes.json(); expect(createData.success).toBe(true); - createdSessions.push(createData.sessionId); + createdSessions.push(createData.data.sessionId); - const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/respawn/config`, { + const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/respawn/config`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ idleTimeoutMs: 10000 }), @@ -958,7 +958,7 @@ describe('Ralph Integration Tests', () => { expect(res.status).toBe(200); expect(data.success).toBe(true); - expect(data.config.idleTimeoutMs).toBe(10000); + expect(data.data.config.idleTimeoutMs).toBe(10000); }); }); @@ -976,9 +976,9 @@ describe('Ralph Integration Tests', () => { }); const createData = await createRes.json(); expect(createData.success).toBe(true); - createdSessions.push(createData.sessionId); + createdSessions.push(createData.data.sessionId); - const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/output`); + const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/output`); const data = await res.json(); expect(res.status).toBe(200); @@ -1008,14 +1008,14 @@ describe('Ralph Integration Tests', () => { }); const createData = await createRes.json(); expect(createData.success).toBe(true); - createdSessions.push(createData.sessionId); + createdSessions.push(createData.data.sessionId); - const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/terminal`); + const res = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/terminal`); const data = await res.json(); expect(res.status).toBe(200); - expect(data.terminalBuffer).toBeDefined(); - expect(data.status).toBeDefined(); + expect(data.data.terminalBuffer).toBeDefined(); + expect(data.data.status).toBeDefined(); }); it('should return error for terminal of non-existent session', async () => { diff --git a/test/routes/case-routes.test.ts b/test/routes/case-routes.test.ts index 4b620b50..ac1a60f5 100644 --- a/test/routes/case-routes.test.ts +++ b/test/routes/case-routes.test.ts @@ -3,10 +3,22 @@ * * Uses app.inject() — no real HTTP ports needed. * Port: N/A (app.inject doesn't open ports) + * + * Responses follow the uniform envelope contract: + * SUCCESS -> HTTP 2xx, body = { success: true, data: } + * ERROR -> HTTP 4xx/5xx, body = { success: false, error, errorCode } + * Bare handler returns are wrapped into { success:true, data } and returned + * error envelopes are mapped to their conventional HTTP status by the same + * preSerialization hook the production server installs (mirrored below so test + * behavior matches production exactly). */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; +import Fastify, { type FastifyInstance } from 'fastify'; +import fastifyCookie from '@fastify/cookie'; +import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; +import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; +import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js'; import { registerCaseRoutes } from '../../src/web/routes/case-routes.js'; // Mock filesystem modules @@ -51,11 +63,52 @@ const mockedReaddirSync = vi.mocked(readdirSync); const mockedReaddir = vi.mocked(fs.readdir); const mockedReadFile = vi.mocked(fs.readFile); +interface CaseRouteHarness { + app: FastifyInstance; + ctx: MockRouteContext; +} + +/** + * Build a route harness that mirrors production: cookie plugin, the shared + * route error handler, AND the uniform-envelope preSerialization hook (copied + * from src/web/server.ts) so bare handler returns become { success:true, data } + * and returned error envelopes get mapped to a conventional HTTP status. + */ +async function createEnvelopeHarness(): Promise { + const app = Fastify({ logger: false }); + await app.register(fastifyCookie); + + // Uniform response envelope (matches src/web/server.ts preSerialization hook). + app.addHook('preSerialization', (req, reply, payload: unknown, done) => { + if (!req.url.startsWith('/api')) return done(null, payload); + if (payload === null || typeof payload !== 'object') return done(null, payload); + if (Buffer.isBuffer(payload) || typeof (payload as { pipe?: unknown }).pipe === 'function') { + return done(null, payload); + } + const p = payload as { success?: unknown; errorCode?: unknown }; + if (p.success === false) { + if (reply.statusCode === 200 && typeof p.errorCode === 'string') { + reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode)); + } + return done(null, payload); + } + if (p.success === true) return done(null, payload); + return done(null, { success: true, data: payload }); + }); + + const ctx = createMockRouteContext(); + registerCaseRoutes(app, ctx as never); + installRouteErrorHandler(app); + await app.ready(); + + return { app, ctx }; +} + describe('case-routes', () => { - let harness: RouteTestHarness; + let harness: CaseRouteHarness; beforeEach(async () => { - harness = await createRouteTestHarness(registerCaseRoutes); + harness = await createEnvelopeHarness(); vi.clearAllMocks(); // Default: existsSync returns false, readFile throws ENOENT @@ -79,7 +132,8 @@ describe('case-routes', () => { }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body).toEqual([]); + expect(body.success).toBe(true); + expect(body.data).toEqual([]); }); it('returns cases from CASES_DIR', async () => { @@ -97,10 +151,10 @@ describe('case-routes', () => { }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body).toHaveLength(2); - expect(body[0].name).toBe('my-case'); - expect(body[1].name).toBe('other-case'); - expect(body[0].hasClaudeMd).toBe(false); + expect(body.data).toHaveLength(2); + expect(body.data[0].name).toBe('my-case'); + expect(body.data[1].name).toBe('other-case'); + expect(body.data[0].hasClaudeMd).toBe(false); }); it('includes hasClaudeMd flag', async () => { @@ -113,7 +167,7 @@ describe('case-routes', () => { }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body[0].hasClaudeMd).toBe(true); + expect(body.data[0].hasClaudeMd).toBe(true); }); it('includes linked cases from linked-cases.json', async () => { @@ -141,7 +195,7 @@ describe('case-routes', () => { expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); // Should have both regular and linked cases - expect(body.length).toBeGreaterThanOrEqual(1); + expect(body.data.length).toBeGreaterThanOrEqual(1); }); }); @@ -189,7 +243,7 @@ describe('case-routes', () => { url: '/api/cases', payload: { name: 'existing-case' }, }); - expect(res.statusCode).toBe(200); + expect(res.statusCode).toBe(409); const body = JSON.parse(res.body); expect(body.success).toBe(false); expect(body.error).toContain('already exists'); @@ -250,7 +304,7 @@ describe('case-routes', () => { url: '/api/cases/link', payload: { name: 'my-project', path: '/nonexistent/path' }, }); - expect(res.statusCode).toBe(200); + expect(res.statusCode).toBe(404); const body = JSON.parse(res.body); expect(body.success).toBe(false); expect(body.error).toContain('not found'); @@ -265,7 +319,7 @@ describe('case-routes', () => { url: '/api/cases/link', payload: { name: 'existing-case', path: '/home/user/project' }, }); - expect(res.statusCode).toBe(200); + expect(res.statusCode).toBe(409); const body = JSON.parse(res.body); expect(body.success).toBe(false); expect(body.error).toContain('already exists'); @@ -310,8 +364,9 @@ describe('case-routes', () => { }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body.name).toBe('my-case'); - expect(body.linked).toBe(true); + expect(body.success).toBe(true); + expect(body.data.name).toBe('my-case'); + expect(body.data.linked).toBe(true); }); it('returns CASES_DIR case when no linked case found', async () => { @@ -326,7 +381,8 @@ describe('case-routes', () => { }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body.name).toBe('regular-case'); + expect(body.success).toBe(true); + expect(body.data.name).toBe('regular-case'); }); it('returns error when case not found anywhere', async () => { @@ -337,7 +393,7 @@ describe('case-routes', () => { method: 'GET', url: '/api/cases/nonexistent', }); - expect(res.statusCode).toBe(200); + expect(res.statusCode).toBe(404); const body = JSON.parse(res.body); expect(body.success).toBe(false); expect(body.error).toContain('not found'); @@ -358,9 +414,9 @@ describe('case-routes', () => { expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); expect(body.success).toBe(true); - expect(body.exists).toBe(false); - expect(body.content).toBeNull(); - expect(body.todos).toEqual([]); + expect(body.data.exists).toBe(false); + expect(body.data.content).toBeNull(); + expect(body.data.todos).toEqual([]); }); it('parses fix plan with todos and stats', async () => { @@ -397,9 +453,9 @@ describe('case-routes', () => { expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); expect(body.success).toBe(true); - expect(body.exists).toBe(true); - expect(body.todos.length).toBeGreaterThan(0); - expect(body.stats.total).toBeGreaterThan(0); + expect(body.data.exists).toBe(true); + expect(body.data.todos.length).toBeGreaterThan(0); + expect(body.data.stats.total).toBeGreaterThan(0); }); }); @@ -413,7 +469,7 @@ describe('case-routes', () => { method: 'GET', url: '/api/cases/my-case/ralph-wizard/files', }); - expect(res.statusCode).toBe(200); + expect(res.statusCode).toBe(404); const body = JSON.parse(res.body); expect(body.success).toBe(false); expect(body.error).toContain('not found'); @@ -424,7 +480,7 @@ describe('case-routes', () => { method: 'GET', url: '/api/cases/..%2F..%2Fetc/ralph-wizard/files', }); - expect(res.statusCode).toBe(200); + expect(res.statusCode).toBe(400); const body = JSON.parse(res.body); expect(body.success).toBe(false); }); @@ -464,7 +520,7 @@ describe('case-routes', () => { method: 'GET', url: '/api/cases/my-case/ralph-wizard/file/research%2Fprompt.md', }); - expect(res.statusCode).toBe(200); + expect(res.statusCode).toBe(404); const body = JSON.parse(res.body); expect(body.success).toBe(false); }); diff --git a/test/routes/hook-event-routes.test.ts b/test/routes/hook-event-routes.test.ts index c8f5587a..279d2c3a 100644 --- a/test/routes/hook-event-routes.test.ts +++ b/test/routes/hook-event-routes.test.ts @@ -3,17 +3,73 @@ * * Uses app.inject() — no real HTTP ports needed. * Port: N/A (app.inject doesn't open ports) + * + * These tests assert the UNIFORM response envelope (stable HTTP contract): + * success -> 2xx, { success: true, data: } + * error -> 4xx/5xx, { success: false, error, errorCode } + * The production server applies this via a preSerialization hook (server.ts). + * The shared route harness doesn't install it, so we build a local harness here + * that mirrors production: the same preSerialization envelope hook + the shared + * route error handler, so assertions match the real wire format. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; +import Fastify, { type FastifyInstance } from 'fastify'; +import fastifyCookie from '@fastify/cookie'; +import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; +import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; +import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js'; import { registerHookEventRoutes } from '../../src/web/routes/hook-event-routes.js'; +interface LocalHarness { + app: FastifyInstance; + ctx: MockRouteContext; +} + +/** + * Build a Fastify instance that mirrors production's uniform-envelope behavior + * (server.ts preSerialization hook) so the test wire format matches the contract: + * bare payloads become { success: true, data }, and { success:false } error + * envelopes get the conventional HTTP status from their errorCode. + */ +async function createEnvelopeHarness( + registerFn: (app: FastifyInstance, ctx: MockRouteContext) => void +): Promise { + const app = Fastify({ logger: false }); + await app.register(fastifyCookie); + + const ctx = createMockRouteContext(); + registerFn(app, ctx); + + // Mirror production uniform response envelope (server.ts). + app.addHook('preSerialization', (req, reply, payload: unknown, done) => { + if (!req.url.startsWith('/api')) return done(null, payload); + if (payload === null || typeof payload !== 'object') return done(null, payload); + if (Buffer.isBuffer(payload) || typeof (payload as { pipe?: unknown }).pipe === 'function') { + return done(null, payload); + } + const p = payload as { success?: unknown; errorCode?: unknown }; + if (p.success === false) { + if (reply.statusCode === 200 && typeof p.errorCode === 'string') { + reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode)); + } + return done(null, payload); + } + if (p.success === true) return done(null, payload); + return done(null, { success: true, data: payload }); + }); + + installRouteErrorHandler(app); + await app.ready(); + + return { app, ctx }; +} + describe('hook-event-routes', () => { - let harness: RouteTestHarness; + let harness: LocalHarness; beforeEach(async () => { - harness = await createRouteTestHarness(registerHookEventRoutes); + harness = await createEnvelopeHarness(registerHookEventRoutes); }); afterEach(async () => { @@ -69,7 +125,7 @@ describe('hook-event-routes', () => { data: null, }, }); - expect(res.statusCode).toBe(200); + expect(res.statusCode).toBe(404); const body = JSON.parse(res.body); expect(body.success).toBe(false); expect(body.error).toBeDefined(); diff --git a/test/routes/mux-routes.test.ts b/test/routes/mux-routes.test.ts index fada50a7..999d6452 100644 --- a/test/routes/mux-routes.test.ts +++ b/test/routes/mux-routes.test.ts @@ -15,9 +15,7 @@ describe('mux-routes', () => { beforeEach(async () => { // Add mux methods that mux-routes needs but mock-route-context doesn't provide harness = await createRouteTestHarness(registerMuxRoutes); - harness.ctx.mux.getSessionsWithStats = vi.fn(async () => [ - { name: 'codeman-abc', pid: 1234, created: Date.now() }, - ]); + harness.ctx.mux.getSessionsWithStats = vi.fn(async () => [{ name: 'codeman-abc', pid: 1234, created: Date.now() }]); harness.ctx.mux.isAvailable = vi.fn(() => true); harness.ctx.mux.reconcileSessions = vi.fn(async () => ({ orphaned: [], @@ -74,7 +72,7 @@ describe('mux-routes', () => { }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body.success).toBe(true); + expect(body.killed).toBe(true); expect(harness.ctx.mux.killSession).toHaveBeenCalledWith('codeman-abc'); }); @@ -87,7 +85,7 @@ describe('mux-routes', () => { }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body.success).toBe(false); + expect(body.killed).toBe(false); }); }); @@ -119,7 +117,7 @@ describe('mux-routes', () => { }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body.success).toBe(true); + expect(body).toEqual({}); expect(harness.ctx.mux.startStatsCollection).toHaveBeenCalled(); }); }); @@ -134,7 +132,7 @@ describe('mux-routes', () => { }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body.success).toBe(true); + expect(body).toEqual({}); expect(harness.ctx.mux.stopStatsCollection).toHaveBeenCalled(); }); }); diff --git a/test/routes/push-routes.test.ts b/test/routes/push-routes.test.ts index 20ee2777..4b10e97b 100644 --- a/test/routes/push-routes.test.ts +++ b/test/routes/push-routes.test.ts @@ -131,7 +131,11 @@ describe('push-routes', () => { }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body.success).toBe(true); + // Handler returns a bare {} on success; the uniform envelope wraps it to + // { success:true, data:{} } in production. At the route-handler layer the + // harness sees the bare return, so the meaningful check is the empty body + // plus the pushStore call below. + expect(body).toEqual({}); expect(harness.ctx.pushStore.updatePreferences).toHaveBeenCalledWith('sub-123', { 'session:idle': true, 'session:error': true, @@ -176,7 +180,11 @@ describe('push-routes', () => { }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body.success).toBe(true); + // Handler returns a bare {} on success; the uniform envelope wraps it to + // { success:true, data:{} } in production. At the route-handler layer the + // harness sees the bare return, so the meaningful check is the empty body + // plus the pushStore call below. + expect(body).toEqual({}); expect(harness.ctx.pushStore.removeSubscription).toHaveBeenCalledWith('sub-123'); }); diff --git a/test/routes/ralph-routes.test.ts b/test/routes/ralph-routes.test.ts index b44db4de..f0050b47 100644 --- a/test/routes/ralph-routes.test.ts +++ b/test/routes/ralph-routes.test.ts @@ -62,7 +62,7 @@ describe('ralph-routes', () => { }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body.success).toBe(true); + expect(body).toEqual({}); }); it('enables ralph tracker', async () => { @@ -230,7 +230,7 @@ describe('ralph-routes', () => { }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body.success).toBe(true); + expect(body).toEqual({}); expect(tracker.resetCircuitBreaker).toHaveBeenCalled(); }); diff --git a/test/routes/respawn-routes.test.ts b/test/routes/respawn-routes.test.ts index c6867847..94095a7a 100644 --- a/test/routes/respawn-routes.test.ts +++ b/test/routes/respawn-routes.test.ts @@ -69,7 +69,9 @@ describe('respawn-routes', () => { }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body.success).toBe(true); + // Handler now returns a bare payload; the uniform envelope wraps it to + // { success:true, data:{ config, active } } in production. At the route-handler + // layer the harness sees the bare return, so config/active stay top-level. expect(body.config).toBeNull(); expect(body.active).toBe(false); }); @@ -88,7 +90,8 @@ describe('respawn-routes', () => { }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body.success).toBe(true); + // Handler returns a bare { config, active } payload (wrapped under data by the + // uniform envelope in production); the harness sees the bare return. expect(body.active).toBe(true); expect(body.config.idleTimeoutMs).toBe(5000); }); @@ -121,7 +124,11 @@ describe('respawn-routes', () => { }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body.success).toBe(true); + // Handler returns a bare {} on success; the uniform envelope wraps it to + // { success:true, data:{} } in production. At the route-handler layer the + // harness sees the bare return, so success is signalled by the 200 + empty + // body plus the side effects asserted below. + expect(body).toEqual({}); expect(mockController.stop).toHaveBeenCalled(); expect(harness.ctx.respawnControllers.has(harness.ctx._sessionId)).toBe(false); expect(harness.ctx.broadcast).toHaveBeenCalledWith('respawn:stopped', { @@ -192,7 +199,9 @@ describe('respawn-routes', () => { }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body.success).toBe(true); + // Handler returns a bare { config } payload (wrapped under data by the uniform + // envelope in production); the harness sees the bare return. + expect(body.config).toBeDefined(); expect(mockController.updateConfig).toHaveBeenCalled(); expect(harness.ctx.broadcast).toHaveBeenCalledWith( 'respawn:configUpdated', @@ -208,7 +217,9 @@ describe('respawn-routes', () => { }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body.success).toBe(true); + // Handler returns a bare { config } payload (wrapped under data by the uniform + // envelope in production); the harness sees the bare return. + expect(body.config).toBeDefined(); expect(harness.ctx.mux.updateRespawnConfig).toHaveBeenCalled(); }); }); diff --git a/test/routes/scheduled-routes.test.ts b/test/routes/scheduled-routes.test.ts index bd1f3b5e..b9e532f4 100644 --- a/test/routes/scheduled-routes.test.ts +++ b/test/routes/scheduled-routes.test.ts @@ -102,7 +102,9 @@ describe('scheduled-routes', () => { }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body.success).toBe(true); + // Handler returns a bare { run } now; the uniform envelope wraps it to + // { success:true, data:{ run } } in production. At the route-handler layer + // the harness sees the bare return, so assert body.run directly. expect(body.run.id).toBe('new-run'); }); @@ -169,7 +171,9 @@ describe('scheduled-routes', () => { }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body.success).toBe(true); + // Bare { run } return (envelope-wrapped to { success:true, data:{ run } } + // in production; harness sees the bare return). + expect(body.run).toBeDefined(); // Should default to 60 minutes expect(harness.ctx.startScheduledRun).toHaveBeenCalledWith('test', expect.any(String), 60); }); @@ -200,7 +204,9 @@ describe('scheduled-routes', () => { }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body.success).toBe(true); + // Handler returns a bare {} on success; the uniform envelope wraps it to + // { success:true, data:{} } in production. The harness sees the bare return. + expect(body).toEqual({}); expect(harness.ctx.stopScheduledRun).toHaveBeenCalledWith('run-to-delete'); }); diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index b2cf213c..76eaad61 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -3,10 +3,22 @@ * * Uses app.inject() (Fastify's built-in test helper) — no real HTTP ports needed. * Port: N/A (app.inject doesn't open ports) + * + * These tests assert the UNIFORM response envelope (stable HTTP contract): + * success -> 2xx, { success: true, data: } + * error -> 4xx/5xx, { success: false, error, errorCode } + * The production server applies this via a preSerialization hook (server.ts). + * The shared route harness doesn't install it, so we build a local harness here + * that mirrors production: the same preSerialization envelope hook + the shared + * route error handler, so assertions match the real wire format. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; +import Fastify, { type FastifyInstance } from 'fastify'; +import fastifyCookie from '@fastify/cookie'; +import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; +import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; +import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js'; // Mock execFile so the send-key route's `tmux` invocation is observable (not run for real). const { execFile } = vi.hoisted(() => ({ execFile: vi.fn() })); @@ -17,11 +29,55 @@ vi.mock('node:child_process', async (orig) => { import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; +interface LocalHarness { + app: FastifyInstance; + ctx: MockRouteContext; +} + +/** + * Build a Fastify instance that mirrors production's uniform-envelope behavior + * (server.ts preSerialization hook) so the test wire format matches the contract: + * bare payloads become { success: true, data }, and { success:false } error + * envelopes get the conventional HTTP status from their errorCode. + */ +async function createEnvelopeHarness( + registerFn: (app: FastifyInstance, ctx: MockRouteContext) => void +): Promise { + const app = Fastify({ logger: false }); + await app.register(fastifyCookie); + + const ctx = createMockRouteContext(); + registerFn(app, ctx); + + // Mirror production uniform response envelope (server.ts). + app.addHook('preSerialization', (req, reply, payload: unknown, done) => { + if (!req.url.startsWith('/api')) return done(null, payload); + if (payload === null || typeof payload !== 'object') return done(null, payload); + if (Buffer.isBuffer(payload) || typeof (payload as { pipe?: unknown }).pipe === 'function') { + return done(null, payload); + } + const p = payload as { success?: unknown; errorCode?: unknown }; + if (p.success === false) { + if (reply.statusCode === 200 && typeof p.errorCode === 'string') { + reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode)); + } + return done(null, payload); + } + if (p.success === true) return done(null, payload); + return done(null, { success: true, data: payload }); + }); + + installRouteErrorHandler(app); + await app.ready(); + + return { app, ctx }; +} + describe('session-routes', () => { - let harness: RouteTestHarness; + let harness: LocalHarness; beforeEach(async () => { - harness = await createRouteTestHarness(registerSessionRoutes); + harness = await createEnvelopeHarness(registerSessionRoutes); }); afterEach(async () => { @@ -73,15 +129,15 @@ describe('session-routes', () => { const res = await harness.app.inject({ method: 'GET', url: '/api/sessions' }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(Array.isArray(body)).toBe(true); - expect(body).toHaveLength(1); + expect(Array.isArray(body.data)).toBe(true); + expect(body.data).toHaveLength(1); }); it('returns empty array when no sessions', async () => { harness.ctx.sessions.clear(); const res = await harness.app.inject({ method: 'GET', url: '/api/sessions' }); expect(res.statusCode).toBe(200); - expect(JSON.parse(res.body)).toEqual([]); + expect(JSON.parse(res.body).data).toEqual([]); }); }); @@ -95,7 +151,7 @@ describe('session-routes', () => { }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body.id).toBe(harness.ctx._sessionId); + expect(body.data.id).toBe(harness.ctx._sessionId); }); it('returns error for unknown session', async () => { @@ -129,7 +185,7 @@ describe('session-routes', () => { method: 'DELETE', url: '/api/sessions/nonexistent', }); - expect(res.statusCode).toBe(200); + expect(res.statusCode).toBe(404); const body = JSON.parse(res.body); expect(body.success).toBe(false); }); @@ -163,7 +219,7 @@ describe('session-routes', () => { expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); expect(body.success).toBe(true); - expect(body.name).toBe('new-name'); + expect(body.data.name).toBe('new-name'); expect(harness.ctx.persistSessionState).toHaveBeenCalled(); expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:updated', expect.anything()); }); @@ -192,7 +248,7 @@ describe('session-routes', () => { expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); expect(body.success).toBe(true); - expect(body.color).toBe('blue'); + expect(body.data.color).toBe('blue'); }); it('rejects invalid color', async () => { @@ -201,7 +257,7 @@ describe('session-routes', () => { url: `/api/sessions/${harness.ctx._sessionId}/color`, payload: { color: 'neon-rainbow' }, }); - expect(res.statusCode).toBe(200); + expect(res.statusCode).toBe(400); const body = JSON.parse(res.body); expect(body.success).toBe(false); }); @@ -304,7 +360,7 @@ describe('session-routes', () => { }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body.terminalBuffer).toBeDefined(); + expect(body.data.terminalBuffer).toBeDefined(); }); it('returns error for unknown session', async () => { @@ -361,7 +417,7 @@ describe('session-routes', () => { url: `/api/sessions/${harness.ctx._sessionId}/run`, payload: { prompt: 'test' }, }); - expect(res.statusCode).toBe(200); + expect(res.statusCode).toBe(409); const body = JSON.parse(res.body); expect(body.success).toBe(false); }); @@ -397,7 +453,7 @@ describe('session-routes', () => { method: 'POST', url: `/api/sessions/${harness.ctx._sessionId}/interactive`, }); - expect(res.statusCode).toBe(200); + expect(res.statusCode).toBe(409); const body = JSON.parse(res.body); expect(body.success).toBe(false); }); @@ -423,7 +479,7 @@ describe('session-routes', () => { method: 'POST', url: `/api/sessions/${harness.ctx._sessionId}/shell`, }); - expect(res.statusCode).toBe(200); + expect(res.statusCode).toBe(409); const body = JSON.parse(res.body); expect(body.success).toBe(false); }); @@ -512,8 +568,8 @@ describe('session-routes', () => { }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body).toHaveProperty('sessions'); - expect(Array.isArray(body.sessions)).toBe(true); + expect(body.data).toHaveProperty('sessions'); + expect(Array.isArray(body.data.sessions)).toBe(true); }); it('sessions have required fields', async () => { @@ -522,7 +578,7 @@ describe('session-routes', () => { url: '/api/history/sessions', }); const body = JSON.parse(res.body); - for (const session of body.sessions) { + for (const session of body.data.sessions) { expect(session).toHaveProperty('sessionId'); expect(session).toHaveProperty('workingDir'); expect(session).toHaveProperty('projectKey'); @@ -539,7 +595,7 @@ describe('session-routes', () => { url: '/api/history/sessions', }); const body = JSON.parse(res.body); - const dates = body.sessions.map((s: { lastModified: string }) => new Date(s.lastModified).getTime()); + const dates = body.data.sessions.map((s: { lastModified: string }) => new Date(s.lastModified).getTime()); for (let i = 1; i < dates.length; i++) { expect(dates[i - 1]).toBeGreaterThanOrEqual(dates[i]); } @@ -551,7 +607,7 @@ describe('session-routes', () => { url: '/api/history/sessions', }); const body = JSON.parse(res.body); - expect(body.sessions.length).toBeLessThanOrEqual(50); + expect(body.data.sessions.length).toBeLessThanOrEqual(50); }); }); @@ -572,7 +628,7 @@ describe('session-routes', () => { expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); expect(body.success).toBe(true); - expect(body.session).toBeDefined(); + expect(body.data.session).toBeDefined(); }); it('rejects invalid resumeSessionId format', async () => { diff --git a/test/routes/system-routes.test.ts b/test/routes/system-routes.test.ts index 18a664d1..e5d4f87d 100644 --- a/test/routes/system-routes.test.ts +++ b/test/routes/system-routes.test.ts @@ -140,7 +140,6 @@ describe('system-routes', () => { const res = await harness.app.inject({ method: 'GET', url: '/api/config' }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body.success).toBe(true); expect(body.config).toBeDefined(); }); }); @@ -156,7 +155,7 @@ describe('system-routes', () => { }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body.success).toBe(true); + expect(body.config).toBeDefined(); expect(harness.ctx.store.setConfig).toHaveBeenCalled(); }); @@ -179,7 +178,6 @@ describe('system-routes', () => { const res = await harness.app.inject({ method: 'GET', url: '/api/stats' }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body.success).toBe(true); expect(body.stats).toBeDefined(); }); }); @@ -191,7 +189,6 @@ describe('system-routes', () => { const res = await harness.app.inject({ method: 'GET', url: '/api/token-stats' }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body.success).toBe(true); expect(body.daily).toBeDefined(); expect(body.totals).toBeDefined(); }); @@ -230,7 +227,6 @@ describe('system-routes', () => { const res = await harness.app.inject({ method: 'POST', url: '/api/cleanup-state' }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body.success).toBe(true); expect(body.cleanedSessions).toBe(0); expect(harness.ctx.store.cleanupStaleSessions).toHaveBeenCalled(); }); @@ -266,7 +262,7 @@ describe('system-routes', () => { }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body.success).toBe(true); + expect(body).toEqual({}); }); it('revokes a specific session token', async () => { @@ -280,7 +276,6 @@ describe('system-routes', () => { payload: { sessionToken: 'tok-123' }, }); expect(res.statusCode).toBe(200); - expect(JSON.parse(res.body).success).toBe(true); expect(authSessions.has('tok-123')).toBe(false); }); @@ -333,8 +328,6 @@ describe('system-routes', () => { payload: { showSystemStats: true, subagentTrackingEnabled: false }, }); expect(res.statusCode).toBe(200); - const body = JSON.parse(res.body); - expect(body.success).toBe(true); expect(mockedWriteFile).toHaveBeenCalled(); }); @@ -347,7 +340,7 @@ describe('system-routes', () => { payload: { showTokenCount: false }, }); expect(res.statusCode).toBe(200); - expect(JSON.parse(res.body).success).toBe(true); + expect(JSON.parse(res.body)).toEqual({}); // Verify writeFile was called with merged content const writtenContent = JSON.parse(mockedWriteFile.mock.calls[0][1] as string); @@ -377,7 +370,7 @@ describe('system-routes', () => { payload: { lastUsedCase: 'my-test-case' }, }); expect(res.statusCode).toBe(200); - expect(JSON.parse(res.body).success).toBe(true); + expect(JSON.parse(res.body)).toEqual({}); const writtenContent = JSON.parse(mockedWriteFile.mock.calls[0][1] as string); expect(writtenContent.lastUsedCase).toBe('my-test-case'); @@ -439,7 +432,7 @@ describe('system-routes', () => { payload: { minimized: { 'agent-1': true }, open: ['agent-2'] }, }); expect(res.statusCode).toBe(200); - expect(JSON.parse(res.body).success).toBe(true); + expect(JSON.parse(res.body)).toEqual({}); expect(mockedWriteFile).toHaveBeenCalled(); }); @@ -450,7 +443,7 @@ describe('system-routes', () => { payload: { minimized: {}, open: [] }, }); expect(res.statusCode).toBe(200); - expect(JSON.parse(res.body).success).toBe(true); + expect(JSON.parse(res.body)).toEqual({}); }); it('rejects invalid minimized values', async () => { @@ -496,7 +489,7 @@ describe('system-routes', () => { payload: { 'agent-1': 'session-abc' }, }); expect(res.statusCode).toBe(200); - expect(JSON.parse(res.body).success).toBe(true); + expect(JSON.parse(res.body)).toEqual({}); expect(mockedWriteFile).toHaveBeenCalled(); }); @@ -507,7 +500,7 @@ describe('system-routes', () => { payload: {}, }); expect(res.statusCode).toBe(200); - expect(JSON.parse(res.body).success).toBe(true); + expect(JSON.parse(res.body)).toEqual({}); }); it('rejects non-string values in parent map', async () => { @@ -629,7 +622,6 @@ describe('system-routes', () => { const res = await harness.app.inject({ method: 'GET', url: '/api/session-lifecycle' }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body.success).toBe(true); expect(body.entries).toEqual(mockEntries); }); @@ -754,7 +746,7 @@ describe('system-routes', () => { payload: { model: 'claude-3', temperature: 0.5 }, }); expect(res.statusCode).toBe(200); - expect(JSON.parse(res.body).success).toBe(true); + expect(JSON.parse(res.body)).toEqual({}); expect(mockedWriteFile).toHaveBeenCalled(); // Verify the written content contains modelConfig @@ -771,7 +763,7 @@ describe('system-routes', () => { payload: { model: 'new-model' }, }); expect(res.statusCode).toBe(200); - expect(JSON.parse(res.body).success).toBe(true); + expect(JSON.parse(res.body)).toEqual({}); const writtenContent = JSON.parse(mockedWriteFile.mock.calls[0][1] as string); expect(writtenContent.showCost).toBe(true); @@ -972,7 +964,7 @@ describe('system-routes', () => { const res = await harness.app.inject({ method: 'POST', url: '/api/tunnel/qr/regenerate' }); expect(res.statusCode).toBe(200); - expect(JSON.parse(res.body).success).toBe(true); + expect(JSON.parse(res.body)).toEqual({}); }); }); }); diff --git a/test/routes/system-span-displays.test.ts b/test/routes/system-span-displays.test.ts index 9a5359e5..6198fef6 100644 --- a/test/routes/system-span-displays.test.ts +++ b/test/routes/system-span-displays.test.ts @@ -65,7 +65,10 @@ describe('POST /api/system/span-displays', () => { headers: { host: 'localhost:5000' }, }); expect(res.statusCode).toBe(200); - expect(res.json()).toMatchObject({ success: true, url: 'http://localhost:5000' }); + // Handler returns a bare { url } on success; the uniform envelope wraps it to + // { success:true, data:{ url } } in production. At the route-handler layer the + // harness sees the bare return, so we assert on body.url directly. + expect(res.json()).toMatchObject({ url: 'http://localhost:5000' }); expect(spawnMock).toHaveBeenCalledTimes(1); const [cmd, args] = spawnMock.mock.calls[0] as [string, string[]]; expect(cmd).toBe('bash'); diff --git a/test/scheduled-runs.test.ts b/test/scheduled-runs.test.ts index b4c93484..67a6aab3 100644 --- a/test/scheduled-runs.test.ts +++ b/test/scheduled-runs.test.ts @@ -29,7 +29,7 @@ describe('Scheduled Runs API', () => { const response = await fetch(`${baseUrl}/api/scheduled`); const data = await response.json(); - expect(Array.isArray(data)).toBe(true); + expect(Array.isArray(data.data)).toBe(true); }); }); @@ -47,16 +47,16 @@ describe('Scheduled Runs API', () => { const data = await response.json(); expect(data.success).toBe(true); - expect(data.run).toBeDefined(); - expect(data.run.id).toBeDefined(); - expect(data.run.prompt).toBe('echo test'); - expect(data.run.durationMinutes).toBe(1); - expect(data.run.status).toBe('running'); + expect(data.data.run).toBeDefined(); + expect(data.data.run.id).toBeDefined(); + expect(data.data.run.prompt).toBe('echo test'); + expect(data.data.run.durationMinutes).toBe(1); + expect(data.data.run.status).toBe('running'); - createdRuns.push(data.run.id); + createdRuns.push(data.data.run.id); // Stop the run immediately to avoid resource usage - await fetch(`${baseUrl}/api/scheduled/${data.run.id}`, { method: 'DELETE' }); + await fetch(`${baseUrl}/api/scheduled/${data.data.run.id}`, { method: 'DELETE' }); }); it('should set correct timestamps', async () => { @@ -74,12 +74,12 @@ describe('Scheduled Runs API', () => { const afterCreate = Date.now(); - expect(data.run.startedAt).toBeGreaterThanOrEqual(beforeCreate); - expect(data.run.startedAt).toBeLessThanOrEqual(afterCreate); - expect(data.run.endAt).toBe(data.run.startedAt + 5 * 60 * 1000); + expect(data.data.run.startedAt).toBeGreaterThanOrEqual(beforeCreate); + expect(data.data.run.startedAt).toBeLessThanOrEqual(afterCreate); + expect(data.data.run.endAt).toBe(data.data.run.startedAt + 5 * 60 * 1000); - createdRuns.push(data.run.id); - await fetch(`${baseUrl}/api/scheduled/${data.run.id}`, { method: 'DELETE' }); + createdRuns.push(data.data.run.id); + await fetch(`${baseUrl}/api/scheduled/${data.data.run.id}`, { method: 'DELETE' }); }); it('should initialize with zero completed tasks and cost', async () => { @@ -93,13 +93,13 @@ describe('Scheduled Runs API', () => { }); const data = await response.json(); - expect(data.run.completedTasks).toBe(0); - expect(data.run.totalCost).toBe(0); + expect(data.data.run.completedTasks).toBe(0); + expect(data.data.run.totalCost).toBe(0); // Logs may have 1 or more entries depending on timing - expect(data.run.logs.length).toBeGreaterThanOrEqual(1); + expect(data.data.run.logs.length).toBeGreaterThanOrEqual(1); - createdRuns.push(data.run.id); - await fetch(`${baseUrl}/api/scheduled/${data.run.id}`, { method: 'DELETE' }); + createdRuns.push(data.data.run.id); + await fetch(`${baseUrl}/api/scheduled/${data.data.run.id}`, { method: 'DELETE' }); }); }); @@ -115,15 +115,15 @@ describe('Scheduled Runs API', () => { }), }); const createData = await createRes.json(); - const runId = createData.run.id; + const runId = createData.data.run.id; createdRuns.push(runId); // Get the run const response = await fetch(`${baseUrl}/api/scheduled/${runId}`); const data = await response.json(); - expect(data.id).toBe(runId); - expect(data.prompt).toBe('test get'); + expect(data.data.id).toBe(runId); + expect(data.data.prompt).toBe('test get'); await fetch(`${baseUrl}/api/scheduled/${runId}`, { method: 'DELETE' }); }); @@ -148,7 +148,7 @@ describe('Scheduled Runs API', () => { }), }); const createData = await createRes.json(); - const runId = createData.run.id; + const runId = createData.data.run.id; // Delete/stop the run const response = await fetch(`${baseUrl}/api/scheduled/${runId}`, { @@ -161,7 +161,7 @@ describe('Scheduled Runs API', () => { // Verify it's stopped const getRes = await fetch(`${baseUrl}/api/scheduled/${runId}`); const runData = await getRes.json(); - expect(runData.status).toBe('stopped'); + expect(runData.data.status).toBe('stopped'); }); it('should return error for non-existent run', async () => { @@ -204,7 +204,9 @@ describe('Quick Run API', () => { }); const data = await response.json(); - expect(data.sessionId).toBeDefined(); + // On success the payload is wrapped ({ success:true, data:{ sessionId, ... } }); + // on failure the handler returns a bare { success:false, sessionId, error }. + expect(data.data?.sessionId ?? data.sessionId).toBeDefined(); // Note: success/failure depends on Claude actually running }); diff --git a/test/session-cleanup.test.ts b/test/session-cleanup.test.ts index 7118fb76..c5d760b8 100644 --- a/test/session-cleanup.test.ts +++ b/test/session-cleanup.test.ts @@ -60,14 +60,14 @@ describe('Session Cleanup', () => { expect(quickStartData.success).toBe(true); // Delete the session - const deleteRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.sessionId}`, { + const deleteRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.data.sessionId}`, { method: 'DELETE', }); const deleteData = await deleteRes.json(); expect(deleteData.success).toBe(true); // Verify session is gone - const getRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.sessionId}`); + const getRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.data.sessionId}`); const getData = await getRes.json(); expect(getData.error).toContain('not found'); }); @@ -87,7 +87,7 @@ describe('Session Cleanup', () => { }); const data = await res.json(); expect(data.success).toBe(true); - sessionIds.push(data.sessionId); + sessionIds.push(data.data.sessionId); } // Delete all sessions @@ -103,7 +103,7 @@ describe('Session Cleanup', () => { const listRes = await fetch(`${baseUrl}/api/sessions`); const sessions = await listRes.json(); for (const id of sessionIds) { - expect(sessions.find((s: any) => s.id === id)).toBeUndefined(); + expect(sessions.data.find((s: any) => s.id === id)).toBeUndefined(); } }, 60000); // Extended timeout for multi-session test }); @@ -129,19 +129,19 @@ describe('Session Cleanup', () => { expect(sessionData.success).toBe(true); // Start interactive with respawn - const interactiveRes = await fetch(`${baseUrl}/api/sessions/${sessionData.session.id}/interactive-respawn`, { + const interactiveRes = await fetch(`${baseUrl}/api/sessions/${sessionData.data.session.id}/interactive-respawn`, { method: 'POST', }); const interactiveData = await interactiveRes.json(); expect(interactiveData.success).toBe(true); // Verify respawn is running - const respawnRes = await fetch(`${baseUrl}/api/sessions/${sessionData.session.id}/respawn`); + const respawnRes = await fetch(`${baseUrl}/api/sessions/${sessionData.data.session.id}/respawn`); const respawnData = await respawnRes.json(); - expect(respawnData.enabled).toBe(true); + expect(respawnData.data.enabled).toBe(true); // Delete session - await fetch(`${baseUrl}/api/sessions/${sessionData.session.id}`, { + await fetch(`${baseUrl}/api/sessions/${sessionData.data.session.id}`, { method: 'DELETE', }); @@ -149,10 +149,10 @@ describe('Session Cleanup', () => { await new Promise((resolve) => setTimeout(resolve, 500)); // Verify respawn controller is cleaned up (enabled: false when controller doesn't exist) - const respawnAfterRes = await fetch(`${baseUrl}/api/sessions/${sessionData.session.id}/respawn`); + const respawnAfterRes = await fetch(`${baseUrl}/api/sessions/${sessionData.data.session.id}/respawn`); const respawnAfterData = await respawnAfterRes.json(); // The respawn endpoint returns enabled: false when there's no controller - expect(respawnAfterData.enabled).toBe(false); + expect(respawnAfterData.data.enabled).toBe(false); }); }); @@ -172,16 +172,16 @@ describe('Session Cleanup', () => { expect(createData.success).toBe(true); // Stop the scheduled run - const stopRes = await fetch(`${baseUrl}/api/scheduled/${createData.run.id}`, { + const stopRes = await fetch(`${baseUrl}/api/scheduled/${createData.data.run.id}`, { method: 'DELETE', }); const stopData = await stopRes.json(); expect(stopData.success).toBe(true); // Verify status is stopped - const getRes = await fetch(`${baseUrl}/api/scheduled/${createData.run.id}`); + const getRes = await fetch(`${baseUrl}/api/scheduled/${createData.data.run.id}`); const getData = await getRes.json(); - expect(getData.status).toBe('stopped'); + expect(getData.data.status).toBe('stopped'); }); }); }); @@ -211,7 +211,7 @@ describe('Resource Management', () => { // Get initial session count (may have restored sessions from other tests) const initialRes = await fetch(`${baseUrl}/api/sessions`); const initialSessions = await initialRes.json(); - const initialCount = initialSessions.length; + const initialCount = initialSessions.data.length; const iterations = 5; const createdSessionIds: string[] = []; @@ -228,10 +228,10 @@ describe('Resource Management', () => { }); const createData = await createRes.json(); expect(createData.success).toBe(true); - createdSessionIds.push(createData.sessionId); + createdSessionIds.push(createData.data.sessionId); // Delete immediately - const deleteRes = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}`, { + const deleteRes = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}`, { method: 'DELETE', }); const deleteData = await deleteRes.json(); @@ -250,11 +250,11 @@ describe('Resource Management', () => { // None of the sessions we created should still exist for (const sessionId of createdSessionIds) { - expect(sessions.find((s: { id: string }) => s.id === sessionId)).toBeUndefined(); + expect(sessions.data.find((s: { id: string }) => s.id === sessionId)).toBeUndefined(); } // Session count should be back to initial (or less if some restored sessions were cleaned up) - expect(sessions.length).toBeLessThanOrEqual(initialCount); + expect(sessions.data.length).toBeLessThanOrEqual(initialCount); }); it('should clear terminal buffer after session stop', async () => { @@ -274,18 +274,18 @@ describe('Resource Management', () => { await new Promise((resolve) => setTimeout(resolve, 2000)); // Get terminal buffer before stop - may or may not have content depending on timing - const terminalRes = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/terminal`); + const terminalRes = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/terminal`); const terminalData = await terminalRes.json(); // Just verify the property exists - expect(terminalData.terminalBuffer).toBeDefined(); + expect(terminalData.data.terminalBuffer).toBeDefined(); // Delete session - await fetch(`${baseUrl}/api/sessions/${createData.sessionId}`, { + await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}`, { method: 'DELETE', }); // Session should be gone - const afterRes = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/terminal`); + const afterRes = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/terminal`); const afterData = await afterRes.json(); expect(afterData.error).toContain('not found'); }); diff --git a/test/session.test.ts b/test/session.test.ts index e60c8317..6d8fe084 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -32,9 +32,9 @@ describe('Interactive Session Lifecycle', () => { const data = await response.json(); expect(data.success).toBe(true); - expect(data.session.id).toBeDefined(); - expect(data.session.workingDir).toBeDefined(); - expect(data.session.status).toBe('idle'); + expect(data.data.session.id).toBeDefined(); + expect(data.data.session.workingDir).toBeDefined(); + expect(data.data.session.status).toBe('idle'); }); it('should create session with custom working directory', async () => { @@ -47,7 +47,7 @@ describe('Interactive Session Lifecycle', () => { const data = await response.json(); expect(data.success).toBe(true); - expect(data.session.workingDir).toBe('/tmp/test'); + expect(data.data.session.workingDir).toBe('/tmp/test'); }); }); @@ -60,14 +60,14 @@ describe('Interactive Session Lifecycle', () => { body: JSON.stringify({}), }); const createData = await createRes.json(); - const sessionId = createData.session.id; + const sessionId = createData.data.session.id; // Get the session const response = await fetch(`${baseUrl}/api/sessions/${sessionId}`); const data = await response.json(); - expect(data.id).toBe(sessionId); - expect(data.status).toBeDefined(); + expect(data.data.id).toBe(sessionId); + expect(data.data.status).toBeDefined(); }); it('should return error for non-existent session', async () => { @@ -87,7 +87,7 @@ describe('Interactive Session Lifecycle', () => { body: JSON.stringify({}), }); const createData = await createRes.json(); - const sessionId = createData.session.id; + const sessionId = createData.data.session.id; // Delete the session const response = await fetch(`${baseUrl}/api/sessions/${sessionId}`, { @@ -123,7 +123,7 @@ describe('Interactive Session Lifecycle', () => { body: JSON.stringify({}), }); const createData = await createRes.json(); - const sessionId = createData.session.id; + const sessionId = createData.data.session.id; // Get output const response = await fetch(`${baseUrl}/api/sessions/${sessionId}/output`); @@ -145,14 +145,14 @@ describe('Interactive Session Lifecycle', () => { body: JSON.stringify({}), }); const createData = await createRes.json(); - const sessionId = createData.session.id; + const sessionId = createData.data.session.id; // Get terminal buffer const response = await fetch(`${baseUrl}/api/sessions/${sessionId}/terminal`); const data = await response.json(); - expect(data).toHaveProperty('terminalBuffer'); - expect(data).toHaveProperty('status'); + expect(data.data).toHaveProperty('terminalBuffer'); + expect(data.data).toHaveProperty('status'); }); }); @@ -165,7 +165,7 @@ describe('Interactive Session Lifecycle', () => { body: JSON.stringify({}), }); const createData = await createRes.json(); - const sessionId = createData.session.id; + const sessionId = createData.data.session.id; // Start interactive mode const response = await fetch(`${baseUrl}/api/sessions/${sessionId}/interactive`, { @@ -195,7 +195,7 @@ describe('Interactive Session Lifecycle', () => { body: JSON.stringify({}), }); const createData = await createRes.json(); - const sessionId = createData.session.id; + const sessionId = createData.data.session.id; await fetch(`${baseUrl}/api/sessions/${sessionId}/interactive`, { method: 'POST', @@ -225,7 +225,7 @@ describe('Interactive Session Lifecycle', () => { body: JSON.stringify({}), }); const createData = await createRes.json(); - const sessionId = createData.session.id; + const sessionId = createData.data.session.id; await fetch(`${baseUrl}/api/sessions/${sessionId}/interactive`, { method: 'POST', diff --git a/test/sse-events.test.ts b/test/sse-events.test.ts index 4ee745f8..ea3b1573 100644 --- a/test/sse-events.test.ts +++ b/test/sse-events.test.ts @@ -93,7 +93,7 @@ describe('SSE Events', () => { // Parse and check for init event const events = parseSSEEvents(receivedData); - const initEvent = events.find(e => e.event === 'init'); + const initEvent = events.find((e) => e.event === 'init'); expect(initEvent).toBeDefined(); expect((initEvent?.data as any).sessions).toBeDefined(); @@ -109,7 +109,7 @@ describe('SSE Events', () => { // Start listening const fetchPromise = fetch(`${baseUrl}/api/events`, { signal: controller.signal, - }).then(async response => { + }).then(async (response) => { const reader = response.body?.getReader(); if (reader) { try { @@ -123,7 +123,7 @@ describe('SSE Events', () => { }); // Give time to connect - await new Promise(resolve => setTimeout(resolve, 100)); + await new Promise((resolve) => setTimeout(resolve, 100)); // Create a session await fetch(`${baseUrl}/api/sessions`, { @@ -133,15 +133,17 @@ describe('SSE Events', () => { }); // Wait a bit for the event - await new Promise(resolve => setTimeout(resolve, 200)); + await new Promise((resolve) => setTimeout(resolve, 200)); // Stop listening controller.abort(); - try { await fetchPromise; } catch {} + try { + await fetchPromise; + } catch {} // Parse events const events = parseSSEEvents(receivedData); - const sessionCreated = events.find(e => e.event === 'session:created'); + const sessionCreated = events.find((e) => e.event === 'session:created'); expect(sessionCreated).toBeDefined(); expect((sessionCreated?.data as any).id).toBeDefined(); @@ -151,12 +153,13 @@ describe('SSE Events', () => { describe('GET /api/status', () => { it('should return full state', async () => { const response = await fetch(`${baseUrl}/api/status`); - const data = await response.json(); + const body = await response.json(); - expect(data).toHaveProperty('sessions'); - expect(data).toHaveProperty('scheduledRuns'); - expect(data).toHaveProperty('respawnStatus'); - expect(data).toHaveProperty('timestamp'); + expect(body.success).toBe(true); + expect(body.data).toHaveProperty('sessions'); + expect(body.data).toHaveProperty('scheduledRuns'); + expect(body.data).toHaveProperty('respawnStatus'); + expect(body.data).toHaveProperty('timestamp'); }); it('should include active sessions', async () => { @@ -168,9 +171,9 @@ describe('SSE Events', () => { }); const response = await fetch(`${baseUrl}/api/status`); - const data = await response.json(); + const body = await response.json(); - expect(data.sessions.length).toBeGreaterThan(0); + expect(body.data.sessions.length).toBeGreaterThan(0); }); }); }); @@ -197,7 +200,7 @@ describe('SSE Event Types', () => { // Start listening const fetchPromise = fetch(`${baseUrl}/api/events`, { signal: controller.signal, - }).then(async response => { + }).then(async (response) => { const reader = response.body?.getReader(); if (reader) { try { @@ -211,7 +214,7 @@ describe('SSE Event Types', () => { }); // Give time to connect - await new Promise(resolve => setTimeout(resolve, 100)); + await new Promise((resolve) => setTimeout(resolve, 100)); // Create a session const createRes = await fetch(`${baseUrl}/api/sessions`, { @@ -222,23 +225,25 @@ describe('SSE Event Types', () => { const createData = await createRes.json(); // Delete it - await fetch(`${baseUrl}/api/sessions/${createData.session.id}`, { + await fetch(`${baseUrl}/api/sessions/${createData.data.session.id}`, { method: 'DELETE', }); // Wait for events - await new Promise(resolve => setTimeout(resolve, 200)); + await new Promise((resolve) => setTimeout(resolve, 200)); // Stop listening controller.abort(); - try { await fetchPromise; } catch {} + try { + await fetchPromise; + } catch {} // Parse events const events = parseSSEEvents(receivedData); - const sessionDeleted = events.find(e => e.event === 'session:deleted'); + const sessionDeleted = events.find((e) => e.event === 'session:deleted'); expect(sessionDeleted).toBeDefined(); - expect((sessionDeleted?.data as any).id).toBe(createData.session.id); + expect((sessionDeleted?.data as any).id).toBe(createData.data.session.id); }); }); @@ -250,7 +255,7 @@ describe('SSE Event Types', () => { // Start listening const fetchPromise = fetch(`${baseUrl}/api/events`, { signal: controller.signal, - }).then(async response => { + }).then(async (response) => { const reader = response.body?.getReader(); if (reader) { try { @@ -264,7 +269,7 @@ describe('SSE Event Types', () => { }); // Give time to connect - await new Promise(resolve => setTimeout(resolve, 100)); + await new Promise((resolve) => setTimeout(resolve, 100)); // Create a case const caseName = `test-sse-case-${Date.now()}`; @@ -275,15 +280,17 @@ describe('SSE Event Types', () => { }); // Wait for events - await new Promise(resolve => setTimeout(resolve, 200)); + await new Promise((resolve) => setTimeout(resolve, 200)); // Stop listening controller.abort(); - try { await fetchPromise; } catch {} + try { + await fetchPromise; + } catch {} // Parse events const events = parseSSEEvents(receivedData); - const caseCreated = events.find(e => e.event === 'case:created'); + const caseCreated = events.find((e) => e.event === 'case:created'); expect(caseCreated).toBeDefined(); expect((caseCreated?.data as any).name).toBe(caseName); diff --git a/test/sse-subscription-filter.test.ts b/test/sse-subscription-filter.test.ts index d81afaea..59511d92 100644 --- a/test/sse-subscription-filter.test.ts +++ b/test/sse-subscription-filter.test.ts @@ -159,7 +159,7 @@ describe('SSE Subscription Filtering', () => { body: JSON.stringify({ workingDir: '/tmp' }), }); const createData = await createRes.json(); - const sessionId = createData.session.id; + const sessionId = createData.data.session.id; // Wait for events to arrive await new Promise((resolve) => setTimeout(resolve, 300)); @@ -210,7 +210,7 @@ describe('SSE Subscription Filtering', () => { body: JSON.stringify({ workingDir: '/tmp' }), }); const createData = await createRes.json(); - const sessionId = createData.session.id; + const sessionId = createData.data.session.id; // Now connect SSE with the session filter const controller = new AbortController(); @@ -264,14 +264,14 @@ describe('SSE Subscription Filtering', () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workingDir: '/tmp' }), }); - const session1 = (await createRes1.json()).session; + const session1 = (await createRes1.json()).data.session; const createRes2 = await fetch(`${baseUrl}/api/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workingDir: '/tmp' }), }); - const session2 = (await createRes2.json()).session; + const session2 = (await createRes2.json()).data.session; // Connect SSE subscribed ONLY to session1 const controller = new AbortController(); @@ -331,14 +331,14 @@ describe('SSE Subscription Filtering', () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workingDir: '/tmp' }), }); - const session1 = (await createRes1.json()).session; + const session1 = (await createRes1.json()).data.session; const createRes2 = await fetch(`${baseUrl}/api/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workingDir: '/tmp' }), }); - const session2 = (await createRes2.json()).session; + const session2 = (await createRes2.json()).data.session; // Connect SSE subscribed to both sessions const controller = new AbortController(); diff --git a/test/types.test.ts b/test/types.test.ts index 99f64555..0f1b67f0 100644 --- a/test/types.test.ts +++ b/test/types.test.ts @@ -368,9 +368,9 @@ describe('types utility functions', () => { expect(ApiErrorCode.INTERNAL_ERROR).toBe('INTERNAL_ERROR'); }); - it('should have 6 error codes', () => { + it('should have 9 error codes', () => { const codes = Object.values(ApiErrorCode); - expect(codes).toHaveLength(6); + expect(codes).toHaveLength(9); }); }); });