refactor: codebase cleanup — dead code, regex helper, centralized constants, tests

- Remove unused validateTokenCounts/validateTokensAndCost exports and PlanPhase type alias
- Add execPattern() helper to eliminate 8 repetitive .lastIndex=0 + exec() loops
- Centralize 11 magic number constants into config/ai-defaults.ts and config/server-timing.ts
- Remove stale src/tui from tsconfig.json exclude
- Fix CLAUDE.md inaccuracies (session helpers, app.js line count, module count)
- Add 316 new tests: LRUMap (38), StaleExpirationMap (42), BufferAccumulator (33),
  respawn helpers (142), system-routes expansion (11→61)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-03-07 07:33:44 +01:00
co-authored by Claude Opus 4.6
parent b7b2555dc0
commit 0717cfbfec
30 changed files with 3351 additions and 138 deletions
+2 -2
View File
@@ -98,7 +98,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| Domain | Key files | Notes | | Domain | Key files | Notes |
|--------|-----------|-------| |--------|-----------|-------|
| **Entry** | `src/index.ts`, `src/cli.ts` | | | **Entry** | `src/index.ts`, `src/cli.ts` | |
| **Session** | `src/session.ts` ★, `src/session-manager.ts`, `src/session-auto-ops.ts`, `src/session-cli-builder.ts` | | | **Session** | `src/session.ts` ★, `src/session-manager.ts`, `src/session-auto-ops.ts`, `src/session-cli-builder.ts`, `src/session-lifecycle-log.ts`, `src/session-task-cache.ts` | |
| **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.ts` | | | **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.ts` | |
| **Respawn** | `src/respawn-controller.ts` ★ + 4 helpers (`-adaptive-timing`, `-health`, `-metrics`, `-patterns`) | Read `docs/respawn-state-machine.md` first | | **Respawn** | `src/respawn-controller.ts` ★ + 4 helpers (`-adaptive-timing`, `-health`, `-metrics`, `-patterns`) | Read `docs/respawn-state-machine.md` first |
| **Ralph** | `src/ralph-tracker.ts` ★, `src/ralph-loop.ts` + 5 helpers (`-config`, `-fix-plan-watcher`, `-plan-tracker`, `-stall-detector`, `-status-parser`) | Read `docs/ralph-wiggum-guide.md` first | | **Ralph** | `src/ralph-tracker.ts` ★, `src/ralph-loop.ts` + 5 helpers (`-config`, `-fix-plan-watcher`, `-plan-tracker`, `-stall-detector`, `-status-parser`) | Read `docs/ralph-wiggum-guide.md` first |
@@ -109,7 +109,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | | | **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | | | **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | |
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (12 route modules + barrel), `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | | | **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (12 route modules + barrel), `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
| **Frontend** | `src/web/public/app.js` ★ (~11.8K lines) + 10 JS modules (incl. `sw.js` service worker) | | | **Frontend** | `src/web/public/app.js` ★ (~12.1K lines) + 9 JS modules (incl. `sw.js` service worker) | |
| **Types** | `src/types/index.ts` → 13 domain files | See `@fileoverview` in index.ts | | **Types** | `src/types/index.ts` → 13 domain files | See `@fileoverview` in index.ts |
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in. ★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in.
+2 -4
View File
@@ -30,6 +30,7 @@ import { join } from 'node:path';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { getAugmentedPath } from './utils/claude-cli-resolver.js'; import { getAugmentedPath } from './utils/claude-cli-resolver.js';
import { ANSI_ESCAPE_PATTERN_SIMPLE } from './utils/index.js'; import { ANSI_ESCAPE_PATTERN_SIMPLE } from './utils/index.js';
import { AI_CHECK_MAX_BACKOFF_MS } from './config/ai-defaults.js';
// ========== Security Validation ========== // ========== Security Validation ==========
@@ -534,10 +535,7 @@ export abstract class AiCheckerBase<
// P1-005: Exponential backoff for errors // P1-005: Exponential backoff for errors
// Base cooldown * 2^(consecutiveErrors-1), capped at 5 minutes // Base cooldown * 2^(consecutiveErrors-1), capped at 5 minutes
const backoffMultiplier = Math.pow(2, this.consecutiveErrors - 1); const backoffMultiplier = Math.pow(2, this.consecutiveErrors - 1);
const backoffCooldownMs = Math.min( const backoffCooldownMs = Math.min(this.config.errorCooldownMs * backoffMultiplier, AI_CHECK_MAX_BACKOFF_MS);
this.config.errorCooldownMs * backoffMultiplier,
5 * 60 * 1000 // Max 5 minutes
);
this.log(`Exponential backoff: ${Math.round(backoffCooldownMs / 1000)}s (error #${this.consecutiveErrors})`); this.log(`Exponential backoff: ${Math.round(backoffCooldownMs / 1000)}s (error #${this.consecutiveErrors})`);
this.startCooldown(backoffCooldownMs); this.startCooldown(backoffCooldownMs);
} }
+12 -5
View File
@@ -30,7 +30,14 @@ import {
type AiCheckerResultBase, type AiCheckerResultBase,
type AiCheckerStateBase, type AiCheckerStateBase,
} from './ai-checker-base.js'; } from './ai-checker-base.js';
import { AI_CHECK_MODEL, AI_IDLE_CHECK_MAX_CONTEXT } from './config/ai-defaults.js'; import {
AI_CHECK_MODEL,
AI_IDLE_CHECK_MAX_CONTEXT,
AI_IDLE_CHECK_TIMEOUT_MS,
AI_IDLE_CHECK_COOLDOWN_MS,
AI_IDLE_CHECK_ERROR_COOLDOWN_MS,
AI_CHECK_MAX_CONSECUTIVE_ERRORS,
} from './config/ai-defaults.js';
// ========== Types ========== // ========== Types ==========
@@ -48,10 +55,10 @@ const DEFAULT_AI_CHECK_CONFIG: AiIdleCheckConfig = {
enabled: true, enabled: true,
model: AI_CHECK_MODEL, model: AI_CHECK_MODEL,
maxContextChars: AI_IDLE_CHECK_MAX_CONTEXT, maxContextChars: AI_IDLE_CHECK_MAX_CONTEXT,
checkTimeoutMs: 90000, checkTimeoutMs: AI_IDLE_CHECK_TIMEOUT_MS,
cooldownMs: 180000, cooldownMs: AI_IDLE_CHECK_COOLDOWN_MS,
errorCooldownMs: 60000, errorCooldownMs: AI_IDLE_CHECK_ERROR_COOLDOWN_MS,
maxConsecutiveErrors: 3, maxConsecutiveErrors: AI_CHECK_MAX_CONSECUTIVE_ERRORS,
}; };
/** Pattern to match IDLE or WORKING as the first word of output */ /** Pattern to match IDLE or WORKING as the first word of output */
+12 -5
View File
@@ -29,7 +29,14 @@ import {
type AiCheckerResultBase, type AiCheckerResultBase,
type AiCheckerStateBase, type AiCheckerStateBase,
} from './ai-checker-base.js'; } from './ai-checker-base.js';
import { AI_CHECK_MODEL, AI_PLAN_CHECK_MAX_CONTEXT } from './config/ai-defaults.js'; import {
AI_CHECK_MODEL,
AI_PLAN_CHECK_MAX_CONTEXT,
AI_PLAN_CHECK_TIMEOUT_MS,
AI_PLAN_CHECK_COOLDOWN_MS,
AI_PLAN_CHECK_ERROR_COOLDOWN_MS,
AI_CHECK_MAX_CONSECUTIVE_ERRORS,
} from './config/ai-defaults.js';
// ========== Types ========== // ========== Types ==========
@@ -47,10 +54,10 @@ const DEFAULT_PLAN_CHECK_CONFIG: AiPlanCheckConfig = {
enabled: true, enabled: true,
model: AI_CHECK_MODEL, model: AI_CHECK_MODEL,
maxContextChars: AI_PLAN_CHECK_MAX_CONTEXT, maxContextChars: AI_PLAN_CHECK_MAX_CONTEXT,
checkTimeoutMs: 60000, checkTimeoutMs: AI_PLAN_CHECK_TIMEOUT_MS,
cooldownMs: 30000, cooldownMs: AI_PLAN_CHECK_COOLDOWN_MS,
errorCooldownMs: 30000, errorCooldownMs: AI_PLAN_CHECK_ERROR_COOLDOWN_MS,
maxConsecutiveErrors: 3, maxConsecutiveErrors: AI_CHECK_MAX_CONSECUTIVE_ERRORS,
}; };
/** Pattern to match PLAN_MODE or NOT_PLAN_MODE as the first word(s) of output */ /** Pattern to match PLAN_MODE or NOT_PLAN_MODE as the first word(s) of output */
+44 -4
View File
@@ -1,13 +1,17 @@
/** /**
* @fileoverview Default model and context limits for AI-powered checkers. * @fileoverview Default model, context limits, and timing for AI-powered checkers.
* *
* Centralizes the AI model identifier and context window sizes used by * Centralizes the AI model identifier, context window sizes, and timeout/cooldown
* the idle checker, plan checker, respawn controller defaults, and * defaults used by the idle checker, plan checker, respawn controller defaults,
* respawn route fallbacks. Change the model here when upgrading. * and respawn route fallbacks. Change values here when tuning AI check behavior.
* *
* @module config/ai-defaults * @module config/ai-defaults
*/ */
// ============================================================================
// Model & Context
// ============================================================================
/** Default model for AI idle and plan checkers */ /** Default model for AI idle and plan checkers */
export const AI_CHECK_MODEL = 'claude-opus-4-5-20251101'; export const AI_CHECK_MODEL = 'claude-opus-4-5-20251101';
@@ -16,3 +20,39 @@ export const AI_IDLE_CHECK_MAX_CONTEXT = 16000;
/** Max context chars for plan checker (~2k tokens, plan mode UI is compact) */ /** Max context chars for plan checker (~2k tokens, plan mode UI is compact) */
export const AI_PLAN_CHECK_MAX_CONTEXT = 8000; export const AI_PLAN_CHECK_MAX_CONTEXT = 8000;
// ============================================================================
// AI Idle Checker Timing
// ============================================================================
/** Timeout for AI idle check (90 seconds — thinking can be slow) */
export const AI_IDLE_CHECK_TIMEOUT_MS = 90_000;
/** Cooldown after WORKING verdict (3 minutes) */
export const AI_IDLE_CHECK_COOLDOWN_MS = 180_000;
/** Cooldown after AI idle check error (1 minute) */
export const AI_IDLE_CHECK_ERROR_COOLDOWN_MS = 60_000;
// ============================================================================
// AI Plan Checker Timing
// ============================================================================
/** Timeout for AI plan check (60 seconds — allows time for thinking) */
export const AI_PLAN_CHECK_TIMEOUT_MS = 60_000;
/** Cooldown after NOT_PLAN_MODE verdict (30 seconds) */
export const AI_PLAN_CHECK_COOLDOWN_MS = 30_000;
/** Cooldown after AI plan check error (30 seconds) */
export const AI_PLAN_CHECK_ERROR_COOLDOWN_MS = 30_000;
// ============================================================================
// Shared AI Checker Limits
// ============================================================================
/** Max consecutive errors before disabling an AI checker */
export const AI_CHECK_MAX_CONSECUTIVE_ERRORS = 3;
/** Maximum exponential backoff cap for AI checker errors (5 minutes) */
export const AI_CHECK_MAX_BACKOFF_MS = 5 * 60 * 1000;
+13
View File
@@ -73,3 +73,16 @@ export const MAX_CONSECUTIVE_ERRORS = 5;
/** Error counter reset interval — forgives errors after quiet period (ms) */ /** Error counter reset interval — forgives errors after quiet period (ms) */
export const ERROR_RESET_MS = 60_000; export const ERROR_RESET_MS = 60_000;
// ============================================================================
// Common Cleanup Intervals
// ============================================================================
/** Standard 1-minute cleanup/check interval used by multiple subsystems (ms) */
export const CLEANUP_CHECK_INTERVAL_MS = 60_000;
/** Standard 1-hour max age for stale/completed data (ms) */
export const STALE_DATA_MAX_AGE_MS = 60 * 60 * 1000;
/** Standard 5-minute inactivity timeout for streams and caches (ms) */
export const INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000;
+3 -2
View File
@@ -16,6 +16,7 @@ import { existsSync, statSync, realpathSync } from 'node:fs';
import { resolve, relative, isAbsolute } from 'node:path'; import { resolve, relative, isAbsolute } from 'node:path';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { CLEANUP_CHECK_INTERVAL_MS, INACTIVITY_TIMEOUT_MS } from './config/server-timing.js';
// ========== Configuration Constants ========== // ========== Configuration Constants ==========
@@ -39,7 +40,7 @@ const MAX_STREAMS_PER_SESSION = 5;
* Inactivity timeout for streams (5 minutes). * Inactivity timeout for streams (5 minutes).
* Streams with no data for this long will be auto-closed. * Streams with no data for this long will be auto-closed.
*/ */
const STREAM_INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000; const STREAM_INACTIVITY_TIMEOUT_MS = INACTIVITY_TIMEOUT_MS;
// ========== Types ========== // ========== Types ==========
@@ -129,7 +130,7 @@ export class FileStreamManager extends EventEmitter {
constructor() { constructor() {
super(); super();
// Start cleanup timer for inactive streams // Start cleanup timer for inactive streams
this.cleanupTimer = setInterval(() => this.cleanupInactiveStreams(), 60 * 1000); this.cleanupTimer = setInterval(() => this.cleanupInactiveStreams(), CLEANUP_CHECK_INTERVAL_MS);
} }
// ========== Public Methods ========== // ========== Public Methods ==========
+3 -4
View File
@@ -9,6 +9,7 @@
import { existsSync, readFileSync } from 'node:fs'; import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { execPattern } from './utils/index.js';
// Pattern to extract completion phrase from CLAUDE.md // Pattern to extract completion phrase from CLAUDE.md
// Matches <promise>PHRASE</promise> with optional whitespace // Matches <promise>PHRASE</promise> with optional whitespace
@@ -83,9 +84,7 @@ export function parseRalphLoopConfigFromContent(content: string): RalphLoopConfi
}; };
// Parse each YAML line // Parse each YAML line
let match; execPattern(YAML_LINE_PATTERN, yaml, (match) => {
YAML_LINE_PATTERN.lastIndex = 0;
while ((match = YAML_LINE_PATTERN.exec(yaml)) !== null) {
const key = match[1].toLowerCase(); const key = match[1].toLowerCase();
const value = match[2].trim(); const value = match[2].trim();
@@ -103,7 +102,7 @@ export function parseRalphLoopConfigFromContent(content: string): RalphLoopConfi
config.completionPromise = value.toUpperCase(); config.completionPromise = value.toUpperCase();
break; break;
} }
} });
return config; return config;
} }
+2 -1
View File
@@ -10,6 +10,7 @@
*/ */
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { CLEANUP_CHECK_INTERVAL_MS } from './config/server-timing.js';
/** /**
* RalphStallDetector - Detects iteration stalls in the Ralph loop. * RalphStallDetector - Detects iteration stalls in the Ralph loop.
@@ -57,7 +58,7 @@ export class RalphStallDetector extends EventEmitter {
// Check every minute // Check every minute
this._iterationStallTimer = setInterval(() => { this._iterationStallTimer = setInterval(() => {
this.checkIterationStall(); this.checkIterationStall();
}, 60 * 1000); }, CLEANUP_CHECK_INTERVAL_MS);
} }
/** /**
+17 -21
View File
@@ -55,6 +55,7 @@ import {
stringSimilarity, stringSimilarity,
Debouncer, Debouncer,
CleanupManager, CleanupManager,
execPattern,
} from './utils/index.js'; } from './utils/index.js';
import { MAX_LINE_BUFFER_SIZE } from './config/buffer-limits.js'; import { MAX_LINE_BUFFER_SIZE } from './config/buffer-limits.js';
import { MAX_TODOS_PER_SESSION } from './config/map-limits.js'; import { MAX_TODOS_PER_SESSION } from './config/map-limits.js';
@@ -63,6 +64,7 @@ import type { EnhancedPlanTask, CheckpointReview } from './ralph-plan-tracker.js
import { RalphFixPlanWatcher, generateFixPlanMarkdown, importFixPlanMarkdown } from './ralph-fix-plan-watcher.js'; import { RalphFixPlanWatcher, generateFixPlanMarkdown, importFixPlanMarkdown } from './ralph-fix-plan-watcher.js';
import { RalphStallDetector } from './ralph-stall-detector.js'; import { RalphStallDetector } from './ralph-stall-detector.js';
import { RalphStatusParser } from './ralph-status-parser.js'; import { RalphStatusParser } from './ralph-status-parser.js';
import { STALE_DATA_MAX_AGE_MS, INACTIVITY_TIMEOUT_MS } from './config/server-timing.js';
// Re-export sub-module types for backward compatibility // Re-export sub-module types for backward compatibility
export type { EnhancedPlanTask, CheckpointReview } from './ralph-plan-tracker.js'; export type { EnhancedPlanTask, CheckpointReview } from './ralph-plan-tracker.js';
@@ -72,9 +74,9 @@ export type { EnhancedPlanTask, CheckpointReview } from './ralph-plan-tracker.js
/** /**
* Todo items older than this duration (in milliseconds) will be auto-expired. * Todo items older than this duration (in milliseconds) will be auto-expired.
* Default: 1 hour (60 * 60 * 1000) * Default: 1 hour
*/ */
const TODO_EXPIRY_MS = 60 * 60 * 1000; const TODO_EXPIRY_MS = STALE_DATA_MAX_AGE_MS;
/** /**
* Minimum interval between on-demand cleanup checks (in milliseconds). * Minimum interval between on-demand cleanup checks (in milliseconds).
@@ -88,7 +90,7 @@ const CLEANUP_THROTTLE_MS = 30 * 1000;
* Actively purges expired todos even when no terminal data is flowing. * Actively purges expired todos even when no terminal data is flowing.
* Default: 5 minutes * Default: 5 minutes
*/ */
const TODO_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; const TODO_CLEANUP_INTERVAL_MS = INACTIVITY_TIMEOUT_MS;
/** /**
* Similarity threshold for todo deduplication. * Similarity threshold for todo deduplication.
@@ -1666,35 +1668,32 @@ export class RalphTracker extends EventEmitter {
let match: RegExpExecArray | null; let match: RegExpExecArray | null;
if (hasCheckbox) { if (hasCheckbox) {
TODO_CHECKBOX_PATTERN.lastIndex = 0; execPattern(TODO_CHECKBOX_PATTERN, line, (match) => {
while ((match = TODO_CHECKBOX_PATTERN.exec(line)) !== null) {
const checked = match[1].toLowerCase() === 'x'; const checked = match[1].toLowerCase() === 'x';
const content = match[2].trim(); const content = match[2].trim();
const status: RalphTodoStatus = checked ? 'completed' : 'pending'; const status: RalphTodoStatus = checked ? 'completed' : 'pending';
this.upsertTodo(content, status); this.upsertTodo(content, status);
updated = true; updated = true;
} });
} }
if (hasTodoIndicator) { if (hasTodoIndicator) {
TODO_INDICATOR_PATTERN.lastIndex = 0; execPattern(TODO_INDICATOR_PATTERN, line, (match) => {
while ((match = TODO_INDICATOR_PATTERN.exec(line)) !== null) {
const icon = match[1]; const icon = match[1];
const content = match[2].trim(); const content = match[2].trim();
const status = this.iconToStatus(icon); const status = this.iconToStatus(icon);
this.upsertTodo(content, status); this.upsertTodo(content, status);
updated = true; updated = true;
} });
} }
if (hasStatus) { if (hasStatus) {
TODO_STATUS_PATTERN.lastIndex = 0; execPattern(TODO_STATUS_PATTERN, line, (match) => {
while ((match = TODO_STATUS_PATTERN.exec(line)) !== null) {
const content = match[1].trim(); const content = match[1].trim();
const status = match[2] as RalphTodoStatus; const status = match[2] as RalphTodoStatus;
this.upsertTodo(content, status); this.upsertTodo(content, status);
updated = true; updated = true;
} });
} }
if (hasNativeCheckbox) { if (hasNativeCheckbox) {
@@ -1715,8 +1714,7 @@ export class RalphTracker extends EventEmitter {
} }
if (hasCheckmark) { if (hasCheckmark) {
TODO_TASK_CREATED_PATTERN.lastIndex = 0; execPattern(TODO_TASK_CREATED_PATTERN, line, (match) => {
while ((match = TODO_TASK_CREATED_PATTERN.exec(line)) !== null) {
const taskNum = parseInt(match[1], 10); const taskNum = parseInt(match[1], 10);
const content = match[2].trim(); const content = match[2].trim();
if (content.length >= 5) { if (content.length >= 5) {
@@ -1725,10 +1723,9 @@ export class RalphTracker extends EventEmitter {
this.upsertTodo(content, 'pending'); this.upsertTodo(content, 'pending');
updated = true; updated = true;
} }
} });
TODO_TASK_SUMMARY_PATTERN.lastIndex = 0; execPattern(TODO_TASK_SUMMARY_PATTERN, line, (match) => {
while ((match = TODO_TASK_SUMMARY_PATTERN.exec(line)) !== null) {
const taskNum = parseInt(match[1], 10); const taskNum = parseInt(match[1], 10);
const content = match[2].trim(); const content = match[2].trim();
if (content.length >= 5) { if (content.length >= 5) {
@@ -1739,10 +1736,9 @@ export class RalphTracker extends EventEmitter {
this.upsertTodo(this._taskNumberToContent.get(taskNum) || content, 'pending'); this.upsertTodo(this._taskNumberToContent.get(taskNum) || content, 'pending');
updated = true; updated = true;
} }
} });
TODO_TASK_STATUS_PATTERN.lastIndex = 0; execPattern(TODO_TASK_STATUS_PATTERN, line, (match) => {
while ((match = TODO_TASK_STATUS_PATTERN.exec(line)) !== null) {
const taskNum = parseInt(match[1], 10); const taskNum = parseInt(match[1], 10);
const statusStr = match[2].trim(); const statusStr = match[2].trim();
const status: RalphTodoStatus = const status: RalphTodoStatus =
@@ -1752,7 +1748,7 @@ export class RalphTracker extends EventEmitter {
this.upsertTodo(content, status); this.upsertTodo(content, status);
updated = true; updated = true;
} }
} });
if (!updated) { if (!updated) {
TODO_PLAIN_CHECKMARK_PATTERN.lastIndex = 0; TODO_PLAIN_CHECKMARK_PATTERN.lastIndex = 0;
+13 -5
View File
@@ -62,7 +62,15 @@ import {
import { RespawnAdaptiveTiming } from './respawn-adaptive-timing.js'; import { RespawnAdaptiveTiming } from './respawn-adaptive-timing.js';
import { RespawnCycleMetricsTracker } from './respawn-metrics.js'; import { RespawnCycleMetricsTracker } from './respawn-metrics.js';
import { calculateHealthScore, shouldSkipClear, type HealthInputs } from './respawn-health.js'; import { calculateHealthScore, shouldSkipClear, type HealthInputs } from './respawn-health.js';
import { AI_CHECK_MODEL, AI_IDLE_CHECK_MAX_CONTEXT, AI_PLAN_CHECK_MAX_CONTEXT } from './config/ai-defaults.js'; import {
AI_CHECK_MODEL,
AI_IDLE_CHECK_MAX_CONTEXT,
AI_PLAN_CHECK_MAX_CONTEXT,
AI_IDLE_CHECK_TIMEOUT_MS,
AI_IDLE_CHECK_COOLDOWN_MS,
AI_PLAN_CHECK_TIMEOUT_MS,
AI_PLAN_CHECK_COOLDOWN_MS,
} from './config/ai-defaults.js';
import type { import type {
RespawnCycleMetrics, RespawnCycleMetrics,
RespawnAggregateMetrics, RespawnAggregateMetrics,
@@ -559,13 +567,13 @@ const DEFAULT_CONFIG: RespawnConfig = {
aiIdleCheckEnabled: true, // use AI to confirm idle state aiIdleCheckEnabled: true, // use AI to confirm idle state
aiIdleCheckModel: AI_CHECK_MODEL, aiIdleCheckModel: AI_CHECK_MODEL,
aiIdleCheckMaxContext: AI_IDLE_CHECK_MAX_CONTEXT, aiIdleCheckMaxContext: AI_IDLE_CHECK_MAX_CONTEXT,
aiIdleCheckTimeoutMs: 90000, // 90 seconds (thinking can be slow) aiIdleCheckTimeoutMs: AI_IDLE_CHECK_TIMEOUT_MS,
aiIdleCheckCooldownMs: 180000, // 3 minutes after WORKING verdict aiIdleCheckCooldownMs: AI_IDLE_CHECK_COOLDOWN_MS,
aiPlanCheckEnabled: true, // use AI to confirm plan mode before auto-accept aiPlanCheckEnabled: true, // use AI to confirm plan mode before auto-accept
aiPlanCheckModel: AI_CHECK_MODEL, aiPlanCheckModel: AI_CHECK_MODEL,
aiPlanCheckMaxContext: AI_PLAN_CHECK_MAX_CONTEXT, aiPlanCheckMaxContext: AI_PLAN_CHECK_MAX_CONTEXT,
aiPlanCheckTimeoutMs: 60000, // 60 seconds (thinking can be slow) aiPlanCheckTimeoutMs: AI_PLAN_CHECK_TIMEOUT_MS,
aiPlanCheckCooldownMs: 30000, // 30 seconds after NOT_PLAN_MODE aiPlanCheckCooldownMs: AI_PLAN_CHECK_COOLDOWN_MS,
stuckStateDetectionEnabled: true, // detect stuck states stuckStateDetectionEnabled: true, // detect stuck states
stuckStateWarningMs: 300000, // 5 minutes warning threshold stuckStateWarningMs: 300000, // 5 minutes warning threshold
stuckStateRecoveryMs: 600000, // 10 minutes recovery threshold stuckStateRecoveryMs: 600000, // 10 minutes recovery threshold
+2 -1
View File
@@ -22,6 +22,7 @@ import {
RunSummaryStats, RunSummaryStats,
createInitialRunSummaryStats, createInitialRunSummaryStats,
} from './types.js'; } from './types.js';
import { CLEANUP_CHECK_INTERVAL_MS } from './config/server-timing.js';
/** Maximum events to keep per session (FIFO trimming) */ /** Maximum events to keep per session (FIFO trimming) */
const MAX_EVENTS = 1000; const MAX_EVENTS = 1000;
@@ -36,7 +37,7 @@ const TOKEN_MILESTONE_INTERVAL = 50000;
const STATE_STUCK_WARNING_MS = 10 * 60 * 1000; // 10 minutes const STATE_STUCK_WARNING_MS = 10 * 60 * 1000; // 10 minutes
/** State stuck check interval (ms) */ /** State stuck check interval (ms) */
const STATE_STUCK_CHECK_INTERVAL = 60 * 1000; // 1 minute const STATE_STUCK_CHECK_INTERVAL = CLEANUP_CHECK_INTERVAL_MS;
/** /**
* Tracks events and statistics for a session's run summary. * Tracks events and statistics for a session's run summary.
+9 -7
View File
@@ -49,7 +49,13 @@ import { TaskTracker, type BackgroundTask } from './task-tracker.js';
import { RalphTracker } from './ralph-tracker.js'; import { RalphTracker } from './ralph-tracker.js';
import { BashToolParser } from './bash-tool-parser.js'; import { BashToolParser } from './bash-tool-parser.js';
import { BufferAccumulator } from './utils/buffer-accumulator.js'; import { BufferAccumulator } from './utils/buffer-accumulator.js';
import { ANSI_ESCAPE_PATTERN_FULL, TOKEN_PATTERN, SPINNER_PATTERN, MAX_SESSION_TOKENS } from './utils/index.js'; import {
ANSI_ESCAPE_PATTERN_FULL,
TOKEN_PATTERN,
SPINNER_PATTERN,
MAX_SESSION_TOKENS,
execPattern,
} from './utils/index.js';
import { import {
MAX_TERMINAL_BUFFER_SIZE, MAX_TERMINAL_BUFFER_SIZE,
TRIM_TERMINAL_TO as TERMINAL_BUFFER_TRIM_SIZE, TRIM_TERMINAL_TO as TERMINAL_BUFFER_TRIM_SIZE,
@@ -1692,16 +1698,12 @@ export class Session extends EventEmitter {
// Quick pre-check: skip expensive regex if no common tool patterns present // Quick pre-check: skip expensive regex if no common tool patterns present
if (!cleanLine.includes('(') || !cleanLine.includes(')')) return; if (!cleanLine.includes('(') || !cleanLine.includes(')')) return;
// Reset regex lastIndex for global pattern execPattern(TASK_TOOL_PATTERN, cleanLine, (match) => {
TASK_TOOL_PATTERN.lastIndex = 0;
let match;
while ((match = TASK_TOOL_PATTERN.exec(cleanLine)) !== null) {
const description = match[2].trim(); const description = match[2].trim();
if (description && description.length > 0) { if (description && description.length > 0) {
this._taskCache.add(Date.now(), description); this._taskCache.add(Date.now(), description);
} }
} });
} }
/** /**
+2 -1
View File
@@ -34,6 +34,7 @@ import { join, basename } from 'node:path';
import { execFile } from 'node:child_process'; import { execFile } from 'node:child_process';
import { readFile, readdir, stat as statAsync } from 'node:fs/promises'; import { readFile, readdir, stat as statAsync } from 'node:fs/promises';
import { PENDING_TOOL_CALL_TTL_MS, MAX_PENDING_TOOL_CALLS, MAX_TRACKED_AGENTS } from './config/map-limits.js'; import { PENDING_TOOL_CALL_TTL_MS, MAX_PENDING_TOOL_CALLS, MAX_TRACKED_AGENTS } from './config/map-limits.js';
import { STALE_DATA_MAX_AGE_MS } from './config/server-timing.js';
import { CleanupManager, KeyedDebouncer } from './utils/index.js'; import { CleanupManager, KeyedDebouncer } from './utils/index.js';
// ========== Types ========== // ========== Types ==========
@@ -151,7 +152,7 @@ const POLL_INTERVAL_MS = 1000; // Base poll interval (lightweight checks)
const FULL_SCAN_EVERY_N_POLLS = 5; // Full directory traversal every 5th poll (5s) const FULL_SCAN_EVERY_N_POLLS = 5; // Full directory traversal every 5th poll (5s)
const LIVENESS_CHECK_MS = 10000; // Check if subagent processes are still alive every 10s const LIVENESS_CHECK_MS = 10000; // Check if subagent processes are still alive every 10s
const FILE_ALIVE_THRESHOLD_MS = 30000; // File mtime within 30s = agent alive (primary check) const FILE_ALIVE_THRESHOLD_MS = 30000; // File mtime within 30s = agent alive (primary check)
const STALE_COMPLETED_MAX_AGE_MS = 60 * 60 * 1000; // Remove completed agents older than 1 hour const STALE_COMPLETED_MAX_AGE_MS = STALE_DATA_MAX_AGE_MS; // Remove completed agents older than 1 hour
const STALE_IDLE_MAX_AGE_MS = 4 * 60 * 60 * 1000; // Remove idle agents older than 4 hours const STALE_IDLE_MAX_AGE_MS = 4 * 60 * 60 * 1000; // Remove idle agents older than 4 hours
const STARTUP_MAX_FILE_AGE_MS = 4 * 60 * 60 * 1000; // Only load files modified in last 4 hours on startup const STARTUP_MAX_FILE_AGE_MS = 4 * 60 * 60 * 1000; // Only load files modified in last 4 hours on startup
+2 -1
View File
@@ -22,6 +22,7 @@
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { assertNever } from './utils/index.js'; import { assertNever } from './utils/index.js';
import { STALE_DATA_MAX_AGE_MS } from './config/server-timing.js';
// ========== Configuration Constants ========== // ========== Configuration Constants ==========
@@ -36,7 +37,7 @@ const MAX_COMPLETED_TASKS = 100;
* Entries older than this are cleaned up to prevent unbounded growth. * Entries older than this are cleaned up to prevent unbounded growth.
* Default: 1 hour * Default: 1 hour
*/ */
const PENDING_TOOL_USE_MAX_AGE_MS = 60 * 60 * 1000; const PENDING_TOOL_USE_MAX_AGE_MS = STALE_DATA_MAX_AGE_MS;
/** /**
* Maximum number of pending tool uses to allow. * Maximum number of pending tool uses to allow.
+2 -5
View File
@@ -6,7 +6,7 @@
* Key exports: * Key exports:
* - PlanItem — a single task with priority (P0/P1/P2), TDD phase, dependencies, verification criteria * - PlanItem — a single task with priority (P0/P1/P2), TDD phase, dependencies, verification criteria
* - PlanTaskStatus — 'pending' | 'in_progress' | 'completed' | 'failed' | 'blocked' * - PlanTaskStatus — 'pending' | 'in_progress' | 'completed' | 'failed' | 'blocked'
* - TddPhase / PlanPhase — 'setup' | 'test' | 'impl' | 'verify' | 'review' * - TddPhase — 'setup' | 'test' | 'impl' | 'verify' | 'review'
* *
* Used by PlanOrchestrator (`src/plan-orchestrator.ts`) and the plan API routes * Used by PlanOrchestrator (`src/plan-orchestrator.ts`) and the plan API routes
* (`src/web/routes/plan-routes.ts`). Served at `GET /api/sessions/:id/plan/tasks`. * (`src/web/routes/plan-routes.ts`). Served at `GET /api/sessions/:id/plan/tasks`.
@@ -21,9 +21,6 @@ export type PlanTaskStatus = 'pending' | 'in_progress' | 'completed' | 'failed'
/** TDD phase categories */ /** TDD phase categories */
export type TddPhase = 'setup' | 'test' | 'impl' | 'verify' | 'review'; export type TddPhase = 'setup' | 'test' | 'impl' | 'verify' | 'review';
/** Development phase in TDD cycle (alias for TddPhase) */
export type PlanPhase = TddPhase;
/** /**
* A single plan item for plan orchestration. * A single plan item for plan orchestration.
* Moved here from plan-orchestrator.ts to break circular dependency. * Moved here from plan-orchestrator.ts to break circular dependency.
@@ -42,7 +39,7 @@ export interface PlanItem {
lastError?: string; lastError?: string;
completedAt?: number; completedAt?: number;
complexity?: 'low' | 'medium' | 'high'; complexity?: 'low' | 'medium' | 'high';
tddPhase?: PlanPhase; tddPhase?: TddPhase;
pairedWith?: string; pairedWith?: string;
reviewChecklist?: string[]; reviewChecklist?: string[];
} }
+2 -1
View File
@@ -20,8 +20,9 @@ export {
createAnsiPatternSimple, createAnsiPatternSimple,
stripAnsi, stripAnsi,
SAFE_PATH_PATTERN, SAFE_PATH_PATTERN,
execPattern,
} from './regex-patterns.js'; } from './regex-patterns.js';
export { MAX_SESSION_TOKENS, validateTokenCounts, validateTokensAndCost } from './token-validation.js'; export { MAX_SESSION_TOKENS } from './token-validation.js';
export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js'; export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js';
export { assertNever } from './type-safety.js'; export { assertNever } from './type-safety.js';
export { wrapWithNice } from './nice-wrapper.js'; export { wrapWithNice } from './nice-wrapper.js';
+12
View File
@@ -79,3 +79,15 @@ export function stripAnsi(text: string): string {
export const SPINNER_PATTERN = /[⠋⠙⠹⠸⠼⠴⠦⠧]/; export const SPINNER_PATTERN = /[⠋⠙⠹⠸⠼⠴⠦⠧]/;
export const SAFE_PATH_PATTERN = /^[a-zA-Z0-9_/\-. ~]+$/; export const SAFE_PATH_PATTERN = /^[a-zA-Z0-9_/\-. ~]+$/;
/**
* Execute a global regex pattern against data, calling the callback for each match.
* Automatically resets lastIndex before execution.
*/
export function execPattern(pattern: RegExp, data: string, callback: (match: RegExpExecArray) => void): void {
pattern.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(data)) !== null) {
callback(match);
}
}
+1 -57
View File
@@ -1,7 +1,6 @@
/** /**
* @fileoverview Token validation utilities. * @fileoverview Token validation constants.
* *
* Centralizes token count validation logic used across the codebase.
* Claude's context window is ~200k tokens, so 500k is a generous upper bound. * Claude's context window is ~200k tokens, so 500k is a generous upper bound.
* *
* @module utils/token-validation * @module utils/token-validation
@@ -12,58 +11,3 @@
* Claude's context is ~200k, so 500k is a safe upper bound for validation. * Claude's context is ~200k, so 500k is a safe upper bound for validation.
*/ */
export const MAX_SESSION_TOKENS = 500_000; export const MAX_SESSION_TOKENS = 500_000;
/**
* Validates token counts are within acceptable bounds.
* Rejects negative values and values exceeding MAX_SESSION_TOKENS.
*
* @param inputTokens - Input token count to validate
* @param outputTokens - Output token count to validate
* @returns Object with isValid flag and optional error reason
*/
export function validateTokenCounts(inputTokens: number, outputTokens: number): { isValid: boolean; reason?: string } {
if (inputTokens < 0 || outputTokens < 0) {
return {
isValid: false,
reason: `Negative token values: input=${inputTokens}, output=${outputTokens}`,
};
}
if (inputTokens > MAX_SESSION_TOKENS || outputTokens > MAX_SESSION_TOKENS) {
return {
isValid: false,
reason: `Token values exceed maximum (${MAX_SESSION_TOKENS}): input=${inputTokens}, output=${outputTokens}`,
};
}
return { isValid: true };
}
/**
* Validates token counts and cost for restoration/persistence.
* Returns true if all values are valid.
*
* @param inputTokens - Input token count
* @param outputTokens - Output token count
* @param cost - Cost value (must be non-negative)
* @returns Object with isValid flag and optional error reason
*/
export function validateTokensAndCost(
inputTokens: number,
outputTokens: number,
cost: number
): { isValid: boolean; reason?: string } {
const tokenValidation = validateTokenCounts(inputTokens, outputTokens);
if (!tokenValidation.isValid) {
return tokenValidation;
}
if (cost < 0) {
return {
isValid: false,
reason: `Negative cost value: ${cost}`,
};
}
return { isValid: true };
}
+17 -5
View File
@@ -11,7 +11,15 @@ import { SseEvent } from '../sse-events.js';
import { findSessionOrFail, autoConfigureRalph } from '../route-helpers.js'; import { findSessionOrFail, autoConfigureRalph } from '../route-helpers.js';
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js'; import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js'; import { getLifecycleLog } from '../../session-lifecycle-log.js';
import { AI_CHECK_MODEL, AI_IDLE_CHECK_MAX_CONTEXT, AI_PLAN_CHECK_MAX_CONTEXT } from '../../config/ai-defaults.js'; import {
AI_CHECK_MODEL,
AI_IDLE_CHECK_MAX_CONTEXT,
AI_PLAN_CHECK_MAX_CONTEXT,
AI_IDLE_CHECK_TIMEOUT_MS,
AI_IDLE_CHECK_COOLDOWN_MS,
AI_PLAN_CHECK_TIMEOUT_MS,
AI_PLAN_CHECK_COOLDOWN_MS,
} from '../../config/ai-defaults.js';
/** No-op EventPort used to suppress broadcasts during pre-start ralph configuration. */ /** No-op EventPort used to suppress broadcasts during pre-start ralph configuration. */
const noopEventPort: EventPort = { const noopEventPort: EventPort = {
@@ -188,14 +196,18 @@ export function registerRespawnRoutes(
aiIdleCheckModel: config.aiIdleCheckModel ?? currentConfig?.aiIdleCheckModel ?? AI_CHECK_MODEL, aiIdleCheckModel: config.aiIdleCheckModel ?? currentConfig?.aiIdleCheckModel ?? AI_CHECK_MODEL,
aiIdleCheckMaxContext: aiIdleCheckMaxContext:
config.aiIdleCheckMaxContext ?? currentConfig?.aiIdleCheckMaxContext ?? AI_IDLE_CHECK_MAX_CONTEXT, config.aiIdleCheckMaxContext ?? currentConfig?.aiIdleCheckMaxContext ?? AI_IDLE_CHECK_MAX_CONTEXT,
aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs ?? currentConfig?.aiIdleCheckTimeoutMs ?? 90000, aiIdleCheckTimeoutMs:
aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs ?? currentConfig?.aiIdleCheckCooldownMs ?? 180000, config.aiIdleCheckTimeoutMs ?? currentConfig?.aiIdleCheckTimeoutMs ?? AI_IDLE_CHECK_TIMEOUT_MS,
aiIdleCheckCooldownMs:
config.aiIdleCheckCooldownMs ?? currentConfig?.aiIdleCheckCooldownMs ?? AI_IDLE_CHECK_COOLDOWN_MS,
aiPlanCheckEnabled: config.aiPlanCheckEnabled ?? currentConfig?.aiPlanCheckEnabled ?? true, aiPlanCheckEnabled: config.aiPlanCheckEnabled ?? currentConfig?.aiPlanCheckEnabled ?? true,
aiPlanCheckModel: config.aiPlanCheckModel ?? currentConfig?.aiPlanCheckModel ?? AI_CHECK_MODEL, aiPlanCheckModel: config.aiPlanCheckModel ?? currentConfig?.aiPlanCheckModel ?? AI_CHECK_MODEL,
aiPlanCheckMaxContext: aiPlanCheckMaxContext:
config.aiPlanCheckMaxContext ?? currentConfig?.aiPlanCheckMaxContext ?? AI_PLAN_CHECK_MAX_CONTEXT, config.aiPlanCheckMaxContext ?? currentConfig?.aiPlanCheckMaxContext ?? AI_PLAN_CHECK_MAX_CONTEXT,
aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs ?? currentConfig?.aiPlanCheckTimeoutMs ?? 60000, aiPlanCheckTimeoutMs:
aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs ?? currentConfig?.aiPlanCheckCooldownMs ?? 30000, config.aiPlanCheckTimeoutMs ?? currentConfig?.aiPlanCheckTimeoutMs ?? AI_PLAN_CHECK_TIMEOUT_MS,
aiPlanCheckCooldownMs:
config.aiPlanCheckCooldownMs ?? currentConfig?.aiPlanCheckCooldownMs ?? AI_PLAN_CHECK_COOLDOWN_MS,
durationMinutes: currentConfig?.durationMinutes, durationMinutes: currentConfig?.durationMinutes,
}; };
ctx.mux.updateRespawnConfig(id, merged); ctx.mux.updateRespawnConfig(id, merged);
+3 -2
View File
@@ -120,6 +120,7 @@ import {
ITERATION_PAUSE_MS, ITERATION_PAUSE_MS,
BATCH_FLUSH_THRESHOLD, BATCH_FLUSH_THRESHOLD,
STATS_COLLECTION_INTERVAL_MS, STATS_COLLECTION_INTERVAL_MS,
INACTIVITY_TIMEOUT_MS,
} from '../config/server-timing.js'; } from '../config/server-timing.js';
// DEC mode 2026 - Synchronized Output // DEC mode 2026 - Synchronized Output
@@ -230,7 +231,7 @@ export class WebServer extends EventEmitter {
// Adaptive batching: track rapid events to extend batch window (per-session) // Adaptive batching: track rapid events to extend batch window (per-session)
// StaleExpirationMap auto-cleans entries for sessions that stop generating output // StaleExpirationMap auto-cleans entries for sessions that stop generating output
private lastTerminalEventTime: StaleExpirationMap<string, number> = new StaleExpirationMap({ private lastTerminalEventTime: StaleExpirationMap<string, number> = new StaleExpirationMap({
ttlMs: 5 * 60 * 1000, // 5 minutes - auto-expire stale session timing data ttlMs: INACTIVITY_TIMEOUT_MS, // 5 minutes - auto-expire stale session timing data
refreshOnGet: false, // Don't refresh on reads, only on explicit sets refreshOnGet: false, // Don't refresh on reads, only on explicit sets
}); });
// Centralized cleanup for standalone timers (intervals + resettable timeouts) // Centralized cleanup for standalone timers (intervals + resettable timeouts)
@@ -2397,7 +2398,7 @@ export class WebServer extends EventEmitter {
() => { () => {
this.recordPeriodicTokenUsage(); this.recordPeriodicTokenUsage();
}, },
5 * 60 * 1000, INACTIVITY_TIMEOUT_MS,
{ description: 'periodic token recording' } { description: 'periodic token recording' }
); );
+279
View File
@@ -0,0 +1,279 @@
import { describe, it, expect, vi } from 'vitest';
import { BufferAccumulator } from '../src/utils/buffer-accumulator.js';
describe('BufferAccumulator', () => {
describe('construction', () => {
it('creates with simple size parameters', () => {
const buf = new BufferAccumulator(1000, 800);
expect(buf.isEmpty).toBe(true);
expect(buf.length).toBe(0);
expect(buf.value).toBe('');
});
it('creates with BufferConfig object', () => {
const buf = new BufferAccumulator({ maxSize: 1000, trimSize: 800 });
expect(buf.isEmpty).toBe(true);
});
it('creates with BufferConfig including onTrim', () => {
const onTrim = vi.fn();
const buf = new BufferAccumulator({ maxSize: 100, trimSize: 50, onTrim });
buf.append('x'.repeat(101));
expect(onTrim).toHaveBeenCalled();
});
});
describe('append', () => {
it('appends data and updates length', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('hello');
expect(buf.length).toBe(5);
expect(buf.isEmpty).toBe(false);
buf.append(' world');
expect(buf.length).toBe(11);
expect(buf.value).toBe('hello world');
});
it('ignores empty strings', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('');
expect(buf.length).toBe(0);
expect(buf.isEmpty).toBe(true);
});
it('handles multiple chunks efficiently', () => {
const buf = new BufferAccumulator(10000, 8000);
for (let i = 0; i < 100; i++) {
buf.append(`chunk${i}`);
}
expect(buf.length).toBeGreaterThan(0);
const value = buf.value;
expect(value).toContain('chunk0');
expect(value).toContain('chunk99');
});
});
describe('value', () => {
it('returns empty string when empty', () => {
const buf = new BufferAccumulator(1000, 800);
expect(buf.value).toBe('');
});
it('returns single chunk without joining', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('single');
expect(buf.value).toBe('single');
});
it('consolidates chunks on access', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('a');
buf.append('b');
buf.append('c');
expect(buf.value).toBe('abc');
// Access again should be same (consolidated)
expect(buf.value).toBe('abc');
});
});
describe('clear', () => {
it('resets buffer to empty', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('some data');
buf.clear();
expect(buf.isEmpty).toBe(true);
expect(buf.length).toBe(0);
expect(buf.value).toBe('');
});
});
describe('set', () => {
it('replaces buffer content', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('old data');
buf.set('new data');
expect(buf.value).toBe('new data');
expect(buf.length).toBe(8);
});
it('handles empty string', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('some data');
buf.set('');
expect(buf.isEmpty).toBe(true);
expect(buf.length).toBe(0);
});
});
describe('tail', () => {
it('returns last N characters', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('hello world');
expect(buf.tail(5)).toBe('world');
});
it('returns entire buffer when N >= length', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('short');
expect(buf.tail(100)).toBe('short');
});
it('returns empty string on empty buffer', () => {
const buf = new BufferAccumulator(1000, 800);
expect(buf.tail(5)).toBe('');
});
});
describe('endsWith', () => {
it('returns true when buffer ends with suffix', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('hello world');
expect(buf.endsWith('world')).toBe(true);
});
it('returns false when buffer does not end with suffix', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('hello world');
expect(buf.endsWith('hello')).toBe(false);
});
it('returns true for empty suffix', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('hello');
expect(buf.endsWith('')).toBe(true);
});
it('returns false when suffix is longer than buffer', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('hi');
expect(buf.endsWith('hello world')).toBe(false);
});
});
describe('contains', () => {
it('finds string pattern in buffer', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('hello world foo bar');
expect(buf.contains('world')).toBe(true);
expect(buf.contains('baz')).toBe(false);
});
it('finds regex pattern in buffer', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('hello world 123');
expect(buf.contains(/\d+/)).toBe(true);
expect(buf.contains(/[A-Z]{3}/)).toBe(false);
});
it('searches within last N characters when fromEnd provided', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('hello world');
expect(buf.contains('hello', 5)).toBe(false); // last 5 = 'world'
expect(buf.contains('world', 5)).toBe(true);
});
it('regex search with fromEnd', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('abc 123 xyz');
expect(buf.contains(/\d+/, 4)).toBe(false); // last 4 = ' xyz'
expect(buf.contains(/\d+/, 8)).toBe(true); // last 8 = '123 xyz'
});
});
describe('trimming', () => {
it('trims when maxSize is exceeded', () => {
const buf = new BufferAccumulator(100, 50);
buf.append('x'.repeat(60));
buf.append('y'.repeat(50)); // total 110, triggers trim
expect(buf.length).toBeLessThanOrEqual(100);
// After trim, should keep most recent data
expect(buf.value).toContain('y');
});
it('calls onTrim callback with trimmed byte count', () => {
const onTrim = vi.fn();
const buf = new BufferAccumulator({ maxSize: 100, trimSize: 50, onTrim });
buf.append('x'.repeat(101)); // triggers trim
expect(onTrim).toHaveBeenCalledTimes(1);
expect(onTrim).toHaveBeenCalledWith(expect.any(Number));
const trimmedBytes = onTrim.mock.calls[0][0] as number;
expect(trimmedBytes).toBeGreaterThan(0);
});
it('keeps most recent data after trim', () => {
const buf = new BufferAccumulator(100, 50);
buf.append('A'.repeat(60));
buf.append('B'.repeat(50)); // total 110, triggers trim to ~50 chars
const value = buf.value;
// The end should be B's
expect(value.endsWith('B')).toBe(true);
});
it('advances past first newline within 4KB to avoid mid-ANSI-escape', () => {
const buf = new BufferAccumulator(100, 80);
// Create content that when trimmed, has a newline near the start of the kept portion
const prefix = 'A'.repeat(30);
const newlineSection = 'X'.repeat(10) + '\n' + 'Y'.repeat(10);
const suffix = 'B'.repeat(60);
buf.append(prefix + newlineSection + suffix);
// After trimming, the buffer should start after the first newline in the kept portion
const value = buf.value;
// Should not start with partial X's before the newline
if (value.includes('\n')) {
// If the newline fell within the kept window, it should be skipped
expect(value.startsWith('X')).toBe(false);
}
});
it('handles trim with no newline in first 4KB', () => {
const buf = new BufferAccumulator(100, 50);
// No newlines at all
buf.append('A'.repeat(110));
// Should still trim without error
expect(buf.length).toBeLessThanOrEqual(100);
});
it('does not call onTrim when trimmedBytes is 0 or negative', () => {
const onTrim = vi.fn();
// trimSize equal to maxSize means nothing actually gets trimmed in bytes
// But this scenario is unusual; just test that onTrim is only called with positive values
const buf = new BufferAccumulator({ maxSize: 50, trimSize: 50, onTrim });
buf.append('x'.repeat(51)); // triggers trim, but trimSize == 50 so 1 byte trimmed
// The trim logic: full.length(51) - trimSize(50) = 1 > 0, so onTrim IS called
expect(onTrim).toHaveBeenCalledWith(expect.any(Number));
});
});
describe('edge cases', () => {
it('works with very small maxSize', () => {
const buf = new BufferAccumulator(5, 3);
buf.append('abcdef'); // 6 chars, triggers trim to 3
expect(buf.length).toBeLessThanOrEqual(5);
});
it('handles rapid appends', () => {
const buf = new BufferAccumulator(10000, 8000);
for (let i = 0; i < 1000; i++) {
buf.append('data');
}
expect(buf.length).toBeLessThanOrEqual(10000);
expect(buf.length).toBeGreaterThan(0);
});
it('set after trim works correctly', () => {
const buf = new BufferAccumulator(100, 50);
buf.append('x'.repeat(110)); // triggers trim
buf.set('fresh start');
expect(buf.value).toBe('fresh start');
expect(buf.length).toBe(11);
});
it('clear after trim works correctly', () => {
const buf = new BufferAccumulator(100, 50);
buf.append('x'.repeat(110)); // triggers trim
buf.clear();
expect(buf.isEmpty).toBe(true);
expect(buf.value).toBe('');
});
});
});
+356
View File
@@ -0,0 +1,356 @@
import { describe, it, expect, vi } from 'vitest';
import { LRUMap } from '../src/utils/lru-map.js';
describe('LRUMap', () => {
describe('construction', () => {
it('creates an empty map with maxSize', () => {
const map = new LRUMap<string, number>({ maxSize: 5 });
expect(map.size).toBe(0);
expect(map.maxEntries).toBe(5);
expect(map.freeSlots).toBe(5);
});
it('accepts an onEvict callback', () => {
const onEvict = vi.fn();
const map = new LRUMap<string, number>({ maxSize: 2, onEvict });
map.set('a', 1);
map.set('b', 2);
map.set('c', 3); // evicts 'a'
expect(onEvict).toHaveBeenCalledWith('a', 1);
});
});
describe('set/get', () => {
it('stores and retrieves values', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
map.set('a', 1);
map.set('b', 2);
expect(map.get('a')).toBe(1);
expect(map.get('b')).toBe(2);
});
it('returns undefined for missing keys', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
expect(map.get('nonexistent')).toBeUndefined();
});
it('overwrites existing key and refreshes position', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
map.set('a', 1);
map.set('b', 2);
map.set('a', 10); // refresh 'a' position
expect(map.get('a')).toBe(10);
expect(map.keysInOrder()).toEqual(['b', 'a']);
});
it('supports chaining on set', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
const result = map.set('a', 1).set('b', 2);
expect(result).toBe(map);
expect(map.size).toBe(2);
});
});
describe('has', () => {
it('returns true for existing keys', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
map.set('a', 1);
expect(map.has('a')).toBe(true);
});
it('returns false for missing keys', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
expect(map.has('a')).toBe(false);
});
it('does not refresh position', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
map.set('a', 1);
map.set('b', 2);
map.has('a'); // should NOT refresh 'a'
expect(map.keysInOrder()).toEqual(['a', 'b']);
});
});
describe('delete', () => {
it('removes an existing key', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
map.set('a', 1);
expect(map.delete('a')).toBe(true);
expect(map.has('a')).toBe(false);
expect(map.size).toBe(0);
});
it('returns false for non-existent key', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
expect(map.delete('nonexistent')).toBe(false);
});
it('updates newestKey when deleting the newest entry', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
map.set('a', 1);
map.set('b', 2);
map.set('c', 3);
map.delete('c'); // delete newest
expect(map.newest()).toEqual(['b', 2]);
});
it('handles deleting the only entry', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
map.set('a', 1);
map.delete('a');
expect(map.newest()).toBeUndefined();
expect(map.oldest()).toBeUndefined();
});
});
describe('clear', () => {
it('removes all entries', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
map.set('a', 1);
map.set('b', 2);
map.clear();
expect(map.size).toBe(0);
expect(map.newest()).toBeUndefined();
expect(map.oldest()).toBeUndefined();
});
});
describe('eviction', () => {
it('evicts oldest entry when exceeding maxSize', () => {
const map = new LRUMap<string, number>({ maxSize: 2 });
map.set('a', 1);
map.set('b', 2);
map.set('c', 3); // evicts 'a'
expect(map.has('a')).toBe(false);
expect(map.has('b')).toBe(true);
expect(map.has('c')).toBe(true);
expect(map.size).toBe(2);
});
it('evicts multiple entries to stay within maxSize', () => {
const map = new LRUMap<string, number>({ maxSize: 1 });
map.set('a', 1);
map.set('b', 2); // evicts 'a'
expect(map.size).toBe(1);
expect(map.has('a')).toBe(false);
expect(map.get('b')).toBe(2);
});
it('calls onEvict for each evicted entry', () => {
const onEvict = vi.fn();
const map = new LRUMap<string, number>({ maxSize: 2, onEvict });
map.set('a', 1);
map.set('b', 2);
map.set('c', 3); // evicts 'a'
map.set('d', 4); // evicts 'b'
expect(onEvict).toHaveBeenCalledTimes(2);
expect(onEvict).toHaveBeenCalledWith('a', 1);
expect(onEvict).toHaveBeenCalledWith('b', 2);
});
it('get() refreshes position and changes eviction order', () => {
const map = new LRUMap<string, number>({ maxSize: 2 });
map.set('a', 1);
map.set('b', 2);
map.get('a'); // refresh 'a' — now 'b' is oldest
map.set('c', 3); // evicts 'b', not 'a'
expect(map.has('a')).toBe(true);
expect(map.has('b')).toBe(false);
expect(map.has('c')).toBe(true);
});
});
describe('peek', () => {
it('returns value without refreshing position', () => {
const map = new LRUMap<string, number>({ maxSize: 2 });
map.set('a', 1);
map.set('b', 2);
expect(map.peek('a')).toBe(1);
// 'a' should still be oldest since peek doesn't refresh
map.set('c', 3); // should evict 'a'
expect(map.has('a')).toBe(false);
});
it('returns undefined for missing keys', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
expect(map.peek('nonexistent')).toBeUndefined();
});
});
describe('oldest/newest', () => {
it('returns undefined on empty map', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
expect(map.oldest()).toBeUndefined();
expect(map.newest()).toBeUndefined();
});
it('returns correct oldest and newest', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
map.set('a', 1);
map.set('b', 2);
map.set('c', 3);
expect(map.oldest()).toEqual(['a', 1]);
expect(map.newest()).toEqual(['c', 3]);
});
it('newest updates after get()', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
map.set('a', 1);
map.set('b', 2);
map.get('a'); // 'a' becomes newest
expect(map.newest()).toEqual(['a', 1]);
expect(map.oldest()).toEqual(['b', 2]);
});
it('newest updates after set() overwrites', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
map.set('a', 1);
map.set('b', 2);
map.set('a', 10); // 'a' refreshed to newest
expect(map.newest()).toEqual(['a', 10]);
});
});
describe('expireOlderThan', () => {
it('removes entries older than cutoff', () => {
const map = new LRUMap<string, { ts: number }>({ maxSize: 5 });
const now = Date.now();
map.set('old1', { ts: now - 10000 });
map.set('old2', { ts: now - 8000 });
map.set('new1', { ts: now - 1000 });
const evicted = map.expireOlderThan(5000, (v) => v.ts);
expect(evicted).toBe(2);
expect(map.size).toBe(1);
expect(map.has('new1')).toBe(true);
});
it('calls onEvict for expired entries', () => {
const onEvict = vi.fn();
const map = new LRUMap<string, { ts: number }>({ maxSize: 5, onEvict });
const now = Date.now();
map.set('old', { ts: now - 10000 });
map.set('new', { ts: now - 100 });
map.expireOlderThan(5000, (v) => v.ts);
expect(onEvict).toHaveBeenCalledTimes(1);
expect(onEvict).toHaveBeenCalledWith('old', { ts: now - 10000 });
});
it('returns 0 when nothing to expire', () => {
const map = new LRUMap<string, { ts: number }>({ maxSize: 5 });
const now = Date.now();
map.set('a', { ts: now });
expect(map.expireOlderThan(5000, (v) => v.ts)).toBe(0);
});
it('handles empty map', () => {
const map = new LRUMap<string, { ts: number }>({ maxSize: 5 });
expect(map.expireOlderThan(5000, (v) => v.ts)).toBe(0);
});
it('updates newestKey when all entries expired', () => {
const map = new LRUMap<string, { ts: number }>({ maxSize: 5 });
const now = Date.now();
map.set('a', { ts: now - 20000 });
map.set('b', { ts: now - 10000 });
map.expireOlderThan(5000, (v) => v.ts);
expect(map.newest()).toBeUndefined();
expect(map.size).toBe(0);
});
});
describe('keysInOrder/valuesInOrder', () => {
it('returns keys from oldest to newest', () => {
const map = new LRUMap<string, number>({ maxSize: 5 });
map.set('a', 1);
map.set('b', 2);
map.set('c', 3);
expect(map.keysInOrder()).toEqual(['a', 'b', 'c']);
});
it('returns values from oldest to newest', () => {
const map = new LRUMap<string, number>({ maxSize: 5 });
map.set('a', 1);
map.set('b', 2);
map.set('c', 3);
expect(map.valuesInOrder()).toEqual([1, 2, 3]);
});
it('reflects refreshed order', () => {
const map = new LRUMap<string, number>({ maxSize: 5 });
map.set('a', 1);
map.set('b', 2);
map.set('c', 3);
map.get('a'); // 'a' moves to end
expect(map.keysInOrder()).toEqual(['b', 'c', 'a']);
expect(map.valuesInOrder()).toEqual([2, 3, 1]);
});
it('returns empty arrays for empty map', () => {
const map = new LRUMap<string, number>({ maxSize: 5 });
expect(map.keysInOrder()).toEqual([]);
expect(map.valuesInOrder()).toEqual([]);
});
});
describe('maxEntries/freeSlots', () => {
it('freeSlots decreases as entries are added', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
expect(map.freeSlots).toBe(3);
map.set('a', 1);
expect(map.freeSlots).toBe(2);
map.set('b', 2);
expect(map.freeSlots).toBe(1);
map.set('c', 3);
expect(map.freeSlots).toBe(0);
});
it('freeSlots does not go below 0 after eviction', () => {
const map = new LRUMap<string, number>({ maxSize: 2 });
map.set('a', 1);
map.set('b', 2);
map.set('c', 3); // evicts 'a'
expect(map.freeSlots).toBe(0);
expect(map.size).toBe(2);
});
});
describe('iteration', () => {
it('iterates entries via for..of', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
map.set('a', 1);
map.set('b', 2);
const entries: [string, number][] = [];
for (const [k, v] of map) {
entries.push([k, v]);
}
expect(entries).toEqual([
['a', 1],
['b', 2],
]);
});
});
describe('edge cases', () => {
it('works with maxSize of 1', () => {
const map = new LRUMap<string, number>({ maxSize: 1 });
map.set('a', 1);
expect(map.get('a')).toBe(1);
map.set('b', 2);
expect(map.has('a')).toBe(false);
expect(map.get('b')).toBe(2);
expect(map.size).toBe(1);
});
it('handles non-string keys', () => {
const map = new LRUMap<number, string>({ maxSize: 3 });
map.set(1, 'one');
map.set(2, 'two');
expect(map.get(1)).toBe('one');
// get(1) refreshes key 1, so order is now [2, 1]
expect(map.keysInOrder()).toEqual([2, 1]);
});
});
});
+248
View File
@@ -0,0 +1,248 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { RespawnAdaptiveTiming, AdaptiveTimingConfig } from '../src/respawn-adaptive-timing.js';
describe('RespawnAdaptiveTiming', () => {
let timing: RespawnAdaptiveTiming;
const defaultConfig: AdaptiveTimingConfig = {
adaptiveMinConfirmMs: 5000,
adaptiveMaxConfirmMs: 30000,
};
beforeEach(() => {
timing = new RespawnAdaptiveTiming(defaultConfig);
});
describe('initial state', () => {
it('should start with default 10000ms completion confirm', () => {
expect(timing.getAdaptiveCompletionConfirmMs()).toBe(10000);
});
it('should start with empty timing history', () => {
const history = timing.getTimingHistory();
expect(history.recentIdleDetectionMs).toEqual([]);
expect(history.recentCycleDurationMs).toEqual([]);
expect(history.sampleCount).toBe(0);
expect(history.maxSamples).toBe(20);
});
});
describe('recordTimingData', () => {
it('should add samples to rolling windows', () => {
timing.recordTimingData(1000, 5000);
const history = timing.getTimingHistory();
expect(history.recentIdleDetectionMs).toEqual([1000]);
expect(history.recentCycleDurationMs).toEqual([5000]);
expect(history.sampleCount).toBe(1);
});
it('should accumulate multiple samples', () => {
timing.recordTimingData(1000, 5000);
timing.recordTimingData(2000, 6000);
timing.recordTimingData(3000, 7000);
const history = timing.getTimingHistory();
expect(history.recentIdleDetectionMs).toEqual([1000, 2000, 3000]);
expect(history.recentCycleDurationMs).toEqual([5000, 6000, 7000]);
expect(history.sampleCount).toBe(3);
});
it('should trim to maxSamples when exceeded', () => {
// Add 22 samples (exceeds maxSamples of 20)
for (let i = 1; i <= 22; i++) {
timing.recordTimingData(i * 100, i * 500);
}
const history = timing.getTimingHistory();
expect(history.recentIdleDetectionMs.length).toBe(20);
expect(history.recentCycleDurationMs.length).toBe(20);
// First two should have been shifted off
expect(history.recentIdleDetectionMs[0]).toBe(300); // 3rd sample
expect(history.recentCycleDurationMs[0]).toBe(1500);
expect(history.sampleCount).toBe(20);
});
it('should update lastUpdatedAt timestamp', () => {
const before = Date.now();
timing.recordTimingData(1000, 5000);
const after = Date.now();
const history = timing.getTimingHistory();
expect(history.lastUpdatedAt).toBeGreaterThanOrEqual(before);
expect(history.lastUpdatedAt).toBeLessThanOrEqual(after);
});
});
describe('adaptive timing calculation', () => {
it('should not recalculate with fewer than 5 samples', () => {
// Add 4 samples -- below the threshold
for (let i = 0; i < 4; i++) {
timing.recordTimingData(8000, 20000);
}
// Should still be the default
expect(timing.getAdaptiveCompletionConfirmMs()).toBe(10000);
});
it('should recalculate at exactly 5 samples', () => {
// All the same value = easy to predict
for (let i = 0; i < 5; i++) {
timing.recordTimingData(10000, 20000);
}
// P75 of [10000, 10000, 10000, 10000, 10000] = 10000
// With 20% buffer = 12000, clamped to [5000, 30000] = 12000
expect(timing.getAdaptiveCompletionConfirmMs()).toBe(12000);
});
it('should use 75th percentile with 20% buffer', () => {
// 5 sorted values: 1000, 2000, 3000, 4000, 5000
timing.recordTimingData(3000, 10000);
timing.recordTimingData(1000, 10000);
timing.recordTimingData(5000, 10000);
timing.recordTimingData(2000, 10000);
timing.recordTimingData(4000, 10000);
// Sorted: [1000, 2000, 3000, 4000, 5000]
// P75 index = floor(5 * 0.75) = 3 -> value = 4000
// With 20% buffer: 4000 * 1.2 = 4800
// Clamped to min 5000
expect(timing.getAdaptiveCompletionConfirmMs()).toBe(5000);
});
it('should clamp to minimum configured value', () => {
// Very small idle detection times
for (let i = 0; i < 5; i++) {
timing.recordTimingData(1000, 5000);
}
// P75 = 1000, with buffer = 1200, clamped to min 5000
expect(timing.getAdaptiveCompletionConfirmMs()).toBe(5000);
});
it('should clamp to maximum configured value', () => {
// Very large idle detection times
for (let i = 0; i < 5; i++) {
timing.recordTimingData(50000, 100000);
}
// P75 = 50000, with buffer = 60000, clamped to max 30000
expect(timing.getAdaptiveCompletionConfirmMs()).toBe(30000);
});
it('should handle varying values correctly', () => {
// 10 values, spread out
const values = [2000, 4000, 6000, 8000, 10000, 12000, 14000, 16000, 18000, 20000];
for (const v of values) {
timing.recordTimingData(v, v * 2);
}
// Sorted: [2000, 4000, 6000, 8000, 10000, 12000, 14000, 16000, 18000, 20000]
// P75 index = floor(10 * 0.75) = 7 -> value = 16000
// With 20% buffer: 16000 * 1.2 = 19200
// Clamped to [5000, 30000] = 19200
expect(timing.getAdaptiveCompletionConfirmMs()).toBe(19200);
});
it('should adapt as new data arrives', () => {
// Start with small values
for (let i = 0; i < 5; i++) {
timing.recordTimingData(3000, 10000);
}
// P75 = 3000, buffer = 3600, clamped to min 5000
expect(timing.getAdaptiveCompletionConfirmMs()).toBe(5000);
// Now add larger values that shift the P75 up
for (let i = 0; i < 10; i++) {
timing.recordTimingData(20000, 40000);
}
// P75 of mostly-20000 values = 20000, buffer = 24000
expect(timing.getAdaptiveCompletionConfirmMs()).toBe(24000);
});
});
describe('getTimingHistory', () => {
it('should return a new object each call (shallow copy)', () => {
timing.recordTimingData(1000, 5000);
const history1 = timing.getTimingHistory();
const history2 = timing.getTimingHistory();
// Different object references each time
expect(history1).not.toBe(history2);
// But equal content
expect(history1).toEqual(history2);
});
it('should allow overwriting top-level fields on the copy without affecting source', () => {
timing.recordTimingData(1000, 5000);
const copy = timing.getTimingHistory();
copy.sampleCount = 999;
expect(timing.getTimingHistory().sampleCount).toBe(1);
});
});
describe('reset', () => {
it('should clear all timing history', () => {
// Add some data
for (let i = 0; i < 10; i++) {
timing.recordTimingData(i * 1000, i * 5000);
}
timing.reset();
const history = timing.getTimingHistory();
expect(history.recentIdleDetectionMs).toEqual([]);
expect(history.recentCycleDurationMs).toEqual([]);
expect(history.adaptiveCompletionConfirmMs).toBe(10000);
expect(history.sampleCount).toBe(0);
expect(history.maxSamples).toBe(20);
});
it('should allow re-recording after reset', () => {
for (let i = 0; i < 5; i++) {
timing.recordTimingData(10000, 20000);
}
expect(timing.getAdaptiveCompletionConfirmMs()).toBe(12000);
timing.reset();
expect(timing.getAdaptiveCompletionConfirmMs()).toBe(10000);
// Record new data -- below 5 samples, should stay at default
timing.recordTimingData(5000, 10000);
expect(timing.getAdaptiveCompletionConfirmMs()).toBe(10000);
});
});
describe('edge cases', () => {
it('should handle zero idle detection times', () => {
for (let i = 0; i < 5; i++) {
timing.recordTimingData(0, 5000);
}
// P75 = 0, buffer = 0, clamped to min 5000
expect(timing.getAdaptiveCompletionConfirmMs()).toBe(5000);
});
it('should handle identical values', () => {
for (let i = 0; i < 20; i++) {
timing.recordTimingData(15000, 30000);
}
// P75 = 15000, buffer = 18000
expect(timing.getAdaptiveCompletionConfirmMs()).toBe(18000);
});
it('should handle config with very narrow bounds', () => {
const narrowTiming = new RespawnAdaptiveTiming({
adaptiveMinConfirmMs: 10000,
adaptiveMaxConfirmMs: 10000,
});
for (let i = 0; i < 5; i++) {
narrowTiming.recordTimingData(50000, 100000);
}
// Always clamped to 10000 regardless of data
expect(narrowTiming.getAdaptiveCompletionConfirmMs()).toBe(10000);
});
});
});
+517
View File
@@ -0,0 +1,517 @@
import { describe, it, expect } from 'vitest';
import { calculateHealthScore, shouldSkipClear, HealthInputs } from '../src/respawn-health.js';
import type { RespawnAggregateMetrics, CircuitBreakerStatus } from '../src/types/index.js';
/**
* Helper to create a default healthy HealthInputs object.
* Override specific fields as needed.
*/
function createHealthInputs(overrides: Partial<HealthInputs> = {}): HealthInputs {
return {
aggregateMetrics: {
totalCycles: 10,
successfulCycles: 10,
stuckRecoveryCycles: 0,
blockedCycles: 0,
errorCycles: 0,
avgCycleDurationMs: 5000,
avgIdleDetectionMs: 1000,
p90CycleDurationMs: 8000,
successRate: 100,
lastUpdatedAt: Date.now(),
},
circuitBreakerStatus: null,
iterationStallMetrics: null,
aiCheckerState: { status: 'ready', consecutiveErrors: 0 },
stuckRecoveryCount: 0,
maxStuckRecoveries: 5,
...overrides,
};
}
function createCircuitBreakerStatus(state: 'CLOSED' | 'HALF_OPEN' | 'OPEN'): CircuitBreakerStatus {
return {
state,
consecutiveNoProgress: state === 'OPEN' ? 5 : state === 'HALF_OPEN' ? 2 : 0,
consecutiveSameError: 0,
consecutiveTestsFailure: 0,
lastProgressIteration: 0,
reason: `State: ${state}`,
reasonCode: 'no_progress',
lastTransitionAt: Date.now(),
lastErrorMessage: null,
};
}
describe('calculateHealthScore', () => {
describe('overall score calculation', () => {
it('should return 100 for perfectly healthy system', () => {
const result = calculateHealthScore(createHealthInputs());
expect(result.score).toBe(100);
expect(result.status).toBe('excellent');
expect(result.components.cycleSuccess).toBe(100);
expect(result.components.circuitBreaker).toBe(100);
expect(result.components.iterationProgress).toBe(100);
expect(result.components.aiChecker).toBe(100);
expect(result.components.stuckRecovery).toBe(100);
});
it('should return excellent for score >= 90', () => {
const result = calculateHealthScore(createHealthInputs());
expect(result.score).toBeGreaterThanOrEqual(90);
expect(result.status).toBe('excellent');
});
it('should return good for score >= 70 and < 90', () => {
// AI checker disabled = 30 (weight 0.15), everything else 100
// Score = 100*0.35 + 100*0.2 + 100*0.2 + 30*0.15 + 100*0.1 = 35+20+20+4.5+10 = 89.5 -> 90
// That's still excellent. Let's use circuitBreaker half-open for 50 (weight 0.2):
// Score = 100*0.35 + 50*0.2 + 100*0.2 + 100*0.15 + 100*0.1 = 35+10+20+15+10 = 90
// Still excellent. Use a lower cycleSuccess:
const result = calculateHealthScore(
createHealthInputs({
aggregateMetrics: {
totalCycles: 10,
successfulCycles: 8,
stuckRecoveryCycles: 1,
blockedCycles: 1,
errorCycles: 0,
avgCycleDurationMs: 5000,
avgIdleDetectionMs: 1000,
p90CycleDurationMs: 8000,
successRate: 80,
lastUpdatedAt: Date.now(),
},
circuitBreakerStatus: createCircuitBreakerStatus('HALF_OPEN'),
})
);
// cycleSuccess=80, circuitBreaker=50, iterationProgress=100, aiChecker=100, stuckRecovery=100
// 80*0.35 + 50*0.2 + 100*0.2 + 100*0.15 + 100*0.1 = 28+10+20+15+10 = 83
expect(result.score).toBe(83);
expect(result.status).toBe('good');
});
it('should return degraded for score >= 50 and < 70', () => {
const result = calculateHealthScore(
createHealthInputs({
aggregateMetrics: {
totalCycles: 10,
successfulCycles: 5,
stuckRecoveryCycles: 2,
blockedCycles: 2,
errorCycles: 1,
avgCycleDurationMs: 5000,
avgIdleDetectionMs: 1000,
p90CycleDurationMs: 8000,
successRate: 50,
lastUpdatedAt: Date.now(),
},
circuitBreakerStatus: createCircuitBreakerStatus('HALF_OPEN'),
aiCheckerState: { status: 'cooldown', consecutiveErrors: 0 },
})
);
// cycleSuccess=50, circuitBreaker=50, iterationProgress=100, aiChecker=70, stuckRecovery=100
// 50*0.35 + 50*0.2 + 100*0.2 + 70*0.15 + 100*0.1 = 17.5+10+20+10.5+10 = 68
expect(result.score).toBe(68);
expect(result.status).toBe('degraded');
});
it('should return critical for score < 50', () => {
const result = calculateHealthScore(
createHealthInputs({
aggregateMetrics: {
totalCycles: 10,
successfulCycles: 0,
stuckRecoveryCycles: 3,
blockedCycles: 4,
errorCycles: 3,
avgCycleDurationMs: 5000,
avgIdleDetectionMs: 1000,
p90CycleDurationMs: 8000,
successRate: 0,
lastUpdatedAt: Date.now(),
},
circuitBreakerStatus: createCircuitBreakerStatus('OPEN'),
iterationStallMetrics: {
stallDurationMs: 600000,
warningThresholdMs: 300000,
criticalThresholdMs: 600000,
},
aiCheckerState: { status: 'error', consecutiveErrors: 3 },
stuckRecoveryCount: 5,
maxStuckRecoveries: 5,
})
);
// cycleSuccess=0, circuitBreaker=0, iterationProgress=0, aiChecker=50, stuckRecovery=0
// 0*0.35 + 0*0.2 + 0*0.2 + 50*0.15 + 0*0.1 = 7.5 -> 8
expect(result.score).toBeLessThan(50);
expect(result.status).toBe('critical');
});
});
describe('component: cycleSuccess', () => {
it('should return 100 when no cycles have been tracked', () => {
const result = calculateHealthScore(
createHealthInputs({
aggregateMetrics: {
totalCycles: 0,
successfulCycles: 0,
stuckRecoveryCycles: 0,
blockedCycles: 0,
errorCycles: 0,
avgCycleDurationMs: 0,
avgIdleDetectionMs: 0,
p90CycleDurationMs: 0,
successRate: 100,
lastUpdatedAt: Date.now(),
},
})
);
expect(result.components.cycleSuccess).toBe(100);
});
it('should use successRate from aggregate metrics', () => {
const result = calculateHealthScore(
createHealthInputs({
aggregateMetrics: {
totalCycles: 20,
successfulCycles: 15,
stuckRecoveryCycles: 3,
blockedCycles: 1,
errorCycles: 1,
avgCycleDurationMs: 5000,
avgIdleDetectionMs: 1000,
p90CycleDurationMs: 8000,
successRate: 75,
lastUpdatedAt: Date.now(),
},
})
);
expect(result.components.cycleSuccess).toBe(75);
});
});
describe('component: circuitBreaker', () => {
it('should return 100 when circuit breaker is null', () => {
const result = calculateHealthScore(createHealthInputs({ circuitBreakerStatus: null }));
expect(result.components.circuitBreaker).toBe(100);
});
it('should return 100 for CLOSED state', () => {
const result = calculateHealthScore(
createHealthInputs({ circuitBreakerStatus: createCircuitBreakerStatus('CLOSED') })
);
expect(result.components.circuitBreaker).toBe(100);
});
it('should return 50 for HALF_OPEN state', () => {
const result = calculateHealthScore(
createHealthInputs({ circuitBreakerStatus: createCircuitBreakerStatus('HALF_OPEN') })
);
expect(result.components.circuitBreaker).toBe(50);
});
it('should return 0 for OPEN state', () => {
const result = calculateHealthScore(
createHealthInputs({ circuitBreakerStatus: createCircuitBreakerStatus('OPEN') })
);
expect(result.components.circuitBreaker).toBe(0);
});
});
describe('component: iterationProgress', () => {
it('should return 100 when stall metrics are null', () => {
const result = calculateHealthScore(createHealthInputs({ iterationStallMetrics: null }));
expect(result.components.iterationProgress).toBe(100);
});
it('should return 100 when stall duration is below half the warning threshold', () => {
const result = calculateHealthScore(
createHealthInputs({
iterationStallMetrics: {
stallDurationMs: 10000,
warningThresholdMs: 300000,
criticalThresholdMs: 600000,
},
})
);
expect(result.components.iterationProgress).toBe(100);
});
it('should return 70 when stall duration is at half the warning threshold', () => {
const result = calculateHealthScore(
createHealthInputs({
iterationStallMetrics: {
stallDurationMs: 150000,
warningThresholdMs: 300000,
criticalThresholdMs: 600000,
},
})
);
expect(result.components.iterationProgress).toBe(70);
});
it('should return 30 when stall duration hits warning threshold', () => {
const result = calculateHealthScore(
createHealthInputs({
iterationStallMetrics: {
stallDurationMs: 300000,
warningThresholdMs: 300000,
criticalThresholdMs: 600000,
},
})
);
expect(result.components.iterationProgress).toBe(30);
});
it('should return 0 when stall duration hits critical threshold', () => {
const result = calculateHealthScore(
createHealthInputs({
iterationStallMetrics: {
stallDurationMs: 600000,
warningThresholdMs: 300000,
criticalThresholdMs: 600000,
},
})
);
expect(result.components.iterationProgress).toBe(0);
});
it('should return 0 when stall duration exceeds critical threshold', () => {
const result = calculateHealthScore(
createHealthInputs({
iterationStallMetrics: {
stallDurationMs: 900000,
warningThresholdMs: 300000,
criticalThresholdMs: 600000,
},
})
);
expect(result.components.iterationProgress).toBe(0);
});
});
describe('component: aiChecker', () => {
it('should return 100 for ready status with no errors', () => {
const result = calculateHealthScore(
createHealthInputs({ aiCheckerState: { status: 'ready', consecutiveErrors: 0 } })
);
expect(result.components.aiChecker).toBe(100);
});
it('should return 30 for disabled status', () => {
const result = calculateHealthScore(
createHealthInputs({ aiCheckerState: { status: 'disabled', consecutiveErrors: 0 } })
);
expect(result.components.aiChecker).toBe(30);
});
it('should return 70 for cooldown status', () => {
const result = calculateHealthScore(
createHealthInputs({ aiCheckerState: { status: 'cooldown', consecutiveErrors: 0 } })
);
expect(result.components.aiChecker).toBe(70);
});
it('should return 50 for ready status with consecutive errors', () => {
const result = calculateHealthScore(
createHealthInputs({ aiCheckerState: { status: 'ready', consecutiveErrors: 2 } })
);
expect(result.components.aiChecker).toBe(50);
});
});
describe('component: stuckRecovery', () => {
it('should return 100 when no stuck recoveries', () => {
const result = calculateHealthScore(createHealthInputs({ stuckRecoveryCount: 0, maxStuckRecoveries: 5 }));
expect(result.components.stuckRecovery).toBe(100);
});
it('should return 0 when stuck recoveries reach max', () => {
const result = calculateHealthScore(createHealthInputs({ stuckRecoveryCount: 5, maxStuckRecoveries: 5 }));
expect(result.components.stuckRecovery).toBe(0);
});
it('should return proportional score for partial recoveries', () => {
const result = calculateHealthScore(createHealthInputs({ stuckRecoveryCount: 2, maxStuckRecoveries: 5 }));
// 100 - (2/5) * 100 = 60
expect(result.components.stuckRecovery).toBe(60);
});
it('should return 0 when stuck recoveries exceed max', () => {
const result = calculateHealthScore(createHealthInputs({ stuckRecoveryCount: 10, maxStuckRecoveries: 5 }));
expect(result.components.stuckRecovery).toBe(0);
});
});
describe('recommendations', () => {
it('should recommend no action for healthy system', () => {
const result = calculateHealthScore(createHealthInputs());
expect(result.recommendations).toEqual(['System is healthy. No action needed.']);
});
it('should recommend checking cycle success when low', () => {
const result = calculateHealthScore(
createHealthInputs({
aggregateMetrics: {
totalCycles: 10,
successfulCycles: 5,
stuckRecoveryCycles: 3,
blockedCycles: 1,
errorCycles: 1,
avgCycleDurationMs: 5000,
avgIdleDetectionMs: 1000,
p90CycleDurationMs: 8000,
successRate: 50,
lastUpdatedAt: Date.now(),
},
})
);
expect(result.recommendations).toContainEqual(expect.stringContaining('Cycle success rate is low'));
});
it('should recommend reviewing circuit breaker when open', () => {
const result = calculateHealthScore(
createHealthInputs({
circuitBreakerStatus: createCircuitBreakerStatus('OPEN'),
})
);
expect(result.recommendations).toContainEqual(expect.stringContaining('Circuit breaker is open'));
});
it('should recommend checking iteration when stalled', () => {
const result = calculateHealthScore(
createHealthInputs({
iterationStallMetrics: {
stallDurationMs: 400000,
warningThresholdMs: 300000,
criticalThresholdMs: 600000,
},
})
);
expect(result.recommendations).toContainEqual(expect.stringContaining('Iteration progress has stalled'));
});
it('should recommend checking AI checker when disabled', () => {
const result = calculateHealthScore(
createHealthInputs({
aiCheckerState: { status: 'disabled', consecutiveErrors: 3 },
})
);
// aiChecker score = 30 (disabled), which is < 50 -> triggers recommendation
expect(result.recommendations).toContainEqual(expect.stringContaining('AI idle checker has errors'));
});
it('should recommend adjusting timeouts when stuck recoveries are high', () => {
const result = calculateHealthScore(
createHealthInputs({
stuckRecoveryCount: 4,
maxStuckRecoveries: 5,
})
);
expect(result.recommendations).toContainEqual(expect.stringContaining('Multiple stuck-state recoveries'));
});
it('should include multiple recommendations when multiple components are degraded', () => {
const result = calculateHealthScore(
createHealthInputs({
aggregateMetrics: {
totalCycles: 10,
successfulCycles: 3,
stuckRecoveryCycles: 3,
blockedCycles: 2,
errorCycles: 2,
avgCycleDurationMs: 5000,
avgIdleDetectionMs: 1000,
p90CycleDurationMs: 8000,
successRate: 30,
lastUpdatedAt: Date.now(),
},
circuitBreakerStatus: createCircuitBreakerStatus('OPEN'),
stuckRecoveryCount: 4,
maxStuckRecoveries: 5,
})
);
expect(result.recommendations.length).toBeGreaterThanOrEqual(3);
});
});
describe('summary', () => {
it('should include score in summary', () => {
const result = calculateHealthScore(createHealthInputs());
expect(result.summary).toContain('100/100');
});
it('should describe excellent status', () => {
const result = calculateHealthScore(createHealthInputs());
expect(result.summary).toContain('excellently');
});
it('should describe critical status with lowest component', () => {
const result = calculateHealthScore(
createHealthInputs({
aggregateMetrics: {
totalCycles: 10,
successfulCycles: 0,
stuckRecoveryCycles: 5,
blockedCycles: 3,
errorCycles: 2,
avgCycleDurationMs: 5000,
avgIdleDetectionMs: 1000,
p90CycleDurationMs: 8000,
successRate: 0,
lastUpdatedAt: Date.now(),
},
circuitBreakerStatus: createCircuitBreakerStatus('OPEN'),
})
);
expect(result.summary).toContain('critical');
});
it('should include calculatedAt timestamp', () => {
const before = Date.now();
const result = calculateHealthScore(createHealthInputs());
const after = Date.now();
expect(result.calculatedAt).toBeGreaterThanOrEqual(before);
expect(result.calculatedAt).toBeLessThanOrEqual(after);
});
});
});
describe('shouldSkipClear', () => {
it('should not skip when token count is 0 (unknown)', () => {
expect(shouldSkipClear(0, 50, 200000)).toBe(false);
});
it('should skip when usage is below threshold', () => {
// 10000 / 200000 = 5% < 50%
expect(shouldSkipClear(10000, 50, 200000)).toBe(true);
});
it('should not skip when usage is above threshold', () => {
// 150000 / 200000 = 75% > 50%
expect(shouldSkipClear(150000, 50, 200000)).toBe(false);
});
it('should not skip when usage equals threshold', () => {
// 100000 / 200000 = 50% == 50% -> not less than, so false
expect(shouldSkipClear(100000, 50, 200000)).toBe(false);
});
it('should handle edge case of very small threshold', () => {
// 1000 / 200000 = 0.5% < 1%
expect(shouldSkipClear(1000, 1, 200000)).toBe(true);
});
it('should handle 100% threshold (always skip)', () => {
// 190000 / 200000 = 95% < 100%
expect(shouldSkipClear(190000, 100, 200000)).toBe(true);
});
it('should handle 0% threshold (never skip except when unknown)', () => {
// Any positive count / total > 0% so not < 0%
expect(shouldSkipClear(1, 0, 200000)).toBe(false);
});
});
+332
View File
@@ -0,0 +1,332 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { RespawnCycleMetricsTracker } from '../src/respawn-metrics.js';
import type { CycleOutcome } from '../src/types/index.js';
describe('RespawnCycleMetricsTracker', () => {
let tracker: RespawnCycleMetricsTracker;
beforeEach(() => {
tracker = new RespawnCycleMetricsTracker();
});
describe('initial state', () => {
it('should have no current cycle', () => {
expect(tracker.getCurrentCycle()).toBeNull();
});
it('should have empty recent metrics', () => {
expect(tracker.getRecent()).toEqual([]);
});
it('should have zeroed aggregate metrics', () => {
const agg = tracker.getAggregate();
expect(agg.totalCycles).toBe(0);
expect(agg.successfulCycles).toBe(0);
expect(agg.stuckRecoveryCycles).toBe(0);
expect(agg.blockedCycles).toBe(0);
expect(agg.errorCycles).toBe(0);
expect(agg.avgCycleDurationMs).toBe(0);
expect(agg.avgIdleDetectionMs).toBe(0);
expect(agg.p90CycleDurationMs).toBe(0);
expect(agg.successRate).toBe(100);
});
});
describe('startCycle', () => {
it('should create a current cycle with correct fields', () => {
const idleDetectionStartTime = Date.now() - 1500;
tracker.startCycle('session-1', 1, 'completion_message', idleDetectionStartTime, 50000, 10000);
const cycle = tracker.getCurrentCycle();
expect(cycle).not.toBeNull();
expect(cycle!.cycleId).toBe('session-1:1');
expect(cycle!.sessionId).toBe('session-1');
expect(cycle!.cycleNumber).toBe(1);
expect(cycle!.idleReason).toBe('completion_message');
expect(cycle!.stepsCompleted).toEqual([]);
expect(cycle!.clearSkipped).toBe(false);
expect(cycle!.tokenCountAtStart).toBe(50000);
expect(cycle!.completionConfirmMsUsed).toBe(10000);
// idleDetectionMs should be approximately 1500 (within margin)
expect(cycle!.idleDetectionMs).toBeGreaterThanOrEqual(1400);
expect(cycle!.idleDetectionMs).toBeLessThanOrEqual(2000);
});
it('should overwrite a previous current cycle', () => {
tracker.startCycle('session-1', 1, 'reason1', Date.now(), 10000, 5000);
tracker.startCycle('session-1', 2, 'reason2', Date.now(), 20000, 6000);
const cycle = tracker.getCurrentCycle();
expect(cycle!.cycleNumber).toBe(2);
expect(cycle!.idleReason).toBe('reason2');
});
});
describe('recordStep', () => {
it('should add steps to current cycle', () => {
tracker.startCycle('session-1', 1, 'test', Date.now(), 0, 10000);
tracker.recordStep('update');
tracker.recordStep('clear');
tracker.recordStep('init');
const cycle = tracker.getCurrentCycle();
expect(cycle!.stepsCompleted).toEqual(['update', 'clear', 'init']);
});
it('should be a no-op when no cycle is in progress', () => {
// Should not throw
tracker.recordStep('update');
expect(tracker.getCurrentCycle()).toBeNull();
});
});
describe('markClearSkipped', () => {
it('should set clearSkipped flag on current cycle', () => {
tracker.startCycle('session-1', 1, 'test', Date.now(), 0, 10000);
tracker.markClearSkipped();
const cycle = tracker.getCurrentCycle();
expect(cycle!.clearSkipped).toBe(true);
});
it('should be a no-op when no cycle is in progress', () => {
// Should not throw
tracker.markClearSkipped();
expect(tracker.getCurrentCycle()).toBeNull();
});
});
describe('completeCycle', () => {
it('should return null when no cycle is in progress', () => {
const result = tracker.completeCycle('success', 60000);
expect(result).toBeNull();
});
it('should return completed metrics with correct fields', () => {
tracker.startCycle('session-1', 1, 'completion_message', Date.now(), 50000, 10000);
tracker.recordStep('update');
tracker.recordStep('clear');
const result = tracker.completeCycle('success', 60000);
expect(result).not.toBeNull();
expect(result!.outcome).toBe('success');
expect(result!.tokenCountAtEnd).toBe(60000);
expect(result!.completedAt).toBeGreaterThan(0);
expect(result!.durationMs).toBeGreaterThanOrEqual(0);
expect(result!.stepsCompleted).toEqual(['update', 'clear']);
});
it('should include error message for error outcome', () => {
tracker.startCycle('session-1', 1, 'test', Date.now(), 0, 10000);
const result = tracker.completeCycle('error', 0, 'Something went wrong');
expect(result!.outcome).toBe('error');
expect(result!.errorMessage).toBe('Something went wrong');
});
it('should clear current cycle after completion', () => {
tracker.startCycle('session-1', 1, 'test', Date.now(), 0, 10000);
tracker.completeCycle('success', 0);
expect(tracker.getCurrentCycle()).toBeNull();
});
it('should add to recent metrics', () => {
tracker.startCycle('session-1', 1, 'test', Date.now(), 0, 10000);
tracker.completeCycle('success', 0);
const recent = tracker.getRecent();
expect(recent.length).toBe(1);
expect(recent[0].outcome).toBe('success');
});
});
describe('aggregate metrics updates', () => {
function completeOneCycle(outcome: CycleOutcome): void {
tracker.startCycle('session-1', tracker.getAggregate().totalCycles + 1, 'test', Date.now(), 0, 10000);
tracker.completeCycle(outcome, 0);
}
it('should count successful cycles', () => {
completeOneCycle('success');
completeOneCycle('success');
const agg = tracker.getAggregate();
expect(agg.totalCycles).toBe(2);
expect(agg.successfulCycles).toBe(2);
expect(agg.successRate).toBe(100);
});
it('should count stuck_recovery cycles', () => {
completeOneCycle('stuck_recovery');
const agg = tracker.getAggregate();
expect(agg.totalCycles).toBe(1);
expect(agg.stuckRecoveryCycles).toBe(1);
expect(agg.successRate).toBe(0);
});
it('should count blocked cycles', () => {
completeOneCycle('blocked');
const agg = tracker.getAggregate();
expect(agg.totalCycles).toBe(1);
expect(agg.blockedCycles).toBe(1);
});
it('should count error cycles', () => {
completeOneCycle('error');
const agg = tracker.getAggregate();
expect(agg.totalCycles).toBe(1);
expect(agg.errorCycles).toBe(1);
});
it('should count cancelled cycles in total but no specific category', () => {
completeOneCycle('cancelled');
const agg = tracker.getAggregate();
expect(agg.totalCycles).toBe(1);
expect(agg.successfulCycles).toBe(0);
expect(agg.stuckRecoveryCycles).toBe(0);
expect(agg.blockedCycles).toBe(0);
expect(agg.errorCycles).toBe(0);
});
it('should calculate success rate correctly', () => {
completeOneCycle('success');
completeOneCycle('success');
completeOneCycle('error');
completeOneCycle('success');
const agg = tracker.getAggregate();
expect(agg.totalCycles).toBe(4);
expect(agg.successfulCycles).toBe(3);
expect(agg.successRate).toBe(75);
});
it('should update lastUpdatedAt timestamp', () => {
const before = Date.now();
completeOneCycle('success');
const after = Date.now();
const agg = tracker.getAggregate();
expect(agg.lastUpdatedAt).toBeGreaterThanOrEqual(before);
expect(agg.lastUpdatedAt).toBeLessThanOrEqual(after);
});
});
describe('getRecent', () => {
it('should return newest first', () => {
for (let i = 1; i <= 5; i++) {
tracker.startCycle('session-1', i, `reason-${i}`, Date.now(), 0, 10000);
tracker.completeCycle('success', 0);
}
const recent = tracker.getRecent();
expect(recent.length).toBe(5);
expect(recent[0].cycleNumber).toBe(5);
expect(recent[4].cycleNumber).toBe(1);
});
it('should respect limit parameter', () => {
for (let i = 1; i <= 10; i++) {
tracker.startCycle('session-1', i, 'test', Date.now(), 0, 10000);
tracker.completeCycle('success', 0);
}
const recent = tracker.getRecent(3);
expect(recent.length).toBe(3);
expect(recent[0].cycleNumber).toBe(10);
});
it('should default to 20 items', () => {
for (let i = 1; i <= 25; i++) {
tracker.startCycle('session-1', i, 'test', Date.now(), 0, 10000);
tracker.completeCycle('success', 0);
}
const recent = tracker.getRecent();
expect(recent.length).toBe(20);
});
});
describe('memory limit (MAX_CYCLE_METRICS_IN_MEMORY)', () => {
it('should trim recent metrics beyond 100 entries', () => {
for (let i = 1; i <= 105; i++) {
tracker.startCycle('session-1', i, 'test', Date.now(), 0, 10000);
tracker.completeCycle('success', 0);
}
// Internal storage should be capped at 100
// We can verify via getRecent with a high limit
const recent = tracker.getRecent(200);
expect(recent.length).toBe(100);
// The earliest should be cycle 6 (first 5 were shifted off)
expect(recent[recent.length - 1].cycleNumber).toBe(6);
});
});
describe('getAggregate', () => {
it('should return a copy, not a reference', () => {
const agg1 = tracker.getAggregate();
const agg2 = tracker.getAggregate();
expect(agg1).not.toBe(agg2);
expect(agg1).toEqual(agg2);
});
});
describe('reset', () => {
it('should clear all state', () => {
// Add some cycles
for (let i = 1; i <= 5; i++) {
tracker.startCycle('session-1', i, 'test', Date.now(), 0, 10000);
tracker.completeCycle('success', 0);
}
// Start another cycle
tracker.startCycle('session-1', 6, 'test', Date.now(), 0, 10000);
tracker.reset();
expect(tracker.getCurrentCycle()).toBeNull();
expect(tracker.getRecent()).toEqual([]);
const agg = tracker.getAggregate();
expect(agg.totalCycles).toBe(0);
expect(agg.successfulCycles).toBe(0);
expect(agg.successRate).toBe(100);
});
});
describe('aggregate averages and P90', () => {
it('should calculate average cycle duration across recent cycles', () => {
// We can't directly control durationMs since it's computed from Date.now(),
// but we can verify the aggregate fields are populated after completing cycles
tracker.startCycle('session-1', 1, 'test', Date.now(), 0, 10000);
tracker.completeCycle('success', 0);
const agg = tracker.getAggregate();
// durationMs should be very small (within the same tick)
expect(agg.avgCycleDurationMs).toBeGreaterThanOrEqual(0);
expect(agg.p90CycleDurationMs).toBeGreaterThanOrEqual(0);
});
it('should calculate P90 from sorted durations', () => {
// Complete multiple cycles to populate P90
for (let i = 1; i <= 10; i++) {
tracker.startCycle('session-1', i, 'test', Date.now(), 0, 10000);
tracker.completeCycle('success', 0);
}
const agg = tracker.getAggregate();
// P90 should be >= avg (for uniform values, P90 ~= avg)
expect(agg.p90CycleDurationMs).toBeGreaterThanOrEqual(0);
});
});
});
+255
View File
@@ -0,0 +1,255 @@
import { describe, it, expect } from 'vitest';
import {
isCompletionMessage,
hasWorkingPattern,
extractTokenCount,
PROMPT_PATTERNS,
WORKING_PATTERNS,
} from '../src/respawn-patterns.js';
describe('isCompletionMessage', () => {
describe('valid completion messages', () => {
it('should match "Worked for 2m 46s"', () => {
expect(isCompletionMessage('Worked for 2m 46s')).toBe(true);
});
it('should match "Worked for 46s"', () => {
expect(isCompletionMessage('Worked for 46s')).toBe(true);
});
it('should match "Worked for 1h 2m 3s"', () => {
expect(isCompletionMessage('Worked for 1h 2m 3s')).toBe(true);
});
it('should match "Worked for 5m"', () => {
expect(isCompletionMessage('Worked for 5m')).toBe(true);
});
it('should match "Worked for 2h"', () => {
expect(isCompletionMessage('Worked for 2h')).toBe(true);
});
it('should match case-insensitively', () => {
expect(isCompletionMessage('worked for 10s')).toBe(true);
expect(isCompletionMessage('WORKED FOR 10s')).toBe(true);
});
it('should match when embedded in larger string', () => {
expect(isCompletionMessage('Some prefix text Worked for 5m 30s and more text')).toBe(true);
});
it('should not match when ANSI escape codes break the word boundary', () => {
// The regex uses \b word boundary, so ANSI codes right before "Worked"
// break the match. In practice, data is ANSI-stripped before reaching this function.
expect(isCompletionMessage('\x1b[32mWorked for 3m 15s\x1b[0m')).toBe(false);
});
it('should match ANSI-stripped output', () => {
// After stripping ANSI codes, the pattern works fine
expect(isCompletionMessage('Worked for 3m 15s')).toBe(true);
});
});
describe('invalid patterns (should not match)', () => {
it('should not match "wait for 5s"', () => {
expect(isCompletionMessage('wait for 5s')).toBe(false);
});
it('should not match "run for 2m"', () => {
expect(isCompletionMessage('run for 2m')).toBe(false);
});
it('should not match "for 3s the system..."', () => {
expect(isCompletionMessage('for 3s the system responded')).toBe(false);
});
it('should not match bare time durations', () => {
expect(isCompletionMessage('2m 46s')).toBe(false);
});
it('should not match empty string', () => {
expect(isCompletionMessage('')).toBe(false);
});
it('should not match "Worked" without time pattern', () => {
expect(isCompletionMessage('Worked on the task')).toBe(false);
});
it('should not match "Worked for" without time', () => {
expect(isCompletionMessage('Worked for a long time')).toBe(false);
});
});
});
describe('hasWorkingPattern', () => {
describe('text working indicators', () => {
it('should detect "Thinking" in window', () => {
expect(hasWorkingPattern('Claude is Thinking about your request')).toBe(true);
});
it('should detect "Writing" in window', () => {
expect(hasWorkingPattern('Writing to file src/main.ts')).toBe(true);
});
it('should detect "Reading" in window', () => {
expect(hasWorkingPattern('Reading file contents')).toBe(true);
});
it('should detect "Running" in window', () => {
expect(hasWorkingPattern('Running npm install')).toBe(true);
});
it('should detect "Searching" in window', () => {
expect(hasWorkingPattern('Searching for patterns')).toBe(true);
});
it('should detect "Editing" in window', () => {
expect(hasWorkingPattern('Editing src/utils.ts')).toBe(true);
});
it('should detect all defined text patterns', () => {
const textPatterns = WORKING_PATTERNS.filter((p) => p.length > 2);
for (const pattern of textPatterns) {
expect(hasWorkingPattern(`Some text with ${pattern} in it`)).toBe(true);
}
});
});
describe('spinner characters', () => {
it('should detect braille spinner characters', () => {
expect(hasWorkingPattern('Loading... \u280B')).toBe(true);
expect(hasWorkingPattern('\u2839 processing')).toBe(true);
});
it('should detect circle spinner characters', () => {
expect(hasWorkingPattern('\u25D0 working')).toBe(true);
expect(hasWorkingPattern('status: \u25D3')).toBe(true);
});
it('should detect braille block spinners', () => {
expect(hasWorkingPattern('\u28FE loading')).toBe(true); // ⣾
expect(hasWorkingPattern('\u28FD processing')).toBe(true); // ⣽
});
});
describe('negative cases', () => {
it('should return false for empty string', () => {
expect(hasWorkingPattern('')).toBe(false);
});
it('should return false for prompt characters only', () => {
expect(hasWorkingPattern('\u276F ')).toBe(false);
});
it('should return false for regular output text', () => {
expect(hasWorkingPattern('Here is the result of the computation')).toBe(false);
});
it('should return false for completion messages', () => {
expect(hasWorkingPattern('Worked for 2m 46s')).toBe(false);
});
});
});
describe('extractTokenCount', () => {
describe('basic token patterns', () => {
it('should extract plain number tokens', () => {
expect(extractTokenCount('500 tokens')).toBe(500);
});
it('should extract decimal number tokens', () => {
expect(extractTokenCount('123.4 tokens')).toBe(123);
});
it('should extract k suffix (thousands)', () => {
expect(extractTokenCount('123.4k tokens')).toBe(123400);
});
it('should extract K suffix (uppercase)', () => {
expect(extractTokenCount('50K tokens')).toBe(50000);
});
it('should extract m suffix (millions)', () => {
expect(extractTokenCount('1.5m tokens')).toBe(1500000);
});
it('should extract M suffix (uppercase)', () => {
expect(extractTokenCount('2M tokens')).toBe(2000000);
});
it('should handle whitespace between number and suffix', () => {
expect(extractTokenCount('123.4 k tokens')).toBe(123400);
});
});
describe('embedded in text', () => {
it('should extract from terminal output', () => {
expect(extractTokenCount('Current usage: 45.2k tokens remaining')).toBe(45200);
});
it('should extract from completion message', () => {
expect(extractTokenCount('Worked for 2m 46s | 150.3k tokens used')).toBe(150300);
});
});
describe('negative cases', () => {
it('should return null for empty string', () => {
expect(extractTokenCount('')).toBeNull();
});
it('should return null for string without token pattern', () => {
expect(extractTokenCount('Hello world')).toBeNull();
});
it('should return null for "tokens" without a number', () => {
expect(extractTokenCount('many tokens')).toBeNull();
});
});
describe('rounding', () => {
it('should round to nearest integer', () => {
// 1.7k = 1700 (exact, no rounding needed)
expect(extractTokenCount('1.7k tokens')).toBe(1700);
});
it('should round fractional results', () => {
// 1.23456k = 1234.56 -> rounds to 1235
expect(extractTokenCount('1.23456k tokens')).toBe(1235);
});
});
});
describe('PROMPT_PATTERNS', () => {
it('should contain standard prompt characters', () => {
expect(PROMPT_PATTERNS).toContain('\u276F'); // Unicode right-pointing angle
expect(PROMPT_PATTERNS).toContain('\u23F5'); // Play button variant
});
it('should be a non-empty array', () => {
expect(PROMPT_PATTERNS.length).toBeGreaterThan(0);
});
});
describe('WORKING_PATTERNS', () => {
it('should be a non-empty array', () => {
expect(WORKING_PATTERNS.length).toBeGreaterThan(0);
});
it('should contain common Claude activity indicators', () => {
expect(WORKING_PATTERNS).toContain('Thinking');
expect(WORKING_PATTERNS).toContain('Writing');
expect(WORKING_PATTERNS).toContain('Reading');
expect(WORKING_PATTERNS).toContain('Running');
});
it('should contain spinner characters', () => {
expect(WORKING_PATTERNS).toContain('\u280B');
expect(WORKING_PATTERNS).toContain('\u2819');
});
it('should not contain completion indicators like stars', () => {
// Per source comment: "Note: \u273B and \u273D removed - they appear in completion messages too."
expect(WORKING_PATTERNS).not.toContain('\u273B');
expect(WORKING_PATTERNS).not.toContain('\u273D');
});
});
+786 -4
View File
@@ -4,20 +4,119 @@
* Uses app.inject() — no real HTTP ports needed. * Uses app.inject() — no real HTTP ports needed.
* Port: N/A (app.inject doesn't open ports) * Port: N/A (app.inject doesn't open ports)
* *
* Note: Some system routes access singleton modules (subagentWatcher, imageWatcher) * Mocks: fs (promises + sync), subagentWatcher, imageWatcher,
* directly. Those routes are tested lightly here since we can't easily mock singletons * session-lifecycle-log, and opencode-cli-resolver singletons.
* without vi.mock(). Focus is on routes that use the port context.
*/ */
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js'; import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
// ── Mocks ──────────────────────────────────────────────────────────
vi.mock('node:fs/promises', () => ({
default: {
readFile: vi.fn(async () => '{}'),
writeFile: vi.fn(async () => undefined),
},
}));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return {
...actual,
existsSync: vi.fn(() => true),
mkdirSync: vi.fn(),
readdirSync: vi.fn(() => []),
};
});
vi.mock('../../src/subagent-watcher.js', () => ({
subagentWatcher: {
getSubagents: vi.fn(() => []),
getRecentSubagents: vi.fn(() => []),
getSubagentsForSession: vi.fn(() => []),
getSubagent: vi.fn(() => null),
getTranscript: vi.fn(async () => []),
formatTranscript: vi.fn(() => ''),
killSubagent: vi.fn(async () => false),
cleanupNow: vi.fn(() => 0),
clearAll: vi.fn(() => 0),
getStats: vi.fn(() => ({
totalAgents: 0,
activeAgents: 0,
fileDebouncerCount: 0,
dirWatcherCount: 0,
idleTimerCount: 0,
})),
isRunning: vi.fn(() => true),
start: vi.fn(),
stop: vi.fn(),
},
}));
vi.mock('../../src/image-watcher.js', () => ({
imageWatcher: {
isRunning: vi.fn(() => false),
start: vi.fn(),
stop: vi.fn(),
watchSession: vi.fn(),
},
}));
vi.mock('../../src/session-lifecycle-log.js', () => ({
getLifecycleLog: vi.fn(() => ({
log: vi.fn(),
query: vi.fn(async () => []),
})),
}));
vi.mock('../../src/utils/opencode-cli-resolver.js', () => ({
isOpenCodeAvailable: vi.fn(() => false),
resolveOpenCodeDir: vi.fn(() => null),
}));
import fs from 'node:fs/promises';
import { existsSync, readdirSync } from 'node:fs';
import { subagentWatcher } from '../../src/subagent-watcher.js';
import { getLifecycleLog } from '../../src/session-lifecycle-log.js';
import { isOpenCodeAvailable, resolveOpenCodeDir } from '../../src/utils/opencode-cli-resolver.js';
const mockedReadFile = vi.mocked(fs.readFile);
const mockedWriteFile = vi.mocked(fs.writeFile);
const mockedExistsSync = vi.mocked(existsSync);
const mockedReaddirSync = vi.mocked(readdirSync);
const mockedSubagentWatcher = vi.mocked(subagentWatcher);
const mockedGetLifecycleLog = vi.mocked(getLifecycleLog);
const mockedIsOpenCodeAvailable = vi.mocked(isOpenCodeAvailable);
const mockedResolveOpenCodeDir = vi.mocked(resolveOpenCodeDir);
describe('system-routes', () => { describe('system-routes', () => {
let harness: RouteTestHarness; let harness: RouteTestHarness;
beforeEach(async () => { beforeEach(async () => {
harness = await createRouteTestHarness(registerSystemRoutes); harness = await createRouteTestHarness(registerSystemRoutes);
vi.clearAllMocks();
// Re-apply defaults after clearAllMocks
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
mockedWriteFile.mockResolvedValue(undefined);
mockedExistsSync.mockReturnValue(true);
mockedReaddirSync.mockReturnValue([]);
mockedSubagentWatcher.getSubagents.mockReturnValue([]);
mockedSubagentWatcher.getStats.mockReturnValue({
totalAgents: 0,
activeAgents: 0,
fileDebouncerCount: 0,
dirWatcherCount: 0,
idleTimerCount: 0,
} as never);
mockedGetLifecycleLog.mockReturnValue({
log: vi.fn(),
query: vi.fn(async () => []),
} as never);
mockedIsOpenCodeAvailable.mockReturnValue(false);
mockedResolveOpenCodeDir.mockReturnValue(null);
}); });
afterEach(async () => { afterEach(async () => {
@@ -147,6 +246,13 @@ describe('system-routes', () => {
expect(body.success).toBe(true); expect(body.success).toBe(true);
expect(body.data).toBeDefined(); expect(body.data).toBeDefined();
}); });
it('filters by minutes when query param provided', async () => {
mockedSubagentWatcher.getRecentSubagents.mockReturnValue([]);
const res = await harness.app.inject({ method: 'GET', url: '/api/subagents?minutes=30' });
expect(res.statusCode).toBe(200);
expect(mockedSubagentWatcher.getRecentSubagents).toHaveBeenCalledWith(30);
});
}); });
// ========== POST /api/auth/revoke ========== // ========== POST /api/auth/revoke ==========
@@ -162,5 +268,681 @@ describe('system-routes', () => {
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(true); expect(body.success).toBe(true);
}); });
it('revokes a specific session token', async () => {
const authSessions = new Map();
authSessions.set('tok-123', { ip: '1.2.3.4', ua: 'test', createdAt: Date.now(), method: 'basic' });
harness.ctx.authSessions = authSessions;
const res = await harness.app.inject({
method: 'POST',
url: '/api/auth/revoke',
payload: { sessionToken: 'tok-123' },
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).success).toBe(true);
expect(authSessions.has('tok-123')).toBe(false);
});
it('clears all sessions when no token specified', async () => {
const authSessions = new Map();
authSessions.set('a', { ip: '1', ua: '', createdAt: 0, method: 'basic' });
authSessions.set('b', { ip: '2', ua: '', createdAt: 0, method: 'qr' });
harness.ctx.authSessions = authSessions;
const res = await harness.app.inject({
method: 'POST',
url: '/api/auth/revoke',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(authSessions.size).toBe(0);
});
});
// ========== GET /api/settings ==========
describe('GET /api/settings', () => {
it('returns empty object when settings file does not exist', async () => {
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
const res = await harness.app.inject({ method: 'GET', url: '/api/settings' });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body)).toEqual({});
});
it('returns parsed settings when file exists', async () => {
const settings = { subagentTrackingEnabled: true, showSystemStats: false };
mockedReadFile.mockResolvedValue(JSON.stringify(settings) as never);
const res = await harness.app.inject({ method: 'GET', url: '/api/settings' });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body)).toEqual(settings);
});
});
// ========== PUT /api/settings ==========
describe('PUT /api/settings', () => {
it('saves valid settings', async () => {
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { showSystemStats: true, subagentTrackingEnabled: false },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(mockedWriteFile).toHaveBeenCalled();
});
it('merges with existing settings', async () => {
mockedReadFile.mockResolvedValue(JSON.stringify({ showCost: true }) as never);
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { showTokenCount: false },
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).success).toBe(true);
// Verify writeFile was called with merged content
const writtenContent = JSON.parse(mockedWriteFile.mock.calls[0][1] as string);
expect(writtenContent.showCost).toBe(true);
expect(writtenContent.showTokenCount).toBe(false);
});
it('rejects unknown settings fields (strict schema)', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { unknownField: 'bad' },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
it('rejects non-object body', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: 'not-an-object',
headers: { 'content-type': 'application/json' },
});
// Fastify will reject non-object JSON before it reaches the handler
expect(res.statusCode).not.toBe(200);
});
});
// ========== GET /api/subagent-window-states ==========
describe('GET /api/subagent-window-states', () => {
it('returns default when file does not exist', async () => {
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
const res = await harness.app.inject({ method: 'GET', url: '/api/subagent-window-states' });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body)).toEqual({ minimized: {}, open: [] });
});
it('returns persisted window states', async () => {
const states = { minimized: { 'agent-1': true }, open: ['agent-2'] };
mockedReadFile.mockResolvedValue(JSON.stringify(states) as never);
const res = await harness.app.inject({ method: 'GET', url: '/api/subagent-window-states' });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body)).toEqual(states);
});
});
// ========== PUT /api/subagent-window-states ==========
describe('PUT /api/subagent-window-states', () => {
it('saves valid window states', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/subagent-window-states',
payload: { minimized: { 'agent-1': true }, open: ['agent-2'] },
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).success).toBe(true);
expect(mockedWriteFile).toHaveBeenCalled();
});
it('accepts empty state', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/subagent-window-states',
payload: { minimized: {}, open: [] },
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).success).toBe(true);
});
it('rejects invalid minimized values', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/subagent-window-states',
payload: { minimized: { 'agent-1': 'not-a-boolean' } },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
});
// ========== GET /api/subagent-parents ==========
describe('GET /api/subagent-parents', () => {
it('returns empty object when file does not exist', async () => {
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
const res = await harness.app.inject({ method: 'GET', url: '/api/subagent-parents' });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body)).toEqual({});
});
it('returns persisted parent map', async () => {
const parents = { 'agent-1': 'session-abc', 'agent-2': 'session-xyz' };
mockedReadFile.mockResolvedValue(JSON.stringify(parents) as never);
const res = await harness.app.inject({ method: 'GET', url: '/api/subagent-parents' });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body)).toEqual(parents);
});
});
// ========== PUT /api/subagent-parents ==========
describe('PUT /api/subagent-parents', () => {
it('saves valid parent map', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/subagent-parents',
payload: { 'agent-1': 'session-abc' },
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).success).toBe(true);
expect(mockedWriteFile).toHaveBeenCalled();
});
it('accepts empty parent map', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/subagent-parents',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).success).toBe(true);
});
it('rejects non-string values in parent map', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/subagent-parents',
payload: { 'agent-1': 123 },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
});
// ========== GET /api/screenshots ==========
describe('GET /api/screenshots', () => {
it('returns empty list when directory does not exist', async () => {
mockedExistsSync.mockReturnValue(false);
const res = await harness.app.inject({ method: 'GET', url: '/api/screenshots' });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body)).toEqual({ files: [] });
});
it('returns image files sorted in reverse order', async () => {
mockedExistsSync.mockReturnValue(true);
mockedReaddirSync.mockReturnValue([
'screenshot_2026-01-01_00-00-00.png',
'screenshot_2026-01-02_00-00-00.png',
'not-an-image.txt',
'screenshot_2026-01-03_00-00-00.jpg',
] as never);
const res = await harness.app.inject({ method: 'GET', url: '/api/screenshots' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.files).toHaveLength(3); // excludes .txt
// Sorted reverse: 03, 02, 01
expect(body.files[0].name).toBe('screenshot_2026-01-03_00-00-00.jpg');
expect(body.files[1].name).toBe('screenshot_2026-01-02_00-00-00.png');
expect(body.files[2].name).toBe('screenshot_2026-01-01_00-00-00.png');
});
});
// ========== GET /api/screenshots/:name ==========
describe('GET /api/screenshots/:name', () => {
it('rejects path traversal attempts', async () => {
const res = await harness.app.inject({
method: 'GET',
url: '/api/screenshots/..%2F..%2Fetc%2Fpasswd',
});
expect(res.statusCode).toBe(400);
});
it('returns 404 when screenshot does not exist', async () => {
mockedExistsSync.mockReturnValue(false);
const res = await harness.app.inject({
method: 'GET',
url: '/api/screenshots/nonexistent.png',
});
expect(res.statusCode).toBe(404);
});
it('serves existing screenshot with correct content type', async () => {
mockedExistsSync.mockReturnValue(true);
mockedReadFile.mockResolvedValue(Buffer.from('fake-png-data') as never);
const res = await harness.app.inject({
method: 'GET',
url: '/api/screenshots/test-image.png',
});
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toBe('image/png');
});
it('serves jpeg with correct content type', async () => {
mockedExistsSync.mockReturnValue(true);
mockedReadFile.mockResolvedValue(Buffer.from('fake-jpg-data') as never);
const res = await harness.app.inject({
method: 'GET',
url: '/api/screenshots/photo.jpg',
});
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toBe('image/jpeg');
});
});
// ========== POST /api/screenshots ==========
describe('POST /api/screenshots', () => {
it('rejects non-multipart content type', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/screenshots',
payload: { file: 'data' },
headers: { 'content-type': 'application/json' },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.error).toContain('multipart');
});
});
// ========== GET /api/session-lifecycle ==========
describe('GET /api/session-lifecycle', () => {
it('returns lifecycle entries', async () => {
const mockEntries = [{ event: 'session_created', sessionId: 'test-1', timestamp: Date.now() }];
mockedGetLifecycleLog.mockReturnValue({
log: vi.fn(),
query: vi.fn(async () => mockEntries),
} as never);
const res = await harness.app.inject({ method: 'GET', url: '/api/session-lifecycle' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.entries).toEqual(mockEntries);
});
it('passes filter query params to lifecycle log', async () => {
const queryFn = vi.fn(async () => []);
mockedGetLifecycleLog.mockReturnValue({
log: vi.fn(),
query: queryFn,
} as never);
await harness.app.inject({
method: 'GET',
url: '/api/session-lifecycle?sessionId=s1&event=session_created&since=1000&limit=50',
});
expect(queryFn).toHaveBeenCalledWith({
sessionId: 's1',
event: 'session_created',
since: 1000,
limit: 50,
});
});
it('caps limit at 1000', async () => {
const queryFn = vi.fn(async () => []);
mockedGetLifecycleLog.mockReturnValue({
log: vi.fn(),
query: queryFn,
} as never);
await harness.app.inject({
method: 'GET',
url: '/api/session-lifecycle?limit=5000',
});
expect(queryFn).toHaveBeenCalledWith(expect.objectContaining({ limit: 1000 }));
});
it('defaults limit to 200 when not specified', async () => {
const queryFn = vi.fn(async () => []);
mockedGetLifecycleLog.mockReturnValue({
log: vi.fn(),
query: queryFn,
} as never);
await harness.app.inject({ method: 'GET', url: '/api/session-lifecycle' });
expect(queryFn).toHaveBeenCalledWith(expect.objectContaining({ limit: 200 }));
});
});
// ========== GET /api/opencode/status ==========
describe('GET /api/opencode/status', () => {
it('returns unavailable when opencode is not installed', async () => {
mockedIsOpenCodeAvailable.mockReturnValue(false);
mockedResolveOpenCodeDir.mockReturnValue(null);
const res = await harness.app.inject({ method: 'GET', url: '/api/opencode/status' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.available).toBe(false);
expect(body.path).toBeNull();
});
it('returns available with path when opencode is installed', async () => {
mockedIsOpenCodeAvailable.mockReturnValue(true);
mockedResolveOpenCodeDir.mockReturnValue('/usr/local/bin');
const res = await harness.app.inject({ method: 'GET', url: '/api/opencode/status' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.available).toBe(true);
expect(body.path).toBe('/usr/local/bin');
});
});
// ========== GET /api/execution/model-config ==========
describe('GET /api/execution/model-config', () => {
it('returns empty data when settings file does not exist', async () => {
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
const res = await harness.app.inject({ method: 'GET', url: '/api/execution/model-config' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data).toEqual({});
});
it('returns model config from settings', async () => {
const settings = { modelConfig: { model: 'claude-3', temperature: 0.7 } };
mockedReadFile.mockResolvedValue(JSON.stringify(settings) as never);
const res = await harness.app.inject({ method: 'GET', url: '/api/execution/model-config' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data).toEqual(settings.modelConfig);
});
it('returns empty data when settings has no modelConfig', async () => {
mockedReadFile.mockResolvedValue(JSON.stringify({ showCost: true }) as never);
const res = await harness.app.inject({ method: 'GET', url: '/api/execution/model-config' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data).toEqual({});
});
});
// ========== PUT /api/execution/model-config ==========
describe('PUT /api/execution/model-config', () => {
it('saves valid model config', async () => {
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
const res = await harness.app.inject({
method: 'PUT',
url: '/api/execution/model-config',
payload: { model: 'claude-3', temperature: 0.5 },
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).success).toBe(true);
expect(mockedWriteFile).toHaveBeenCalled();
// Verify the written content contains modelConfig
const writtenContent = JSON.parse(mockedWriteFile.mock.calls[0][1] as string);
expect(writtenContent.modelConfig).toEqual({ model: 'claude-3', temperature: 0.5 });
});
it('preserves existing settings when updating model config', async () => {
mockedReadFile.mockResolvedValue(JSON.stringify({ showCost: true, other: 'value' }) as never);
const res = await harness.app.inject({
method: 'PUT',
url: '/api/execution/model-config',
payload: { model: 'new-model' },
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).success).toBe(true);
const writtenContent = JSON.parse(mockedWriteFile.mock.calls[0][1] as string);
expect(writtenContent.showCost).toBe(true);
expect(writtenContent.other).toBe('value');
expect(writtenContent.modelConfig).toEqual({ model: 'new-model' });
});
});
// ========== GET /api/subagents/:agentId ==========
describe('GET /api/subagents/:agentId', () => {
it('returns 200 with error for unknown agent', async () => {
mockedSubagentWatcher.getSubagent.mockReturnValue(null);
const res = await harness.app.inject({ method: 'GET', url: '/api/subagents/unknown-agent' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.error).toContain('not found');
});
it('returns agent info for known agent', async () => {
const agentInfo = { agentId: 'agent-1', status: 'active', pid: 1234 };
mockedSubagentWatcher.getSubagent.mockReturnValue(agentInfo as never);
const res = await harness.app.inject({ method: 'GET', url: '/api/subagents/agent-1' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data).toEqual(agentInfo);
});
});
// ========== GET /api/subagents/:agentId/transcript ==========
describe('GET /api/subagents/:agentId/transcript', () => {
it('returns raw transcript by default', async () => {
const transcript = [{ role: 'assistant', content: 'hello' }];
mockedSubagentWatcher.getTranscript.mockResolvedValue(transcript as never);
const res = await harness.app.inject({
method: 'GET',
url: '/api/subagents/agent-1/transcript',
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data).toEqual(transcript);
});
it('returns formatted transcript when format=formatted', async () => {
const transcript = [{ role: 'assistant', content: 'hello' }];
mockedSubagentWatcher.getTranscript.mockResolvedValue(transcript as never);
mockedSubagentWatcher.formatTranscript.mockReturnValue('## Formatted\nhello');
const res = await harness.app.inject({
method: 'GET',
url: '/api/subagents/agent-1/transcript?format=formatted',
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.formatted).toBe('## Formatted\nhello');
expect(body.data.entryCount).toBe(1);
});
it('passes limit parameter', async () => {
mockedSubagentWatcher.getTranscript.mockResolvedValue([] as never);
await harness.app.inject({
method: 'GET',
url: '/api/subagents/agent-1/transcript?limit=10',
});
expect(mockedSubagentWatcher.getTranscript).toHaveBeenCalledWith('agent-1', 10);
});
});
// ========== DELETE /api/subagents/:agentId ==========
describe('DELETE /api/subagents/:agentId', () => {
it('returns error for unknown agent', async () => {
mockedSubagentWatcher.getSubagent.mockReturnValue(null);
const res = await harness.app.inject({ method: 'DELETE', url: '/api/subagents/unknown' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.error).toContain('not found');
});
it('kills a known agent successfully', async () => {
mockedSubagentWatcher.getSubagent.mockReturnValue({ agentId: 'agent-1' } as never);
mockedSubagentWatcher.killSubagent.mockResolvedValue(true);
const res = await harness.app.inject({ method: 'DELETE', url: '/api/subagents/agent-1' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.status).toBe('killed');
});
it('returns error when kill fails', async () => {
mockedSubagentWatcher.getSubagent.mockReturnValue({ agentId: 'agent-1' } as never);
mockedSubagentWatcher.killSubagent.mockResolvedValue(false);
const res = await harness.app.inject({ method: 'DELETE', url: '/api/subagents/agent-1' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.error).toContain('already completed');
});
});
// ========== POST /api/subagents/cleanup ==========
describe('POST /api/subagents/cleanup', () => {
it('triggers cleanup and returns count', async () => {
mockedSubagentWatcher.cleanupNow.mockReturnValue(3);
mockedSubagentWatcher.getSubagents.mockReturnValue([{ agentId: 'a' }, { agentId: 'b' }] as never);
const res = await harness.app.inject({ method: 'POST', url: '/api/subagents/cleanup' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.removed).toBe(3);
expect(body.data.remaining).toBe(2);
});
});
// ========== DELETE /api/subagents ==========
describe('DELETE /api/subagents', () => {
it('clears all subagents', async () => {
mockedSubagentWatcher.clearAll.mockReturnValue(5);
const res = await harness.app.inject({ method: 'DELETE', url: '/api/subagents' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.cleared).toBe(5);
});
});
// ========== GET /api/sessions/:id/subagents ==========
describe('GET /api/sessions/:id/subagents', () => {
it('returns 404 for unknown session', async () => {
const res = await harness.app.inject({
method: 'GET',
url: '/api/sessions/nonexistent/subagents',
});
expect(res.statusCode).toBe(404);
});
it('returns subagents for valid session', async () => {
mockedSubagentWatcher.getSubagentsForSession.mockReturnValue([{ agentId: 'sub-1', status: 'active' }] as never);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/subagents`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data).toHaveLength(1);
});
});
// ========== POST /api/tunnel/qr/regenerate ==========
describe('POST /api/tunnel/qr/regenerate', () => {
it('calls regenerateQrToken on tunnel manager', async () => {
// Set up a mock tunnel manager
harness.ctx.tunnelManager = {
regenerateQrToken: vi.fn(),
getStatus: vi.fn(() => ({ running: false })),
getUrl: vi.fn(() => null),
getQrSvg: vi.fn(),
consumeToken: vi.fn(),
isRunning: vi.fn(() => false),
start: vi.fn(),
stop: vi.fn(),
} as never;
// Re-create the harness since tunnelManager was null originally
await harness.app.close();
harness = await createRouteTestHarness(registerSystemRoutes);
harness.ctx.tunnelManager = {
regenerateQrToken: vi.fn(),
getStatus: vi.fn(() => ({ running: false })),
getUrl: vi.fn(() => null),
getQrSvg: vi.fn(),
consumeToken: vi.fn(),
isRunning: vi.fn(() => false),
start: vi.fn(),
stop: vi.fn(),
} as never;
const res = await harness.app.inject({ method: 'POST', url: '/api/tunnel/qr/regenerate' });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).success).toBe(true);
});
}); });
}); });
+402
View File
@@ -0,0 +1,402 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { StaleExpirationMap } from '../src/utils/stale-expiration-map.js';
describe('StaleExpirationMap', () => {
let map: StaleExpirationMap<string, number>;
afterEach(() => {
// Always dispose to clear cleanup timers
map?.dispose();
});
describe('construction', () => {
it('creates an empty map', () => {
map = new StaleExpirationMap({ ttlMs: 1000 });
expect(map.size).toBe(0);
expect(map.isDisposed).toBe(false);
});
it('defaults refreshOnGet to true', () => {
map = new StaleExpirationMap({ ttlMs: 1000 });
// Verify by setting, advancing time partially, getting (refreshes), advancing again
vi.useFakeTimers();
map.set('a', 1);
vi.advanceTimersByTime(800); // 800ms of 1000ms TTL
map.get('a'); // should refresh
vi.advanceTimersByTime(800); // 1600ms total, but only 800ms since last access
expect(map.has('a')).toBe(true);
vi.useRealTimers();
});
});
describe('set/get', () => {
beforeEach(() => {
map = new StaleExpirationMap({ ttlMs: 1000 });
});
it('stores and retrieves values', () => {
map.set('a', 1);
map.set('b', 2);
expect(map.get('a')).toBe(1);
expect(map.get('b')).toBe(2);
});
it('returns undefined for missing keys', () => {
expect(map.get('nonexistent')).toBeUndefined();
});
it('overwrites existing values', () => {
map.set('a', 1);
map.set('a', 10);
expect(map.get('a')).toBe(10);
});
it('supports chaining on set', () => {
const result = map.set('a', 1).set('b', 2);
expect(result).toBe(map);
expect(map.size).toBe(2);
});
it('ignores set when disposed', () => {
map.dispose();
map.set('a', 1);
expect(map.size).toBe(0);
});
});
describe('TTL expiration', () => {
it('expires entries after TTL', () => {
vi.useFakeTimers();
map = new StaleExpirationMap({ ttlMs: 1000 });
map.set('a', 1);
vi.advanceTimersByTime(1001);
expect(map.get('a')).toBeUndefined();
expect(map.size).toBe(0);
vi.useRealTimers();
});
it('entries are accessible before TTL', () => {
vi.useFakeTimers();
map = new StaleExpirationMap({ ttlMs: 1000 });
map.set('a', 1);
vi.advanceTimersByTime(500);
expect(map.get('a')).toBe(1);
vi.useRealTimers();
});
it('get refreshes TTL when refreshOnGet is true', () => {
vi.useFakeTimers();
map = new StaleExpirationMap({ ttlMs: 1000, refreshOnGet: true });
map.set('a', 1);
vi.advanceTimersByTime(800);
map.get('a'); // refreshes to now
vi.advanceTimersByTime(800); // 800ms after refresh, still within TTL
expect(map.get('a')).toBe(1);
vi.useRealTimers();
});
it('get does not refresh TTL when refreshOnGet is false', () => {
vi.useFakeTimers();
map = new StaleExpirationMap({ ttlMs: 1000, refreshOnGet: false });
map.set('a', 1);
vi.advanceTimersByTime(800);
map.get('a'); // does NOT refresh
vi.advanceTimersByTime(300); // 1100ms total, past TTL
expect(map.get('a')).toBeUndefined();
vi.useRealTimers();
});
});
describe('has', () => {
it('returns true for non-expired keys', () => {
map = new StaleExpirationMap({ ttlMs: 1000 });
map.set('a', 1);
expect(map.has('a')).toBe(true);
});
it('returns false for missing keys', () => {
map = new StaleExpirationMap({ ttlMs: 1000 });
expect(map.has('nonexistent')).toBe(false);
});
it('returns false and deletes expired keys', () => {
vi.useFakeTimers();
map = new StaleExpirationMap({ ttlMs: 1000 });
map.set('a', 1);
vi.advanceTimersByTime(1001);
expect(map.has('a')).toBe(false);
vi.useRealTimers();
});
});
describe('peek', () => {
it('returns value without refreshing TTL', () => {
vi.useFakeTimers();
map = new StaleExpirationMap({ ttlMs: 1000 });
map.set('a', 1);
vi.advanceTimersByTime(800);
expect(map.peek('a')).toBe(1); // does not refresh
vi.advanceTimersByTime(300); // 1100ms total
expect(map.peek('a')).toBeUndefined();
vi.useRealTimers();
});
it('returns undefined for missing keys', () => {
map = new StaleExpirationMap({ ttlMs: 1000 });
expect(map.peek('nonexistent')).toBeUndefined();
});
it('deletes expired entries on peek', () => {
vi.useFakeTimers();
map = new StaleExpirationMap({ ttlMs: 1000 });
map.set('a', 1);
vi.advanceTimersByTime(1001);
map.peek('a');
// The internal entry should be deleted
expect(map.size).toBe(0);
vi.useRealTimers();
});
});
describe('delete', () => {
it('removes an existing key', () => {
map = new StaleExpirationMap({ ttlMs: 1000 });
map.set('a', 1);
expect(map.delete('a')).toBe(true);
expect(map.has('a')).toBe(false);
expect(map.size).toBe(0);
});
it('returns false for non-existent key', () => {
map = new StaleExpirationMap({ ttlMs: 1000 });
expect(map.delete('nonexistent')).toBe(false);
});
it('does not call onExpire on manual delete', () => {
const onExpire = vi.fn();
map = new StaleExpirationMap({ ttlMs: 1000, onExpire });
map.set('a', 1);
map.delete('a');
expect(onExpire).not.toHaveBeenCalled();
});
});
describe('clear', () => {
it('removes all entries', () => {
map = new StaleExpirationMap({ ttlMs: 1000 });
map.set('a', 1);
map.set('b', 2);
map.clear();
expect(map.size).toBe(0);
expect(map.get('a')).toBeUndefined();
});
});
describe('touch', () => {
it('refreshes TTL without returning value', () => {
vi.useFakeTimers();
map = new StaleExpirationMap({ ttlMs: 1000 });
map.set('a', 1);
vi.advanceTimersByTime(800);
expect(map.touch('a')).toBe(true);
vi.advanceTimersByTime(800); // 1600ms total, but 800ms since touch
expect(map.has('a')).toBe(true);
vi.useRealTimers();
});
it('returns false for non-existent keys', () => {
map = new StaleExpirationMap({ ttlMs: 1000 });
expect(map.touch('nonexistent')).toBe(false);
});
it('returns false for expired keys', () => {
vi.useFakeTimers();
map = new StaleExpirationMap({ ttlMs: 1000 });
map.set('a', 1);
vi.advanceTimersByTime(1001);
expect(map.touch('a')).toBe(false);
vi.useRealTimers();
});
});
describe('getAge', () => {
it('returns age since creation', () => {
vi.useFakeTimers();
map = new StaleExpirationMap({ ttlMs: 10000 });
map.set('a', 1);
vi.advanceTimersByTime(3000);
const age = map.getAge('a');
expect(age).toBe(3000);
vi.useRealTimers();
});
it('returns undefined for missing keys', () => {
map = new StaleExpirationMap({ ttlMs: 1000 });
expect(map.getAge('nonexistent')).toBeUndefined();
});
});
describe('getRemainingTtl', () => {
it('returns remaining time before expiration', () => {
vi.useFakeTimers();
map = new StaleExpirationMap({ ttlMs: 1000 });
map.set('a', 1);
vi.advanceTimersByTime(300);
const remaining = map.getRemainingTtl('a');
expect(remaining).toBe(700);
vi.useRealTimers();
});
it('returns 0 for expired entries (before cleanup removes them)', () => {
vi.useFakeTimers();
// Use a long cleanup interval so the entry is not auto-removed
map = new StaleExpirationMap({ ttlMs: 1000, cleanupIntervalMs: 60000 });
map.set('a', 1);
vi.advanceTimersByTime(2000);
expect(map.getRemainingTtl('a')).toBe(0);
vi.useRealTimers();
});
it('returns undefined after cleanup removes expired entry', () => {
vi.useFakeTimers();
map = new StaleExpirationMap({ ttlMs: 1000 });
map.set('a', 1);
vi.advanceTimersByTime(2000); // cleanup fires and removes 'a'
expect(map.getRemainingTtl('a')).toBeUndefined();
vi.useRealTimers();
});
it('returns undefined for missing keys', () => {
map = new StaleExpirationMap({ ttlMs: 1000 });
expect(map.getRemainingTtl('nonexistent')).toBeUndefined();
});
});
describe('cleanup', () => {
it('removes expired entries and returns count', () => {
vi.useFakeTimers();
map = new StaleExpirationMap({ ttlMs: 1000 });
map.set('a', 1);
map.set('b', 2);
vi.advanceTimersByTime(500);
map.set('c', 3);
vi.advanceTimersByTime(600); // 'a' and 'b' at 1100ms, 'c' at 600ms
const removed = map.cleanup();
expect(removed).toBe(2);
expect(map.size).toBe(1);
expect(map.has('c')).toBe(true);
vi.useRealTimers();
});
it('calls onExpire for each expired entry', () => {
vi.useFakeTimers();
const onExpire = vi.fn();
map = new StaleExpirationMap({ ttlMs: 1000, onExpire });
map.set('a', 1);
map.set('b', 2);
vi.advanceTimersByTime(1001);
map.cleanup();
expect(onExpire).toHaveBeenCalledTimes(2);
expect(onExpire).toHaveBeenCalledWith('a', 1);
expect(onExpire).toHaveBeenCalledWith('b', 2);
vi.useRealTimers();
});
it('returns 0 when nothing is expired', () => {
map = new StaleExpirationMap({ ttlMs: 1000 });
map.set('a', 1);
expect(map.cleanup()).toBe(0);
});
it('periodic cleanup runs automatically', () => {
vi.useFakeTimers();
const onExpire = vi.fn();
map = new StaleExpirationMap({ ttlMs: 1000, cleanupIntervalMs: 500, onExpire });
map.set('a', 1);
vi.advanceTimersByTime(1500); // past TTL, and cleanup interval fires
expect(onExpire).toHaveBeenCalledWith('a', 1);
vi.useRealTimers();
});
});
describe('iteration', () => {
it('iterates non-expired entries via for..of', () => {
map = new StaleExpirationMap({ ttlMs: 5000 });
map.set('a', 1);
map.set('b', 2);
const entries: [string, number][] = [];
for (const [k, v] of map) {
entries.push([k, v]);
}
expect(entries).toEqual([
['a', 1],
['b', 2],
]);
});
it('skips expired entries during iteration', () => {
vi.useFakeTimers();
map = new StaleExpirationMap({ ttlMs: 1000 });
map.set('a', 1);
vi.advanceTimersByTime(500);
map.set('b', 2);
vi.advanceTimersByTime(600); // 'a' expired at 1100ms, 'b' at 600ms
const entries: [string, number][] = [];
for (const [k, v] of map) {
entries.push([k, v]);
}
expect(entries).toEqual([['b', 2]]);
vi.useRealTimers();
});
it('keys() yields non-expired keys', () => {
map = new StaleExpirationMap({ ttlMs: 5000 });
map.set('a', 1);
map.set('b', 2);
expect([...map.keys()]).toEqual(['a', 'b']);
});
it('values() yields non-expired values', () => {
map = new StaleExpirationMap({ ttlMs: 5000 });
map.set('a', 1);
map.set('b', 2);
expect([...map.values()]).toEqual([1, 2]);
});
});
describe('dispose', () => {
it('marks map as disposed', () => {
map = new StaleExpirationMap({ ttlMs: 1000 });
expect(map.isDisposed).toBe(false);
map.dispose();
expect(map.isDisposed).toBe(true);
});
it('clears all entries on dispose', () => {
map = new StaleExpirationMap({ ttlMs: 1000 });
map.set('a', 1);
map.dispose();
expect(map.size).toBe(0);
});
it('is idempotent', () => {
map = new StaleExpirationMap({ ttlMs: 1000 });
map.dispose();
map.dispose(); // should not throw
expect(map.isDisposed).toBe(true);
});
it('stops periodic cleanup after dispose', () => {
vi.useFakeTimers();
const onExpire = vi.fn();
map = new StaleExpirationMap({ ttlMs: 1000, cleanupIntervalMs: 500, onExpire });
map.set('a', 1);
map.dispose();
vi.advanceTimersByTime(2000);
// onExpire should not be called because timer was stopped
expect(onExpire).not.toHaveBeenCalled();
vi.useRealTimers();
});
});
});
+1 -1
View File
@@ -23,5 +23,5 @@
"allowUnusedLabels": false "allowUnusedLabels": false
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/tui"] "exclude": ["node_modules", "dist"]
} }