mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -98,7 +98,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| Domain | Key files | Notes |
|
||||
|--------|-----------|-------|
|
||||
| **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` | |
|
||||
| **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 |
|
||||
@@ -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` | |
|
||||
| **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` | |
|
||||
| **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 |
|
||||
|
||||
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in.
|
||||
|
||||
@@ -30,6 +30,7 @@ import { join } from 'node:path';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { getAugmentedPath } from './utils/claude-cli-resolver.js';
|
||||
import { ANSI_ESCAPE_PATTERN_SIMPLE } from './utils/index.js';
|
||||
import { AI_CHECK_MAX_BACKOFF_MS } from './config/ai-defaults.js';
|
||||
|
||||
// ========== Security Validation ==========
|
||||
|
||||
@@ -534,10 +535,7 @@ export abstract class AiCheckerBase<
|
||||
// P1-005: Exponential backoff for errors
|
||||
// Base cooldown * 2^(consecutiveErrors-1), capped at 5 minutes
|
||||
const backoffMultiplier = Math.pow(2, this.consecutiveErrors - 1);
|
||||
const backoffCooldownMs = Math.min(
|
||||
this.config.errorCooldownMs * backoffMultiplier,
|
||||
5 * 60 * 1000 // Max 5 minutes
|
||||
);
|
||||
const backoffCooldownMs = Math.min(this.config.errorCooldownMs * backoffMultiplier, AI_CHECK_MAX_BACKOFF_MS);
|
||||
this.log(`Exponential backoff: ${Math.round(backoffCooldownMs / 1000)}s (error #${this.consecutiveErrors})`);
|
||||
this.startCooldown(backoffCooldownMs);
|
||||
}
|
||||
|
||||
+12
-5
@@ -30,7 +30,14 @@ import {
|
||||
type AiCheckerResultBase,
|
||||
type AiCheckerStateBase,
|
||||
} 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 ==========
|
||||
|
||||
@@ -48,10 +55,10 @@ const DEFAULT_AI_CHECK_CONFIG: AiIdleCheckConfig = {
|
||||
enabled: true,
|
||||
model: AI_CHECK_MODEL,
|
||||
maxContextChars: AI_IDLE_CHECK_MAX_CONTEXT,
|
||||
checkTimeoutMs: 90000,
|
||||
cooldownMs: 180000,
|
||||
errorCooldownMs: 60000,
|
||||
maxConsecutiveErrors: 3,
|
||||
checkTimeoutMs: AI_IDLE_CHECK_TIMEOUT_MS,
|
||||
cooldownMs: AI_IDLE_CHECK_COOLDOWN_MS,
|
||||
errorCooldownMs: AI_IDLE_CHECK_ERROR_COOLDOWN_MS,
|
||||
maxConsecutiveErrors: AI_CHECK_MAX_CONSECUTIVE_ERRORS,
|
||||
};
|
||||
|
||||
/** Pattern to match IDLE or WORKING as the first word of output */
|
||||
|
||||
+12
-5
@@ -29,7 +29,14 @@ import {
|
||||
type AiCheckerResultBase,
|
||||
type AiCheckerStateBase,
|
||||
} 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 ==========
|
||||
|
||||
@@ -47,10 +54,10 @@ const DEFAULT_PLAN_CHECK_CONFIG: AiPlanCheckConfig = {
|
||||
enabled: true,
|
||||
model: AI_CHECK_MODEL,
|
||||
maxContextChars: AI_PLAN_CHECK_MAX_CONTEXT,
|
||||
checkTimeoutMs: 60000,
|
||||
cooldownMs: 30000,
|
||||
errorCooldownMs: 30000,
|
||||
maxConsecutiveErrors: 3,
|
||||
checkTimeoutMs: AI_PLAN_CHECK_TIMEOUT_MS,
|
||||
cooldownMs: AI_PLAN_CHECK_COOLDOWN_MS,
|
||||
errorCooldownMs: AI_PLAN_CHECK_ERROR_COOLDOWN_MS,
|
||||
maxConsecutiveErrors: AI_CHECK_MAX_CONSECUTIVE_ERRORS,
|
||||
};
|
||||
|
||||
/** Pattern to match PLAN_MODE or NOT_PLAN_MODE as the first word(s) of output */
|
||||
|
||||
@@ -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
|
||||
* the idle checker, plan checker, respawn controller defaults, and
|
||||
* respawn route fallbacks. Change the model here when upgrading.
|
||||
* Centralizes the AI model identifier, context window sizes, and timeout/cooldown
|
||||
* defaults used by the idle checker, plan checker, respawn controller defaults,
|
||||
* and respawn route fallbacks. Change values here when tuning AI check behavior.
|
||||
*
|
||||
* @module config/ai-defaults
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Model & Context
|
||||
// ============================================================================
|
||||
|
||||
/** Default model for AI idle and plan checkers */
|
||||
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) */
|
||||
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;
|
||||
|
||||
@@ -73,3 +73,16 @@ export const MAX_CONSECUTIVE_ERRORS = 5;
|
||||
|
||||
/** Error counter reset interval — forgives errors after quiet period (ms) */
|
||||
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;
|
||||
|
||||
@@ -16,6 +16,7 @@ import { existsSync, statSync, realpathSync } from 'node:fs';
|
||||
import { resolve, relative, isAbsolute } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { CLEANUP_CHECK_INTERVAL_MS, INACTIVITY_TIMEOUT_MS } from './config/server-timing.js';
|
||||
|
||||
// ========== Configuration Constants ==========
|
||||
|
||||
@@ -39,7 +40,7 @@ const MAX_STREAMS_PER_SESSION = 5;
|
||||
* Inactivity timeout for streams (5 minutes).
|
||||
* 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 ==========
|
||||
|
||||
@@ -129,7 +130,7 @@ export class FileStreamManager extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
// Start cleanup timer for inactive streams
|
||||
this.cleanupTimer = setInterval(() => this.cleanupInactiveStreams(), 60 * 1000);
|
||||
this.cleanupTimer = setInterval(() => this.cleanupInactiveStreams(), CLEANUP_CHECK_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// ========== Public Methods ==========
|
||||
|
||||
+3
-4
@@ -9,6 +9,7 @@
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { execPattern } from './utils/index.js';
|
||||
|
||||
// Pattern to extract completion phrase from CLAUDE.md
|
||||
// Matches <promise>PHRASE</promise> with optional whitespace
|
||||
@@ -83,9 +84,7 @@ export function parseRalphLoopConfigFromContent(content: string): RalphLoopConfi
|
||||
};
|
||||
|
||||
// Parse each YAML line
|
||||
let match;
|
||||
YAML_LINE_PATTERN.lastIndex = 0;
|
||||
while ((match = YAML_LINE_PATTERN.exec(yaml)) !== null) {
|
||||
execPattern(YAML_LINE_PATTERN, yaml, (match) => {
|
||||
const key = match[1].toLowerCase();
|
||||
const value = match[2].trim();
|
||||
|
||||
@@ -103,7 +102,7 @@ export function parseRalphLoopConfigFromContent(content: string): RalphLoopConfi
|
||||
config.completionPromise = value.toUpperCase();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { CLEANUP_CHECK_INTERVAL_MS } from './config/server-timing.js';
|
||||
|
||||
/**
|
||||
* RalphStallDetector - Detects iteration stalls in the Ralph loop.
|
||||
@@ -57,7 +58,7 @@ export class RalphStallDetector extends EventEmitter {
|
||||
// Check every minute
|
||||
this._iterationStallTimer = setInterval(() => {
|
||||
this.checkIterationStall();
|
||||
}, 60 * 1000);
|
||||
}, CLEANUP_CHECK_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+17
-21
@@ -55,6 +55,7 @@ import {
|
||||
stringSimilarity,
|
||||
Debouncer,
|
||||
CleanupManager,
|
||||
execPattern,
|
||||
} from './utils/index.js';
|
||||
import { MAX_LINE_BUFFER_SIZE } from './config/buffer-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 { RalphStallDetector } from './ralph-stall-detector.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
|
||||
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.
|
||||
* 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).
|
||||
@@ -88,7 +90,7 @@ const CLEANUP_THROTTLE_MS = 30 * 1000;
|
||||
* Actively purges expired todos even when no terminal data is flowing.
|
||||
* Default: 5 minutes
|
||||
*/
|
||||
const TODO_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
||||
const TODO_CLEANUP_INTERVAL_MS = INACTIVITY_TIMEOUT_MS;
|
||||
|
||||
/**
|
||||
* Similarity threshold for todo deduplication.
|
||||
@@ -1666,35 +1668,32 @@ export class RalphTracker extends EventEmitter {
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
if (hasCheckbox) {
|
||||
TODO_CHECKBOX_PATTERN.lastIndex = 0;
|
||||
while ((match = TODO_CHECKBOX_PATTERN.exec(line)) !== null) {
|
||||
execPattern(TODO_CHECKBOX_PATTERN, line, (match) => {
|
||||
const checked = match[1].toLowerCase() === 'x';
|
||||
const content = match[2].trim();
|
||||
const status: RalphTodoStatus = checked ? 'completed' : 'pending';
|
||||
this.upsertTodo(content, status);
|
||||
updated = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (hasTodoIndicator) {
|
||||
TODO_INDICATOR_PATTERN.lastIndex = 0;
|
||||
while ((match = TODO_INDICATOR_PATTERN.exec(line)) !== null) {
|
||||
execPattern(TODO_INDICATOR_PATTERN, line, (match) => {
|
||||
const icon = match[1];
|
||||
const content = match[2].trim();
|
||||
const status = this.iconToStatus(icon);
|
||||
this.upsertTodo(content, status);
|
||||
updated = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (hasStatus) {
|
||||
TODO_STATUS_PATTERN.lastIndex = 0;
|
||||
while ((match = TODO_STATUS_PATTERN.exec(line)) !== null) {
|
||||
execPattern(TODO_STATUS_PATTERN, line, (match) => {
|
||||
const content = match[1].trim();
|
||||
const status = match[2] as RalphTodoStatus;
|
||||
this.upsertTodo(content, status);
|
||||
updated = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (hasNativeCheckbox) {
|
||||
@@ -1715,8 +1714,7 @@ export class RalphTracker extends EventEmitter {
|
||||
}
|
||||
|
||||
if (hasCheckmark) {
|
||||
TODO_TASK_CREATED_PATTERN.lastIndex = 0;
|
||||
while ((match = TODO_TASK_CREATED_PATTERN.exec(line)) !== null) {
|
||||
execPattern(TODO_TASK_CREATED_PATTERN, line, (match) => {
|
||||
const taskNum = parseInt(match[1], 10);
|
||||
const content = match[2].trim();
|
||||
if (content.length >= 5) {
|
||||
@@ -1725,10 +1723,9 @@ export class RalphTracker extends EventEmitter {
|
||||
this.upsertTodo(content, 'pending');
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
TODO_TASK_SUMMARY_PATTERN.lastIndex = 0;
|
||||
while ((match = TODO_TASK_SUMMARY_PATTERN.exec(line)) !== null) {
|
||||
execPattern(TODO_TASK_SUMMARY_PATTERN, line, (match) => {
|
||||
const taskNum = parseInt(match[1], 10);
|
||||
const content = match[2].trim();
|
||||
if (content.length >= 5) {
|
||||
@@ -1739,10 +1736,9 @@ export class RalphTracker extends EventEmitter {
|
||||
this.upsertTodo(this._taskNumberToContent.get(taskNum) || content, 'pending');
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
TODO_TASK_STATUS_PATTERN.lastIndex = 0;
|
||||
while ((match = TODO_TASK_STATUS_PATTERN.exec(line)) !== null) {
|
||||
execPattern(TODO_TASK_STATUS_PATTERN, line, (match) => {
|
||||
const taskNum = parseInt(match[1], 10);
|
||||
const statusStr = match[2].trim();
|
||||
const status: RalphTodoStatus =
|
||||
@@ -1752,7 +1748,7 @@ export class RalphTracker extends EventEmitter {
|
||||
this.upsertTodo(content, status);
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
TODO_PLAIN_CHECKMARK_PATTERN.lastIndex = 0;
|
||||
|
||||
@@ -62,7 +62,15 @@ import {
|
||||
import { RespawnAdaptiveTiming } from './respawn-adaptive-timing.js';
|
||||
import { RespawnCycleMetricsTracker } from './respawn-metrics.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 {
|
||||
RespawnCycleMetrics,
|
||||
RespawnAggregateMetrics,
|
||||
@@ -559,13 +567,13 @@ const DEFAULT_CONFIG: RespawnConfig = {
|
||||
aiIdleCheckEnabled: true, // use AI to confirm idle state
|
||||
aiIdleCheckModel: AI_CHECK_MODEL,
|
||||
aiIdleCheckMaxContext: AI_IDLE_CHECK_MAX_CONTEXT,
|
||||
aiIdleCheckTimeoutMs: 90000, // 90 seconds (thinking can be slow)
|
||||
aiIdleCheckCooldownMs: 180000, // 3 minutes after WORKING verdict
|
||||
aiIdleCheckTimeoutMs: AI_IDLE_CHECK_TIMEOUT_MS,
|
||||
aiIdleCheckCooldownMs: AI_IDLE_CHECK_COOLDOWN_MS,
|
||||
aiPlanCheckEnabled: true, // use AI to confirm plan mode before auto-accept
|
||||
aiPlanCheckModel: AI_CHECK_MODEL,
|
||||
aiPlanCheckMaxContext: AI_PLAN_CHECK_MAX_CONTEXT,
|
||||
aiPlanCheckTimeoutMs: 60000, // 60 seconds (thinking can be slow)
|
||||
aiPlanCheckCooldownMs: 30000, // 30 seconds after NOT_PLAN_MODE
|
||||
aiPlanCheckTimeoutMs: AI_PLAN_CHECK_TIMEOUT_MS,
|
||||
aiPlanCheckCooldownMs: AI_PLAN_CHECK_COOLDOWN_MS,
|
||||
stuckStateDetectionEnabled: true, // detect stuck states
|
||||
stuckStateWarningMs: 300000, // 5 minutes warning threshold
|
||||
stuckStateRecoveryMs: 600000, // 10 minutes recovery threshold
|
||||
|
||||
+2
-1
@@ -22,6 +22,7 @@ import {
|
||||
RunSummaryStats,
|
||||
createInitialRunSummaryStats,
|
||||
} from './types.js';
|
||||
import { CLEANUP_CHECK_INTERVAL_MS } from './config/server-timing.js';
|
||||
|
||||
/** Maximum events to keep per session (FIFO trimming) */
|
||||
const MAX_EVENTS = 1000;
|
||||
@@ -36,7 +37,7 @@ const TOKEN_MILESTONE_INTERVAL = 50000;
|
||||
const STATE_STUCK_WARNING_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
/** 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.
|
||||
|
||||
+9
-7
@@ -49,7 +49,13 @@ import { TaskTracker, type BackgroundTask } from './task-tracker.js';
|
||||
import { RalphTracker } from './ralph-tracker.js';
|
||||
import { BashToolParser } from './bash-tool-parser.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 {
|
||||
MAX_TERMINAL_BUFFER_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
|
||||
if (!cleanLine.includes('(') || !cleanLine.includes(')')) return;
|
||||
|
||||
// Reset regex lastIndex for global pattern
|
||||
TASK_TOOL_PATTERN.lastIndex = 0;
|
||||
|
||||
let match;
|
||||
while ((match = TASK_TOOL_PATTERN.exec(cleanLine)) !== null) {
|
||||
execPattern(TASK_TOOL_PATTERN, cleanLine, (match) => {
|
||||
const description = match[2].trim();
|
||||
if (description && description.length > 0) {
|
||||
this._taskCache.add(Date.now(), description);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,6 +34,7 @@ import { join, basename } from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
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 { STALE_DATA_MAX_AGE_MS } from './config/server-timing.js';
|
||||
import { CleanupManager, KeyedDebouncer } from './utils/index.js';
|
||||
|
||||
// ========== 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 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 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 STARTUP_MAX_FILE_AGE_MS = 4 * 60 * 60 * 1000; // Only load files modified in last 4 hours on startup
|
||||
|
||||
|
||||
+2
-1
@@ -22,6 +22,7 @@
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { assertNever } from './utils/index.js';
|
||||
import { STALE_DATA_MAX_AGE_MS } from './config/server-timing.js';
|
||||
|
||||
// ========== Configuration Constants ==========
|
||||
|
||||
@@ -36,7 +37,7 @@ const MAX_COMPLETED_TASKS = 100;
|
||||
* Entries older than this are cleaned up to prevent unbounded growth.
|
||||
* 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.
|
||||
|
||||
+2
-5
@@ -6,7 +6,7 @@
|
||||
* Key exports:
|
||||
* - PlanItem — a single task with priority (P0/P1/P2), TDD phase, dependencies, verification criteria
|
||||
* - 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
|
||||
* (`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 */
|
||||
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.
|
||||
* Moved here from plan-orchestrator.ts to break circular dependency.
|
||||
@@ -42,7 +39,7 @@ export interface PlanItem {
|
||||
lastError?: string;
|
||||
completedAt?: number;
|
||||
complexity?: 'low' | 'medium' | 'high';
|
||||
tddPhase?: PlanPhase;
|
||||
tddPhase?: TddPhase;
|
||||
pairedWith?: string;
|
||||
reviewChecklist?: string[];
|
||||
}
|
||||
|
||||
+2
-1
@@ -20,8 +20,9 @@ export {
|
||||
createAnsiPatternSimple,
|
||||
stripAnsi,
|
||||
SAFE_PATH_PATTERN,
|
||||
execPattern,
|
||||
} 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 { assertNever } from './type-safety.js';
|
||||
export { wrapWithNice } from './nice-wrapper.js';
|
||||
|
||||
@@ -79,3 +79,15 @@ export function stripAnsi(text: string): string {
|
||||
export const SPINNER_PATTERN = /[⠋⠙⠹⠸⠼⠴⠦⠧]/;
|
||||
|
||||
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,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.
|
||||
*
|
||||
* @module utils/token-validation
|
||||
@@ -12,58 +11,3 @@
|
||||
* Claude's context is ~200k, so 500k is a safe upper bound for validation.
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -11,7 +11,15 @@ import { SseEvent } from '../sse-events.js';
|
||||
import { findSessionOrFail, autoConfigureRalph } from '../route-helpers.js';
|
||||
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.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. */
|
||||
const noopEventPort: EventPort = {
|
||||
@@ -188,14 +196,18 @@ export function registerRespawnRoutes(
|
||||
aiIdleCheckModel: config.aiIdleCheckModel ?? currentConfig?.aiIdleCheckModel ?? AI_CHECK_MODEL,
|
||||
aiIdleCheckMaxContext:
|
||||
config.aiIdleCheckMaxContext ?? currentConfig?.aiIdleCheckMaxContext ?? AI_IDLE_CHECK_MAX_CONTEXT,
|
||||
aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs ?? currentConfig?.aiIdleCheckTimeoutMs ?? 90000,
|
||||
aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs ?? currentConfig?.aiIdleCheckCooldownMs ?? 180000,
|
||||
aiIdleCheckTimeoutMs:
|
||||
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,
|
||||
aiPlanCheckModel: config.aiPlanCheckModel ?? currentConfig?.aiPlanCheckModel ?? AI_CHECK_MODEL,
|
||||
aiPlanCheckMaxContext:
|
||||
config.aiPlanCheckMaxContext ?? currentConfig?.aiPlanCheckMaxContext ?? AI_PLAN_CHECK_MAX_CONTEXT,
|
||||
aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs ?? currentConfig?.aiPlanCheckTimeoutMs ?? 60000,
|
||||
aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs ?? currentConfig?.aiPlanCheckCooldownMs ?? 30000,
|
||||
aiPlanCheckTimeoutMs:
|
||||
config.aiPlanCheckTimeoutMs ?? currentConfig?.aiPlanCheckTimeoutMs ?? AI_PLAN_CHECK_TIMEOUT_MS,
|
||||
aiPlanCheckCooldownMs:
|
||||
config.aiPlanCheckCooldownMs ?? currentConfig?.aiPlanCheckCooldownMs ?? AI_PLAN_CHECK_COOLDOWN_MS,
|
||||
durationMinutes: currentConfig?.durationMinutes,
|
||||
};
|
||||
ctx.mux.updateRespawnConfig(id, merged);
|
||||
|
||||
+3
-2
@@ -120,6 +120,7 @@ import {
|
||||
ITERATION_PAUSE_MS,
|
||||
BATCH_FLUSH_THRESHOLD,
|
||||
STATS_COLLECTION_INTERVAL_MS,
|
||||
INACTIVITY_TIMEOUT_MS,
|
||||
} from '../config/server-timing.js';
|
||||
|
||||
// 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)
|
||||
// StaleExpirationMap auto-cleans entries for sessions that stop generating output
|
||||
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
|
||||
});
|
||||
// Centralized cleanup for standalone timers (intervals + resettable timeouts)
|
||||
@@ -2397,7 +2398,7 @@ export class WebServer extends EventEmitter {
|
||||
() => {
|
||||
this.recordPeriodicTokenUsage();
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
INACTIVITY_TIMEOUT_MS,
|
||||
{ description: 'periodic token recording' }
|
||||
);
|
||||
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -4,20 +4,119 @@
|
||||
* Uses app.inject() — no real HTTP ports needed.
|
||||
* Port: N/A (app.inject doesn't open ports)
|
||||
*
|
||||
* Note: Some system routes access singleton modules (subagentWatcher, imageWatcher)
|
||||
* directly. Those routes are tested lightly here since we can't easily mock singletons
|
||||
* without vi.mock(). Focus is on routes that use the port context.
|
||||
* Mocks: fs (promises + sync), subagentWatcher, imageWatcher,
|
||||
* session-lifecycle-log, and opencode-cli-resolver singletons.
|
||||
*/
|
||||
|
||||
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 { 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', () => {
|
||||
let harness: RouteTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
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 () => {
|
||||
@@ -147,6 +246,13 @@ describe('system-routes', () => {
|
||||
expect(body.success).toBe(true);
|
||||
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 ==========
|
||||
@@ -162,5 +268,681 @@ describe('system-routes', () => {
|
||||
const body = JSON.parse(res.body);
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -23,5 +23,5 @@
|
||||
"allowUnusedLabels": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "src/tui"]
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user