From 6f01626c7981db08649b6639058746ae1936a2c2 Mon Sep 17 00:00:00 2001 From: Teigen Date: Mon, 25 May 2026 19:50:26 +0800 Subject: [PATCH 1/2] fix(test): share route error handler with test harness + fix stale assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/web/route-error-handler.ts | 29 +++++++++++++ src/web/server.ts | 16 ++----- test/routes/_route-test-utils.ts | 6 ++- test/routes/case-routes.test.ts | 23 +++++----- test/routes/file-routes.test.ts | 3 +- test/routes/hook-event-routes.test.ts | 15 +++---- test/routes/orchestrator-routes.test.ts | 6 +-- test/routes/plan-routes.test.ts | 18 ++++---- test/routes/push-routes.test.ts | 10 ++--- test/routes/ralph-routes.test.ts | 56 +++++++++++++++---------- test/routes/respawn-routes.test.ts | 4 +- test/routes/scheduled-routes.test.ts | 12 ++---- test/routes/session-routes.test.ts | 26 ++++++------ test/routes/system-routes.test.ts | 8 ++-- 14 files changed, 131 insertions(+), 101 deletions(-) create mode 100644 src/web/route-error-handler.ts diff --git a/src/web/route-error-handler.ts b/src/web/route-error-handler.ts new file mode 100644 index 00000000..ba0495f9 --- /dev/null +++ b/src/web/route-error-handler.ts @@ -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))); + } + }); +} diff --git a/src/web/server.ts b/src/web/server.ts index 703c8c77..dc3f725a 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -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 = ''; diff --git a/test/routes/_route-test-utils.ts b/test/routes/_route-test-utils.ts index dc038aa1..1ea553ae 100644 --- a/test/routes/_route-test-utils.ts +++ b/test/routes/_route-test-utils.ts @@ -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 { 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 }; diff --git a/test/routes/case-routes.test.ts b/test/routes/case-routes.test.ts index 07741f99..4b620b50 100644 --- a/test/routes/case-routes.test.ts +++ b/test/routes/case-routes.test.ts @@ -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' }) + ); }); }); diff --git a/test/routes/file-routes.test.ts b/test/routes/file-routes.test.ts index 3be7a310..bb657733 100644 --- a/test/routes/file-routes.test.ts +++ b/test/routes/file-routes.test.ts @@ -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 () => { diff --git a/test/routes/hook-event-routes.test.ts b/test/routes/hook-event-routes.test.ts index 68f9ab17..c8f5587a 100644 --- a/test/routes/hook-event-routes.test.ts +++ b/test/routes/hook-event-routes.test.ts @@ -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' ); }); diff --git a/test/routes/orchestrator-routes.test.ts b/test/routes/orchestrator-routes.test.ts index 0548ea71..9a29a4da 100644 --- a/test/routes/orchestrator-routes.test.ts +++ b/test/routes/orchestrator-routes.test.ts @@ -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); }); diff --git a/test/routes/plan-routes.test.ts b/test/routes/plan-routes.test.ts index 99a19ab8..390a8966 100644 --- a/test/routes/plan-routes.test.ts +++ b/test/routes/plan-routes.test.ts @@ -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); }); diff --git a/test/routes/push-routes.test.ts b/test/routes/push-routes.test.ts index d135bd30..20ee2777 100644 --- a/test/routes/push-routes.test.ts +++ b/test/routes/push-routes.test.ts @@ -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); }); diff --git a/test/routes/ralph-routes.test.ts b/test/routes/ralph-routes.test.ts index e0550b8e..b44db4de 100644 --- a/test/routes/ralph-routes.test.ts +++ b/test/routes/ralph-routes.test.ts @@ -66,7 +66,9 @@ describe('ralph-routes', () => { }); it('enables ralph tracker', async () => { - const tracker = (harness.ctx._session as Record).ralphTracker as ReturnType; + const tracker = (harness.ctx._session as Record).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).ralphTracker as ReturnType; + const tracker = (harness.ctx._session as Record).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).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).ralphTracker as ReturnType; + const tracker = (harness.ctx._session as Record).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).ralphTracker as ReturnType; + const tracker = (harness.ctx._session as Record).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).ralphTracker as ReturnType; + const tracker = (harness.ctx._session as Record).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).ralphTracker as ReturnType; + const tracker = (harness.ctx._session as Record).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).ralphTracker as ReturnType; + const tracker = (harness.ctx._session as Record).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); }); diff --git a/test/routes/respawn-routes.test.ts b/test/routes/respawn-routes.test.ts index d65fc888..c6867847 100644 --- a/test/routes/respawn-routes.test.ts +++ b/test/routes/respawn-routes.test.ts @@ -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 }) ); }); diff --git a/test/routes/scheduled-routes.test.ts b/test/routes/scheduled-routes.test.ts index 76e56d65..bd1f3b5e 100644 --- a/test/routes/scheduled-routes.test.ts +++ b/test/routes/scheduled-routes.test.ts @@ -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); }); }); diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index 4ef1c921..e48875b0 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -57,7 +57,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(); @@ -128,7 +128,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); }); @@ -181,7 +181,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); }); @@ -192,7 +192,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); }); @@ -219,7 +219,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); }); @@ -230,7 +230,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); }); @@ -241,7 +241,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); }); @@ -266,7 +266,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); }); @@ -292,7 +292,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); }); @@ -303,7 +303,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); }); @@ -340,7 +340,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); }); @@ -404,7 +404,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); }); @@ -540,7 +540,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); }); diff --git a/test/routes/system-routes.test.ts b/test/routes/system-routes.test.ts index 6a636d42..18a664d1 100644 --- a/test/routes/system-routes.test.ts +++ b/test/routes/system-routes.test.ts @@ -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); }); From 240dd6a1d3c943019145a363804c209b569fb991 Mon Sep 17 00:00:00 2001 From: Teigen Date: Mon, 25 May 2026 19:50:34 +0800 Subject: [PATCH 2/2] test(respawn): mock child_process so AI checker never spawns real processes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- test/respawn-controller.test.ts | 290 ++++++++++++++++---------------- 1 file changed, 149 insertions(+), 141 deletions(-) diff --git a/test/respawn-controller.test.ts b/test/respawn-controller.test.ts index 6b42f8f6..2ab87a5e 100644 --- a/test/respawn-controller.test.ts +++ b/test/respawn-controller.test.ts @@ -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(); + 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);