mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
chore: bump version to 0.1486
feat: interactive teammate tmux pane windows with xterm.js terminals feat: auto-cleanup teams/subagents/pane windows on session delete fix: only show agent/teammate windows when matching Claudeman tab exists fix: UTF-8 encoding in teammate pane terminal output (Uint8Array) fix: standalone pane window cleanup via subagentParentMap lookup fix: xterm.js dimensions crash with deferred init + null-safe dispose Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,570 @@
|
||||
/**
|
||||
* @fileoverview Tests for BashToolParser
|
||||
*
|
||||
* Tests the terminal output parser that detects Bash tool invocations,
|
||||
* extracts file paths, and tracks tool lifecycle (start/completion).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { BashToolParser } from '../src/bash-tool-parser.js';
|
||||
|
||||
describe('BashToolParser', () => {
|
||||
let parser: BashToolParser;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
parser = new BashToolParser({ sessionId: 'test-session' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
parser.destroy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ========== Constructor & Accessors ==========
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize with session ID', () => {
|
||||
expect(parser.sessionId).toBe('test-session');
|
||||
});
|
||||
|
||||
it('should be enabled by default', () => {
|
||||
expect(parser.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept enabled: false', () => {
|
||||
const p = new BashToolParser({ sessionId: 's', enabled: false });
|
||||
expect(p.enabled).toBe(false);
|
||||
p.destroy();
|
||||
});
|
||||
|
||||
it('should accept a custom working directory', () => {
|
||||
const p = new BashToolParser({ sessionId: 's', workingDir: '/tmp/test' });
|
||||
expect(p.workingDir).toBe('/tmp/test');
|
||||
p.destroy();
|
||||
});
|
||||
|
||||
it('should start with no active tools', () => {
|
||||
expect(parser.activeTools).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Enable / Disable ==========
|
||||
|
||||
describe('enable / disable', () => {
|
||||
it('should toggle enabled state', () => {
|
||||
parser.disable();
|
||||
expect(parser.enabled).toBe(false);
|
||||
parser.enable();
|
||||
expect(parser.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should ignore processTerminalData when disabled', () => {
|
||||
const handler = vi.fn();
|
||||
parser.on('toolStart', handler);
|
||||
parser.disable();
|
||||
|
||||
parser.processTerminalData('● Bash(tail -f /var/log/syslog)\n');
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Path Normalization ==========
|
||||
|
||||
describe('normalizePath', () => {
|
||||
it('should expand ~ to home directory', () => {
|
||||
const home = process.env.HOME || '/home/user';
|
||||
expect(parser.normalizePath('~/foo/bar')).toBe(`${home}/foo/bar`);
|
||||
});
|
||||
|
||||
it('should expand bare ~', () => {
|
||||
const home = process.env.HOME || '/home/user';
|
||||
expect(parser.normalizePath('~')).toBe(home);
|
||||
});
|
||||
|
||||
it('should leave absolute paths unchanged', () => {
|
||||
expect(parser.normalizePath('/var/log/app.log')).toBe('/var/log/app.log');
|
||||
});
|
||||
|
||||
it('should resolve .. components', () => {
|
||||
expect(parser.normalizePath('/a/b/../c')).toBe('/a/c');
|
||||
});
|
||||
|
||||
it('should resolve . components', () => {
|
||||
expect(parser.normalizePath('/a/./b')).toBe('/a/b');
|
||||
});
|
||||
|
||||
it('should not go above root with ..', () => {
|
||||
expect(parser.normalizePath('/../../etc/passwd')).toBe('/etc/passwd');
|
||||
});
|
||||
|
||||
it('should return empty string for empty input', () => {
|
||||
expect(parser.normalizePath('')).toBe('');
|
||||
});
|
||||
|
||||
it('should trim whitespace', () => {
|
||||
expect(parser.normalizePath(' /var/log ')).toBe('/var/log');
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Path Equivalence ==========
|
||||
|
||||
describe('pathsAreEquivalent', () => {
|
||||
it('should match identical paths', () => {
|
||||
expect(parser.pathsAreEquivalent('/var/log/a.log', '/var/log/a.log')).toBe(true);
|
||||
});
|
||||
|
||||
it('should match after normalization', () => {
|
||||
expect(parser.pathsAreEquivalent('/var/log/../log/a.log', '/var/log/a.log')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match different files', () => {
|
||||
expect(parser.pathsAreEquivalent('/var/log/a.log', '/var/log/b.log')).toBe(false);
|
||||
});
|
||||
|
||||
it('should match shallow root path with working dir path (same filename)', () => {
|
||||
const p = new BashToolParser({ sessionId: 's', workingDir: '/home/user/project' });
|
||||
// /test.txt is a shallow root path and /home/user/project/test.txt is in workdir
|
||||
expect(p.pathsAreEquivalent('/test.txt', '/home/user/project/test.txt')).toBe(true);
|
||||
p.destroy();
|
||||
});
|
||||
|
||||
it('should not match shallow root path with non-workdir path', () => {
|
||||
const p = new BashToolParser({ sessionId: 's', workingDir: '/home/user/project' });
|
||||
// Both are outside working dir
|
||||
expect(p.pathsAreEquivalent('/test.txt', '/other/dir/test.txt')).toBe(false);
|
||||
p.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Path Deduplication ==========
|
||||
|
||||
describe('deduplicatePaths', () => {
|
||||
it('should return single path as-is', () => {
|
||||
expect(parser.deduplicatePaths(['/a/b'])).toEqual(['/a/b']);
|
||||
});
|
||||
|
||||
it('should remove exact duplicates', () => {
|
||||
const result = parser.deduplicatePaths(['/a/b', '/a/b']);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should remove normalized duplicates', () => {
|
||||
const result = parser.deduplicatePaths(['/a/b/../c', '/a/c']);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should keep different paths', () => {
|
||||
const result = parser.deduplicatePaths(['/a/b', '/c/d']);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
expect(parser.deduplicatePaths([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Tool Detection ==========
|
||||
|
||||
describe('processTerminalData - tool start', () => {
|
||||
it('should detect ● Bash(tail -f /var/log/syslog) pattern', () => {
|
||||
const handler = vi.fn();
|
||||
parser.on('toolStart', handler);
|
||||
|
||||
parser.processTerminalData('● Bash(tail -f /var/log/syslog)\n');
|
||||
// toolStart is emitted synchronously (not debounced)
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
const tool = handler.mock.calls[0][0];
|
||||
expect(tool.command).toBe('tail -f /var/log/syslog');
|
||||
expect(tool.filePaths).toContain('/var/log/syslog');
|
||||
expect(tool.status).toBe('running');
|
||||
expect(tool.sessionId).toBe('test-session');
|
||||
});
|
||||
|
||||
it('should detect tool with timeout', () => {
|
||||
const handler = vi.fn();
|
||||
parser.on('toolStart', handler);
|
||||
|
||||
parser.processTerminalData('● Bash(tail -f /tmp/out.log) timeout: 5m 0s\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler.mock.calls[0][0].timeout).toBe('5m 0s');
|
||||
});
|
||||
|
||||
it('should detect cat command', () => {
|
||||
const handler = vi.fn();
|
||||
parser.on('toolStart', handler);
|
||||
|
||||
parser.processTerminalData('● Bash(cat /etc/hostname)\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler.mock.calls[0][0].filePaths).toContain('/etc/hostname');
|
||||
});
|
||||
|
||||
it('should detect head command', () => {
|
||||
const handler = vi.fn();
|
||||
parser.on('toolStart', handler);
|
||||
|
||||
parser.processTerminalData('● Bash(head -n 20 /var/log/kern.log)\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler.mock.calls[0][0].filePaths).toContain('/var/log/kern.log');
|
||||
});
|
||||
|
||||
it('should detect grep command with file path', () => {
|
||||
const handler = vi.fn();
|
||||
parser.on('toolStart', handler);
|
||||
|
||||
parser.processTerminalData('● Bash(grep error /var/log/app.log)\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler.mock.calls[0][0].filePaths).toContain('/var/log/app.log');
|
||||
});
|
||||
|
||||
it('should detect watch command', () => {
|
||||
const handler = vi.fn();
|
||||
parser.on('toolStart', handler);
|
||||
|
||||
parser.processTerminalData('● Bash(watch /tmp/status.txt)\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should NOT detect non-file-viewer commands', () => {
|
||||
const handler = vi.fn();
|
||||
parser.on('toolStart', handler);
|
||||
|
||||
parser.processTerminalData('● Bash(echo hello)\n');
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should extract multiple file paths from a single command', () => {
|
||||
const handler = vi.fn();
|
||||
parser.on('toolStart', handler);
|
||||
|
||||
parser.processTerminalData('● Bash(tail -f /var/log/a.log /var/log/b.log)\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
const paths = handler.mock.calls[0][0].filePaths;
|
||||
expect(paths).toContain('/var/log/a.log');
|
||||
expect(paths).toContain('/var/log/b.log');
|
||||
});
|
||||
|
||||
it('should skip /dev/null paths', () => {
|
||||
const handler = vi.fn();
|
||||
parser.on('toolStart', handler);
|
||||
|
||||
// grep with /dev/null shouldn't produce a tool (only invalid path)
|
||||
parser.processTerminalData('● Bash(cat /dev/null)\n');
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle ANSI codes in the output', () => {
|
||||
const handler = vi.fn();
|
||||
parser.on('toolStart', handler);
|
||||
|
||||
parser.processTerminalData('\x1b[1m● Bash(tail -f /var/log/test.log)\x1b[0m\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler.mock.calls[0][0].filePaths).toContain('/var/log/test.log');
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Tool Completion ==========
|
||||
|
||||
describe('processTerminalData - tool completion', () => {
|
||||
it('should emit toolEnd on ✓ Bash', () => {
|
||||
const endHandler = vi.fn();
|
||||
parser.on('toolEnd', endHandler);
|
||||
|
||||
// Start a tool first
|
||||
parser.processTerminalData('● Bash(tail -f /var/log/syslog)\n');
|
||||
// Complete it
|
||||
parser.processTerminalData('✓ Bash\n');
|
||||
|
||||
expect(endHandler).toHaveBeenCalledTimes(1);
|
||||
expect(endHandler.mock.calls[0][0].status).toBe('completed');
|
||||
});
|
||||
|
||||
it('should emit toolEnd on ✗ Bash (failure)', () => {
|
||||
const endHandler = vi.fn();
|
||||
parser.on('toolEnd', endHandler);
|
||||
|
||||
parser.processTerminalData('● Bash(tail -f /var/log/syslog)\n');
|
||||
parser.processTerminalData('✗ Bash\n');
|
||||
|
||||
expect(endHandler).toHaveBeenCalledTimes(1);
|
||||
expect(endHandler.mock.calls[0][0].status).toBe('completed');
|
||||
});
|
||||
|
||||
it('should remove completed tool after 2000ms delay', () => {
|
||||
parser.processTerminalData('● Bash(tail -f /var/log/syslog)\n');
|
||||
expect(parser.activeTools).toHaveLength(1);
|
||||
|
||||
parser.processTerminalData('✓ Bash\n');
|
||||
// Tool still present (in completed state)
|
||||
expect(parser.activeTools).toHaveLength(1);
|
||||
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(parser.activeTools).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Text Command Detection ==========
|
||||
|
||||
describe('processTerminalData - text command fallback', () => {
|
||||
it('should detect plain text "tail -f /path" suggestions', () => {
|
||||
const handler = vi.fn();
|
||||
parser.on('toolStart', handler);
|
||||
|
||||
parser.processTerminalData('tail -f /var/log/app.log\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler.mock.calls[0][0].filePaths).toContain('/var/log/app.log');
|
||||
});
|
||||
|
||||
it('should auto-remove text command suggestions after 30s', () => {
|
||||
parser.processTerminalData('tail -f /var/log/app.log\n');
|
||||
expect(parser.activeTools).toHaveLength(1);
|
||||
|
||||
vi.advanceTimersByTime(30000);
|
||||
expect(parser.activeTools).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Log File Mention Detection ==========
|
||||
|
||||
describe('processTerminalData - log file mentions', () => {
|
||||
it('should detect .log file paths mentioned in text', () => {
|
||||
const handler = vi.fn();
|
||||
parser.on('toolStart', handler);
|
||||
|
||||
parser.processTerminalData('The output is saved to /tmp/output.log for review\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler.mock.calls[0][0].filePaths).toContain('/tmp/output.log');
|
||||
});
|
||||
|
||||
it('should detect .txt file paths mentioned in text', () => {
|
||||
const handler = vi.fn();
|
||||
parser.on('toolStart', handler);
|
||||
|
||||
parser.processTerminalData('See /tmp/results.txt for details\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should detect .out file paths mentioned in text', () => {
|
||||
const handler = vi.fn();
|
||||
parser.on('toolStart', handler);
|
||||
|
||||
parser.processTerminalData('Wrote to /tmp/build.out\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should auto-remove log mentions after 60s', () => {
|
||||
parser.processTerminalData('Wrote to /tmp/build.log\n');
|
||||
expect(parser.activeTools).toHaveLength(1);
|
||||
|
||||
vi.advanceTimersByTime(60000);
|
||||
expect(parser.activeTools).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should strip trailing punctuation from paths', () => {
|
||||
const handler = vi.fn();
|
||||
parser.on('toolStart', handler);
|
||||
|
||||
parser.processTerminalData('Check /tmp/output.log, please\n');
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
const filePaths = handler.mock.calls[0][0].filePaths;
|
||||
expect(filePaths[0]).not.toMatch(/,$/);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Deduplication ==========
|
||||
|
||||
describe('cross-pattern deduplication', () => {
|
||||
it('should not add duplicate file paths from different patterns', () => {
|
||||
const handler = vi.fn();
|
||||
parser.on('toolStart', handler);
|
||||
|
||||
// First: detected as a Bash tool
|
||||
parser.processTerminalData('● Bash(tail -f /var/log/syslog)\n');
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second: same path as text command — should be skipped
|
||||
parser.processTerminalData('tail -f /var/log/syslog\n');
|
||||
expect(handler).toHaveBeenCalledTimes(1); // still 1
|
||||
});
|
||||
|
||||
it('should track file paths correctly', () => {
|
||||
parser.processTerminalData('● Bash(tail -f /var/log/syslog)\n');
|
||||
|
||||
expect(parser.isFilePathTracked('/var/log/syslog')).toBe(true);
|
||||
expect(parser.isFilePathTracked('/var/log/other.log')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Max Tools Limit ==========
|
||||
|
||||
describe('max active tools limit', () => {
|
||||
it('should evict oldest tool when limit reached', () => {
|
||||
// The limit is MAX_ACTIVE_TOOLS = 20
|
||||
for (let i = 0; i < 21; i++) {
|
||||
parser.processTerminalData(`● Bash(tail -f /var/log/file${i}.log)\n`);
|
||||
}
|
||||
|
||||
// Should have exactly 20 tools
|
||||
expect(parser.activeTools.length).toBeLessThanOrEqual(20);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Line Buffer ==========
|
||||
|
||||
describe('line buffer handling', () => {
|
||||
it('should handle data split across multiple chunks', () => {
|
||||
const handler = vi.fn();
|
||||
parser.on('toolStart', handler);
|
||||
|
||||
// Split the line across two processTerminalData calls
|
||||
parser.processTerminalData('● Bash(tail -f /var/');
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
|
||||
parser.processTerminalData('log/syslog)\n');
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle multiple lines in one chunk', () => {
|
||||
const startHandler = vi.fn();
|
||||
const endHandler = vi.fn();
|
||||
parser.on('toolStart', startHandler);
|
||||
parser.on('toolEnd', endHandler);
|
||||
|
||||
parser.processTerminalData(
|
||||
'● Bash(tail -f /var/log/a.log)\n✓ Bash\n● Bash(cat /var/log/b.log)\n',
|
||||
);
|
||||
|
||||
expect(startHandler).toHaveBeenCalledTimes(2);
|
||||
expect(endHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should truncate line buffer if it exceeds max size', () => {
|
||||
// Generate data larger than MAX_LINE_BUFFER_SIZE (64KB)
|
||||
const longLine = 'x'.repeat(70 * 1024);
|
||||
parser.processTerminalData(longLine);
|
||||
|
||||
// Should not throw; parser continues working
|
||||
const handler = vi.fn();
|
||||
parser.on('toolStart', handler);
|
||||
parser.processTerminalData('● Bash(tail -f /var/log/test.log)\n');
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Debounced Updates ==========
|
||||
|
||||
describe('debounced updates', () => {
|
||||
it('should emit toolsUpdate after debounce period', () => {
|
||||
const handler = vi.fn();
|
||||
parser.on('toolsUpdate', handler);
|
||||
|
||||
parser.processTerminalData('● Bash(tail -f /var/log/syslog)\n');
|
||||
|
||||
// Not emitted yet (debounced at 50ms)
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(50);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler.mock.calls[0][0]).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Reset ==========
|
||||
|
||||
describe('reset', () => {
|
||||
it('should clear all tracked tools', () => {
|
||||
parser.processTerminalData('● Bash(tail -f /var/log/syslog)\n');
|
||||
expect(parser.activeTools).toHaveLength(1);
|
||||
|
||||
parser.reset();
|
||||
expect(parser.activeTools).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Destroy ==========
|
||||
|
||||
describe('destroy', () => {
|
||||
it('should prevent further processing after destroy', () => {
|
||||
const handler = vi.fn();
|
||||
parser.on('toolStart', handler);
|
||||
|
||||
parser.destroy();
|
||||
parser.processTerminalData('● Bash(tail -f /var/log/syslog)\n');
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear active tools and timers', () => {
|
||||
parser.processTerminalData('● Bash(tail -f /var/log/syslog)\n');
|
||||
parser.destroy();
|
||||
|
||||
expect(parser.activeTools).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== isPathInWorkingDir ==========
|
||||
|
||||
describe('isPathInWorkingDir', () => {
|
||||
it('should return true for paths inside working dir', () => {
|
||||
const p = new BashToolParser({ sessionId: 's', workingDir: '/home/user/project' });
|
||||
expect(p.isPathInWorkingDir('/home/user/project/src/main.ts')).toBe(true);
|
||||
p.destroy();
|
||||
});
|
||||
|
||||
it('should return false for paths outside working dir', () => {
|
||||
const p = new BashToolParser({ sessionId: 's', workingDir: '/home/user/project' });
|
||||
expect(p.isPathInWorkingDir('/var/log/app.log')).toBe(false);
|
||||
p.destroy();
|
||||
});
|
||||
|
||||
it('should return true for the working dir itself', () => {
|
||||
const p = new BashToolParser({ sessionId: 's', workingDir: '/home/user/project' });
|
||||
expect(p.isPathInWorkingDir('/home/user/project')).toBe(true);
|
||||
p.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// ========== setWorkingDir ==========
|
||||
|
||||
describe('setWorkingDir', () => {
|
||||
it('should update the working directory', () => {
|
||||
parser.setWorkingDir('/new/dir');
|
||||
expect(parser.workingDir).toBe('/new/dir');
|
||||
});
|
||||
});
|
||||
|
||||
// ========== getTrackedPaths ==========
|
||||
|
||||
describe('getTrackedPaths', () => {
|
||||
it('should return raw and normalized paths for running tools', () => {
|
||||
parser.processTerminalData('● Bash(tail -f /var/log/syslog)\n');
|
||||
|
||||
const tracked = parser.getTrackedPaths();
|
||||
expect(tracked).toHaveLength(1);
|
||||
expect(tracked[0].raw).toBe('/var/log/syslog');
|
||||
expect(tracked[0].normalized).toBe('/var/log/syslog');
|
||||
});
|
||||
|
||||
it('should return empty array when no tools are active', () => {
|
||||
expect(parser.getTrackedPaths()).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,531 @@
|
||||
/**
|
||||
* @fileoverview Tests for FileStreamManager
|
||||
*
|
||||
* Tests stream creation, path validation, concurrent stream limits,
|
||||
* cleanup, and inactivity timeout logic.
|
||||
*
|
||||
* Uses mocked child_process.spawn to avoid real tail processes.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
|
||||
// Mock child_process.spawn
|
||||
const mockSpawn = vi.fn();
|
||||
vi.mock('node:child_process', () => ({
|
||||
spawn: (...args: unknown[]) => mockSpawn(...args),
|
||||
}));
|
||||
|
||||
// Mock fs functions
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const orig = await importOriginal<typeof import('node:fs')>();
|
||||
return {
|
||||
...orig,
|
||||
existsSync: vi.fn(() => true),
|
||||
statSync: vi.fn(() => ({ size: 1024 })),
|
||||
};
|
||||
});
|
||||
|
||||
import { FileStreamManager } from '../src/file-stream-manager.js';
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
|
||||
/**
|
||||
* Creates a mock ChildProcess-like EventEmitter with stdout/stderr streams.
|
||||
*/
|
||||
function createMockProcess(pid = 12345): ChildProcess {
|
||||
const proc = new EventEmitter() as unknown as ChildProcess;
|
||||
(proc as Record<string, unknown>).pid = pid;
|
||||
(proc as Record<string, unknown>).stdout = new EventEmitter();
|
||||
(proc as Record<string, unknown>).stderr = new EventEmitter();
|
||||
(proc as Record<string, unknown>).kill = vi.fn();
|
||||
return proc;
|
||||
}
|
||||
|
||||
describe('FileStreamManager', () => {
|
||||
let manager: FileStreamManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
manager = new FileStreamManager();
|
||||
mockSpawn.mockReset();
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(statSync).mockReturnValue({ size: 1024 } as ReturnType<typeof statSync>);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
manager.destroy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ========== Stream Creation ==========
|
||||
|
||||
describe('createStream', () => {
|
||||
it('should create a stream successfully', async () => {
|
||||
const proc = createMockProcess();
|
||||
mockSpawn.mockReturnValue(proc);
|
||||
|
||||
const result = await manager.createStream({
|
||||
sessionId: 'session-1',
|
||||
filePath: '/var/log/app.log',
|
||||
workingDir: '/var/log',
|
||||
onData: vi.fn(),
|
||||
onEnd: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.streamId).toBeTruthy();
|
||||
expect(manager.activeStreamCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should call spawn with correct arguments', async () => {
|
||||
const proc = createMockProcess();
|
||||
mockSpawn.mockReturnValue(proc);
|
||||
|
||||
await manager.createStream({
|
||||
sessionId: 'session-1',
|
||||
filePath: '/var/log/app.log',
|
||||
workingDir: '/var/log',
|
||||
onData: vi.fn(),
|
||||
onEnd: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
});
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
'tail',
|
||||
['-f', '-n', '50', expect.stringContaining('/var/log/app.log')],
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
||||
);
|
||||
});
|
||||
|
||||
it('should use custom lines parameter', async () => {
|
||||
const proc = createMockProcess();
|
||||
mockSpawn.mockReturnValue(proc);
|
||||
|
||||
await manager.createStream({
|
||||
sessionId: 'session-1',
|
||||
filePath: '/var/log/app.log',
|
||||
workingDir: '/var/log',
|
||||
lines: 100,
|
||||
onData: vi.fn(),
|
||||
onEnd: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
});
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
'tail',
|
||||
['-f', '-n', '100', expect.any(String)],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject when file does not exist', async () => {
|
||||
vi.mocked(existsSync).mockReturnValue(false);
|
||||
|
||||
const result = await manager.createStream({
|
||||
sessionId: 'session-1',
|
||||
filePath: '/var/log/nonexistent.log',
|
||||
workingDir: '/var/log',
|
||||
onData: vi.fn(),
|
||||
onEnd: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should reject when file is too large', async () => {
|
||||
vi.mocked(statSync).mockReturnValue({ size: 200 * 1024 * 1024 } as ReturnType<typeof statSync>);
|
||||
|
||||
const result = await manager.createStream({
|
||||
sessionId: 'session-1',
|
||||
filePath: '/var/log/huge.log',
|
||||
workingDir: '/var/log',
|
||||
onData: vi.fn(),
|
||||
onEnd: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/too large/i);
|
||||
});
|
||||
|
||||
it('should reject when process fails to spawn (no PID)', async () => {
|
||||
const proc = createMockProcess(0);
|
||||
(proc as Record<string, unknown>).pid = undefined;
|
||||
mockSpawn.mockReturnValue(proc);
|
||||
|
||||
const result = await manager.createStream({
|
||||
sessionId: 'session-1',
|
||||
filePath: '/var/log/app.log',
|
||||
workingDir: '/var/log',
|
||||
onData: vi.fn(),
|
||||
onEnd: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/Failed to spawn/);
|
||||
});
|
||||
|
||||
it('should forward stdout data to onData callback', async () => {
|
||||
const proc = createMockProcess();
|
||||
mockSpawn.mockReturnValue(proc);
|
||||
const onData = vi.fn();
|
||||
|
||||
await manager.createStream({
|
||||
sessionId: 'session-1',
|
||||
filePath: '/var/log/app.log',
|
||||
workingDir: '/var/log',
|
||||
onData,
|
||||
onEnd: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
});
|
||||
|
||||
proc.stdout!.emit('data', Buffer.from('line 1\nline 2\n'));
|
||||
expect(onData).toHaveBeenCalledWith('line 1\nline 2\n');
|
||||
});
|
||||
|
||||
it('should forward stderr data to onError callback', async () => {
|
||||
const proc = createMockProcess();
|
||||
mockSpawn.mockReturnValue(proc);
|
||||
const onError = vi.fn();
|
||||
|
||||
await manager.createStream({
|
||||
sessionId: 'session-1',
|
||||
filePath: '/var/log/app.log',
|
||||
workingDir: '/var/log',
|
||||
onData: vi.fn(),
|
||||
onEnd: vi.fn(),
|
||||
onError,
|
||||
});
|
||||
|
||||
proc.stderr!.emit('data', Buffer.from('tail: error\n'));
|
||||
expect(onError).toHaveBeenCalledWith('tail: error\n');
|
||||
});
|
||||
|
||||
it('should call onEnd when process exits', async () => {
|
||||
const proc = createMockProcess();
|
||||
mockSpawn.mockReturnValue(proc);
|
||||
const onEnd = vi.fn();
|
||||
|
||||
await manager.createStream({
|
||||
sessionId: 'session-1',
|
||||
filePath: '/var/log/app.log',
|
||||
workingDir: '/var/log',
|
||||
onData: vi.fn(),
|
||||
onEnd,
|
||||
onError: vi.fn(),
|
||||
});
|
||||
|
||||
proc.emit('exit', 0);
|
||||
expect(onEnd).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Concurrent Stream Limits ==========
|
||||
|
||||
describe('concurrent stream limits', () => {
|
||||
it('should enforce max 5 streams per session', async () => {
|
||||
// Create 5 streams for same session
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const proc = createMockProcess(1000 + i);
|
||||
mockSpawn.mockReturnValue(proc);
|
||||
|
||||
const result = await manager.createStream({
|
||||
sessionId: 'session-1',
|
||||
filePath: `/var/log/file${i}.log`,
|
||||
workingDir: '/var/log',
|
||||
onData: vi.fn(),
|
||||
onEnd: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
}
|
||||
|
||||
// 6th should fail
|
||||
const proc = createMockProcess(2000);
|
||||
mockSpawn.mockReturnValue(proc);
|
||||
|
||||
const result = await manager.createStream({
|
||||
sessionId: 'session-1',
|
||||
filePath: '/var/log/file5.log',
|
||||
workingDir: '/var/log',
|
||||
onData: vi.fn(),
|
||||
onEnd: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/Maximum.*5.*concurrent/);
|
||||
});
|
||||
|
||||
it('should allow streams for different sessions independently', async () => {
|
||||
for (const sessionId of ['session-a', 'session-b']) {
|
||||
const proc = createMockProcess();
|
||||
mockSpawn.mockReturnValue(proc);
|
||||
|
||||
const result = await manager.createStream({
|
||||
sessionId,
|
||||
filePath: '/var/log/app.log',
|
||||
workingDir: '/var/log',
|
||||
onData: vi.fn(),
|
||||
onEnd: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
}
|
||||
|
||||
expect(manager.activeStreamCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Close Stream ==========
|
||||
|
||||
describe('closeStream', () => {
|
||||
it('should close an existing stream', async () => {
|
||||
const proc = createMockProcess();
|
||||
mockSpawn.mockReturnValue(proc);
|
||||
|
||||
const result = await manager.createStream({
|
||||
sessionId: 'session-1',
|
||||
filePath: '/var/log/app.log',
|
||||
workingDir: '/var/log',
|
||||
onData: vi.fn(),
|
||||
onEnd: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
});
|
||||
|
||||
const closed = manager.closeStream(result.streamId!);
|
||||
expect(closed).toBe(true);
|
||||
expect(proc.kill).toHaveBeenCalledWith('SIGTERM');
|
||||
expect(manager.activeStreamCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should return false for non-existent stream', () => {
|
||||
expect(manager.closeStream('nonexistent')).toBe(false);
|
||||
});
|
||||
|
||||
it('should call onEnd when stream is closed', async () => {
|
||||
const proc = createMockProcess();
|
||||
mockSpawn.mockReturnValue(proc);
|
||||
const onEnd = vi.fn();
|
||||
|
||||
const result = await manager.createStream({
|
||||
sessionId: 'session-1',
|
||||
filePath: '/var/log/app.log',
|
||||
workingDir: '/var/log',
|
||||
onData: vi.fn(),
|
||||
onEnd,
|
||||
onError: vi.fn(),
|
||||
});
|
||||
|
||||
manager.closeStream(result.streamId!);
|
||||
expect(onEnd).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Close Session Streams ==========
|
||||
|
||||
describe('closeSessionStreams', () => {
|
||||
it('should close all streams for a session', async () => {
|
||||
const procs: ChildProcess[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const proc = createMockProcess(1000 + i);
|
||||
procs.push(proc);
|
||||
mockSpawn.mockReturnValueOnce(proc);
|
||||
|
||||
await manager.createStream({
|
||||
sessionId: 'session-1',
|
||||
filePath: `/var/log/file${i}.log`,
|
||||
workingDir: '/var/log',
|
||||
onData: vi.fn(),
|
||||
onEnd: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
const closed = manager.closeSessionStreams('session-1');
|
||||
expect(closed).toBe(3);
|
||||
expect(manager.activeStreamCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should not affect other sessions', async () => {
|
||||
for (const sessionId of ['session-1', 'session-2']) {
|
||||
const proc = createMockProcess();
|
||||
mockSpawn.mockReturnValueOnce(proc);
|
||||
await manager.createStream({
|
||||
sessionId,
|
||||
filePath: '/var/log/app.log',
|
||||
workingDir: '/var/log',
|
||||
onData: vi.fn(),
|
||||
onEnd: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
manager.closeSessionStreams('session-1');
|
||||
expect(manager.activeStreamCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== getSessionStreams ==========
|
||||
|
||||
describe('getSessionStreams', () => {
|
||||
it('should return stream info for a session', async () => {
|
||||
const proc = createMockProcess();
|
||||
mockSpawn.mockReturnValue(proc);
|
||||
|
||||
await manager.createStream({
|
||||
sessionId: 'session-1',
|
||||
filePath: '/var/log/app.log',
|
||||
workingDir: '/var/log',
|
||||
onData: vi.fn(),
|
||||
onEnd: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
});
|
||||
|
||||
const streams = manager.getSessionStreams('session-1');
|
||||
expect(streams).toHaveLength(1);
|
||||
expect(streams[0].filePath).toContain('app.log');
|
||||
expect(streams[0].id).toBeTruthy();
|
||||
expect(streams[0].createdAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return empty array for unknown session', () => {
|
||||
expect(manager.getSessionStreams('unknown')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Path Validation ==========
|
||||
|
||||
describe('path validation', () => {
|
||||
it('should reject paths outside allowed directories', async () => {
|
||||
const result = await manager.createStream({
|
||||
sessionId: 'session-1',
|
||||
filePath: '/etc/shadow',
|
||||
workingDir: '/var/log',
|
||||
onData: vi.fn(),
|
||||
onEnd: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/Path must be within/);
|
||||
});
|
||||
|
||||
it('should allow paths within working directory', async () => {
|
||||
const proc = createMockProcess();
|
||||
mockSpawn.mockReturnValue(proc);
|
||||
|
||||
const result = await manager.createStream({
|
||||
sessionId: 'session-1',
|
||||
filePath: '/var/log/sub/app.log',
|
||||
workingDir: '/var/log',
|
||||
onData: vi.fn(),
|
||||
onEnd: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow paths in /var/log', async () => {
|
||||
const proc = createMockProcess();
|
||||
mockSpawn.mockReturnValue(proc);
|
||||
|
||||
const result = await manager.createStream({
|
||||
sessionId: 'session-1',
|
||||
filePath: '/var/log/syslog',
|
||||
workingDir: '/home/user',
|
||||
onData: vi.fn(),
|
||||
onEnd: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow paths in /tmp', async () => {
|
||||
const proc = createMockProcess();
|
||||
mockSpawn.mockReturnValue(proc);
|
||||
|
||||
const result = await manager.createStream({
|
||||
sessionId: 'session-1',
|
||||
filePath: '/tmp/test.log',
|
||||
workingDir: '/home/user',
|
||||
onData: vi.fn(),
|
||||
onEnd: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle stat errors gracefully', async () => {
|
||||
vi.mocked(statSync).mockImplementation(() => {
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
const result = await manager.createStream({
|
||||
sessionId: 'session-1',
|
||||
filePath: '/var/log/gone.log',
|
||||
workingDir: '/var/log',
|
||||
onData: vi.fn(),
|
||||
onEnd: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/not found|not accessible/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Inactivity Cleanup ==========
|
||||
|
||||
describe('inactivity cleanup', () => {
|
||||
it('should close streams inactive for 5+ minutes', async () => {
|
||||
const proc = createMockProcess();
|
||||
mockSpawn.mockReturnValue(proc);
|
||||
const onEnd = vi.fn();
|
||||
|
||||
await manager.createStream({
|
||||
sessionId: 'session-1',
|
||||
filePath: '/var/log/app.log',
|
||||
workingDir: '/var/log',
|
||||
onData: vi.fn(),
|
||||
onEnd,
|
||||
onError: vi.fn(),
|
||||
});
|
||||
|
||||
// Advance past the cleanup interval (1 min) + inactivity timeout (5 min)
|
||||
vi.advanceTimersByTime(6 * 60 * 1000);
|
||||
|
||||
expect(manager.activeStreamCount).toBe(0);
|
||||
expect(onEnd).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Destroy ==========
|
||||
|
||||
describe('destroy', () => {
|
||||
it('should close all streams and clear state', async () => {
|
||||
const proc = createMockProcess();
|
||||
mockSpawn.mockReturnValue(proc);
|
||||
|
||||
await manager.createStream({
|
||||
sessionId: 'session-1',
|
||||
filePath: '/var/log/app.log',
|
||||
workingDir: '/var/log',
|
||||
onData: vi.fn(),
|
||||
onEnd: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
});
|
||||
|
||||
manager.destroy();
|
||||
expect(manager.activeStreamCount).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* @fileoverview Tests for ImageWatcher
|
||||
*
|
||||
* Tests image file detection, burst throttling, debounce behavior,
|
||||
* session management, and cleanup.
|
||||
*
|
||||
* Uses mocked chokidar to avoid real filesystem watching.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
// Create a mock watcher factory
|
||||
const mockWatchers = new Map<string, EventEmitter>();
|
||||
|
||||
vi.mock('chokidar', () => ({
|
||||
watch: vi.fn((path: string) => {
|
||||
const watcher = new EventEmitter();
|
||||
(watcher as Record<string, unknown>).close = vi.fn();
|
||||
mockWatchers.set(path, watcher);
|
||||
return watcher;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const orig = await importOriginal<typeof import('node:fs')>();
|
||||
return {
|
||||
...orig,
|
||||
statSync: vi.fn(() => ({ size: 2048 })),
|
||||
};
|
||||
});
|
||||
|
||||
import { ImageWatcher } from '../src/image-watcher.js';
|
||||
import { statSync } from 'node:fs';
|
||||
|
||||
describe('ImageWatcher', () => {
|
||||
let watcher: ImageWatcher;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mockWatchers.clear();
|
||||
watcher = new ImageWatcher();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
watcher.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ========== Start / Stop ==========
|
||||
|
||||
describe('start / stop', () => {
|
||||
it('should not be running initially', () => {
|
||||
expect(watcher.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it('should be running after start()', () => {
|
||||
watcher.start();
|
||||
expect(watcher.isRunning()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not be running after stop()', () => {
|
||||
watcher.start();
|
||||
watcher.stop();
|
||||
expect(watcher.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it('should be idempotent on double start', () => {
|
||||
watcher.start();
|
||||
watcher.start();
|
||||
expect(watcher.isRunning()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Session Watching ==========
|
||||
|
||||
describe('watchSession', () => {
|
||||
it('should auto-start when watchSession is called', () => {
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
expect(watcher.isRunning()).toBe(true);
|
||||
});
|
||||
|
||||
it('should track watched sessions', () => {
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
expect(watcher.getWatchedSessions()).toContain('session-1');
|
||||
});
|
||||
|
||||
it('should not double-watch the same session with same dir', () => {
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
// Only one watcher should be created
|
||||
expect(watcher.getWatchedSessions()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should replace watcher when working directory changes', () => {
|
||||
watcher.watchSession('session-1', '/home/user/project-a');
|
||||
watcher.watchSession('session-1', '/home/user/project-b');
|
||||
|
||||
expect(watcher.getWatchedSessions()).toHaveLength(1);
|
||||
// Should have created watcher for both paths
|
||||
expect(mockWatchers.has('/home/user/project-a')).toBe(true);
|
||||
expect(mockWatchers.has('/home/user/project-b')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Unwatch Session ==========
|
||||
|
||||
describe('unwatchSession', () => {
|
||||
it('should remove a watched session', () => {
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
watcher.unwatchSession('session-1');
|
||||
expect(watcher.getWatchedSessions()).not.toContain('session-1');
|
||||
});
|
||||
|
||||
it('should close the chokidar watcher', () => {
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
|
||||
watcher.unwatchSession('session-1');
|
||||
expect(chokidarWatcher.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should be safe to call for non-watched session', () => {
|
||||
expect(() => watcher.unwatchSession('nonexistent')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should clear pending debounce timers for the session', () => {
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
|
||||
|
||||
// Trigger an image detection (starts debounce timer)
|
||||
chokidarWatcher.emit('add', '/home/user/project/screenshot.png');
|
||||
|
||||
// Unwatch should clear the timer without emitting
|
||||
const handler = vi.fn();
|
||||
watcher.on('image:detected', handler);
|
||||
watcher.unwatchSession('session-1');
|
||||
|
||||
// Advance past debounce period
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Image Detection ==========
|
||||
|
||||
describe('image detection', () => {
|
||||
it('should emit image:detected for .png files', () => {
|
||||
const handler = vi.fn();
|
||||
watcher.on('image:detected', handler);
|
||||
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
|
||||
|
||||
chokidarWatcher.emit('add', '/home/user/project/screenshot.png');
|
||||
vi.advanceTimersByTime(300); // past debounce (200ms)
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
const event = handler.mock.calls[0][0];
|
||||
expect(event.sessionId).toBe('session-1');
|
||||
expect(event.fileName).toBe('screenshot.png');
|
||||
expect(event.filePath).toBe('/home/user/project/screenshot.png');
|
||||
expect(event.relativePath).toBe('screenshot.png');
|
||||
expect(event.size).toBe(2048);
|
||||
});
|
||||
|
||||
it('should emit for .jpg files', () => {
|
||||
const handler = vi.fn();
|
||||
watcher.on('image:detected', handler);
|
||||
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
|
||||
|
||||
chokidarWatcher.emit('add', '/home/user/project/photo.jpg');
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should emit for .jpeg files', () => {
|
||||
const handler = vi.fn();
|
||||
watcher.on('image:detected', handler);
|
||||
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/img.jpeg');
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should emit for .gif files', () => {
|
||||
const handler = vi.fn();
|
||||
watcher.on('image:detected', handler);
|
||||
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/anim.gif');
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should emit for .webp files', () => {
|
||||
const handler = vi.fn();
|
||||
watcher.on('image:detected', handler);
|
||||
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/img.webp');
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should emit for .svg files', () => {
|
||||
const handler = vi.fn();
|
||||
watcher.on('image:detected', handler);
|
||||
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/icon.svg');
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should NOT emit for non-image files', () => {
|
||||
const handler = vi.fn();
|
||||
watcher.on('image:detected', handler);
|
||||
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
|
||||
|
||||
chokidarWatcher.emit('add', '/home/user/project/readme.md');
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT emit for .ts files', () => {
|
||||
const handler = vi.fn();
|
||||
watcher.on('image:detected', handler);
|
||||
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/index.ts');
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should compute relative path from working directory', () => {
|
||||
const handler = vi.fn();
|
||||
watcher.on('image:detected', handler);
|
||||
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/assets/img.png');
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(handler.mock.calls[0][0].relativePath).toBe('assets/img.png');
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Debounce Behavior ==========
|
||||
|
||||
describe('debounce', () => {
|
||||
it('should debounce rapid events for the same file', () => {
|
||||
const handler = vi.fn();
|
||||
watcher.on('image:detected', handler);
|
||||
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
|
||||
|
||||
// Rapid adds of the same file
|
||||
chokidarWatcher.emit('add', '/home/user/project/screenshot.png');
|
||||
vi.advanceTimersByTime(100); // not yet past debounce
|
||||
chokidarWatcher.emit('add', '/home/user/project/screenshot.png');
|
||||
vi.advanceTimersByTime(100);
|
||||
chokidarWatcher.emit('add', '/home/user/project/screenshot.png');
|
||||
vi.advanceTimersByTime(300); // now past debounce from last emit
|
||||
|
||||
// Should only emit once (the last debounced one)
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should allow events for different files concurrently', () => {
|
||||
const handler = vi.fn();
|
||||
watcher.on('image:detected', handler);
|
||||
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
|
||||
|
||||
chokidarWatcher.emit('add', '/home/user/project/a.png');
|
||||
chokidarWatcher.emit('add', '/home/user/project/b.png');
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Burst Throttling ==========
|
||||
|
||||
describe('burst throttling', () => {
|
||||
it('should throttle after 20 images in 10 seconds', () => {
|
||||
const handler = vi.fn();
|
||||
watcher.on('image:detected', handler);
|
||||
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
|
||||
|
||||
// Emit 25 unique images in quick succession
|
||||
for (let i = 0; i < 25; i++) {
|
||||
chokidarWatcher.emit('add', `/home/user/project/img${i}.png`);
|
||||
vi.advanceTimersByTime(250); // past debounce, within burst window
|
||||
}
|
||||
|
||||
// Only 20 should get through (BURST_LIMIT = 20)
|
||||
expect(handler).toHaveBeenCalledTimes(20);
|
||||
});
|
||||
|
||||
it('should reset burst counter after window expires', () => {
|
||||
const handler = vi.fn();
|
||||
watcher.on('image:detected', handler);
|
||||
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
|
||||
|
||||
// Fill up burst limit
|
||||
for (let i = 0; i < 20; i++) {
|
||||
chokidarWatcher.emit('add', `/home/user/project/img${i}.png`);
|
||||
vi.advanceTimersByTime(250);
|
||||
}
|
||||
expect(handler).toHaveBeenCalledTimes(20);
|
||||
|
||||
// Wait for burst window to expire (10 seconds)
|
||||
vi.advanceTimersByTime(11_000);
|
||||
|
||||
// Should accept new images
|
||||
chokidarWatcher.emit('add', '/home/user/project/new.png');
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(21);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Error Handling ==========
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should emit image:error on chokidar error', () => {
|
||||
const errorHandler = vi.fn();
|
||||
watcher.on('image:error', errorHandler);
|
||||
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
|
||||
|
||||
const testError = new Error('watch failed');
|
||||
chokidarWatcher.emit('error', testError);
|
||||
|
||||
expect(errorHandler).toHaveBeenCalledWith(testError, 'session-1');
|
||||
});
|
||||
|
||||
it('should emit image:error if statSync fails during emission', () => {
|
||||
vi.mocked(statSync).mockImplementation(() => {
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
const errorHandler = vi.fn();
|
||||
const detectHandler = vi.fn();
|
||||
watcher.on('image:error', errorHandler);
|
||||
watcher.on('image:detected', detectHandler);
|
||||
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/deleted.png');
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(detectHandler).not.toHaveBeenCalled();
|
||||
expect(errorHandler).toHaveBeenCalled();
|
||||
|
||||
// Restore
|
||||
vi.mocked(statSync).mockReturnValue({ size: 2048 } as ReturnType<typeof statSync>);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Stop / Cleanup ==========
|
||||
|
||||
describe('stop', () => {
|
||||
it('should close all chokidar watchers', () => {
|
||||
watcher.watchSession('session-1', '/home/user/project-a');
|
||||
watcher.watchSession('session-2', '/home/user/project-b');
|
||||
|
||||
watcher.stop();
|
||||
|
||||
for (const w of mockWatchers.values()) {
|
||||
expect(w.close).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('should clear all maps', () => {
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
watcher.stop();
|
||||
|
||||
expect(watcher.getWatchedSessions()).toHaveLength(0);
|
||||
expect(watcher.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it('should clear debounce timers so no events fire after stop', () => {
|
||||
const handler = vi.fn();
|
||||
watcher.on('image:detected', handler);
|
||||
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/screenshot.png');
|
||||
|
||||
// Stop before debounce fires
|
||||
watcher.stop();
|
||||
vi.advanceTimersByTime(500);
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @fileoverview Tests for nice-wrapper utility
|
||||
*
|
||||
* Tests the wrapWithNice function that prepends `nice -n <value>`
|
||||
* to shell commands for CPU priority adjustment.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { wrapWithNice } from '../src/utils/nice-wrapper.js';
|
||||
import type { NiceConfig } from '../src/types.js';
|
||||
|
||||
describe('wrapWithNice', () => {
|
||||
it('should return command unchanged when disabled', () => {
|
||||
const config: NiceConfig = { enabled: false, niceValue: 10 };
|
||||
expect(wrapWithNice('claude --dangerously-skip-permissions', config)).toBe(
|
||||
'claude --dangerously-skip-permissions',
|
||||
);
|
||||
});
|
||||
|
||||
it('should wrap command with nice when enabled', () => {
|
||||
const config: NiceConfig = { enabled: true, niceValue: 10 };
|
||||
expect(wrapWithNice('claude --dangerously-skip-permissions', config)).toBe(
|
||||
'nice -n 10 claude --dangerously-skip-permissions',
|
||||
);
|
||||
});
|
||||
|
||||
it('should support negative nice values', () => {
|
||||
const config: NiceConfig = { enabled: true, niceValue: -5 };
|
||||
expect(wrapWithNice('some-cmd', config)).toBe('nice -n -5 some-cmd');
|
||||
});
|
||||
|
||||
it('should clamp nice value to max 19', () => {
|
||||
const config: NiceConfig = { enabled: true, niceValue: 50 };
|
||||
expect(wrapWithNice('cmd', config)).toBe('nice -n 19 cmd');
|
||||
});
|
||||
|
||||
it('should clamp nice value to min -20', () => {
|
||||
const config: NiceConfig = { enabled: true, niceValue: -100 };
|
||||
expect(wrapWithNice('cmd', config)).toBe('nice -n -20 cmd');
|
||||
});
|
||||
|
||||
it('should handle zero nice value', () => {
|
||||
const config: NiceConfig = { enabled: true, niceValue: 0 };
|
||||
expect(wrapWithNice('cmd', config)).toBe('nice -n 0 cmd');
|
||||
});
|
||||
|
||||
it('should handle boundary nice values exactly', () => {
|
||||
expect(wrapWithNice('cmd', { enabled: true, niceValue: 19 })).toBe('nice -n 19 cmd');
|
||||
expect(wrapWithNice('cmd', { enabled: true, niceValue: -20 })).toBe('nice -n -20 cmd');
|
||||
});
|
||||
|
||||
it('should preserve complex commands with pipes and redirects', () => {
|
||||
const config: NiceConfig = { enabled: true, niceValue: 10 };
|
||||
const cmd = 'bash -c "echo hello | grep h > /tmp/out"';
|
||||
expect(wrapWithNice(cmd, config)).toBe(`nice -n 10 ${cmd}`);
|
||||
});
|
||||
|
||||
it('should handle empty command string', () => {
|
||||
const config: NiceConfig = { enabled: true, niceValue: 10 };
|
||||
expect(wrapWithNice('', config)).toBe('nice -n 10 ');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user