/** * Tests for taskRunner — mock-based unit tests. */ import { describe, it, expect, beforeEach, vi } from 'vitest'; // Mock all desktop and external dependencies vi.mock('@mariozechner/pi-ai', () => ({ complete: vi.fn().mockResolvedValue({ content: [{ type: 'text', text: '{"summary": "compressed memory"}' }], }), getModel: vi.fn().mockReturnValue({ provider: 'mock', model: 'mock' }), })); vi.mock('@mariozechner/pi-agent-core', () => { const subscribers: Array<(event: any) => void> = []; let aborted = false; return { Agent: vi.fn().mockImplementation((config: any) => ({ state: { messages: [ { role: 'assistant', content: [{ type: 'text', text: 'Task completed successfully' }], stopReason: 'end', }, ], }, subscribe: vi.fn((fn: any) => { subscribers.push(fn); return () => {}; }), prompt: vi.fn(async () => { // Simulate one turn for (const sub of subscribers) { sub({ type: 'turn_end', message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] }, toolResults: [{ toolName: 'computer_done', isError: false }], }); } }), abort: vi.fn(() => { aborted = true; }), setSystemPrompt: vi.fn(), })), }; }); vi.mock('../../src/desktop/screenshot.js', () => ({ captureScreenshot: vi.fn().mockResolvedValue({ image: 'base64screenshot', mimeType: 'image/jpeg', imageWidth: 1920, imageHeight: 1080, logicalWidth: 1920, logicalHeight: 1080, physicalWidth: 1920, physicalHeight: 1080, scaleFactor: 1, displayIndex: 0, }), })); vi.mock('../../src/desktop/mouse.js', () => ({ click: vi.fn(), doubleClick: vi.fn(), moveTo: vi.fn(), drag: vi.fn(), scroll: vi.fn(), getPosition: vi.fn().mockResolvedValue({ x: 0, y: 0 }), })); vi.mock('../../src/desktop/keyboard.js', () => ({ typeText: vi.fn(), pressKey: vi.fn(), hotkey: vi.fn(), })); vi.mock('../../src/desktop/display.js', () => ({ getDisplay: vi.fn().mockResolvedValue({ index: 0, label: 'Main', width: 1920, height: 1080, scaleFactor: 1, isPrimary: true, origin: { x: 0, y: 0 }, }), })); vi.mock('../../src/coordinates/modelProfiles.js', () => ({ getModelProfile: vi.fn().mockReturnValue({ coordinateMode: 'image-absolute' as const, coordinateOrder: 'xy', }), })); vi.mock('sharp', () => ({ default: vi.fn().mockReturnValue({ resize: vi.fn().mockReturnThis(), raw: vi.fn().mockReturnThis(), toBuffer: vi.fn().mockResolvedValue(Buffer.alloc(32 * 32 * 3)), }), })); import { createTaskRunner, createModel } from '../../src/agent/taskRunner.js'; import { AuditLog } from '../../src/safety/auditLog.js'; import type { GuiAgentConfig } from '../../src/config.js'; function createTestConfig(): GuiAgentConfig { return { provider: 'anthropic', apiProtocol: 'anthropic', model: 'claude-sonnet-4-20250514', apiKey: 'test-key', port: 60008, maxSteps: 50, stepDelayMs: 0, stuckThreshold: 3, jpegQuality: 75, displayIndex: 0, transport: 'http' as const, coordinateMode: 'image-absolute' as const, }; } describe('createTaskRunner', () => { let auditLog: AuditLog; beforeEach(() => { vi.clearAllMocks(); auditLog = new AuditLog(); }); it('should return run and abort functions', () => { const runner = createTaskRunner(createTestConfig(), auditLog); expect(typeof runner.run).toBe('function'); expect(typeof runner.abort).toBe('function'); }); it('should complete a task successfully', async () => { const runner = createTaskRunner(createTestConfig(), auditLog); const controller = new AbortController(); const progressCalls: any[] = []; const result = await runner.run( 'Open Finder', controller.signal, (info) => progressCalls.push(info), ); expect(result.success).toBe(true); expect(result.finalScreenshot).toBe('base64screenshot'); expect(result.steps.length).toBeGreaterThanOrEqual(0); }); it('should handle abort signal', async () => { const { Agent } = await import('@mariozechner/pi-agent-core'); const mockAgent = (Agent as any).mockImplementation((config: any) => ({ state: { messages: [ { role: 'assistant', content: [{ type: 'text', text: 'aborted' }], stopReason: 'aborted', }, ], }, subscribe: vi.fn(() => () => {}), prompt: vi.fn(async () => {}), abort: vi.fn(), setSystemPrompt: vi.fn(), })); const runner = createTaskRunner(createTestConfig(), auditLog); const controller = new AbortController(); const result = await runner.run( 'Open Finder', controller.signal, () => {}, ); expect(result.success).toBe(false); expect(result.error).toBe('Task was aborted'); }); it('should handle errors in task execution', async () => { const { Agent } = await import('@mariozechner/pi-agent-core'); (Agent as any).mockImplementation(() => ({ state: { messages: [] }, subscribe: vi.fn(() => () => {}), prompt: vi.fn(async () => { throw new Error('API connection failed'); }), abort: vi.fn(), setSystemPrompt: vi.fn(), })); const runner = createTaskRunner(createTestConfig(), auditLog); const controller = new AbortController(); const result = await runner.run( 'Open Finder', controller.signal, () => {}, ); expect(result.success).toBe(false); expect(result.error).toBe('API connection failed'); }); it('should enforce maxSteps', async () => { const { Agent } = await import('@mariozechner/pi-agent-core'); let agentAborted = false; (Agent as any).mockImplementation((agentConfig: any) => { const subs: any[] = []; return { state: { messages: [ { role: 'assistant', content: [{ type: 'text', text: 'done' }], stopReason: 'aborted' }, ], }, subscribe: vi.fn((fn: any) => { subs.push(fn); return () => {}; }), prompt: vi.fn(async () => { // Simulate maxSteps+1 turns to trigger abort for (let i = 0; i < 5; i++) { for (const sub of subs) { sub({ type: 'turn_end', message: { role: 'assistant', content: [] }, toolResults: [{ toolName: 'computer_screenshot', isError: false }], }); } } }), abort: vi.fn(() => { agentAborted = true; }), setSystemPrompt: vi.fn(), }; }); const config = createTestConfig(); config.maxSteps = 3; const runner = createTaskRunner(config, auditLog); const controller = new AbortController(); const result = await runner.run('Open Finder', controller.signal, () => {}); expect(agentAborted).toBe(true); }); }); describe('createModel', () => { it('uses getModel for built-in anthropic provider without baseUrl', () => { const model = createModel('anthropic', 'anthropic', 'claude-sonnet-4-20250514'); // getModel is mocked to return { provider: 'mock', model: 'mock' } expect(model).toEqual({ provider: 'mock', model: 'mock' }); }); it('uses getModel for built-in openai provider without baseUrl', () => { const model = createModel('openai', 'openai', 'gpt-4o'); expect(model).toEqual({ provider: 'mock', model: 'mock' }); }); it('uses getModel for built-in google provider without baseUrl', () => { const model = createModel('google', 'openai', 'gemini-2.5-pro'); expect(model).toEqual({ provider: 'mock', model: 'mock' }); }); it('constructs manual Model for custom provider', () => { const model = createModel('zhipu', 'openai', 'glm-4v-plus', 'https://open.bigmodel.cn/api/paas/v4'); expect(model.id).toBe('glm-4v-plus'); expect(model.name).toBe('glm-4v-plus'); expect(model.api).toBe('openai-completions'); expect(model.provider).toBe('zhipu'); expect(model.baseUrl).toBe('https://open.bigmodel.cn/api/paas/v4'); }); it('constructs manual Model for built-in provider with custom baseUrl', () => { const model = createModel('anthropic', 'anthropic', 'claude-sonnet-4-20250514', 'https://proxy.example.com'); expect(model.id).toBe('claude-sonnet-4-20250514'); expect(model.api).toBe('anthropic-messages'); expect(model.baseUrl).toBe('https://proxy.example.com'); }); it('maps anthropic protocol to anthropic-messages api', () => { const model = createModel('custom', 'anthropic', 'my-model', 'https://custom.api.com'); expect(model.api).toBe('anthropic-messages'); }); it('maps openai protocol to openai-completions api', () => { const model = createModel('custom', 'openai', 'my-model', 'https://custom.api.com'); expect(model.api).toBe('openai-completions'); }); });