feat(api): establish stable HTTP contract — uniform {success,data} envelope, status codes, /api/v1

Point 1 of the v1.0 lock-in: commit to a stable HTTP API (the cleanest, fullest form).

Core (centralized):
- Every JSON /api response now uses ONE envelope via a Fastify preSerialization hook (src/web/server.ts): success -> { success:true, data:<payload> }; error -> { success:false, error, errorCode } with a conventional HTTP status. Non-JSON routes (file-raw, tail-file SSE, download, screenshots, /q redirect, WS) are skipped.
- Error-code -> HTTP status is a single source of truth (httpStatusForErrorCode in src/types/api.ts): 400/401/404/409/422/429/500. Expanded ApiErrorCode (added UNAUTHORIZED, CONFLICT, RATE_LIMITED). Errors are no longer HTTP 200.
- Versioned alias: /api/v1/* rewrites to /api/* (rewriteApiV1Url), so external clients pin to a stable surface while the bundled UI keeps using /api/*.
- Handlers stripped of manual 'success:true' (50 across 14 route files) so they return bare payloads the hook wraps uniformly; fixed the mux DELETE {success:<bool>} envelope collision (-> {killed}).

Frontend (48 call sites across 10 files):
- _apiJson() auto-unwraps { success:true, data } -> data (null on error), so most bare-shape readers are transparent. Raw-fetch sites relocate payload reads under .data; success/res.ok/error checks unchanged.

Docs: new docs/api-reference.md (envelope, status table, error codes, /api/v1, SSE); versioning-policy.md flipped — the HTTP/SSE API is now part of the stable, SemVer-covered surface.

Verification: full unit/route suite green (2680 passed) incl. ~166 updated assertions across 24 test files; typecheck/lint/format/frontend-syntax clean; a headless-chromium smoke loaded the migrated UI and drove the panels with 0 console/page errors; /api/status and /api/v1/status confirmed returning the uniform envelope live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
arkon
2026-06-10 01:30:43 +02:00
co-authored by Claude Opus 4.8
parent 5b3024b327
commit 458fb81cbe
49 changed files with 929 additions and 554 deletions
+90
View File
@@ -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": <payload> }
```
`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<T>` 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: <name>` + `data: <json>`. 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=<id,...>` 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`.
+18 -17
View File
@@ -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 "1.0" is a commitment to stability, and an undocumented public surface invites
incompatible client assumptions we would then be pressured to keep. 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 > **Status:** finalized for the 1.0 cut. The HTTP/SSE API **is** part of the stable
> API is *not* SemVer-covered — should be confirmed by the maintainer before 1.0, > surface — served under `/api/v1` with a uniform response envelope and
> since it determines whether a number of in-flight cleanups are "breaking." > conventional HTTP status codes. See [`api-reference.md`](api-reference.md).
## What SemVer covers (the public, stable surface) ## 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 1. **The CLI.** Command names, documented flags, and their behavior for
`codeman <command>` (published to npm as `aicodeman`; invoked as `codeman`). `codeman <command>` (published to npm as `aicodeman`; invoked as `codeman`).
This is the package's actual public entry point (`bin`). This is the package's actual public entry point (`bin`).
- *Note:* the npm package name vs. invoked command name (`aicodeman` vs - The package is published to npm as `aicodeman` and installs **both** the
`codeman`) is a known inconsistency to resolve **before** 1.0 — renaming `aicodeman` and `codeman` commands (`bin` aliases); `codeman` is the
either after 1.0 is itself a breaking change. 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 2. **The published `xterm-zerolag-input` library**, but on **its own version
line** — it is versioned and released independently of the Codeman app. Its 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* 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_INSTANCE`, `CODEMAN_ALLOWED_HOSTS`, `CODEMAN_DATA_DIR`,
`CODEMAN_TMUX_SOCKET`, and the `--host` / `--port` / `--https` CLI flags. `CODEMAN_TMUX_SOCKET`, and the `--host` / `--port` / `--https` CLI flags.
Removing or changing the meaning of one of these is breaking. 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) ## 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: 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 1. **The `~/.codeman/` state file formats** (`state.json`, `settings.json`,
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`,
`mux-sessions.json`, etc.). We make a **best-effort** to migrate existing data `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 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. 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). 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 Gesture Control (beta), Agent Teams
(`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`), and anything labeled experimental (`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`), and anything labeled experimental
in the UI or docs. These may change or be removed at any time. in the UI or docs. These may change or be removed at any time.
+34 -3
View File
@@ -25,12 +25,18 @@ export enum ApiErrorCode {
NOT_FOUND = 'NOT_FOUND', NOT_FOUND = 'NOT_FOUND',
/** Invalid input provided */ /** Invalid input provided */
INVALID_INPUT = 'INVALID_INPUT', INVALID_INPUT = 'INVALID_INPUT',
/** Authentication required or failed */
UNAUTHORIZED = 'UNAUTHORIZED',
/** Session is currently busy */ /** Session is currently busy */
SESSION_BUSY = 'SESSION_BUSY', SESSION_BUSY = 'SESSION_BUSY',
/** Operation failed */ /** Request conflicts with current state (e.g. already running) */
OPERATION_FAILED = 'OPERATION_FAILED', CONFLICT = 'CONFLICT',
/** Resource already exists */ /** Resource already exists */
ALREADY_EXISTS = '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 server error */
INTERNAL_ERROR = 'INTERNAL_ERROR', INTERNAL_ERROR = 'INTERNAL_ERROR',
} }
@@ -41,12 +47,37 @@ export enum ApiErrorCode {
const ErrorMessages: Record<ApiErrorCode, string> = { const ErrorMessages: Record<ApiErrorCode, string> = {
[ApiErrorCode.NOT_FOUND]: 'The requested resource was not found', [ApiErrorCode.NOT_FOUND]: 'The requested resource was not found',
[ApiErrorCode.INVALID_INPUT]: 'Invalid input provided', [ApiErrorCode.INVALID_INPUT]: 'Invalid input provided',
[ApiErrorCode.UNAUTHORIZED]: 'Authentication required',
[ApiErrorCode.SESSION_BUSY]: 'Session is currently busy', [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.ALREADY_EXISTS]: 'Resource already exists',
[ApiErrorCode.RATE_LIMITED]: 'Too many requests',
[ApiErrorCode.OPERATION_FAILED]: 'The operation failed',
[ApiErrorCode.INTERNAL_ERROR]: 'An internal error occurred', [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, number> = {
[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 * Hook event types triggered by Claude Code's hooks system
*/ */
+10 -1
View File
@@ -44,11 +44,20 @@ Object.assign(CodemanApp.prototype, {
async _apiJson(path, opts = {}) { async _apiJson(path, opts = {}) {
const res = await this._api(path, opts); const res = await this._api(path, opts);
if (!res || !res.ok) return null; if (!res || !res.ok) return null;
let body;
try { try {
return await res.json(); body = await res.json();
} catch { } catch {
return null; 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;
}, },
/** /**
+8 -8
View File
@@ -597,7 +597,7 @@ class CodemanApp {
// Fetch tunnel status for header indicator (desktop only) // Fetch tunnel status for header indicator (desktop only)
this.loadTunnelStatus(); this.loadTunnelStatus();
// Share a single settings fetch between both consumers // 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.loadQuickStartCases(null, settingsPromise);
this._initRunMode(); this._initRunMode();
this.setupEventListeners(); this.setupEventListeners();
@@ -1531,13 +1531,13 @@ class CodemanApp {
try { try {
// Source 1: Transcript JSONL (best quality — clean structured text from Claude) // Source 1: Transcript JSONL (best quality — clean structured text from Claude)
const res = await fetch(`/api/sessions/${this.activeSessionId}/last-response`); 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 || ''; let lastResponse = data.text || '';
// Source 2: Terminal buffer fallback — strip ANSI, drop Claude CLI chrome // Source 2: Terminal buffer fallback — strip ANSI, drop Claude CLI chrome
if (!lastResponse) { if (!lastResponse) {
const termRes = await fetch(`/api/sessions/${this.activeSessionId}/terminal`); const termRes = await fetch(`/api/sessions/${this.activeSessionId}/terminal`);
const termData = await termRes.json(); const termData = (await termRes.json())?.data ?? {};
if (termData.terminalBuffer) { if (termData.terminalBuffer) {
lastResponse = this._cleanTerminalBuffer(termData.terminalBuffer); lastResponse = this._cleanTerminalBuffer(termData.terminalBuffer);
} }
@@ -1567,7 +1567,7 @@ class CodemanApp {
if (moreBtn) moreBtn.textContent = '...'; if (moreBtn) moreBtn.textContent = '...';
try { try {
const res = await fetch(`/api/sessions/${this.activeSessionId}/last-response?context=full`); 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 messages = data.messages || [];
const body = document.getElementById('responseViewerBody'); const body = document.getElementById('responseViewerBody');
const title = document.getElementById('responseViewerTitle'); const title = document.getElementById('responseViewerTitle');
@@ -1618,7 +1618,7 @@ class CodemanApp {
if (this._isLoadingBuffer) return; if (this._isLoadingBuffer) return;
try { try {
const res = await fetch(`/api/sessions/${this.activeSessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`); 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) { if (data.terminalBuffer) {
this.terminal.clear(); this.terminal.clear();
this.terminal.reset(); this.terminal.reset();
@@ -1648,7 +1648,7 @@ class CodemanApp {
// Fetch buffer, clear terminal, write buffer, resize (no Ctrl+L needed) // Fetch buffer, clear terminal, write buffer, resize (no Ctrl+L needed)
try { try {
const res = await fetch(`/api/sessions/${data.id}/terminal`); 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.clear();
this.terminal.reset(); this.terminal.reset();
@@ -2254,7 +2254,7 @@ class CodemanApp {
try { try {
const res = await fetch('/api/status'); const res = await fetch('/api/status');
const data = await res.json(); const data = await res.json();
this.handleInit(data); this.handleInit(data?.data ?? {});
} catch (err) { } catch (err) {
console.error('Failed to load state:', err); console.error('Failed to load state:', err);
} }
@@ -2938,7 +2938,7 @@ class CodemanApp {
_crashDiag.log('FETCH_START'); _crashDiag.log('FETCH_START');
const res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`); const res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
if (this._isStaleSelect(selectGen)) return; 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}`); _crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`);
if (data.terminalBuffer) { if (data.terminalBuffer) {
+1 -1
View File
@@ -149,7 +149,7 @@ Object.assign(CodemanApp.prototype, {
} }
const data = await resp.json(); 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 // Decode an image File through the browser and re-encode it to a format the
+3 -3
View File
@@ -182,7 +182,7 @@ Object.assign(CodemanApp.prototype, {
body: JSON.stringify({ goal, config }), body: JSON.stringify({ goal, config }),
}); });
const data = await res.json(); const data = await res.json();
if (data.ok) { if (data.data?.ok) {
this.orchestratorState = { state: 'planning', plan: null }; this.orchestratorState = { state: 'planning', plan: null };
this.showOrchestratorPanel(); this.showOrchestratorPanel();
this.renderOrchestratorPanel(); this.renderOrchestratorPanel();
@@ -259,8 +259,8 @@ Object.assign(CodemanApp.prototype, {
try { try {
const res = await fetch('/api/orchestrator/status'); const res = await fetch('/api/orchestrator/status');
const data = await res.json(); const data = await res.json();
if (data.ok) { if (data.data?.ok) {
this.orchestratorState = data; this.orchestratorState = data.data;
this.renderOrchestratorPanel(); this.renderOrchestratorPanel();
} }
} catch (err) { } catch (err) {
+5 -5
View File
@@ -251,7 +251,7 @@ Object.assign(CodemanApp.prototype, {
const response = await fetch('/api/token-stats'); const response = await fetch('/api/token-stats');
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
this.renderTokenStats(data); this.renderTokenStats(data.data);
document.getElementById('tokenStatsModal').classList.add('active'); document.getElementById('tokenStatsModal').classList.add('active');
} else { } else {
this.showToast('Failed to load token stats', 'error'); this.showToast('Failed to load token stats', 'error');
@@ -2881,7 +2881,7 @@ Object.assign(CodemanApp.prototype, {
try { try {
const res = await fetch('/api/mux-sessions'); const res = await fetch('/api/mux-sessions');
const data = await res.json(); const data = await res.json();
this.muxSessions = data.sessions || []; this.muxSessions = data.data?.sessions || [];
this.renderMuxSessions(); this.renderMuxSessions();
} catch (err) { } catch (err) {
console.error('Failed to load mux sessions:', 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 res = await fetch('/api/mux-sessions/reconcile', { method: 'POST' });
const data = await res.json(); const data = await res.json();
if (data.dead && data.dead.length > 0) { if (data.data?.dead && data.data.dead.length > 0) {
this.showToast(`Found ${data.dead.length} dead mux session(s)`, 'warning'); this.showToast(`Found ${data.data.dead.length} dead mux session(s)`, 'warning');
await this.loadMuxSessions(); await this.loadMuxSessions();
} else { } else {
this.showToast('All mux sessions are alive', 'success'); this.showToast('All mux sessions are alive', 'success');
@@ -3220,7 +3220,7 @@ Object.assign(CodemanApp.prototype, {
try { try {
const res = await fetch('/api/system/stats'); const res = await fetch('/api/system/stats');
const stats = await res.json(); const stats = await res.json();
this.updateSystemStatsDisplay(stats); this.updateSystemStatsDisplay(stats.data);
} catch (err) { } catch (err) {
// Silently fail - system stats are not critical // Silently fail - system stats are not critical
} }
+2 -2
View File
@@ -996,14 +996,14 @@ Object.assign(CodemanApp.prototype, {
return; return;
} }
const history = data.history || []; const history = data.data.history || [];
if (history.length === 0) { if (history.length === 0) {
this.showToast('No plan history available', 'info'); this.showToast('No plan history available', 'info');
return; return;
} }
// Show history dropdown modal // Show history dropdown modal
this.showPlanHistoryModal(history, data.currentVersion); this.showPlanHistoryModal(history, data.data.currentVersion);
} catch (err) { } catch (err) {
this.showToast('Failed to load plan history: ' + err.message, 'error'); this.showToast('Failed to load plan history: ' + err.message, 'error');
} }
+6 -6
View File
@@ -151,11 +151,11 @@ Object.assign(CodemanApp.prototype, {
const res = await fetch(`/api/cases/${encodeURIComponent(caseName)}/fix-plan`); const res = await fetch(`/api/cases/${encodeURIComponent(caseName)}/fix-plan`);
const data = await res.json(); 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 = { this.ralphWizardConfig.existingPlan = {
todos: data.todos, todos: data.data.todos,
stats: data.stats, stats: data.data.stats,
content: data.content, content: data.data.content,
}; };
this.updateExistingPlanUI(); this.updateExistingPlanUI();
} else { } else {
@@ -1054,8 +1054,8 @@ Object.assign(CodemanApp.prototype, {
this.showToast(data.error || 'Failed to start', 'error'); this.showToast(data.error || 'Failed to start', 'error');
return; return;
} }
this.ralphClosedSessions.delete(data.sessionId); this.ralphClosedSessions.delete(data.data.sessionId);
await this.selectSession(data.sessionId); await this.selectSession(data.data.sessionId);
this.showToast(`Ralph Loop started in ${config.caseName}`, 'success'); this.showToast(`Ralph Loop started in ${config.caseName}`, 'success');
} catch (err) { } catch (err) {
console.error('Failed to start Ralph loop:', err); console.error('Failed to start Ralph loop:', err);
+1 -1
View File
@@ -1041,7 +1041,7 @@ Object.assign(CodemanApp.prototype, {
return; return;
} }
this.runSummaryData = data.summary; this.runSummaryData = data.data.summary;
this.renderRunSummary(); this.renderRunSummary();
} catch (err) { } catch (err) {
console.error('Failed to load run summary:', err); console.error('Failed to load run summary:', err);
+13 -13
View File
@@ -50,15 +50,15 @@ Object.assign(CodemanApp.prototype, {
let lastUsedCase = null; let lastUsedCase = null;
try { 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);
if (settings) { if (settings && settings.data) {
lastUsedCase = settings.lastUsedCase || null; lastUsedCase = settings.data.lastUsedCase || null;
} }
} catch { } catch {
// Ignore settings load errors // Ignore settings load errors
} }
const res = await fetch('/api/cases'); const res = await fetch('/api/cases');
const cases = await res.json(); const cases = (await res.json()).data;
this.cases = cases; this.cases = cases;
console.log('[loadQuickStartCases] Loaded cases:', cases.map(c => c.name), 'lastUsedCase:', lastUsedCase); console.log('[loadQuickStartCases] Loaded cases:', cases.map(c => c.name), 'lastUsedCase:', lastUsedCase);
@@ -125,7 +125,7 @@ Object.assign(CodemanApp.prototype, {
async updateDirDisplayForCase(caseName) { async updateDirDisplayForCase(caseName) {
try { try {
const res = await fetch(`/api/cases/${caseName}`); const res = await fetch(`/api/cases/${caseName}`);
const data = await res.json(); const data = (await res.json()).data;
if (data.path) { if (data.path) {
document.getElementById('dirDisplay').textContent = data.path; document.getElementById('dirDisplay').textContent = data.path;
document.getElementById('dirInput').value = data.path; document.getElementById('dirInput').value = data.path;
@@ -304,7 +304,7 @@ Object.assign(CodemanApp.prototype, {
try { try {
// Get case path first // Get case path first
const caseRes = await fetch(`/api/cases/${caseName}`); 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 // Create the case if it doesn't exist
if (!caseData.path) { if (!caseData.path) {
@@ -373,7 +373,7 @@ Object.assign(CodemanApp.prototype, {
const sessionIds = []; const sessionIds = [];
for (const result of createResults) { for (const result of createResults) {
if (!result.success) throw new Error(result.error); if (!result.success) throw new Error(result.error);
sessionIds.push(result.session.id); sessionIds.push(result.data.session.id);
} }
firstSessionId = sessionIds[0]; firstSessionId = sessionIds[0];
@@ -452,7 +452,7 @@ Object.assign(CodemanApp.prototype, {
try { try {
// Get the case path // Get the case path
const caseRes = await fetch(`/api/cases/${caseName}`); 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 // Create the case if it doesn't exist
if (!caseData.path) { if (!caseData.path) {
@@ -501,7 +501,7 @@ Object.assign(CodemanApp.prototype, {
const sessionIds = []; const sessionIds = [];
for (const result of createResults) { for (const result of createResults) {
if (!result.success) throw new Error(result.error); 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 // Step 2: Start all shells in parallel
@@ -545,7 +545,7 @@ Object.assign(CodemanApp.prototype, {
try { try {
// Check if OpenCode is available // Check if OpenCode is available
const statusRes = await fetch('/api/opencode/status'); const statusRes = await fetch('/api/opencode/status');
const status = await statusRes.json(); const status = (await statusRes.json()).data;
if (!status.available) { if (!status.available) {
this.terminal.writeln('\x1b[1;31m OpenCode CLI not found.\x1b[0m'); 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'); 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 // Switch to the new session (don't pre-set activeSessionId — selectSession
// early-returns when IDs match, skipping buffer load and sendResize) // early-returns when IDs match, skipping buffer load and sendResize)
if (data.sessionId) { if (data.data.sessionId) {
await this.selectSession(data.sessionId); await this.selectSession(data.data.sessionId);
} }
this.terminal.focus(); this.terminal.focus();
@@ -789,8 +789,8 @@ Object.assign(CodemanApp.prototype, {
try { try {
const res = await fetch(`/api/sessions/${sessionId}/respawn/config`); const res = await fetch(`/api/sessions/${sessionId}/respawn/config`);
const data = await res.json(); const data = await res.json();
if (data.success && data.config) { if (data.success && data.data && data.data.config) {
const c = data.config; const c = data.data.config;
document.getElementById('modalRespawnPrompt').value = c.updatePrompt || 'update all the docs and CLAUDE.md'; document.getElementById('modalRespawnPrompt').value = c.updatePrompt || 'update all the docs and CLAUDE.md';
document.getElementById('modalRespawnSendClear').checked = c.sendClear ?? true; document.getElementById('modalRespawnSendClear').checked = c.sendClear ?? true;
document.getElementById('modalRespawnSendInit').checked = c.sendInit ?? true; document.getElementById('modalRespawnSendInit').checked = c.sendInit ?? true;
+31 -18
View File
@@ -185,9 +185,9 @@ Object.assign(CodemanApp.prototype, {
try { try {
// Get VAPID public key from server // Get VAPID public key from server
const keyData = await this._apiJson('/api/push/vapid-key'); 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({ const subscription = await this._swRegistration.pushManager.subscribe({
userVisibleOnly: true, userVisibleOnly: true,
applicationServerKey, applicationServerKey,
@@ -204,11 +204,11 @@ Object.assign(CodemanApp.prototype, {
pushPreferences: this._buildPushPreferences(), 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._pushSubscription = subscription;
this._pushSubscriptionId = data.data.id; this._pushSubscriptionId = data.id;
localStorage.setItem('codeman-push-subscription-id', data.data.id); localStorage.setItem('codeman-push-subscription-id', data.id);
this._updatePushUI(true); this._updatePushUI(true);
this.showToast('Push notifications enabled', 'success'); this.showToast('Push notifications enabled', 'success');
} catch (err) { } catch (err) {
@@ -598,7 +598,10 @@ Object.assign(CodemanApp.prototype, {
let data = null; let data = null;
try { try {
const res = await fetch('/api/system/update/status'); 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 */ } } catch { /* server restarting — keep polling */ }
if (!data) { if (!data) {
@@ -652,7 +655,8 @@ Object.assign(CodemanApp.prototype, {
async loadTunnelStatus() { async loadTunnelStatus() {
try { try {
const res = await fetch('/api/tunnel/status'); 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; const active = status.running && status.url;
this._tunnelUrl = active ? status.url : null; this._tunnelUrl = active ? status.url : null;
this._updateTunnelUrlDisplay(this._tunnelUrl); this._updateTunnelUrlDisplay(this._tunnelUrl);
@@ -721,7 +725,8 @@ Object.assign(CodemanApp.prototype, {
if (!res.ok) throw new Error('Tunnel not running'); if (!res.ok) throw new Error('Tunnel not running');
return res.json(); return res.json();
}) })
.then(data => { .then(env => {
const data = env?.success === true ? env.data : env;
const container = document.getElementById('tunnelQrContainer'); const container = document.getElementById('tunnelQrContainer');
if (container && data.svg) container.innerHTML = data.svg; if (container && data.svg) container.innerHTML = data.svg;
// Show auth badge, countdown, and regenerate button when auth is enabled // Show auth badge, countdown, and regenerate button when auth is enabled
@@ -754,7 +759,8 @@ Object.assign(CodemanApp.prototype, {
// Fetch URL for display // Fetch URL for display
fetch('/api/tunnel/status') fetch('/api/tunnel/status')
.then(r => r.json()) .then(r => r.json())
.then(status => { .then(env => {
const status = env?.success === true ? env.data : env;
const urlEl = document.getElementById('tunnelQrUrl'); const urlEl = document.getElementById('tunnelQrUrl');
if (urlEl && status.url) { if (urlEl && status.url) {
urlEl.textContent = status.url; urlEl.textContent = status.url;
@@ -786,7 +792,8 @@ Object.assign(CodemanApp.prototype, {
_refreshTunnelQrFromApi() { _refreshTunnelQrFromApi() {
fetch('/api/tunnel/qr') fetch('/api/tunnel/qr')
.then(res => res.ok ? res.json() : null) .then(res => res.ok ? res.json() : null)
.then(data => { .then(env => {
const data = env?.success === true ? env.data : env;
if (!data?.svg) return; if (!data?.svg) return;
const container = document.getElementById('tunnelQrContainer'); const container = document.getElementById('tunnelQrContainer');
if (container) container.innerHTML = data.svg; if (container) container.innerHTML = data.svg;
@@ -901,7 +908,8 @@ Object.assign(CodemanApp.prototype, {
this._tunnelPollTimer = setTimeout(async () => { this._tunnelPollTimer = setTimeout(async () => {
try { try {
const res = await fetch('/api/tunnel/status'); 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) { if (status.running && status.url) {
// Tunnel is up — update UI // Tunnel is up — update UI
this._dismissTunnelConnecting(); this._dismissTunnelConnecting();
@@ -960,7 +968,7 @@ Object.assign(CodemanApp.prototype, {
} }
fetch('/api/tunnel/qr') fetch('/api/tunnel/qr')
.then(r => { if (!r.ok) throw new Error(); return r.json(); }) .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 = '<div style="color:#999;font-size:11px;padding:20px">QR unavailable</div>'; }); .catch(() => { qrInner.innerHTML = '<div style="color:#999;font-size:11px;padding:20px">QR unavailable</div>'; });
} else { } else {
clearTimeout(this._welcomeQrShrinkTimer); clearTimeout(this._welcomeQrShrinkTimer);
@@ -1032,7 +1040,8 @@ Object.assign(CodemanApp.prototype, {
// Fetch tunnel info // Fetch tunnel info
try { try {
const res = await fetch('/api/tunnel/info'); 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); this._renderTunnelPanel(info);
} catch { } catch {
const body = document.getElementById('tunnelPanelBody'); const body = document.getElementById('tunnelPanelBody');
@@ -1166,7 +1175,8 @@ Object.assign(CodemanApp.prototype, {
this.showToast('All sessions revoked', 'success'); this.showToast('All sessions revoked', 'success');
// Refresh panel // Refresh panel
const res = await fetch('/api/tunnel/info'); 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); this._renderTunnelPanel(info);
} catch { } catch {
this.showToast('Failed to revoke sessions', 'error'); this.showToast('Failed to revoke sessions', 'error');
@@ -1261,7 +1271,8 @@ Object.assign(CodemanApp.prototype, {
try { try {
const res = await fetch(`/api/session-lifecycle?${params}`); 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 tbody = document.getElementById('lifecycleTableBody');
const empty = document.getElementById('lifecycleEmpty'); const empty = document.getElementById('lifecycleEmpty');
@@ -1850,7 +1861,7 @@ Object.assign(CodemanApp.prototype, {
async loadAppSettingsFromServer(settingsPromise = null) { async loadAppSettingsFromServer(settingsPromise = null) {
try { 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) { if (settings) {
// Extract notification prefs before merging app settings // Extract notification prefs before merging app settings
const { notificationPreferences, voiceSettings, respawnPresets, runMode, ...appSettings } = settings; const { notificationPreferences, voiceSettings, respawnPresets, runMode, ...appSettings } = settings;
@@ -1942,7 +1953,8 @@ Object.assign(CodemanApp.prototype, {
try { try {
const res = await fetch('/api/subagent-window-states'); const res = await fetch('/api/subagent-window-states');
if (res.ok) { if (res.ok) {
states = await res.json(); const env = await res.json();
states = env?.success === true ? env.data : env;
// Also update localStorage // Also update localStorage
localStorage.setItem('codeman-subagent-window-states', JSON.stringify(states)); localStorage.setItem('codeman-subagent-window-states', JSON.stringify(states));
} }
@@ -2009,7 +2021,8 @@ Object.assign(CodemanApp.prototype, {
try { try {
const res = await fetch('/api/subagent-parents'); const res = await fetch('/api/subagent-parents');
if (res.ok) { if (res.ok) {
mapData = await res.json(); const env = await res.json();
mapData = env?.success === true ? env.data : env;
// Update localStorage as cache // Update localStorage as cache
localStorage.setItem('codeman-subagent-parents', JSON.stringify(mapData)); localStorage.setItem('codeman-subagent-parents', JSON.stringify(mapData));
} }
+5 -5
View File
@@ -868,7 +868,7 @@ Object.assign(CodemanApp.prototype, {
async _fetchHistorySessions() { async _fetchHistorySessions() {
const res = await fetch('/api/history/sessions'); const res = await fetch('/api/history/sessions');
const data = await res.json(); const data = await res.json();
const sessions = data.sessions || []; const sessions = data.data?.sessions || [];
if (sessions.length === 0) return []; if (sessions.length === 0) return [];
const byProject = new Map(); const byProject = new Map();
@@ -1046,7 +1046,7 @@ Object.assign(CodemanApp.prototype, {
// Prefer already-loaded this.cases to avoid an extra request. // Prefer already-loaded this.cases to avoid an extra request.
const casesPromise = Array.isArray(this.cases) && this.cases.length > 0 const casesPromise = Array.isArray(this.cases) && this.cases.length > 0
? Promise.resolve(this.cases) ? 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([ const [allSessions, cases] = await Promise.all([
this._fetchHistorySessions(30), this._fetchHistorySessions(30),
casesPromise, casesPromise,
@@ -1173,8 +1173,8 @@ Object.assign(CodemanApp.prototype, {
const url = `/api/history/sessions?projectKey=${encodeURIComponent(projectKey)}&offset=${offset}&limit=${limit}`; const url = `/api/history/sessions?projectKey=${encodeURIComponent(projectKey)}&offset=${offset}&limit=${limit}`;
const res = await fetch(url); const res = await fetch(url);
const data = await res.json(); const data = await res.json();
const sessions = data.sessions || []; const sessions = data.data?.sessions || [];
state.total = typeof data.total === 'number' ? data.total : sessions.length + offset; state.total = typeof data.data?.total === 'number' ? data.data.total : sessions.length + offset;
if (offset === 0 && sessions.length === 0) { if (offset === 0 && sessions.length === 0) {
const empty = document.createElement('div'); const empty = document.createElement('div');
@@ -1261,7 +1261,7 @@ Object.assign(CodemanApp.prototype, {
const createData = await createRes.json(); const createData = await createRes.json();
if (!createData.success) throw new Error(createData.error); if (!createData.success) throw new Error(createData.error);
const newSessionId = createData.session.id; const newSessionId = createData.data.session.id;
// Start interactive // Start interactive
await fetch(`/api/sessions/${newSessionId}/interactive`, { method: 'POST' }); await fetch(`/api/sessions/${newSessionId}/interactive`, { method: 'POST' });
+1 -2
View File
@@ -262,7 +262,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
const fixPlanPath = join(casePath, '@fix_plan.md'); const fixPlanPath = join(casePath, '@fix_plan.md');
if (!existsSync(fixPlanPath)) { if (!existsSync(fixPlanPath)) {
return { success: true, exists: false, content: null, todos: [] }; return { exists: false, content: null, todos: [] };
} }
try { try {
@@ -339,7 +339,6 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
const stats = { total: todos.length, pending, inProgress, completed }; const stats = { total: todos.length, pending, inProgress, completed };
return { return {
success: true,
exists: true, exists: true,
content, content,
todos, todos,
+1 -1
View File
@@ -19,6 +19,6 @@ export function registerClipboardRoutes(app: FastifyInstance, ctx: EventPort): v
sessionId: body.sessionId ?? null, sessionId: body.sessionId ?? null,
timestamp: Date.now(), timestamp: Date.now(),
}); });
return { success: true }; return {};
}); });
} }
+1 -1
View File
@@ -66,6 +66,6 @@ export function registerHookEventRoutes(
summaryTracker.recordHookEvent(event, safeData); summaryTracker.recordHookEvent(event, safeData);
} }
return { success: true }; return {};
}); });
} }
+3 -3
View File
@@ -19,7 +19,7 @@ export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void {
app.delete('/api/mux-sessions/:sessionId', async (req) => { app.delete('/api/mux-sessions/:sessionId', async (req) => {
const { sessionId } = req.params as { sessionId: string }; const { sessionId } = req.params as { sessionId: string };
const success = await ctx.mux.killSession(sessionId); const success = await ctx.mux.killSession(sessionId);
return { success }; return { killed: success };
}); });
app.post('/api/mux-sessions/reconcile', async () => { 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 () => { app.post('/api/mux-sessions/stats/start', async () => {
ctx.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS); ctx.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS);
return { success: true }; return {};
}); });
app.post('/api/mux-sessions/stats/stop', async () => { app.post('/api/mux-sessions/stats/stop', async () => {
ctx.mux.stopStatsCollection(); ctx.mux.stopStatsCollection();
return { success: true }; return {};
}); });
} }
+2 -2
View File
@@ -35,7 +35,7 @@ export function registerPushRoutes(app: FastifyInstance, ctx: InfraPort): void {
if (!updated) { if (!updated) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subscription not found'); return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subscription not found');
} }
return { success: true }; return {};
}); });
app.delete('/api/push/subscribe/:id', async (req) => { app.delete('/api/push/subscribe/:id', async (req) => {
@@ -44,6 +44,6 @@ export function registerPushRoutes(app: FastifyInstance, ctx: InfraPort): void {
if (!removed) { if (!removed) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subscription not found'); return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subscription not found');
} }
return { success: true }; return {};
}); });
} }
+2 -2
View File
@@ -101,7 +101,7 @@ export function registerRalphRoutes(
state: session.ralphLoopState, state: session.ralphLoopState,
}); });
return { success: true }; return {};
}); });
// Reset circuit breaker for Ralph tracker // Reset circuit breaker for Ralph tracker
@@ -110,7 +110,7 @@ export function registerRalphRoutes(
const session = findSessionOrFail(ctx, id); const session = findSessionOrFail(ctx, id);
session.ralphTracker.resetCircuitBreaker(); session.ralphTracker.resetCircuitBreaker();
return { success: true }; return {};
}); });
// Get Ralph status block and circuit breaker state // Get Ralph status block and circuit breaker state
+7 -8
View File
@@ -62,16 +62,16 @@ export function registerRespawnRoutes(
const controller = ctx.respawnControllers.get(id); const controller = ctx.respawnControllers.get(id);
if (controller) { if (controller) {
return { success: true, config: controller.getConfig(), active: true }; return { config: controller.getConfig(), active: true };
} }
// Return pre-saved config from mux-sessions.json // Return pre-saved config from mux-sessions.json
const preConfig = ctx.mux.getSession(id)?.respawnConfig; const preConfig = ctx.mux.getSession(id)?.respawnConfig;
if (preConfig) { 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() }); ctx.broadcast(SseEvent.RespawnStarted, { sessionId: id, status: controller.getStatus() });
return { success: true, status: controller.getStatus() }; return { status: controller.getStatus() };
}); });
// ========== Stop Respawn ========== // ========== Stop Respawn ==========
@@ -150,7 +150,7 @@ export function registerRespawnRoutes(
ctx.broadcast(SseEvent.RespawnStopped, { sessionId: id }); ctx.broadcast(SseEvent.RespawnStopped, { sessionId: id });
return { success: true }; return {};
}); });
// ========== Update Respawn Config ========== // ========== Update Respawn Config ==========
@@ -169,7 +169,7 @@ export function registerRespawnRoutes(
ctx.saveRespawnConfig(id, controller.getConfig()); ctx.saveRespawnConfig(id, controller.getConfig());
ctx.persistSessionState(session); ctx.persistSessionState(session);
ctx.broadcast(SseEvent.RespawnConfigUpdated, { sessionId: id, config: controller.getConfig() }); 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 // No controller running - save as pre-config for when respawn starts
@@ -206,7 +206,7 @@ export function registerRespawnRoutes(
ctx.mux.updateRespawnConfig(id, merged); ctx.mux.updateRespawnConfig(id, merged);
ctx.persistSessionState(session); ctx.persistSessionState(session);
ctx.broadcast(SseEvent.RespawnConfigUpdated, { sessionId: id, config: merged }); 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() }); ctx.broadcast(SseEvent.RespawnStarted, { sessionId: id, status: controller.getStatus() });
return { return {
success: true,
message: 'Respawn enabled on existing session', message: 'Respawn enabled on existing session',
respawnStatus: controller.getStatus(), respawnStatus: controller.getStatus(),
}; };
+3 -3
View File
@@ -15,7 +15,7 @@ export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort &
return Array.from(ctx.scheduledRuns.values()); return Array.from(ctx.scheduledRuns.values());
}); });
app.post('/api/scheduled', async (req): Promise<{ success: boolean; run: ScheduledRun } | ApiResponse<never>> => { app.post('/api/scheduled', async (req): Promise<{ run: ScheduledRun } | ApiResponse<never>> => {
const { prompt, workingDir, durationMinutes } = parseBody(ScheduledRunSchema, req.body, 'Invalid request body'); const { prompt, workingDir, durationMinutes } = parseBody(ScheduledRunSchema, req.body, 'Invalid request body');
// Validate workingDir exists and is a directory // 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); const run = await ctx.startScheduledRun(prompt, workingDir || process.cwd(), durationMinutes ?? 60);
return { success: true, run }; return { run };
}); });
app.delete('/api/scheduled/:id', async (req) => { app.delete('/api/scheduled/:id', async (req) => {
@@ -43,7 +43,7 @@ export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort &
} }
await ctx.stopScheduledRun(id); await ctx.stopScheduledRun(id);
return { success: true }; return {};
}); });
app.get('/api/scheduled/:id', async (req) => { app.get('/api/scheduled/:id', async (req) => {
+23 -25
View File
@@ -16,7 +16,6 @@ import {
createErrorResponse, createErrorResponse,
getErrorMessage, getErrorMessage,
type ApiResponse, type ApiResponse,
type QuickStartResponse,
type SessionColor, type SessionColor,
} from '../../types.js'; } from '../../types.js';
import { Session } from '../../session.js'; import { Session } from '../../session.js';
@@ -211,7 +210,7 @@ export function registerSessionRoutes(
ctx.authSessions?.delete(sessionToken); ctx.authSessions?.delete(sessionToken);
} }
reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' }); 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. // Avoids serializing 2-3MB of terminal+text buffers per session creation.
const lightState = ctx.getSessionStateWithRespawn(session); const lightState = ctx.getSessionStateWithRespawn(session);
ctx.broadcast(SseEvent.SessionCreated, lightState); ctx.broadcast(SseEvent.SessionCreated, lightState);
return { success: true, session: lightState }; return { session: lightState };
}); });
// ========== Rename Session ========== // ========== Rename Session ==========
@@ -364,7 +363,7 @@ export function registerSessionRoutes(
// Also update the mux session name if applicable // Also update the mux session name if applicable
ctx.mux.updateSessionName(id, session.name); ctx.mux.updateSessionName(id, session.name);
persistAndBroadcastSession(ctx, session); persistAndBroadcastSession(ctx, session);
return { success: true, name: session.name }; return { name: session.name };
}); });
// ========== Set Session Color ========== // ========== Set Session Color ==========
@@ -381,12 +380,12 @@ export function registerSessionRoutes(
session.setColor(body.color as SessionColor); session.setColor(body.color as SessionColor);
persistAndBroadcastSession(ctx, session); persistAndBroadcastSession(ctx, session);
return { success: true, color: session.color }; return { color: session.color };
}); });
// ========== Delete Session ========== // ========== Delete Session ==========
app.delete('/api/sessions/:id', async (req): Promise<ApiResponse> => { app.delete('/api/sessions/:id', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const query = req.query as { killMux?: string }; const query = req.query as { killMux?: string };
const killMux = query.killMux !== 'false'; // Default to true const killMux = query.killMux !== 'false'; // Default to true
@@ -396,7 +395,7 @@ export function registerSessionRoutes(
} }
await ctx.cleanupSession(id, killMux, 'user_delete'); await ctx.cleanupSession(id, killMux, 'user_delete');
return { success: true }; return {};
}); });
// ========== Delete All Sessions ========== // ========== Delete All Sessions ==========
@@ -473,13 +472,13 @@ export function registerSessionRoutes(
// Create a fresh tracker if one doesn't exist (shouldn't happen normally) // Create a fresh tracker if one doesn't exist (shouldn't happen normally)
const newTracker = new RunSummaryTracker(id, session.name); const newTracker = new RunSummaryTracker(id, session.name);
ctx.runSummaryTrackers.set(id, newTracker); ctx.runSummaryTrackers.set(id, newTracker);
return { success: true, summary: newTracker.getSummary() }; return { summary: newTracker.getSummary() };
} }
// Update session name in case it changed // Update session name in case it changed
tracker.setSessionName(session.name); tracker.setSessionName(session.name);
return { success: true, summary: tracker.getSummary() }; return { summary: tracker.getSummary() };
}); });
// ========== Get Active Tools ========== // ========== Get Active Tools ==========
@@ -502,7 +501,7 @@ export function registerSessionRoutes(
// ========== Run Prompt ========== // ========== Run Prompt ==========
app.post('/api/sessions/:id/run', async (req): Promise<ApiResponse> => { app.post('/api/sessions/:id/run', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const { prompt } = parseBody(RunPromptSchema, req.body); const { prompt } = parseBody(RunPromptSchema, req.body);
const session = findSessionOrFail(ctx, id); const session = findSessionOrFail(ctx, id);
@@ -517,12 +516,12 @@ export function registerSessionRoutes(
}); });
ctx.broadcast(SseEvent.SessionRunning, { id, prompt }); ctx.broadcast(SseEvent.SessionRunning, { id, prompt });
return { success: true }; return {};
}); });
// ========== Start Interactive Mode ========== // ========== Start Interactive Mode ==========
app.post('/api/sessions/:id/interactive', async (req): Promise<ApiResponse> => { app.post('/api/sessions/:id/interactive', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id); const session = findSessionOrFail(ctx, id);
@@ -554,7 +553,7 @@ export function registerSessionRoutes(
ctx.broadcast(SseEvent.SessionInteractive, { id }); ctx.broadcast(SseEvent.SessionInteractive, { id });
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) }); ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
return { success: true }; return {};
} catch (err) { } catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
} }
@@ -562,7 +561,7 @@ export function registerSessionRoutes(
// ========== Start Shell Mode ========== // ========== Start Shell Mode ==========
app.post('/api/sessions/:id/shell', async (req): Promise<ApiResponse> => { app.post('/api/sessions/:id/shell', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id); const session = findSessionOrFail(ctx, id);
@@ -580,7 +579,7 @@ export function registerSessionRoutes(
}); });
ctx.broadcast(SseEvent.SessionInteractive, { id, mode: 'shell' }); ctx.broadcast(SseEvent.SessionInteractive, { id, mode: 'shell' });
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) }); ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
return { success: true }; return {};
} catch (err) { } catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
} }
@@ -592,7 +591,7 @@ export function registerSessionRoutes(
// ========== Send Input ========== // ========== Send Input ==========
app.post('/api/sessions/:id/input', async (req): Promise<ApiResponse> => { app.post('/api/sessions/:id/input', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const { input, useMux } = parseBody(SessionInputWithLimitSchema, req.body); const { input, useMux } = parseBody(SessionInputWithLimitSchema, req.body);
const session = findSessionOrFail(ctx, id); const session = findSessionOrFail(ctx, id);
@@ -624,7 +623,7 @@ export function registerSessionRoutes(
} else { } else {
session.write(inputStr); session.write(inputStr);
} }
return { success: true }; return {};
}); });
// ========== Send Named Key (tmux send-keys -H) ========== // ========== 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 // 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). // Ink input recognizes as "insert newline" vs 0x0d (carriage return = submit).
app.post('/api/sessions/:id/send-key', async (req): Promise<ApiResponse> => { app.post('/api/sessions/:id/send-key', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const body = req.body as Record<string, unknown>; const body = req.body as Record<string, unknown>;
const key = typeof body?.key === 'string' ? body.key : ''; const key = typeof body?.key === 'string' ? body.key : '';
@@ -671,18 +670,18 @@ export function registerSessionRoutes(
console.error('[Server] send-key failed:', err); console.error('[Server] send-key failed:', err);
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'tmux send-keys failed'); return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'tmux send-keys failed');
} }
return { success: true }; return {};
}); });
// ========== Resize Terminal ========== // ========== Resize Terminal ==========
app.post('/api/sessions/:id/resize', async (req): Promise<ApiResponse> => { app.post('/api/sessions/:id/resize', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const { cols, rows } = parseBody(ResizeSchema, req.body); const { cols, rows } = parseBody(ResizeSchema, req.body);
const session = findSessionOrFail(ctx, id); const session = findSessionOrFail(ctx, id);
session.resize(cols, rows); session.resize(cols, rows);
return { success: true }; return {};
}); });
// ========== Get Last Response (from transcript JSONL) ========== // ========== Get Last Response (from transcript JSONL) ==========
@@ -1084,7 +1083,7 @@ export function registerSessionRoutes(
const result = await session.runPrompt(prompt); const result = await session.runPrompt(prompt);
// Clean up session after completion to prevent memory leak // Clean up session after completion to prevent memory leak
await ctx.cleanupSession(session.id, true, 'run_prompt_complete'); await ctx.cleanupSession(session.id, true, 'run_prompt_complete');
return { success: true, sessionId: session.id, ...result }; return { sessionId: session.id, ...result };
} catch (err) { } catch (err) {
// Clean up session on error too // Clean up session on error too
await ctx.cleanupSession(session.id, true, 'run_prompt_error'); await ctx.cleanupSession(session.id, true, 'run_prompt_error');
@@ -1094,7 +1093,7 @@ export function registerSessionRoutes(
// ========== Quick Start ========== // ========== Quick Start ==========
app.post('/api/quick-start', async (req): Promise<QuickStartResponse> => { app.post('/api/quick-start', async (req) => {
// Prevent unbounded session creation // Prevent unbounded session creation
if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) { if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) {
return createErrorResponse( return createErrorResponse(
@@ -1263,7 +1262,6 @@ export function registerSessionRoutes(
} }
return { return {
success: true,
sessionId: session.id, sessionId: session.id,
casePath, casePath,
caseName, caseName,
@@ -1725,6 +1723,6 @@ export function registerSessionRoutes(
await fh.close(); await fh.close();
} }
return { success: true, path: filepath, filename }; return { path: filepath, filename };
}); });
} }
+13 -17
View File
@@ -238,7 +238,7 @@ export function registerSystemRoutes(
app.post('/api/tunnel/qr/regenerate', async () => { app.post('/api/tunnel/qr/regenerate', async () => {
ctx.tunnelManager.regenerateQrToken(); ctx.tunnelManager.regenerateQrToken();
return { success: true }; return {};
}); });
// ========== Auth Session Revocation ========== // ========== Auth Session Revocation ==========
@@ -251,7 +251,7 @@ export function registerSystemRoutes(
// Revoke all sessions (nuclear option) // Revoke all sessions (nuclear option)
ctx.authSessions?.clear(); ctx.authSessions?.clear();
} }
return { success: true }; return {};
}); });
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
@@ -288,7 +288,7 @@ export function registerSystemRoutes(
const child = spawn('bash', [scriptPath, url], { detached: true, stdio: 'ignore' }); const child = spawn('bash', [scriptPath, url], { detached: true, stdio: 'ignore' });
child.on('error', (err) => app.log.error({ err }, 'span-displays launch failed')); child.on('error', (err) => app.log.error({ err }, 'span-displays launch failed'));
child.unref(); child.unref();
return { success: true, url }; return { url };
} catch (err) { } catch (err) {
return reply.code(500).send(createErrorResponse(ApiErrorCode.INTERNAL_ERROR, getErrorMessage(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) => { app.post('/api/system/update', async (_req, reply) => {
const result = await startUpdate(); const result = await startUpdate();
if (result.ok) { 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 = { const map = {
'in-flight': { http: 409, api: ApiErrorCode.ALREADY_EXISTS }, 'in-flight': { http: 409, api: ApiErrorCode.ALREADY_EXISTS },
@@ -354,7 +354,7 @@ export function registerSystemRoutes(
for (const s of result.cleaned) { for (const s of result.cleaned) {
lifecycleLog.log({ event: 'stale_cleaned', sessionId: s.id, name: s.name }); 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) => { app.get('/api/session-lifecycle', async (req) => {
@@ -371,7 +371,7 @@ export function registerSystemRoutes(
since: query.since ? Number(query.since) : undefined, since: query.since ? Number(query.since) : undefined,
limit: query.limit ? Math.min(Number(query.limit), 1000) : 200, limit: query.limit ? Math.min(Number(query.limit), 1000) : 200,
}); });
return { success: true, entries }; return { entries };
}); });
// ========== Stats ========== // ========== Stats ==========
@@ -391,7 +391,6 @@ export function registerSystemRoutes(
app.get('/api/stats', async () => { app.get('/api/stats', async () => {
const activeSessionTokens = collectActiveTokens(); const activeSessionTokens = collectActiveTokens();
return { return {
success: true,
stats: ctx.store.getAggregateStats(activeSessionTokens), stats: ctx.store.getAggregateStats(activeSessionTokens),
raw: ctx.store.getGlobalStats(), raw: ctx.store.getGlobalStats(),
}; };
@@ -400,7 +399,6 @@ export function registerSystemRoutes(
app.get('/api/token-stats', async () => { app.get('/api/token-stats', async () => {
const activeSessionTokens = collectActiveTokens(); const activeSessionTokens = collectActiveTokens();
return { return {
success: true,
daily: ctx.store.getDailyStats(30), daily: ctx.store.getDailyStats(30),
totals: ctx.store.getAggregateStats(activeSessionTokens), totals: ctx.store.getAggregateStats(activeSessionTokens),
}; };
@@ -413,13 +411,13 @@ export function registerSystemRoutes(
// ========== Config ========== // ========== Config ==========
app.get('/api/config', async () => { app.get('/api/config', async () => {
return { success: true, config: ctx.store.getConfig() }; return { config: ctx.store.getConfig() };
}); });
app.put('/api/config', async (req) => { app.put('/api/config', async (req) => {
const configData = parseBody(ConfigUpdateSchema, req.body, 'Invalid config'); const configData = parseBody(ConfigUpdateSchema, req.body, 'Invalid config');
ctx.store.setConfig(configData as Partial<ReturnType<typeof ctx.store.getConfig>>); ctx.store.setConfig(configData as Partial<ReturnType<typeof ctx.store.getConfig>>);
return { success: true, config: ctx.store.getConfig() }; return { config: ctx.store.getConfig() };
}); });
// ========== Debug/Memory ========== // ========== Debug/Memory ==========
@@ -535,7 +533,7 @@ export function registerSystemRoutes(
} }
} }
return { success: true }; return {};
} catch (err) { } catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(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)); await fs.writeFile(SETTINGS_PATH, JSON.stringify(existingSettings, null, 2));
return { success: true }; return {};
} catch (err) { } catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
} }
@@ -580,7 +578,6 @@ export function registerSystemRoutes(
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id); const session = findSessionOrFail(ctx, id);
return { return {
success: true,
nice: session.niceConfig, nice: session.niceConfig,
}; };
}); });
@@ -596,7 +593,6 @@ export function registerSystemRoutes(
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) }); ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
return { return {
success: true,
nice: session.niceConfig, nice: session.niceConfig,
note: 'Nice priority only affects newly created mux sessions, not currently running ones.', note: 'Nice priority only affects newly created mux sessions, not currently running ones.',
}; };
@@ -620,7 +616,7 @@ export function registerSystemRoutes(
mkdirSync(dir, { recursive: true }); mkdirSync(dir, { recursive: true });
} }
await fs.writeFile(windowStatesPath, JSON.stringify(states, null, 2)); await fs.writeFile(windowStatesPath, JSON.stringify(states, null, 2));
return { success: true }; return {};
} catch (err) { } catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
} }
@@ -640,7 +636,7 @@ export function registerSystemRoutes(
mkdirSync(dir, { recursive: true }); mkdirSync(dir, { recursive: true });
} }
await fs.writeFile(parentMapPath, JSON.stringify(parentMap, null, 2)); await fs.writeFile(parentMapPath, JSON.stringify(parentMap, null, 2));
return { success: true }; return {};
} catch (err) { } catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
} }
@@ -799,7 +795,7 @@ export function registerSystemRoutes(
const filepath = join(SCREENSHOTS_DIR, filename); const filepath = join(SCREENSHOTS_DIR, filename);
await fs.writeFile(filepath, filePart.data); await fs.writeFile(filepath, filePart.data);
return { success: true, path: filepath, filename }; return { path: filepath, filename };
}); });
app.get('/api/screenshots', async () => { app.get('/api/screenshots', async () => {
+37 -2
View File
@@ -90,8 +90,21 @@ import { reconcileUpdateOnBoot } from './self-update.js';
// Load version from package.json // Load version from package.json
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const { version: APP_VERSION } = require('../../package.json'); 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 { import {
getErrorMessage, getErrorMessage,
httpStatusForErrorCode,
ApiErrorCode,
type PersistedRespawnConfig, type PersistedRespawnConfig,
type NiceConfig, type NiceConfig,
type ImageDetectedEvent, type ImageDetectedEvent,
@@ -274,11 +287,12 @@ export class WebServer extends EventEmitter {
this.windowTitle = `codeman:${this.titleHostname}`; this.windowTitle = `codeman:${this.titleHostname}`;
this.indexHtmlTemplate = readFileSync(join(__dirname, 'public', 'index.html'), 'utf-8'); this.indexHtmlTemplate = readFileSync(join(__dirname, 'public', 'index.html'), 'utf-8');
const rewriteUrl = (req: { url?: string }): string => rewriteApiV1Url(req.url || '');
if (https) { if (https) {
const { key, cert } = getOrCreateSelfSignedCert(); const { key, cert } = getOrCreateSelfSignedCert();
this.app = Fastify({ logger: false, https: { key, cert } }); this.app = Fastify({ logger: false, https: { key, cert }, rewriteUrl });
} else { } else {
this.app = Fastify({ logger: false }); this.app = Fastify({ logger: false, rewriteUrl });
} }
this.mux = createMultiplexer(); this.mux = createMultiplexer();
this.sse = new SseStreamManager( this.sse = new SseStreamManager(
@@ -561,6 +575,27 @@ export class WebServer extends EventEmitter {
// Cookie plugin (needed for auth session tokens) // Cookie plugin (needed for auth session tokens)
await this.app.register(fastifyCookie); 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 // Anti-DNS-rebinding Host allowlist + cross-site (CSRF) Origin guard. Registered
// before auth so forged cross-site / rebound requests are rejected up front, even // 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. // on the default no-password install. See docs/reports/security-review-2026-06-09.md.
+9 -9
View File
@@ -190,7 +190,7 @@ describe('Edge Cases and Error Handling', () => {
// Should succeed with valid characters, even if long // Should succeed with valid characters, even if long
if (data.success) { if (data.success) {
createdCases.push(longName); createdCases.push(data.data.caseName);
} }
// Either succeeds or fails gracefully // Either succeeds or fails gracefully
expect(data).toHaveProperty('success'); 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 response = await fetch(`${baseUrl}/api/sessions/non-existent/respawn`);
const data = await response.json(); const data = await response.json();
expect(data.enabled).toBe(false); expect(data.data.enabled).toBe(false);
expect(data.status).toBeNull(); expect(data.data.status).toBeNull();
}); });
it('should handle starting respawn on non-existent session', async () => { it('should handle starting respawn on non-existent session', async () => {
@@ -287,17 +287,17 @@ describe('Concurrent Session Handling', () => {
// All should succeed // All should succeed
for (const result of results) { for (const result of results) {
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.session.id).toBeDefined(); expect(result.data.session.id).toBeDefined();
} }
// Verify sessions are listed // Verify sessions are listed
const listRes = await fetch(`${baseUrl}/api/sessions`); const listRes = await fetch(`${baseUrl}/api/sessions`);
const sessions = await listRes.json(); const sessions = await listRes.json();
expect(sessions.length).toBeGreaterThanOrEqual(5); expect(sessions.data.length).toBeGreaterThanOrEqual(5);
// Clean up - delete all created sessions // Clean up - delete all created sessions
for (const result of results) { for (const result of results) {
await fetch(`${baseUrl}/api/sessions/${result.session.id}`, { await fetch(`${baseUrl}/api/sessions/${result.data.session.id}`, {
method: 'DELETE', method: 'DELETE',
}); });
} }
@@ -317,7 +317,7 @@ describe('Concurrent Session Handling', () => {
expect(createData.success).toBe(true); expect(createData.success).toBe(true);
// Delete immediately // 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', method: 'DELETE',
}); });
const deleteData = await deleteRes.json(); const deleteData = await deleteRes.json();
@@ -341,8 +341,8 @@ describe('Concurrent Session Handling', () => {
for (const result of results) { for (const result of results) {
expect(result.success).toBe(true); expect(result.success).toBe(true);
if (result.caseName) { if (result.data.caseName) {
createdCases.push(result.caseName); createdCases.push(result.data.caseName);
} }
} }
+2 -2
View File
@@ -147,7 +147,7 @@ describe('File Link Click Tests', () => {
const data = await response.json(); const data = await response.json();
expect(data.success).toBe(true); expect(data.success).toBe(true);
createdSessions.push(data.session.id); createdSessions.push(data.data.sessionId);
// Wait for session to appear in UI // Wait for session to appear in UI
await new Promise((r) => setTimeout(r, 2000)); await new Promise((r) => setTimeout(r, 2000));
@@ -347,7 +347,7 @@ describe('File Link Click Tests', () => {
}); });
const data = await response.json(); const data = await response.json();
expect(data.success).toBe(true); 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); createdSessions.push(sessionId);
} }
+12 -12
View File
@@ -32,21 +32,21 @@ describe('generateHooksConfig', () => {
it('should configure idle_prompt matcher', () => { it('should configure idle_prompt matcher', () => {
const config = generateHooksConfig(); const config = generateHooksConfig();
const notifHooks = config.hooks.Notification as Array<{ matcher?: string }>; 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(); expect(idleHook).toBeDefined();
}); });
it('should configure permission_prompt matcher', () => { it('should configure permission_prompt matcher', () => {
const config = generateHooksConfig(); const config = generateHooksConfig();
const notifHooks = config.hooks.Notification as Array<{ matcher?: string }>; 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(); expect(permHook).toBeDefined();
}); });
it('should configure elicitation_dialog matcher', () => { it('should configure elicitation_dialog matcher', () => {
const config = generateHooksConfig(); const config = generateHooksConfig();
const notifHooks = config.hooks.Notification as Array<{ matcher?: string }>; 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(); expect(elicitHook).toBeDefined();
}); });
@@ -146,7 +146,7 @@ describe('writeHooksConfig', () => {
mkdirSync(claudeDir, { recursive: true }); mkdirSync(claudeDir, { recursive: true });
writeFileSync( writeFileSync(
join(claudeDir, 'settings.local.json'), 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); await writeHooksConfig(testDir);
@@ -160,10 +160,7 @@ describe('writeHooksConfig', () => {
it('should overwrite existing hooks key', async () => { it('should overwrite existing hooks key', async () => {
const claudeDir = join(testDir, '.claude'); const claudeDir = join(testDir, '.claude');
mkdirSync(claudeDir, { recursive: true }); mkdirSync(claudeDir, { recursive: true });
writeFileSync( writeFileSync(join(claudeDir, 'settings.local.json'), JSON.stringify({ hooks: { oldHook: [] } }, null, 2));
join(claudeDir, 'settings.local.json'),
JSON.stringify({ hooks: { oldHook: [] } }, null, 2),
);
await writeHooksConfig(testDir); await writeHooksConfig(testDir);
@@ -214,7 +211,7 @@ describe('Hook Event API', () => {
body: JSON.stringify({}), body: JSON.stringify({}),
}); });
const createData = await createRes.json(); const createData = await createRes.json();
testSessionId = createData.session.id; testSessionId = createData.data.session.id;
}); });
afterAll(async () => { afterAll(async () => {
@@ -396,7 +393,7 @@ describe('Hook Data Sanitization', () => {
body: JSON.stringify({}), body: JSON.stringify({}),
}); });
const createData = await createRes.json(); const createData = await createRes.json();
testSessionId = createData.session.id; testSessionId = createData.data.session.id;
}); });
afterAll(async () => { afterAll(async () => {
@@ -641,7 +638,7 @@ describe('Hook Config Generation - Extended', () => {
it('should include all event types', () => { it('should include all event types', () => {
const config = generateHooksConfig(); const config = generateHooksConfig();
const notifHooks = config.hooks.Notification as Array<{ matcher?: string }>; 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('idle_prompt');
expect(matchers).toContain('permission_prompt'); expect(matchers).toContain('permission_prompt');
expect(matchers).toContain('elicitation_dialog'); expect(matchers).toContain('elicitation_dialog');
@@ -694,7 +691,10 @@ describe('Hook Config Generation - Extended', () => {
it('should have consistent structure across all notification hooks', () => { it('should have consistent structure across all notification hooks', () => {
const config = generateHooksConfig(); 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) { for (const hook of notifHooks) {
expect(hook.matcher).toBeDefined(); expect(hook.matcher).toBeDefined();
+47 -41
View File
@@ -58,24 +58,24 @@ describe('Integration Flows', () => {
const quickStartData = await quickStartRes.json(); const quickStartData = await quickStartRes.json();
expect(quickStartData.success).toBe(true); expect(quickStartData.success).toBe(true);
expect(quickStartData.sessionId).toBeDefined(); expect(quickStartData.data.sessionId).toBeDefined();
expect(quickStartData.caseName).toBe(caseName); expect(quickStartData.data.caseName).toBe(caseName);
createdSessions.push(quickStartData.sessionId); createdSessions.push(quickStartData.data.sessionId);
// Step 2: Verify session is in interactive mode // 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(); const sessionData = await sessionRes.json();
expect(sessionData.id).toBe(quickStartData.sessionId); expect(sessionData.data.id).toBe(quickStartData.data.sessionId);
expect(sessionData.workingDir).toContain(caseName); expect(sessionData.data.workingDir).toContain(caseName);
expect(['busy', 'idle', 'running']).toContain(sessionData.status); // May transition quickly in test mode expect(['busy', 'idle', 'running']).toContain(sessionData.data.status); // May transition quickly in test mode
// Step 3: Verify case was created with CLAUDE.md // Step 3: Verify case was created with CLAUDE.md
const caseRes = await fetch(`${baseUrl}/api/cases/${caseName}`); const caseRes = await fetch(`${baseUrl}/api/cases/${caseName}`);
const caseData = await caseRes.json(); const caseData = await caseRes.json();
expect(caseData.name).toBe(caseName); expect(caseData.data.name).toBe(caseName);
expect(caseData.hasClaudeMd).toBe(true); expect(caseData.data.hasClaudeMd).toBe(true);
}); });
it('should reuse existing case when quick starting with existing case name', async () => { 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(); const firstData = await firstRes.json();
expect(firstData.success).toBe(true); expect(firstData.success).toBe(true);
createdSessions.push(firstData.sessionId); createdSessions.push(firstData.data.sessionId);
// Delete the session but keep the case // 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 // Second quick start - should reuse the case
const secondRes = await fetch(`${baseUrl}/api/quick-start`, { const secondRes = await fetch(`${baseUrl}/api/quick-start`, {
@@ -104,9 +104,9 @@ describe('Integration Flows', () => {
const secondData = await secondRes.json(); const secondData = await secondRes.json();
expect(secondData.success).toBe(true); expect(secondData.success).toBe(true);
expect(secondData.caseName).toBe(caseName); expect(secondData.data.caseName).toBe(caseName);
expect(secondData.casePath).toBe(firstData.casePath); expect(secondData.data.casePath).toBe(firstData.data.casePath);
createdSessions.push(secondData.sessionId); createdSessions.push(secondData.data.sessionId);
}); });
}); });
@@ -132,20 +132,20 @@ describe('Integration Flows', () => {
}); });
const sessionData = await sessionRes.json(); const sessionData = await sessionRes.json();
expect(sessionData.success).toBe(true); expect(sessionData.success).toBe(true);
createdSessions.push(sessionData.session.id); createdSessions.push(sessionData.data.session.id);
// Step 3: Start interactive mode // 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', method: 'POST',
}); });
const interactiveData = await interactiveRes.json(); const interactiveData = await interactiveRes.json();
expect(interactiveData.success).toBe(true); expect(interactiveData.success).toBe(true);
// Verify session state // 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(); const verifyData = await verifyRes.json();
expect(['busy', 'idle', 'running']).toContain(verifyData.status); expect(['busy', 'idle', 'running']).toContain(verifyData.data.status);
expect(verifyData.workingDir).toContain(caseName); expect(verifyData.data.workingDir).toContain(caseName);
}); });
}); });
@@ -162,13 +162,13 @@ describe('Integration Flows', () => {
}); });
const quickStartData = await quickStartRes.json(); const quickStartData = await quickStartRes.json();
expect(quickStartData.success).toBe(true); expect(quickStartData.success).toBe(true);
createdSessions.push(quickStartData.sessionId); createdSessions.push(quickStartData.data.sessionId);
// Wait for Claude to start up // Wait for Claude to start up
await new Promise((resolve) => setTimeout(resolve, 2000)); await new Promise((resolve) => setTimeout(resolve, 2000));
// Send input // 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: '/help\n' }), body: JSON.stringify({ input: '/help\n' }),
@@ -180,9 +180,9 @@ describe('Integration Flows', () => {
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
// Check terminal buffer has content // 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(); const terminalData = await terminalRes.json();
expect(terminalData.terminalBuffer.length).toBeGreaterThan(0); expect(terminalData.data.terminalBuffer.length).toBeGreaterThan(0);
}); });
it('should handle terminal resize', async () => { it('should handle terminal resize', async () => {
@@ -197,15 +197,21 @@ describe('Integration Flows', () => {
}); });
const quickStartData = await quickStartRes.json(); const quickStartData = await quickStartRes.json();
expect(quickStartData.success).toBe(true); expect(quickStartData.success).toBe(true);
createdSessions.push(quickStartData.sessionId); createdSessions.push(quickStartData.data.sessionId);
// Resize terminal // Resize terminal (retry briefly — a just-quick-started session can be
const resizeRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.sessionId}/resize`, { // momentarily busy, which would return SESSION_BUSY; this is a transient race).
method: 'POST', let resizeData;
headers: { 'Content-Type': 'application/json' }, for (let attempt = 0; attempt < 5; attempt++) {
body: JSON.stringify({ cols: 200, rows: 50 }), const resizeRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.data.sessionId}/resize`, {
}); method: 'POST',
const resizeData = await resizeRes.json(); 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); expect(resizeData.success).toBe(true);
}); });
}); });
@@ -225,14 +231,14 @@ describe('Integration Flows', () => {
expect(quickStartData.success).toBe(true); expect(quickStartData.success).toBe(true);
// Delete the session // Delete the session
const deleteRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.sessionId}`, { const deleteRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.data.sessionId}`, {
method: 'DELETE', method: 'DELETE',
}); });
const deleteData = await deleteRes.json(); const deleteData = await deleteRes.json();
expect(deleteData.success).toBe(true); expect(deleteData.success).toBe(true);
// Verify session is gone // 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(); const verifyData = await verifyRes.json();
expect(verifyData.error).toContain('not found'); expect(verifyData.error).toContain('not found');
}); });
@@ -251,20 +257,20 @@ describe('Integration Flows', () => {
}); });
const quickStartData = await quickStartRes.json(); const quickStartData = await quickStartRes.json();
expect(quickStartData.success).toBe(true); expect(quickStartData.success).toBe(true);
createdSessions.push(quickStartData.sessionId); createdSessions.push(quickStartData.data.sessionId);
// Get full status // Get full status
const statusRes = await fetch(`${baseUrl}/api/status`); const statusRes = await fetch(`${baseUrl}/api/status`);
const statusData = await statusRes.json(); const statusData = await statusRes.json();
expect(statusData.sessions).toBeDefined(); expect(statusData.data.sessions).toBeDefined();
expect(Array.isArray(statusData.sessions)).toBe(true); expect(Array.isArray(statusData.data.sessions)).toBe(true);
expect(statusData.scheduledRuns).toBeDefined(); expect(statusData.data.scheduledRuns).toBeDefined();
expect(statusData.respawnStatus).toBeDefined(); expect(statusData.data.respawnStatus).toBeDefined();
expect(statusData.timestamp).toBeDefined(); expect(statusData.data.timestamp).toBeDefined();
// Verify our session is in the list // 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).toBeDefined();
expect(ourSession.workingDir).toContain(caseName); expect(ourSession.workingDir).toContain(caseName);
}); });
@@ -341,7 +347,7 @@ describe('SSE Event Flow', () => {
}); });
const quickStartData = await quickStartRes.json(); const quickStartData = await quickStartRes.json();
expect(quickStartData.success).toBe(true); expect(quickStartData.success).toBe(true);
createdSessions.push(quickStartData.sessionId); createdSessions.push(quickStartData.data.sessionId);
// Wait for events // Wait for events
await new Promise((resolve) => setTimeout(resolve, 500)); await new Promise((resolve) => setTimeout(resolve, 500));
+25 -23
View File
@@ -88,10 +88,10 @@ async function createSession(baseUrl: string): Promise<string> {
body: JSON.stringify({ workingDir: '/tmp' }), body: JSON.stringify({ workingDir: '/tmp' }),
}); });
const data = await res.json(); const data = await res.json();
if (!data.session?.id) { if (!data.data?.session?.id) {
throw new Error(`Failed to create session: ${JSON.stringify(data)}`); throw new Error(`Failed to create session: ${JSON.stringify(data)}`);
} }
return data.session.id; return data.data.session.id;
} }
// Helper to delete a session // Helper to delete a session
@@ -392,9 +392,9 @@ describe('Operation Lightspeed', () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
// terminalBuffer may be empty for a fresh session, but field should exist // terminalBuffer may be empty for a fresh session, but field should exist
expect(data).toHaveProperty('terminalBuffer'); expect(data.data).toHaveProperty('terminalBuffer');
expect(data).toHaveProperty('truncated'); expect(data.data).toHaveProperty('truncated');
expect(data.truncated).toBe(false); expect(data.data.truncated).toBe(false);
await deleteSession(baseUrl, sessionId); await deleteSession(baseUrl, sessionId);
}); });
@@ -407,7 +407,7 @@ describe('Operation Lightspeed', () => {
const data = await res.json(); const data = await res.json();
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(data).toHaveProperty('terminalBuffer'); expect(data.data).toHaveProperty('terminalBuffer');
await deleteSession(baseUrl, sessionId); await deleteSession(baseUrl, sessionId);
}); });
@@ -431,7 +431,7 @@ describe('Operation Lightspeed', () => {
// All should succeed // All should succeed
for (const data of results) { for (const data of results) {
expect(data).toHaveProperty('terminalBuffer'); expect(data.data).toHaveProperty('terminalBuffer');
} }
// Cleanup // Cleanup
@@ -692,7 +692,8 @@ describe('Operation Lightspeed', () => {
const sessionId = await createSession(baseUrl); const sessionId = await createSession(baseUrl);
const res = await fetch(`${baseUrl}/api/sessions`); 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); expect(Array.isArray(sessions)).toBe(true);
const session = sessions.find((s: any) => s.id === sessionId); const session = sessions.find((s: any) => s.id === sessionId);
@@ -718,7 +719,8 @@ describe('Operation Lightspeed', () => {
await new Promise((resolve) => setTimeout(resolve, 1100)); await new Promise((resolve) => setTimeout(resolve, 1100));
const res = await fetch(`${baseUrl}/api/sessions`); 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 // All 3 should be present in the response
const foundIds = sessions.map((s: any) => s.id); const foundIds = sessions.map((s: any) => s.id);
@@ -911,10 +913,10 @@ describe('Operation Lightspeed', () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
// Local echo overlay needs session status to know when to show/hide // Local echo overlay needs session status to know when to show/hide
expect(data).toHaveProperty('status'); expect(data.data).toHaveProperty('status');
expect(typeof data.status).toBe('string'); expect(typeof data.data.status).toBe('string');
// Fresh session starts as 'starting' // 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); await deleteSession(baseUrl, sessionId);
}); });
@@ -926,9 +928,9 @@ describe('Operation Lightspeed', () => {
const data = await res.json(); const data = await res.json();
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(data).toHaveProperty('fullSize'); expect(data.data).toHaveProperty('fullSize');
expect(typeof data.fullSize).toBe('number'); expect(typeof data.data.fullSize).toBe('number');
expect(data.fullSize).toBeGreaterThanOrEqual(0); expect(data.data.fullSize).toBeGreaterThanOrEqual(0);
await deleteSession(baseUrl, sessionId); await deleteSession(baseUrl, sessionId);
}); });
@@ -942,9 +944,9 @@ describe('Operation Lightspeed', () => {
]); ]);
// tail=0 means "don't tail" — should return same as no tail param // tail=0 means "don't tail" — should return same as no tail param
expect(fullRes.truncated).toBe(false); expect(fullRes.data.truncated).toBe(false);
expect(tailZeroRes.truncated).toBe(false); expect(tailZeroRes.data.truncated).toBe(false);
expect(fullRes.terminalBuffer).toBe(tailZeroRes.terminalBuffer); expect(fullRes.data.terminalBuffer).toBe(tailZeroRes.data.terminalBuffer);
await deleteSession(baseUrl, sessionId); await deleteSession(baseUrl, sessionId);
}); });
@@ -957,8 +959,8 @@ describe('Operation Lightspeed', () => {
const data = await res.json(); const data = await res.json();
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(data).toHaveProperty('terminalBuffer'); expect(data.data).toHaveProperty('terminalBuffer');
expect(data.truncated).toBe(false); // Can't truncate if tail > fullSize expect(data.data.truncated).toBe(false); // Can't truncate if tail > fullSize
await deleteSession(baseUrl, sessionId); await deleteSession(baseUrl, sessionId);
}); });
@@ -971,7 +973,7 @@ describe('Operation Lightspeed', () => {
// Should handle gracefully (either return full buffer or error cleanly) // Should handle gracefully (either return full buffer or error cleanly)
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(data).toHaveProperty('terminalBuffer'); expect(data.data).toHaveProperty('terminalBuffer');
await deleteSession(baseUrl, sessionId); await deleteSession(baseUrl, sessionId);
}); });
@@ -984,7 +986,7 @@ describe('Operation Lightspeed', () => {
// NaN tail should be handled (parseInt('abc') = NaN, which is falsy) // NaN tail should be handled (parseInt('abc') = NaN, which is falsy)
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(data).toHaveProperty('terminalBuffer'); expect(data.data).toHaveProperty('terminalBuffer');
await deleteSession(baseUrl, sessionId); 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(ids.length).toBe(5);
expect(new Set(ids).size).toBe(5); // All unique expect(new Set(ids).size).toBe(5); // All unique
+20 -23
View File
@@ -86,8 +86,7 @@ describe('QR Token Manager (unit)', () => {
// Manually expire the token by manipulating its createdAt // Manually expire the token by manipulating its createdAt
// Access the private map — this is a unit test, we need to verify the TTL logic // Access the private map — this is a unit test, we need to verify the TTL logic
const tokenMap = (tm as unknown as { qrTokensByCode: Map<string, { createdAt: number }> }) const tokenMap = (tm as unknown as { qrTokensByCode: Map<string, { createdAt: number }> }).qrTokensByCode;
.qrTokensByCode;
const record = tokenMap.get(code)!; const record = tokenMap.get(code)!;
record.createdAt = Date.now() - 91_000; // 91 seconds ago (beyond 90s grace) 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()!; const code = tm.getCurrentShortCode()!;
// Set createdAt to 80 seconds ago (within 90s grace) // Set createdAt to 80 seconds ago (within 90s grace)
const tokenMap = (tm as unknown as { qrTokensByCode: Map<string, { createdAt: number }> }) const tokenMap = (tm as unknown as { qrTokensByCode: Map<string, { createdAt: number }> }).qrTokensByCode;
.qrTokensByCode;
const record = tokenMap.get(code)!; const record = tokenMap.get(code)!;
record.createdAt = Date.now() - 80_000; record.createdAt = Date.now() - 80_000;
@@ -172,8 +170,7 @@ describe('QR Token Manager (unit)', () => {
it('should accept token at exactly grace period (90000ms)', () => { it('should accept token at exactly grace period (90000ms)', () => {
const code = tm.getCurrentShortCode()!; const code = tm.getCurrentShortCode()!;
const tokenMap = (tm as unknown as { qrTokensByCode: Map<string, { createdAt: number }> }) const tokenMap = (tm as unknown as { qrTokensByCode: Map<string, { createdAt: number }> }).qrTokensByCode;
.qrTokensByCode;
const record = tokenMap.get(code)!; const record = tokenMap.get(code)!;
record.createdAt = Date.now() - 90_000; record.createdAt = Date.now() - 90_000;
// Condition is `> QR_TOKEN_GRACE_MS` (strict >), so exactly 90000 should pass // 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)', () => { it('should reject token at grace period + 1ms (90001ms)', () => {
const code = tm.getCurrentShortCode()!; const code = tm.getCurrentShortCode()!;
const tokenMap = (tm as unknown as { qrTokensByCode: Map<string, { createdAt: number }> }) const tokenMap = (tm as unknown as { qrTokensByCode: Map<string, { createdAt: number }> }).qrTokensByCode;
.qrTokensByCode;
const record = tokenMap.get(code)!; const record = tokenMap.get(code)!;
record.createdAt = Date.now() - 90_001; record.createdAt = Date.now() - 90_001;
expect(tm.consumeToken(code)).toBe(false); expect(tm.consumeToken(code)).toBe(false);
@@ -308,8 +304,7 @@ describe('QR Auth Integration', () => {
beforeEach(() => { beforeEach(() => {
// Reset QR failure counter to prevent cross-test contamination // Reset QR failure counter to prevent cross-test contamination
// (all requests come from 127.0.0.1) // (all requests come from 127.0.0.1)
const qrFailures = (server as unknown as { qrAuthFailures: { clear(): void } | null }) const qrFailures = (server as unknown as { qrAuthFailures: { clear(): void } | null }).qrAuthFailures;
.qrAuthFailures;
if (qrFailures) qrFailures.clear(); if (qrFailures) qrFailures.clear();
}); });
@@ -568,9 +563,11 @@ describe('QR Auth Integration', () => {
const setCookie = res.headers.get('set-cookie')!; const setCookie = res.headers.get('set-cookie')!;
const token = setCookie.match(/codeman_session=([^;]+)/)![1]; const token = setCookie.match(/codeman_session=([^;]+)/)![1];
const authSessions = (server as unknown as { const authSessions = (
authSessions: { get(k: string): { method: string } | undefined } | null; server as unknown as {
}).authSessions; authSessions: { get(k: string): { method: string } | undefined } | null;
}
).authSessions;
const record = authSessions?.get(token); const record = authSessions?.get(token);
expect(record).toBeDefined(); expect(record).toBeDefined();
expect(record!.method).toBe('qr'); expect(record!.method).toBe('qr');
@@ -631,9 +628,9 @@ describe('QR SVG Endpoint (GET /api/tunnel/qr)', () => {
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
const data = await res.json(); const data = await res.json();
expect(data.authEnabled).toBe(true); expect(data.data.authEnabled).toBe(true);
expect(data.svg).toContain('<svg'); expect(data.data.svg).toContain('<svg');
expect(data.svg).toContain('</svg>'); expect(data.data.svg).toContain('</svg>');
} finally { } finally {
tm.stopTokenRotation(); tm.stopTokenRotation();
simulateTunnelStopped(tm); simulateTunnelStopped(tm);
@@ -653,9 +650,9 @@ describe('QR SVG Endpoint (GET /api/tunnel/qr)', () => {
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
const data = await res.json(); const data = await res.json();
expect(data.authEnabled).toBe(false); expect(data.data.authEnabled).toBe(false);
expect(data.svg).toContain('<svg'); expect(data.data.svg).toContain('<svg');
expect(data.svg).toContain('</svg>'); expect(data.data.svg).toContain('</svg>');
} finally { } finally {
process.env.CODEMAN_PASSWORD = savedPass; process.env.CODEMAN_PASSWORD = savedPass;
simulateTunnelStopped(tm); simulateTunnelStopped(tm);
@@ -709,8 +706,8 @@ describe('QR SVG Endpoint (GET /api/tunnel/qr)', () => {
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
const data = await res.json(); const data = await res.json();
expect(data.svg).toContain('<svg'); expect(data.data.svg).toContain('<svg');
expect(data.authEnabled).toBe(false); expect(data.data.authEnabled).toBe(false);
} finally { } finally {
process.env.CODEMAN_PASSWORD = savedPass; process.env.CODEMAN_PASSWORD = savedPass;
simulateTunnelStopped(tm); simulateTunnelStopped(tm);
@@ -732,7 +729,7 @@ describe('QR SVG Endpoint (GET /api/tunnel/qr)', () => {
}); });
const data2 = await res2.json(); const data2 = await res2.json();
expect(data1.svg).toBe(data2.svg); expect(data1.data.svg).toBe(data2.data.svg);
} finally { } finally {
tm.stopTokenRotation(); tm.stopTokenRotation();
simulateTunnelStopped(tm); simulateTunnelStopped(tm);
@@ -756,7 +753,7 @@ describe('QR SVG Endpoint (GET /api/tunnel/qr)', () => {
}); });
const data2 = await res2.json(); const data2 = await res2.json();
expect(data1.svg).not.toBe(data2.svg); expect(data1.data.svg).not.toBe(data2.data.svg);
} finally { } finally {
tm.stopTokenRotation(); tm.stopTokenRotation();
simulateTunnelStopped(tm); simulateTunnelStopped(tm);
+28 -24
View File
@@ -47,14 +47,14 @@ describe('Quick Start API', () => {
const data = await response.json(); const data = await response.json();
expect(data.success).toBe(true); expect(data.success).toBe(true);
expect(data.sessionId).toBeDefined(); expect(data.data.sessionId).toBeDefined();
expect(data.caseName).toBe(testCaseName); expect(data.data.caseName).toBe(testCaseName);
expect(data.casePath).toBe(join(CASES_DIR, testCaseName)); expect(data.data.casePath).toBe(join(CASES_DIR, testCaseName));
// Verify case folder was created // Verify case folder was created
expect(existsSync(data.casePath)).toBe(true); expect(existsSync(data.data.casePath)).toBe(true);
expect(existsSync(join(data.casePath, 'CLAUDE.md'))).toBe(true); expect(existsSync(join(data.data.casePath, 'CLAUDE.md'))).toBe(true);
expect(existsSync(join(data.casePath, 'src'))).toBe(true); expect(existsSync(join(data.data.casePath, 'src'))).toBe(true);
}); });
it('should use existing case without recreating it', async () => { it('should use existing case without recreating it', async () => {
@@ -74,7 +74,7 @@ describe('Quick Start API', () => {
const data = await response.json(); const data = await response.json();
expect(data.success).toBe(true); 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 // Case should exist but CLAUDE.md won't be created since case already exists
expect(existsSync(casePath)).toBe(true); expect(existsSync(casePath)).toBe(true);
}); });
@@ -118,7 +118,7 @@ describe('Quick Start API', () => {
const data = await response.json(); const data = await response.json();
expect(data.success).toBe(true); 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 () => { it('should default to "testcase" when no caseName provided', async () => {
@@ -137,7 +137,7 @@ describe('Quick Start API', () => {
const data = await response.json(); const data = await response.json();
expect(data.success).toBe(true); 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(); const data = await response.json();
expect(data.success).toBe(true); expect(data.success).toBe(true);
expect(data.session).toBeDefined(); expect(data.data.session).toBeDefined();
expect(data.session.id).toBeDefined(); expect(data.data.session.id).toBeDefined();
expect(data.session.workingDir).toBe('/tmp'); expect(data.data.session.workingDir).toBe('/tmp');
expect(data.session.status).toBe('idle'); expect(data.data.session.status).toBe('idle');
}); });
}); });
@@ -179,7 +179,8 @@ describe('Session Management', () => {
const response = await fetch(`${baseUrl}/api/sessions`); const response = await fetch(`${baseUrl}/api/sessions`);
const data = await response.json(); 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 response = await fetch(`${baseUrl}/api/status`);
const data = await response.json(); const data = await response.json();
expect(data).toHaveProperty('sessions'); expect(data.success).toBe(true);
expect(data).toHaveProperty('scheduledRuns'); expect(data.data).toHaveProperty('sessions');
expect(data).toHaveProperty('respawnStatus'); expect(data.data).toHaveProperty('scheduledRuns');
expect(data).toHaveProperty('timestamp'); expect(data.data).toHaveProperty('respawnStatus');
expect(Array.isArray(data.sessions)).toBe(true); expect(data.data).toHaveProperty('timestamp');
expect(Array.isArray(data.scheduledRuns)).toBe(true); 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 response = await fetch(`${baseUrl}/api/cases`);
const data = await response.json(); 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 response = await fetch(`${baseUrl}/api/cases/${testCaseName}`);
const data = await response.json(); const data = await response.json();
expect(data.name).toBe(testCaseName); expect(data.success).toBe(true);
expect(data.path).toBeDefined(); expect(data.data.name).toBe(testCaseName);
expect(data.hasClaudeMd).toBe(true); expect(data.data.path).toBeDefined();
expect(data.data.hasClaudeMd).toBe(true);
}); });
it('should return error for non-existent case', async () => { it('should return error for non-existent case', async () => {
+87 -87
View File
@@ -61,7 +61,7 @@ describe('Ralph Integration Tests', () => {
const data = await res.json(); const data = await res.json();
expect(res.status).toBe(200); 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 () => { it('should create a new session via quick-start', async () => {
@@ -76,8 +76,8 @@ describe('Ralph Integration Tests', () => {
const data = await res.json(); const data = await res.json();
expect(data.success).toBe(true); expect(data.success).toBe(true);
expect(data.sessionId).toBeDefined(); expect(data.data.sessionId).toBeDefined();
createdSessions.push(data.sessionId); createdSessions.push(data.data.sessionId);
}); });
it('should get session details by ID', async () => { it('should get session details by ID', async () => {
@@ -91,15 +91,15 @@ describe('Ralph Integration Tests', () => {
body: JSON.stringify({ caseName }), body: JSON.stringify({ caseName }),
}); });
const createData = await createRes.json(); const createData = await createRes.json();
createdSessions.push(createData.sessionId); createdSessions.push(createData.data.sessionId);
// Get session details // 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(); const data = await res.json();
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(data.id).toBe(createData.sessionId); expect(data.data.id).toBe(createData.data.sessionId);
expect(data.workingDir).toContain(caseName); expect(data.data.workingDir).toContain(caseName);
}); });
it('should return error for non-existent session', async () => { it('should return error for non-existent session', async () => {
@@ -122,7 +122,7 @@ describe('Ralph Integration Tests', () => {
body: JSON.stringify({ caseName }), body: JSON.stringify({ caseName }),
}); });
const createData = await createRes.json(); const createData = await createRes.json();
const sessionId = createData.sessionId; const sessionId = createData.data.sessionId;
// Delete session // Delete session
const deleteRes = await fetch(`${baseUrl}/api/sessions/${sessionId}`, { const deleteRes = await fetch(`${baseUrl}/api/sessions/${sessionId}`, {
@@ -152,13 +152,13 @@ describe('Ralph Integration Tests', () => {
const data = await res.json(); const data = await res.json();
expect(data.success).toBe(true); expect(data.success).toBe(true);
expect(data.sessionId).toBeDefined(); expect(data.data.sessionId).toBeDefined();
createdSessions.push(data.sessionId); createdSessions.push(data.data.sessionId);
// Verify mode // 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(); 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 }), body: JSON.stringify({ caseName }),
}); });
const createData = await createRes.json(); 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(); const data = await res.json();
expect(res.status).toBe(200); expect(res.status).toBe(200);
@@ -206,7 +206,7 @@ describe('Ralph Integration Tests', () => {
const data = await res.json(); const data = await res.json();
expect(res.status).toBe(200); 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 () => { it('should create a new case', async () => {
@@ -244,7 +244,7 @@ describe('Ralph Integration Tests', () => {
}); });
const data = await res.json(); const data = await res.json();
expect(res.status).toBe(200); expect(res.status).toBe(409);
expect(data.success).toBe(false); expect(data.success).toBe(false);
expect(data.error).toContain('already exists'); expect(data.error).toContain('already exists');
}); });
@@ -265,15 +265,15 @@ describe('Ralph Integration Tests', () => {
const data = await res.json(); const data = await res.json();
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(data.name).toBe(caseName); expect(data.data.name).toBe(caseName);
expect(data.path).toContain(caseName); expect(data.data.path).toContain(caseName);
}); });
it('should return error for non-existent case', async () => { it('should return error for non-existent case', async () => {
const res = await fetch(`${baseUrl}/api/cases/non-existent-case-12345`); const res = await fetch(`${baseUrl}/api/cases/non-existent-case-12345`);
const data = await res.json(); const data = await res.json();
expect(res.status).toBe(200); expect(res.status).toBe(404);
expect(data.error).toBe('Case not found'); expect(data.error).toBe('Case not found');
}); });
}); });
@@ -286,18 +286,18 @@ describe('Ralph Integration Tests', () => {
const data = await res.json(); const data = await res.json();
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(data.sessions).toBeDefined(); expect(data.data.sessions).toBeDefined();
expect(data.scheduledRuns).toBeDefined(); expect(data.data.scheduledRuns).toBeDefined();
expect(data.respawnStatus).toBeDefined(); expect(data.data.respawnStatus).toBeDefined();
expect(data.timestamp).toBeDefined(); expect(data.data.timestamp).toBeDefined();
}); });
it('should include sessions array in status', async () => { it('should include sessions array in status', async () => {
const res = await fetch(`${baseUrl}/api/status`); const res = await fetch(`${baseUrl}/api/status`);
const data = await res.json(); const data = await res.json();
expect(Array.isArray(data.sessions)).toBe(true); expect(Array.isArray(data.data.sessions)).toBe(true);
expect(typeof data.timestamp).toBe('number'); expect(typeof data.data.timestamp).toBe('number');
}); });
}); });
@@ -309,9 +309,9 @@ describe('Ralph Integration Tests', () => {
const data = await res.json(); const data = await res.json();
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(data.sessions).toBeDefined(); expect(data.data.sessions).toBeDefined();
expect(Array.isArray(data.sessions)).toBe(true); expect(Array.isArray(data.data.sessions)).toBe(true);
expect(typeof data.muxAvailable).toBe('boolean'); expect(typeof data.data.muxAvailable).toBe('boolean');
}); });
}); });
@@ -328,9 +328,9 @@ describe('Ralph Integration Tests', () => {
body: JSON.stringify({ caseName }), body: JSON.stringify({ caseName }),
}); });
const createData = await createRes.json(); 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: 'test input' }), body: JSON.stringify({ input: 'test input' }),
@@ -351,9 +351,9 @@ describe('Ralph Integration Tests', () => {
body: JSON.stringify({ caseName }), body: JSON.stringify({ caseName }),
}); });
const createData = await createRes.json(); 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: null }), body: JSON.stringify({ input: null }),
@@ -393,12 +393,12 @@ describe('Ralph Integration Tests', () => {
}); });
const createData = await createRes.json(); const createData = await createRes.json();
expect(createData.success).toBe(true); expect(createData.success).toBe(true);
createdSessions.push(createData.sessionId); createdSessions.push(createData.data.sessionId);
// Wait for session to be ready // Wait for session to be ready
await new Promise((r) => setTimeout(r, 200)); 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cols: 120, rows: 40 }), body: JSON.stringify({ cols: 120, rows: 40 }),
@@ -420,12 +420,12 @@ describe('Ralph Integration Tests', () => {
}); });
const createData = await createRes.json(); const createData = await createRes.json();
expect(createData.success).toBe(true); expect(createData.success).toBe(true);
createdSessions.push(createData.sessionId); createdSessions.push(createData.data.sessionId);
// Wait for session to be ready // Wait for session to be ready
await new Promise((r) => setTimeout(r, 200)); 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cols: -1, rows: 40 }), body: JSON.stringify({ cols: -1, rows: 40 }),
@@ -452,9 +452,9 @@ describe('Ralph Integration Tests', () => {
}); });
const createData = await createRes.json(); const createData = await createRes.json();
expect(createData.success).toBe(true); 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true, threshold: 100000 }), body: JSON.stringify({ enabled: true, threshold: 100000 }),
@@ -489,9 +489,9 @@ describe('Ralph Integration Tests', () => {
}); });
const createData = await createRes.json(); const createData = await createRes.json();
expect(createData.success).toBe(true); 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true, threshold: -100 }), body: JSON.stringify({ enabled: true, threshold: -100 }),
@@ -516,9 +516,9 @@ describe('Ralph Integration Tests', () => {
}); });
const createData = await createRes.json(); const createData = await createRes.json();
expect(createData.success).toBe(true); 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true, threshold: 150000 }), body: JSON.stringify({ enabled: true, threshold: 150000 }),
@@ -553,9 +553,9 @@ describe('Ralph Integration Tests', () => {
}); });
const createData = await createRes.json(); const createData = await createRes.json();
expect(createData.success).toBe(true); 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true, threshold: -50 }), body: JSON.stringify({ enabled: true, threshold: -50 }),
@@ -582,9 +582,9 @@ describe('Ralph Integration Tests', () => {
}); });
const createData = await createRes.json(); const createData = await createRes.json();
expect(createData.success).toBe(true); 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true }), body: JSON.stringify({ enabled: true }),
@@ -606,9 +606,9 @@ describe('Ralph Integration Tests', () => {
}); });
const createData = await createRes.json(); const createData = await createRes.json();
expect(createData.success).toBe(true); 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reset: true }), body: JSON.stringify({ reset: true }),
@@ -643,9 +643,9 @@ describe('Ralph Integration Tests', () => {
}); });
const createData = await createRes.json(); const createData = await createRes.json();
expect(createData.success).toBe(true); 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reset: 'full' }), body: JSON.stringify({ reset: 'full' }),
@@ -667,9 +667,9 @@ describe('Ralph Integration Tests', () => {
}); });
const createData = await createRes.json(); const createData = await createRes.json();
expect(createData.success).toBe(true); 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true, completionPhrase: 'DONE' }), body: JSON.stringify({ enabled: true, completionPhrase: 'DONE' }),
@@ -680,7 +680,7 @@ describe('Ralph Integration Tests', () => {
expect(data.success).toBe(true); expect(data.success).toBe(true);
// Verify the state was updated // 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(); const stateData = await stateRes.json();
expect(stateData.success).toBe(true); expect(stateData.success).toBe(true);
@@ -698,9 +698,9 @@ describe('Ralph Integration Tests', () => {
}); });
const createData = await createRes.json(); const createData = await createRes.json();
expect(createData.success).toBe(true); 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ disableAutoEnable: true }), body: JSON.stringify({ disableAutoEnable: true }),
@@ -726,9 +726,9 @@ describe('Ralph Integration Tests', () => {
}); });
const createData = await createRes.json(); const createData = await createRes.json();
expect(createData.success).toBe(true); 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(); const data = await res.json();
expect(res.status).toBe(200); expect(res.status).toBe(200);
@@ -750,17 +750,17 @@ describe('Ralph Integration Tests', () => {
}); });
const createData = await createRes.json(); const createData = await createRes.json();
expect(createData.success).toBe(true); expect(createData.success).toBe(true);
createdSessions.push(createData.sessionId); createdSessions.push(createData.data.sessionId);
// Enable ralph tracking // Enable ralph tracking
await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-config`, { await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-config`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true, completionPhrase: 'TASK_DONE' }), body: JSON.stringify({ enabled: true, completionPhrase: 'TASK_DONE' }),
}); });
// Check state // 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(); const data = await res.json();
expect(data.success).toBe(true); expect(data.success).toBe(true);
@@ -779,17 +779,17 @@ describe('Ralph Integration Tests', () => {
}); });
const createData = await createRes.json(); const createData = await createRes.json();
expect(createData.success).toBe(true); expect(createData.success).toBe(true);
createdSessions.push(createData.sessionId); createdSessions.push(createData.data.sessionId);
// Enable first // Enable first
await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-config`, { await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-config`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true }), body: JSON.stringify({ enabled: true }),
}); });
// Then reset // 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reset: true }), body: JSON.stringify({ reset: true }),
@@ -799,7 +799,7 @@ describe('Ralph Integration Tests', () => {
expect(resetData.success).toBe(true); expect(resetData.success).toBe(true);
// Check that todos are cleared // 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(); const stateData = await stateRes.json();
expect(stateData.success).toBe(true); expect(stateData.success).toBe(true);
@@ -817,24 +817,24 @@ describe('Ralph Integration Tests', () => {
}); });
const createData = await createRes.json(); const createData = await createRes.json();
expect(createData.success).toBe(true); expect(createData.success).toBe(true);
createdSessions.push(createData.sessionId); createdSessions.push(createData.data.sessionId);
// Enable tracking // Enable tracking
await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-config`, { await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-config`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true }), body: JSON.stringify({ enabled: true }),
}); });
// Soft reset (keep enabled) // 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reset: true }), body: JSON.stringify({ reset: true }),
}); });
// Check state // 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(); const stateData = await stateRes.json();
expect(stateData.success).toBe(true); expect(stateData.success).toBe(true);
@@ -852,24 +852,24 @@ describe('Ralph Integration Tests', () => {
}); });
const createData = await createRes.json(); const createData = await createRes.json();
expect(createData.success).toBe(true); expect(createData.success).toBe(true);
createdSessions.push(createData.sessionId); createdSessions.push(createData.data.sessionId);
// Enable tracking // Enable tracking
await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-config`, { await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-config`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true }), body: JSON.stringify({ enabled: true }),
}); });
// Full reset // Full reset
await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/ralph-config`, { await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}/ralph-config`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reset: 'full' }), body: JSON.stringify({ reset: 'full' }),
}); });
// Check state // 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(); const stateData = await stateRes.json();
expect(stateData.success).toBe(true); expect(stateData.success).toBe(true);
@@ -891,13 +891,13 @@ describe('Ralph Integration Tests', () => {
}); });
const createData = await createRes.json(); const createData = await createRes.json();
expect(createData.success).toBe(true); 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(); const data = await res.json();
expect(res.status).toBe(200); 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 () => { 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(); const createData = await createRes.json();
expect(createData.success).toBe(true); 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', method: 'POST',
}); });
const data = await res.json(); const data = await res.json();
expect(res.status).toBe(200); expect(res.status).toBe(404);
expect(data.success).toBe(false); expect(data.success).toBe(false);
expect(data.errorCode).toBe('NOT_FOUND'); expect(data.errorCode).toBe('NOT_FOUND');
}); });
@@ -947,9 +947,9 @@ describe('Ralph Integration Tests', () => {
}); });
const createData = await createRes.json(); const createData = await createRes.json();
expect(createData.success).toBe(true); 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', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ idleTimeoutMs: 10000 }), body: JSON.stringify({ idleTimeoutMs: 10000 }),
@@ -958,7 +958,7 @@ describe('Ralph Integration Tests', () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(data.success).toBe(true); 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(); const createData = await createRes.json();
expect(createData.success).toBe(true); 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(); const data = await res.json();
expect(res.status).toBe(200); expect(res.status).toBe(200);
@@ -1008,14 +1008,14 @@ describe('Ralph Integration Tests', () => {
}); });
const createData = await createRes.json(); const createData = await createRes.json();
expect(createData.success).toBe(true); 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(); const data = await res.json();
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(data.terminalBuffer).toBeDefined(); expect(data.data.terminalBuffer).toBeDefined();
expect(data.status).toBeDefined(); expect(data.data.status).toBeDefined();
}); });
it('should return error for terminal of non-existent session', async () => { it('should return error for terminal of non-existent session', async () => {
+82 -26
View File
@@ -3,10 +3,22 @@
* *
* Uses app.inject() — no real HTTP ports needed. * Uses app.inject() — no real HTTP ports needed.
* Port: N/A (app.inject doesn't open ports) * Port: N/A (app.inject doesn't open ports)
*
* Responses follow the uniform envelope contract:
* SUCCESS -> HTTP 2xx, body = { success: true, data: <payload> }
* 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 { 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'; import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
// Mock filesystem modules // Mock filesystem modules
@@ -51,11 +63,52 @@ const mockedReaddirSync = vi.mocked(readdirSync);
const mockedReaddir = vi.mocked(fs.readdir); const mockedReaddir = vi.mocked(fs.readdir);
const mockedReadFile = vi.mocked(fs.readFile); 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<CaseRouteHarness> {
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', () => { describe('case-routes', () => {
let harness: RouteTestHarness; let harness: CaseRouteHarness;
beforeEach(async () => { beforeEach(async () => {
harness = await createRouteTestHarness(registerCaseRoutes); harness = await createEnvelopeHarness();
vi.clearAllMocks(); vi.clearAllMocks();
// Default: existsSync returns false, readFile throws ENOENT // Default: existsSync returns false, readFile throws ENOENT
@@ -79,7 +132,8 @@ describe('case-routes', () => {
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); 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 () => { it('returns cases from CASES_DIR', async () => {
@@ -97,10 +151,10 @@ describe('case-routes', () => {
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body).toHaveLength(2); expect(body.data).toHaveLength(2);
expect(body[0].name).toBe('my-case'); expect(body.data[0].name).toBe('my-case');
expect(body[1].name).toBe('other-case'); expect(body.data[1].name).toBe('other-case');
expect(body[0].hasClaudeMd).toBe(false); expect(body.data[0].hasClaudeMd).toBe(false);
}); });
it('includes hasClaudeMd flag', async () => { it('includes hasClaudeMd flag', async () => {
@@ -113,7 +167,7 @@ describe('case-routes', () => {
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); 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 () => { it('includes linked cases from linked-cases.json', async () => {
@@ -141,7 +195,7 @@ describe('case-routes', () => {
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
// Should have both regular and linked cases // 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', url: '/api/cases',
payload: { name: 'existing-case' }, payload: { name: 'existing-case' },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(409);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
expect(body.error).toContain('already exists'); expect(body.error).toContain('already exists');
@@ -250,7 +304,7 @@ describe('case-routes', () => {
url: '/api/cases/link', url: '/api/cases/link',
payload: { name: 'my-project', path: '/nonexistent/path' }, payload: { name: 'my-project', path: '/nonexistent/path' },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
expect(body.error).toContain('not found'); expect(body.error).toContain('not found');
@@ -265,7 +319,7 @@ describe('case-routes', () => {
url: '/api/cases/link', url: '/api/cases/link',
payload: { name: 'existing-case', path: '/home/user/project' }, payload: { name: 'existing-case', path: '/home/user/project' },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(409);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
expect(body.error).toContain('already exists'); expect(body.error).toContain('already exists');
@@ -310,8 +364,9 @@ describe('case-routes', () => {
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.name).toBe('my-case'); expect(body.success).toBe(true);
expect(body.linked).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 () => { it('returns CASES_DIR case when no linked case found', async () => {
@@ -326,7 +381,8 @@ describe('case-routes', () => {
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); 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 () => { it('returns error when case not found anywhere', async () => {
@@ -337,7 +393,7 @@ describe('case-routes', () => {
method: 'GET', method: 'GET',
url: '/api/cases/nonexistent', url: '/api/cases/nonexistent',
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
expect(body.error).toContain('not found'); expect(body.error).toContain('not found');
@@ -358,9 +414,9 @@ describe('case-routes', () => {
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(true); expect(body.success).toBe(true);
expect(body.exists).toBe(false); expect(body.data.exists).toBe(false);
expect(body.content).toBeNull(); expect(body.data.content).toBeNull();
expect(body.todos).toEqual([]); expect(body.data.todos).toEqual([]);
}); });
it('parses fix plan with todos and stats', async () => { it('parses fix plan with todos and stats', async () => {
@@ -397,9 +453,9 @@ describe('case-routes', () => {
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(true); expect(body.success).toBe(true);
expect(body.exists).toBe(true); expect(body.data.exists).toBe(true);
expect(body.todos.length).toBeGreaterThan(0); expect(body.data.todos.length).toBeGreaterThan(0);
expect(body.stats.total).toBeGreaterThan(0); expect(body.data.stats.total).toBeGreaterThan(0);
}); });
}); });
@@ -413,7 +469,7 @@ describe('case-routes', () => {
method: 'GET', method: 'GET',
url: '/api/cases/my-case/ralph-wizard/files', url: '/api/cases/my-case/ralph-wizard/files',
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
expect(body.error).toContain('not found'); expect(body.error).toContain('not found');
@@ -424,7 +480,7 @@ describe('case-routes', () => {
method: 'GET', method: 'GET',
url: '/api/cases/..%2F..%2Fetc/ralph-wizard/files', url: '/api/cases/..%2F..%2Fetc/ralph-wizard/files',
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -464,7 +520,7 @@ describe('case-routes', () => {
method: 'GET', method: 'GET',
url: '/api/cases/my-case/ralph-wizard/file/research%2Fprompt.md', 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); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
+60 -4
View File
@@ -3,17 +3,73 @@
* *
* Uses app.inject() — no real HTTP ports needed. * Uses app.inject() — no real HTTP ports needed.
* Port: N/A (app.inject doesn't open ports) * Port: N/A (app.inject doesn't open ports)
*
* These tests assert the UNIFORM response envelope (stable HTTP contract):
* success -> 2xx, { success: true, data: <payload> }
* 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 { 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'; 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<LocalHarness> {
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', () => { describe('hook-event-routes', () => {
let harness: RouteTestHarness; let harness: LocalHarness;
beforeEach(async () => { beforeEach(async () => {
harness = await createRouteTestHarness(registerHookEventRoutes); harness = await createEnvelopeHarness(registerHookEventRoutes);
}); });
afterEach(async () => { afterEach(async () => {
@@ -69,7 +125,7 @@ describe('hook-event-routes', () => {
data: null, data: null,
}, },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
expect(body.error).toBeDefined(); expect(body.error).toBeDefined();
+5 -7
View File
@@ -15,9 +15,7 @@ describe('mux-routes', () => {
beforeEach(async () => { beforeEach(async () => {
// Add mux methods that mux-routes needs but mock-route-context doesn't provide // Add mux methods that mux-routes needs but mock-route-context doesn't provide
harness = await createRouteTestHarness(registerMuxRoutes); harness = await createRouteTestHarness(registerMuxRoutes);
harness.ctx.mux.getSessionsWithStats = vi.fn(async () => [ harness.ctx.mux.getSessionsWithStats = vi.fn(async () => [{ name: 'codeman-abc', pid: 1234, created: Date.now() }]);
{ name: 'codeman-abc', pid: 1234, created: Date.now() },
]);
harness.ctx.mux.isAvailable = vi.fn(() => true); harness.ctx.mux.isAvailable = vi.fn(() => true);
harness.ctx.mux.reconcileSessions = vi.fn(async () => ({ harness.ctx.mux.reconcileSessions = vi.fn(async () => ({
orphaned: [], orphaned: [],
@@ -74,7 +72,7 @@ describe('mux-routes', () => {
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(true); expect(body.killed).toBe(true);
expect(harness.ctx.mux.killSession).toHaveBeenCalledWith('codeman-abc'); expect(harness.ctx.mux.killSession).toHaveBeenCalledWith('codeman-abc');
}); });
@@ -87,7 +85,7 @@ describe('mux-routes', () => {
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); 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); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(true); expect(body).toEqual({});
expect(harness.ctx.mux.startStatsCollection).toHaveBeenCalled(); expect(harness.ctx.mux.startStatsCollection).toHaveBeenCalled();
}); });
}); });
@@ -134,7 +132,7 @@ describe('mux-routes', () => {
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(true); expect(body).toEqual({});
expect(harness.ctx.mux.stopStatsCollection).toHaveBeenCalled(); expect(harness.ctx.mux.stopStatsCollection).toHaveBeenCalled();
}); });
}); });
+10 -2
View File
@@ -131,7 +131,11 @@ describe('push-routes', () => {
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); 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', { expect(harness.ctx.pushStore.updatePreferences).toHaveBeenCalledWith('sub-123', {
'session:idle': true, 'session:idle': true,
'session:error': true, 'session:error': true,
@@ -176,7 +180,11 @@ describe('push-routes', () => {
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); 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'); expect(harness.ctx.pushStore.removeSubscription).toHaveBeenCalledWith('sub-123');
}); });
+2 -2
View File
@@ -62,7 +62,7 @@ describe('ralph-routes', () => {
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(true); expect(body).toEqual({});
}); });
it('enables ralph tracker', async () => { it('enables ralph tracker', async () => {
@@ -230,7 +230,7 @@ describe('ralph-routes', () => {
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(true); expect(body).toEqual({});
expect(tracker.resetCircuitBreaker).toHaveBeenCalled(); expect(tracker.resetCircuitBreaker).toHaveBeenCalled();
}); });
+16 -5
View File
@@ -69,7 +69,9 @@ describe('respawn-routes', () => {
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); 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.config).toBeNull();
expect(body.active).toBe(false); expect(body.active).toBe(false);
}); });
@@ -88,7 +90,8 @@ describe('respawn-routes', () => {
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); 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.active).toBe(true);
expect(body.config.idleTimeoutMs).toBe(5000); expect(body.config.idleTimeoutMs).toBe(5000);
}); });
@@ -121,7 +124,11 @@ describe('respawn-routes', () => {
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); 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(mockController.stop).toHaveBeenCalled();
expect(harness.ctx.respawnControllers.has(harness.ctx._sessionId)).toBe(false); expect(harness.ctx.respawnControllers.has(harness.ctx._sessionId)).toBe(false);
expect(harness.ctx.broadcast).toHaveBeenCalledWith('respawn:stopped', { expect(harness.ctx.broadcast).toHaveBeenCalledWith('respawn:stopped', {
@@ -192,7 +199,9 @@ describe('respawn-routes', () => {
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); 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(mockController.updateConfig).toHaveBeenCalled();
expect(harness.ctx.broadcast).toHaveBeenCalledWith( expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'respawn:configUpdated', 'respawn:configUpdated',
@@ -208,7 +217,9 @@ describe('respawn-routes', () => {
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); 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(); expect(harness.ctx.mux.updateRespawnConfig).toHaveBeenCalled();
}); });
}); });
+9 -3
View File
@@ -102,7 +102,9 @@ describe('scheduled-routes', () => {
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); 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'); expect(body.run.id).toBe('new-run');
}); });
@@ -169,7 +171,9 @@ describe('scheduled-routes', () => {
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); 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 // Should default to 60 minutes
expect(harness.ctx.startScheduledRun).toHaveBeenCalledWith('test', expect.any(String), 60); expect(harness.ctx.startScheduledRun).toHaveBeenCalledWith('test', expect.any(String), 60);
}); });
@@ -200,7 +204,9 @@ describe('scheduled-routes', () => {
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); 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'); expect(harness.ctx.stopScheduledRun).toHaveBeenCalledWith('run-to-delete');
}); });
+77 -21
View File
@@ -3,10 +3,22 @@
* *
* Uses app.inject() (Fastify's built-in test helper) — no real HTTP ports needed. * Uses app.inject() (Fastify's built-in test helper) — no real HTTP ports needed.
* Port: N/A (app.inject doesn't open ports) * Port: N/A (app.inject doesn't open ports)
*
* These tests assert the UNIFORM response envelope (stable HTTP contract):
* success -> 2xx, { success: true, data: <payload> }
* 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 { 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). // Mock execFile so the send-key route's `tmux` invocation is observable (not run for real).
const { execFile } = vi.hoisted(() => ({ execFile: vi.fn() })); 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'; 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<LocalHarness> {
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', () => { describe('session-routes', () => {
let harness: RouteTestHarness; let harness: LocalHarness;
beforeEach(async () => { beforeEach(async () => {
harness = await createRouteTestHarness(registerSessionRoutes); harness = await createEnvelopeHarness(registerSessionRoutes);
}); });
afterEach(async () => { afterEach(async () => {
@@ -73,15 +129,15 @@ describe('session-routes', () => {
const res = await harness.app.inject({ method: 'GET', url: '/api/sessions' }); const res = await harness.app.inject({ method: 'GET', url: '/api/sessions' });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(Array.isArray(body)).toBe(true); expect(Array.isArray(body.data)).toBe(true);
expect(body).toHaveLength(1); expect(body.data).toHaveLength(1);
}); });
it('returns empty array when no sessions', async () => { it('returns empty array when no sessions', async () => {
harness.ctx.sessions.clear(); harness.ctx.sessions.clear();
const res = await harness.app.inject({ method: 'GET', url: '/api/sessions' }); const res = await harness.app.inject({ method: 'GET', url: '/api/sessions' });
expect(res.statusCode).toBe(200); 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); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); 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 () => { it('returns error for unknown session', async () => {
@@ -129,7 +185,7 @@ describe('session-routes', () => {
method: 'DELETE', method: 'DELETE',
url: '/api/sessions/nonexistent', url: '/api/sessions/nonexistent',
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -163,7 +219,7 @@ describe('session-routes', () => {
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(true); 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.persistSessionState).toHaveBeenCalled();
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:updated', expect.anything()); expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:updated', expect.anything());
}); });
@@ -192,7 +248,7 @@ describe('session-routes', () => {
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(true); expect(body.success).toBe(true);
expect(body.color).toBe('blue'); expect(body.data.color).toBe('blue');
}); });
it('rejects invalid color', async () => { it('rejects invalid color', async () => {
@@ -201,7 +257,7 @@ describe('session-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/color`, url: `/api/sessions/${harness.ctx._sessionId}/color`,
payload: { color: 'neon-rainbow' }, payload: { color: 'neon-rainbow' },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -304,7 +360,7 @@ describe('session-routes', () => {
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.terminalBuffer).toBeDefined(); expect(body.data.terminalBuffer).toBeDefined();
}); });
it('returns error for unknown session', async () => { it('returns error for unknown session', async () => {
@@ -361,7 +417,7 @@ describe('session-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/run`, url: `/api/sessions/${harness.ctx._sessionId}/run`,
payload: { prompt: 'test' }, payload: { prompt: 'test' },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(409);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -397,7 +453,7 @@ describe('session-routes', () => {
method: 'POST', method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/interactive`, url: `/api/sessions/${harness.ctx._sessionId}/interactive`,
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(409);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -423,7 +479,7 @@ describe('session-routes', () => {
method: 'POST', method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/shell`, url: `/api/sessions/${harness.ctx._sessionId}/shell`,
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(409);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@@ -512,8 +568,8 @@ describe('session-routes', () => {
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body).toHaveProperty('sessions'); expect(body.data).toHaveProperty('sessions');
expect(Array.isArray(body.sessions)).toBe(true); expect(Array.isArray(body.data.sessions)).toBe(true);
}); });
it('sessions have required fields', async () => { it('sessions have required fields', async () => {
@@ -522,7 +578,7 @@ describe('session-routes', () => {
url: '/api/history/sessions', url: '/api/history/sessions',
}); });
const body = JSON.parse(res.body); 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('sessionId');
expect(session).toHaveProperty('workingDir'); expect(session).toHaveProperty('workingDir');
expect(session).toHaveProperty('projectKey'); expect(session).toHaveProperty('projectKey');
@@ -539,7 +595,7 @@ describe('session-routes', () => {
url: '/api/history/sessions', url: '/api/history/sessions',
}); });
const body = JSON.parse(res.body); 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++) { for (let i = 1; i < dates.length; i++) {
expect(dates[i - 1]).toBeGreaterThanOrEqual(dates[i]); expect(dates[i - 1]).toBeGreaterThanOrEqual(dates[i]);
} }
@@ -551,7 +607,7 @@ describe('session-routes', () => {
url: '/api/history/sessions', url: '/api/history/sessions',
}); });
const body = JSON.parse(res.body); 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); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(true); expect(body.success).toBe(true);
expect(body.session).toBeDefined(); expect(body.data.session).toBeDefined();
}); });
it('rejects invalid resumeSessionId format', async () => { it('rejects invalid resumeSessionId format', async () => {
+11 -19
View File
@@ -140,7 +140,6 @@ describe('system-routes', () => {
const res = await harness.app.inject({ method: 'GET', url: '/api/config' }); const res = await harness.app.inject({ method: 'GET', url: '/api/config' });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.config).toBeDefined(); expect(body.config).toBeDefined();
}); });
}); });
@@ -156,7 +155,7 @@ describe('system-routes', () => {
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(true); expect(body.config).toBeDefined();
expect(harness.ctx.store.setConfig).toHaveBeenCalled(); expect(harness.ctx.store.setConfig).toHaveBeenCalled();
}); });
@@ -179,7 +178,6 @@ describe('system-routes', () => {
const res = await harness.app.inject({ method: 'GET', url: '/api/stats' }); const res = await harness.app.inject({ method: 'GET', url: '/api/stats' });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.stats).toBeDefined(); expect(body.stats).toBeDefined();
}); });
}); });
@@ -191,7 +189,6 @@ describe('system-routes', () => {
const res = await harness.app.inject({ method: 'GET', url: '/api/token-stats' }); const res = await harness.app.inject({ method: 'GET', url: '/api/token-stats' });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.daily).toBeDefined(); expect(body.daily).toBeDefined();
expect(body.totals).toBeDefined(); expect(body.totals).toBeDefined();
}); });
@@ -230,7 +227,6 @@ describe('system-routes', () => {
const res = await harness.app.inject({ method: 'POST', url: '/api/cleanup-state' }); const res = await harness.app.inject({ method: 'POST', url: '/api/cleanup-state' });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.cleanedSessions).toBe(0); expect(body.cleanedSessions).toBe(0);
expect(harness.ctx.store.cleanupStaleSessions).toHaveBeenCalled(); expect(harness.ctx.store.cleanupStaleSessions).toHaveBeenCalled();
}); });
@@ -266,7 +262,7 @@ describe('system-routes', () => {
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(true); expect(body).toEqual({});
}); });
it('revokes a specific session token', async () => { it('revokes a specific session token', async () => {
@@ -280,7 +276,6 @@ describe('system-routes', () => {
payload: { sessionToken: 'tok-123' }, payload: { sessionToken: 'tok-123' },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).success).toBe(true);
expect(authSessions.has('tok-123')).toBe(false); expect(authSessions.has('tok-123')).toBe(false);
}); });
@@ -333,8 +328,6 @@ describe('system-routes', () => {
payload: { showSystemStats: true, subagentTrackingEnabled: false }, payload: { showSystemStats: true, subagentTrackingEnabled: false },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(mockedWriteFile).toHaveBeenCalled(); expect(mockedWriteFile).toHaveBeenCalled();
}); });
@@ -347,7 +340,7 @@ describe('system-routes', () => {
payload: { showTokenCount: false }, payload: { showTokenCount: false },
}); });
expect(res.statusCode).toBe(200); 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 // Verify writeFile was called with merged content
const writtenContent = JSON.parse(mockedWriteFile.mock.calls[0][1] as string); const writtenContent = JSON.parse(mockedWriteFile.mock.calls[0][1] as string);
@@ -377,7 +370,7 @@ describe('system-routes', () => {
payload: { lastUsedCase: 'my-test-case' }, payload: { lastUsedCase: 'my-test-case' },
}); });
expect(res.statusCode).toBe(200); 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); const writtenContent = JSON.parse(mockedWriteFile.mock.calls[0][1] as string);
expect(writtenContent.lastUsedCase).toBe('my-test-case'); expect(writtenContent.lastUsedCase).toBe('my-test-case');
@@ -439,7 +432,7 @@ describe('system-routes', () => {
payload: { minimized: { 'agent-1': true }, open: ['agent-2'] }, payload: { minimized: { 'agent-1': true }, open: ['agent-2'] },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).success).toBe(true); expect(JSON.parse(res.body)).toEqual({});
expect(mockedWriteFile).toHaveBeenCalled(); expect(mockedWriteFile).toHaveBeenCalled();
}); });
@@ -450,7 +443,7 @@ describe('system-routes', () => {
payload: { minimized: {}, open: [] }, payload: { minimized: {}, open: [] },
}); });
expect(res.statusCode).toBe(200); 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 () => { it('rejects invalid minimized values', async () => {
@@ -496,7 +489,7 @@ describe('system-routes', () => {
payload: { 'agent-1': 'session-abc' }, payload: { 'agent-1': 'session-abc' },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).success).toBe(true); expect(JSON.parse(res.body)).toEqual({});
expect(mockedWriteFile).toHaveBeenCalled(); expect(mockedWriteFile).toHaveBeenCalled();
}); });
@@ -507,7 +500,7 @@ describe('system-routes', () => {
payload: {}, payload: {},
}); });
expect(res.statusCode).toBe(200); 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 () => { 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' }); const res = await harness.app.inject({ method: 'GET', url: '/api/session-lifecycle' });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.entries).toEqual(mockEntries); expect(body.entries).toEqual(mockEntries);
}); });
@@ -754,7 +746,7 @@ describe('system-routes', () => {
payload: { model: 'claude-3', temperature: 0.5 }, payload: { model: 'claude-3', temperature: 0.5 },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).success).toBe(true); expect(JSON.parse(res.body)).toEqual({});
expect(mockedWriteFile).toHaveBeenCalled(); expect(mockedWriteFile).toHaveBeenCalled();
// Verify the written content contains modelConfig // Verify the written content contains modelConfig
@@ -771,7 +763,7 @@ describe('system-routes', () => {
payload: { model: 'new-model' }, payload: { model: 'new-model' },
}); });
expect(res.statusCode).toBe(200); 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); const writtenContent = JSON.parse(mockedWriteFile.mock.calls[0][1] as string);
expect(writtenContent.showCost).toBe(true); 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' }); const res = await harness.app.inject({ method: 'POST', url: '/api/tunnel/qr/regenerate' });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).success).toBe(true); expect(JSON.parse(res.body)).toEqual({});
}); });
}); });
}); });
+4 -1
View File
@@ -65,7 +65,10 @@ describe('POST /api/system/span-displays', () => {
headers: { host: 'localhost:5000' }, headers: { host: 'localhost:5000' },
}); });
expect(res.statusCode).toBe(200); 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); expect(spawnMock).toHaveBeenCalledTimes(1);
const [cmd, args] = spawnMock.mock.calls[0] as [string, string[]]; const [cmd, args] = spawnMock.mock.calls[0] as [string, string[]];
expect(cmd).toBe('bash'); expect(cmd).toBe('bash');
+26 -24
View File
@@ -29,7 +29,7 @@ describe('Scheduled Runs API', () => {
const response = await fetch(`${baseUrl}/api/scheduled`); const response = await fetch(`${baseUrl}/api/scheduled`);
const data = await response.json(); 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(); const data = await response.json();
expect(data.success).toBe(true); expect(data.success).toBe(true);
expect(data.run).toBeDefined(); expect(data.data.run).toBeDefined();
expect(data.run.id).toBeDefined(); expect(data.data.run.id).toBeDefined();
expect(data.run.prompt).toBe('echo test'); expect(data.data.run.prompt).toBe('echo test');
expect(data.run.durationMinutes).toBe(1); expect(data.data.run.durationMinutes).toBe(1);
expect(data.run.status).toBe('running'); 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 // 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 () => { it('should set correct timestamps', async () => {
@@ -74,12 +74,12 @@ describe('Scheduled Runs API', () => {
const afterCreate = Date.now(); const afterCreate = Date.now();
expect(data.run.startedAt).toBeGreaterThanOrEqual(beforeCreate); expect(data.data.run.startedAt).toBeGreaterThanOrEqual(beforeCreate);
expect(data.run.startedAt).toBeLessThanOrEqual(afterCreate); expect(data.data.run.startedAt).toBeLessThanOrEqual(afterCreate);
expect(data.run.endAt).toBe(data.run.startedAt + 5 * 60 * 1000); expect(data.data.run.endAt).toBe(data.data.run.startedAt + 5 * 60 * 1000);
createdRuns.push(data.run.id); createdRuns.push(data.data.run.id);
await fetch(`${baseUrl}/api/scheduled/${data.run.id}`, { method: 'DELETE' }); await fetch(`${baseUrl}/api/scheduled/${data.data.run.id}`, { method: 'DELETE' });
}); });
it('should initialize with zero completed tasks and cost', async () => { it('should initialize with zero completed tasks and cost', async () => {
@@ -93,13 +93,13 @@ describe('Scheduled Runs API', () => {
}); });
const data = await response.json(); const data = await response.json();
expect(data.run.completedTasks).toBe(0); expect(data.data.run.completedTasks).toBe(0);
expect(data.run.totalCost).toBe(0); expect(data.data.run.totalCost).toBe(0);
// Logs may have 1 or more entries depending on timing // 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); createdRuns.push(data.data.run.id);
await fetch(`${baseUrl}/api/scheduled/${data.run.id}`, { method: 'DELETE' }); 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 createData = await createRes.json();
const runId = createData.run.id; const runId = createData.data.run.id;
createdRuns.push(runId); createdRuns.push(runId);
// Get the run // Get the run
const response = await fetch(`${baseUrl}/api/scheduled/${runId}`); const response = await fetch(`${baseUrl}/api/scheduled/${runId}`);
const data = await response.json(); const data = await response.json();
expect(data.id).toBe(runId); expect(data.data.id).toBe(runId);
expect(data.prompt).toBe('test get'); expect(data.data.prompt).toBe('test get');
await fetch(`${baseUrl}/api/scheduled/${runId}`, { method: 'DELETE' }); await fetch(`${baseUrl}/api/scheduled/${runId}`, { method: 'DELETE' });
}); });
@@ -148,7 +148,7 @@ describe('Scheduled Runs API', () => {
}), }),
}); });
const createData = await createRes.json(); const createData = await createRes.json();
const runId = createData.run.id; const runId = createData.data.run.id;
// Delete/stop the run // Delete/stop the run
const response = await fetch(`${baseUrl}/api/scheduled/${runId}`, { const response = await fetch(`${baseUrl}/api/scheduled/${runId}`, {
@@ -161,7 +161,7 @@ describe('Scheduled Runs API', () => {
// Verify it's stopped // Verify it's stopped
const getRes = await fetch(`${baseUrl}/api/scheduled/${runId}`); const getRes = await fetch(`${baseUrl}/api/scheduled/${runId}`);
const runData = await getRes.json(); 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 () => { it('should return error for non-existent run', async () => {
@@ -204,7 +204,9 @@ describe('Quick Run API', () => {
}); });
const data = await response.json(); 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 // Note: success/failure depends on Claude actually running
}); });
+22 -22
View File
@@ -60,14 +60,14 @@ describe('Session Cleanup', () => {
expect(quickStartData.success).toBe(true); expect(quickStartData.success).toBe(true);
// Delete the session // Delete the session
const deleteRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.sessionId}`, { const deleteRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.data.sessionId}`, {
method: 'DELETE', method: 'DELETE',
}); });
const deleteData = await deleteRes.json(); const deleteData = await deleteRes.json();
expect(deleteData.success).toBe(true); expect(deleteData.success).toBe(true);
// Verify session is gone // 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(); const getData = await getRes.json();
expect(getData.error).toContain('not found'); expect(getData.error).toContain('not found');
}); });
@@ -87,7 +87,7 @@ describe('Session Cleanup', () => {
}); });
const data = await res.json(); const data = await res.json();
expect(data.success).toBe(true); expect(data.success).toBe(true);
sessionIds.push(data.sessionId); sessionIds.push(data.data.sessionId);
} }
// Delete all sessions // Delete all sessions
@@ -103,7 +103,7 @@ describe('Session Cleanup', () => {
const listRes = await fetch(`${baseUrl}/api/sessions`); const listRes = await fetch(`${baseUrl}/api/sessions`);
const sessions = await listRes.json(); const sessions = await listRes.json();
for (const id of sessionIds) { 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 }, 60000); // Extended timeout for multi-session test
}); });
@@ -129,19 +129,19 @@ describe('Session Cleanup', () => {
expect(sessionData.success).toBe(true); expect(sessionData.success).toBe(true);
// Start interactive with respawn // 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', method: 'POST',
}); });
const interactiveData = await interactiveRes.json(); const interactiveData = await interactiveRes.json();
expect(interactiveData.success).toBe(true); expect(interactiveData.success).toBe(true);
// Verify respawn is running // 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(); const respawnData = await respawnRes.json();
expect(respawnData.enabled).toBe(true); expect(respawnData.data.enabled).toBe(true);
// Delete session // Delete session
await fetch(`${baseUrl}/api/sessions/${sessionData.session.id}`, { await fetch(`${baseUrl}/api/sessions/${sessionData.data.session.id}`, {
method: 'DELETE', method: 'DELETE',
}); });
@@ -149,10 +149,10 @@ describe('Session Cleanup', () => {
await new Promise((resolve) => setTimeout(resolve, 500)); await new Promise((resolve) => setTimeout(resolve, 500));
// Verify respawn controller is cleaned up (enabled: false when controller doesn't exist) // 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(); const respawnAfterData = await respawnAfterRes.json();
// The respawn endpoint returns enabled: false when there's no controller // 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); expect(createData.success).toBe(true);
// Stop the scheduled run // 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', method: 'DELETE',
}); });
const stopData = await stopRes.json(); const stopData = await stopRes.json();
expect(stopData.success).toBe(true); expect(stopData.success).toBe(true);
// Verify status is stopped // 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(); 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) // Get initial session count (may have restored sessions from other tests)
const initialRes = await fetch(`${baseUrl}/api/sessions`); const initialRes = await fetch(`${baseUrl}/api/sessions`);
const initialSessions = await initialRes.json(); const initialSessions = await initialRes.json();
const initialCount = initialSessions.length; const initialCount = initialSessions.data.length;
const iterations = 5; const iterations = 5;
const createdSessionIds: string[] = []; const createdSessionIds: string[] = [];
@@ -228,10 +228,10 @@ describe('Resource Management', () => {
}); });
const createData = await createRes.json(); const createData = await createRes.json();
expect(createData.success).toBe(true); expect(createData.success).toBe(true);
createdSessionIds.push(createData.sessionId); createdSessionIds.push(createData.data.sessionId);
// Delete immediately // Delete immediately
const deleteRes = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}`, { const deleteRes = await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}`, {
method: 'DELETE', method: 'DELETE',
}); });
const deleteData = await deleteRes.json(); const deleteData = await deleteRes.json();
@@ -250,11 +250,11 @@ describe('Resource Management', () => {
// None of the sessions we created should still exist // None of the sessions we created should still exist
for (const sessionId of createdSessionIds) { 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) // 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 () => { it('should clear terminal buffer after session stop', async () => {
@@ -274,18 +274,18 @@ describe('Resource Management', () => {
await new Promise((resolve) => setTimeout(resolve, 2000)); await new Promise((resolve) => setTimeout(resolve, 2000));
// Get terminal buffer before stop - may or may not have content depending on timing // 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(); const terminalData = await terminalRes.json();
// Just verify the property exists // Just verify the property exists
expect(terminalData.terminalBuffer).toBeDefined(); expect(terminalData.data.terminalBuffer).toBeDefined();
// Delete session // Delete session
await fetch(`${baseUrl}/api/sessions/${createData.sessionId}`, { await fetch(`${baseUrl}/api/sessions/${createData.data.sessionId}`, {
method: 'DELETE', method: 'DELETE',
}); });
// Session should be gone // 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(); const afterData = await afterRes.json();
expect(afterData.error).toContain('not found'); expect(afterData.error).toContain('not found');
}); });
+15 -15
View File
@@ -32,9 +32,9 @@ describe('Interactive Session Lifecycle', () => {
const data = await response.json(); const data = await response.json();
expect(data.success).toBe(true); expect(data.success).toBe(true);
expect(data.session.id).toBeDefined(); expect(data.data.session.id).toBeDefined();
expect(data.session.workingDir).toBeDefined(); expect(data.data.session.workingDir).toBeDefined();
expect(data.session.status).toBe('idle'); expect(data.data.session.status).toBe('idle');
}); });
it('should create session with custom working directory', async () => { it('should create session with custom working directory', async () => {
@@ -47,7 +47,7 @@ describe('Interactive Session Lifecycle', () => {
const data = await response.json(); const data = await response.json();
expect(data.success).toBe(true); 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({}), body: JSON.stringify({}),
}); });
const createData = await createRes.json(); const createData = await createRes.json();
const sessionId = createData.session.id; const sessionId = createData.data.session.id;
// Get the session // Get the session
const response = await fetch(`${baseUrl}/api/sessions/${sessionId}`); const response = await fetch(`${baseUrl}/api/sessions/${sessionId}`);
const data = await response.json(); const data = await response.json();
expect(data.id).toBe(sessionId); expect(data.data.id).toBe(sessionId);
expect(data.status).toBeDefined(); expect(data.data.status).toBeDefined();
}); });
it('should return error for non-existent session', async () => { it('should return error for non-existent session', async () => {
@@ -87,7 +87,7 @@ describe('Interactive Session Lifecycle', () => {
body: JSON.stringify({}), body: JSON.stringify({}),
}); });
const createData = await createRes.json(); const createData = await createRes.json();
const sessionId = createData.session.id; const sessionId = createData.data.session.id;
// Delete the session // Delete the session
const response = await fetch(`${baseUrl}/api/sessions/${sessionId}`, { const response = await fetch(`${baseUrl}/api/sessions/${sessionId}`, {
@@ -123,7 +123,7 @@ describe('Interactive Session Lifecycle', () => {
body: JSON.stringify({}), body: JSON.stringify({}),
}); });
const createData = await createRes.json(); const createData = await createRes.json();
const sessionId = createData.session.id; const sessionId = createData.data.session.id;
// Get output // Get output
const response = await fetch(`${baseUrl}/api/sessions/${sessionId}/output`); const response = await fetch(`${baseUrl}/api/sessions/${sessionId}/output`);
@@ -145,14 +145,14 @@ describe('Interactive Session Lifecycle', () => {
body: JSON.stringify({}), body: JSON.stringify({}),
}); });
const createData = await createRes.json(); const createData = await createRes.json();
const sessionId = createData.session.id; const sessionId = createData.data.session.id;
// Get terminal buffer // Get terminal buffer
const response = await fetch(`${baseUrl}/api/sessions/${sessionId}/terminal`); const response = await fetch(`${baseUrl}/api/sessions/${sessionId}/terminal`);
const data = await response.json(); const data = await response.json();
expect(data).toHaveProperty('terminalBuffer'); expect(data.data).toHaveProperty('terminalBuffer');
expect(data).toHaveProperty('status'); expect(data.data).toHaveProperty('status');
}); });
}); });
@@ -165,7 +165,7 @@ describe('Interactive Session Lifecycle', () => {
body: JSON.stringify({}), body: JSON.stringify({}),
}); });
const createData = await createRes.json(); const createData = await createRes.json();
const sessionId = createData.session.id; const sessionId = createData.data.session.id;
// Start interactive mode // Start interactive mode
const response = await fetch(`${baseUrl}/api/sessions/${sessionId}/interactive`, { const response = await fetch(`${baseUrl}/api/sessions/${sessionId}/interactive`, {
@@ -195,7 +195,7 @@ describe('Interactive Session Lifecycle', () => {
body: JSON.stringify({}), body: JSON.stringify({}),
}); });
const createData = await createRes.json(); const createData = await createRes.json();
const sessionId = createData.session.id; const sessionId = createData.data.session.id;
await fetch(`${baseUrl}/api/sessions/${sessionId}/interactive`, { await fetch(`${baseUrl}/api/sessions/${sessionId}/interactive`, {
method: 'POST', method: 'POST',
@@ -225,7 +225,7 @@ describe('Interactive Session Lifecycle', () => {
body: JSON.stringify({}), body: JSON.stringify({}),
}); });
const createData = await createRes.json(); const createData = await createRes.json();
const sessionId = createData.session.id; const sessionId = createData.data.session.id;
await fetch(`${baseUrl}/api/sessions/${sessionId}/interactive`, { await fetch(`${baseUrl}/api/sessions/${sessionId}/interactive`, {
method: 'POST', method: 'POST',
+32 -25
View File
@@ -93,7 +93,7 @@ describe('SSE Events', () => {
// Parse and check for init event // Parse and check for init event
const events = parseSSEEvents(receivedData); 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).toBeDefined();
expect((initEvent?.data as any).sessions).toBeDefined(); expect((initEvent?.data as any).sessions).toBeDefined();
@@ -109,7 +109,7 @@ describe('SSE Events', () => {
// Start listening // Start listening
const fetchPromise = fetch(`${baseUrl}/api/events`, { const fetchPromise = fetch(`${baseUrl}/api/events`, {
signal: controller.signal, signal: controller.signal,
}).then(async response => { }).then(async (response) => {
const reader = response.body?.getReader(); const reader = response.body?.getReader();
if (reader) { if (reader) {
try { try {
@@ -123,7 +123,7 @@ describe('SSE Events', () => {
}); });
// Give time to connect // Give time to connect
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
// Create a session // Create a session
await fetch(`${baseUrl}/api/sessions`, { await fetch(`${baseUrl}/api/sessions`, {
@@ -133,15 +133,17 @@ describe('SSE Events', () => {
}); });
// Wait a bit for the event // Wait a bit for the event
await new Promise(resolve => setTimeout(resolve, 200)); await new Promise((resolve) => setTimeout(resolve, 200));
// Stop listening // Stop listening
controller.abort(); controller.abort();
try { await fetchPromise; } catch {} try {
await fetchPromise;
} catch {}
// Parse events // Parse events
const events = parseSSEEvents(receivedData); 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).toBeDefined();
expect((sessionCreated?.data as any).id).toBeDefined(); expect((sessionCreated?.data as any).id).toBeDefined();
@@ -151,12 +153,13 @@ describe('SSE Events', () => {
describe('GET /api/status', () => { describe('GET /api/status', () => {
it('should return full state', async () => { it('should return full state', async () => {
const response = await fetch(`${baseUrl}/api/status`); const response = await fetch(`${baseUrl}/api/status`);
const data = await response.json(); const body = await response.json();
expect(data).toHaveProperty('sessions'); expect(body.success).toBe(true);
expect(data).toHaveProperty('scheduledRuns'); expect(body.data).toHaveProperty('sessions');
expect(data).toHaveProperty('respawnStatus'); expect(body.data).toHaveProperty('scheduledRuns');
expect(data).toHaveProperty('timestamp'); expect(body.data).toHaveProperty('respawnStatus');
expect(body.data).toHaveProperty('timestamp');
}); });
it('should include active sessions', async () => { it('should include active sessions', async () => {
@@ -168,9 +171,9 @@ describe('SSE Events', () => {
}); });
const response = await fetch(`${baseUrl}/api/status`); 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 // Start listening
const fetchPromise = fetch(`${baseUrl}/api/events`, { const fetchPromise = fetch(`${baseUrl}/api/events`, {
signal: controller.signal, signal: controller.signal,
}).then(async response => { }).then(async (response) => {
const reader = response.body?.getReader(); const reader = response.body?.getReader();
if (reader) { if (reader) {
try { try {
@@ -211,7 +214,7 @@ describe('SSE Event Types', () => {
}); });
// Give time to connect // Give time to connect
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
// Create a session // Create a session
const createRes = await fetch(`${baseUrl}/api/sessions`, { const createRes = await fetch(`${baseUrl}/api/sessions`, {
@@ -222,23 +225,25 @@ describe('SSE Event Types', () => {
const createData = await createRes.json(); const createData = await createRes.json();
// Delete it // Delete it
await fetch(`${baseUrl}/api/sessions/${createData.session.id}`, { await fetch(`${baseUrl}/api/sessions/${createData.data.session.id}`, {
method: 'DELETE', method: 'DELETE',
}); });
// Wait for events // Wait for events
await new Promise(resolve => setTimeout(resolve, 200)); await new Promise((resolve) => setTimeout(resolve, 200));
// Stop listening // Stop listening
controller.abort(); controller.abort();
try { await fetchPromise; } catch {} try {
await fetchPromise;
} catch {}
// Parse events // Parse events
const events = parseSSEEvents(receivedData); 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).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 // Start listening
const fetchPromise = fetch(`${baseUrl}/api/events`, { const fetchPromise = fetch(`${baseUrl}/api/events`, {
signal: controller.signal, signal: controller.signal,
}).then(async response => { }).then(async (response) => {
const reader = response.body?.getReader(); const reader = response.body?.getReader();
if (reader) { if (reader) {
try { try {
@@ -264,7 +269,7 @@ describe('SSE Event Types', () => {
}); });
// Give time to connect // Give time to connect
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
// Create a case // Create a case
const caseName = `test-sse-case-${Date.now()}`; const caseName = `test-sse-case-${Date.now()}`;
@@ -275,15 +280,17 @@ describe('SSE Event Types', () => {
}); });
// Wait for events // Wait for events
await new Promise(resolve => setTimeout(resolve, 200)); await new Promise((resolve) => setTimeout(resolve, 200));
// Stop listening // Stop listening
controller.abort(); controller.abort();
try { await fetchPromise; } catch {} try {
await fetchPromise;
} catch {}
// Parse events // Parse events
const events = parseSSEEvents(receivedData); 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).toBeDefined();
expect((caseCreated?.data as any).name).toBe(caseName); expect((caseCreated?.data as any).name).toBe(caseName);
+6 -6
View File
@@ -159,7 +159,7 @@ describe('SSE Subscription Filtering', () => {
body: JSON.stringify({ workingDir: '/tmp' }), body: JSON.stringify({ workingDir: '/tmp' }),
}); });
const createData = await createRes.json(); const createData = await createRes.json();
const sessionId = createData.session.id; const sessionId = createData.data.session.id;
// Wait for events to arrive // Wait for events to arrive
await new Promise((resolve) => setTimeout(resolve, 300)); await new Promise((resolve) => setTimeout(resolve, 300));
@@ -210,7 +210,7 @@ describe('SSE Subscription Filtering', () => {
body: JSON.stringify({ workingDir: '/tmp' }), body: JSON.stringify({ workingDir: '/tmp' }),
}); });
const createData = await createRes.json(); const createData = await createRes.json();
const sessionId = createData.session.id; const sessionId = createData.data.session.id;
// Now connect SSE with the session filter // Now connect SSE with the session filter
const controller = new AbortController(); const controller = new AbortController();
@@ -264,14 +264,14 @@ describe('SSE Subscription Filtering', () => {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }), 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`, { const createRes2 = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }), body: JSON.stringify({ workingDir: '/tmp' }),
}); });
const session2 = (await createRes2.json()).session; const session2 = (await createRes2.json()).data.session;
// Connect SSE subscribed ONLY to session1 // Connect SSE subscribed ONLY to session1
const controller = new AbortController(); const controller = new AbortController();
@@ -331,14 +331,14 @@ describe('SSE Subscription Filtering', () => {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }), 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`, { const createRes2 = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }), body: JSON.stringify({ workingDir: '/tmp' }),
}); });
const session2 = (await createRes2.json()).session; const session2 = (await createRes2.json()).data.session;
// Connect SSE subscribed to both sessions // Connect SSE subscribed to both sessions
const controller = new AbortController(); const controller = new AbortController();
+2 -2
View File
@@ -368,9 +368,9 @@ describe('types utility functions', () => {
expect(ApiErrorCode.INTERNAL_ERROR).toBe('INTERNAL_ERROR'); expect(ApiErrorCode.INTERNAL_ERROR).toBe('INTERNAL_ERROR');
}); });
it('should have 6 error codes', () => { it('should have 9 error codes', () => {
const codes = Object.values(ApiErrorCode); const codes = Object.values(ApiErrorCode);
expect(codes).toHaveLength(6); expect(codes).toHaveLength(9);
}); });
}); });
}); });