mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-01 13:09:42 +02:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
016c23934f | ||
|
|
896dc5b177 | ||
|
|
196646a7ff | ||
|
|
1b652ceb87 |
@@ -1,5 +1,14 @@
|
||||
# aicodeman
|
||||
|
||||
## 0.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Response viewer & terminal-stability improvements, plus test/error-handling hardening.
|
||||
- **Copy button on code blocks (#98):** Every fenced code block in the response viewer now has a one-click copy button pinned to its top-right, outside the `<pre>` scroll container so it stays put during horizontal scroll. ASCII diagrams keep their line-wrap toggle alongside it. Copy prefers the async Clipboard API and falls back to a hidden-textarea + `execCommand` path, so it works over plain HTTP (tunnel) too, with a brief ✓/✕ feedback state.
|
||||
- **Fix: stop auto-sending Ctrl+L from session-selection paths (#99):** A fast page refresh or SSE reconnect could fire two programmatic Ctrl+L (`\x0c`) sends within Claude Code 2.x's "clear conversation" confirmation window, silently wiping the active conversation. Removed the automatic Ctrl+L sends from `selectSession()`, `restoreTerminalSize()`, and the dead `sendPendingCtrlL()` path; redraws now rely on resize/SIGWINCH. User-initiated Ctrl+L still works. Trade-off: an occasional transient stale Ink frame right after refresh that self-heals on the next keypress — far preferable to silent data loss.
|
||||
- **Test & error-handling hardening (#97):** Repaired route-test harness error rendering via a dedicated `route-error-handler.ts`, and stopped the AI idle/plan checkers from spawning real processes during tests.
|
||||
|
||||
## 0.6.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -56,7 +56,7 @@ When user says "COM":
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
**Version**: 0.6.12 (must match `package.json`)
|
||||
**Version**: 0.7.0 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -181,7 +181,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
### API Routes
|
||||
|
||||
~128 handlers across 15 route files in `src/web/routes/`: system (36), sessions (27), orchestrator (10), cases (9), ralph (9), plan (8), respawn (7), files (5), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
~130 handlers across 15 route files in `src/web/routes/`: system (36), sessions (28), orchestrator (10), cases (9), ralph (9), plan (8), respawn (7), files (5), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
|
||||
## Adding Features
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.6.12",
|
||||
"version": "0.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "0.6.12",
|
||||
"version": "0.7.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.6.12",
|
||||
"version": "0.7.0",
|
||||
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
+81
-23
@@ -1128,22 +1128,40 @@ class CodemanApp {
|
||||
const DIAGRAM_CHAR = /[─-╿▀-▟]/;
|
||||
const tmpl = document.createElement('template');
|
||||
tmpl.innerHTML = html;
|
||||
// Every fenced code block gets a positioned wrapper with an action
|
||||
// toolbar pinned to its top-right corner. The toolbar lives OUTSIDE the
|
||||
// <pre> scroll container so its buttons stay put during horizontal
|
||||
// scroll. All blocks get a one-click copy button; ASCII diagrams keep
|
||||
// the additional line-wrap toggle.
|
||||
tmpl.content.querySelectorAll('pre > code').forEach((code) => {
|
||||
if (!DIAGRAM_CHAR.test(code.textContent || '')) return;
|
||||
const pre = code.parentElement;
|
||||
pre.classList.add('rv-diagram');
|
||||
const isDiagram = DIAGRAM_CHAR.test(code.textContent || '');
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'rv-diagram-wrap';
|
||||
wrap.className = isDiagram ? 'rv-code-wrap rv-diagram-wrap' : 'rv-code-wrap';
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'rv-wrap-toggle';
|
||||
btn.type = 'button';
|
||||
btn.setAttribute('aria-label', 'Toggle line wrapping');
|
||||
btn.setAttribute('title', 'Toggle line wrapping');
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'rv-code-actions';
|
||||
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.className = 'rv-copy-btn';
|
||||
copyBtn.type = 'button';
|
||||
copyBtn.setAttribute('aria-label', 'Copy code');
|
||||
copyBtn.setAttribute('title', 'Copy code');
|
||||
actions.appendChild(copyBtn);
|
||||
|
||||
if (isDiagram) {
|
||||
pre.classList.add('rv-diagram');
|
||||
const toggle = document.createElement('button');
|
||||
toggle.className = 'rv-wrap-toggle';
|
||||
toggle.type = 'button';
|
||||
toggle.setAttribute('aria-label', 'Toggle line wrapping');
|
||||
toggle.setAttribute('title', 'Toggle line wrapping');
|
||||
actions.appendChild(toggle);
|
||||
}
|
||||
|
||||
pre.parentNode.insertBefore(wrap, pre);
|
||||
wrap.appendChild(btn);
|
||||
wrap.appendChild(actions);
|
||||
wrap.appendChild(pre);
|
||||
});
|
||||
return tmpl.innerHTML;
|
||||
@@ -1162,7 +1180,23 @@ class CodemanApp {
|
||||
_bindResponseViewerInteractions(body) {
|
||||
if (!body || body.dataset.rvBound === '1') return;
|
||||
body.dataset.rvBound = '1';
|
||||
body.addEventListener('click', (ev) => {
|
||||
body.addEventListener('click', async (ev) => {
|
||||
// One-click copy: lift the raw source from the sibling <pre><code>.
|
||||
const copyBtn = ev.target.closest('.rv-copy-btn');
|
||||
if (copyBtn) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const code = copyBtn.closest('.rv-code-wrap')?.querySelector('pre code');
|
||||
const ok = code ? await this._copyText(code.textContent || '') : false;
|
||||
copyBtn.classList.remove('rv-copied', 'rv-copy-failed');
|
||||
copyBtn.classList.add(ok ? 'rv-copied' : 'rv-copy-failed');
|
||||
clearTimeout(copyBtn._resetTimer);
|
||||
copyBtn._resetTimer = setTimeout(() => {
|
||||
copyBtn.classList.remove('rv-copied', 'rv-copy-failed');
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = ev.target.closest('.rv-wrap-toggle');
|
||||
if (!btn) return;
|
||||
ev.preventDefault();
|
||||
@@ -1175,6 +1209,34 @@ class CodemanApp {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy text to the clipboard. Prefers the async Clipboard API (secure
|
||||
* contexts); falls back to a hidden-textarea + execCommand path so copy
|
||||
* still works over plain HTTP. Returns true on success.
|
||||
*/
|
||||
async _copyText(text) {
|
||||
if (!text) return false;
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch { /* secure-context write failed — try the legacy path */ }
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.setAttribute('readonly', '');
|
||||
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async toggleResponseViewer() {
|
||||
const viewer = document.getElementById('responseViewer');
|
||||
const backdrop = document.getElementById('responseViewerBackdrop');
|
||||
@@ -2643,19 +2705,15 @@ class CodemanApp {
|
||||
});
|
||||
}
|
||||
|
||||
// Fire-and-forget resize + Ctrl+L to force Ink redraw.
|
||||
// Tailed buffers accumulate stale CUP-positioned Ink frames that overlap
|
||||
// in the viewport (e.g. duplicate "bypass permissions" bars). Ctrl+L
|
||||
// triggers a full Ink redraw which overwrites all stale frame content.
|
||||
// sendResize may be a no-op if dimensions match, so Ctrl+L is essential.
|
||||
this.sendResize(sessionId).then(() => {
|
||||
if (selectGen !== this._selectGeneration) return;
|
||||
fetch(`/api/sessions/${sessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: '\x0c' })
|
||||
}).catch(() => {});
|
||||
});
|
||||
// Fire-and-forget resize to nudge Ink via SIGWINCH on real size changes.
|
||||
// Previously we also sent Ctrl+L (\x0c) here to force a full Ink redraw,
|
||||
// but Claude Code 2.x treats Ctrl+L as a two-step "clear conversation"
|
||||
// command — if a page refresh or SSE reconnect ran selectSession twice
|
||||
// within Claude's confirmation window, the second \x0c silently wiped the
|
||||
// conversation. Stale Ink frames in the tailed buffer are a cosmetic
|
||||
// annoyance that disappear on the user's next keypress; data loss is not
|
||||
// acceptable. Do NOT re-introduce Ctrl+L here.
|
||||
this.sendResize(sessionId);
|
||||
|
||||
// Defer secondary panel updates so they don't block the main thread
|
||||
// after terminal content is already visible.
|
||||
|
||||
@@ -8223,6 +8223,74 @@ kbd {
|
||||
content: '⤢';
|
||||
}
|
||||
|
||||
/* ── Code block one-click copy ──────────────────────────────────────────────
|
||||
Every fenced code block is wrapped in .rv-code-wrap with an action toolbar
|
||||
pinned to its top-right. Regular blocks get the relative positioning here;
|
||||
ASCII diagrams already get it from .rv-diagram-wrap (don't clobber its
|
||||
centering margins). */
|
||||
.rv-text .rv-code-wrap:not(.rv-diagram-wrap),
|
||||
.response-viewer-body .rv-code-wrap:not(.rv-diagram-wrap) {
|
||||
position: relative;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.rv-text .rv-code-wrap:not(.rv-diagram-wrap) > pre,
|
||||
.response-viewer-body .rv-code-wrap:not(.rv-diagram-wrap) > pre {
|
||||
margin: 0;
|
||||
padding-right: 44px; /* reserve room for the copy button */
|
||||
}
|
||||
|
||||
/* Diagrams carry two buttons (copy + wrap toggle) — widen the reserve. */
|
||||
.rv-text .rv-code-wrap.rv-diagram-wrap > pre.rv-diagram,
|
||||
.response-viewer-body .rv-code-wrap.rv-diagram-wrap > pre.rv-diagram {
|
||||
padding-right: 76px;
|
||||
}
|
||||
|
||||
.rv-code-actions {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Inside the flex toolbar the wrap toggle flows normally — drop its own pin. */
|
||||
.rv-code-actions .rv-wrap-toggle {
|
||||
position: static;
|
||||
top: auto;
|
||||
right: auto;
|
||||
}
|
||||
|
||||
.rv-copy-btn {
|
||||
width: 28px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 1px solid #2f2f45;
|
||||
border-radius: 5px;
|
||||
background: rgba(20, 20, 32, 0.92);
|
||||
color: #8b8b97;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.rv-copy-btn:hover,
|
||||
.rv-copy-btn:active {
|
||||
color: #e0e0ec;
|
||||
border-color: #4a4a65;
|
||||
}
|
||||
|
||||
.rv-copy-btn::before { content: '\2398'; } /* ⎘ — matches file-preview copy */
|
||||
.rv-copy-btn.rv-copied { color: #9ece6a; border-color: #3a5a3a; }
|
||||
.rv-copy-btn.rv-copied::before { content: '\2713'; } /* ✓ */
|
||||
.rv-copy-btn.rv-copy-failed { color: #f7768e; border-color: #5a3a3a; }
|
||||
.rv-copy-btn.rv-copy-failed::before { content: '\2715'; } /* ✕ */
|
||||
|
||||
.rv-text ul, .rv-text ol,
|
||||
.response-viewer-body > ul, .response-viewer-body > ol {
|
||||
margin: 0.6em 0;
|
||||
|
||||
@@ -1704,7 +1704,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
/**
|
||||
* Restore terminal size to match web UI dimensions.
|
||||
* Use this after mobile screen attachment has squeezed the terminal.
|
||||
* Sends resize to PTY and Ctrl+L to trigger Claude to redraw.
|
||||
* Sends only resize — SIGWINCH triggers Ink redraw on real dimension changes.
|
||||
* Ctrl+L is NOT sent here (Claude Code 2.x treats it as "clear conversation").
|
||||
*/
|
||||
async restoreTerminalSize() {
|
||||
if (!this.activeSessionId) {
|
||||
@@ -1719,16 +1720,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
try {
|
||||
// Send resize to restore proper dimensions (with minimum enforcement)
|
||||
// Send resize to restore proper dimensions (with minimum enforcement).
|
||||
// The PTY's SIGWINCH on real dim change is enough for Ink to redraw.
|
||||
await this.sendResize(this.activeSessionId);
|
||||
|
||||
// Send Ctrl+L to trigger Claude to redraw at new size
|
||||
await fetch(`/api/sessions/${this.activeSessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: '\x0c' }),
|
||||
});
|
||||
|
||||
this.showToast(`Terminal restored to ${dims.cols}x${dims.rows}`, 'success');
|
||||
} catch (err) {
|
||||
console.error('Failed to restore terminal size:', err);
|
||||
@@ -1736,26 +1731,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// Send Ctrl+L to fix display for newly created sessions once Claude is running
|
||||
sendPendingCtrlL(sessionId) {
|
||||
if (!this.pendingCtrlL || !this.pendingCtrlL.has(sessionId)) {
|
||||
return;
|
||||
}
|
||||
this.pendingCtrlL.delete(sessionId);
|
||||
|
||||
// Only send if this is the active session
|
||||
if (sessionId !== this.activeSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Send resize + Ctrl+L to fix the display (with minimum dimension enforcement)
|
||||
this.sendResize(sessionId).then(() => {
|
||||
fetch(`/api/sessions/${sessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: '\x0c' }),
|
||||
});
|
||||
});
|
||||
// Legacy hook for newly-created sessions; kept as a no-op so the SSE
|
||||
// idle/working handlers can still call it without conditional guards.
|
||||
//
|
||||
// Originally this sent Ctrl+L (\x0c) when a flagged session first reached
|
||||
// idle/working to scrub mux-init junk from the screen. Two problems:
|
||||
// 1. `pendingCtrlL` was never actually populated anywhere (dead path).
|
||||
// 2. Claude Code 2.x interprets Ctrl+L as a two-step "clear conversation"
|
||||
// command — sending it from background flows risked nuking the user's
|
||||
// conversation if it coincided with another Ctrl+L (e.g. from
|
||||
// selectSession on page reload).
|
||||
// If a per-session display-fix is ever needed again, do it via sendResize
|
||||
// or an Ink-safe control sequence, NOT \x0c.
|
||||
sendPendingCtrlL(_sessionId) {
|
||||
// intentionally empty
|
||||
},
|
||||
|
||||
async copyTerminal() {
|
||||
|
||||
@@ -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