Files
Codeman/test/terminal-parsing.test.ts
T
arkonandClaude Opus 4.5 34fb33fbc6 test: add global test setup with screen limits and reach 1337 tests
- Add test/setup.ts with max 10 concurrent screen sessions limiter
- Add orphaned Claude/screen process cleanup before/after tests
- Add semaphore-based screen slot acquisition for concurrency control
- Update vitest.config.ts with setupFiles and fileParallelism: false
- Add 16 new test files for comprehensive coverage
- Update README badge to show 1337 total tests
- Update CLAUDE.md with test setup documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 11:30:10 +01:00

603 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @fileoverview Tests for terminal output parsing utilities
*
* Tests various terminal output parsing patterns used throughout
* the Claudeman application.
*/
import { describe, it, expect } from 'vitest';
describe('Terminal Output Parsing', () => {
describe('ANSI Escape Code Stripping', () => {
// Comprehensive ANSI escape pattern including DEC Private Mode sequences
const stripAnsi = (str: string): string => {
return str.replace(/\x1b\[[\?]?[0-9;]*[a-zA-Z]/g, '');
};
it('should strip color codes', () => {
expect(stripAnsi('\x1b[32mgreen\x1b[0m')).toBe('green');
expect(stripAnsi('\x1b[1;34mbold blue\x1b[0m')).toBe('bold blue');
expect(stripAnsi('\x1b[31;1mred bold\x1b[0m')).toBe('red bold');
});
it('should strip cursor movement codes', () => {
expect(stripAnsi('\x1b[Htext')).toBe('text');
expect(stripAnsi('\x1b[2Jclear')).toBe('clear');
expect(stripAnsi('\x1b[10;20Hposition')).toBe('position');
});
it('should strip multiple codes', () => {
expect(stripAnsi('\x1b[32m\x1b[1mmultiple\x1b[0m')).toBe('multiple');
});
it('should handle no codes', () => {
expect(stripAnsi('plain text')).toBe('plain text');
});
it('should handle empty string', () => {
expect(stripAnsi('')).toBe('');
});
it('should strip complex sequences', () => {
const complex = '\x1b[?25l\x1b[32mtext\x1b[0m\x1b[?25h';
expect(stripAnsi(complex)).toBe('text');
});
});
describe('Token Parsing', () => {
const parseTokens = (line: string): { total: number; unit: string } | null => {
const match = line.match(/(\d+(?:\.\d+)?)\s*([kKmM])?\s*tokens?/);
if (!match) return null;
let total = parseFloat(match[1]);
const unit = match[2]?.toLowerCase() || '';
if (unit === 'k') total *= 1000;
else if (unit === 'm') total *= 1000000;
return { total, unit };
};
it('should parse simple token count', () => {
const result = parseTokens('100 tokens');
expect(result?.total).toBe(100);
});
it('should parse k (thousand) suffix', () => {
const result = parseTokens('50k tokens');
expect(result?.total).toBe(50000);
});
it('should parse K (uppercase) suffix', () => {
const result = parseTokens('50K tokens');
expect(result?.total).toBe(50000);
});
it('should parse m (million) suffix', () => {
const result = parseTokens('1.5m tokens');
expect(result?.total).toBe(1500000);
});
it('should parse decimal values', () => {
const result = parseTokens('123.4k tokens');
expect(result?.total).toBe(123400);
});
it('should handle singular "token"', () => {
const result = parseTokens('1 token');
expect(result?.total).toBe(1);
});
it('should return null for no match', () => {
expect(parseTokens('no token count')).toBeNull();
});
it('should handle embedded token count', () => {
const result = parseTokens('Used 50k tokens so far');
expect(result?.total).toBe(50000);
});
});
describe('Cost Parsing', () => {
const parseCost = (line: string): number | null => {
const match = line.match(/\$([0-9]+(?:\.[0-9]+)?)/);
return match ? parseFloat(match[1]) : null;
};
it('should parse dollar amounts', () => {
expect(parseCost('Cost: $0.05')).toBe(0.05);
expect(parseCost('$1.23')).toBe(1.23);
expect(parseCost('Total: $10.00')).toBe(10.00);
});
it('should handle integer amounts', () => {
expect(parseCost('$5')).toBe(5);
});
it('should return null for no match', () => {
expect(parseCost('no cost here')).toBeNull();
});
it('should parse first occurrence', () => {
expect(parseCost('$0.01 and $0.02')).toBe(0.01);
});
});
describe('Prompt Detection', () => {
const isPrompt = (line: string): boolean => {
const promptPatterns = [
/❯\s*$/,
/>\s*$/,
/\$\s*$/,
/⏵\s*$/,
/↵\s*send/,
];
return promptPatterns.some(p => p.test(line));
};
it('should detect chevron prompt', () => {
expect(isPrompt('❯ ')).toBe(true);
expect(isPrompt('dir ❯')).toBe(true);
});
it('should detect greater-than prompt', () => {
expect(isPrompt('> ')).toBe(true);
});
it('should detect dollar prompt', () => {
expect(isPrompt('user$ ')).toBe(true);
});
it('should detect play button prompt', () => {
expect(isPrompt('⏵ ')).toBe(true);
});
it('should detect send indicator', () => {
expect(isPrompt('↵ send')).toBe(true);
});
it('should not match regular text', () => {
expect(isPrompt('Hello world')).toBe(false);
});
});
describe('Working State Detection', () => {
const isWorking = (line: string): boolean => {
const workingPatterns = [
/\bThinking\b/i,
/\bWriting\b/i,
/\bRunning\b/i,
/\bSearching\b/i,
/\bReading\b/i,
/\bAnalyzing\b/i,
/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/,
];
return workingPatterns.some(p => p.test(line));
};
it('should detect Thinking state', () => {
expect(isWorking('Thinking...')).toBe(true);
expect(isWorking('thinking about it')).toBe(true);
});
it('should detect Writing state', () => {
expect(isWorking('Writing code...')).toBe(true);
});
it('should detect Running state', () => {
expect(isWorking('Running tests...')).toBe(true);
});
it('should detect spinner characters', () => {
expect(isWorking('Loading ⠋')).toBe(true);
expect(isWorking('⠹ Processing')).toBe(true);
});
it('should not match idle text', () => {
expect(isWorking('Ready for input')).toBe(false);
});
});
describe('Line Buffering', () => {
class LineBuffer {
private buffer = '';
process(data: string): string[] {
this.buffer += data;
const lines: string[] = [];
let newlineIndex;
while ((newlineIndex = this.buffer.indexOf('\n')) !== -1) {
lines.push(this.buffer.slice(0, newlineIndex));
this.buffer = this.buffer.slice(newlineIndex + 1);
}
return lines;
}
getRemaining(): string {
return this.buffer;
}
clear(): void {
this.buffer = '';
}
}
it('should buffer incomplete lines', () => {
const buffer = new LineBuffer();
const lines = buffer.process('incomplete');
expect(lines).toHaveLength(0);
expect(buffer.getRemaining()).toBe('incomplete');
});
it('should emit complete lines', () => {
const buffer = new LineBuffer();
const lines = buffer.process('complete\n');
expect(lines).toEqual(['complete']);
expect(buffer.getRemaining()).toBe('');
});
it('should handle multiple lines', () => {
const buffer = new LineBuffer();
const lines = buffer.process('line1\nline2\nline3\n');
expect(lines).toEqual(['line1', 'line2', 'line3']);
});
it('should accumulate across calls', () => {
const buffer = new LineBuffer();
let lines = buffer.process('par');
expect(lines).toHaveLength(0);
lines = buffer.process('tial\n');
expect(lines).toEqual(['partial']);
});
it('should clear buffer', () => {
const buffer = new LineBuffer();
buffer.process('data');
buffer.clear();
expect(buffer.getRemaining()).toBe('');
});
});
describe('JSON Message Parsing', () => {
interface ClaudeMessage {
type: 'system' | 'assistant' | 'user' | 'result';
message?: {
content: Array<{ type: string; text?: string }>;
};
total_cost_usd?: number;
}
const parseClaudeMessage = (line: string): ClaudeMessage | null => {
try {
const cleaned = line.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
return JSON.parse(cleaned);
} catch {
return null;
}
};
it('should parse valid JSON', () => {
const json = '{"type":"assistant","message":{"content":[{"type":"text","text":"Hello"}]}}';
const msg = parseClaudeMessage(json);
expect(msg?.type).toBe('assistant');
expect(msg?.message?.content[0].text).toBe('Hello');
});
it('should parse JSON with ANSI codes', () => {
const json = '\x1b[32m{"type":"system"}\x1b[0m';
const msg = parseClaudeMessage(json);
expect(msg?.type).toBe('system');
});
it('should return null for invalid JSON', () => {
expect(parseClaudeMessage('not json')).toBeNull();
expect(parseClaudeMessage('{invalid}')).toBeNull();
});
it('should parse result message with cost', () => {
const json = '{"type":"result","total_cost_usd":0.05}';
const msg = parseClaudeMessage(json);
expect(msg?.type).toBe('result');
expect(msg?.total_cost_usd).toBe(0.05);
});
});
describe('Tool Use Detection', () => {
const detectToolUse = (content: Array<{ type: string; name?: string }>): string[] => {
return content
.filter(block => block.type === 'tool_use' && block.name)
.map(block => block.name!);
};
it('should detect single tool use', () => {
const content = [
{ type: 'text', text: 'Let me read the file' },
{ type: 'tool_use', name: 'Read' },
];
expect(detectToolUse(content)).toEqual(['Read']);
});
it('should detect multiple tool uses', () => {
const content = [
{ type: 'tool_use', name: 'Read' },
{ type: 'tool_use', name: 'Write' },
{ type: 'tool_use', name: 'Bash' },
];
expect(detectToolUse(content)).toEqual(['Read', 'Write', 'Bash']);
});
it('should handle no tool uses', () => {
const content = [
{ type: 'text', text: 'Just text' },
];
expect(detectToolUse(content)).toEqual([]);
});
it('should ignore tool_result', () => {
const content = [
{ type: 'tool_use', name: 'Read' },
{ type: 'tool_result' },
];
expect(detectToolUse(content)).toEqual(['Read']);
});
});
describe('Completion Phrase Extraction', () => {
const extractCompletionPhrase = (text: string): string | null => {
const match = text.match(/<promise>([A-Z0-9_-]+)<\/promise>/);
return match ? match[1] : null;
};
it('should extract simple phrase', () => {
expect(extractCompletionPhrase('<promise>COMPLETE</promise>')).toBe('COMPLETE');
});
it('should extract phrase with underscores', () => {
expect(extractCompletionPhrase('<promise>ALL_DONE</promise>')).toBe('ALL_DONE');
});
it('should extract phrase with hyphens', () => {
expect(extractCompletionPhrase('<promise>TESTS-PASS</promise>')).toBe('TESTS-PASS');
});
it('should extract phrase with numbers', () => {
expect(extractCompletionPhrase('<promise>TASK_123</promise>')).toBe('TASK_123');
});
it('should return null for no match', () => {
expect(extractCompletionPhrase('no promise here')).toBeNull();
});
it('should not match lowercase', () => {
expect(extractCompletionPhrase('<promise>lowercase</promise>')).toBeNull();
});
it('should not match empty promise', () => {
expect(extractCompletionPhrase('<promise></promise>')).toBeNull();
});
});
describe('Iteration Pattern Detection', () => {
const parseIteration = (text: string): { current: number; max: number | null } | null => {
// Pattern: "Iteration 5/50" or "[5/50]" or "Iteration 5"
const slashMatch = text.match(/(?:Iteration\s+)?(\d+)\s*\/\s*(\d+)/i);
if (slashMatch) {
return {
current: parseInt(slashMatch[1], 10),
max: parseInt(slashMatch[2], 10),
};
}
const bracketMatch = text.match(/\[(\d+)\s*\/\s*(\d+)\]/);
if (bracketMatch) {
return {
current: parseInt(bracketMatch[1], 10),
max: parseInt(bracketMatch[2], 10),
};
}
const simpleMatch = text.match(/[Ii]teration\s+(\d+)/);
if (simpleMatch) {
return {
current: parseInt(simpleMatch[1], 10),
max: null,
};
}
return null;
};
it('should parse "Iteration 5/50"', () => {
const result = parseIteration('Iteration 5/50');
expect(result?.current).toBe(5);
expect(result?.max).toBe(50);
});
it('should parse "[5/50]"', () => {
const result = parseIteration('[5/50] Working...');
expect(result?.current).toBe(5);
expect(result?.max).toBe(50);
});
it('should parse "Iteration 3" without max', () => {
const result = parseIteration('Iteration 3 - processing');
expect(result?.current).toBe(3);
expect(result?.max).toBeNull();
});
it('should parse embedded patterns', () => {
const result = parseIteration('Log: Iteration 10/100 started');
expect(result?.current).toBe(10);
expect(result?.max).toBe(100);
});
it('should return null for no match', () => {
expect(parseIteration('no iteration')).toBeNull();
});
});
describe('Todo Checkbox Parsing', () => {
const parseTodoCheckbox = (line: string): { content: string; status: string } | null => {
// Match "- [ ] Task" or "- [x] Task" or "* [ ] Task"
const match = line.match(/^[\s\-*]*\[\s*([xX ])\s*\]\s*(.+)$/);
if (!match) return null;
return {
content: match[2].trim(),
status: match[1].toLowerCase() === 'x' ? 'completed' : 'pending',
};
};
it('should parse pending checkbox', () => {
const result = parseTodoCheckbox('- [ ] Task to do');
expect(result?.content).toBe('Task to do');
expect(result?.status).toBe('pending');
});
it('should parse completed checkbox', () => {
const result = parseTodoCheckbox('- [x] Done task');
expect(result?.content).toBe('Done task');
expect(result?.status).toBe('completed');
});
it('should parse uppercase X', () => {
const result = parseTodoCheckbox('- [X] Also done');
expect(result?.status).toBe('completed');
});
it('should parse asterisk bullet', () => {
const result = parseTodoCheckbox('* [ ] Asterisk item');
expect(result?.content).toBe('Asterisk item');
});
it('should handle leading whitespace', () => {
const result = parseTodoCheckbox(' - [ ] Indented task');
expect(result?.content).toBe('Indented task');
});
it('should return null for non-checkbox', () => {
expect(parseTodoCheckbox('Just text')).toBeNull();
expect(parseTodoCheckbox('- No checkbox')).toBeNull();
});
});
describe('Cycle Pattern Detection', () => {
const parseCycleNumber = (text: string): number | null => {
// Match "cycle #5" or "Starting cycle #10" or "respawn cycle #3"
const match = text.match(/cycle\s*#(\d+)/i);
return match ? parseInt(match[1], 10) : null;
};
it('should parse "cycle #5"', () => {
expect(parseCycleNumber('cycle #5')).toBe(5);
});
it('should parse "Starting cycle #10"', () => {
expect(parseCycleNumber('Starting cycle #10')).toBe(10);
});
it('should parse "respawn cycle #3"', () => {
expect(parseCycleNumber('respawn cycle #3')).toBe(3);
});
it('should handle case insensitivity', () => {
expect(parseCycleNumber('CYCLE #7')).toBe(7);
expect(parseCycleNumber('Cycle #7')).toBe(7);
});
it('should return null for no match', () => {
expect(parseCycleNumber('no cycle here')).toBeNull();
});
});
describe('Elapsed Time Parsing', () => {
const parseElapsedTime = (text: string): number | null => {
// Match "Elapsed: X.X hours" or "X.X hours elapsed"
const match = text.match(/(\d+(?:\.\d+)?)\s*hours?/i);
return match ? parseFloat(match[1]) : null;
};
it('should parse "Elapsed: 2.5 hours"', () => {
expect(parseElapsedTime('Elapsed: 2.5 hours')).toBe(2.5);
});
it('should parse integer hours', () => {
expect(parseElapsedTime('5 hours elapsed')).toBe(5);
});
it('should parse singular "hour"', () => {
expect(parseElapsedTime('1 hour')).toBe(1);
});
it('should parse decimal hours', () => {
expect(parseElapsedTime('0.5 hours')).toBe(0.5);
});
it('should return null for no match', () => {
expect(parseElapsedTime('no time here')).toBeNull();
});
});
describe('Buffer Size Limits', () => {
const MAX_BUFFER_SIZE = 2 * 1024 * 1024; // 2MB
const TRIM_TO_SIZE = 1.5 * 1024 * 1024; // 1.5MB
const trimBuffer = (buffer: string): string => {
if (buffer.length > MAX_BUFFER_SIZE) {
return buffer.slice(-TRIM_TO_SIZE);
}
return buffer;
};
it('should not trim small buffers', () => {
const small = 'x'.repeat(1000);
expect(trimBuffer(small)).toBe(small);
});
it('should trim large buffers', () => {
const large = 'x'.repeat(MAX_BUFFER_SIZE + 1000);
const trimmed = trimBuffer(large);
expect(trimmed.length).toBe(TRIM_TO_SIZE);
});
it('should keep recent data', () => {
const buffer = 'old'.repeat(MAX_BUFFER_SIZE / 3) + 'new'.repeat(TRIM_TO_SIZE / 3);
const trimmed = trimBuffer(buffer);
expect(trimmed.endsWith('newnewnew')).toBe(true);
});
});
describe('Message Count Limits', () => {
const MAX_MESSAGES = 1000;
const TRIM_TO = 800;
const trimMessages = <T>(messages: T[]): T[] => {
if (messages.length > MAX_MESSAGES) {
return messages.slice(-TRIM_TO);
}
return messages;
};
it('should not trim small arrays', () => {
const arr = Array.from({ length: 100 }, (_, i) => i);
expect(trimMessages(arr)).toEqual(arr);
});
it('should trim large arrays', () => {
const arr = Array.from({ length: 1500 }, (_, i) => i);
const trimmed = trimMessages(arr);
expect(trimmed.length).toBe(TRIM_TO);
});
it('should keep recent messages', () => {
const arr = Array.from({ length: 1200 }, (_, i) => i);
const trimmed = trimMessages(arr);
expect(trimmed[trimmed.length - 1]).toBe(1199);
});
});
});