mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 })
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user