diff --git a/test/task.test.ts b/test/task.test.ts new file mode 100644 index 00000000..52df7132 --- /dev/null +++ b/test/task.test.ts @@ -0,0 +1,355 @@ +/** + * @fileoverview Tests for Task class + * + * Tests the Task model including lifecycle states, + * completion detection, timeout handling, and serialization. + */ + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { Task, CreateTaskOptions } from '../src/task.js'; +import { TaskState } from '../src/types.js'; + +describe('Task', () => { + describe('constructor', () => { + it('should create a task with required options', () => { + const task = new Task({ prompt: 'Test prompt' }); + + expect(task.id).toBeDefined(); + expect(task.prompt).toBe('Test prompt'); + expect(task.workingDir).toBe(process.cwd()); + expect(task.priority).toBe(0); + expect(task.dependencies).toEqual([]); + expect(task.completionPhrase).toBeUndefined(); + expect(task.timeoutMs).toBeUndefined(); + expect(task.createdAt).toBeLessThanOrEqual(Date.now()); + }); + + it('should create a task with all options', () => { + const options: CreateTaskOptions = { + prompt: 'Test prompt', + workingDir: '/tmp/test', + priority: 10, + dependencies: ['dep-1', 'dep-2'], + completionPhrase: 'DONE', + timeoutMs: 30000, + }; + + const task = new Task(options); + + expect(task.prompt).toBe('Test prompt'); + expect(task.workingDir).toBe('/tmp/test'); + expect(task.priority).toBe(10); + expect(task.dependencies).toEqual(['dep-1', 'dep-2']); + expect(task.completionPhrase).toBe('DONE'); + expect(task.timeoutMs).toBe(30000); + }); + + it('should use provided ID if given', () => { + const task = new Task({ prompt: 'Test' }, 'custom-id'); + + expect(task.id).toBe('custom-id'); + }); + + it('should generate unique IDs', () => { + const task1 = new Task({ prompt: 'Task 1' }); + const task2 = new Task({ prompt: 'Task 2' }); + + expect(task1.id).not.toBe(task2.id); + }); + }); + + describe('initial state', () => { + it('should start in pending status', () => { + const task = new Task({ prompt: 'Test' }); + + expect(task.status).toBe('pending'); + expect(task.isPending()).toBe(true); + expect(task.isRunning()).toBe(false); + expect(task.isCompleted()).toBe(false); + expect(task.isFailed()).toBe(false); + expect(task.isDone()).toBe(false); + }); + + it('should have null for runtime properties', () => { + const task = new Task({ prompt: 'Test' }); + + expect(task.assignedSessionId).toBeNull(); + expect(task.startedAt).toBeNull(); + expect(task.completedAt).toBeNull(); + expect(task.output).toBe(''); + expect(task.error).toBeNull(); + }); + }); + + describe('assign', () => { + it('should assign task to a session', () => { + const task = new Task({ prompt: 'Test' }); + + task.assign('session-123'); + + expect(task.status).toBe('running'); + expect(task.assignedSessionId).toBe('session-123'); + expect(task.startedAt).toBeLessThanOrEqual(Date.now()); + expect(task.isRunning()).toBe(true); + expect(task.isPending()).toBe(false); + }); + + it('should throw if task is not pending', () => { + const task = new Task({ prompt: 'Test' }); + task.assign('session-1'); + + expect(() => task.assign('session-2')).toThrow(/Cannot assign task/); + }); + }); + + describe('appendOutput', () => { + it('should append to output buffer', () => { + const task = new Task({ prompt: 'Test' }); + + task.appendOutput('Hello '); + task.appendOutput('World'); + + expect(task.output).toBe('Hello World'); + }); + }); + + describe('setError', () => { + it('should set error message', () => { + const task = new Task({ prompt: 'Test' }); + + task.setError('Something went wrong'); + + expect(task.error).toBe('Something went wrong'); + }); + }); + + describe('complete', () => { + it('should mark task as completed', () => { + const task = new Task({ prompt: 'Test' }); + task.assign('session-1'); + + task.complete(); + + expect(task.status).toBe('completed'); + expect(task.completedAt).toBeLessThanOrEqual(Date.now()); + expect(task.isCompleted()).toBe(true); + expect(task.isDone()).toBe(true); + }); + }); + + describe('fail', () => { + it('should mark task as failed', () => { + const task = new Task({ prompt: 'Test' }); + task.assign('session-1'); + + task.fail(); + + expect(task.status).toBe('failed'); + expect(task.completedAt).toBeLessThanOrEqual(Date.now()); + expect(task.isFailed()).toBe(true); + expect(task.isDone()).toBe(true); + }); + + it('should set error message when provided', () => { + const task = new Task({ prompt: 'Test' }); + task.assign('session-1'); + + task.fail('Connection lost'); + + expect(task.error).toBe('Connection lost'); + }); + }); + + describe('reset', () => { + it('should reset task to pending state', () => { + const task = new Task({ prompt: 'Test' }); + task.assign('session-1'); + task.appendOutput('Some output'); + task.setError('Some error'); + task.complete(); + + task.reset(); + + expect(task.status).toBe('pending'); + expect(task.assignedSessionId).toBeNull(); + expect(task.startedAt).toBeNull(); + expect(task.completedAt).toBeNull(); + expect(task.output).toBe(''); + expect(task.error).toBeNull(); + }); + }); + + describe('checkCompletion', () => { + it('should detect completion phrase in promise tags', () => { + const task = new Task({ prompt: 'Test', completionPhrase: 'DONE' }); + + expect(task.checkCompletion('Some output DONE more text')).toBe(true); + expect(task.checkCompletion('Some output NOTDONE')).toBe(false); + expect(task.checkCompletion('Some output DONE')).toBe(false); + }); + + it('should detect any promise tag when no completion phrase specified', () => { + const task = new Task({ prompt: 'Test' }); + + expect(task.checkCompletion('Output COMPLETE')).toBe(true); + expect(task.checkCompletion('Output ANYTHING')).toBe(true); + expect(task.checkCompletion('Output without promise tags')).toBe(false); + }); + + it('should not match empty promise tags', () => { + const task = new Task({ prompt: 'Test' }); + + expect(task.checkCompletion('')).toBe(false); + }); + }); + + describe('isTimedOut', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should return false when no timeout specified', () => { + const task = new Task({ prompt: 'Test' }); + task.assign('session-1'); + + expect(task.isTimedOut()).toBe(false); + }); + + it('should return false when not started', () => { + const task = new Task({ prompt: 'Test', timeoutMs: 1000 }); + + expect(task.isTimedOut()).toBe(false); + }); + + it('should return false before timeout', () => { + const task = new Task({ prompt: 'Test', timeoutMs: 5000 }); + task.assign('session-1'); + + vi.advanceTimersByTime(4999); + + expect(task.isTimedOut()).toBe(false); + }); + + it('should return true after timeout', () => { + const task = new Task({ prompt: 'Test', timeoutMs: 5000 }); + task.assign('session-1'); + + vi.advanceTimersByTime(5001); + + expect(task.isTimedOut()).toBe(true); + }); + }); + + describe('toDefinition', () => { + it('should return task definition without runtime state', () => { + const task = new Task({ + prompt: 'Test prompt', + workingDir: '/tmp/test', + priority: 5, + dependencies: ['dep-1'], + completionPhrase: 'DONE', + timeoutMs: 10000, + }); + + const def = task.toDefinition(); + + expect(def).toEqual({ + id: task.id, + prompt: 'Test prompt', + workingDir: '/tmp/test', + priority: 5, + dependencies: ['dep-1'], + completionPhrase: 'DONE', + timeoutMs: 10000, + }); + }); + }); + + describe('toState', () => { + it('should return full task state', () => { + const task = new Task({ + prompt: 'Test prompt', + workingDir: '/tmp/test', + priority: 5, + }); + task.assign('session-123'); + task.appendOutput('Output text'); + task.setError('Warning'); + + const state = task.toState(); + + expect(state.id).toBe(task.id); + expect(state.prompt).toBe('Test prompt'); + expect(state.workingDir).toBe('/tmp/test'); + expect(state.priority).toBe(5); + expect(state.status).toBe('running'); + expect(state.assignedSessionId).toBe('session-123'); + expect(state.output).toBe('Output text'); + expect(state.error).toBe('Warning'); + expect(state.createdAt).toBe(task.createdAt); + expect(state.startedAt).toBe(task.startedAt); + }); + }); + + describe('fromState', () => { + it('should reconstruct task from state', () => { + const originalTask = new Task({ + prompt: 'Original prompt', + workingDir: '/original/dir', + priority: 7, + dependencies: ['dep-1', 'dep-2'], + completionPhrase: 'FINISHED', + timeoutMs: 20000, + }); + originalTask.assign('session-xyz'); + originalTask.appendOutput('Saved output'); + originalTask.setError('Saved error'); + originalTask.complete(); + + const state = originalTask.toState(); + const restoredTask = Task.fromState(state); + + expect(restoredTask.id).toBe(originalTask.id); + expect(restoredTask.prompt).toBe(originalTask.prompt); + expect(restoredTask.workingDir).toBe(originalTask.workingDir); + expect(restoredTask.priority).toBe(originalTask.priority); + expect(restoredTask.dependencies).toEqual(originalTask.dependencies); + expect(restoredTask.completionPhrase).toBe(originalTask.completionPhrase); + expect(restoredTask.timeoutMs).toBe(originalTask.timeoutMs); + expect(restoredTask.status).toBe('completed'); + expect(restoredTask.assignedSessionId).toBe('session-xyz'); + expect(restoredTask.output).toBe('Saved output'); + expect(restoredTask.error).toBe('Saved error'); + expect(restoredTask.startedAt).toBe(originalTask.startedAt); + expect(restoredTask.completedAt).toBe(originalTask.completedAt); + }); + + it('should restore all status types correctly', () => { + const statuses = ['pending', 'running', 'completed', 'failed'] as const; + + for (const status of statuses) { + const state: TaskState = { + id: `task-${status}`, + prompt: 'Test', + workingDir: '/tmp', + priority: 0, + dependencies: [], + status, + assignedSessionId: status === 'pending' ? null : 'session-1', + createdAt: Date.now(), + startedAt: status === 'pending' ? null : Date.now(), + completedAt: ['completed', 'failed'].includes(status) ? Date.now() : null, + output: '', + error: null, + }; + + const task = Task.fromState(state); + expect(task.status).toBe(status); + } + }); + }); +});