Files
Codeman/test/ralph-tracker.test.ts
arkonandClaude Opus 4.6 8e5f95d6ea test: add unit tests for Debouncer, CleanupManager, and timer migration refactors
New test files for utilities that previously had no dedicated coverage,
plus migration tests validating the ralph-tracker and respawn-controller
timer refactorings work correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 16:50:23 +01:00

1975 lines
68 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { RalphTracker } from '../src/ralph-tracker.js';
import { RalphTrackerState, RalphTodoItem, RalphStatusBlock, CircuitBreakerStatus } from '../src/types.js';
/**
* RalphTracker Tests
*
* Tests the detection of Ralph Wiggum loops and todo lists from terminal output
* running inside Claude Code sessions.
*/
describe('RalphTracker', () => {
let tracker: RalphTracker;
beforeEach(() => {
tracker = new RalphTracker();
// Enable tracker by default for most tests (testing detection logic)
tracker.enable();
});
describe('Initialization', () => {
it('should start with inactive loop state', () => {
const freshTracker = new RalphTracker();
const state = freshTracker.loopState;
expect(state.active).toBe(false);
expect(state.completionPhrase).toBeNull();
expect(state.startedAt).toBeNull();
expect(state.cycleCount).toBe(0);
});
it('should start with empty todos', () => {
expect(tracker.todos).toHaveLength(0);
});
it('should start disabled by default', () => {
const freshTracker = new RalphTracker();
expect(freshTracker.enabled).toBe(false);
expect(freshTracker.loopState.enabled).toBe(false);
});
});
describe('Auto-Enable Behavior', () => {
it('should not process data when disabled', () => {
const freshTracker = new RalphTracker();
// This pattern doesn't trigger auto-enable
freshTracker.processTerminalData('Elapsed: 2.5 hours\n');
expect(freshTracker.loopState.elapsedHours).toBeNull();
});
it('should not auto-enable by default (auto-enable disabled)', () => {
const freshTracker = new RalphTracker();
freshTracker.processTerminalData('/ralph-loop:ralph-loop\n');
expect(freshTracker.enabled).toBe(false);
});
it('should not auto-enable on completion phrase by default', () => {
const freshTracker = new RalphTracker();
freshTracker.processTerminalData('<promise>COMPLETE</promise>\n');
expect(freshTracker.enabled).toBe(false);
});
it('should not auto-enable on TodoWrite by default', () => {
const freshTracker = new RalphTracker();
freshTracker.processTerminalData('TodoWrite: Todos have been modified\n');
expect(freshTracker.enabled).toBe(false);
});
it('should not auto-enable on todo checkboxes by default', () => {
const freshTracker = new RalphTracker();
freshTracker.processTerminalData('- [ ] New task\n');
expect(freshTracker.enabled).toBe(false);
expect(freshTracker.todos).toHaveLength(0);
});
it('should auto-enable when enableAutoEnable() is called', () => {
const freshTracker = new RalphTracker();
const enableHandler = vi.fn();
freshTracker.on('enabled', enableHandler);
freshTracker.enableAutoEnable();
freshTracker.processTerminalData('/ralph-loop:ralph-loop\n');
expect(freshTracker.enabled).toBe(true);
expect(enableHandler).toHaveBeenCalled();
});
it('should auto-enable on iteration patterns when auto-enable allowed', () => {
const freshTracker = new RalphTracker();
freshTracker.enableAutoEnable();
freshTracker.processTerminalData('Iteration 5/50\n');
expect(freshTracker.enabled).toBe(true);
});
it('should auto-enable on loop start patterns when auto-enable allowed', () => {
const freshTracker = new RalphTracker();
freshTracker.enableAutoEnable();
freshTracker.processTerminalData('Loop started at 2024-01-15\n');
expect(freshTracker.enabled).toBe(true);
});
it('should allow manual enable/disable', () => {
const freshTracker = new RalphTracker();
expect(freshTracker.enabled).toBe(false);
freshTracker.enable();
expect(freshTracker.enabled).toBe(true);
freshTracker.disable();
expect(freshTracker.enabled).toBe(false);
});
it('should reset to disabled on clear', () => {
tracker.processTerminalData('/ralph-loop:ralph-loop\n');
expect(tracker.enabled).toBe(true);
tracker.clear();
expect(tracker.enabled).toBe(false);
});
});
describe('Completion Phrase Detection', () => {
it('should detect <promise>COMPLETE</promise> pattern', () => {
const completionHandler = vi.fn();
tracker.on('completionDetected', completionHandler);
// Start loop first (realistic workflow)
tracker.startLoop();
tracker.processTerminalData('<promise>COMPLETE</promise>\n');
expect(completionHandler).toHaveBeenCalledWith('COMPLETE');
expect(tracker.loopState.completionPhrase).toBe('COMPLETE');
});
it('should detect <promise>TIME_COMPLETE</promise> pattern', () => {
const completionHandler = vi.fn();
tracker.on('completionDetected', completionHandler);
// Start loop first (realistic workflow)
tracker.startLoop();
tracker.processTerminalData('Output: <promise>TIME_COMPLETE</promise>\n');
expect(completionHandler).toHaveBeenCalledWith('TIME_COMPLETE');
});
it('should detect custom completion phrases', () => {
const completionHandler = vi.fn();
tracker.on('completionDetected', completionHandler);
// Start loop first (realistic workflow)
tracker.startLoop();
tracker.processTerminalData('<promise>MY_CUSTOM_PHRASE_123</promise>\n');
expect(completionHandler).toHaveBeenCalledWith('MY_CUSTOM_PHRASE_123');
expect(tracker.loopState.completionPhrase).toBe('MY_CUSTOM_PHRASE_123');
});
it('should detect completion phrases with hyphens', () => {
const completionHandler = vi.fn();
tracker.on('completionDetected', completionHandler);
tracker.startLoop();
tracker.processTerminalData('<promise>TESTS-PASS</promise>\n');
expect(completionHandler).toHaveBeenCalledWith('TESTS-PASS');
expect(tracker.loopState.completionPhrase).toBe('TESTS-PASS');
});
it('should detect completion phrases with mixed characters', () => {
const completionHandler = vi.fn();
tracker.on('completionDetected', completionHandler);
tracker.startLoop();
tracker.processTerminalData('<promise>TASK-123_COMPLETE</promise>\n');
expect(completionHandler).toHaveBeenCalledWith('TASK-123_COMPLETE');
expect(tracker.loopState.completionPhrase).toBe('TASK-123_COMPLETE');
});
it('should mark loop as inactive when completion detected', () => {
// Start a loop first
tracker.startLoop('TEST_PHRASE');
expect(tracker.loopState.active).toBe(true);
// Detect completion
tracker.processTerminalData('<promise>TEST_PHRASE</promise>\n');
expect(tracker.loopState.active).toBe(false);
});
});
describe('Loop Status Detection', () => {
it('should detect loop start patterns', () => {
const loopHandler = vi.fn();
tracker.on('loopUpdate', loopHandler);
tracker.processTerminalData('Loop started at 2024-01-15\n');
expect(loopHandler).toHaveBeenCalled();
expect(tracker.loopState.active).toBe(true);
expect(tracker.loopState.startedAt).not.toBeNull();
});
it('should detect elapsed time pattern', () => {
const loopHandler = vi.fn();
tracker.on('loopUpdate', loopHandler);
tracker.processTerminalData('Elapsed: 2.5 hours\n');
expect(tracker.loopState.elapsedHours).toBe(2.5);
});
it('should detect cycle count pattern', () => {
const loopHandler = vi.fn();
tracker.on('loopUpdate', loopHandler);
tracker.processTerminalData('Starting cycle #5\n');
expect(tracker.loopState.cycleCount).toBe(5);
});
it('should detect respawn cycle pattern', () => {
tracker.processTerminalData('respawn cycle #10\n');
expect(tracker.loopState.cycleCount).toBe(10);
});
});
describe('Todo Detection - Checkbox Format', () => {
it('should detect pending checkbox todos', () => {
const todoHandler = vi.fn();
tracker.on('todoUpdate', todoHandler);
tracker.processTerminalData('- [ ] First task\n');
tracker.flushPendingEvents(); // Flush debounced events
expect(todoHandler).toHaveBeenCalled();
const todos = tracker.todos;
expect(todos).toHaveLength(1);
expect(todos[0].content).toBe('First task');
expect(todos[0].status).toBe('pending');
});
it('should detect completed checkbox todos', () => {
tracker.processTerminalData('- [x] Completed task\n');
const todos = tracker.todos;
expect(todos).toHaveLength(1);
expect(todos[0].content).toBe('Completed task');
expect(todos[0].status).toBe('completed');
});
it('should detect uppercase X as completed', () => {
tracker.processTerminalData('- [X] Also completed\n');
const todos = tracker.todos;
expect(todos[0].status).toBe('completed');
});
it('should handle asterisk bullets', () => {
tracker.processTerminalData('* [ ] Asterisk task\n');
const todos = tracker.todos;
expect(todos).toHaveLength(1);
expect(todos[0].content).toBe('Asterisk task');
});
});
describe('Todo Detection - Indicator Format', () => {
it('should detect pending indicator todos', () => {
tracker.processTerminalData('Todo: ☐ Pending task\n');
const todos = tracker.todos;
expect(todos).toHaveLength(1);
expect(todos[0].content).toBe('Pending task');
expect(todos[0].status).toBe('pending');
});
it('should detect in-progress indicator todos', () => {
tracker.processTerminalData('Todo: ◐ Working on this\n');
const todos = tracker.todos;
expect(todos[0].status).toBe('in_progress');
});
it('should detect completed indicator todos', () => {
tracker.processTerminalData('Todo: ✓ Done task\n');
const todos = tracker.todos;
expect(todos[0].status).toBe('completed');
});
it('should detect checkmark emoji as completed', () => {
tracker.processTerminalData('Todo: ✅ Also done\n');
const todos = tracker.todos;
expect(todos[0].status).toBe('completed');
});
it('should detect hourglass as in-progress', () => {
tracker.processTerminalData('Todo: ⏳ Still working\n');
const todos = tracker.todos;
expect(todos[0].status).toBe('in_progress');
});
});
describe('Todo Detection - Status Parentheses Format', () => {
it('should detect pending status', () => {
tracker.processTerminalData('- Task name (pending)\n');
const todos = tracker.todos;
expect(todos).toHaveLength(1);
expect(todos[0].content).toBe('Task name');
expect(todos[0].status).toBe('pending');
});
it('should detect in_progress status', () => {
tracker.processTerminalData('- Working task (in_progress)\n');
const todos = tracker.todos;
expect(todos[0].status).toBe('in_progress');
});
it('should detect completed status', () => {
tracker.processTerminalData('- Done task (completed)\n');
const todos = tracker.todos;
expect(todos[0].status).toBe('completed');
});
});
describe('Todo Detection - Claude Code Native Format', () => {
it('should detect pending native checkbox (☐)', () => {
tracker.processTerminalData('☐ List files in current directory\n');
const todos = tracker.todos;
expect(todos).toHaveLength(1);
expect(todos[0].content).toBe('List files in current directory');
expect(todos[0].status).toBe('pending');
});
it('should detect completed native checkbox (☒)', () => {
tracker.processTerminalData('☒ Completed task\n');
const todos = tracker.todos;
expect(todos).toHaveLength(1);
expect(todos[0].status).toBe('completed');
});
it('should detect todos with leading bracket (⎿)', () => {
tracker.processTerminalData('⎿ ☐ Task with bracket\n');
const todos = tracker.todos;
expect(todos).toHaveLength(1);
expect(todos[0].content).toBe('Task with bracket');
expect(todos[0].status).toBe('pending');
});
it('should detect todos with leading whitespace', () => {
tracker.processTerminalData(' ☐ Indented task\n');
const todos = tracker.todos;
expect(todos).toHaveLength(1);
expect(todos[0].content).toBe('Indented task');
});
it('should detect in-progress native (◐)', () => {
tracker.processTerminalData('◐ Working on this\n');
const todos = tracker.todos;
expect(todos[0].status).toBe('in_progress');
});
it('should handle multiple native todos in sequence', () => {
tracker.processTerminalData('⎿ ☐ First task\n');
tracker.processTerminalData(' ☐ Second task\n');
tracker.processTerminalData(' ☒ Third task\n');
const todos = tracker.todos;
expect(todos).toHaveLength(3);
expect(todos.filter(t => t.status === 'pending')).toHaveLength(2);
expect(todos.filter(t => t.status === 'completed')).toHaveLength(1);
});
it('should not auto-enable on native todo pattern by default', () => {
const freshTracker = new RalphTracker();
freshTracker.processTerminalData('☐ New task\n');
expect(freshTracker.enabled).toBe(false);
});
it('should auto-enable on native todo pattern when auto-enable allowed', () => {
const freshTracker = new RalphTracker();
freshTracker.enableAutoEnable();
freshTracker.processTerminalData('☐ New task\n');
expect(freshTracker.enabled).toBe(true);
});
});
describe('Todo Updates', () => {
it('should update existing todos by content', () => {
// Add pending todo
tracker.processTerminalData('- [ ] My task\n');
expect(tracker.todos[0].status).toBe('pending');
// Update to completed
tracker.processTerminalData('- [x] My task\n');
expect(tracker.todos).toHaveLength(1); // Still just 1 todo
expect(tracker.todos[0].status).toBe('completed');
});
it('should emit todoUpdate on changes', () => {
const todoHandler = vi.fn();
tracker.on('todoUpdate', todoHandler);
tracker.processTerminalData('- [ ] Task 1\n');
tracker.flushPendingEvents(); // Flush debounced events
tracker.processTerminalData('- [ ] Task 2\n');
tracker.flushPendingEvents(); // Flush debounced events
expect(todoHandler).toHaveBeenCalledTimes(2);
});
});
describe('Todo Stats', () => {
it('should calculate correct stats', () => {
tracker.processTerminalData('- [ ] Pending 1\n');
tracker.processTerminalData('- [ ] Pending 2\n');
tracker.processTerminalData('Todo: ◐ In progress\n');
tracker.processTerminalData('- [x] Completed 1\n');
tracker.processTerminalData('- [x] Completed 2\n');
tracker.processTerminalData('- [x] Completed 3\n');
const stats = tracker.getTodoStats();
expect(stats.total).toBe(6);
expect(stats.pending).toBe(2);
expect(stats.inProgress).toBe(1);
expect(stats.completed).toBe(3);
});
});
describe('Manual Control', () => {
it('should start loop manually', () => {
const loopHandler = vi.fn();
tracker.on('loopUpdate', loopHandler);
tracker.startLoop('MANUAL_PHRASE');
expect(tracker.loopState.active).toBe(true);
expect(tracker.loopState.completionPhrase).toBe('MANUAL_PHRASE');
expect(loopHandler).toHaveBeenCalled();
});
it('should stop loop manually', () => {
tracker.startLoop();
expect(tracker.loopState.active).toBe(true);
tracker.stopLoop();
expect(tracker.loopState.active).toBe(false);
});
it('should clear all state', () => {
// Use unique phrase that won't appear in the todo content
// (bare phrase detection would trigger on common words like 'TEST')
tracker.startLoop('XYZZY_COMPLETE');
tracker.processTerminalData('- [ ] Sample task to clear\n');
expect(tracker.loopState.active).toBe(true);
expect(tracker.todos).toHaveLength(1);
tracker.clear();
expect(tracker.loopState.active).toBe(false);
expect(tracker.loopState.completionPhrase).toBeNull();
expect(tracker.todos).toHaveLength(0);
});
});
describe('State Restoration', () => {
it('should restore state from persisted data', () => {
const loopState: RalphTrackerState = {
enabled: true,
active: true,
completionPhrase: 'RESTORED',
startedAt: Date.now() - 1000,
cycleCount: 5,
maxIterations: 50,
lastActivity: Date.now(),
elapsedHours: 1.5,
};
const todos: RalphTodoItem[] = [
{ id: 'todo-1', content: 'Task 1', status: 'completed', detectedAt: Date.now() },
{ id: 'todo-2', content: 'Task 2', status: 'in_progress', detectedAt: Date.now() },
];
tracker.restoreState(loopState, todos);
expect(tracker.loopState.enabled).toBe(true);
expect(tracker.loopState.active).toBe(true);
expect(tracker.loopState.completionPhrase).toBe('RESTORED');
expect(tracker.loopState.cycleCount).toBe(5);
expect(tracker.loopState.maxIterations).toBe(50);
expect(tracker.todos).toHaveLength(2);
});
it('should handle missing enabled flag in legacy state', () => {
// Simulate old state without enabled flag
const loopState = {
active: true,
completionPhrase: 'TEST',
startedAt: Date.now(),
cycleCount: 0,
maxIterations: null,
lastActivity: Date.now(),
elapsedHours: null,
} as RalphTrackerState;
tracker.restoreState(loopState, []);
// Should default to false for backwards compatibility
expect(tracker.loopState.enabled).toBe(false);
});
});
describe('Enhanced Ralph Detection Patterns', () => {
it('should detect /ralph-loop:ralph-loop command', () => {
const loopHandler = vi.fn();
tracker.on('loopUpdate', loopHandler);
tracker.processTerminalData('/ralph-loop:ralph-loop\n');
expect(loopHandler).toHaveBeenCalled();
expect(tracker.loopState.active).toBe(true);
});
it('should detect "Starting Ralph Wiggum loop"', () => {
tracker.processTerminalData('Starting Ralph Wiggum loop now\n');
expect(tracker.loopState.active).toBe(true);
});
it('should detect "ralph loop started"', () => {
tracker.processTerminalData('ralph loop started at 10:00\n');
expect(tracker.loopState.active).toBe(true);
});
it('should detect iteration pattern "Iteration 5/50"', () => {
const loopHandler = vi.fn();
tracker.on('loopUpdate', loopHandler);
tracker.processTerminalData('Iteration 5/50\n');
expect(tracker.loopState.active).toBe(true);
expect(tracker.loopState.cycleCount).toBe(5);
expect(tracker.loopState.maxIterations).toBe(50);
});
it('should detect iteration pattern "[5/50]"', () => {
tracker.processTerminalData('[5/50] Working on task...\n');
expect(tracker.loopState.active).toBe(true);
expect(tracker.loopState.cycleCount).toBe(5);
expect(tracker.loopState.maxIterations).toBe(50);
});
it('should detect iteration pattern without max "Iteration 3"', () => {
tracker.processTerminalData('Iteration 3 - processing\n');
expect(tracker.loopState.active).toBe(true);
expect(tracker.loopState.cycleCount).toBe(3);
expect(tracker.loopState.maxIterations).toBeNull();
});
it('should detect max-iterations setting', () => {
tracker.processTerminalData('Setting max-iterations: 100\n');
expect(tracker.loopState.maxIterations).toBe(100);
});
it('should detect maxIterations setting', () => {
tracker.processTerminalData('maxIterations=75\n');
expect(tracker.loopState.maxIterations).toBe(75);
});
it('should detect max_iterations setting', () => {
tracker.processTerminalData('config: max_iterations = 25\n');
expect(tracker.loopState.maxIterations).toBe(25);
});
it('should detect TodoWrite tool output', () => {
const loopHandler = vi.fn();
tracker.on('loopUpdate', loopHandler);
// TodoWrite detection should update lastActivity but not emit
tracker.processTerminalData('TodoWrite: Todos have been modified successfully\n');
expect(tracker.loopState.lastActivity).toBeGreaterThan(0);
});
});
describe('startLoop with maxIterations', () => {
it('should set maxIterations when starting loop', () => {
tracker.startLoop('COMPLETE', 100);
expect(tracker.loopState.active).toBe(true);
expect(tracker.loopState.completionPhrase).toBe('COMPLETE');
expect(tracker.loopState.maxIterations).toBe(100);
});
it('should allow setting maxIterations separately', () => {
tracker.startLoop('TEST');
expect(tracker.loopState.maxIterations).toBeNull();
tracker.setMaxIterations(50);
expect(tracker.loopState.maxIterations).toBe(50);
});
});
describe('ANSI Escape Handling', () => {
it('should strip ANSI escape codes before parsing', () => {
// ANSI colored output
tracker.processTerminalData('\x1b[32m- [x] Colored task\x1b[0m\n');
const todos = tracker.todos;
expect(todos).toHaveLength(1);
expect(todos[0].content).toBe('Colored task');
});
});
describe('Buffer Management', () => {
it('should handle incomplete lines across multiple calls', () => {
// Split across two calls
tracker.processTerminalData('- [x] Split');
tracker.processTerminalData(' task\n');
const todos = tracker.todos;
expect(todos).toHaveLength(1);
expect(todos[0].content).toBe('Split task');
});
});
describe('Maximum Todo Limit', () => {
it('should limit to max 500 todos', () => {
// Add 505 todos
for (let i = 0; i < 505; i++) {
tracker.processTerminalData(`- [ ] Task ${i}\n`);
}
expect(tracker.todos.length).toBeLessThanOrEqual(500);
});
});
describe('Edge Cases and Optimizations', () => {
it('should skip empty or whitespace-only content', () => {
// These should not create todos
tracker.processTerminalData('- [ ] \n');
tracker.processTerminalData('- [ ] \n');
expect(tracker.todos).toHaveLength(0);
});
it('should skip lines without todo markers (early exit optimization)', () => {
const todoHandler = vi.fn();
tracker.on('todoUpdate', todoHandler);
// Process lines that have no todo markers
tracker.processTerminalData('This is just regular text\n');
tracker.processTerminalData('Another line without markers\n');
tracker.processTerminalData('Some code: function() {}\n');
// No todoUpdate should be emitted
expect(todoHandler).not.toHaveBeenCalled();
expect(tracker.todos).toHaveLength(0);
});
it('should generate different IDs for different content', () => {
tracker.processTerminalData('- [ ] Task A\n');
tracker.processTerminalData('- [ ] Task B\n');
const todos = tracker.todos;
expect(todos).toHaveLength(2);
expect(todos[0].id).not.toBe(todos[1].id);
});
it('should use activateLoopIfNeeded only once', () => {
const loopHandler = vi.fn();
tracker.on('loopUpdate', loopHandler);
// Multiple loop start patterns should only activate once
tracker.processTerminalData('Loop started at 2024-01-15\n');
tracker.processTerminalData('Starting Ralph loop\n');
tracker.processTerminalData('/ralph-loop:ralph-loop\n');
// Loop should only have been activated once
expect(tracker.loopState.active).toBe(true);
// But multiple updates are OK (state changes)
});
it('should handle mixed content with todos and non-todos', () => {
tracker.processTerminalData(`
Some regular text
- [ ] Actual todo item
More text here
☐ Another todo with icon
Final text
`);
expect(tracker.todos).toHaveLength(2);
});
it('should handle very long todo content', () => {
const longContent = 'x'.repeat(1000);
tracker.processTerminalData(`- [ ] ${longContent}\n`);
const todos = tracker.todos;
expect(todos).toHaveLength(1);
});
it('should handle todos with special characters', () => {
tracker.processTerminalData('- [ ] Task with "quotes" and \'apostrophes\'\n');
tracker.processTerminalData('- [ ] Task with <html> tags\n');
tracker.processTerminalData('- [ ] Task with $variable and `backticks`\n');
expect(tracker.todos).toHaveLength(3);
});
it('should handle todos with numbers', () => {
tracker.processTerminalData('- [ ] Task 123\n');
tracker.processTerminalData('- [ ] 456 numbered\n');
expect(tracker.todos).toHaveLength(2);
});
});
describe('Completion Detection Edge Cases', () => {
it('should not emit completion for partial matches', () => {
const completionHandler = vi.fn();
tracker.on('completionDetected', completionHandler);
tracker.startLoop('COMPLETE');
tracker.processTerminalData('<promise>COMPLE\n'); // Partial
expect(completionHandler).not.toHaveBeenCalled();
});
it('should handle completion phrase with leading/trailing whitespace', () => {
const completionHandler = vi.fn();
tracker.on('completionDetected', completionHandler);
tracker.startLoop();
tracker.processTerminalData(' <promise>DONE</promise> \n');
expect(completionHandler).toHaveBeenCalledWith('DONE');
});
it('should handle multiple completion phrases in one line', () => {
const completionHandler = vi.fn();
tracker.on('completionDetected', completionHandler);
tracker.startLoop();
tracker.processTerminalData('<promise>FIRST</promise> <promise>SECOND</promise>\n');
// Should detect at least one
expect(completionHandler).toHaveBeenCalled();
});
it('should handle nested-looking promise tags', () => {
const completionHandler = vi.fn();
tracker.on('completionDetected', completionHandler);
tracker.startLoop();
tracker.processTerminalData('<promise><promise>NESTED</promise></promise>\n');
// Should handle gracefully
expect(completionHandler).toHaveBeenCalled();
});
});
describe('Loop State Management', () => {
it('should track elapsedHours accurately', () => {
tracker.processTerminalData('Elapsed: 5.5 hours\n');
expect(tracker.loopState.elapsedHours).toBe(5.5);
tracker.processTerminalData('Elapsed: 10.25 hours\n');
expect(tracker.loopState.elapsedHours).toBe(10.25);
});
it('should track integer elapsed hours', () => {
tracker.processTerminalData('Elapsed: 3 hours\n');
expect(tracker.loopState.elapsedHours).toBe(3);
});
it('should handle zero elapsed hours', () => {
tracker.processTerminalData('Elapsed: 0 hours\n');
expect(tracker.loopState.elapsedHours).toBe(0);
});
it('should update lastActivity on any input', () => {
const before = tracker.loopState.lastActivity;
tracker.processTerminalData('some data\n');
const after = tracker.loopState.lastActivity;
expect(after).toBeGreaterThanOrEqual(before);
});
it('should increment cycleCount on cycle pattern', () => {
tracker.processTerminalData('Starting cycle #1\n');
expect(tracker.loopState.cycleCount).toBe(1);
tracker.processTerminalData('Starting cycle #5\n');
expect(tracker.loopState.cycleCount).toBe(5);
tracker.processTerminalData('Starting cycle #10\n');
expect(tracker.loopState.cycleCount).toBe(10);
});
});
describe('Todo Status Updates', () => {
it('should update todo from pending to in_progress', () => {
tracker.processTerminalData('- [ ] Task to update\n');
expect(tracker.todos[0].status).toBe('pending');
tracker.processTerminalData('◐ Task to update\n');
expect(tracker.todos[0].status).toBe('in_progress');
});
it('should update todo from in_progress to completed', () => {
tracker.processTerminalData('◐ Working task\n');
expect(tracker.todos[0].status).toBe('in_progress');
tracker.processTerminalData('✓ Working task\n');
expect(tracker.todos[0].status).toBe('completed');
});
it('should handle multiple status transitions', () => {
tracker.processTerminalData('- [ ] Multi-transition task\n');
expect(tracker.todos[0].status).toBe('pending');
tracker.processTerminalData('Todo: ◐ Multi-transition task\n');
expect(tracker.todos[0].status).toBe('in_progress');
tracker.processTerminalData('- [x] Multi-transition task\n');
expect(tracker.todos[0].status).toBe('completed');
});
it('should not revert completed back to pending', () => {
tracker.processTerminalData('- [x] Done task\n');
expect(tracker.todos[0].status).toBe('completed');
// If we see the same task as pending, it might be different output
// but same content should keep completed status
tracker.processTerminalData('- [ ] Done task\n');
// The update behavior depends on implementation
expect(tracker.todos.length).toBeGreaterThan(0);
});
});
describe('Reset Behaviors', () => {
it('should reset todos but keep enabled on soft reset', () => {
tracker.enable();
tracker.processTerminalData('- [ ] Task 1\n');
expect(tracker.enabled).toBe(true);
expect(tracker.todos).toHaveLength(1);
tracker.reset();
expect(tracker.enabled).toBe(true);
expect(tracker.todos).toHaveLength(0);
});
it('should fully reset everything on fullReset', () => {
tracker.enable();
tracker.startLoop('TEST');
tracker.processTerminalData('- [ ] Task 1\n');
tracker.fullReset();
expect(tracker.enabled).toBe(false);
expect(tracker.todos).toHaveLength(0);
expect(tracker.loopState.active).toBe(false);
expect(tracker.loopState.completionPhrase).toBeNull();
});
});
describe('Debounced Events', () => {
it('should batch rapid todo updates', () => {
const todoHandler = vi.fn();
tracker.on('todoUpdate', todoHandler);
// Rapid updates
for (let i = 0; i < 10; i++) {
tracker.processTerminalData(`- [ ] Task ${i}\n`);
}
// Flush to ensure all events are processed
tracker.flushPendingEvents();
// Should have been called but possibly batched
expect(todoHandler).toHaveBeenCalled();
});
});
describe('Pattern Detection Accuracy', () => {
it('should not match false positives for todos', () => {
tracker.processTerminalData('This is not a [x] checkbox\n');
tracker.processTerminalData('Some text with - in it\n');
tracker.processTerminalData('[x] not at start\n');
// Should not create todos from these false positives
const actualTodos = tracker.todos.filter(t => t.content.length > 0);
expect(actualTodos.length).toBeLessThanOrEqual(1);
});
it('should detect todos in code output', () => {
tracker.processTerminalData('```\n');
tracker.processTerminalData('- [ ] Task in code block\n');
tracker.processTerminalData('```\n');
// Should still detect the todo
expect(tracker.todos.length).toBeGreaterThanOrEqual(1);
});
it('should handle markdown list items correctly', () => {
tracker.processTerminalData('* [ ] Asterisk item 1\n');
tracker.processTerminalData('* [x] Asterisk item 2\n');
tracker.processTerminalData('- [ ] Dash item 1\n');
tracker.processTerminalData('- [x] Dash item 2\n');
expect(tracker.todos).toHaveLength(4);
});
});
describe('Configuration from Ralph Plugin', () => {
it('should configure from external state', () => {
tracker.configure({
enabled: true,
completionPhrase: 'EXTERNAL_PHRASE',
maxIterations: 100,
});
expect(tracker.enabled).toBe(true);
expect(tracker.loopState.completionPhrase).toBe('EXTERNAL_PHRASE');
expect(tracker.loopState.maxIterations).toBe(100);
});
it('should partially configure', () => {
tracker.startLoop('ORIGINAL');
tracker.configure({
maxIterations: 50,
});
expect(tracker.loopState.completionPhrase).toBe('ORIGINAL');
expect(tracker.loopState.maxIterations).toBe(50);
});
});
describe('Serialization', () => {
it('should provide serializable state', () => {
tracker.enable();
tracker.startLoop('TEST', 100);
tracker.processTerminalData('- [ ] Task 1\n');
const state = tracker.loopState;
const serialized = JSON.stringify(state);
const parsed = JSON.parse(serialized);
expect(parsed.enabled).toBe(true);
expect(parsed.completionPhrase).toBe('TEST');
expect(parsed.maxIterations).toBe(100);
});
it('should provide serializable todos', () => {
tracker.processTerminalData('- [ ] Task 1\n');
tracker.processTerminalData('- [x] Task 2\n');
const todos = tracker.todos;
const serialized = JSON.stringify(todos);
const parsed = JSON.parse(serialized);
expect(parsed).toHaveLength(2);
expect(parsed[0].content).toBe('Task 1');
});
});
describe('Edge Cases and Bug Fixes', () => {
describe('ITERATION_PATTERN "X of Y" parsing', () => {
it('should parse "Iteration 5 of 50" format', () => {
tracker.processTerminalData('Iteration 5 of 50\n');
const state = tracker.loopState;
expect(state.cycleCount).toBe(5);
expect(state.maxIterations).toBe(50);
expect(state.active).toBe(true);
});
it('should parse "iter 10 of 100" format', () => {
tracker.processTerminalData('iter 10 of 100\n');
const state = tracker.loopState;
expect(state.cycleCount).toBe(10);
expect(state.maxIterations).toBe(100);
expect(state.active).toBe(true);
});
it('should parse "Iteration 1 of 25" format (lowercase of)', () => {
tracker.processTerminalData('Iteration 1 of 25\n');
const state = tracker.loopState;
expect(state.cycleCount).toBe(1);
expect(state.maxIterations).toBe(25);
});
it('should parse "iter. 7 of 20" format (with period)', () => {
tracker.processTerminalData('iter. 7 of 20\n');
const state = tracker.loopState;
expect(state.cycleCount).toBe(7);
expect(state.maxIterations).toBe(20);
});
it('should parse "Iteration #3 of 15" format (with hash)', () => {
tracker.processTerminalData('Iteration #3 of 15\n');
const state = tracker.loopState;
expect(state.cycleCount).toBe(3);
expect(state.maxIterations).toBe(15);
});
it('should handle mixed case "ITERATION 5 OF 50"', () => {
tracker.processTerminalData('ITERATION 5 OF 50\n');
const state = tracker.loopState;
expect(state.cycleCount).toBe(5);
expect(state.maxIterations).toBe(50);
});
});
describe('Nested promise tags edge case', () => {
it('should handle nested promise tags gracefully', () => {
const completionHandler = vi.fn();
tracker.on('completionDetected', completionHandler);
tracker.startLoop();
tracker.processTerminalData('<promise><promise>NESTED</promise></promise>\n');
// Should capture the inner "NESTED" not "<promise>NESTED"
// The regex [^<]+ will stop at the first < character
expect(completionHandler).toHaveBeenCalled();
// Verify the phrase captured is "NESTED" (from innermost tag)
expect(tracker.loopState.completionPhrase).toBe('NESTED');
});
it('should handle malformed nested tags without crashing', () => {
const completionHandler = vi.fn();
tracker.on('completionDetected', completionHandler);
tracker.startLoop();
// This should not crash even with weird nesting
tracker.processTerminalData('<promise>OUTER<promise>INNER</promise>STILL_OUTER</promise>\n');
// Should still detect something
expect(completionHandler).toHaveBeenCalled();
});
it('should extract correct phrase from consecutive promise tags', () => {
const completionHandler = vi.fn();
tracker.on('completionDetected', completionHandler);
tracker.startLoop();
tracker.processTerminalData('<promise>FIRST</promise> then <promise>SECOND</promise>\n');
// Should have detected at least one completion
expect(completionHandler).toHaveBeenCalled();
});
});
describe('Bare phrase detection after loop starts', () => {
it('should detect bare phrase after startLoop()', () => {
const completionHandler = vi.fn();
tracker.on('completionDetected', completionHandler);
tracker.startLoop('COMPLETE');
tracker.processTerminalData('The task is COMPLETE now.\n');
expect(completionHandler).toHaveBeenCalledWith('COMPLETE');
});
it('should detect bare phrase after tagged phrase was seen', () => {
const completionHandler = vi.fn();
tracker.on('completionDetected', completionHandler);
// First occurrence: tagged phrase (from prompt)
tracker.processTerminalData('<promise>DONE_SIGNAL</promise>\n');
// Second occurrence: bare phrase (actual completion)
tracker.processTerminalData('All work is DONE_SIGNAL finished.\n');
// Should have been called for both occurrences
expect(completionHandler).toHaveBeenCalled();
});
it('should not detect bare phrase if never seen in tags', () => {
const completionHandler = vi.fn();
tracker.on('completionDetected', completionHandler);
tracker.enable();
// No startLoop() call, no tagged phrase seen
tracker.processTerminalData('The task is COMPLETE now.\n');
// Should NOT trigger - no expected phrase established
expect(completionHandler).not.toHaveBeenCalled();
});
it('should only fire once for bare phrase detection', () => {
const completionHandler = vi.fn();
tracker.on('completionDetected', completionHandler);
tracker.startLoop('FINISHED');
tracker.processTerminalData('Task is FINISHED.\n');
tracker.processTerminalData('Everything is FINISHED now.\n');
// Should only fire once for bare phrase
expect(completionHandler).toHaveBeenCalledTimes(1);
});
});
describe('Completion phrase map trimming edge case', () => {
it('should preserve current phrase when trimming map', () => {
const completionHandler = vi.fn();
tracker.on('completionDetected', completionHandler);
tracker.enable();
// Simulate many unique phrases to exceed MAX_COMPLETION_PHRASE_ENTRIES (50)
for (let i = 0; i < 60; i++) {
tracker.processTerminalData(`<promise>PHRASE${i}</promise>\n`);
}
// The most recent phrase should still be tracked and trigger completion
tracker.processTerminalData('<promise>PHRASE59</promise>\n');
// Should have detected completion (second occurrence of PHRASE59)
expect(completionHandler).toHaveBeenCalled();
});
it('should keep high-count phrases when trimming', () => {
const completionHandler = vi.fn();
tracker.on('completionDetected', completionHandler);
tracker.enable();
// Set a phrase with high count
tracker.startLoop('IMPORTANT');
// Add many other phrases
for (let i = 0; i < 55; i++) {
tracker.processTerminalData(`<promise>FILLER${i}</promise>\n`);
}
// The important phrase should still be tracked
tracker.processTerminalData('<promise>IMPORTANT</promise>\n');
expect(completionHandler).toHaveBeenCalledWith('IMPORTANT');
});
});
describe('TODO_TASK_STATUS_PATTERN arrow variations', () => {
it('should detect task status with arrow character (unicode arrow)', () => {
tracker.processTerminalData('✔ Task #1 created: Fix the bug\n');
expect(tracker.todos).toHaveLength(1);
expect(tracker.todos[0].status).toBe('pending');
tracker.processTerminalData('✔ Task #1 updated: status → completed\n');
tracker.flushPendingEvents();
expect(tracker.todos[0].status).toBe('completed');
});
it('should detect task status with in progress update', () => {
tracker.processTerminalData('✔ Task #2 created: Implement feature\n');
tracker.processTerminalData('✔ Task #2 updated: status → in progress\n');
tracker.flushPendingEvents();
const task = tracker.todos.find(t => t.content === 'Implement feature');
expect(task?.status).toBe('in_progress');
});
it('should detect task status with pending update', () => {
tracker.processTerminalData('✔ Task #3 created: Review code\n');
tracker.processTerminalData('✔ Task #3 updated: status → pending\n');
tracker.flushPendingEvents();
const task = tracker.todos.find(t => t.content === 'Review code');
expect(task?.status).toBe('pending');
});
it('should handle multiple task status updates in sequence', () => {
// Create tasks
tracker.processTerminalData('✔ Task #1 created: Task one\n');
tracker.processTerminalData('✔ Task #2 created: Task two\n');
tracker.processTerminalData('✔ Task #3 created: Task three\n');
// Update statuses
tracker.processTerminalData('✔ Task #1 updated: status → completed\n');
tracker.processTerminalData('✔ Task #2 updated: status → in progress\n');
tracker.flushPendingEvents();
const stats = tracker.getTodoStats();
expect(stats.completed).toBe(1);
expect(stats.inProgress).toBe(1);
expect(stats.pending).toBe(1);
});
});
describe('Additional edge cases', () => {
it('should handle promise tag with spaces around phrase', () => {
const completionHandler = vi.fn();
tracker.on('completionDetected', completionHandler);
tracker.startLoop();
tracker.processTerminalData('<promise> SPACED_PHRASE </promise>\n');
// Should capture with spaces (the regex captures [^<]+)
expect(completionHandler).toHaveBeenCalled();
});
it('should handle iteration pattern at end of long line', () => {
const longPrefix = 'Processing task: ' + 'x'.repeat(100) + ' ';
tracker.processTerminalData(`${longPrefix}Iteration 42 of 100\n`);
expect(tracker.loopState.cycleCount).toBe(42);
expect(tracker.loopState.maxIterations).toBe(100);
});
it('should handle zero iteration count gracefully', () => {
tracker.processTerminalData('Iteration 0 of 10\n');
// Should not crash and should record the values
expect(tracker.loopState.cycleCount).toBe(0);
expect(tracker.loopState.maxIterations).toBe(10);
});
it('should handle very large iteration numbers', () => {
tracker.processTerminalData('Iteration 999999 of 1000000\n');
expect(tracker.loopState.cycleCount).toBe(999999);
expect(tracker.loopState.maxIterations).toBe(1000000);
});
it('should preserve enabled state across multiple resets', () => {
tracker.enable();
expect(tracker.enabled).toBe(true);
tracker.reset();
expect(tracker.enabled).toBe(true);
tracker.reset();
expect(tracker.enabled).toBe(true);
// Full reset should disable
tracker.fullReset();
expect(tracker.enabled).toBe(false);
});
it('should handle task summary format without prior creation', () => {
// This tests the "✔ #N content" format when no "created" line was seen
tracker.processTerminalData('✔ #5 Some standalone task\n');
tracker.flushPendingEvents();
// Should create a todo from the summary format
expect(tracker.todos).toHaveLength(1);
expect(tracker.todos[0].content).toBe('Some standalone task');
});
it('should handle consecutive data chunks without newlines', () => {
// Simulate data arriving in chunks
tracker.processTerminalData('- [ ] First ');
tracker.processTerminalData('part of task');
tracker.processTerminalData('\n- [x] Complete task\n');
expect(tracker.todos).toHaveLength(2);
expect(tracker.todos[0].content).toBe('First part of task');
expect(tracker.todos[1].content).toBe('Complete task');
});
it('should not create duplicate todos from repeated output', () => {
// Simulate terminal refresh showing same todo multiple times
for (let i = 0; i < 5; i++) {
tracker.processTerminalData('- [ ] Repeated task\n');
}
expect(tracker.todos).toHaveLength(1);
expect(tracker.todos[0].content).toBe('Repeated task');
});
});
});
// ========== NEW TEST SUITES: RALPH_STATUS, Circuit Breaker, Exit Gate, Priority ==========
describe('RALPH_STATUS Block Parsing', () => {
/**
* Helper to feed a complete RALPH_STATUS block via processTerminalData.
* Lines are joined with newlines and wrapped with start/end markers.
*/
function feedStatusBlock(tracker: RalphTracker, fields: string[]): void {
const block = [
'---RALPH_STATUS---',
...fields,
'---END_RALPH_STATUS---',
].join('\n') + '\n';
tracker.processTerminalData(block);
}
it('should parse a valid status block with all 7 fields', () => {
const handler = vi.fn();
tracker.on('statusBlockDetected', handler);
feedStatusBlock(tracker, [
'STATUS: IN_PROGRESS',
'TASKS_COMPLETED_THIS_LOOP: 3',
'FILES_MODIFIED: 7',
'TESTS_STATUS: PASSING',
'WORK_TYPE: IMPLEMENTATION',
'EXIT_SIGNAL: false',
'RECOMMENDATION: Continue working on feature X',
]);
expect(handler).toHaveBeenCalledTimes(1);
const block: RalphStatusBlock = handler.mock.calls[0][0];
expect(block.status).toBe('IN_PROGRESS');
expect(block.tasksCompletedThisLoop).toBe(3);
expect(block.filesModified).toBe(7);
expect(block.testsStatus).toBe('PASSING');
expect(block.workType).toBe('IMPLEMENTATION');
expect(block.exitSignal).toBe(false);
expect(block.recommendation).toBe('Continue working on feature X');
expect(block.parsedAt).toBeGreaterThan(0);
});
it('should parse block with missing optional fields using defaults', () => {
const handler = vi.fn();
tracker.on('statusBlockDetected', handler);
// Only provide the required STATUS field
feedStatusBlock(tracker, [
'STATUS: COMPLETE',
]);
expect(handler).toHaveBeenCalledTimes(1);
const block: RalphStatusBlock = handler.mock.calls[0][0];
expect(block.status).toBe('COMPLETE');
expect(block.tasksCompletedThisLoop).toBe(0);
expect(block.filesModified).toBe(0);
expect(block.testsStatus).toBe('NOT_RUN');
expect(block.workType).toBe('IMPLEMENTATION');
expect(block.exitSignal).toBe(false);
expect(block.recommendation).toBe('');
});
it('should ignore malformed block without END marker', () => {
const handler = vi.fn();
tracker.on('statusBlockDetected', handler);
// No ---END_RALPH_STATUS--- marker
tracker.processTerminalData(
'---RALPH_STATUS---\n' +
'STATUS: IN_PROGRESS\n' +
'TASKS_COMPLETED_THIS_LOOP: 5\n' +
'Some other text\n'
);
expect(handler).not.toHaveBeenCalled();
});
it('should skip block missing required STATUS field', () => {
const handler = vi.fn();
tracker.on('statusBlockDetected', handler);
// Block with no STATUS field
feedStatusBlock(tracker, [
'TASKS_COMPLETED_THIS_LOOP: 5',
'FILES_MODIFIED: 2',
]);
expect(handler).not.toHaveBeenCalled();
});
it('should handle multiple blocks in sequence (latest wins for lastStatusBlock)', () => {
const handler = vi.fn();
tracker.on('statusBlockDetected', handler);
feedStatusBlock(tracker, [
'STATUS: IN_PROGRESS',
'FILES_MODIFIED: 1',
]);
feedStatusBlock(tracker, [
'STATUS: COMPLETE',
'FILES_MODIFIED: 10',
'EXIT_SIGNAL: true',
]);
expect(handler).toHaveBeenCalledTimes(2);
// lastStatusBlock should be the second one
const last = tracker.lastStatusBlock;
expect(last).not.toBeNull();
expect(last!.status).toBe('COMPLETE');
expect(last!.filesModified).toBe(10);
expect(last!.exitSignal).toBe(true);
});
it('should parse case-insensitive field values', () => {
const handler = vi.fn();
tracker.on('statusBlockDetected', handler);
feedStatusBlock(tracker, [
'STATUS: in_progress',
'TESTS_STATUS: failing',
'WORK_TYPE: testing',
'EXIT_SIGNAL: True',
]);
expect(handler).toHaveBeenCalledTimes(1);
const block: RalphStatusBlock = handler.mock.calls[0][0];
expect(block.status).toBe('IN_PROGRESS');
expect(block.testsStatus).toBe('FAILING');
expect(block.workType).toBe('TESTING');
expect(block.exitSignal).toBe(true);
});
it('should update cumulative stats across multiple blocks', () => {
feedStatusBlock(tracker, [
'STATUS: IN_PROGRESS',
'FILES_MODIFIED: 3',
'TASKS_COMPLETED_THIS_LOOP: 2',
]);
feedStatusBlock(tracker, [
'STATUS: IN_PROGRESS',
'FILES_MODIFIED: 5',
'TASKS_COMPLETED_THIS_LOOP: 1',
]);
const stats = tracker.cumulativeStats;
expect(stats.filesModified).toBe(8);
expect(stats.tasksCompleted).toBe(3);
});
it('should parse BLOCKED status', () => {
const handler = vi.fn();
tracker.on('statusBlockDetected', handler);
feedStatusBlock(tracker, [
'STATUS: BLOCKED',
'RECOMMENDATION: Need human review of failing tests',
]);
expect(handler).toHaveBeenCalledTimes(1);
const block: RalphStatusBlock = handler.mock.calls[0][0];
expect(block.status).toBe('BLOCKED');
expect(block.recommendation).toBe('Need human review of failing tests');
});
});
describe('Circuit Breaker State Transitions', () => {
/**
* Helper to feed a status block with specific progress/test values.
*/
function feedStatusBlock(tracker: RalphTracker, opts: {
filesModified?: number;
tasksCompleted?: number;
testsStatus?: string;
status?: string;
}): void {
const fields = [
`STATUS: ${opts.status ?? 'IN_PROGRESS'}`,
`FILES_MODIFIED: ${opts.filesModified ?? 0}`,
`TASKS_COMPLETED_THIS_LOOP: ${opts.tasksCompleted ?? 0}`,
];
if (opts.testsStatus) {
fields.push(`TESTS_STATUS: ${opts.testsStatus}`);
}
const block = [
'---RALPH_STATUS---',
...fields,
'---END_RALPH_STATUS---',
].join('\n') + '\n';
tracker.processTerminalData(block);
}
it('should start in CLOSED state', () => {
expect(tracker.circuitBreakerStatus.state).toBe('CLOSED');
});
it('should transition CLOSED → HALF_OPEN on 2 consecutive no-progress', () => {
const handler = vi.fn();
tracker.on('circuitBreakerUpdate', handler);
// 2 iterations with no progress (filesModified=0, tasksCompleted=0)
feedStatusBlock(tracker, { filesModified: 0, tasksCompleted: 0 });
feedStatusBlock(tracker, { filesModified: 0, tasksCompleted: 0 });
expect(tracker.circuitBreakerStatus.state).toBe('HALF_OPEN');
expect(handler).toHaveBeenCalled();
const status: CircuitBreakerStatus = handler.mock.calls[handler.mock.calls.length - 1][0];
expect(status.state).toBe('HALF_OPEN');
expect(status.reasonCode).toBe('no_progress_warning');
});
it('should transition CLOSED → OPEN on 3 consecutive no-progress', () => {
const handler = vi.fn();
tracker.on('circuitBreakerUpdate', handler);
feedStatusBlock(tracker, { filesModified: 0, tasksCompleted: 0 });
feedStatusBlock(tracker, { filesModified: 0, tasksCompleted: 0 });
feedStatusBlock(tracker, { filesModified: 0, tasksCompleted: 0 });
expect(tracker.circuitBreakerStatus.state).toBe('OPEN');
expect(tracker.circuitBreakerStatus.reasonCode).toBe('no_progress_open');
});
it('should transition HALF_OPEN → CLOSED when progress detected', () => {
const handler = vi.fn();
tracker.on('circuitBreakerUpdate', handler);
// Get to HALF_OPEN (2 no-progress)
feedStatusBlock(tracker, { filesModified: 0, tasksCompleted: 0 });
feedStatusBlock(tracker, { filesModified: 0, tasksCompleted: 0 });
expect(tracker.circuitBreakerStatus.state).toBe('HALF_OPEN');
// Progress detected → should close circuit
feedStatusBlock(tracker, { filesModified: 3, tasksCompleted: 1 });
expect(tracker.circuitBreakerStatus.state).toBe('CLOSED');
expect(tracker.circuitBreakerStatus.reasonCode).toBe('progress_detected');
});
it('should transition HALF_OPEN → OPEN on continued no-progress', () => {
// Get to HALF_OPEN
feedStatusBlock(tracker, { filesModified: 0, tasksCompleted: 0 });
feedStatusBlock(tracker, { filesModified: 0, tasksCompleted: 0 });
expect(tracker.circuitBreakerStatus.state).toBe('HALF_OPEN');
// One more no-progress → OPEN (consecutiveNoProgress now 3)
feedStatusBlock(tracker, { filesModified: 0, tasksCompleted: 0 });
expect(tracker.circuitBreakerStatus.state).toBe('OPEN');
});
it('should reset from OPEN → CLOSED via resetCircuitBreaker()', () => {
const handler = vi.fn();
tracker.on('circuitBreakerUpdate', handler);
// Get to OPEN
feedStatusBlock(tracker, { filesModified: 0, tasksCompleted: 0 });
feedStatusBlock(tracker, { filesModified: 0, tasksCompleted: 0 });
feedStatusBlock(tracker, { filesModified: 0, tasksCompleted: 0 });
expect(tracker.circuitBreakerStatus.state).toBe('OPEN');
tracker.resetCircuitBreaker();
expect(tracker.circuitBreakerStatus.state).toBe('CLOSED');
expect(tracker.circuitBreakerStatus.reasonCode).toBe('manual_reset');
expect(tracker.circuitBreakerStatus.reason).toBe('Manual reset');
});
it('should open on 5 consecutive test failures', () => {
for (let i = 0; i < 5; i++) {
feedStatusBlock(tracker, { filesModified: 1, tasksCompleted: 0, testsStatus: 'FAILING' });
}
expect(tracker.circuitBreakerStatus.state).toBe('OPEN');
expect(tracker.circuitBreakerStatus.reasonCode).toBe('tests_failing_too_long');
});
it('should reset test failure count when tests pass', () => {
// 4 failing iterations
for (let i = 0; i < 4; i++) {
feedStatusBlock(tracker, { filesModified: 1, tasksCompleted: 0, testsStatus: 'FAILING' });
}
expect(tracker.circuitBreakerStatus.state).toBe('CLOSED');
// Tests pass → reset counter
feedStatusBlock(tracker, { filesModified: 1, tasksCompleted: 0, testsStatus: 'PASSING' });
// 4 more failing → should NOT open (counter was reset)
for (let i = 0; i < 4; i++) {
feedStatusBlock(tracker, { filesModified: 1, tasksCompleted: 0, testsStatus: 'FAILING' });
}
expect(tracker.circuitBreakerStatus.state).toBe('CLOSED');
});
it('should open immediately on BLOCKED status', () => {
feedStatusBlock(tracker, { filesModified: 1, tasksCompleted: 1, status: 'BLOCKED' });
expect(tracker.circuitBreakerStatus.state).toBe('OPEN');
expect(tracker.circuitBreakerStatus.reasonCode).toBe('same_error_repeated');
});
it('should emit circuitBreakerUpdate only on state transitions', () => {
const handler = vi.fn();
tracker.on('circuitBreakerUpdate', handler);
// First no-progress: CLOSED → CLOSED (no transition, no event)
feedStatusBlock(tracker, { filesModified: 0, tasksCompleted: 0 });
expect(handler).not.toHaveBeenCalled();
// Second no-progress: CLOSED → HALF_OPEN (transition → event)
feedStatusBlock(tracker, { filesModified: 0, tasksCompleted: 0 });
expect(handler).toHaveBeenCalledTimes(1);
});
});
describe('Dual-Condition Exit Gate', () => {
/**
* Helper to feed a RALPH_STATUS block.
*/
function feedStatusBlock(tracker: RalphTracker, opts: {
status?: string;
exitSignal?: boolean;
filesModified?: number;
}): void {
const fields = [
`STATUS: ${opts.status ?? 'IN_PROGRESS'}`,
`EXIT_SIGNAL: ${opts.exitSignal ?? false}`,
`FILES_MODIFIED: ${opts.filesModified ?? 0}`,
];
const block = [
'---RALPH_STATUS---',
...fields,
'---END_RALPH_STATUS---',
].join('\n') + '\n';
tracker.processTerminalData(block);
}
it('should fire exitGateMet when completionIndicators >= 2 AND exitSignal = true', () => {
const handler = vi.fn();
tracker.on('exitGateMet', handler);
// Feed 2 COMPLETE status blocks (each increments completionIndicators)
feedStatusBlock(tracker, { status: 'COMPLETE', exitSignal: false });
feedStatusBlock(tracker, { status: 'COMPLETE', exitSignal: false });
expect(handler).not.toHaveBeenCalled();
// Now send exitSignal: true with indicators already >= 2
feedStatusBlock(tracker, { status: 'COMPLETE', exitSignal: true });
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][0]).toEqual({
completionIndicators: 3,
exitSignal: true,
});
expect(tracker.exitGateMet).toBe(true);
});
it('should NOT fire exitGateMet when indicators >= 2 but exitSignal is false', () => {
const handler = vi.fn();
tracker.on('exitGateMet', handler);
// Feed 3 COMPLETE blocks without exitSignal
feedStatusBlock(tracker, { status: 'COMPLETE', exitSignal: false });
feedStatusBlock(tracker, { status: 'COMPLETE', exitSignal: false });
feedStatusBlock(tracker, { status: 'COMPLETE', exitSignal: false });
expect(handler).not.toHaveBeenCalled();
expect(tracker.exitGateMet).toBe(false);
});
it('should NOT fire exitGateMet when exitSignal is true but indicators < 2', () => {
const handler = vi.fn();
tracker.on('exitGateMet', handler);
// Only 1 COMPLETE + exitSignal
feedStatusBlock(tracker, { status: 'COMPLETE', exitSignal: true });
expect(handler).not.toHaveBeenCalled();
expect(tracker.exitGateMet).toBe(false);
});
it('should only fire exitGateMet once (not on subsequent qualifying blocks)', () => {
const handler = vi.fn();
tracker.on('exitGateMet', handler);
// Get to 2 indicators
feedStatusBlock(tracker, { status: 'COMPLETE', exitSignal: false });
feedStatusBlock(tracker, { status: 'COMPLETE', exitSignal: true });
expect(handler).toHaveBeenCalledTimes(1);
// Send another qualifying block — should NOT fire again
feedStatusBlock(tracker, { status: 'COMPLETE', exitSignal: true });
expect(handler).toHaveBeenCalledTimes(1);
});
it('should track completionIndicators in cumulativeStats', () => {
feedStatusBlock(tracker, { status: 'COMPLETE' });
feedStatusBlock(tracker, { status: 'IN_PROGRESS' });
feedStatusBlock(tracker, { status: 'COMPLETE' });
expect(tracker.cumulativeStats.completionIndicators).toBe(2);
});
});
describe('Priority Todo Parsing', () => {
it('should assign P0 for critical keywords', () => {
const criticalKeywords = [
'CRITICAL: Fix database connection',
'This is a BLOCKER for release',
'URGENT: Deploy hotfix now',
'Security vulnerability found in auth',
'Application is CRASHING on startup',
'Login page is BROKEN',
];
for (const keyword of criticalKeywords) {
const freshTracker = new RalphTracker();
freshTracker.enable();
freshTracker.processTerminalData(`- [ ] ${keyword}\n`);
const todos = freshTracker.todos;
expect(todos).toHaveLength(1);
expect(todos[0].priority).toBe('P0');
}
});
it('should assign P1 for high priority keywords', () => {
const highKeywords = [
'IMPORTANT: Update user validation',
'HIGH PRIORITY: Review API changes',
'Fix the BUG in payment processing',
'FIX: Handle null pointer in parser',
'ERROR in authentication flow',
'Tests are FAILING on CI',
];
for (const keyword of highKeywords) {
const freshTracker = new RalphTracker();
freshTracker.enable();
freshTracker.processTerminalData(`- [ ] ${keyword}\n`);
const todos = freshTracker.todos;
expect(todos).toHaveLength(1);
expect(todos[0].priority).toBe('P1');
}
});
it('should assign P2 for lower priority keywords', () => {
const lowKeywords = [
'NICE TO HAVE: Add dark mode',
'LOW PRIORITY: Update readme',
'REFACTOR the database layer',
'CLEANUP old migration files',
'IMPROVE the logging output',
'OPTIMIZE query performance',
];
for (const keyword of lowKeywords) {
const freshTracker = new RalphTracker();
freshTracker.enable();
freshTracker.processTerminalData(`- [ ] ${keyword}\n`);
const todos = freshTracker.todos;
expect(todos).toHaveLength(1);
expect(todos[0].priority).toBe('P2');
}
});
it('should assign null priority when no keywords match', () => {
tracker.processTerminalData('- [ ] Add unit tests for user service\n');
const todos = tracker.todos;
expect(todos).toHaveLength(1);
expect(todos[0].priority).toBeNull();
});
it('should assign P0 over P1 when both match (highest wins)', () => {
// "CRITICAL" is P0 and "BUG" is P1 — P0 should win
tracker.processTerminalData('- [ ] CRITICAL BUG in production\n');
const todos = tracker.todos;
expect(todos).toHaveLength(1);
expect(todos[0].priority).toBe('P0');
});
it('should detect explicit P0/P1/P2 labels', () => {
tracker.processTerminalData('- [ ] P0: Server is down\n');
tracker.processTerminalData('- [ ] (P1) Review PR comments\n');
tracker.processTerminalData('- [ ] P2: Add logging\n');
const todos = tracker.todos;
expect(todos).toHaveLength(3);
expect(todos.find(t => t.content.includes('Server'))?.priority).toBe('P0');
expect(todos.find(t => t.content.includes('Review'))?.priority).toBe('P1');
expect(todos.find(t => t.content.includes('logging'))?.priority).toBe('P2');
});
it('should be case-insensitive for priority keywords', () => {
tracker.processTerminalData('- [ ] critical issue with login\n');
const todos = tracker.todos;
expect(todos).toHaveLength(1);
expect(todos[0].priority).toBe('P0');
});
});
describe('Debouncer migration', () => {
// EVENT_DEBOUNCE_MS is 50 in ralph-tracker.ts
const EVENT_DEBOUNCE_MS = 50;
beforeEach(() => {
vi.useFakeTimers();
tracker = new RalphTracker();
tracker.enable();
});
afterEach(() => {
tracker.destroy();
vi.useRealTimers();
});
it('should debounce todoUpdate events (not fire immediately)', () => {
const handler = vi.fn();
tracker.on('todoUpdate', handler);
tracker.processTerminalData('- [ ] Fix the bug\n');
// Should not fire immediately
expect(handler).not.toHaveBeenCalled();
// Should fire after debounce delay
vi.advanceTimersByTime(EVENT_DEBOUNCE_MS);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(expect.arrayContaining([
expect.objectContaining({ content: expect.stringContaining('Fix the bug') }),
]));
});
it('should debounce loopUpdate events (not fire immediately)', () => {
const handler = vi.fn();
tracker.on('loopUpdate', handler);
// enable() emits loopUpdate synchronously — capture that first
tracker.processTerminalData('<promise>SETUP</promise>\n');
const callsAfterSetup = handler.mock.calls.length;
// Iteration/Elapsed/Cycle lines use emitLoopUpdateDebounced()
tracker.processTerminalData('Elapsed: 2.5 hours\n');
// Should not fire immediately (debounced)
expect(handler).toHaveBeenCalledTimes(callsAfterSetup);
// Should fire after debounce delay
vi.advanceTimersByTime(EVENT_DEBOUNCE_MS);
expect(handler).toHaveBeenCalledTimes(callsAfterSetup + 1);
});
it('should flush pending todoUpdate events immediately via flushPendingEvents()', () => {
const handler = vi.fn();
tracker.on('todoUpdate', handler);
tracker.processTerminalData('- [ ] Pending task\n');
expect(handler).not.toHaveBeenCalled();
tracker.flushPendingEvents();
expect(handler).toHaveBeenCalledTimes(1);
// Timer should be cancelled — no duplicate after delay
vi.advanceTimersByTime(EVENT_DEBOUNCE_MS);
expect(handler).toHaveBeenCalledTimes(1);
});
it('should flush pending loopUpdate events immediately via flushPendingEvents()', () => {
const handler = vi.fn();
tracker.on('loopUpdate', handler);
// Use Elapsed pattern which goes through emitLoopUpdateDebounced()
tracker.processTerminalData('Elapsed: 3.0 hours\n');
const callsBefore = handler.mock.calls.length;
// Should have a pending debounce (not yet fired)
tracker.flushPendingEvents();
expect(handler).toHaveBeenCalledTimes(callsBefore + 1);
// No duplicate after delay
vi.advanceTimersByTime(EVENT_DEBOUNCE_MS);
expect(handler).toHaveBeenCalledTimes(callsBefore + 1);
});
it('should not fire pending events after destroy()', () => {
const todoHandler = vi.fn();
const loopHandler = vi.fn();
tracker.on('todoUpdate', todoHandler);
tracker.on('loopUpdate', loopHandler);
// Feed data that uses debounced paths
tracker.processTerminalData('- [ ] Will not arrive\n');
tracker.processTerminalData('Elapsed: 5.0 hours\n');
const todoCallsBefore = todoHandler.mock.calls.length;
const loopCallsBefore = loopHandler.mock.calls.length;
tracker.destroy();
// Advance past debounce — nothing new should fire
vi.advanceTimersByTime(EVENT_DEBOUNCE_MS * 2);
expect(todoHandler).toHaveBeenCalledTimes(todoCallsBefore);
expect(loopHandler).toHaveBeenCalledTimes(loopCallsBefore);
});
it('should coalesce rapid todoUpdate events into one emission', () => {
const handler = vi.fn();
tracker.on('todoUpdate', handler);
// Feed multiple todo items rapidly (within debounce window)
tracker.processTerminalData('- [ ] Task one\n');
vi.advanceTimersByTime(10);
tracker.processTerminalData('- [ ] Task two\n');
vi.advanceTimersByTime(10);
tracker.processTerminalData('- [ ] Task three\n');
// Still within debounce window — nothing fired yet
expect(handler).not.toHaveBeenCalled();
// Advance past debounce from the last schedule
vi.advanceTimersByTime(EVENT_DEBOUNCE_MS);
expect(handler).toHaveBeenCalledTimes(1);
// The final emission should include all 3 todos
const emittedTodos = handler.mock.calls[0][0];
expect(emittedTodos).toHaveLength(3);
});
it('should coalesce rapid loopUpdate events into one emission', () => {
const handler = vi.fn();
tracker.on('loopUpdate', handler);
// Feed multiple loop state changes rapidly
tracker.processTerminalData('Iteration: 1/10\n');
vi.advanceTimersByTime(10);
tracker.processTerminalData('Elapsed: 1.5 hours\n');
vi.advanceTimersByTime(10);
tracker.processTerminalData('Cycle: 3\n');
// Still within debounce window
expect(handler).not.toHaveBeenCalled();
// Advance past debounce
vi.advanceTimersByTime(EVENT_DEBOUNCE_MS);
expect(handler).toHaveBeenCalledTimes(1);
});
it('should allow new debounced events after flush', () => {
const handler = vi.fn();
tracker.on('todoUpdate', handler);
tracker.processTerminalData('- [ ] First batch\n');
tracker.flushPendingEvents();
expect(handler).toHaveBeenCalledTimes(1);
// Feed more data — should debounce again
tracker.processTerminalData('- [ ] Second batch\n');
expect(handler).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(EVENT_DEBOUNCE_MS);
expect(handler).toHaveBeenCalledTimes(2);
});
it('should clear pending debounced events on reset()', () => {
const handler = vi.fn();
tracker.on('todoUpdate', handler);
tracker.processTerminalData('- [ ] About to reset\n');
expect(handler).not.toHaveBeenCalled();
tracker.reset();
// Timer should have been cancelled by reset
vi.advanceTimersByTime(EVENT_DEBOUNCE_MS * 2);
expect(handler).not.toHaveBeenCalled();
});
});
});