test: repair route harness error rendering + stop AI-checker spawning real processes in tests (#97)

* fix(test): share route error handler with test harness + fix stale assertions

The route test harness built a bare Fastify instance without the production
global error handler (server.ts), so structured errors thrown by route helpers
(findSessionOrFail → 404, parseBody → 400) fell through to Fastify's default
handler — yielding a `{statusCode,error,message}` body instead of the
`{success:false,...}` shape, and the tests asserted the old implicit-200
behavior. 51 route tests across 7 files were red.

- Extract the handler into src/web/route-error-handler.ts; server.ts and the
  test harness now install the identical handler (single source of truth).
- Correct stale assertions across route test files: throw-based error paths
  now assert 404 (unknown session) / 400 (invalid body); genuine in-handler
  `return createErrorResponse(...)` paths (200 + success:false) left untouched.
- Reformat a few test files prettier flagged (pre-existing non-compliance).

Route suite: 307/307 passing (was 256/307). No production behavior change.

* test(respawn): mock child_process so AI checker never spawns real processes

respawn-controller.test.ts drives the AI idle checker (ai-checker-base), whose
runCheck() spawns a real `tmux new-session` running `claude -p`. The AI-enabled
tests only assert the ai_checking state transition (then cancel/stop), so the
spawn produced stray real tmux sessions and claude processes on every run — the
reason `npm test` (full suite) was unsafe to run inside a managed session.

Mock node:child_process here (mirroring ai-idle-checker.test.ts), spreading the
real module so `exec` stays intact for transitively-imported modules
(tmux-manager calls promisify(exec) at load). With this, the full non-mobile
suite runs without spawning any real tmux/claude.

---------

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
This commit is contained in:
Tenggan Zhang
2026-05-25 23:55:57 +02:00
committed by GitHub
co-authored by Teigen
parent 8abf349cfc
commit 1b652ceb87
15 changed files with 280 additions and 242 deletions
+29
View File
@@ -0,0 +1,29 @@
/**
* @fileoverview Shared Fastify error handler for Codeman's HTTP routes.
*
* Route helpers (`findSessionOrFail`, `parseBody` in route-helpers.ts) throw
* structured errors carrying `{ statusCode, body }`. This handler renders them
* into the proper HTTP response. It is installed by BOTH the production server
* and the route test harness so test behavior matches production exactly —
* without it, thrown errors fall through to Fastify's default handler and the
* response body is `{statusCode,error,message}` instead of `{success:false,...}`.
*/
import type { FastifyInstance } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../types.js';
/**
* Install the global error handler that renders structured route errors.
* Errors thrown with a `statusCode`/`body` (see route-helpers.ts) are sent
* verbatim at that status; anything else becomes a 500 OPERATION_FAILED response.
*/
export function installRouteErrorHandler(app: FastifyInstance): void {
app.setErrorHandler((error, _req, reply) => {
const statusCode = (error as { statusCode?: number }).statusCode ?? 500;
const body = (error as { body?: unknown }).body;
if (body) {
reply.code(statusCode).send(body);
} else {
reply.code(statusCode).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(error)));
}
});
}
+4 -12
View File
@@ -89,8 +89,6 @@ const require = createRequire(import.meta.url);
const { version: APP_VERSION } = require('../../package.json');
import {
getErrorMessage,
ApiErrorCode,
createErrorResponse,
type PersistedRespawnConfig,
type NiceConfig,
type ImageDetectedEvent,
@@ -101,6 +99,7 @@ import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.j
import { SseEvent } from './sse-events.js';
import type { ScheduledRun } from './ports/index.js';
import { registerAuthMiddleware, registerSecurityHeaders } from './middleware/auth.js';
import { installRouteErrorHandler } from './route-error-handler.js';
import {
registerPushRoutes,
registerTeamRoutes,
@@ -657,16 +656,9 @@ export class WebServer extends EventEmitter {
reply.code(updated ? 204 : 404).send();
});
// Global error handler for structured errors thrown by findSessionOrFail
this.app.setErrorHandler((error, _req, reply) => {
const statusCode = (error as { statusCode?: number }).statusCode ?? 500;
const body = (error as { body?: unknown }).body;
if (body) {
reply.code(statusCode).send(body);
} else {
reply.code(statusCode).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(error)));
}
});
// Global error handler for structured errors thrown by findSessionOrFail /
// parseBody. Shared with the route test harness so test behavior matches prod.
installRouteErrorHandler(this.app);
// Crash diagnostics beacon — frontend POSTs breadcrumbs, GET to read them
let _crashBreadcrumbs = '';
+149 -141
View File
@@ -1,4 +1,18 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
// The respawn controller drives the AI idle checker, which spawns real `tmux`/`claude`
// via child_process. Neutralize those spawns here (mirrors ai-idle-checker.test.ts) so
// tests never launch real processes. Spread the real module to keep `exec` etc. intact —
// transitively-imported modules (e.g. tmux-manager) call `promisify(exec)` at load time.
vi.mock('node:child_process', async (orig) => {
const actual = await orig<typeof import('node:child_process')>();
return {
...actual,
execSync: vi.fn(),
spawn: vi.fn(() => ({ unref: vi.fn(), pid: 12345, on: vi.fn() })),
};
});
import { RespawnController, RespawnState, RespawnConfig } from '../src/respawn-controller.js';
import { Session } from '../src/session.js';
import { MockSession } from './mocks/index.js';
@@ -38,7 +52,9 @@ describe('RespawnController', () => {
it('should have default configuration', () => {
const config = controller.getConfig();
expect(config.enabled).toBe(true);
expect(config.updatePrompt).toBe('write a brief progress summary to CLAUDE.md noting what you accomplished, then continue working.');
expect(config.updatePrompt).toBe(
'write a brief progress summary to CLAUDE.md noting what you accomplished, then continue working.'
);
});
it('should allow custom configuration', () => {
@@ -95,9 +111,9 @@ describe('RespawnController', () => {
session.simulateCompletionMessage();
// Wait for log
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
const hasCompletionLog = logMessages.some(msg => msg.includes('Completion message detected'));
const hasCompletionLog = logMessages.some((msg) => msg.includes('Completion message detected'));
expect(hasCompletionLog).toBe(true);
});
@@ -139,7 +155,7 @@ describe('RespawnController', () => {
session.simulateCompletionMessage();
// Wait for completion confirmation (completionConfirmMs=50) + processing
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(cycleStarted).toBe(true);
expect(controller.currentCycle).toBe(1);
@@ -155,7 +171,7 @@ describe('RespawnController', () => {
session.simulateCompletionMessage();
// Wait for completion confirmation + step delay
await new Promise(resolve => setTimeout(resolve, 300));
await new Promise((resolve) => setTimeout(resolve, 300));
expect(stepSent).toBe('update');
expect(session.writeBuffer.length).toBeGreaterThan(0);
@@ -170,7 +186,7 @@ describe('RespawnController', () => {
session.simulateCompletionMessage();
// Wait for state transitions
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Should have transitioned through multiple states (watching -> confirming_idle -> sending_update)
expect(states).toContain('watching');
@@ -239,7 +255,7 @@ describe('RespawnController', () => {
controller.start();
// Wait a bit
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
const status = controller.getStatus();
expect(status.timeSinceActivity).toBeGreaterThan(0);
@@ -299,7 +315,7 @@ describe('RespawnController', () => {
});
controller.start();
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(events.length).toBeGreaterThan(0);
expect(events[0].state).toBe('watching');
@@ -313,7 +329,7 @@ describe('RespawnController', () => {
controller.start();
expect(logs.length).toBeGreaterThan(0);
expect(logs.some(l => l.includes('Starting'))).toBe(true);
expect(logs.some((l) => l.includes('Starting'))).toBe(true);
});
});
});
@@ -346,13 +362,13 @@ describe('RespawnController Integration', () => {
// Alternate between working and idle
session.simulatePrompt();
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
session.simulateWorking();
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
session.simulatePrompt();
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
// Should handle transitions gracefully
expect(controller.isRunning).toBe(true);
@@ -467,7 +483,7 @@ describe('RespawnController Integration', () => {
controller.start();
session.simulatePrompt();
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(cycleStarted).toBe(false);
controller.stop();
@@ -560,7 +576,7 @@ describe('RespawnController Configuration', () => {
it('should clamp completionConfirmMs to noOutputTimeoutMs', () => {
const controller = new RespawnController(session as unknown as Session, {
completionConfirmMs: 60000, // Greater than noOutputTimeoutMs
completionConfirmMs: 60000, // Greater than noOutputTimeoutMs
noOutputTimeoutMs: 30000,
});
// completionConfirmMs should be clamped to noOutputTimeoutMs
@@ -646,7 +662,7 @@ describe('RespawnController State Transitions', () => {
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(stateHistory).toContain('watching');
expect(stateHistory.length).toBeGreaterThan(1);
@@ -657,7 +673,7 @@ describe('RespawnController State Transitions', () => {
session.simulateCompletionMessage();
// Wait a bit then stop during potential transition
await new Promise(resolve => setTimeout(resolve, 30));
await new Promise((resolve) => setTimeout(resolve, 30));
controller.stop();
expect(controller.state).toBe('stopped');
@@ -672,7 +688,7 @@ describe('RespawnController State Transitions', () => {
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 500));
await new Promise((resolve) => setTimeout(resolve, 500));
// May or may not complete depending on timing
expect(controller.isRunning).toBe(true);
@@ -692,7 +708,7 @@ describe('RespawnController State Transitions', () => {
session.simulateCompletionMessage();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Should detect completion messages
expect(completionCount).toBeGreaterThan(0);
@@ -708,10 +724,10 @@ describe('RespawnController State Transitions', () => {
session.simulateCompletionMessage();
// Before confirmation timer fires, start working
await new Promise(resolve => setTimeout(resolve, 20));
await new Promise((resolve) => setTimeout(resolve, 20));
session.simulateWorking();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Cycle should not have started due to working state canceling confirmation
expect(controller.getStatus().workingDetected).toBe(true);
@@ -820,7 +836,7 @@ describe('RespawnController Edge Cases', () => {
// Update config mid-run
controller.updateConfig({ updatePrompt: 'updated' });
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(controller.getConfig().updatePrompt).toBe('updated');
controller.stop();
@@ -840,7 +856,7 @@ describe('RespawnController Edge Cases', () => {
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(controller.currentCycle).toBeGreaterThan(0);
controller.stop();
@@ -853,7 +869,7 @@ describe('RespawnController Edge Cases', () => {
controller.start();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
const status = controller.getStatus();
// Allow for slight timing variance (timers may fire 1-2ms early)
@@ -868,7 +884,7 @@ describe('RespawnController Edge Cases', () => {
controller.start();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
session.simulateTerminalOutput('new data');
@@ -908,7 +924,7 @@ describe('RespawnController Edge Cases', () => {
session.simulateTerminalOutput('Would you like to proceed?\n❯ 1. Yes\n 2. No\n');
// Wait for autoAcceptDelayMs to expire
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(autoAcceptFired).toBe(true);
expect(session.writeBuffer).toContain('\r');
@@ -935,7 +951,7 @@ describe('RespawnController Edge Cases', () => {
session.simulateCompletionMessage();
// Wait for autoAcceptDelayMs
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(autoAcceptFired).toBe(false);
autoAcceptController.stop();
@@ -958,7 +974,7 @@ describe('RespawnController Edge Cases', () => {
autoAcceptController.start();
session.simulateTerminalOutput('Plan: Waiting for approval...');
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(autoAcceptFired).toBe(false);
autoAcceptController.stop();
@@ -981,7 +997,7 @@ describe('RespawnController Edge Cases', () => {
autoAcceptController.start();
// Don't simulate any output - just wait
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(autoAcceptFired).toBe(false);
autoAcceptController.stop();
@@ -1006,15 +1022,15 @@ describe('RespawnController Edge Cases', () => {
session.simulateTerminalOutput('❯ 1. Yes\n 2. No\n');
// Wait 100ms (less than 150ms delay), then send more output
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
session.simulateTerminalOutput('More output');
// Wait another 100ms - total 200ms from start but only 100ms from last output
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
expect(autoAcceptFired).toBe(false);
// Wait the remaining time
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
expect(autoAcceptFired).toBe(true);
autoAcceptController.stop();
});
@@ -1038,16 +1054,16 @@ describe('RespawnController Edge Cases', () => {
session.simulateTerminalOutput('❯ 1. Yes\n 2. No\n');
// Wait for first auto-accept
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(autoAcceptCount).toBe(1);
// Wait more - should NOT fire again (hasReceivedOutput is false)
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(autoAcceptCount).toBe(1);
// New output comes in (plan mode again), then silence again - should fire again
session.simulateTerminalOutput('❯ 1. Yes\n 2. No\n');
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(autoAcceptCount).toBe(2);
autoAcceptController.stop();
@@ -1072,14 +1088,14 @@ describe('RespawnController Edge Cases', () => {
// Trigger a respawn cycle via completion message
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 150));
await new Promise((resolve) => setTimeout(resolve, 150));
// Now in sending_update or waiting_update state
expect(autoAcceptController.state).not.toBe('watching');
// Simulate output in the waiting state, then silence
session.simulateTerminalOutput('Processing update...');
await new Promise(resolve => setTimeout(resolve, 150));
await new Promise((resolve) => setTimeout(resolve, 150));
// Auto-accept should NOT fire because we're not in watching state
expect(autoAcceptFired).toBe(false);
@@ -1107,7 +1123,7 @@ describe('RespawnController Edge Cases', () => {
autoAcceptController.signalElicitation();
// Wait for autoAcceptDelayMs to expire
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Auto-accept should NOT fire because elicitation was signaled
expect(autoAcceptFired).toBe(false);
@@ -1141,7 +1157,7 @@ describe('RespawnController Edge Cases', () => {
// New silence after work - plan mode approval with plan mode UI
session.simulateTerminalOutput('❯ 1. Yes\n 2. No\n');
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Auto-accept should fire now (elicitation cleared by working pattern)
expect(autoAcceptFired).toBe(true);
@@ -1210,7 +1226,7 @@ describe('RespawnController AI Idle Check', () => {
session.simulateCompletionMessage();
// Wait for completion confirm timer to fire and AI check to start
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Should have transitioned to ai_checking
expect(states).toContain('ai_checking');
@@ -1230,13 +1246,13 @@ describe('RespawnController AI Idle Check', () => {
session.simulateCompletionMessage();
// Wait for AI check to start
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Simulate working patterns during AI check
session.simulateWorking();
// Should be back to watching
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(controller.state).toBe('watching');
controller.stop();
@@ -1254,13 +1270,13 @@ describe('RespawnController AI Idle Check', () => {
session.simulateCompletionMessage();
// Wait for AI check to start
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Simulate substantial output during AI check
session.simulateTerminalOutput('Some meaningful output that is more than 2 chars');
// Should be back to watching
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(controller.state).toBe('watching');
controller.stop();
@@ -1282,7 +1298,7 @@ describe('RespawnController AI Idle Check', () => {
session.simulateCompletionMessage();
// Wait for completion confirm and direct idle
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(cycleStarted).toBe(true);
controller.stop();
@@ -1304,7 +1320,7 @@ describe('RespawnController AI Idle Check', () => {
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 150));
await new Promise((resolve) => setTimeout(resolve, 150));
expect(aiCheckStarted).toBe(true);
controller.stop();
@@ -1347,7 +1363,7 @@ describe('RespawnController AI Idle Check', () => {
session.simulateCompletionMessage();
// Wait for completion confirm timer + AI check start
await new Promise(resolve => setTimeout(resolve, 150));
await new Promise((resolve) => setTimeout(resolve, 150));
// Should have triggered ai_checking via completion path
expect(states).toContain('ai_checking');
@@ -1370,7 +1386,7 @@ describe('RespawnController AI Idle Check', () => {
session.simulateCompletionMessage();
// Wait for AI check to start and timeout
await new Promise(resolve => setTimeout(resolve, 300));
await new Promise((resolve) => setTimeout(resolve, 300));
// Should return to watching after timeout (with cooldown)
expect(controller.state).toBe('watching');
@@ -1416,7 +1432,7 @@ describe('RespawnController AI Plan Mode Check', () => {
// Output without plan mode patterns (no numbered list, no selector)
session.simulateTerminalOutput('Claude is just thinking about something...\nSome regular output here.');
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Pre-filter should block - no plan mode patterns found
expect(autoAcceptFired).toBe(false);
@@ -1442,13 +1458,10 @@ describe('RespawnController AI Plan Mode Check', () => {
// Output WITH plan mode patterns
session.simulateTerminalOutput(
'Would you like to proceed with this plan?\n' +
'❯ 1. Yes\n' +
' 2. No\n' +
' 3. Type your own\n'
'Would you like to proceed with this plan?\n' + '❯ 1. Yes\n' + ' 2. No\n' + ' 3. Type your own\n'
);
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Pre-filter should pass and send Enter (AI disabled)
expect(autoAcceptFired).toBe(true);
@@ -1473,15 +1486,11 @@ describe('RespawnController AI Plan Mode Check', () => {
controller.start();
// Plan mode patterns BUT also has working patterns (spinner) in the tail
session.simulateTerminalOutput(
'❯ 1. Yes\n' +
' 2. No\n' +
'Thinking ⠋\n'
);
session.simulateTerminalOutput('❯ 1. Yes\n' + ' 2. No\n' + 'Thinking ⠋\n');
// Wait for autoAcceptDelay - but working pattern resets the timer
// so we need to wait longer and check after working pattern was consumed
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Should NOT fire because working patterns detected resets timer
// (the working pattern in handleTerminalData clears timers)
@@ -1508,13 +1517,9 @@ describe('RespawnController AI Plan Mode Check', () => {
controller.start();
// Output with plan mode patterns to pass pre-filter
session.simulateTerminalOutput(
'Would you like to proceed?\n' +
'❯ 1. Yes\n' +
' 2. No\n'
);
session.simulateTerminalOutput('Would you like to proceed?\n' + '❯ 1. Yes\n' + ' 2. No\n');
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Plan check should have been started (pre-filter passed, AI enabled)
expect(planCheckStarted).toBe(true);
@@ -1544,17 +1549,14 @@ describe('RespawnController AI Plan Mode Check', () => {
controller.start();
// Trigger plan check
session.simulateTerminalOutput(
'❯ 1. Yes\n' +
' 2. No\n'
);
await new Promise(resolve => setTimeout(resolve, 150));
session.simulateTerminalOutput('❯ 1. Yes\n' + ' 2. No\n');
await new Promise((resolve) => setTimeout(resolve, 150));
expect(planCheckStarted).toBe(true);
// New output arrives - should cancel plan check (stale)
session.simulateTerminalOutput('New output from Claude...');
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Auto-accept should NOT have fired (check was cancelled)
expect(autoAcceptFired).toBe(false);
@@ -1580,16 +1582,14 @@ describe('RespawnController AI Plan Mode Check', () => {
controller.start();
// Plan mode patterns to trigger check
session.simulateTerminalOutput(
'❯ 1. Yes\n 2. No\n'
);
await new Promise(resolve => setTimeout(resolve, 150));
session.simulateTerminalOutput('❯ 1. Yes\n 2. No\n');
await new Promise((resolve) => setTimeout(resolve, 150));
// Output arrives during check - result should be discarded
session.simulateTerminalOutput('Claude started working again');
// Wait for any pending check to complete
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(autoAcceptFired).toBe(false);
controller.stop();
@@ -1617,11 +1617,9 @@ describe('RespawnController AI Plan Mode Check', () => {
controller.start();
// Plan mode patterns
session.simulateTerminalOutput(
'❯ 1. Yes\n 2. No\n'
);
session.simulateTerminalOutput('❯ 1. Yes\n 2. No\n');
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Should send Enter directly (no AI check)
expect(planCheckStarted).toBe(false);
@@ -1663,7 +1661,7 @@ describe('RespawnController AI Plan Mode Check', () => {
controller.start();
// Don't send any output - hasReceivedOutput should guard
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(autoAcceptFired).toBe(false);
controller.stop();
@@ -1861,7 +1859,7 @@ describe('RespawnController Resume Behavior', () => {
session.simulateCompletionMessage();
// Wait for completion confirm timer to fire
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Should start cycle based on completion message alone
expect(cycleStarted).toBe(true);
@@ -1886,7 +1884,7 @@ describe('RespawnController Resume Behavior', () => {
session.simulateTerminalOutput('Some text output');
// Wait for noOutput fallback
await new Promise(resolve => setTimeout(resolve, 300));
await new Promise((resolve) => setTimeout(resolve, 300));
// Should eventually trigger via fallback
expect(cycleStarted).toBe(true);
@@ -1915,7 +1913,7 @@ describe('RespawnController Resume Behavior', () => {
// Verify cycle can still start after resume
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(states).toContain('confirming_idle');
controller.stop();
@@ -1933,7 +1931,7 @@ describe('RespawnController Resume Behavior', () => {
session.simulateCompletionMessage();
// Wait for cycle to start
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Pause during cycle
controller.pause();
@@ -1975,7 +1973,7 @@ describe('RespawnController Step Confirmation', () => {
session.simulateCompletionMessage();
// Wait for full cycle
await new Promise(resolve => setTimeout(resolve, 300));
await new Promise((resolve) => setTimeout(resolve, 300));
// Should have gone through: watching -> confirming_idle -> sending_update -> waiting_update -> watching
expect(states).toContain('watching');
@@ -1996,12 +1994,12 @@ describe('RespawnController Step Confirmation', () => {
session.simulateCompletionMessage();
// Wait for update to be sent
await new Promise(resolve => setTimeout(resolve, 150));
await new Promise((resolve) => setTimeout(resolve, 150));
// Simulate continuous output while waiting for update completion
for (let i = 0; i < 5; i++) {
session.simulateTerminalOutput(`Processing step ${i}...`);
await new Promise(resolve => setTimeout(resolve, 20));
await new Promise((resolve) => setTimeout(resolve, 20));
}
// Controller should still be functional
@@ -2021,7 +2019,7 @@ describe('RespawnController Step Confirmation', () => {
session.simulateCompletionMessage();
// Wait for cycle to start
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Continuously emit output to prevent completion detection
const outputInterval = setInterval(() => {
@@ -2029,7 +2027,7 @@ describe('RespawnController Step Confirmation', () => {
}, 50);
// Wait a reasonable time
await new Promise(resolve => setTimeout(resolve, 500));
await new Promise((resolve) => setTimeout(resolve, 500));
clearInterval(outputInterval);
@@ -2057,7 +2055,7 @@ describe('RespawnController Step Confirmation', () => {
session.simulateCompletionMessage();
// Wait for step to complete
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// After update step completes (via timeout), should emit stepCompleted
// Note: with sendClear/sendInit false, cycle completes after update
@@ -2086,7 +2084,7 @@ describe('RespawnController AI Check Cooldown Behavior', () => {
session.simulateCompletionMessage();
// Wait for AI check to start
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
const detection = controller.getDetectionStatus();
// AI check should have started or be in progress
@@ -2130,7 +2128,7 @@ describe('RespawnController AI Check Cooldown Behavior', () => {
session.simulateCompletionMessage();
// Wait for AI check to timeout
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Should have gone through ai_checking and back
expect(states).toContain('ai_checking');
@@ -2151,18 +2149,18 @@ describe('RespawnController AI Check Cooldown Behavior', () => {
// First cycle - should start AI check
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
const detection1 = controller.getDetectionStatus();
// AI check was attempted
// Reset by simulating working
session.simulateWorking();
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
// Second cycle - might be on cooldown
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Controller should still be functional
expect(controller.isRunning).toBe(true);
@@ -2240,7 +2238,18 @@ describe('RespawnController Working Pattern Detection', () => {
controller.start();
// All spinner characters should indicate working
const spinnerChars = ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834', '\u2826', '\u2827', '\u2807', '\u280f'];
const spinnerChars = [
'\u280b',
'\u2819',
'\u2839',
'\u2838',
'\u283c',
'\u2834',
'\u2826',
'\u2827',
'\u2807',
'\u280f',
];
for (const char of spinnerChars) {
session.simulateTerminalOutput(char);
}
@@ -2266,7 +2275,7 @@ describe('RespawnController Working Pattern Detection', () => {
// Then completion
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
// Working should be cleared after a bit of silence
status = controller.getStatus();
@@ -2301,7 +2310,7 @@ describe('RespawnController Cycle Count Tracking', () => {
expect(controller.currentCycle).toBe(0);
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 150));
await new Promise((resolve) => setTimeout(resolve, 150));
expect(controller.currentCycle).toBeGreaterThan(0);
controller.stop();
@@ -2321,7 +2330,7 @@ describe('RespawnController Cycle Count Tracking', () => {
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 150));
await new Promise((resolve) => setTimeout(resolve, 150));
expect(cycleNumber).toBe(1);
controller.stop();
@@ -2425,13 +2434,13 @@ describe('RespawnController Timer Cleanup', () => {
session.simulateCompletionMessage();
// Start a timer-based operation
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
// Stop should clean up all timers
controller.stop();
// Wait to ensure no timer fires after stop
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(controller.state).toBe('stopped');
expect(controller.isRunning).toBe(false);
@@ -2448,13 +2457,13 @@ describe('RespawnController Timer Cleanup', () => {
session.simulateCompletionMessage();
// Wait for confirming_idle
await new Promise(resolve => setTimeout(resolve, 30));
await new Promise((resolve) => setTimeout(resolve, 30));
// Interrupt with working pattern
session.simulateWorking();
// Should cancel completion confirm timer and return to watching
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(controller.state).toBe('watching');
controller.stop();
@@ -2469,14 +2478,13 @@ describe('RespawnController Timer Cleanup', () => {
for (let i = 0; i < 10; i++) {
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 10));
await new Promise((resolve) => setTimeout(resolve, 10));
controller.stop();
}
// Should end in stopped state without errors
expect(controller.state).toBe('stopped');
});
});
// ========== Hook-Based Detection Tests (Phase 1) ==========
@@ -2542,7 +2550,7 @@ describe('RespawnController Hook-Based Idle Detection', () => {
testController.signalStopHook();
// Wait for hook confirmation timer (3s default)
await new Promise(resolve => setTimeout(resolve, 3100));
await new Promise((resolve) => setTimeout(resolve, 3100));
expect(cycleStarted).toHaveBeenCalled();
testController.stop();
@@ -2562,7 +2570,7 @@ describe('RespawnController Hook-Based Idle Detection', () => {
testController.signalIdlePrompt();
// idle_prompt skips confirmation and goes directly to idle
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
expect(cycleStarted).toHaveBeenCalled();
testController.stop();
@@ -2582,11 +2590,11 @@ describe('RespawnController Hook-Based Idle Detection', () => {
testController.signalStopHook();
// Simulate working patterns IMMEDIATELY after hook (before confirmation)
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
session.simulateWorking();
// Wait longer than hook confirmation delay (3s)
await new Promise(resolve => setTimeout(resolve, 3500));
await new Promise((resolve) => setTimeout(resolve, 3500));
// Cycle should NOT have started because working was detected
expect(cycleStarted).not.toHaveBeenCalled();
@@ -2608,7 +2616,7 @@ describe('RespawnController Hook-Based Idle Detection', () => {
testController.start();
testController.signalIdlePrompt(); // Start a cycle
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Now in sending_update state - Stop hook should be ignored
testController.signalStopHook();
@@ -2658,7 +2666,7 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
session.simulateCompletionMessage();
// Wait for completion detection to trigger timer
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Should have started at least one timer (completion-confirm or no-output-fallback)
expect(timerEvents.length).toBeGreaterThan(0);
@@ -2690,9 +2698,9 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
// Send output to trigger no-output timer reset
session.simulateTerminalOutput('some output');
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
const noOutputTimer = timerEvents.find(e => e.name === 'no-output-fallback');
const noOutputTimer = timerEvents.find((e) => e.name === 'no-output-fallback');
if (noOutputTimer) {
expect(noOutputTimer.durationMs).toBe(noOutputTimeoutMs);
}
@@ -2716,7 +2724,7 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
session.simulateCompletionMessage();
// Wait for completion confirm timer to fire (50ms + processing)
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// At least one timer should have completed (completion-confirm)
expect(completedTimers.length).toBeGreaterThan(0);
@@ -2741,11 +2749,11 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
session.simulateCompletionMessage();
// Wait for timer to start
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Simulate working to cancel the completion confirm timer
session.simulateWorking();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Working patterns should have cancelled at least one timer
const hasCancel = cancelledTimers.length > 0;
@@ -2770,13 +2778,13 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
// Trigger Stop hook to start hook-confirm timer
controller.signalStopHook();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Then working patterns should cancel it with a reason
session.simulateWorking();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
const hookCancel = cancelledTimers.find(e => e.name === 'hook-confirm');
const hookCancel = cancelledTimers.find((e) => e.name === 'hook-confirm');
if (hookCancel) {
expect(hookCancel.reason).toBeTruthy();
}
@@ -2797,7 +2805,7 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
session.simulateCompletionMessage();
// Wait for timers to start
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Should have active timers
const timersBefore = controller.getActiveTimers();
@@ -2822,7 +2830,7 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
session.simulateCompletionMessage();
// Wait for timers to start
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
controller.stop();
@@ -2835,7 +2843,7 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
});
// Wait longer than any timer duration
await new Promise(resolve => setTimeout(resolve, 400));
await new Promise((resolve) => setTimeout(resolve, 400));
expect(timerFiredAfterStop).toBe(false);
});
@@ -2852,11 +2860,11 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
// First cycle: start, trigger completion-related timers, stop
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Should have completion-confirm timer active
const firstCycleTimers = controller.getActiveTimers();
const hasCompletionConfirm = firstCycleTimers.some(t => t.name === 'completion-confirm');
const hasCompletionConfirm = firstCycleTimers.some((t) => t.name === 'completion-confirm');
expect(hasCompletionConfirm).toBe(true);
controller.stop();
@@ -2866,14 +2874,14 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
controller.start();
expect(controller.state).toBe('watching');
const restartTimers = controller.getActiveTimers();
const staleCompletionConfirm = restartTimers.some(t => t.name === 'completion-confirm');
const staleCompletionConfirm = restartTimers.some((t) => t.name === 'completion-confirm');
expect(staleCompletionConfirm).toBe(false);
// Can still trigger new timers
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
const newTimers = controller.getActiveTimers();
const hasNewCompletionConfirm = newTimers.some(t => t.name === 'completion-confirm');
const hasNewCompletionConfirm = newTimers.some((t) => t.name === 'completion-confirm');
expect(hasNewCompletionConfirm).toBe(true);
controller.stop();
@@ -2895,13 +2903,13 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
for (let i = 0; i < 5; i++) {
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 20));
await new Promise((resolve) => setTimeout(resolve, 20));
controller.stop();
}
// Wait to check no stale timers fire
const countBefore = completedTimers.length;
await new Promise(resolve => setTimeout(resolve, 300));
await new Promise((resolve) => setTimeout(resolve, 300));
const countAfter = completedTimers.length;
// No new timer completions should happen after final stop
@@ -2920,7 +2928,7 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
const activeTimers = controller.getActiveTimers();
expect(activeTimers.length).toBeGreaterThan(0);
@@ -2946,7 +2954,7 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
const detectionStatus = controller.getDetectionStatus();
expect(Array.isArray(detectionStatus.activeTimers)).toBe(true);
@@ -2966,14 +2974,14 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
session.simulateCompletionMessage();
// Wait for completion confirm timer to be set up
await new Promise(resolve => setTimeout(resolve, 20));
await new Promise((resolve) => setTimeout(resolve, 20));
const timersBefore = controller.getActiveTimers();
const hasCompletionConfirm = timersBefore.some(t => t.name === 'completion-confirm');
const hasCompletionConfirm = timersBefore.some((t) => t.name === 'completion-confirm');
// Wait for the timer to fire
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
const timersAfter = controller.getActiveTimers();
const stillHasCompletionConfirm = timersAfter.some(t => t.name === 'completion-confirm');
const stillHasCompletionConfirm = timersAfter.some((t) => t.name === 'completion-confirm');
// If we caught the timer before it fired, it should be gone now
if (hasCompletionConfirm) {
@@ -2992,7 +3000,7 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Should have timers
const timersBefore = controller.getActiveTimers();
@@ -3000,11 +3008,11 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
// Cancel via working
session.simulateWorking();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// completion-confirm should be removed
const timersAfter = controller.getActiveTimers();
const hasCompletionConfirm = timersAfter.some(t => t.name === 'completion-confirm');
const hasCompletionConfirm = timersAfter.some((t) => t.name === 'completion-confirm');
expect(hasCompletionConfirm).toBe(false);
controller.stop();
@@ -3019,10 +3027,10 @@ describe('RespawnController CleanupManager Timer Tracking', () => {
controller.start();
controller.signalStopHook();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
const activeTimers = controller.getActiveTimers();
const hookTimer = activeTimers.find(t => t.name === 'hook-confirm');
const hookTimer = activeTimers.find((t) => t.name === 'hook-confirm');
expect(hookTimer).toBeDefined();
if (hookTimer) {
expect(hookTimer.totalMs).toBeGreaterThan(0);
+5 -1
View File
@@ -7,6 +7,7 @@
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';
export interface RouteTestHarness {
app: FastifyInstance;
@@ -24,7 +25,7 @@ export interface RouteTestHarness {
export async function createRouteTestHarness(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
registerFn: (app: FastifyInstance, ctx: any) => void,
ctxOptions?: { sessionId?: string },
ctxOptions?: { sessionId?: string }
): Promise<RouteTestHarness> {
const app = Fastify({ logger: false });
@@ -34,6 +35,9 @@ export async function createRouteTestHarness(
const ctx = createMockRouteContext(ctxOptions);
registerFn(app, ctx);
// Mirror production: structured errors thrown by route helpers (findSessionOrFail,
// parseBody) are rendered to {success:false} bodies at the right status.
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
+11 -12
View File
@@ -104,9 +104,7 @@ describe('case-routes', () => {
});
it('includes hasClaudeMd flag', async () => {
mockedReaddir.mockResolvedValue([
{ name: 'case-with-md', isDirectory: () => true },
] as never);
mockedReaddir.mockResolvedValue([{ name: 'case-with-md', isDirectory: () => true }] as never);
mockedExistsSync.mockReturnValue(true);
const res = await harness.app.inject({
@@ -120,9 +118,7 @@ describe('case-routes', () => {
it('includes linked cases from linked-cases.json', async () => {
// CASES_DIR readdir returns one case
mockedReaddir.mockResolvedValue([
{ name: 'regular-case', isDirectory: () => true },
] as never);
mockedReaddir.mockResolvedValue([{ name: 'regular-case', isDirectory: () => true }] as never);
// linked-cases.json is read second (after CASES_DIR readdir)
let readCallCount = 0;
mockedReadFile.mockImplementation(async () => {
@@ -158,7 +154,7 @@ describe('case-routes', () => {
url: '/api/cases',
payload: { name: 'invalid case name!!' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -169,7 +165,7 @@ describe('case-routes', () => {
url: '/api/cases',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -180,7 +176,7 @@ describe('case-routes', () => {
url: '/api/cases',
payload: { name: '../etc' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -230,7 +226,7 @@ describe('case-routes', () => {
url: '/api/cases/link',
payload: { name: 'bad name!' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -241,7 +237,7 @@ describe('case-routes', () => {
url: '/api/cases/link',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -294,7 +290,10 @@ describe('case-routes', () => {
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.case.name).toBe('linked-project');
expect(harness.ctx.broadcast).toHaveBeenCalledWith('case:linked', expect.objectContaining({ name: 'linked-project' }));
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'case:linked',
expect.objectContaining({ name: 'linked-project' })
);
});
});
+2 -1
View File
@@ -312,7 +312,8 @@ describe('file-routes', () => {
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=../../etc/shadow`,
});
expect(res.statusCode).toBe(400);
// Path traversal returns 404 ("File not found") to avoid revealing the target exists.
expect(res.statusCode).toBe(404);
});
it('rejects overly large raw files', async () => {
+6 -9
View File
@@ -38,7 +38,7 @@ describe('hook-event-routes', () => {
expect(body.success).toBe(true);
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'hook:stop',
expect.objectContaining({ sessionId: harness.ctx._sessionId }),
expect.objectContaining({ sessionId: harness.ctx._sessionId })
);
});
@@ -55,7 +55,7 @@ describe('hook-event-routes', () => {
expect(res.statusCode).toBe(200);
expect(harness.ctx.sendPushNotifications).toHaveBeenCalledWith(
'hook:idle_prompt',
expect.objectContaining({ sessionId: harness.ctx._sessionId }),
expect.objectContaining({ sessionId: harness.ctx._sessionId })
);
});
@@ -85,7 +85,7 @@ describe('hook-event-routes', () => {
data: null,
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -98,7 +98,7 @@ describe('hook-event-routes', () => {
event: 'stop',
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -180,10 +180,7 @@ describe('hook-event-routes', () => {
},
});
expect(res.statusCode).toBe(200);
expect(mockTracker.recordHookEvent).toHaveBeenCalledWith(
'stop',
expect.any(Object),
);
expect(mockTracker.recordHookEvent).toHaveBeenCalledWith('stop', expect.any(Object));
});
it('starts transcript watcher when transcript_path is provided', async () => {
@@ -199,7 +196,7 @@ describe('hook-event-routes', () => {
expect(res.statusCode).toBe(200);
expect(harness.ctx.startTranscriptWatcher).toHaveBeenCalledWith(
harness.ctx._sessionId,
'/home/user/.claude/transcript.jsonl',
'/home/user/.claude/transcript.jsonl'
);
});
+3 -3
View File
@@ -103,7 +103,7 @@ describe('orchestrator-routes', () => {
url: '/api/orchestrator/start',
payload: { goal: '' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -114,7 +114,7 @@ describe('orchestrator-routes', () => {
url: '/api/orchestrator/start',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -228,7 +228,7 @@ describe('orchestrator-routes', () => {
url: '/api/orchestrator/reject',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
+8 -10
View File
@@ -80,7 +80,7 @@ describe('plan-routes', () => {
url: '/api/cancel-plan-generation',
payload: { orchestratorId: 12345 }, // should be string
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -129,7 +129,7 @@ describe('plan-routes', () => {
expect(body.data.status).toBe('completed');
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'session:planTaskUpdate',
expect.objectContaining({ sessionId: harness.ctx._sessionId, taskId: 'task-1' }),
expect.objectContaining({ sessionId: harness.ctx._sessionId, taskId: 'task-1' })
);
});
@@ -159,7 +159,7 @@ describe('plan-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/plan/task/task-1`,
payload: { status: 'invalid_status' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -208,7 +208,7 @@ describe('plan-routes', () => {
expect(body.data.completedCount).toBe(5);
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'session:planCheckpoint',
expect.objectContaining({ sessionId: harness.ctx._sessionId }),
expect.objectContaining({ sessionId: harness.ctx._sessionId })
);
});
});
@@ -279,9 +279,7 @@ describe('plan-routes', () => {
});
it('rolls back to a previous version', async () => {
const mockPlan = [
{ id: 'task-1', content: 'Step 1', status: 'pending' },
];
const mockPlan = [{ id: 'task-1', content: 'Step 1', status: 'pending' }];
harness.ctx._session.ralphTracker = {
rollbackToVersion: vi.fn(() => ({ success: true, plan: mockPlan })),
} as never;
@@ -296,7 +294,7 @@ describe('plan-routes', () => {
expect(body.data).toHaveLength(1);
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'session:planRollback',
expect.objectContaining({ sessionId: harness.ctx._sessionId, version: 1 }),
expect.objectContaining({ sessionId: harness.ctx._sessionId, version: 1 })
);
});
@@ -358,7 +356,7 @@ describe('plan-routes', () => {
expect(body.data.content).toBe('New task');
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'session:planTaskAdded',
expect.objectContaining({ sessionId: harness.ctx._sessionId }),
expect.objectContaining({ sessionId: harness.ctx._sessionId })
);
});
@@ -372,7 +370,7 @@ describe('plan-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/plan/task`,
payload: { priority: 'P1' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
+5 -5
View File
@@ -65,7 +65,7 @@ describe('push-routes', () => {
expect.objectContaining({
endpoint: 'https://push.example.com/send/abc123',
keys: { p256dh: 'test-p256dh-key', auth: 'test-auth-key' },
}),
})
);
});
@@ -87,7 +87,7 @@ describe('push-routes', () => {
expect.objectContaining({
userAgent: 'TestBrowser/1.0',
pushPreferences: { 'session:idle': true, 'session:error': false },
}),
})
);
});
@@ -99,7 +99,7 @@ describe('push-routes', () => {
keys: { p256dh: 'test-p256dh', auth: 'test-auth' },
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -112,7 +112,7 @@ describe('push-routes', () => {
endpoint: 'https://push.example.com/send/abc123',
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -160,7 +160,7 @@ describe('push-routes', () => {
url: '/api/push/subscribe/sub-123',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
+35 -21
View File
@@ -66,7 +66,9 @@ describe('ralph-routes', () => {
});
it('enables ralph tracker', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
const res = await harness.app.inject({
method: 'POST',
@@ -79,7 +81,9 @@ describe('ralph-routes', () => {
});
it('disables ralph tracker', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
const res = await harness.app.inject({
method: 'POST',
@@ -99,7 +103,7 @@ describe('ralph-routes', () => {
});
expect((harness.ctx.mux as Record<string, unknown>).updateRalphEnabled).toHaveBeenCalledWith(
harness.ctx._sessionId,
true,
true
);
});
@@ -116,7 +120,9 @@ describe('ralph-routes', () => {
});
it('handles reset option', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
await harness.app.inject({
method: 'POST',
@@ -127,7 +133,9 @@ describe('ralph-routes', () => {
});
it('configures completion phrase and max iterations', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
await harness.app.inject({
method: 'POST',
@@ -138,7 +146,9 @@ describe('ralph-routes', () => {
});
it('sets max iterations independently', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
await harness.app.inject({
method: 'POST',
@@ -154,7 +164,7 @@ describe('ralph-routes', () => {
url: '/api/sessions/nonexistent/ralph-config',
payload: { enabled: true },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -178,13 +188,15 @@ describe('ralph-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/ralph-config`,
payload: { enabled: 'not-boolean' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
it('handles disableAutoEnable flag', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
await harness.app.inject({
method: 'POST',
@@ -208,7 +220,9 @@ describe('ralph-routes', () => {
describe('POST /api/sessions/:id/ralph-circuit-breaker/reset', () => {
it('resets circuit breaker for valid session', async () => {
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<
typeof createMockRalphTracker
>;
const res = await harness.app.inject({
method: 'POST',
@@ -225,7 +239,7 @@ describe('ralph-routes', () => {
method: 'POST',
url: '/api/sessions/nonexistent/ralph-circuit-breaker/reset',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -253,7 +267,7 @@ describe('ralph-routes', () => {
method: 'GET',
url: '/api/sessions/nonexistent/ralph-status',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -279,7 +293,7 @@ describe('ralph-routes', () => {
method: 'GET',
url: '/api/sessions/nonexistent/fix-plan',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -315,7 +329,7 @@ describe('ralph-routes', () => {
url: '/api/sessions/nonexistent/fix-plan/import',
payload: { content: 'test' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -326,7 +340,7 @@ describe('ralph-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/fix-plan/import`,
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -341,7 +355,7 @@ describe('ralph-routes', () => {
url: '/api/sessions/nonexistent/ralph-prompt/write',
payload: { content: 'test prompt' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -365,7 +379,7 @@ describe('ralph-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/ralph-prompt/write`,
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -379,7 +393,7 @@ describe('ralph-routes', () => {
method: 'POST',
url: '/api/sessions/nonexistent/fix-plan/write',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -405,7 +419,7 @@ describe('ralph-routes', () => {
method: 'POST',
url: '/api/sessions/nonexistent/fix-plan/read',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -432,7 +446,7 @@ describe('ralph-routes', () => {
url: '/api/ralph-loop/start',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -466,7 +480,7 @@ describe('ralph-routes', () => {
caseName: '../escape-path',
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
+2 -2
View File
@@ -172,7 +172,7 @@ describe('respawn-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/respawn/config`,
payload: { idleTimeoutMs: 'not-a-number' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -196,7 +196,7 @@ describe('respawn-routes', () => {
expect(mockController.updateConfig).toHaveBeenCalled();
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'respawn:configUpdated',
expect.objectContaining({ sessionId: harness.ctx._sessionId }),
expect.objectContaining({ sessionId: harness.ctx._sessionId })
);
});
+4 -8
View File
@@ -114,7 +114,7 @@ describe('scheduled-routes', () => {
prompt: '',
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -125,7 +125,7 @@ describe('scheduled-routes', () => {
url: '/api/scheduled',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -139,7 +139,7 @@ describe('scheduled-routes', () => {
workingDir: '/tmp/test;rm -rf /',
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -171,11 +171,7 @@ describe('scheduled-routes', () => {
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
// 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);
});
});
+13 -13
View File
@@ -103,7 +103,7 @@ describe('session-routes', () => {
method: 'GET',
url: '/api/sessions/nonexistent',
});
expect(res.statusCode).toBe(200); // returns error in body, not HTTP 404
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.error).toBeDefined();
@@ -174,7 +174,7 @@ describe('session-routes', () => {
url: '/api/sessions/nonexistent/name',
payload: { name: 'test' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -227,7 +227,7 @@ describe('session-routes', () => {
url: '/api/sessions/nonexistent/input',
payload: { input: 'hello' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -238,7 +238,7 @@ describe('session-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/input`,
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -265,7 +265,7 @@ describe('session-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/resize`,
payload: { cols: 501, rows: 24 },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -276,7 +276,7 @@ describe('session-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/resize`,
payload: { cols: 80, rows: 201 },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -287,7 +287,7 @@ describe('session-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/resize`,
payload: { cols: 0, rows: 24 },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -312,7 +312,7 @@ describe('session-routes', () => {
method: 'GET',
url: '/api/sessions/nonexistent/terminal',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -338,7 +338,7 @@ describe('session-routes', () => {
url: `/api/sessions/${harness.ctx._sessionId}/run`,
payload: { prompt: '' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -349,7 +349,7 @@ describe('session-routes', () => {
url: '/api/sessions/nonexistent/run',
payload: { prompt: 'test' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -386,7 +386,7 @@ describe('session-routes', () => {
method: 'POST',
url: '/api/sessions/nonexistent/interactive',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -450,7 +450,7 @@ describe('session-routes', () => {
method: 'GET',
url: '/api/sessions/nonexistent/output',
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -586,7 +586,7 @@ describe('session-routes', () => {
resumeSessionId: 'not-a-uuid',
},
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
+4 -4
View File
@@ -166,7 +166,7 @@ describe('system-routes', () => {
url: '/api/config',
payload: { unknownField: 'invalid' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -361,7 +361,7 @@ describe('system-routes', () => {
url: '/api/settings',
payload: { unknownField: 'bad' },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -459,7 +459,7 @@ describe('system-routes', () => {
url: '/api/subagent-window-states',
payload: { minimized: { 'agent-1': 'not-a-boolean' } },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
@@ -516,7 +516,7 @@ describe('system-routes', () => {
url: '/api/subagent-parents',
payload: { 'agent-1': 123 },
});
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});