From 55911f3cbacb0ddbac99a663f914a05c3dcee0f6 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Wed, 8 Apr 2026 22:03:40 +0100 Subject: [PATCH 1/4] refactor: migrate simulator and simulator-management tools to event-based handler contract --- .../__tests__/erase_sims.test.ts | 70 +- .../__tests__/reset_sim_location.test.ts | 110 +-- .../__tests__/set_sim_appearance.test.ts | 121 +-- .../__tests__/set_sim_location.test.ts | 369 +++------ .../__tests__/sim_statusbar.test.ts | 200 ++--- .../tools/simulator-management/erase_sims.ts | 116 +-- .../reset_sim_location.ts | 95 +-- .../set_sim_appearance.ts | 101 +-- .../simulator-management/set_sim_location.ts | 138 ++-- .../simulator-management/sim_statusbar.ts | 98 ++- .../simulator/__tests__/boot_sim.test.ts | 112 +-- .../simulator/__tests__/build_run_sim.test.ts | 267 ++++-- .../simulator/__tests__/build_sim.test.ts | 198 ++--- .../__tests__/get_sim_app_path.test.ts | 103 ++- .../__tests__/install_app_sim.test.ts | 248 +++--- .../__tests__/launch_app_sim.test.ts | 438 +++++----- .../simulator/__tests__/list_sims.test.ts | 222 ++--- .../simulator/__tests__/open_sim.test.ts | 116 +-- .../__tests__/record_sim_video.test.ts | 134 ++- .../simulator/__tests__/screenshot.test.ts | 388 +++++---- .../simulator/__tests__/stop_app_sim.test.ts | 158 ++-- .../simulator/__tests__/test_sim.test.ts | 11 +- src/mcp/tools/simulator/boot_sim.ts | 67 +- src/mcp/tools/simulator/build_run_sim.ts | 762 +++++++++--------- src/mcp/tools/simulator/build_sim.ts | 107 ++- src/mcp/tools/simulator/get_sim_app_path.ts | 193 +++-- src/mcp/tools/simulator/install_app_sim.ts | 115 +-- src/mcp/tools/simulator/launch_app_sim.ts | 155 ++-- src/mcp/tools/simulator/list_sims.ts | 184 +++-- src/mcp/tools/simulator/open_sim.ts | 76 +- src/mcp/tools/simulator/record_sim_video.ts | 137 ++-- src/mcp/tools/simulator/stop_app_sim.ts | 68 +- src/mcp/tools/simulator/test_sim.ts | 65 +- src/utils/__tests__/simulator-utils.test.ts | 10 +- src/utils/simulator-resolver.ts | 123 +-- src/utils/simulator-utils.ts | 125 ++- 36 files changed, 2919 insertions(+), 3081 deletions(-) diff --git a/src/mcp/tools/simulator-management/__tests__/erase_sims.test.ts b/src/mcp/tools/simulator-management/__tests__/erase_sims.test.ts index f830a41b..af06b71e 100644 --- a/src/mcp/tools/simulator-management/__tests__/erase_sims.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/erase_sims.test.ts @@ -1,41 +1,69 @@ import { describe, it, expect } from 'vitest'; -import * as z from 'zod'; import { schema, erase_simsLogic } from '../erase_sims.ts'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; + +const runLogic = async (logic: () => Promise) => { + const { result, run } = createMockToolHandlerContext(); + const response = await run(logic); + + if ( + response && + typeof response === 'object' && + 'content' in (response as Record) + ) { + return response as { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + isError?: boolean; + nextStepParams?: unknown; + }; + } + + const text = result.text(); + const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; + const imageContent = result.attachments.map((attachment) => ({ + type: 'image' as const, + data: attachment.data, + mimeType: attachment.mimeType, + })); + + return { + content: [...textContent, ...imageContent], + isError: result.isError() ? true : undefined, + nextStepParams: result.nextStepParams, + attachments: result.attachments, + text, + }; +}; describe('erase_sims tool (single simulator)', () => { - describe('Schema Validation', () => { - it('should validate schema fields (shape only)', () => { - const schemaObj = z.object(schema); - expect(schemaObj.safeParse({ shutdownFirst: true }).success).toBe(true); - expect(schemaObj.safeParse({}).success).toBe(true); + describe('Plugin Structure', () => { + it('should expose schema', () => { + expect(schema).toBeDefined(); }); }); describe('Single mode', () => { it('erases a simulator successfully', async () => { const mock = createMockExecutor({ success: true, output: 'OK' }); - const res = await erase_simsLogic({ simulatorId: 'UD1' }, mock); - expect(res).toEqual({ - content: [{ type: 'text', text: 'Successfully erased simulator UD1' }], - }); + const res = await runLogic(() => erase_simsLogic({ simulatorId: 'UD1' }, mock)); + expect(res.isError).toBeFalsy(); }); it('returns failure when erase fails', async () => { const mock = createMockExecutor({ success: false, error: 'Booted device' }); - const res = await erase_simsLogic({ simulatorId: 'UD1' }, mock); - expect(res).toEqual({ - content: [{ type: 'text', text: 'Failed to erase simulator: Booted device' }], - }); + const res = await runLogic(() => erase_simsLogic({ simulatorId: 'UD1' }, mock)); + expect(res.isError).toBe(true); }); it('adds tool hint when booted error occurs without shutdownFirst', async () => { const bootedError = 'An error was encountered processing the command (domain=com.apple.CoreSimulator.SimError, code=405):\nUnable to erase contents and settings in current state: Booted\n'; const mock = createMockExecutor({ success: false, error: bootedError }); - const res = await erase_simsLogic({ simulatorId: 'UD1' }, mock); - expect((res.content?.[1] as any).text).toContain('Tool hint'); - expect((res.content?.[1] as any).text).toContain('shutdownFirst: true'); + const res = await runLogic(() => erase_simsLogic({ simulatorId: 'UD1' }, mock)); + const text = allText(res); + expect(text).toContain('shutdownFirst: true'); + expect(res.isError).toBe(true); }); it('performs shutdown first when shutdownFirst=true', async () => { @@ -44,14 +72,14 @@ describe('erase_sims tool (single simulator)', () => { calls.push(cmd); return { success: true, output: 'OK', error: '', process: { pid: 1 } as any }; }; - const res = await erase_simsLogic({ simulatorId: 'UD1', shutdownFirst: true }, exec as any); + const res = await runLogic(() => + erase_simsLogic({ simulatorId: 'UD1', shutdownFirst: true }, exec as any), + ); expect(calls).toEqual([ ['xcrun', 'simctl', 'shutdown', 'UD1'], ['xcrun', 'simctl', 'erase', 'UD1'], ]); - expect(res).toEqual({ - content: [{ type: 'text', text: 'Successfully erased simulator UD1' }], - }); + expect(res.isError).toBeFalsy(); }); }); }); diff --git a/src/mcp/tools/simulator-management/__tests__/reset_sim_location.test.ts b/src/mcp/tools/simulator-management/__tests__/reset_sim_location.test.ts index 131aeee1..125954f2 100644 --- a/src/mcp/tools/simulator-management/__tests__/reset_sim_location.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/reset_sim_location.test.ts @@ -2,6 +2,40 @@ import { describe, it, expect } from 'vitest'; import * as z from 'zod'; import { schema, reset_sim_locationLogic } from '../reset_sim_location.ts'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; + +const runLogic = async (logic: () => Promise) => { + const { result, run } = createMockToolHandlerContext(); + const response = await run(logic); + + if ( + response && + typeof response === 'object' && + 'content' in (response as Record) + ) { + return response as { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + isError?: boolean; + nextStepParams?: unknown; + }; + } + + const text = result.text(); + const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; + const imageContent = result.attachments.map((attachment) => ({ + type: 'image' as const, + data: attachment.data, + mimeType: attachment.mimeType, + })); + + return { + content: [...textContent, ...imageContent], + isError: result.isError() ? true : undefined, + nextStepParams: result.nextStepParams, + attachments: result.attachments, + text, + }; +}; describe('reset_sim_location plugin', () => { describe('Schema Validation', () => { @@ -23,21 +57,16 @@ describe('reset_sim_location plugin', () => { output: 'Location reset successfully', }); - const result = await reset_sim_locationLogic( - { - simulatorId: 'test-uuid-123', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + reset_sim_locationLogic( { - type: 'text', - text: 'Successfully reset simulator test-uuid-123 location.', + simulatorId: 'test-uuid-123', }, - ], - }); + mockExecutor, + ), + ); + + expect(result.isError).toBeFalsy(); }); it('should handle command failure', async () => { @@ -46,41 +75,31 @@ describe('reset_sim_location plugin', () => { error: 'Command failed', }); - const result = await reset_sim_locationLogic( - { - simulatorId: 'test-uuid-123', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + reset_sim_locationLogic( { - type: 'text', - text: 'Failed to reset simulator location: Command failed', + simulatorId: 'test-uuid-123', }, - ], - }); + mockExecutor, + ), + ); + + expect(result.isError).toBe(true); }); it('should handle exception during execution', async () => { const mockExecutor = createMockExecutor(new Error('Network error')); - const result = await reset_sim_locationLogic( - { - simulatorId: 'test-uuid-123', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + reset_sim_locationLogic( { - type: 'text', - text: 'Failed to reset simulator location: Network error', + simulatorId: 'test-uuid-123', }, - ], - }); + mockExecutor, + ), + ); + + expect(result.isError).toBe(true); }); it('should call correct command', async () => { @@ -92,18 +111,19 @@ describe('reset_sim_location plugin', () => { output: 'Location reset successfully', }); - // Create a wrapper to capture the command arguments const capturingExecutor = async (command: string[], logPrefix?: string) => { capturedCommand = command; capturedLogPrefix = logPrefix; return mockExecutor(command, logPrefix); }; - await reset_sim_locationLogic( - { - simulatorId: 'test-uuid-123', - }, - capturingExecutor, + await runLogic(() => + reset_sim_locationLogic( + { + simulatorId: 'test-uuid-123', + }, + capturingExecutor, + ), ); expect(capturedCommand).toEqual(['xcrun', 'simctl', 'location', 'test-uuid-123', 'clear']); diff --git a/src/mcp/tools/simulator-management/__tests__/set_sim_appearance.test.ts b/src/mcp/tools/simulator-management/__tests__/set_sim_appearance.test.ts index a5b3a0a5..2d612a9b 100644 --- a/src/mcp/tools/simulator-management/__tests__/set_sim_appearance.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/set_sim_appearance.test.ts @@ -1,6 +1,41 @@ import { describe, it, expect } from 'vitest'; import * as z from 'zod'; import { schema, handler, set_sim_appearanceLogic } from '../set_sim_appearance.ts'; +import { createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; + +const runLogic = async (logic: () => Promise) => { + const { result, run } = createMockToolHandlerContext(); + const response = await run(logic); + + if ( + response && + typeof response === 'object' && + 'content' in (response as Record) + ) { + return response as { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + isError?: boolean; + nextStepParams?: unknown; + }; + } + + const text = result.text(); + const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; + const imageContent = result.attachments.map((attachment) => ({ + type: 'image' as const, + data: attachment.data, + mimeType: attachment.mimeType, + })); + + return { + content: [...textContent, ...imageContent], + isError: result.isError() ? true : undefined, + nextStepParams: result.nextStepParams, + attachments: result.attachments, + text, + }; +}; + import { createMockCommandResponse, createMockExecutor, @@ -33,22 +68,17 @@ describe('set_sim_appearance plugin', () => { error: '', }); - const result = await set_sim_appearanceLogic( - { - simulatorId: 'test-uuid-123', - mode: 'dark', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + set_sim_appearanceLogic( { - type: 'text', - text: 'Successfully set simulator test-uuid-123 appearance to dark mode', + simulatorId: 'test-uuid-123', + mode: 'dark', }, - ], - }); + mockExecutor, + ), + ); + + expect(result.isError).toBeFalsy(); }); it('should handle appearance change failure', async () => { @@ -57,29 +87,24 @@ describe('set_sim_appearance plugin', () => { error: 'Invalid device: invalid-uuid', }); - const result = await set_sim_appearanceLogic( - { - simulatorId: 'invalid-uuid', - mode: 'light', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + set_sim_appearanceLogic( { - type: 'text', - text: 'Failed to set simulator appearance: Invalid device: invalid-uuid', + simulatorId: 'invalid-uuid', + mode: 'light', }, - ], - }); + mockExecutor, + ), + ); + + expect(result.isError).toBe(true); }); it('should surface session default requirement when simulatorId is missing', async () => { const result = await handler({ mode: 'dark' }); const message = result.content?.[0]?.text ?? ''; - expect(message).toContain('Error: Missing required session defaults'); + expect(message).toContain('Missing required session defaults'); expect(message).toContain('simulatorId is required'); expect(result.isError).toBe(true); }); @@ -87,22 +112,17 @@ describe('set_sim_appearance plugin', () => { it('should handle exception during execution', async () => { const mockExecutor = createMockExecutor(new Error('Network error')); - const result = await set_sim_appearanceLogic( - { - simulatorId: 'test-uuid-123', - mode: 'dark', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + set_sim_appearanceLogic( { - type: 'text', - text: 'Failed to set simulator appearance: Network error', + simulatorId: 'test-uuid-123', + mode: 'dark', }, - ], - }); + mockExecutor, + ), + ); + + expect(result.isError).toBe(true); }); it('should call correct command', async () => { @@ -118,12 +138,14 @@ describe('set_sim_appearance plugin', () => { ); }; - await set_sim_appearanceLogic( - { - simulatorId: 'test-uuid-123', - mode: 'dark', - }, - mockExecutor, + await runLogic(() => + set_sim_appearanceLogic( + { + simulatorId: 'test-uuid-123', + mode: 'dark', + }, + mockExecutor, + ), ); expect(commandCalls).toEqual([ @@ -131,7 +153,6 @@ describe('set_sim_appearance plugin', () => { ['xcrun', 'simctl', 'ui', 'test-uuid-123', 'appearance', 'dark'], 'Set Simulator Appearance', false, - undefined, ], ]); }); diff --git a/src/mcp/tools/simulator-management/__tests__/set_sim_location.test.ts b/src/mcp/tools/simulator-management/__tests__/set_sim_location.test.ts index bdffd902..de07cfbb 100644 --- a/src/mcp/tools/simulator-management/__tests__/set_sim_location.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/set_sim_location.test.ts @@ -1,9 +1,3 @@ -/** - * Tests for set_sim_location tool - * Following CLAUDE.md testing standards with literal validation - * Using pure dependency injection for deterministic testing - */ - import { describe, it, expect } from 'vitest'; import * as z from 'zod'; import { @@ -12,6 +6,40 @@ import { createNoopExecutor, } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, set_sim_locationLogic } from '../set_sim_location.ts'; +import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; + +const runLogic = async (logic: () => Promise) => { + const { result, run } = createMockToolHandlerContext(); + const response = await run(logic); + + if ( + response && + typeof response === 'object' && + 'content' in (response as Record) + ) { + return response as { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + isError?: boolean; + nextStepParams?: unknown; + }; + } + + const text = result.text(); + const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; + const imageContent = result.attachments.map((attachment) => ({ + type: 'image' as const, + data: attachment.data, + mimeType: attachment.mimeType, + })); + + return { + content: [...textContent, ...imageContent], + isError: result.isError() ? true : undefined, + nextStepParams: result.nextStepParams, + attachments: result.attachments, + text, + }; +}; describe('set_sim_location tool', () => { describe('Export Field Validation (Literal)', () => { @@ -49,13 +77,15 @@ describe('set_sim_location tool', () => { }); }; - await set_sim_locationLogic( - { - simulatorId: 'test-uuid-123', - latitude: 37.7749, - longitude: -122.4194, - }, - mockExecutor, + await runLogic(() => + set_sim_locationLogic( + { + simulatorId: 'test-uuid-123', + latitude: 37.7749, + longitude: -122.4194, + }, + mockExecutor, + ), ); expect(capturedCommand).toEqual([ @@ -68,42 +98,11 @@ describe('set_sim_location tool', () => { ]); }); - it('should generate command with different coordinates', async () => { - let capturedCommand: string[] = []; - - const mockExecutor = async (command: string[]) => { - capturedCommand = command; - return createMockCommandResponse({ - success: true, - output: 'Location set successfully', - error: undefined, - }); - }; - - await set_sim_locationLogic( - { - simulatorId: 'different-uuid', - latitude: 45.5, - longitude: -73.6, - }, - mockExecutor, - ); - - expect(capturedCommand).toEqual([ - 'xcrun', - 'simctl', - 'location', - 'different-uuid', - 'set', - '45.5,-73.6', - ]); - }); - - it('should generate command with negative coordinates', async () => { - let capturedCommand: string[] = []; + it('should verify correct executor arguments', async () => { + let capturedArgs: any[] = []; - const mockExecutor = async (command: string[]) => { - capturedCommand = command; + const mockExecutor = async (...args: any[]) => { + capturedArgs = args; return createMockCommandResponse({ success: true, output: 'Location set successfully', @@ -111,22 +110,21 @@ describe('set_sim_location tool', () => { }); }; - await set_sim_locationLogic( - { - simulatorId: 'test-uuid', - latitude: -90, - longitude: -180, - }, - mockExecutor, + await runLogic(() => + set_sim_locationLogic( + { + simulatorId: 'test-uuid-123', + latitude: 37.7749, + longitude: -122.4194, + }, + mockExecutor, + ), ); - expect(capturedCommand).toEqual([ - 'xcrun', - 'simctl', - 'location', - 'test-uuid', - 'set', - '-90,-180', + expect(capturedArgs).toEqual([ + ['xcrun', 'simctl', 'location', 'test-uuid-123', 'set', '37.7749,-122.4194'], + 'Set Simulator Location', + false, ]); }); }); @@ -139,63 +137,50 @@ describe('set_sim_location tool', () => { error: undefined, }); - const result = await set_sim_locationLogic( - { - simulatorId: 'test-uuid-123', - latitude: 37.7749, - longitude: -122.4194, - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + set_sim_locationLogic( { - type: 'text', - text: 'Successfully set simulator test-uuid-123 location to 37.7749,-122.4194', + simulatorId: 'test-uuid-123', + latitude: 37.7749, + longitude: -122.4194, }, - ], - }); + mockExecutor, + ), + ); + + expect(result.isError).toBeFalsy(); }); it('should handle latitude validation failure', async () => { - const result = await set_sim_locationLogic( - { - simulatorId: 'test-uuid-123', - latitude: 95, - longitude: -122.4194, - }, - createNoopExecutor(), - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + set_sim_locationLogic( { - type: 'text', - text: 'Latitude must be between -90 and 90 degrees', + simulatorId: 'test-uuid-123', + latitude: 95, + longitude: -122.4194, }, - ], - }); + createNoopExecutor(), + ), + ); + + expect(allText(result)).toContain('Latitude must be between -90 and 90 degrees'); + expect(result.isError).toBe(true); }); it('should handle longitude validation failure', async () => { - const result = await set_sim_locationLogic( - { - simulatorId: 'test-uuid-123', - latitude: 37.7749, - longitude: -185, - }, - createNoopExecutor(), - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + set_sim_locationLogic( { - type: 'text', - text: 'Longitude must be between -180 and 180 degrees', + simulatorId: 'test-uuid-123', + latitude: 37.7749, + longitude: -185, }, - ], - }); + createNoopExecutor(), + ), + ); + + expect(allText(result)).toContain('Longitude must be between -180 and 180 degrees'); + expect(result.isError).toBe(true); }); it('should handle command failure', async () => { @@ -205,67 +190,35 @@ describe('set_sim_location tool', () => { error: 'Simulator not found', }); - const result = await set_sim_locationLogic( - { - simulatorId: 'invalid-uuid', - latitude: 37.7749, - longitude: -122.4194, - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + set_sim_locationLogic( { - type: 'text', - text: 'Failed to set simulator location: Simulator not found', + simulatorId: 'invalid-uuid', + latitude: 37.7749, + longitude: -122.4194, }, - ], - }); + mockExecutor, + ), + ); + + expect(result.isError).toBe(true); }); it('should handle exception with Error object', async () => { const mockExecutor = createMockExecutor(new Error('Connection failed')); - const result = await set_sim_locationLogic( - { - simulatorId: 'test-uuid-123', - latitude: 37.7749, - longitude: -122.4194, - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + set_sim_locationLogic( { - type: 'text', - text: 'Failed to set simulator location: Connection failed', + simulatorId: 'test-uuid-123', + latitude: 37.7749, + longitude: -122.4194, }, - ], - }); - }); - - it('should handle exception with string error', async () => { - const mockExecutor = createMockExecutor('String error'); - - const result = await set_sim_locationLogic( - { - simulatorId: 'test-uuid-123', - latitude: 37.7749, - longitude: -122.4194, - }, - mockExecutor, + mockExecutor, + ), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to set simulator location: String error', - }, - ], - }); + expect(result.isError).toBe(true); }); it('should handle boundary values for coordinates', async () => { @@ -275,104 +228,18 @@ describe('set_sim_location tool', () => { error: undefined, }); - const result = await set_sim_locationLogic( - { - simulatorId: 'test-uuid-123', - latitude: 90, - longitude: 180, - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + set_sim_locationLogic( { - type: 'text', - text: 'Successfully set simulator test-uuid-123 location to 90,180', + simulatorId: 'test-uuid-123', + latitude: 90, + longitude: 180, }, - ], - }); - }); - - it('should handle boundary values for negative coordinates', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: 'Location set successfully', - error: undefined, - }); - - const result = await set_sim_locationLogic( - { - simulatorId: 'test-uuid-123', - latitude: -90, - longitude: -180, - }, - mockExecutor, + mockExecutor, + ), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Successfully set simulator test-uuid-123 location to -90,-180', - }, - ], - }); - }); - - it('should handle zero coordinates', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: 'Location set successfully', - error: undefined, - }); - - const result = await set_sim_locationLogic( - { - simulatorId: 'test-uuid-123', - latitude: 0, - longitude: 0, - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Successfully set simulator test-uuid-123 location to 0,0', - }, - ], - }); - }); - - it('should verify correct executor arguments', async () => { - let capturedArgs: any[] = []; - - const mockExecutor = async (...args: any[]) => { - capturedArgs = args; - return createMockCommandResponse({ - success: true, - output: 'Location set successfully', - error: undefined, - }); - }; - - await set_sim_locationLogic( - { - simulatorId: 'test-uuid-123', - latitude: 37.7749, - longitude: -122.4194, - }, - mockExecutor, - ); - - expect(capturedArgs).toEqual([ - ['xcrun', 'simctl', 'location', 'test-uuid-123', 'set', '37.7749,-122.4194'], - 'Set Simulator Location', - false, - {}, - ]); + expect(result.isError).toBeFalsy(); }); }); }); diff --git a/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts b/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts index 89d576e5..77877ed0 100644 --- a/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts @@ -1,9 +1,3 @@ -/** - * Tests for sim_statusbar plugin - * Following CLAUDE.md testing standards with literal validation - * Using dependency injection for deterministic testing - */ - import { describe, it, expect } from 'vitest'; import * as z from 'zod'; import { @@ -12,6 +6,40 @@ import { type CommandExecutor, } from '../../../../test-utils/mock-executors.ts'; import { schema, sim_statusbarLogic } from '../sim_statusbar.ts'; +import { createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; + +const runLogic = async (logic: () => Promise) => { + const { result, run } = createMockToolHandlerContext(); + const response = await run(logic); + + if ( + response && + typeof response === 'object' && + 'content' in (response as Record) + ) { + return response as { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + isError?: boolean; + nextStepParams?: unknown; + }; + } + + const text = result.text(); + const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; + const imageContent = result.attachments.map((attachment) => ({ + type: 'image' as const, + data: attachment.data, + mimeType: attachment.mimeType, + })); + + return { + content: [...textContent, ...imageContent], + isError: result.isError() ? true : undefined, + nextStepParams: result.nextStepParams, + attachments: result.attachments, + text, + }; +}; describe('sim_statusbar tool', () => { describe('Schema Validation', () => { @@ -35,44 +63,17 @@ describe('sim_statusbar tool', () => { output: 'Status bar set successfully', }); - const result = await sim_statusbarLogic( - { - simulatorId: 'test-uuid-123', - dataNetwork: 'wifi', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + sim_statusbarLogic( { - type: 'text', - text: 'Successfully set simulator test-uuid-123 status bar data network to wifi', + simulatorId: 'test-uuid-123', + dataNetwork: 'wifi', }, - ], - }); - }); - - it('should handle minimal valid parameters (Zod handles validation)', async () => { - // Note: With createTypedTool, Zod validation happens before the logic function is called - // So we test with a valid minimal parameter set since validation is handled upstream - const mockExecutor = createMockExecutor({ - success: true, - output: 'Status bar set successfully', - }); - - const result = await sim_statusbarLogic( - { - simulatorId: 'test-uuid-123', - dataNetwork: 'wifi', - }, - mockExecutor, + mockExecutor, + ), ); - // The logic function should execute normally with valid parameters - // Zod validation errors are handled by createTypedTool wrapper - expect(result.isError).toBe(undefined); - expect(result.content[0].text).toContain('Successfully set simulator'); + expect(result.isError).toBeFalsy(); }); it('should handle command failure', async () => { @@ -81,23 +82,17 @@ describe('sim_statusbar tool', () => { error: 'Simulator not found', }); - const result = await sim_statusbarLogic( - { - simulatorId: 'invalid-uuid', - dataNetwork: '3g', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + sim_statusbarLogic( { - type: 'text', - text: 'Failed to set status bar: Simulator not found', + simulatorId: 'invalid-uuid', + dataNetwork: '3g', }, - ], - isError: true, - }); + mockExecutor, + ), + ); + + expect(result.isError).toBe(true); }); it('should handle exception with Error object', async () => { @@ -105,47 +100,17 @@ describe('sim_statusbar tool', () => { throw new Error('Connection failed'); }; - const result = await sim_statusbarLogic( - { - simulatorId: 'test-uuid-123', - dataNetwork: '4g', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + sim_statusbarLogic( { - type: 'text', - text: 'Failed to set status bar: Connection failed', + simulatorId: 'test-uuid-123', + dataNetwork: '4g', }, - ], - isError: true, - }); - }); - - it('should handle exception with string error', async () => { - const mockExecutor: CommandExecutor = async () => { - throw 'String error'; - }; - - const result = await sim_statusbarLogic( - { - simulatorId: 'test-uuid-123', - dataNetwork: 'lte', - }, - mockExecutor, + mockExecutor, + ), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to set status bar: String error', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); }); it('should verify command generation with mock executor for override', async () => { @@ -172,12 +137,14 @@ describe('sim_statusbar tool', () => { }); }; - await sim_statusbarLogic( - { - simulatorId: 'test-uuid-123', - dataNetwork: 'wifi', - }, - mockExecutor, + await runLogic(() => + sim_statusbarLogic( + { + simulatorId: 'test-uuid-123', + dataNetwork: 'wifi', + }, + mockExecutor, + ), ); expect(calls).toHaveLength(1); @@ -221,12 +188,14 @@ describe('sim_statusbar tool', () => { }); }; - await sim_statusbarLogic( - { - simulatorId: 'test-uuid-123', - dataNetwork: 'clear', - }, - mockExecutor, + await runLogic(() => + sim_statusbarLogic( + { + simulatorId: 'test-uuid-123', + dataNetwork: 'clear', + }, + mockExecutor, + ), ); expect(calls).toHaveLength(1); @@ -244,22 +213,17 @@ describe('sim_statusbar tool', () => { output: 'Status bar cleared successfully', }); - const result = await sim_statusbarLogic( - { - simulatorId: 'test-uuid-123', - dataNetwork: 'clear', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + sim_statusbarLogic( { - type: 'text', - text: 'Successfully cleared status bar overrides for simulator test-uuid-123', + simulatorId: 'test-uuid-123', + dataNetwork: 'clear', }, - ], - }); + mockExecutor, + ), + ); + + expect(result.isError).toBeFalsy(); }); }); }); diff --git a/src/mcp/tools/simulator-management/erase_sims.ts b/src/mcp/tools/simulator-management/erase_sims.ts index 2fa20467..e2fcc6e9 100644 --- a/src/mcp/tools/simulator-management/erase_sims.ts +++ b/src/mcp/tools/simulator-management/erase_sims.ts @@ -1,82 +1,90 @@ import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, section, statusLine } from '../../../utils/tool-event-builders.ts'; -const eraseSimsBaseSchema = z +const eraseSimsSchema = z .object({ simulatorId: z.uuid().describe('UDID of the simulator to erase.'), shutdownFirst: z.boolean().optional(), }) .passthrough(); -const eraseSimsSchema = eraseSimsBaseSchema; - type EraseSimsParams = z.infer; export async function erase_simsLogic( params: EraseSimsParams, executor: CommandExecutor, -): Promise { - try { - const simulatorId = params.simulatorId; - log( - 'info', - `Erasing simulator ${simulatorId}${params.shutdownFirst ? ' (shutdownFirst=true)' : ''}`, - ); +): Promise { + const simulatorId = params.simulatorId; + const headerEvent = header('Erase Simulator', [ + { label: 'Simulator', value: simulatorId }, + ...(params.shutdownFirst ? [{ label: 'Shutdown First', value: 'true' }] : []), + ]); - if (params.shutdownFirst) { - try { - await executor( - ['xcrun', 'simctl', 'shutdown', simulatorId], - 'Shutdown Simulator', - true, - undefined, - ); - } catch { - // ignore shutdown errors; proceed to erase attempt + const ctx = getHandlerContext(); + + return withErrorHandling( + ctx, + async () => { + log( + 'info', + `Erasing simulator ${simulatorId}${params.shutdownFirst ? ' (shutdownFirst=true)' : ''}`, + ); + + if (params.shutdownFirst) { + try { + await executor( + ['xcrun', 'simctl', 'shutdown', simulatorId], + 'Shutdown Simulator', + true, + undefined, + ); + } catch { + // ignore shutdown errors; proceed to erase attempt + } } - } - const result = await executor( - ['xcrun', 'simctl', 'erase', simulatorId], - 'Erase Simulator', - true, - undefined, - ); - if (result.success) { - return { - content: [{ type: 'text', text: `Successfully erased simulator ${simulatorId}` }], - }; - } + const result = await executor( + ['xcrun', 'simctl', 'erase', simulatorId], + 'Erase Simulator', + true, + undefined, + ); + if (result.success) { + ctx.emit(headerEvent); + ctx.emit(statusLine('success', 'Simulators were erased successfully')); + return; + } - // Add tool hint if simulator is booted and shutdownFirst was not requested - const errText = result.error ?? 'Unknown error'; - if (/Unable to erase contents and settings.*Booted/i.test(errText) && !params.shutdownFirst) { - return { - content: [ - { type: 'text', text: `Failed to erase simulator: ${errText}` }, - { - type: 'text', - text: `Tool hint: The simulator appears to be Booted. Re-run erase_sims with { simulatorId: '${simulatorId}', shutdownFirst: true } to shut it down before erasing.`, - }, - ], - }; - } + const errText = result.error ?? 'Unknown error'; + if (/Unable to erase contents and settings.*Booted/i.test(errText) && !params.shutdownFirst) { + ctx.emit(headerEvent); + ctx.emit(statusLine('error', `Failed to erase simulator: ${errText}`)); + ctx.emit( + section('Hint', [ + `The simulator appears to be Booted. Re-run erase_sims with { simulatorId: '${simulatorId}', shutdownFirst: true } to shut it down before erasing.`, + ]), + ); + return; + } - return { - content: [{ type: 'text', text: `Failed to erase simulator: ${errText}` }], - }; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - log('error', `Error erasing simulators: ${message}`); - return { content: [{ type: 'text', text: `Failed to erase simulators: ${message}` }] }; - } + ctx.emit(headerEvent); + ctx.emit(statusLine('error', `Failed to erase simulator: ${errText}`)); + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Failed to erase simulator: ${message}`, + logMessage: ({ message }) => `Error erasing simulators: ${message}`, + }, + ); } const publicSchemaObject = eraseSimsSchema.omit({ simulatorId: true } as const).passthrough(); diff --git a/src/mcp/tools/simulator-management/reset_sim_location.ts b/src/mcp/tools/simulator-management/reset_sim_location.ts index fc7a15c4..85d93b2b 100644 --- a/src/mcp/tools/simulator-management/reset_sim_location.ts +++ b/src/mcp/tools/simulator-management/reset_sim_location.ts @@ -1,88 +1,57 @@ import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const resetSimulatorLocationSchema = z.object({ simulatorId: z.uuid().describe('UUID of the simulator to use (obtained from list_simulators)'), }); -// Use z.infer for type safety type ResetSimulatorLocationParams = z.infer; -// Helper function to execute simctl commands and handle responses -async function executeSimctlCommandAndRespond( +export async function reset_sim_locationLogic( params: ResetSimulatorLocationParams, - simctlSubCommand: string[], - operationDescriptionForXcodeCommand: string, - successMessage: string, - failureMessagePrefix: string, - operationLogContext: string, executor: CommandExecutor, - extraValidation?: () => ToolResponse | undefined, -): Promise { - if (extraValidation) { - const validationResult = extraValidation(); - if (validationResult) { - return validationResult; - } - } +): Promise { + log('info', `Resetting simulator ${params.simulatorId} location`); - try { - const command = ['xcrun', 'simctl', ...simctlSubCommand]; - const result = await executor(command, operationDescriptionForXcodeCommand, false, {}); + const headerEvent = header('Reset Location', [{ label: 'Simulator', value: params.simulatorId }]); - if (!result.success) { - const fullFailureMessage = `${failureMessagePrefix}: ${result.error}`; - log( - 'error', - `${fullFailureMessage} (operation: ${operationLogContext}, simulator: ${params.simulatorId})`, - ); - return { - content: [{ type: 'text', text: fullFailureMessage }], - }; - } + const ctx = getHandlerContext(); - log( - 'info', - `${successMessage} (operation: ${operationLogContext}, simulator: ${params.simulatorId})`, - ); - return { - content: [{ type: 'text', text: successMessage }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - const fullFailureMessage = `${failureMessagePrefix}: ${errorMessage}`; - log( - 'error', - `Error during ${operationLogContext} for simulator ${params.simulatorId}: ${errorMessage}`, - ); - return { - content: [{ type: 'text', text: fullFailureMessage }], - }; - } -} + return withErrorHandling( + ctx, + async () => { + const command = ['xcrun', 'simctl', 'location', params.simulatorId, 'clear']; + const result = await executor(command, 'Reset Simulator Location', false); -export async function reset_sim_locationLogic( - params: ResetSimulatorLocationParams, - executor: CommandExecutor, -): Promise { - log('info', `Resetting simulator ${params.simulatorId} location`); + if (!result.success) { + log( + 'error', + `Failed to reset simulator location: ${result.error} (simulator: ${params.simulatorId})`, + ); + ctx.emit(headerEvent); + ctx.emit(statusLine('error', `Failed to reset simulator location: ${result.error}`)); + return; + } - return executeSimctlCommandAndRespond( - params, - ['location', params.simulatorId, 'clear'], - 'Reset Simulator Location', - `Successfully reset simulator ${params.simulatorId} location.`, - 'Failed to reset simulator location', - 'reset simulator location', - executor, + log('info', `Reset simulator ${params.simulatorId} location`); + ctx.emit(headerEvent); + ctx.emit(statusLine('success', 'Location successfully reset to default')); + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Failed to reset simulator location: ${message}`, + logMessage: ({ message }) => + `Error during reset simulator location for simulator ${params.simulatorId}: ${message}`, + }, ); } diff --git a/src/mcp/tools/simulator-management/set_sim_appearance.ts b/src/mcp/tools/simulator-management/set_sim_appearance.ts index cb272b30..5f00f3d7 100644 --- a/src/mcp/tools/simulator-management/set_sim_appearance.ts +++ b/src/mcp/tools/simulator-management/set_sim_appearance.ts @@ -1,90 +1,61 @@ import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const setSimAppearanceSchema = z.object({ simulatorId: z.uuid().describe('UUID of the simulator to use (obtained from list_simulators)'), mode: z.enum(['dark', 'light']).describe('dark|light'), }); -// Use z.infer for type safety type SetSimAppearanceParams = z.infer; -// Helper function to execute simctl commands and handle responses -async function executeSimctlCommandAndRespond( +export async function set_sim_appearanceLogic( params: SetSimAppearanceParams, - simctlSubCommand: string[], - operationDescriptionForXcodeCommand: string, - successMessage: string, - failureMessagePrefix: string, - operationLogContext: string, - extraValidation?: () => ToolResponse | undefined, - executor: CommandExecutor = getDefaultCommandExecutor(), -): Promise { - if (extraValidation) { - const validationResult = extraValidation(); - if (validationResult) { - return validationResult; - } - } + executor: CommandExecutor, +): Promise { + log('info', `Setting simulator ${params.simulatorId} appearance to ${params.mode} mode`); - try { - const command = ['xcrun', 'simctl', ...simctlSubCommand]; - const result = await executor(command, operationDescriptionForXcodeCommand, false, undefined); + const headerEvent = header('Set Appearance', [ + { label: 'Simulator', value: params.simulatorId }, + { label: 'Mode', value: params.mode }, + ]); - if (!result.success) { - const fullFailureMessage = `${failureMessagePrefix}: ${result.error}`; - log( - 'error', - `${fullFailureMessage} (operation: ${operationLogContext}, simulator: ${params.simulatorId})`, - ); - return { - content: [{ type: 'text', text: fullFailureMessage }], - }; - } + const ctx = getHandlerContext(); - log( - 'info', - `${successMessage} (operation: ${operationLogContext}, simulator: ${params.simulatorId})`, - ); - return { - content: [{ type: 'text', text: successMessage }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - const fullFailureMessage = `${failureMessagePrefix}: ${errorMessage}`; - log( - 'error', - `Error during ${operationLogContext} for simulator ${params.simulatorId}: ${errorMessage}`, - ); - return { - content: [{ type: 'text', text: fullFailureMessage }], - }; - } -} + return withErrorHandling( + ctx, + async () => { + const command = ['xcrun', 'simctl', 'ui', params.simulatorId, 'appearance', params.mode]; + const result = await executor(command, 'Set Simulator Appearance', false); -export async function set_sim_appearanceLogic( - params: SetSimAppearanceParams, - executor: CommandExecutor, -): Promise { - log('info', `Setting simulator ${params.simulatorId} appearance to ${params.mode} mode`); + if (!result.success) { + log( + 'error', + `Failed to set simulator appearance: ${result.error} (simulator: ${params.simulatorId})`, + ); + ctx.emit(headerEvent); + ctx.emit(statusLine('error', `Failed to set simulator appearance: ${result.error}`)); + return; + } - return executeSimctlCommandAndRespond( - params, - ['ui', params.simulatorId, 'appearance', params.mode], - 'Set Simulator Appearance', - `Successfully set simulator ${params.simulatorId} appearance to ${params.mode} mode`, - 'Failed to set simulator appearance', - 'set simulator appearance', - undefined, - executor, + log('info', `Set simulator ${params.simulatorId} appearance to ${params.mode} mode`); + ctx.emit(headerEvent); + ctx.emit(statusLine('success', `Appearance successfully set to ${params.mode} mode`)); + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Failed to set simulator appearance: ${message}`, + logMessage: ({ message }) => + `Error during set simulator appearance for simulator ${params.simulatorId}: ${message}`, + }, ); } diff --git a/src/mcp/tools/simulator-management/set_sim_location.ts b/src/mcp/tools/simulator-management/set_sim_location.ts index f2fc0a99..3d50d4e2 100644 --- a/src/mcp/tools/simulator-management/set_sim_location.ts +++ b/src/mcp/tools/simulator-management/set_sim_location.ts @@ -1,118 +1,74 @@ import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const setSimulatorLocationSchema = z.object({ simulatorId: z.uuid().describe('UUID of the simulator to use (obtained from list_simulators)'), latitude: z.number(), longitude: z.number(), }); -// Use z.infer for type safety type SetSimulatorLocationParams = z.infer; -// Helper function to execute simctl commands and handle responses -async function executeSimctlCommandAndRespond( +export async function set_sim_locationLogic( params: SetSimulatorLocationParams, - simctlSubCommand: string[], - operationDescriptionForXcodeCommand: string, - successMessage: string, - failureMessagePrefix: string, - operationLogContext: string, - executor: CommandExecutor = getDefaultCommandExecutor(), - extraValidation?: () => ToolResponse | null, -): Promise { - if (extraValidation) { - const validationResult = extraValidation(); - if (validationResult) { - return validationResult; - } - } - - try { - const command = ['xcrun', 'simctl', ...simctlSubCommand]; - const result = await executor(command, operationDescriptionForXcodeCommand, false, {}); + executor: CommandExecutor, +): Promise { + const coords = `${params.latitude},${params.longitude}`; + const headerEvent = header('Set Location', [ + { label: 'Simulator', value: params.simulatorId }, + { label: 'Coordinates', value: coords }, + ]); - if (!result.success) { - const fullFailureMessage = `${failureMessagePrefix}: ${result.error}`; - log( - 'error', - `${fullFailureMessage} (operation: ${operationLogContext}, simulator: ${params.simulatorId})`, - ); - return { - content: [{ type: 'text', text: fullFailureMessage }], - }; - } + const ctx = getHandlerContext(); - log( - 'info', - `${successMessage} (operation: ${operationLogContext}, simulator: ${params.simulatorId})`, - ); - return { - content: [{ type: 'text', text: successMessage }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - const fullFailureMessage = `${failureMessagePrefix}: ${errorMessage}`; - log( - 'error', - `Error during ${operationLogContext} for simulator ${params.simulatorId}: ${errorMessage}`, - ); - return { - content: [{ type: 'text', text: fullFailureMessage }], - }; + if (params.latitude < -90 || params.latitude > 90) { + ctx.emit(headerEvent); + ctx.emit(statusLine('error', 'Latitude must be between -90 and 90 degrees')); + return; + } + if (params.longitude < -180 || params.longitude > 180) { + ctx.emit(headerEvent); + ctx.emit(statusLine('error', 'Longitude must be between -180 and 180 degrees')); + return; } -} -export async function set_sim_locationLogic( - params: SetSimulatorLocationParams, - executor: CommandExecutor, -): Promise { - const extraValidation = (): ToolResponse | null => { - if (params.latitude < -90 || params.latitude > 90) { - return { - content: [ - { - type: 'text', - text: 'Latitude must be between -90 and 90 degrees', - }, - ], - }; - } - if (params.longitude < -180 || params.longitude > 180) { - return { - content: [ - { - type: 'text', - text: 'Longitude must be between -180 and 180 degrees', - }, - ], - }; - } - return null; - }; + log('info', `Setting simulator ${params.simulatorId} location to ${coords}`); - log( - 'info', - `Setting simulator ${params.simulatorId} location to ${params.latitude},${params.longitude}`, - ); + return withErrorHandling( + ctx, + async () => { + const command = ['xcrun', 'simctl', 'location', params.simulatorId, 'set', coords]; + const result = await executor(command, 'Set Simulator Location', false); + + if (!result.success) { + log( + 'error', + `Failed to set simulator location: ${result.error} (simulator: ${params.simulatorId})`, + ); + ctx.emit(headerEvent); + ctx.emit(statusLine('error', `Failed to set simulator location: ${result.error}`)); + return; + } - return executeSimctlCommandAndRespond( - params, - ['location', params.simulatorId, 'set', `${params.latitude},${params.longitude}`], - 'Set Simulator Location', - `Successfully set simulator ${params.simulatorId} location to ${params.latitude},${params.longitude}`, - 'Failed to set simulator location', - 'set simulator location', - executor, - extraValidation, + log('info', `Set simulator ${params.simulatorId} location to ${coords}`); + ctx.emit(headerEvent); + ctx.emit(statusLine('success', 'Location set successfully')); + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Failed to set simulator location: ${message}`, + logMessage: ({ message }) => + `Error during set simulator location for simulator ${params.simulatorId}: ${message}`, + }, ); } diff --git a/src/mcp/tools/simulator-management/sim_statusbar.ts b/src/mcp/tools/simulator-management/sim_statusbar.ts index 6ee5390f..08aa2c7f 100644 --- a/src/mcp/tools/simulator-management/sim_statusbar.ts +++ b/src/mcp/tools/simulator-management/sim_statusbar.ts @@ -1,14 +1,15 @@ import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const simStatusbarSchema = z.object({ simulatorId: z.uuid().describe('UUID of the simulator to use (obtained from list_simulators)'), dataNetwork: z @@ -29,62 +30,71 @@ const simStatusbarSchema = z.object({ .describe('clear|hide|wifi|3g|4g|lte|lte-a|lte+|5g|5g+|5g-uwb|5g-uc'), }); -// Use z.infer for type safety type SimStatusbarParams = z.infer; export async function sim_statusbarLogic( params: SimStatusbarParams, executor: CommandExecutor, -): Promise { +): Promise { log( 'info', `Setting simulator ${params.simulatorId} status bar data network to ${params.dataNetwork}`, ); - try { - let command: string[]; - let successMessage: string; + const headerEvent = header('Statusbar', [ + { label: 'Simulator', value: params.simulatorId }, + { label: 'Data Network', value: params.dataNetwork }, + ]); - if (params.dataNetwork === 'clear') { - command = ['xcrun', 'simctl', 'status_bar', params.simulatorId, 'clear']; - successMessage = `Successfully cleared status bar overrides for simulator ${params.simulatorId}`; - } else { - command = [ - 'xcrun', - 'simctl', - 'status_bar', - params.simulatorId, - 'override', - '--dataNetwork', - params.dataNetwork, - ]; - successMessage = `Successfully set simulator ${params.simulatorId} status bar data network to ${params.dataNetwork}`; - } + const ctx = getHandlerContext(); - const result = await executor(command, 'Set Status Bar', false, undefined); + return withErrorHandling( + ctx, + async () => { + let command: string[]; - if (!result.success) { - const failureMessage = `Failed to set status bar: ${result.error}`; - log('error', `${failureMessage} (simulator: ${params.simulatorId})`); - return { - content: [{ type: 'text', text: failureMessage }], - isError: true, - }; - } + if (params.dataNetwork === 'clear') { + command = ['xcrun', 'simctl', 'status_bar', params.simulatorId, 'clear']; + } else { + command = [ + 'xcrun', + 'simctl', + 'status_bar', + params.simulatorId, + 'override', + '--dataNetwork', + params.dataNetwork, + ]; + } - log('info', `${successMessage} (simulator: ${params.simulatorId})`); - return { - content: [{ type: 'text', text: successMessage }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - const failureMessage = `Failed to set status bar: ${errorMessage}`; - log('error', `Error setting status bar for simulator ${params.simulatorId}: ${errorMessage}`); - return { - content: [{ type: 'text', text: failureMessage }], - isError: true, - }; - } + const result = await executor(command, 'Set Status Bar', false); + + if (!result.success) { + log( + 'error', + `Failed to set status bar: ${result.error} (simulator: ${params.simulatorId})`, + ); + ctx.emit(headerEvent); + ctx.emit(statusLine('error', `Failed to set status bar: ${result.error}`)); + return; + } + + const successMsg = + params.dataNetwork === 'clear' + ? 'Status bar overrides cleared' + : 'Status bar data network set successfully'; + + log('info', `${successMsg} (simulator: ${params.simulatorId})`); + ctx.emit(headerEvent); + ctx.emit(statusLine('success', successMsg)); + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Failed to set status bar: ${message}`, + logMessage: ({ message }) => + `Error setting status bar for simulator ${params.simulatorId}: ${message}`, + }, + ); } const publicSchemaObject = z.strictObject( diff --git a/src/mcp/tools/simulator/__tests__/boot_sim.test.ts b/src/mcp/tools/simulator/__tests__/boot_sim.test.ts index 1ad047db..914c7e35 100644 --- a/src/mcp/tools/simulator/__tests__/boot_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/boot_sim.test.ts @@ -1,8 +1,3 @@ -/** - * Tests for boot_sim plugin (session-aware version) - * Follows CLAUDE.md guidance: dependency injection, no vi-mocks, literal validation. - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { @@ -11,6 +6,40 @@ import { } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, boot_simLogic } from '../boot_sim.ts'; +import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; + +const runLogic = async (logic: () => Promise) => { + const { result, run } = createMockToolHandlerContext(); + const response = await run(logic); + + if ( + response && + typeof response === 'object' && + 'content' in (response as Record) + ) { + return response as { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + isError?: boolean; + nextStepParams?: unknown; + }; + } + + const text = result.text(); + const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; + const imageContent = result.attachments.map((attachment) => ({ + type: 'image' as const, + data: attachment.data, + mimeType: attachment.mimeType, + })); + + return { + content: [...textContent, ...imageContent], + isError: result.isError() ? true : undefined, + nextStepParams: result.nextStepParams, + attachments: result.attachments, + text, + }; +}; describe('boot_sim tool', () => { beforeEach(() => { @@ -48,20 +77,18 @@ describe('boot_sim tool', () => { output: 'Simulator booted successfully', }); - const result = await boot_simLogic({ simulatorId: 'test-uuid-123' }, mockExecutor); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Simulator booted successfully.', - }, - ], - nextStepParams: { - open_sim: {}, - install_app_sim: { simulatorId: 'test-uuid-123', appPath: 'PATH_TO_YOUR_APP' }, - launch_app_sim: { simulatorId: 'test-uuid-123', bundleId: 'YOUR_APP_BUNDLE_ID' }, - }, + const result = await runLogic(() => + boot_simLogic({ simulatorId: 'test-uuid-123' }, mockExecutor), + ); + + const text = allText(result); + expect(text).toContain('Boot Simulator'); + expect(text).toContain('Simulator booted successfully'); + expect(result.isError).toBeFalsy(); + expect(result.nextStepParams).toEqual({ + open_sim: {}, + install_app_sim: { simulatorId: 'test-uuid-123', appPath: 'PATH_TO_YOUR_APP' }, + launch_app_sim: { simulatorId: 'test-uuid-123', bundleId: 'YOUR_APP_BUNDLE_ID' }, }); }); @@ -71,16 +98,13 @@ describe('boot_sim tool', () => { error: 'Simulator not found', }); - const result = await boot_simLogic({ simulatorId: 'invalid-uuid' }, mockExecutor); + const result = await runLogic(() => + boot_simLogic({ simulatorId: 'invalid-uuid' }, mockExecutor), + ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Boot simulator operation failed: Simulator not found', - }, - ], - }); + const text = allText(result); + expect(text).toContain('Boot simulator operation failed: Simulator not found'); + expect(result.isError).toBe(true); }); it('should handle exception with Error object', async () => { @@ -88,16 +112,13 @@ describe('boot_sim tool', () => { throw new Error('Connection failed'); }; - const result = await boot_simLogic({ simulatorId: 'test-uuid-123' }, mockExecutor); + const result = await runLogic(() => + boot_simLogic({ simulatorId: 'test-uuid-123' }, mockExecutor), + ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Boot simulator operation failed: Connection failed', - }, - ], - }); + const text = allText(result); + expect(text).toContain('Boot simulator operation failed: Connection failed'); + expect(result.isError).toBe(true); }); it('should handle exception with string error', async () => { @@ -105,16 +126,13 @@ describe('boot_sim tool', () => { throw 'String error'; }; - const result = await boot_simLogic({ simulatorId: 'test-uuid-123' }, mockExecutor); + const result = await runLogic(() => + boot_simLogic({ simulatorId: 'test-uuid-123' }, mockExecutor), + ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Boot simulator operation failed: String error', - }, - ], - }); + const text = allText(result); + expect(text).toContain('Boot simulator operation failed: String error'); + expect(result.isError).toBe(true); }); it('should verify command generation with mock executor', async () => { @@ -140,7 +158,7 @@ describe('boot_sim tool', () => { }); }; - await boot_simLogic({ simulatorId: 'test-uuid-123' }, mockExecutor); + await runLogic(() => boot_simLogic({ simulatorId: 'test-uuid-123' }, mockExecutor)); expect(calls).toHaveLength(1); expect(calls[0]).toEqual({ diff --git a/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts b/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts index 03238365..bee2eeb6 100644 --- a/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts @@ -1,17 +1,37 @@ -/** - * Tests for build_run_sim plugin (unified) - * Following CLAUDE.md testing standards with dependency injection and literal validation - */ - import { describe, it, expect, beforeEach } from 'vitest'; +import { DERIVED_DATA_DIR } from '../../../../utils/log-paths.ts'; import * as z from 'zod'; import { createMockExecutor, createMockCommandResponse, + mockProcess, } from '../../../../test-utils/mock-executors.ts'; +import { runToolLogic, type MockToolHandlerResult } from '../../../../test-utils/test-helpers.ts'; import type { CommandExecutor } from '../../../../utils/execution/index.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import { schema, handler, build_run_simLogic } from '../build_run_sim.ts'; +import { schema, handler, build_run_simLogic, type SimulatorLauncher } from '../build_run_sim.ts'; +import type { LaunchWithLoggingResult } from '../../../../utils/simulator-steps.ts'; + +const mockLauncher: SimulatorLauncher = async ( + _uuid, + _bundleId, + _opts?, +): Promise => ({ + success: true, + processId: 99999, + logFilePath: '/tmp/mock-logs/test.log', +}); + +const runBuildRunSimLogic = ( + params: Parameters[0], + executor: Parameters[1], + launcher?: Parameters[2], +) => runToolLogic(() => build_run_simLogic(params, executor, launcher)); + +function expectPendingBuildRunResponse(result: MockToolHandlerResult, isError: boolean): void { + expect(result.isError()).toBe(isError); + expect(result.events.some((event) => event.type === 'summary')).toBe(true); +} describe('build_run_sim tool', () => { beforeEach(() => { @@ -46,16 +66,51 @@ describe('build_run_sim tool', () => { }); }); - describe('Handler Behavior (Complete Literal Returns)', () => { - // Note: Parameter validation is now handled by createTypedTool wrapper with Zod schema - // The logic function receives validated parameters, so these tests focus on business logic + describe('Handler Behavior (Pending Pipeline Contract)', () => { + it('should fail fast for an invalid explicit simulator ID with structured sad-path output', async () => { + const callHistory: string[][] = []; + const mockExecutor: CommandExecutor = async (command) => { + callHistory.push(command); + + if (command[0] === 'xcrun' && command[1] === 'simctl') { + return createMockCommandResponse({ + success: true, + output: JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ + { udid: 'SOME-OTHER-UUID', name: 'iPhone 17', isAvailable: true }, + ], + }, + }), + }); + } + + return createMockCommandResponse({ + success: false, + error: 'xcodebuild should not run', + }); + }; + + const { result } = await runBuildRunSimLogic( + { + workspacePath: '/path/to/workspace', + scheme: 'MyScheme', + simulatorId: 'INVALID-SIM-ID-123', + }, + mockExecutor, + ); - it('should handle simulator not found', async () => { + expectPendingBuildRunResponse(result, true); + expect( + callHistory.some((command) => command[0] === 'xcodebuild' && command.includes('build')), + ).toBe(false); + }); + + it('should handle build settings failure as pending error', async () => { let callCount = 0; const mockExecutor: CommandExecutor = async (command) => { callCount++; if (callCount === 1) { - // First call: runtime lookup succeeds return createMockCommandResponse({ success: true, output: JSON.stringify({ @@ -67,13 +122,11 @@ describe('build_run_sim tool', () => { }), }); } else if (callCount === 2) { - // Second call: build succeeds return createMockCommandResponse({ success: true, output: 'BUILD SUCCEEDED', }); } else if (callCount === 3) { - // Third call: showBuildSettings fails to get app path return createMockCommandResponse({ success: false, error: 'Could not get build settings', @@ -85,7 +138,7 @@ describe('build_run_sim tool', () => { }); }; - const result = await build_run_simLogic( + const { result } = await runBuildRunSimLogic( { workspacePath: '/path/to/workspace', scheme: 'MyScheme', @@ -94,24 +147,17 @@ describe('build_run_sim tool', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Build succeeded, but failed to get app path: Could not get build settings', - }, - ], - isError: true, - }); + expectPendingBuildRunResponse(result, true); + expect(result.nextStepParams).toBeUndefined(); }); - it('should handle build failure', async () => { + it('should handle build failure as pending error', async () => { const mockExecutor = createMockExecutor({ success: false, error: 'Build failed with error', }); - const result = await build_run_simLogic( + const { result } = await runBuildRunSimLogic( { workspacePath: '/path/to/workspace', scheme: 'MyScheme', @@ -120,31 +166,23 @@ describe('build_run_sim tool', () => { mockExecutor, ); - expect(result.isError).toBe(true); - expect(result.content).toBeDefined(); - expect(Array.isArray(result.content)).toBe(true); + expectPendingBuildRunResponse(result, true); + expect(result.nextStepParams).toBeUndefined(); }); it('should handle successful build and run', async () => { - // Create a mock executor that simulates full successful flow - let callCount = 0; const mockExecutor: CommandExecutor = async (command) => { - callCount++; - if (command.includes('xcodebuild') && command.includes('build')) { - // First call: build succeeds return createMockCommandResponse({ success: true, output: 'BUILD SUCCEEDED', }); } else if (command.includes('xcodebuild') && command.includes('-showBuildSettings')) { - // Second call: build settings to get app path return createMockCommandResponse({ success: true, output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app\n', }); } else if (command.includes('simctl') && command.includes('list')) { - // Find simulator calls return createMockCommandResponse({ success: true, output: JSON.stringify({ @@ -156,22 +194,22 @@ describe('build_run_sim tool', () => { state: 'Booted', isAvailable: true, }, + '-derivedDataPath', + DERIVED_DATA_DIR, ], }, }), }); } else if ( - command.includes('plutil') || - command.includes('PlistBuddy') || - command.includes('defaults') + command.some( + (c) => c.includes('plutil') || c.includes('PlistBuddy') || c.includes('defaults'), + ) ) { - // Bundle ID extraction return createMockCommandResponse({ success: true, output: 'io.sentry.MyApp', }); } else { - // All other commands (boot, open, install, launch) succeed return createMockCommandResponse({ success: true, output: 'Success', @@ -179,27 +217,86 @@ describe('build_run_sim tool', () => { } }; - const result = await build_run_simLogic( + const { result } = await runBuildRunSimLogic( { workspacePath: '/path/to/workspace', scheme: 'MyScheme', simulatorName: 'iPhone 17', }, mockExecutor, + mockLauncher, ); - expect(result.content).toBeDefined(); - expect(Array.isArray(result.content)).toBe(true); - expect(result.isError).toBe(false); + expectPendingBuildRunResponse(result, false); + expect(result.events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'status-line', + level: 'success', + message: 'Build & Run complete', + }), + expect.objectContaining({ + type: 'detail-tree', + items: expect.arrayContaining([ + expect.objectContaining({ label: 'App Path', value: '/path/to/build/MyApp.app' }), + expect.objectContaining({ label: 'Bundle ID', value: 'io.sentry.MyApp' }), + expect.objectContaining({ + label: 'Build Logs', + value: expect.stringContaining('build_run_sim_'), + }), + ]), + }), + ]), + ); }); - it('should handle exception with Error object', async () => { - const mockExecutor = createMockExecutor({ - success: false, - error: 'Command failed', - }); + it('should handle install failure as pending error', async () => { + let callCount = 0; + const mockExecutor: CommandExecutor = async (command) => { + callCount++; + + if (command.includes('xcodebuild') && command.includes('build')) { + return createMockCommandResponse({ + success: true, + output: 'BUILD SUCCEEDED', + }); + } else if (command.includes('xcodebuild') && command.includes('-showBuildSettings')) { + return createMockCommandResponse({ + success: true, + output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app\n', + }); + } else if (command.includes('simctl') && command.includes('list')) { + return createMockCommandResponse({ + success: true, + output: JSON.stringify({ + devices: { + 'iOS 16.0': [ + { + udid: 'test-uuid-123', + name: 'iPhone 17', + state: 'Booted', + isAvailable: true, + }, + '-derivedDataPath', + DERIVED_DATA_DIR, + ], + }, + }), + }); + } else if (command.includes('simctl') && command.includes('install')) { + return createMockCommandResponse({ + success: false, + error: 'Failed to install', + }); + } else { + return createMockCommandResponse({ + success: true, + output: 'Success', + }); + } + }; - const result = await build_run_simLogic( + const { result } = await runBuildRunSimLogic( { workspacePath: '/path/to/workspace', scheme: 'MyScheme', @@ -208,18 +305,27 @@ describe('build_run_sim tool', () => { mockExecutor, ); - expect(result.isError).toBe(true); - expect(result.content).toBeDefined(); - expect(Array.isArray(result.content)).toBe(true); + expectPendingBuildRunResponse(result, true); + expect(result.nextStepParams).toBeUndefined(); }); - it('should handle exception with string error', async () => { - const mockExecutor = createMockExecutor({ - success: false, - error: 'String error', - }); + it('should handle spawn error as text fallback', async () => { + const mockExecutor = ( + command: string[], + description?: string, + logOutput?: boolean, + opts?: { cwd?: string }, + detached?: boolean, + ) => { + void command; + void description; + void logOutput; + void opts; + void detached; + return Promise.reject(new Error('spawn xcodebuild ENOENT')); + }; - const result = await build_run_simLogic( + const { response, result } = await runBuildRunSimLogic( { workspacePath: '/path/to/workspace', scheme: 'MyScheme', @@ -228,9 +334,10 @@ describe('build_run_sim tool', () => { mockExecutor, ); - expect(result.isError).toBe(true); - expect(result.content).toBeDefined(); - expect(Array.isArray(result.content)).toBe(true); + expect(response).toBeUndefined(); + expect(result.isError()).toBe(true); + const text = result.text(); + expect(text).toContain('Error during simulator build and run'); }); }); @@ -251,7 +358,7 @@ describe('build_run_sim tool', () => { it('should generate correct simctl list command with minimal parameters', async () => { const callHistory: Array<{ command: string[]; logPrefix?: string }> = []; - await build_run_simLogic( + await runBuildRunSimLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', @@ -273,6 +380,8 @@ describe('build_run_sim tool', () => { '-skipMacroValidation', '-destination', 'platform=iOS Simulator,name=iPhone 17,OS=latest', + '-derivedDataPath', + DERIVED_DATA_DIR, 'build', ]); expect(callHistory[1].logPrefix).toBe('iOS Simulator Build'); @@ -306,7 +415,7 @@ describe('build_run_sim tool', () => { }); }; - await build_run_simLogic( + await runBuildRunSimLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', @@ -328,6 +437,8 @@ describe('build_run_sim tool', () => { '-skipMacroValidation', '-destination', 'platform=iOS Simulator,name=iPhone 17,OS=latest', + '-derivedDataPath', + DERIVED_DATA_DIR, 'build', ]); expect(callHistory[1].logPrefix).toBe('iOS Simulator Build'); @@ -367,7 +478,7 @@ describe('build_run_sim tool', () => { }); }; - await build_run_simLogic( + await runBuildRunSimLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', @@ -391,6 +502,8 @@ describe('build_run_sim tool', () => { '-skipMacroValidation', '-destination', 'platform=iOS Simulator,name=iPhone 17', + '-derivedDataPath', + DERIVED_DATA_DIR, 'build', ]); expect(callHistory[1].logPrefix).toBe('iOS Simulator Build'); @@ -405,6 +518,8 @@ describe('build_run_sim tool', () => { 'Release', '-destination', 'platform=iOS Simulator,name=iPhone 17', + '-derivedDataPath', + DERIVED_DATA_DIR, ]); expect(callHistory[2].logPrefix).toBe('Get App Path'); }); @@ -412,7 +527,7 @@ describe('build_run_sim tool', () => { it('should handle paths with spaces in command generation', async () => { const callHistory: Array<{ command: string[]; logPrefix?: string }> = []; - await build_run_simLogic( + await runBuildRunSimLogic( { workspacePath: '/Users/dev/My Project/MyProject.xcworkspace', scheme: 'My Scheme', @@ -434,6 +549,8 @@ describe('build_run_sim tool', () => { '-skipMacroValidation', '-destination', 'platform=iOS Simulator,name=iPhone 17 Pro,OS=latest', + '-derivedDataPath', + DERIVED_DATA_DIR, 'build', ]); expect(callHistory[1].logPrefix).toBe('iOS Simulator Build'); @@ -442,7 +559,7 @@ describe('build_run_sim tool', () => { it('should infer tvOS platform from simulator name for build command', async () => { const callHistory: Array<{ command: string[]; logPrefix?: string }> = []; - await build_run_simLogic( + await runBuildRunSimLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyTVScheme', @@ -464,6 +581,8 @@ describe('build_run_sim tool', () => { '-skipMacroValidation', '-destination', 'platform=tvOS Simulator,name=Apple TV 4K,OS=latest', + '-derivedDataPath', + DERIVED_DATA_DIR, 'build', ]); expect(callHistory[1].logPrefix).toBe('tvOS Simulator Build'); @@ -496,13 +615,12 @@ describe('build_run_sim tool', () => { }); it('should succeed with only projectPath', async () => { - // This test fails early due to build failure, which is expected behavior const mockExecutor = createMockExecutor({ success: false, error: 'Build failed', }); - const result = await build_run_simLogic( + const { result } = await runBuildRunSimLogic( { projectPath: '/path/project.xcodeproj', scheme: 'MyScheme', @@ -510,19 +628,16 @@ describe('build_run_sim tool', () => { }, mockExecutor, ); - // The test succeeds if the logic function accepts the parameters and attempts to build - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Build failed'); + expectPendingBuildRunResponse(result, true); }); it('should succeed with only workspacePath', async () => { - // This test fails early due to build failure, which is expected behavior const mockExecutor = createMockExecutor({ success: false, error: 'Build failed', }); - const result = await build_run_simLogic( + const { result } = await runBuildRunSimLogic( { workspacePath: '/path/workspace.xcworkspace', scheme: 'MyScheme', @@ -530,9 +645,7 @@ describe('build_run_sim tool', () => { }, mockExecutor, ); - // The test succeeds if the logic function accepts the parameters and attempts to build - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Build failed'); + expectPendingBuildRunResponse(result, true); }); }); }); diff --git a/src/mcp/tools/simulator/__tests__/build_sim.test.ts b/src/mcp/tools/simulator/__tests__/build_sim.test.ts index 5655cdd5..62a03d26 100644 --- a/src/mcp/tools/simulator/__tests__/build_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/build_sim.test.ts @@ -1,15 +1,20 @@ import { describe, it, expect, beforeEach } from 'vitest'; +import { DERIVED_DATA_DIR } from '../../../../utils/log-paths.ts'; import * as z from 'zod'; import { createMockExecutor, createMockCommandResponse, } from '../../../../test-utils/mock-executors.ts'; -import type { CommandExecutor } from '../../../../utils/execution/index.ts'; +import { expectPendingBuildResponse, runToolLogic } from '../../../../test-utils/test-helpers.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -// Import the named exports and logic function import { schema, handler, build_simLogic } from '../build_sim.ts'; +const runBuildSimLogic = ( + params: Parameters[0], + executor: Parameters[1], +) => runToolLogic(() => build_simLogic(params, executor)); + describe('build_sim tool', () => { beforeEach(() => { sessionStore.clear(); @@ -23,17 +28,14 @@ describe('build_sim tool', () => { it('should have correct public schema (only non-session fields)', () => { const schemaObj = z.strictObject(schema); - // Public schema should allow empty input expect(schemaObj.safeParse({}).success).toBe(true); - // Valid public inputs expect( schemaObj.safeParse({ extraArgs: ['--verbose'], }).success, ).toBe(true); - // Invalid types or unknown fields on public inputs expect(schemaObj.safeParse({ derivedDataPath: '/path/to/derived' }).success).toBe(false); expect(schemaObj.safeParse({ extraArgs: [123] }).success).toBe(false); expect(schemaObj.safeParse({ preferXcodebuild: false }).success).toBe(false); @@ -70,7 +72,7 @@ describe('build_sim tool', () => { it('should handle empty workspacePath parameter', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); - const result = await build_simLogic( + const { result } = await runBuildSimLogic( { workspacePath: '', scheme: 'MyScheme', @@ -79,17 +81,7 @@ describe('build_sim tool', () => { mockExecutor, ); - // Empty string passes validation but may cause build issues - expect(result.content).toEqual([ - { - type: 'text', - text: '✅ iOS Simulator Build build succeeded for scheme MyScheme.', - }, - { - type: 'text', - text: expect.stringContaining('Next Steps:'), - }, - ]); + expectPendingBuildResponse(result, 'get_sim_app_path'); }); it('should handle missing scheme parameter', async () => { @@ -106,7 +98,7 @@ describe('build_sim tool', () => { it('should handle empty scheme parameter', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); - const result = await build_simLogic( + const { result } = await runBuildSimLogic( { workspacePath: '/path/to/workspace', scheme: '', @@ -115,17 +107,7 @@ describe('build_sim tool', () => { mockExecutor, ); - // Empty string passes validation but may cause build issues - expect(result.content).toEqual([ - { - type: 'text', - text: '✅ iOS Simulator Build build succeeded for scheme .', - }, - { - type: 'text', - text: expect.stringContaining('Next Steps:'), - }, - ]); + expectPendingBuildResponse(result, 'get_sim_app_path'); }); it('should handle missing both simulatorId and simulatorName', async () => { @@ -142,7 +124,6 @@ describe('build_sim tool', () => { it('should handle both simulatorId and simulatorName provided', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'Build succeeded' }); - // Should fail with XOR validation const result = await handler({ workspacePath: '/path/to/workspace', scheme: 'MyScheme', @@ -164,7 +145,7 @@ describe('build_sim tool', () => { error: 'For iOS Simulator platform, either simulatorId or simulatorName must be provided', }); - const result = await build_simLogic( + const { result } = await runBuildSimLogic( { workspacePath: '/path/to/workspace', scheme: 'MyScheme', @@ -173,11 +154,8 @@ describe('build_sim tool', () => { mockExecutor, ); - // Empty simulatorName passes validation but causes early failure in destination construction - expect(result.isError).toBe(true); - expect(result.content[0].text).toBe( - 'For iOS Simulator platform, either simulatorId or simulatorName must be provided', - ); + expect(result.isError()).toBe(true); + expectPendingBuildResponse(result); }); }); @@ -209,7 +187,7 @@ describe('build_sim tool', () => { it('should generate correct build command with minimal parameters (workspace)', async () => { const callHistory: Array<{ command: string[]; logPrefix?: string }> = []; - await build_simLogic( + await runBuildSimLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', @@ -231,6 +209,8 @@ describe('build_sim tool', () => { '-skipMacroValidation', '-destination', 'platform=iOS Simulator,name=iPhone 17,OS=latest', + '-derivedDataPath', + DERIVED_DATA_DIR, 'build', ], 'iOS Simulator Build', @@ -240,7 +220,7 @@ describe('build_sim tool', () => { it('should generate correct build command with minimal parameters (project)', async () => { const callHistory: Array<{ command: string[]; logPrefix?: string }> = []; - await build_simLogic( + await runBuildSimLogic( { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme', @@ -262,6 +242,8 @@ describe('build_sim tool', () => { '-skipMacroValidation', '-destination', 'platform=iOS Simulator,name=iPhone 17,OS=latest', + '-derivedDataPath', + DERIVED_DATA_DIR, 'build', ], 'iOS Simulator Build', @@ -271,7 +253,7 @@ describe('build_sim tool', () => { it('should generate correct build command with all optional parameters', async () => { const callHistory: Array<{ command: string[]; logPrefix?: string }> = []; - await build_simLogic( + await runBuildSimLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', @@ -309,7 +291,7 @@ describe('build_sim tool', () => { it('should handle paths with spaces in command generation', async () => { const callHistory: Array<{ command: string[]; logPrefix?: string }> = []; - await build_simLogic( + await runBuildSimLogic( { workspacePath: '/Users/dev/My Project/MyProject.xcworkspace', scheme: 'My Scheme', @@ -331,6 +313,8 @@ describe('build_sim tool', () => { '-skipMacroValidation', '-destination', 'platform=iOS Simulator,name=iPhone 17 Pro,OS=latest', + '-derivedDataPath', + DERIVED_DATA_DIR, 'build', ], 'iOS Simulator Build', @@ -340,7 +324,7 @@ describe('build_sim tool', () => { it('should generate correct build command with useLatestOS set to true', async () => { const callHistory: Array<{ command: string[]; logPrefix?: string }> = []; - await build_simLogic( + await runBuildSimLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', @@ -363,6 +347,8 @@ describe('build_sim tool', () => { '-skipMacroValidation', '-destination', 'platform=iOS Simulator,name=iPhone 17,OS=latest', + '-derivedDataPath', + DERIVED_DATA_DIR, 'build', ], 'iOS Simulator Build', @@ -372,7 +358,7 @@ describe('build_sim tool', () => { it('should infer watchOS platform from simulator name', async () => { const callHistory: Array<{ command: string[]; logPrefix?: string }> = []; - await build_simLogic( + await runBuildSimLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyWatchScheme', @@ -394,6 +380,8 @@ describe('build_sim tool', () => { '-skipMacroValidation', '-destination', 'platform=watchOS Simulator,name=Apple Watch Ultra 2,OS=latest', + '-derivedDataPath', + DERIVED_DATA_DIR, 'build', ], 'watchOS Simulator Build', @@ -405,7 +393,7 @@ describe('build_sim tool', () => { it('should handle successful build', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); - const result = await build_simLogic( + const { result } = await runBuildSimLogic( { workspacePath: '/path/to/workspace', scheme: 'MyScheme', @@ -414,22 +402,14 @@ describe('build_sim tool', () => { mockExecutor, ); - expect(result.content).toEqual([ - { - type: 'text', - text: '✅ iOS Simulator Build build succeeded for scheme MyScheme.', - }, - { - type: 'text', - text: expect.stringContaining('Next Steps:'), - }, - ]); + expect(result.isError()).toBeFalsy(); + expectPendingBuildResponse(result, 'get_sim_app_path'); }); it('should handle successful build with all optional parameters', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); - const result = await build_simLogic( + const { result } = await runBuildSimLogic( { workspacePath: '/path/to/workspace', scheme: 'MyScheme', @@ -443,16 +423,8 @@ describe('build_sim tool', () => { mockExecutor, ); - expect(result.content).toEqual([ - { - type: 'text', - text: '✅ iOS Simulator Build build succeeded for scheme MyScheme.', - }, - { - type: 'text', - text: expect.stringContaining('Next Steps:'), - }, - ]); + expect(result.isError()).toBeFalsy(); + expectPendingBuildResponse(result, 'get_sim_app_path'); }); it('should handle build failure', async () => { @@ -462,7 +434,7 @@ describe('build_sim tool', () => { error: 'Build failed: Compilation error', }); - const result = await build_simLogic( + const { result } = await runBuildSimLogic( { workspacePath: '/path/to/workspace', scheme: 'MyScheme', @@ -471,19 +443,8 @@ describe('build_sim tool', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: '❌ [stderr] Build failed: Compilation error', - }, - { - type: 'text', - text: '❌ iOS Simulator Build build failed for scheme MyScheme.', - }, - ], - isError: true, - }); + expect(result.isError()).toBe(true); + expectPendingBuildResponse(result); }); it('should handle build warnings', async () => { @@ -492,7 +453,7 @@ describe('build_sim tool', () => { output: 'warning: deprecated method used\nBUILD SUCCEEDED', }); - const result = await build_simLogic( + const { result } = await runBuildSimLogic( { workspacePath: '/path/to/workspace', scheme: 'MyScheme', @@ -501,22 +462,8 @@ describe('build_sim tool', () => { mockExecutor, ); - expect(result.content).toEqual( - expect.arrayContaining([ - { - type: 'text', - text: expect.stringContaining('⚠️'), - }, - { - type: 'text', - text: '✅ iOS Simulator Build build succeeded for scheme MyScheme.', - }, - { - type: 'text', - text: expect.stringContaining('Next Steps:'), - }, - ]), - ); + expect(result.isError()).toBeFalsy(); + expectPendingBuildResponse(result, 'get_sim_app_path'); }); it('should handle command executor errors', async () => { @@ -525,7 +472,7 @@ describe('build_sim tool', () => { error: 'spawn xcodebuild ENOENT', }); - const result = await build_simLogic( + const { result } = await runBuildSimLogic( { workspacePath: '/path/to/workspace', scheme: 'MyScheme', @@ -534,8 +481,8 @@ describe('build_sim tool', () => { mockExecutor, ); - expect(result.isError).toBe(true); - expect(result.content[0].text).toBe('❌ [stderr] spawn xcodebuild ENOENT'); + expect(result.isError()).toBe(true); + expectPendingBuildResponse(result); }); it('should handle mixed warning and error output', async () => { @@ -545,7 +492,7 @@ describe('build_sim tool', () => { error: 'Build failed', }); - const result = await build_simLogic( + const { result } = await runBuildSimLogic( { workspacePath: '/path/to/workspace', scheme: 'MyScheme', @@ -554,60 +501,32 @@ describe('build_sim tool', () => { mockExecutor, ); - expect(result.isError).toBe(true); - expect(result.content).toEqual([ - { - type: 'text', - text: '⚠️ Warning: warning: deprecated method', - }, - { - type: 'text', - text: '❌ Error: error: undefined symbol', - }, - { - type: 'text', - text: '❌ [stderr] Build failed', - }, - { - type: 'text', - text: '❌ iOS Simulator Build build failed for scheme MyScheme.', - }, - ]); + expect(result.isError()).toBe(true); + expectPendingBuildResponse(result); }); it('should use default configuration when not provided', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); - const result = await build_simLogic( + const { result } = await runBuildSimLogic( { workspacePath: '/path/to/workspace', scheme: 'MyScheme', simulatorName: 'iPhone 17', - // configuration intentionally omitted - should default to Debug }, mockExecutor, ); - expect(result.content).toEqual([ - { - type: 'text', - text: '✅ iOS Simulator Build build succeeded for scheme MyScheme.', - }, - { - type: 'text', - text: expect.stringContaining('Next Steps:'), - }, - ]); + expect(result.isError()).toBeFalsy(); + expectPendingBuildResponse(result, 'get_sim_app_path'); }); }); describe('Error Handling', () => { it('should handle catch block exceptions', async () => { - // Create a mock that throws an error when called const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); - // Mock the handler to throw an error by passing invalid parameters to internal functions - const result = await build_simLogic( + const { result } = await runBuildSimLogic( { workspacePath: '/path/to/workspace', scheme: 'MyScheme', @@ -616,17 +535,8 @@ describe('build_sim tool', () => { mockExecutor, ); - // Should handle the build successfully - expect(result.content).toEqual([ - { - type: 'text', - text: '✅ iOS Simulator Build build succeeded for scheme MyScheme.', - }, - { - type: 'text', - text: expect.stringContaining('Next Steps:'), - }, - ]); + expect(result.isError()).toBeFalsy(); + expectPendingBuildResponse(result, 'get_sim_app_path'); }); }); }); diff --git a/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts b/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts index 95bf83df..829a49c0 100644 --- a/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts +++ b/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts @@ -1,9 +1,5 @@ -/** - * Tests for get_sim_app_path plugin (session-aware version) - * Mirrors patterns from other simulator session-aware migrations. - */ - import { describe, it, expect, beforeEach } from 'vitest'; +import { DERIVED_DATA_DIR } from '../../../../utils/log-paths.ts'; import { ChildProcess } from 'child_process'; import * as z from 'zod'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; @@ -11,6 +7,40 @@ import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, get_sim_app_pathLogic } from '../get_sim_app_path.ts'; import type { CommandExecutor } from '../../../../utils/CommandExecutor.ts'; import { XcodePlatform } from '../../../../types/common.ts'; +import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; + +const runLogic = async (logic: () => Promise) => { + const { result, run } = createMockToolHandlerContext(); + const response = await run(logic); + + if ( + response && + typeof response === 'object' && + 'content' in (response as Record) + ) { + return response as { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + isError?: boolean; + nextStepParams?: unknown; + }; + } + + const text = result.text(); + const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; + const imageContent = result.attachments.map((attachment) => ({ + type: 'image' as const, + data: attachment.data, + mimeType: attachment.mimeType, + })); + + return { + content: [...textContent, ...imageContent], + isError: result.isError() ? true : undefined, + nextStepParams: result.nextStepParams, + attachments: result.attachments, + text, + }; +}; describe('get_sim_app_path tool', () => { beforeEach(() => { @@ -131,15 +161,17 @@ describe('get_sim_app_path tool', () => { }; }; - const result = await get_sim_app_pathLogic( - { - workspacePath: '/path/to/workspace.xcworkspace', - scheme: 'MyScheme', - platform: XcodePlatform.iOSSimulator, - simulatorName: 'iPhone 17', - useLatestOS: true, - }, - trackingExecutor, + const result = await runLogic(() => + get_sim_app_pathLogic( + { + workspacePath: '/path/to/workspace.xcworkspace', + scheme: 'MyScheme', + platform: XcodePlatform.iOSSimulator, + simulatorName: 'iPhone 17', + useLatestOS: true, + }, + trackingExecutor, + ), ); expect(callHistory).toHaveLength(1); @@ -156,12 +188,20 @@ describe('get_sim_app_path tool', () => { 'Debug', '-destination', 'platform=iOS Simulator,name=iPhone 17,OS=latest', + '-derivedDataPath', + DERIVED_DATA_DIR, ]); - expect(result.isError).toBe(false); - expect(result.content[0].text).toContain( - '✅ App path retrieved successfully: /tmp/DerivedData/Build/MyApp.app', - ); + expect(result.isError).toBeFalsy(); + const text = allText(result); + expect(text).toContain('Get App Path'); + expect(text).toContain('MyScheme'); + expect(text).toContain('/path/to/workspace.xcworkspace'); + expect(text).toContain('Debug'); + expect(text).toContain('iOS Simulator'); + expect(text).toContain('iPhone 17'); + expect(text).toContain('/tmp/DerivedData/Build/MyApp.app'); + expect(result.nextStepParams).toBeDefined(); }); it('should surface executor failures when build settings cannot be retrieved', async () => { @@ -170,19 +210,26 @@ describe('get_sim_app_path tool', () => { error: 'Failed to run xcodebuild', }); - const result = await get_sim_app_pathLogic( - { - projectPath: '/path/to/project.xcodeproj', - scheme: 'MyScheme', - platform: XcodePlatform.iOSSimulator, - simulatorId: 'SIM-UUID', - }, - mockExecutor, + const result = await runLogic(() => + get_sim_app_pathLogic( + { + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyScheme', + platform: XcodePlatform.iOSSimulator, + simulatorId: 'SIM-UUID', + }, + mockExecutor, + ), ); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Failed to get app path'); - expect(result.content[0].text).toContain('Failed to run xcodebuild'); + const text = allText(result); + expect(text).toContain('Get App Path'); + expect(text).toContain('MyScheme'); + expect(text).toContain('Errors (1):'); + expect(text).toContain('✗ Failed to run xcodebuild'); + expect(text).toContain('Failed to get app path'); + expect(result.nextStepParams).toBeUndefined(); }); }); }); diff --git a/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts b/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts index b983d856..9671fd09 100644 --- a/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts @@ -9,6 +9,40 @@ import { import { sessionStore } from '../../../../utils/session-store.ts'; import type { CommandExecutor } from '../../../../utils/execution/index.ts'; import { schema, handler, install_app_simLogic } from '../install_app_sim.ts'; +import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; + +const runLogic = async (logic: () => Promise) => { + const { result, run } = createMockToolHandlerContext(); + const response = await run(logic); + + if ( + response && + typeof response === 'object' && + 'content' in (response as Record) + ) { + return response as { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + isError?: boolean; + nextStepParams?: unknown; + }; + } + + const text = result.text(); + const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; + const imageContent = result.attachments.map((attachment) => ({ + type: 'image' as const, + data: attachment.data, + mimeType: attachment.mimeType, + })); + + return { + content: [...textContent, ...imageContent], + isError: result.isError() ? true : undefined, + nextStepParams: result.nextStepParams, + attachments: result.attachments, + text, + }; +}; describe('install_app_sim tool', () => { beforeEach(() => { @@ -74,13 +108,15 @@ describe('install_app_sim tool', () => { existsSync: () => true, }); - await install_app_simLogic( - { - simulatorId: 'test-uuid-123', - appPath: '/path/to/app.app', - }, - mockExecutor, - mockFileSystem, + await runLogic(() => + install_app_simLogic( + { + simulatorId: 'test-uuid-123', + appPath: '/path/to/app.app', + }, + mockExecutor, + mockFileSystem, + ), ); expect(executorCalls).toEqual([ @@ -88,13 +124,11 @@ describe('install_app_sim tool', () => { ['xcrun', 'simctl', 'install', 'test-uuid-123', '/path/to/app.app'], 'Install App in Simulator', false, - undefined, ], [ ['defaults', 'read', '/path/to/app.app/Info', 'CFBundleIdentifier'], 'Extract Bundle ID', false, - undefined, ], ]); }); @@ -115,13 +149,15 @@ describe('install_app_sim tool', () => { existsSync: () => true, }); - await install_app_simLogic( - { - simulatorId: 'different-uuid-456', - appPath: '/different/path/MyApp.app', - }, - mockExecutor, - mockFileSystem, + await runLogic(() => + install_app_simLogic( + { + simulatorId: 'different-uuid-456', + appPath: '/different/path/MyApp.app', + }, + mockExecutor, + mockFileSystem, + ), ); expect(executorCalls).toEqual([ @@ -129,13 +165,11 @@ describe('install_app_sim tool', () => { ['xcrun', 'simctl', 'install', 'different-uuid-456', '/different/path/MyApp.app'], 'Install App in Simulator', false, - undefined, ], [ ['defaults', 'read', '/different/path/MyApp.app/Info', 'CFBundleIdentifier'], 'Extract Bundle ID', false, - undefined, ], ]); }); @@ -147,24 +181,20 @@ describe('install_app_sim tool', () => { existsSync: () => false, }); - const result = await install_app_simLogic( - { - simulatorId: 'test-uuid-123', - appPath: '/path/to/app.app', - }, - createNoopExecutor(), - mockFileSystem, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + install_app_simLogic( { - type: 'text', - text: "File not found: '/path/to/app.app'. Please check the path and try again.", + simulatorId: 'test-uuid-123', + appPath: '/path/to/app.app', }, - ], - isError: true, - }); + createNoopExecutor(), + mockFileSystem, + ), + ); + + expect(result.isError).toBe(true); + const text = allText(result); + expect(text).toContain("File not found: '/path/to/app.app'"); }); it('should handle bundle id extraction failure gracefully', async () => { @@ -197,26 +227,23 @@ describe('install_app_sim tool', () => { existsSync: () => true, }); - const result = await install_app_simLogic( - { - simulatorId: 'test-uuid-123', - appPath: '/path/to/app.app', - }, - mockExecutor, - mockFileSystem, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + install_app_simLogic( { - type: 'text', - text: 'App installed successfully in simulator test-uuid-123.', + simulatorId: 'test-uuid-123', + appPath: '/path/to/app.app', }, - ], - nextStepParams: { - open_sim: {}, - launch_app_sim: { simulatorId: 'test-uuid-123', bundleId: 'YOUR_APP_BUNDLE_ID' }, - }, + mockExecutor, + mockFileSystem, + ), + ); + + const text = allText(result); + expect(text).toContain('App installed successfully'); + expect(text).toContain('test-uuid-123'); + expect(result.nextStepParams).toEqual({ + open_sim: {}, + launch_app_sim: { simulatorId: 'test-uuid-123', bundleId: 'YOUR_APP_BUNDLE_ID' }, }); expect(bundleIdCalls).toHaveLength(2); }); @@ -251,26 +278,23 @@ describe('install_app_sim tool', () => { existsSync: () => true, }); - const result = await install_app_simLogic( - { - simulatorId: 'test-uuid-123', - appPath: '/path/to/app.app', - }, - mockExecutor, - mockFileSystem, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + install_app_simLogic( { - type: 'text', - text: 'App installed successfully in simulator test-uuid-123.', + simulatorId: 'test-uuid-123', + appPath: '/path/to/app.app', }, - ], - nextStepParams: { - open_sim: {}, - launch_app_sim: { simulatorId: 'test-uuid-123', bundleId: 'io.sentry.myapp' }, - }, + mockExecutor, + mockFileSystem, + ), + ); + + const text = allText(result); + expect(text).toContain('App installed successfully'); + expect(text).toContain('test-uuid-123'); + expect(result.nextStepParams).toEqual({ + open_sim: {}, + launch_app_sim: { simulatorId: 'test-uuid-123', bundleId: 'io.sentry.myapp' }, }); expect(bundleIdCalls).toHaveLength(2); }); @@ -289,23 +313,21 @@ describe('install_app_sim tool', () => { existsSync: () => true, }); - const result = await install_app_simLogic( - { - simulatorId: 'test-uuid-123', - appPath: '/path/to/app.app', - }, - mockExecutor, - mockFileSystem, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + install_app_simLogic( { - type: 'text', - text: 'Install app in simulator operation failed: Install failed', + simulatorId: 'test-uuid-123', + appPath: '/path/to/app.app', }, - ], - }); + mockExecutor, + mockFileSystem, + ), + ); + + const text = allText(result); + expect(text).toContain('Install app in simulator operation failed'); + expect(text).toContain('Install failed'); + expect(result.isError).toBe(true); }); it('should handle exception with Error object', async () => { @@ -315,23 +337,21 @@ describe('install_app_sim tool', () => { existsSync: () => true, }); - const result = await install_app_simLogic( - { - simulatorId: 'test-uuid-123', - appPath: '/path/to/app.app', - }, - mockExecutor, - mockFileSystem, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + install_app_simLogic( { - type: 'text', - text: 'Install app in simulator operation failed: Command execution failed', + simulatorId: 'test-uuid-123', + appPath: '/path/to/app.app', }, - ], - }); + mockExecutor, + mockFileSystem, + ), + ); + + const text = allText(result); + expect(text).toContain('Install app in simulator operation failed'); + expect(text).toContain('Command execution failed'); + expect(result.isError).toBe(true); }); it('should handle exception with string error', async () => { @@ -341,23 +361,21 @@ describe('install_app_sim tool', () => { existsSync: () => true, }); - const result = await install_app_simLogic( - { - simulatorId: 'test-uuid-123', - appPath: '/path/to/app.app', - }, - mockExecutor, - mockFileSystem, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + install_app_simLogic( { - type: 'text', - text: 'Install app in simulator operation failed: String error', + simulatorId: 'test-uuid-123', + appPath: '/path/to/app.app', }, - ], - }); + mockExecutor, + mockFileSystem, + ), + ); + + const text = allText(result); + expect(text).toContain('Install app in simulator operation failed'); + expect(text).toContain('String error'); + expect(result.isError).toBe(true); }); }); }); diff --git a/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts b/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts index 8ffbd533..7e65626c 100644 --- a/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts @@ -2,7 +2,51 @@ import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import { schema, handler, launch_app_simLogic } from '../launch_app_sim.ts'; +import { schema, handler, launch_app_simLogic, type SimulatorLauncher } from '../launch_app_sim.ts'; +import type { LaunchWithLoggingResult } from '../../../../utils/simulator-steps.ts'; +import { createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; + +const runLogic = async (logic: () => Promise) => { + const { result, run } = createMockToolHandlerContext(); + const response = await run(logic); + + if ( + response && + typeof response === 'object' && + 'content' in (response as Record) + ) { + return response as { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + isError?: boolean; + nextStepParams?: unknown; + }; + } + + const text = result.text(); + const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; + const imageContent = result.attachments.map((attachment) => ({ + type: 'image' as const, + data: attachment.data, + mimeType: attachment.mimeType, + })); + + return { + content: [...textContent, ...imageContent], + isError: result.isError() ? true : undefined, + nextStepParams: result.nextStepParams, + attachments: result.attachments, + text, + }; +}; + +function createMockLauncher(overrides?: Partial): SimulatorLauncher { + return async (_uuid, _bundleId, _opts?) => ({ + success: true, + processId: 12345, + logFilePath: '/tmp/mock-logs/test.log', + ...overrides, + }); +} describe('launch_app_sim tool', () => { beforeEach(() => { @@ -70,139 +114,94 @@ describe('launch_app_sim tool', () => { describe('Logic Behavior (Literal Returns)', () => { it('should launch app successfully with simulatorId', async () => { - let callCount = 0; - const sequencedExecutor = async (command: string[]) => { - callCount++; - if (callCount === 1) { - return { - success: true, - output: '/path/to/app/container', - error: '', - process: {} as any, - }; - } - return { - success: true, - output: 'App launched successfully', - error: '', - process: {} as any, - }; - }; - - const result = await launch_app_simLogic( - { - simulatorId: 'test-uuid-123', - bundleId: 'io.sentry.testapp', - }, - sequencedExecutor, - ); + const installCheckExecutor = async () => ({ + success: true, + output: '/path/to/app/container', + error: '', + process: {} as any, + }); - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + launch_app_simLogic( { - type: 'text', - text: 'App launched successfully in simulator test-uuid-123.', + simulatorId: 'test-uuid-123', + bundleId: 'io.sentry.testapp', }, - ], - nextStepParams: { - open_sim: {}, - start_sim_log_cap: [ - { simulatorId: 'test-uuid-123', bundleId: 'io.sentry.testapp' }, - { - simulatorId: 'test-uuid-123', - bundleId: 'io.sentry.testapp', - captureConsole: true, - }, - ], - }, + installCheckExecutor, + createMockLauncher(), + ), + ); + + const text = result.content.map((c) => (c.type === 'text' ? c.text : '')).join('\n'); + expect(text).toContain('Launch App'); + expect(text).toContain('App launched successfully'); + expect(text).toContain('test-uuid-123'); + expect(result.nextStepParams).toEqual({ + open_sim: {}, + stop_app_sim: { simulatorId: 'test-uuid-123', bundleId: 'io.sentry.testapp' }, }); }); - it('should append additional arguments when provided', async () => { - let callCount = 0; - const commands: string[][] = []; - - const sequencedExecutor = async (command: string[]) => { - callCount++; - commands.push(command); - if (callCount === 1) { - return { - success: true, - output: '/path/to/app/container', - error: '', - process: {} as any, - }; - } - return { - success: true, - output: 'App launched successfully', - error: '', - process: {} as any, - }; + it('should pass args and env through to launcher', async () => { + let capturedArgs: string[] | undefined; + let capturedEnv: Record | undefined; + const trackingLauncher: SimulatorLauncher = async (_uuid, _bundleId, opts?) => { + capturedArgs = opts?.args; + capturedEnv = opts?.env; + return { success: true, processId: 12345, logFilePath: '/tmp/test.log' }; }; - await launch_app_simLogic( - { - simulatorId: 'test-uuid-123', - bundleId: 'io.sentry.testapp', - args: ['--debug', '--verbose'], - }, - sequencedExecutor, + const installCheckExecutor = async () => ({ + success: true, + output: '/path/to/app/container', + error: '', + process: {} as any, + }); + + await runLogic(() => + launch_app_simLogic( + { + simulatorId: 'test-uuid-123', + bundleId: 'io.sentry.testapp', + args: ['--debug', '--verbose'], + env: { STAGING_ENABLED: '1' }, + }, + installCheckExecutor, + trackingLauncher, + ), ); - expect(commands).toEqual([ - ['xcrun', 'simctl', 'get_app_container', 'test-uuid-123', 'io.sentry.testapp', 'app'], - ['xcrun', 'simctl', 'launch', 'test-uuid-123', 'io.sentry.testapp', '--debug', '--verbose'], - ]); + expect(capturedArgs).toEqual(['--debug', '--verbose']); + expect(capturedEnv).toEqual({ STAGING_ENABLED: '1' }); }); it('should display friendly name when simulatorName is provided alongside resolved simulatorId', async () => { - let callCount = 0; - const sequencedExecutor = async (command: string[]) => { - callCount++; - if (callCount === 1) { - return { - success: true, - output: '/path/to/app/container', - error: '', - process: {} as any, - }; - } - return { - success: true, - output: 'App launched successfully', - error: '', - process: {} as any, - }; - }; - - const result = await launch_app_simLogic( - { - simulatorId: 'resolved-uuid', - simulatorName: 'iPhone 17', - bundleId: 'io.sentry.testapp', - }, - sequencedExecutor, - ); + const installCheckExecutor = async () => ({ + success: true, + output: '/path/to/app/container', + error: '', + process: {} as any, + }); - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + launch_app_simLogic( { - type: 'text', - text: 'App launched successfully in simulator "iPhone 17" (resolved-uuid).', + simulatorId: 'resolved-uuid', + simulatorName: 'iPhone 17', + bundleId: 'io.sentry.testapp', }, - ], - nextStepParams: { - open_sim: {}, - start_sim_log_cap: [ - { simulatorId: 'resolved-uuid', bundleId: 'io.sentry.testapp' }, - { - simulatorId: 'resolved-uuid', - bundleId: 'io.sentry.testapp', - captureConsole: true, - }, - ], - }, + installCheckExecutor, + createMockLauncher(), + ), + ); + + const text = result.content.map((c) => (c.type === 'text' ? c.text : '')).join('\n'); + expect(text).toContain('Launch App'); + expect(text).toContain('App launched successfully'); + expect(text).toContain('"iPhone 17" (resolved-uuid)'); + expect(result.nextStepParams).toEqual({ + open_sim: {}, + stop_app_sim: { simulatorId: 'resolved-uuid', bundleId: 'io.sentry.testapp' }, }); }); @@ -224,23 +223,20 @@ describe('launch_app_sim tool', () => { }; }; - const result = await launch_app_simLogic( - { - simulatorId: 'test-uuid-123', - bundleId: 'io.sentry.testapp', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + launch_app_simLogic( { - type: 'text', - text: `App is not installed on the simulator. Please use install_app_sim before launching.\n\nWorkflow: build → install → launch.`, + simulatorId: 'test-uuid-123', + bundleId: 'io.sentry.testapp', }, - ], - isError: true, - }); + mockExecutor, + ), + ); + + const text = result.content.map((c) => (c.type === 'text' ? c.text : '')).join('\n'); + expect(text).toContain('App is not installed on the simulator'); + expect(text).toContain('install_app_sim'); + expect(result.isError).toBe(true); }); it('should return error when install check throws', async () => { @@ -256,147 +252,73 @@ describe('launch_app_sim tool', () => { }; }; - const result = await launch_app_simLogic( - { - simulatorId: 'test-uuid-123', - bundleId: 'io.sentry.testapp', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + launch_app_simLogic( { - type: 'text', - text: `App is not installed on the simulator (check failed). Please use install_app_sim before launching.\n\nWorkflow: build → install → launch.`, + simulatorId: 'test-uuid-123', + bundleId: 'io.sentry.testapp', }, - ], - isError: true, - }); + mockExecutor, + ), + ); + + const text = result.content.map((c) => (c.type === 'text' ? c.text : '')).join('\n'); + expect(text).toContain('App is not installed on the simulator (check failed)'); + expect(text).toContain('install_app_sim'); + expect(result.isError).toBe(true); }); it('should handle launch failure', async () => { - let callCount = 0; - const mockExecutor = async (command: string[]) => { - callCount++; - if (callCount === 1) { - return { - success: true, - output: '/path/to/app/container', - error: '', - process: {} as any, - }; - } - return { - success: false, - output: '', - error: 'Launch failed', - process: {} as any, - }; - }; - - const result = await launch_app_simLogic( - { - simulatorId: 'test-uuid-123', - bundleId: 'io.sentry.testapp', - }, - mockExecutor, - ); + const installCheckExecutor = async () => ({ + success: true, + output: '/path/to/app/container', + error: '', + process: {} as any, + }); - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + launch_app_simLogic( { - type: 'text', - text: 'Launch app in simulator operation failed: Launch failed', + simulatorId: 'test-uuid-123', + bundleId: 'io.sentry.testapp', }, - ], - }); - }); - - it('should pass env vars with SIMCTL_CHILD_ prefix to executor opts', async () => { - let callCount = 0; - const capturedOpts: (Record | undefined)[] = []; - - const sequencedExecutor = async ( - command: string[], - _logPrefix?: string, - _useShell?: boolean, - opts?: { env?: Record }, - ) => { - callCount++; - capturedOpts.push(opts); - if (callCount === 1) { - return { - success: true, - output: '/path/to/app/container', - error: '', - process: {} as any, - }; - } - return { - success: true, - output: 'App launched successfully', - error: '', - process: {} as any, - }; - }; - - await launch_app_simLogic( - { - simulatorId: 'test-uuid-123', - bundleId: 'io.sentry.testapp', - env: { STAGING_ENABLED: '1', DEBUG: 'true' }, - }, - sequencedExecutor, + installCheckExecutor, + createMockLauncher({ success: false, error: 'Launch failed' }), + ), ); - // First call is get_app_container (no env), second is launch (with env) - expect(capturedOpts[1]).toEqual({ - env: { - SIMCTL_CHILD_STAGING_ENABLED: '1', - SIMCTL_CHILD_DEBUG: 'true', - }, - }); + const text = result.content.map((c) => (c.type === 'text' ? c.text : '')).join('\n'); + expect(text).toContain('Launch app in simulator operation failed'); + expect(text).toContain('Launch failed'); + expect(result.isError).toBe(true); }); - it('should not pass env opts when env is undefined', async () => { - let callCount = 0; - const capturedOpts: (Record | undefined)[] = []; - - const sequencedExecutor = async ( - command: string[], - _logPrefix?: string, - _useShell?: boolean, - opts?: { env?: Record }, - ) => { - callCount++; - capturedOpts.push(opts); - if (callCount === 1) { - return { - success: true, - output: '/path/to/app/container', - error: '', - process: {} as any, - }; - } - return { - success: true, - output: 'App launched successfully', - error: '', - process: {} as any, - }; + it('should not pass env when env is undefined', async () => { + let capturedEnv: Record | undefined; + const trackingLauncher: SimulatorLauncher = async (_uuid, _bundleId, opts?) => { + capturedEnv = opts?.env; + return { success: true, processId: 12345, logFilePath: '/tmp/test.log' }; }; - await launch_app_simLogic( - { - simulatorId: 'test-uuid-123', - bundleId: 'io.sentry.testapp', - }, - sequencedExecutor, + const installCheckExecutor = async () => ({ + success: true, + output: '/path/to/app/container', + error: '', + process: {} as any, + }); + + await runLogic(() => + launch_app_simLogic( + { + simulatorId: 'test-uuid-123', + bundleId: 'io.sentry.testapp', + }, + installCheckExecutor, + trackingLauncher, + ), ); - // Launch call opts should be undefined when no env provided - expect(capturedOpts[1]).toBeUndefined(); + expect(capturedEnv).toBeUndefined(); }); }); }); diff --git a/src/mcp/tools/simulator/__tests__/list_sims.test.ts b/src/mcp/tools/simulator/__tests__/list_sims.test.ts index bc3bc7f6..a73f3714 100644 --- a/src/mcp/tools/simulator/__tests__/list_sims.test.ts +++ b/src/mcp/tools/simulator/__tests__/list_sims.test.ts @@ -4,9 +4,20 @@ import { createMockCommandResponse, createMockExecutor, } from '../../../../test-utils/mock-executors.ts'; +import { createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; -// Import the named exports and logic function import { schema, handler, list_simsLogic, listSimulators } from '../list_sims.ts'; +import type { CommandExecutor } from '../../../../utils/execution/index.ts'; + +async function runListSimsLogic(params: { enabled?: boolean }, executor: CommandExecutor) { + const { ctx, result, run } = createMockToolHandlerContext(); + await run(() => list_simsLogic(params, executor)); + return { + content: [{ type: 'text' as const, text: result.text() }], + isError: result.isError() || undefined, + nextStepParams: ctx.nextStepParams, + }; +} describe('list_sims tool', () => { let callHistory: Array<{ @@ -26,13 +37,11 @@ describe('list_sims tool', () => { it('should have correct schema with enabled boolean field', () => { const schemaObj = z.object(schema); - // Valid inputs expect(schemaObj.safeParse({ enabled: true }).success).toBe(true); expect(schemaObj.safeParse({ enabled: false }).success).toBe(true); expect(schemaObj.safeParse({ enabled: undefined }).success).toBe(true); expect(schemaObj.safeParse({}).success).toBe(true); - // Invalid inputs expect(schemaObj.safeParse({ enabled: 'yes' }).success).toBe(false); expect(schemaObj.safeParse({ enabled: 1 }).success).toBe(false); expect(schemaObj.safeParse({ enabled: null }).success).toBe(false); @@ -97,7 +106,6 @@ describe('list_sims tool', () => { -- iOS 17.0 -- iPhone 15 (test-uuid-123) (Shutdown)`; - // Create a mock executor that returns different outputs based on command const mockExecutor = async ( command: string[], logPrefix?: string, @@ -108,7 +116,6 @@ describe('list_sims tool', () => { callHistory.push({ command, logPrefix, useShell, env: opts?.env }); void detached; - // Return JSON output for JSON command if (command.includes('--json')) { return createMockCommandResponse({ success: true, @@ -117,7 +124,6 @@ describe('list_sims tool', () => { }); } - // Return text output for text command return createMockCommandResponse({ success: true, output: mockTextOutput, @@ -125,9 +131,8 @@ describe('list_sims tool', () => { }); }; - const result = await list_simsLogic({ enabled: true }, mockExecutor); + const result = await runListSimsLogic({ enabled: true }, mockExecutor); - // Verify both commands were called expect(callHistory).toHaveLength(2); expect(callHistory[0]).toEqual({ command: ['xcrun', 'simctl', 'list', 'devices', '--json'], @@ -142,28 +147,20 @@ describe('list_sims tool', () => { env: undefined, }); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: `Available iOS Simulators: - -iOS 17.0: -- iPhone 15 (test-uuid-123) - -Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName). -Before running build/run/test/UI automation tools, set the desired simulator identifier in session defaults.`, - }, - ], - nextStepParams: { - boot_sim: { simulatorId: 'UUID_FROM_ABOVE' }, - open_sim: {}, - build_sim: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }, - get_sim_app_path: { - scheme: 'YOUR_SCHEME', - platform: 'iOS Simulator', - simulatorId: 'UUID_FROM_ABOVE', - }, + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('List Simulators'); + expect(text).toContain('iOS 17.0'); + expect(text).toContain('iPhone 15'); + expect(text).toContain('test-uuid-123'); + expect(text).toContain('Shutdown'); + expect(result.nextStepParams).toEqual({ + boot_sim: { simulatorId: 'UUID_FROM_ABOVE' }, + open_sim: {}, + build_sim: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }, + get_sim_app_path: { + scheme: 'YOUR_SCHEME', + platform: 'iOS Simulator', + simulatorId: 'UUID_FROM_ABOVE', }, }); }); @@ -201,30 +198,22 @@ Before running build/run/test/UI automation tools, set the desired simulator ide }); }; - const result = await list_simsLogic({ enabled: true }, mockExecutor); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: `Available iOS Simulators: - -iOS 17.0: -- iPhone 15 (test-uuid-123) [Booted] - -Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName). -Before running build/run/test/UI automation tools, set the desired simulator identifier in session defaults.`, - }, - ], - nextStepParams: { - boot_sim: { simulatorId: 'UUID_FROM_ABOVE' }, - open_sim: {}, - build_sim: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }, - get_sim_app_path: { - scheme: 'YOUR_SCHEME', - platform: 'iOS Simulator', - simulatorId: 'UUID_FROM_ABOVE', - }, + const result = await runListSimsLogic({ enabled: true }, mockExecutor); + + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('List Simulators'); + expect(text).toContain('iOS 17.0'); + expect(text).toContain('iPhone 15'); + expect(text).toContain('test-uuid-123'); + expect(text).toContain('Booted'); + expect(result.nextStepParams).toEqual({ + boot_sim: { simulatorId: 'UUID_FROM_ABOVE' }, + open_sim: {}, + build_sim: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }, + get_sim_app_path: { + scheme: 'YOUR_SCHEME', + platform: 'iOS Simulator', + simulatorId: 'UUID_FROM_ABOVE', }, }); }); @@ -264,34 +253,23 @@ Before running build/run/test/UI automation tools, set the desired simulator ide }); }; - const result = await list_simsLogic({ enabled: true }, mockExecutor); - - // Should contain both iOS 18.6 from JSON and iOS 26.0 from text - expect(result).toEqual({ - content: [ - { - type: 'text', - text: `Available iOS Simulators: - -iOS 18.6: -- iPhone 15 (json-uuid-123) - -iOS 26.0: -- iPhone 17 Pro (text-uuid-456) - -Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName). -Before running build/run/test/UI automation tools, set the desired simulator identifier in session defaults.`, - }, - ], - nextStepParams: { - boot_sim: { simulatorId: 'UUID_FROM_ABOVE' }, - open_sim: {}, - build_sim: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }, - get_sim_app_path: { - scheme: 'YOUR_SCHEME', - platform: 'iOS Simulator', - simulatorId: 'UUID_FROM_ABOVE', - }, + const result = await runListSimsLogic({ enabled: true }, mockExecutor); + + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('iOS 18.6'); + expect(text).toContain('iPhone 15'); + expect(text).toContain('json-uuid-123'); + expect(text).toContain('iOS 26.0'); + expect(text).toContain('iPhone 17 Pro'); + expect(text).toContain('text-uuid-456'); + expect(result.nextStepParams).toEqual({ + boot_sim: { simulatorId: 'UUID_FROM_ABOVE' }, + open_sim: {}, + build_sim: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }, + get_sim_app_path: { + scheme: 'YOUR_SCHEME', + platform: 'iOS Simulator', + simulatorId: 'UUID_FROM_ABOVE', }, }); }); @@ -304,16 +282,11 @@ Before running build/run/test/UI automation tools, set the desired simulator ide process: { pid: 12345 }, }); - const result = await list_simsLogic({ enabled: true }, mockExecutor); + const result = await runListSimsLogic({ enabled: true }, mockExecutor); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to list simulators: Command failed', - }, - ], - }); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Failed to list simulators'); + expect(text).toContain('Command failed'); }); it('should handle JSON parse failure and fall back to text parsing', async () => { @@ -322,7 +295,6 @@ Before running build/run/test/UI automation tools, set the desired simulator ide iPhone 15 (test-uuid-456) (Shutdown)`; const mockExecutor = async (command: string[]) => { - // JSON command returns invalid JSON if (command.includes('--json')) { return createMockCommandResponse({ success: true, @@ -331,7 +303,6 @@ Before running build/run/test/UI automation tools, set the desired simulator ide }); } - // Text command returns valid text output return createMockCommandResponse({ success: true, output: mockTextOutput, @@ -339,31 +310,20 @@ Before running build/run/test/UI automation tools, set the desired simulator ide }); }; - const result = await list_simsLogic({ enabled: true }, mockExecutor); - - // Should fall back to text parsing and extract devices - expect(result).toEqual({ - content: [ - { - type: 'text', - text: `Available iOS Simulators: - -iOS 17.0: -- iPhone 15 (test-uuid-456) - -Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName). -Before running build/run/test/UI automation tools, set the desired simulator identifier in session defaults.`, - }, - ], - nextStepParams: { - boot_sim: { simulatorId: 'UUID_FROM_ABOVE' }, - open_sim: {}, - build_sim: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }, - get_sim_app_path: { - scheme: 'YOUR_SCHEME', - platform: 'iOS Simulator', - simulatorId: 'UUID_FROM_ABOVE', - }, + const result = await runListSimsLogic({ enabled: true }, mockExecutor); + + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('iOS 17.0'); + expect(text).toContain('iPhone 15'); + expect(text).toContain('test-uuid-456'); + expect(result.nextStepParams).toEqual({ + boot_sim: { simulatorId: 'UUID_FROM_ABOVE' }, + open_sim: {}, + build_sim: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }, + get_sim_app_path: { + scheme: 'YOUR_SCHEME', + platform: 'iOS Simulator', + simulatorId: 'UUID_FROM_ABOVE', }, }); }); @@ -371,31 +331,21 @@ Before running build/run/test/UI automation tools, set the desired simulator ide it('should handle exception with Error object', async () => { const mockExecutor = createMockExecutor(new Error('Command execution failed')); - const result = await list_simsLogic({ enabled: true }, mockExecutor); + const result = await runListSimsLogic({ enabled: true }, mockExecutor); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to list simulators: Command execution failed', - }, - ], - }); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Failed to list simulators'); + expect(text).toContain('Command execution failed'); }); it('should handle exception with string error', async () => { const mockExecutor = createMockExecutor('String error'); - const result = await list_simsLogic({ enabled: true }, mockExecutor); + const result = await runListSimsLogic({ enabled: true }, mockExecutor); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to list simulators: String error', - }, - ], - }); + const text = result.content.map((c) => c.text).join('\n'); + expect(text).toContain('Failed to list simulators'); + expect(text).toContain('String error'); }); }); }); diff --git a/src/mcp/tools/simulator/__tests__/open_sim.test.ts b/src/mcp/tools/simulator/__tests__/open_sim.test.ts index d7dda558..13321b71 100644 --- a/src/mcp/tools/simulator/__tests__/open_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/open_sim.test.ts @@ -1,9 +1,3 @@ -/** - * Tests for open_sim plugin - * Following CLAUDE.md testing standards with literal validation - * Using dependency injection for deterministic testing - */ - import { describe, it, expect } from 'vitest'; import * as z from 'zod'; import { @@ -12,6 +6,40 @@ import { type CommandExecutor, } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, open_simLogic } from '../open_sim.ts'; +import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; + +const runLogic = async (logic: () => Promise) => { + const { result, run } = createMockToolHandlerContext(); + const response = await run(logic); + + if ( + response && + typeof response === 'object' && + 'content' in (response as Record) + ) { + return response as { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + isError?: boolean; + nextStepParams?: unknown; + }; + } + + const text = result.text(); + const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; + const imageContent = result.attachments.map((attachment) => ({ + type: 'image' as const, + data: attachment.data, + mimeType: attachment.mimeType, + })); + + return { + content: [...textContent, ...imageContent], + isError: result.isError() ? true : undefined, + nextStepParams: result.nextStepParams, + attachments: result.attachments, + text, + }; +}; describe('open_sim tool', () => { describe('Export Field Validation (Literal)', () => { @@ -22,7 +50,6 @@ describe('open_sim tool', () => { it('should have correct schema validation', () => { const schemaObj = z.object(schema); - // Schema is empty, so any object should pass expect(schemaObj.safeParse({}).success).toBe(true); expect( @@ -31,7 +58,6 @@ describe('open_sim tool', () => { }).success, ).toBe(true); - // Empty schema should accept anything expect( schemaObj.safeParse({ enabled: true, @@ -41,82 +67,58 @@ describe('open_sim tool', () => { }); describe('Handler Behavior (Complete Literal Returns)', () => { - it('should return exact successful open simulator response', async () => { + it('should return successful open simulator response', async () => { const mockExecutor = createMockExecutor({ success: true, output: '', }); - const result = await open_simLogic({}, mockExecutor); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Simulator app opened successfully.', - }, - ], - nextStepParams: { - boot_sim: { simulatorId: 'UUID_FROM_LIST_SIMS' }, - start_sim_log_cap: [ - { simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' }, - { simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID', captureConsole: true }, - ], - launch_app_logs_sim: { simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' }, - }, + const result = await runLogic(() => open_simLogic({}, mockExecutor)); + + const text = allText(result); + expect(text).toContain('Open Simulator'); + expect(text).toContain('Simulator opened successfully'); + expect(result.isError).toBeFalsy(); + expect(result.nextStepParams).toEqual({ + boot_sim: { simulatorId: 'UUID_FROM_LIST_SIMS' }, }); }); - it('should return exact command failure response', async () => { + it('should return command failure response', async () => { const mockExecutor = createMockExecutor({ success: false, error: 'Command failed', }); - const result = await open_simLogic({}, mockExecutor); + const result = await runLogic(() => open_simLogic({}, mockExecutor)); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Open simulator operation failed: Command failed', - }, - ], - }); + const text = allText(result); + expect(text).toContain('Open simulator operation failed: Command failed'); + expect(result.isError).toBe(true); }); - it('should return exact exception handling response', async () => { + it('should return exception handling response', async () => { const mockExecutor: CommandExecutor = async () => { throw new Error('Test error'); }; - const result = await open_simLogic({}, mockExecutor); + const result = await runLogic(() => open_simLogic({}, mockExecutor)); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Open simulator operation failed: Test error', - }, - ], - }); + const text = allText(result); + expect(text).toContain('Open simulator operation failed: Test error'); + expect(result.isError).toBe(true); }); - it('should return exact string error handling response', async () => { + it('should return string error handling response', async () => { const mockExecutor: CommandExecutor = async () => { throw 'String error'; }; - const result = await open_simLogic({}, mockExecutor); + const result = await runLogic(() => open_simLogic({}, mockExecutor)); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Open simulator operation failed: String error', - }, - ], - }); + const text = allText(result); + expect(text).toContain('Open simulator operation failed: String error'); + expect(result.isError).toBe(true); }); it('should verify command generation with mock executor', async () => { @@ -143,7 +145,7 @@ describe('open_sim tool', () => { }); }; - await open_simLogic({}, mockExecutor); + await runLogic(() => open_simLogic({}, mockExecutor)); expect(calls).toHaveLength(1); expect(calls[0]).toEqual({ diff --git a/src/mcp/tools/simulator/__tests__/record_sim_video.test.ts b/src/mcp/tools/simulator/__tests__/record_sim_video.test.ts index b02e3345..62a1df52 100644 --- a/src/mcp/tools/simulator/__tests__/record_sim_video.test.ts +++ b/src/mcp/tools/simulator/__tests__/record_sim_video.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; -// Import the tool and logic import { schema, handler, record_sim_videoLogic } from '../record_sim_video.ts'; import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; +import { createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; const DUMMY_EXECUTOR: any = (async () => ({ success: true })) as any; // CommandExecutor stub const VALID_SIM_ID = '00000000-0000-0000-0000-000000000000'; @@ -48,42 +48,38 @@ describe('record_sim_video logic - start behavior', () => { }), }; - // DI for AXe helpers: available and version OK const axe = { areAxeToolsAvailable: () => true, isAxeAtLeastVersion: async () => true, - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'AXe not available' }], - isError: true, - }), }; const fs = createMockFileSystemExecutor(); - const res = await record_sim_videoLogic( - { - simulatorId: VALID_SIM_ID, - start: true, - // fps omitted to hit default 30 - outputFile: '/tmp/ignored.mp4', // should be ignored with a note - } as any, - DUMMY_EXECUTOR, - axe, - video, - fs, + const { result, run } = createMockToolHandlerContext(); + await run(() => + record_sim_videoLogic( + { + simulatorId: VALID_SIM_ID, + start: true, + outputFile: '/tmp/ignored.mp4', + } as any, + DUMMY_EXECUTOR, + axe, + video, + fs, + ), ); - expect(res.isError).toBe(false); - const texts = (res.content ?? []).map((c: any) => c.text).join('\n'); + expect(result.isError()).toBe(false); + const texts = result.text(); - expect(texts).toMatch(/30\s*fps/i); + expect(texts).toContain('30'); expect(texts.toLowerCase()).toContain('outputfile is ignored'); - // Check nextStepParams instead of embedded text - expect(res.nextStepParams).toBeDefined(); - expect(res.nextStepParams?.record_sim_video).toBeDefined(); - expect(res.nextStepParams?.record_sim_video).toHaveProperty('stop', true); - expect(res.nextStepParams?.record_sim_video).toHaveProperty('outputFile'); + expect(result.nextStepParams).toBeDefined(); + expect(result.nextStepParams?.record_sim_video).toBeDefined(); + expect(result.nextStepParams?.record_sim_video).toHaveProperty('stop', true); + expect(result.nextStepParams?.record_sim_video).toHaveProperty('outputFile'); }); }); @@ -106,46 +102,43 @@ describe('record_sim_video logic - end-to-end stop with rename', () => { const axe = { areAxeToolsAvailable: () => true, isAxeAtLeastVersion: async () => true, - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'AXe not available' }], - isError: true, - }), }; - // Start (not strictly required for stop path, but included to mimic flow) - const startRes = await record_sim_videoLogic( - { - simulatorId: VALID_SIM_ID, - start: true, - } as any, - DUMMY_EXECUTOR, - axe, - video, - fs, + const { result: startResult, run: runStart } = createMockToolHandlerContext(); + await runStart(() => + record_sim_videoLogic( + { + simulatorId: VALID_SIM_ID, + start: true, + } as any, + DUMMY_EXECUTOR, + axe, + video, + fs, + ), ); - expect(startRes.isError).toBe(false); + expect(startResult.isError()).toBe(false); - // Stop and rename const outputFile = '/var/videos/final.mp4'; - const stopRes = await record_sim_videoLogic( - { - simulatorId: VALID_SIM_ID, - stop: true, - outputFile, - } as any, - DUMMY_EXECUTOR, - axe, - video, - fs, + const { result: stopResult, run: runStop } = createMockToolHandlerContext(); + await runStop(() => + record_sim_videoLogic( + { + simulatorId: VALID_SIM_ID, + stop: true, + outputFile, + } as any, + DUMMY_EXECUTOR, + axe, + video, + fs, + ), ); - expect(stopRes.isError).toBe(false); - const texts = (stopRes.content ?? []).map((c: any) => c.text).join('\n'); + expect(stopResult.isError()).toBe(false); + const texts = stopResult.text(); expect(texts).toContain('Original file: /tmp/recorded.mp4'); expect(texts).toContain(`Saved to: ${outputFile}`); - - // _meta should include final saved path - expect((stopRes as any)._meta?.outputFile).toBe(outputFile); }); }); @@ -154,10 +147,6 @@ describe('record_sim_video logic - version gate', () => { const axe = { areAxeToolsAvailable: () => true, isAxeAtLeastVersion: async () => false, - createAxeNotAvailableResponse: () => ({ - content: [{ type: 'text' as const, text: 'AXe not available' }], - isError: true, - }), }; const video: any = { @@ -172,19 +161,22 @@ describe('record_sim_video logic - version gate', () => { const fs = createMockFileSystemExecutor(); - const res = await record_sim_videoLogic( - { - simulatorId: VALID_SIM_ID, - start: true, - } as any, - DUMMY_EXECUTOR, - axe, - video, - fs, + const { result, run } = createMockToolHandlerContext(); + await run(() => + record_sim_videoLogic( + { + simulatorId: VALID_SIM_ID, + start: true, + } as any, + DUMMY_EXECUTOR, + axe, + video, + fs, + ), ); - expect(res.isError).toBe(true); - const text = (res.content?.[0] as any)?.text ?? ''; + expect(result.isError()).toBe(true); + const text = result.text(); expect(text).toContain('AXe v1.1.0'); }); }); diff --git a/src/mcp/tools/simulator/__tests__/screenshot.test.ts b/src/mcp/tools/simulator/__tests__/screenshot.test.ts index 0f249472..2d098e30 100644 --- a/src/mcp/tools/simulator/__tests__/screenshot.test.ts +++ b/src/mcp/tools/simulator/__tests__/screenshot.test.ts @@ -1,9 +1,3 @@ -/** - * Tests for screenshot plugin - * Following CLAUDE.md testing standards with literal validation - * Using pure dependency injection for deterministic testing - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { @@ -13,9 +7,43 @@ import { mockProcess, } from '../../../../test-utils/mock-executors.ts'; import type { CommandExecutor } from '../../../../utils/execution/index.ts'; -import { SystemError } from '../../../../utils/responses/index.ts'; +import { SystemError } from '../../../../utils/errors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, screenshotLogic } from '../../ui-automation/screenshot.ts'; +import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; + +const runLogic = async (logic: () => Promise) => { + const { result, run } = createMockToolHandlerContext(); + const response = await run(logic); + + if ( + response && + typeof response === 'object' && + 'content' in (response as Record) + ) { + return response as { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + isError?: boolean; + nextStepParams?: unknown; + }; + } + + const text = result.text(); + const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; + const imageContent = result.attachments.map((attachment) => ({ + type: 'image' as const, + data: attachment.data, + mimeType: attachment.mimeType, + })); + + return { + content: [...textContent, ...imageContent], + isError: result.isError() ? true : undefined, + nextStepParams: result.nextStepParams, + attachments: result.attachments, + text, + }; +}; describe('screenshot plugin', () => { beforeEach(() => { @@ -41,7 +69,6 @@ describe('screenshot plugin', () => { }); describe('Command Generation', () => { - // Mock device list JSON for proper device name lookup const mockDeviceListJson = JSON.stringify({ devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-17-2': [ @@ -54,11 +81,9 @@ describe('screenshot plugin', () => { it('should generate correct simctl and sips commands', async () => { const capturedCommands: string[][] = []; - // Wrap to capture commands and return appropriate mock responses const capturingExecutor = async (command: string[], ...args: any[]) => { capturedCommands.push(command); const cmdStr = command.join(' '); - // Return device list JSON for list command if (cmdStr.includes('simctl list devices')) { return { success: true, @@ -67,7 +92,6 @@ describe('screenshot plugin', () => { process: mockProcess, }; } - // Return success for all other commands return { success: true, output: '', error: undefined, process: mockProcess }; }; @@ -84,20 +108,20 @@ describe('screenshot plugin', () => { v4: () => 'mock-uuid-123', }; - await screenshotLogic( - { - simulatorId: 'test-uuid', - }, - capturingExecutor, - mockFileSystemExecutor, - mockPathDeps, - mockUuidDeps, + await runLogic(() => + screenshotLogic( + { + simulatorId: 'test-uuid', + }, + capturingExecutor, + mockFileSystemExecutor, + mockPathDeps, + mockUuidDeps, + ), ); - // Should execute all commands in sequence: screenshot, list devices, orientation detection, optimization - expect(capturedCommands).toHaveLength(4); + expect(capturedCommands).toHaveLength(5); - // First command: xcrun simctl screenshot expect(capturedCommands[0]).toEqual([ 'xcrun', 'simctl', @@ -107,16 +131,13 @@ describe('screenshot plugin', () => { '/tmp/screenshot_mock-uuid-123.png', ]); - // Second command: xcrun simctl list devices (to get device name) expect(capturedCommands[1][0]).toBe('xcrun'); expect(capturedCommands[1][1]).toBe('simctl'); expect(capturedCommands[1][2]).toBe('list'); - // Third command: swift orientation detection expect(capturedCommands[2][0]).toBe('swift'); expect(capturedCommands[2][1]).toBe('-e'); - // Fourth command: sips optimization expect(capturedCommands[3]).toEqual([ 'sips', '-Z', @@ -131,16 +152,17 @@ describe('screenshot plugin', () => { '--out', '/tmp/screenshot_optimized_mock-uuid-123.jpg', ]); + + expect(capturedCommands[4][0]).toBe('sips'); + expect(capturedCommands[4][1]).toBe('-g'); }); it('should generate correct path with different uuid', async () => { const capturedCommands: string[][] = []; - // Wrap to capture commands and return appropriate mock responses const capturingExecutor = async (command: string[], ...args: any[]) => { capturedCommands.push(command); const cmdStr = command.join(' '); - // Return device list JSON for list command if (cmdStr.includes('simctl list devices')) { return { success: true, @@ -149,7 +171,6 @@ describe('screenshot plugin', () => { process: mockProcess, }; } - // Return success for all other commands return { success: true, output: '', error: undefined, process: mockProcess }; }; @@ -166,20 +187,20 @@ describe('screenshot plugin', () => { v4: () => 'different-uuid-456', }; - await screenshotLogic( - { - simulatorId: 'another-uuid', - }, - capturingExecutor, - mockFileSystemExecutor, - mockPathDeps, - mockUuidDeps, + await runLogic(() => + screenshotLogic( + { + simulatorId: 'another-uuid', + }, + capturingExecutor, + mockFileSystemExecutor, + mockPathDeps, + mockUuidDeps, + ), ); - // Should execute all commands in sequence: screenshot, list devices, orientation detection, optimization - expect(capturedCommands).toHaveLength(4); + expect(capturedCommands).toHaveLength(5); - // First command: xcrun simctl screenshot expect(capturedCommands[0]).toEqual([ 'xcrun', 'simctl', @@ -189,16 +210,13 @@ describe('screenshot plugin', () => { '/tmp/screenshot_different-uuid-456.png', ]); - // Second command: xcrun simctl list devices (to get device name) expect(capturedCommands[1][0]).toBe('xcrun'); expect(capturedCommands[1][1]).toBe('simctl'); expect(capturedCommands[1][2]).toBe('list'); - // Third command: swift orientation detection expect(capturedCommands[2][0]).toBe('swift'); expect(capturedCommands[2][1]).toBe('-e'); - // Fourth command: sips optimization expect(capturedCommands[3]).toEqual([ 'sips', '-Z', @@ -213,16 +231,17 @@ describe('screenshot plugin', () => { '--out', '/tmp/screenshot_optimized_different-uuid-456.jpg', ]); + + expect(capturedCommands[4][0]).toBe('sips'); + expect(capturedCommands[4][1]).toBe('-g'); }); it('should use default dependencies when not provided', async () => { const capturedCommands: string[][] = []; - // Wrap to capture commands and return appropriate mock responses const capturingExecutor = async (command: string[], ...args: any[]) => { capturedCommands.push(command); const cmdStr = command.join(' '); - // Return device list JSON for list command if (cmdStr.includes('simctl list devices')) { return { success: true, @@ -231,7 +250,6 @@ describe('screenshot plugin', () => { process: mockProcess, }; } - // Return success for all other commands return { success: true, output: '', error: undefined, process: mockProcess }; }; @@ -239,18 +257,19 @@ describe('screenshot plugin', () => { readFile: async () => 'fake-image-data', }); - await screenshotLogic( - { - simulatorId: 'test-uuid', - }, - capturingExecutor, - mockFileSystemExecutor, + await runLogic(() => + screenshotLogic( + { + simulatorId: 'test-uuid', + }, + capturingExecutor, + mockFileSystemExecutor, + ), ); - // Should execute all commands in sequence: screenshot, list devices, orientation detection, optimization - expect(capturedCommands).toHaveLength(4); + // Should execute all commands in sequence: screenshot, list devices, orientation detection, optimization, dimensions + expect(capturedCommands).toHaveLength(5); - // First command should be generated with real os.tmpdir, path.join, and uuidv4 const firstCommand = capturedCommands[0]; expect(firstCommand).toHaveLength(6); expect(firstCommand[0]).toBe('xcrun'); @@ -260,21 +279,17 @@ describe('screenshot plugin', () => { expect(firstCommand[4]).toBe('screenshot'); expect(firstCommand[5]).toMatch(/\/.*\/screenshot_.*\.png/); - // Second command should be xcrun simctl list devices expect(capturedCommands[1][0]).toBe('xcrun'); expect(capturedCommands[1][1]).toBe('simctl'); expect(capturedCommands[1][2]).toBe('list'); - // Third command should be swift orientation detection expect(capturedCommands[2][0]).toBe('swift'); expect(capturedCommands[2][1]).toBe('-e'); - // Fourth command should be sips optimization const thirdCommand = capturedCommands[3]; expect(thirdCommand[0]).toBe('sips'); expect(thirdCommand[1]).toBe('-Z'); expect(thirdCommand[2]).toBe('800'); - // Should have proper PNG input and JPG output paths expect(thirdCommand[thirdCommand.length - 3]).toMatch(/\/.*\/screenshot_.*\.png/); expect(thirdCommand[thirdCommand.length - 1]).toMatch(/\/.*\/screenshot_optimized_.*\.jpg/); }); @@ -284,7 +299,6 @@ describe('screenshot plugin', () => { it('should capture screenshot successfully', async () => { const mockImageBuffer = Buffer.from('fake-image-data'); - // Mock both commands: screenshot + optimization const mockExecutor = createCommandMatchingMockExecutor({ 'xcrun simctl': { success: true, output: 'Screenshot saved' }, sips: { success: true, output: 'Image optimized' }, @@ -303,25 +317,28 @@ describe('screenshot plugin', () => { v4: () => 'mock-uuid-123', }; - const result = await screenshotLogic( - { - simulatorId: 'test-uuid', - }, - mockExecutor, - mockFileSystemExecutor, - mockPathDeps, - mockUuidDeps, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + screenshotLogic( { - type: 'image', - data: mockImageBuffer.toString('base64'), - mimeType: 'image/jpeg', // Now JPEG after optimization + simulatorId: 'test-uuid', }, - ], - isError: false, + mockExecutor, + mockFileSystemExecutor, + mockPathDeps, + mockUuidDeps, + ), + ); + + expect(result.isError).toBeUndefined(); + const text = allText(result); + expect(text).toContain('Screenshot'); + expect(text).toContain('Screenshot captured'); + expect(text).toContain('Format: image/jpeg'); + const imageContent = result.content.find((c) => c.type === 'image'); + expect(imageContent).toEqual({ + type: 'image', + data: mockImageBuffer.toString('base64'), + mimeType: 'image/jpeg', }); }); @@ -351,25 +368,23 @@ describe('screenshot plugin', () => { v4: () => 'mock-uuid-123', }; - const result = await screenshotLogic( - { - simulatorId: 'test-uuid', - }, - mockExecutor, - createMockFileSystemExecutor(), - mockPathDeps, - mockUuidDeps, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + screenshotLogic( { - type: 'text', - text: 'Error: System error executing screenshot: Failed to capture screenshot: Command failed', + simulatorId: 'test-uuid', }, - ], - isError: true, - }); + mockExecutor, + createMockFileSystemExecutor(), + mockPathDeps, + mockUuidDeps, + ), + ); + + expect(result.isError).toBe(true); + const text = allText(result); + expect(text).toContain( + 'System error executing screenshot: Failed to capture screenshot: Command failed', + ); }); it('should handle file read failure', async () => { @@ -394,31 +409,28 @@ describe('screenshot plugin', () => { v4: () => 'mock-uuid-123', }; - const result = await screenshotLogic( - { - simulatorId: 'test-uuid', - }, - mockExecutor, - mockFileSystemExecutor, - mockPathDeps, - mockUuidDeps, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + screenshotLogic( { - type: 'text', - text: 'Error: Screenshot captured but failed to process image file: File not found', + simulatorId: 'test-uuid', }, - ], - isError: true, - }); + mockExecutor, + mockFileSystemExecutor, + mockPathDeps, + mockUuidDeps, + ), + ); + + expect(result.isError).toBe(true); + const text = allText(result); + expect(text).toContain( + 'Screenshot captured but failed to process image file: File not found', + ); }); it('should call correct command with direct execution', async () => { const capturedArgs: any[][] = []; - // Mock device list JSON for proper device name lookup const mockDeviceListJson = JSON.stringify({ devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-17-2': [ @@ -427,12 +439,10 @@ describe('screenshot plugin', () => { }, }); - // Capture all command executions and return appropriate mock responses const capturingExecutor: CommandExecutor = async (...args) => { capturedArgs.push(args); const command = args[0] as string[]; const cmdStr = command.join(' '); - // Return device list JSON for list command if (cmdStr.includes('simctl list devices')) { return { success: true, @@ -441,7 +451,6 @@ describe('screenshot plugin', () => { process: mockProcess, }; } - // Return success for all other commands return { success: true, output: '', error: undefined, process: mockProcess }; }; @@ -458,40 +467,37 @@ describe('screenshot plugin', () => { v4: () => 'mock-uuid-123', }; - await screenshotLogic( - { - simulatorId: 'test-uuid', - }, - capturingExecutor, - mockFileSystemExecutor, - mockPathDeps, - mockUuidDeps, + await runLogic(() => + screenshotLogic( + { + simulatorId: 'test-uuid', + }, + capturingExecutor, + mockFileSystemExecutor, + mockPathDeps, + mockUuidDeps, + ), ); - // Should capture all command executions: screenshot, list devices, orientation detection, optimization - expect(capturedArgs).toHaveLength(4); + expect(capturedArgs).toHaveLength(5); - // First call: xcrun simctl screenshot (3 args: command, logPrefix, useShell) expect(capturedArgs[0]).toEqual([ ['xcrun', 'simctl', 'io', 'test-uuid', 'screenshot', '/tmp/screenshot_mock-uuid-123.png'], '[Screenshot]: screenshot', false, ]); - // Second call: xcrun simctl list devices (to get device name) expect(capturedArgs[1][0][0]).toBe('xcrun'); expect(capturedArgs[1][0][1]).toBe('simctl'); expect(capturedArgs[1][0][2]).toBe('list'); expect(capturedArgs[1][1]).toBe('[Screenshot]: list devices'); expect(capturedArgs[1][2]).toBe(false); - // Third call: swift orientation detection expect(capturedArgs[2][0][0]).toBe('swift'); expect(capturedArgs[2][0][1]).toBe('-e'); expect(capturedArgs[2][1]).toBe('[Screenshot]: detect orientation'); expect(capturedArgs[2][2]).toBe(false); - // Fourth call: sips optimization (3 args: command, logPrefix, useShell) expect(capturedArgs[3]).toEqual([ [ 'sips', @@ -510,6 +516,10 @@ describe('screenshot plugin', () => { '[Screenshot]: optimize image', false, ]); + + expect(capturedArgs[4][0][0]).toBe('sips'); + expect(capturedArgs[4][0][1]).toBe('-g'); + expect(capturedArgs[4][1]).toBe('[Screenshot]: get dimensions'); }); it('should handle SystemError exceptions', async () => { @@ -524,25 +534,21 @@ describe('screenshot plugin', () => { v4: () => 'mock-uuid-123', }; - const result = await screenshotLogic( - { - simulatorId: 'test-uuid', - }, - mockExecutor, - createMockFileSystemExecutor(), - mockPathDeps, - mockUuidDeps, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + screenshotLogic( { - type: 'text', - text: 'Error: System error executing screenshot: System error occurred', + simulatorId: 'test-uuid', }, - ], - isError: true, - }); + mockExecutor, + createMockFileSystemExecutor(), + mockPathDeps, + mockUuidDeps, + ), + ); + + expect(result.isError).toBe(true); + const text = allText(result); + expect(text).toContain('System error executing screenshot: System error occurred'); }); it('should handle unexpected Error objects', async () => { @@ -557,25 +563,21 @@ describe('screenshot plugin', () => { v4: () => 'mock-uuid-123', }; - const result = await screenshotLogic( - { - simulatorId: 'test-uuid', - }, - mockExecutor, - createMockFileSystemExecutor(), - mockPathDeps, - mockUuidDeps, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + screenshotLogic( { - type: 'text', - text: 'Error: An unexpected error occurred: Unexpected error', + simulatorId: 'test-uuid', }, - ], - isError: true, - }); + mockExecutor, + createMockFileSystemExecutor(), + mockPathDeps, + mockUuidDeps, + ), + ); + + expect(result.isError).toBe(true); + const text = allText(result); + expect(text).toContain('An unexpected error occurred: Unexpected error'); }); it('should handle unexpected string errors', async () => { @@ -590,25 +592,21 @@ describe('screenshot plugin', () => { v4: () => 'mock-uuid-123', }; - const result = await screenshotLogic( - { - simulatorId: 'test-uuid', - }, - mockExecutor, - createMockFileSystemExecutor(), - mockPathDeps, - mockUuidDeps, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + screenshotLogic( { - type: 'text', - text: 'Error: An unexpected error occurred: String error', + simulatorId: 'test-uuid', }, - ], - isError: true, - }); + mockExecutor, + createMockFileSystemExecutor(), + mockPathDeps, + mockUuidDeps, + ), + ); + + expect(result.isError).toBe(true); + const text = allText(result); + expect(text).toContain('An unexpected error occurred: String error'); }); it('should handle file read error with fileSystemExecutor', async () => { @@ -633,25 +631,23 @@ describe('screenshot plugin', () => { v4: () => 'mock-uuid-123', }; - const result = await screenshotLogic( - { - simulatorId: 'test-uuid', - }, - mockExecutor, - mockFileSystemExecutor, - mockPathDeps, - mockUuidDeps, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + screenshotLogic( { - type: 'text', - text: 'Error: Screenshot captured but failed to process image file: File system error', + simulatorId: 'test-uuid', }, - ], - isError: true, - }); + mockExecutor, + mockFileSystemExecutor, + mockPathDeps, + mockUuidDeps, + ), + ); + + expect(result.isError).toBe(true); + const text = allText(result); + expect(text).toContain( + 'Screenshot captured but failed to process image file: File system error', + ); }); }); }); diff --git a/src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts b/src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts index 5ca7e21c..0246e9ba 100644 --- a/src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts @@ -7,6 +7,40 @@ import { import type { CommandExecutor } from '../../../../utils/execution/index.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, stop_app_simLogic } from '../stop_app_sim.ts'; +import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; + +const runLogic = async (logic: () => Promise) => { + const { result, run } = createMockToolHandlerContext(); + const response = await run(logic); + + if ( + response && + typeof response === 'object' && + 'content' in (response as Record) + ) { + return response as { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + isError?: boolean; + nextStepParams?: unknown; + }; + } + + const text = result.text(); + const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; + const imageContent = result.attachments.map((attachment) => ({ + type: 'image' as const, + data: attachment.data, + mimeType: attachment.mimeType, + })); + + return { + content: [...textContent, ...imageContent], + isError: result.isError() ? true : undefined, + nextStepParams: result.nextStepParams, + attachments: result.attachments, + text, + }; +}; describe('stop_app_sim tool', () => { beforeEach(() => { @@ -71,44 +105,42 @@ describe('stop_app_sim tool', () => { it('should stop app successfully with simulatorId', async () => { const mockExecutor = createMockExecutor({ success: true, output: '' }); - const result = await stop_app_simLogic( - { - simulatorId: 'test-uuid', - bundleId: 'io.sentry.App', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + stop_app_simLogic( { - type: 'text', - text: 'App io.sentry.App stopped successfully in simulator test-uuid', + simulatorId: 'test-uuid', + bundleId: 'io.sentry.App', }, - ], - }); + mockExecutor, + ), + ); + + const text = allText(result); + expect(text).toContain('Stop App'); + expect(text).toContain('io.sentry.App'); + expect(text).toContain('stopped successfully'); + expect(text).toContain('test-uuid'); }); it('should display friendly name when simulatorName is provided alongside resolved simulatorId', async () => { const mockExecutor = createMockExecutor({ success: true, output: '' }); - const result = await stop_app_simLogic( - { - simulatorId: 'resolved-uuid', - simulatorName: 'iPhone 17', - bundleId: 'io.sentry.App', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + stop_app_simLogic( { - type: 'text', - text: 'App io.sentry.App stopped successfully in simulator "iPhone 17" (resolved-uuid)', + simulatorId: 'resolved-uuid', + simulatorName: 'iPhone 17', + bundleId: 'io.sentry.App', }, - ], - }); + mockExecutor, + ), + ); + + const text = allText(result); + expect(text).toContain('Stop App'); + expect(text).toContain('io.sentry.App'); + expect(text).toContain('stopped successfully'); + expect(text).toContain('"iPhone 17" (resolved-uuid)'); }); it('should surface terminate failures', async () => { @@ -118,23 +150,20 @@ describe('stop_app_sim tool', () => { error: 'Simulator not found', }); - const result = await stop_app_simLogic( - { - simulatorId: 'invalid-uuid', - bundleId: 'io.sentry.App', - }, - terminateExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + stop_app_simLogic( { - type: 'text', - text: 'Stop app in simulator operation failed: Simulator not found', + simulatorId: 'invalid-uuid', + bundleId: 'io.sentry.App', }, - ], - isError: true, - }); + terminateExecutor, + ), + ); + + const text = allText(result); + expect(text).toContain('Stop app in simulator operation failed'); + expect(text).toContain('Simulator not found'); + expect(result.isError).toBe(true); }); it('should handle unexpected exceptions', async () => { @@ -142,23 +171,20 @@ describe('stop_app_sim tool', () => { throw new Error('Unexpected error'); }; - const result = await stop_app_simLogic( - { - simulatorId: 'test-uuid', - bundleId: 'io.sentry.App', - }, - throwingExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + stop_app_simLogic( { - type: 'text', - text: 'Stop app in simulator operation failed: Unexpected error', + simulatorId: 'test-uuid', + bundleId: 'io.sentry.App', }, - ], - isError: true, - }); + throwingExecutor, + ), + ); + + const text = allText(result); + expect(text).toContain('Stop app in simulator operation failed'); + expect(text).toContain('Unexpected error'); + expect(result.isError).toBe(true); }); it('should call correct terminate command', async () => { @@ -185,12 +211,14 @@ describe('stop_app_sim tool', () => { }); }; - await stop_app_simLogic( - { - simulatorId: 'test-uuid', - bundleId: 'io.sentry.App', - }, - trackingExecutor, + await runLogic(() => + stop_app_simLogic( + { + simulatorId: 'test-uuid', + bundleId: 'io.sentry.App', + }, + trackingExecutor, + ), ); expect(calls).toEqual([ diff --git a/src/mcp/tools/simulator/__tests__/test_sim.test.ts b/src/mcp/tools/simulator/__tests__/test_sim.test.ts index 4b398fe7..54df996c 100644 --- a/src/mcp/tools/simulator/__tests__/test_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/test_sim.test.ts @@ -1,12 +1,11 @@ -/** - * Tests for test_sim plugin (session-aware version) - * Follows CLAUDE.md guidance: dependency injection, no vi-mocks, literal validation. - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, test_simLogic } from '../test_sim.ts'; +import { + createMockCommandResponse, + createMockFileSystemExecutor, +} from '../../../../test-utils/mock-executors.ts'; describe('test_sim tool', () => { beforeEach(() => { @@ -35,7 +34,7 @@ describe('test_sim tool', () => { expect(schemaObj.safeParse({ testRunnerEnv: { FOO: 123 } }).success).toBe(false); const schemaKeys = Object.keys(schema).sort(); - expect(schemaKeys).toEqual(['extraArgs', 'testRunnerEnv'].sort()); + expect(schemaKeys).toEqual(['extraArgs', 'progress', 'testRunnerEnv'].sort()); }); }); diff --git a/src/mcp/tools/simulator/boot_sim.ts b/src/mcp/tools/simulator/boot_sim.ts index ba356254..2ae460e0 100644 --- a/src/mcp/tools/simulator/boot_sim.ts +++ b/src/mcp/tools/simulator/boot_sim.ts @@ -1,12 +1,14 @@ import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; const baseSchemaObject = z.object({ simulatorId: z @@ -23,7 +25,6 @@ const baseSchemaObject = z.object({ ), }); -// Internal schema requires simulatorId (factory resolves simulatorName → simulatorId) const internalSchemaObject = z.object({ simulatorId: z.string(), simulatorName: z.string().optional(), @@ -41,49 +42,39 @@ const publicSchemaObject = z.strictObject( export async function boot_simLogic( params: BootSimParams, executor: CommandExecutor, -): Promise { +): Promise { log('info', `Starting xcrun simctl boot request for simulator ${params.simulatorId}`); - try { - const command = ['xcrun', 'simctl', 'boot', params.simulatorId]; - const result = await executor(command, 'Boot Simulator', false); + const headerEvent = header('Boot Simulator', [{ label: 'Simulator', value: params.simulatorId }]); - if (!result.success) { - return { - content: [ - { - type: 'text', - text: `Boot simulator operation failed: ${result.error}`, - }, - ], - }; - } + const ctx = getHandlerContext(); + + return withErrorHandling( + ctx, + async () => { + const command = ['xcrun', 'simctl', 'boot', params.simulatorId]; + const result = await executor(command, 'Boot Simulator', false); - return { - content: [ - { - type: 'text', - text: `Simulator booted successfully.`, - }, - ], - nextStepParams: { + if (!result.success) { + ctx.emit(headerEvent); + ctx.emit(statusLine('error', `Boot simulator operation failed: ${result.error}`)); + return; + } + + ctx.emit(headerEvent); + ctx.emit(statusLine('success', 'Simulator booted successfully')); + ctx.nextStepParams = { open_sim: {}, install_app_sim: { simulatorId: params.simulatorId, appPath: 'PATH_TO_YOUR_APP' }, launch_app_sim: { simulatorId: params.simulatorId, bundleId: 'YOUR_APP_BUNDLE_ID' }, - }, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error during boot simulator operation: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `Boot simulator operation failed: ${errorMessage}`, - }, - ], - }; - } + }; + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Boot simulator operation failed: ${message}`, + logMessage: ({ message }) => `Error during boot simulator operation: ${message}`, + }, + ); } export const schema = getSessionAwareToolSchemaShape({ diff --git a/src/mcp/tools/simulator/build_run_sim.ts b/src/mcp/tools/simulator/build_run_sim.ts index 08bd0136..8da3bcff 100644 --- a/src/mcp/tools/simulator/build_run_sim.ts +++ b/src/mcp/tools/simulator/build_run_sim.ts @@ -7,23 +7,42 @@ */ import * as z from 'zod'; -import type { ToolResponse, SharedBuildParams } from '../../../types/common.ts'; -import { XcodePlatform } from '../../../types/common.ts'; +import type { SharedBuildParams } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; -import { createTextResponse } from '../../../utils/responses/index.ts'; import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; -import { determineSimulatorUuid } from '../../../utils/simulator-utils.ts'; +import { + determineSimulatorUuid, + validateAvailableSimulatorId, +} from '../../../utils/simulator-utils.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { inferPlatform } from '../../../utils/infer-platform.ts'; import { constructDestinationString } from '../../../utils/xcode.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header } from '../../../utils/tool-event-builders.ts'; +import { startBuildPipeline } from '../../../utils/xcodebuild-pipeline.ts'; +import { formatToolPreflight } from '../../../utils/build-preflight.ts'; +import { + createBuildRunResultEvents, + emitPipelineError, + emitPipelineNotice, + finalizeInlineXcodebuild, +} from '../../../utils/xcodebuild-output.ts'; +import { resolveAppPathFromBuildSettings } from '../../../utils/app-path-resolver.ts'; +import { extractBundleIdFromAppPath } from '../../../utils/bundle-id.ts'; +import { + findSimulatorById, + installAppOnSimulator, + launchSimulatorAppWithLogging, + type LaunchWithLoggingResult, +} from '../../../utils/simulator-steps.ts'; -// Unified schema: XOR between projectPath and workspacePath, and XOR between simulatorId and simulatorName const baseOptions = { scheme: z.string().describe('The scheme to use (Required)'), simulatorId: z @@ -79,73 +98,14 @@ const buildRunSimulatorSchema = z.preprocess( export type BuildRunSimulatorParams = z.infer; -// Internal logic for building Simulator apps. -async function _handleSimulatorBuildLogic( - params: BuildRunSimulatorParams, - executor: CommandExecutor, - executeXcodeBuildCommandFn: typeof executeXcodeBuildCommand = executeXcodeBuildCommand, -): Promise<{ response: ToolResponse; detectedPlatform: XcodePlatform }> { - const projectType = params.projectPath ? 'project' : 'workspace'; - const filePath = params.projectPath ?? params.workspacePath; - - // Log warning if useLatestOS is provided with simulatorId - if (params.simulatorId && params.useLatestOS !== undefined) { - log( - 'warn', - `useLatestOS parameter is ignored when using simulatorId (UUID implies exact device/OS)`, - ); - } - - const inferred = await inferPlatform( - { - projectPath: params.projectPath, - workspacePath: params.workspacePath, - scheme: params.scheme, - simulatorId: params.simulatorId, - simulatorName: params.simulatorName, - }, - executor, - ); - const detectedPlatform = inferred.platform; - const platformName = detectedPlatform.replace(' Simulator', ''); - const logPrefix = `${platformName} Simulator Build`; - - log('info', `Starting ${logPrefix} for scheme ${params.scheme} from ${projectType}: ${filePath}`); - log('info', `Inferred simulator platform: ${detectedPlatform} (source: ${inferred.source})`); - - // Create SharedBuildParams object with required configuration property - const sharedBuildParams: SharedBuildParams = { - workspacePath: params.workspacePath, - projectPath: params.projectPath, - scheme: params.scheme, - configuration: params.configuration ?? 'Debug', - derivedDataPath: params.derivedDataPath, - extraArgs: params.extraArgs, - }; - - const response = await executeXcodeBuildCommandFn( - sharedBuildParams, - { - platform: detectedPlatform, - simulatorId: params.simulatorId, - simulatorName: params.simulatorName, - useLatestOS: params.simulatorId ? false : params.useLatestOS, - logPrefix, - }, - params.preferXcodebuild as boolean, - 'build', - executor, - ); - - return { response, detectedPlatform }; -} +export type SimulatorLauncher = typeof launchSimulatorAppWithLogging; -// Exported business logic function for building and running iOS Simulator apps. export async function build_run_simLogic( params: BuildRunSimulatorParams, executor: CommandExecutor, - executeXcodeBuildCommandFn: typeof executeXcodeBuildCommand = executeXcodeBuildCommand, -): Promise { + launcher: SimulatorLauncher = launchSimulatorAppWithLogging, +): Promise { + const ctx = getHandlerContext(); const projectType = params.projectPath ? 'project' : 'workspace'; const filePath = params.projectPath ?? params.workspacePath; @@ -154,359 +114,397 @@ export async function build_run_simLogic( `Starting Simulator build and run for scheme ${params.scheme} from ${projectType}: ${filePath}`, ); - try { - // --- Build Step --- - const { response: buildResult, detectedPlatform } = await _handleSimulatorBuildLogic( - params, - executor, - executeXcodeBuildCommandFn, - ); - - if (buildResult.isError) { - return buildResult; // Return the build error - } - - const platformName = detectedPlatform.replace(' Simulator', ''); - - // --- Get App Path Step --- - // Create the command array for xcodebuild with -showBuildSettings option - const command = ['xcodebuild', '-showBuildSettings']; - - // Add the workspace or project - if (params.workspacePath) { - command.push('-workspace', params.workspacePath); - } else if (params.projectPath) { - command.push('-project', params.projectPath); - } - - // Add the scheme and configuration - command.push('-scheme', params.scheme); - command.push('-configuration', params.configuration ?? 'Debug'); - - // Handle destination for simulator - let destinationString: string; - if (params.simulatorId) { - destinationString = constructDestinationString( - detectedPlatform, - undefined, - params.simulatorId, - ); - } else if (params.simulatorName) { - destinationString = constructDestinationString( - detectedPlatform, - params.simulatorName, - undefined, - params.useLatestOS ?? true, - ); - } else { - // This shouldn't happen due to validation, but handle it - destinationString = constructDestinationString(detectedPlatform); - } - command.push('-destination', destinationString); - - // Add derived data path if provided - if (params.derivedDataPath) { - command.push('-derivedDataPath', params.derivedDataPath); - } - - // Add extra args if provided - if (params.extraArgs && params.extraArgs.length > 0) { - command.push(...params.extraArgs); - } - - // Execute the command directly - const result = await executor(command, 'Get App Path', false, undefined); - - // If there was an error with the command execution, return it - if (!result.success) { - return createTextResponse( - `Build succeeded, but failed to get app path: ${result.error ?? 'Unknown error'}`, - true, - ); - } - - // Parse the output to extract the app path - const buildSettingsOutput = result.output; - - // Try both approaches to get app path - first the project approach (CODESIGNING_FOLDER_PATH) - let appBundlePath: string | null = null; - - // Project approach: Extract CODESIGNING_FOLDER_PATH from build settings to get app path - const appPathMatch = buildSettingsOutput.match(/CODESIGNING_FOLDER_PATH = (.+\.app)/); - if (appPathMatch?.[1]) { - appBundlePath = appPathMatch[1].trim(); - } else { - // Workspace approach: Extract BUILT_PRODUCTS_DIR and FULL_PRODUCT_NAME - const builtProductsDirMatch = buildSettingsOutput.match( - /^\s*BUILT_PRODUCTS_DIR\s*=\s*(.+)$/m, - ); - const fullProductNameMatch = buildSettingsOutput.match(/^\s*FULL_PRODUCT_NAME\s*=\s*(.+)$/m); - - if (builtProductsDirMatch && fullProductNameMatch) { - const builtProductsDir = builtProductsDirMatch[1].trim(); - const fullProductName = fullProductNameMatch[1].trim(); - appBundlePath = `${builtProductsDir}/${fullProductName}`; + return withErrorHandling( + ctx, + async () => { + if (params.simulatorId && params.useLatestOS !== undefined) { + log( + 'warn', + `useLatestOS parameter is ignored when using simulatorId (UUID implies exact device/OS)`, + ); } - } - if (!appBundlePath) { - return createTextResponse( - `Build succeeded, but could not find app path in build settings.`, - true, + const inferred = await inferPlatform( + { + projectPath: params.projectPath, + workspacePath: params.workspacePath, + scheme: params.scheme, + simulatorName: params.simulatorId ? undefined : params.simulatorName, + }, + executor, ); - } - - log('info', `App bundle path for run: ${appBundlePath}`); - - // --- Find/Boot Simulator Step --- - // Use our helper to determine the simulator UUID - const uuidResult = await determineSimulatorUuid( - { simulatorId: params.simulatorId, simulatorName: params.simulatorName }, - executor, - ); - - if (uuidResult.error) { - return createTextResponse(`Build succeeded, but ${uuidResult.error.content[0].text}`, true); - } + const detectedPlatform = inferred.platform; + const displayPlatform = + params.simulatorId && inferred.source !== 'simulator-runtime' + ? 'Simulator' + : String(detectedPlatform); + const platformName = detectedPlatform.replace(' Simulator', ''); + const logPrefix = `${platformName} Simulator Build`; + const configuration = params.configuration ?? 'Debug'; + + log( + 'info', + `Starting ${logPrefix} for scheme ${params.scheme} from ${projectType}: ${filePath}`, + ); + log('info', `Inferred simulator platform: ${detectedPlatform} (source: ${inferred.source})`); + + const preflightText = formatToolPreflight({ + operation: 'Build & Run', + scheme: params.scheme, + workspacePath: params.workspacePath, + projectPath: params.projectPath, + configuration, + platform: displayPlatform, + simulatorName: params.simulatorName, + simulatorId: params.simulatorId, + }); + + const started = startBuildPipeline({ + operation: 'BUILD', + toolName: 'build_run_sim', + params: { + scheme: params.scheme, + workspacePath: params.workspacePath, + projectPath: params.projectPath, + configuration, + platform: displayPlatform, + simulatorName: params.simulatorName, + simulatorId: params.simulatorId, + preflight: preflightText, + }, + message: preflightText, + }); + + // Validate explicit simulator ID before build + if (params.simulatorId) { + const validation = await validateAvailableSimulatorId(params.simulatorId, executor); + if (validation.error) { + emitPipelineError(started, 'BUILD', validation.error); + finalizeInlineXcodebuild({ + started, + emit: ctx.emit, + succeeded: false, + durationMs: Date.now() - started.startedAt, + }); + return; + } + } - if (uuidResult.warning) { - log('warn', uuidResult.warning); - } + // Build + const sharedBuildParams: SharedBuildParams = { + workspacePath: params.workspacePath, + projectPath: params.projectPath, + scheme: params.scheme, + configuration, + derivedDataPath: params.derivedDataPath, + extraArgs: params.extraArgs, + }; - const simulatorId = uuidResult.uuid; + const platformOptions = { + platform: detectedPlatform, + simulatorId: params.simulatorId, + simulatorName: params.simulatorName, + useLatestOS: params.simulatorId ? false : params.useLatestOS, + logPrefix, + }; - if (!simulatorId) { - return createTextResponse( - 'Build succeeded, but no simulator specified and failed to find a suitable one.', - true, - ); - } - - // Check simulator state and boot if needed - try { - log('info', `Checking simulator state for UUID: ${simulatorId}`); - const simulatorListResult = await executor( - ['xcrun', 'simctl', 'list', 'devices', 'available', '--json'], - 'List Simulators', + const buildResult = await executeXcodeBuildCommand( + sharedBuildParams, + platformOptions, + params.preferXcodebuild ?? false, + 'build', + executor, + undefined, + started.pipeline, ); - if (!simulatorListResult.success) { - throw new Error(simulatorListResult.error ?? 'Failed to list simulators'); - } - const simulatorsData = JSON.parse(simulatorListResult.output) as { - devices: Record; - }; - let targetSimulator: { udid: string; name: string; state: string } | null = null; - - // Find the target simulator - for (const runtime in simulatorsData.devices) { - const devices = simulatorsData.devices[runtime]; - if (Array.isArray(devices)) { - for (const device of devices) { - if ( - typeof device === 'object' && - device !== null && - 'udid' in device && - 'name' in device && - 'state' in device && - typeof device.udid === 'string' && - typeof device.name === 'string' && - typeof device.state === 'string' && - device.udid === simulatorId - ) { - targetSimulator = { - udid: device.udid, - name: device.name, - state: device.state, - }; - break; - } - } - if (targetSimulator) break; - } + if (buildResult.isError) { + finalizeInlineXcodebuild({ + started, + emit: ctx.emit, + succeeded: false, + durationMs: Date.now() - started.startedAt, + responseContent: buildResult.content, + errorFallbackPolicy: 'if-no-structured-diagnostics', + }); + return; } - if (!targetSimulator) { - return createTextResponse( - `Build succeeded, but could not find simulator with UUID: ${simulatorId}`, - true, + // Resolve app path + emitPipelineNotice(started, 'BUILD', 'Resolving app path', 'info', { + code: 'build-run-step', + data: { step: 'resolve-app-path', status: 'started' }, + }); + + let destination: string; + if (params.simulatorId) { + destination = constructDestinationString(detectedPlatform, undefined, params.simulatorId); + } else if (params.simulatorName) { + destination = constructDestinationString( + detectedPlatform, + params.simulatorName, + undefined, + params.useLatestOS ?? true, ); + } else { + destination = constructDestinationString(detectedPlatform); } - // Boot if needed - if (targetSimulator.state !== 'Booted') { - log('info', `Booting simulator ${targetSimulator.name}...`); - const bootResult = await executor( - ['xcrun', 'simctl', 'boot', simulatorId], - 'Boot Simulator', + let appBundlePath: string; + try { + appBundlePath = await resolveAppPathFromBuildSettings( + { + projectPath: params.projectPath, + workspacePath: params.workspacePath, + scheme: params.scheme, + configuration: params.configuration, + platform: detectedPlatform, + destination, + derivedDataPath: params.derivedDataPath, + extraArgs: params.extraArgs, + }, + executor, ); - if (!bootResult.success) { - throw new Error(bootResult.error ?? 'Failed to boot simulator'); - } - } else { - log('info', `Simulator ${simulatorId} is already booted`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', 'Build succeeded, but failed to get app path to launch.'); + emitPipelineError(started, 'BUILD', `Failed to get app path to launch: ${errorMessage}`); + finalizeInlineXcodebuild({ + started, + emit: ctx.emit, + succeeded: false, + durationMs: Date.now() - started.startedAt, + }); + return; } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error checking/booting simulator: ${errorMessage}`); - return createTextResponse( - `Build succeeded, but error checking/booting simulator: ${errorMessage}`, - true, - ); - } - - // --- Open Simulator UI Step --- - try { - log('info', 'Opening Simulator app'); - const openResult = await executor(['open', '-a', 'Simulator'], 'Open Simulator App'); - if (!openResult.success) { - throw new Error(openResult.error ?? 'Failed to open Simulator app'); + + log('info', `App bundle path for run: ${appBundlePath}`); + emitPipelineNotice(started, 'BUILD', 'App path resolved', 'success', { + code: 'build-run-step', + data: { step: 'resolve-app-path', status: 'succeeded', appPath: appBundlePath }, + }); + + // Resolve simulator UUID + const uuidResult = params.simulatorId + ? { uuid: params.simulatorId } + : await determineSimulatorUuid( + { simulatorId: params.simulatorId, simulatorName: params.simulatorName }, + executor, + ); + + if (uuidResult.error) { + emitPipelineError( + started, + 'BUILD', + `Failed to resolve simulator UUID: ${uuidResult.error}`, + ); + finalizeInlineXcodebuild({ + started, + emit: ctx.emit, + succeeded: false, + durationMs: Date.now() - started.startedAt, + }); + return; } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('warn', `Warning: Could not open Simulator app: ${errorMessage}`); - // Don't fail the whole operation for this - } - - // --- Install App Step --- - try { - log('info', `Installing app at path: ${appBundlePath} to simulator: ${simulatorId}`); - const installResult = await executor( - ['xcrun', 'simctl', 'install', simulatorId, appBundlePath], - 'Install App', - ); - if (!installResult.success) { - throw new Error(installResult.error ?? 'Failed to install app'); + + if (uuidResult.warning) { + log('warn', uuidResult.warning); } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error installing app: ${errorMessage}`); - return createTextResponse( - `Build succeeded, but error installing app on simulator: ${errorMessage}`, - true, - ); - } - // --- Get Bundle ID Step --- - let bundleId; - try { - log('info', `Extracting bundle ID from app: ${appBundlePath}`); + const simulatorId = uuidResult.uuid; + + if (!simulatorId) { + emitPipelineError( + started, + 'BUILD', + 'Failed to resolve simulator: no simulator identifier provided', + ); + finalizeInlineXcodebuild({ + started, + emit: ctx.emit, + succeeded: false, + durationMs: Date.now() - started.startedAt, + }); + return; + } - // Try multiple methods to get bundle ID - first PlistBuddy, then plutil, then defaults - let bundleIdResult = null; + // Boot simulator if needed + emitPipelineNotice(started, 'BUILD', 'Booting simulator', 'info', { + code: 'build-run-step', + data: { step: 'boot-simulator', status: 'started' }, + }); - // Method 1: PlistBuddy (most reliable) try { - bundleIdResult = await executor( - [ - '/usr/libexec/PlistBuddy', - '-c', - 'Print :CFBundleIdentifier', - `${appBundlePath}/Info.plist`, - ], - 'Get Bundle ID with PlistBuddy', + log('info', `Checking simulator state for UUID: ${simulatorId}`); + const { simulator: targetSimulator, error: findError } = await findSimulatorById( + simulatorId, + executor, ); - if (bundleIdResult.success) { - bundleId = bundleIdResult.output.trim(); + + if (!targetSimulator) { + emitPipelineError( + started, + 'BUILD', + findError ?? `Failed to find simulator with UUID: ${simulatorId}`, + ); + finalizeInlineXcodebuild({ + started, + emit: ctx.emit, + succeeded: false, + durationMs: Date.now() - started.startedAt, + }); + return; } - } catch { - // Continue to next method - } - // Method 2: plutil (workspace approach) - if (!bundleId) { - try { - bundleIdResult = await executor( - ['plutil', '-extract', 'CFBundleIdentifier', 'raw', `${appBundlePath}/Info.plist`], - 'Get Bundle ID with plutil', + if (targetSimulator.state !== 'Booted') { + log('info', `Booting simulator ${targetSimulator.name}...`); + const bootResult = await executor( + ['xcrun', 'simctl', 'boot', simulatorId], + 'Boot Simulator', ); - if (bundleIdResult?.success) { - bundleId = bundleIdResult.output?.trim(); + if (!bootResult.success) { + throw new Error(bootResult.error ?? 'Failed to boot simulator'); } - } catch { - // Continue to next method + } else { + log('info', `Simulator ${simulatorId} is already booted`); } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Failed to boot simulator: ${errorMessage}`); + emitPipelineError(started, 'BUILD', `Failed to boot simulator: ${errorMessage}`); + finalizeInlineXcodebuild({ + started, + emit: ctx.emit, + succeeded: false, + durationMs: Date.now() - started.startedAt, + }); + return; } - // Method 3: defaults (fallback) - if (!bundleId) { - try { - bundleIdResult = await executor( - ['defaults', 'read', `${appBundlePath}/Info`, 'CFBundleIdentifier'], - 'Get Bundle ID with defaults', - ); - if (bundleIdResult?.success) { - bundleId = bundleIdResult.output?.trim(); - } - } catch { - // All methods failed + emitPipelineNotice(started, 'BUILD', 'Simulator ready', 'success', { + code: 'build-run-step', + data: { step: 'boot-simulator', status: 'succeeded' }, + }); + + // Open Simulator.app (non-fatal) + try { + log('info', 'Opening Simulator app'); + const openResult = await executor(['open', '-a', 'Simulator'], 'Open Simulator App'); + if (!openResult.success) { + throw new Error(openResult.error ?? 'Failed to open Simulator app'); } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('warn', `Warning: Could not open Simulator app: ${errorMessage}`); } - if (!bundleId) { - throw new Error('Could not extract bundle ID from Info.plist using any method'); + // Install app + emitPipelineNotice(started, 'BUILD', 'Installing app', 'info', { + code: 'build-run-step', + data: { step: 'install-app', status: 'started' }, + }); + + const installResult = await installAppOnSimulator(simulatorId, appBundlePath, executor); + if (!installResult.success) { + const errorMessage = installResult.error ?? 'Failed to install app'; + log('error', `Failed to install app: ${errorMessage}`); + emitPipelineError(started, 'BUILD', `Failed to install app on simulator: ${errorMessage}`); + finalizeInlineXcodebuild({ + started, + emit: ctx.emit, + succeeded: false, + durationMs: Date.now() - started.startedAt, + }); + return; } - log('info', `Bundle ID for run: ${bundleId}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error getting bundle ID: ${errorMessage}`); - return createTextResponse( - `Build and install succeeded, but error getting bundle ID: ${errorMessage}`, - true, - ); - } - - // --- Launch App Step --- - try { - log('info', `Launching app with bundle ID: ${bundleId} on simulator: ${simulatorId}`); - const launchResult = await executor( - ['xcrun', 'simctl', 'launch', simulatorId, bundleId], - 'Launch App', - ); - if (!launchResult.success) { - throw new Error(launchResult.error ?? 'Failed to launch app'); + emitPipelineNotice(started, 'BUILD', 'App installed', 'success', { + code: 'build-run-step', + data: { step: 'install-app', status: 'succeeded' }, + }); + + // Extract bundle ID + let bundleId: string; + try { + log('info', `Extracting bundle ID from app: ${appBundlePath}`); + bundleId = (await extractBundleIdFromAppPath(appBundlePath, executor)).trim(); + if (bundleId.length === 0) { + throw new Error('Empty bundle ID returned'); + } + log('info', `Bundle ID for run: ${bundleId}`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Failed to extract bundle ID: ${errorMessage}`); + emitPipelineError(started, 'BUILD', `Failed to extract bundle ID: ${errorMessage}`); + finalizeInlineXcodebuild({ + started, + emit: ctx.emit, + succeeded: false, + durationMs: Date.now() - started.startedAt, + }); + return; } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error launching app: ${errorMessage}`); - return createTextResponse( - `Build and install succeeded, but error launching app on simulator: ${errorMessage}`, - true, - ); - } - // --- Success --- - log('info', `${platformName} simulator build & run succeeded.`); + // Launch app + emitPipelineNotice(started, 'BUILD', 'Launching app', 'info', { + code: 'build-run-step', + data: { step: 'launch-app', status: 'started', appPath: appBundlePath }, + }); - const target = params.simulatorId - ? `simulator UUID '${params.simulatorId}'` - : `simulator name '${params.simulatorName}'`; - const sourceType = params.projectPath ? 'project' : 'workspace'; - const sourcePath = params.projectPath ?? params.workspacePath; + const launchResult: LaunchWithLoggingResult = await launcher(simulatorId, bundleId); + if (!launchResult.success) { + const errorMessage = launchResult.error ?? 'Failed to launch app'; + log('error', `Failed to launch app: ${errorMessage}`); + emitPipelineError( + started, + 'BUILD', + `Failed to launch app ${appBundlePath}: ${errorMessage}`, + ); + finalizeInlineXcodebuild({ + started, + emit: ctx.emit, + succeeded: false, + durationMs: Date.now() - started.startedAt, + }); + return; + } - return { - content: [ - { - type: 'text', - text: `${platformName} simulator build and run succeeded for scheme ${params.scheme} from ${sourceType} ${sourcePath} targeting ${target}.\n\nThe app (${bundleId}) is now running in the ${platformName} Simulator.\nIf you don't see the simulator window, it may be hidden behind other windows. The Simulator app should be open.`, - }, - ], - nextStepParams: { - start_sim_log_cap: [ - { simulatorId, bundleId }, - { simulatorId, bundleId, captureConsole: true }, - ], + const processId = launchResult.processId; + if (processId !== undefined) { + log('info', `Launched with PID: ${processId}`); + } + + log('info', `${platformName} simulator build & run succeeded.`); + + finalizeInlineXcodebuild({ + started, + emit: ctx.emit, + succeeded: true, + durationMs: Date.now() - started.startedAt, + tailEvents: createBuildRunResultEvents({ + scheme: params.scheme, + platform: displayPlatform, + target: `${platformName} Simulator`, + appPath: appBundlePath, + bundleId, + launchState: 'requested', + processId, + buildLogPath: started.pipeline.logPath, + runtimeLogPath: launchResult.logFilePath, + osLogPath: launchResult.osLogPath, + }), + includeBuildLogFileRef: false, + }); + ctx.nextStepParams = { stop_app_sim: { simulatorId, bundleId }, - launch_app_logs_sim: { simulatorId, bundleId }, - }, - isError: false, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error in Simulator build and run: ${errorMessage}`); - return createTextResponse(`Error in Simulator build and run: ${errorMessage}`, true); - } + }; + }, + { + header: header('Build & Run Simulator'), + errorMessage: ({ message }) => `Error during simulator build and run: ${message}`, + logMessage: ({ message }) => `Error in Simulator build and run: ${message}`, + }, + ); } const publicSchemaObject = baseSchemaObject.omit({ diff --git a/src/mcp/tools/simulator/build_sim.ts b/src/mcp/tools/simulator/build_sim.ts index ee2c46bc..3a196d23 100644 --- a/src/mcp/tools/simulator/build_sim.ts +++ b/src/mcp/tools/simulator/build_sim.ts @@ -9,17 +9,19 @@ import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; -import type { ToolResponse } from '../../../types/common.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { inferPlatform } from '../../../utils/infer-platform.ts'; +import { startBuildPipeline } from '../../../utils/xcodebuild-pipeline.ts'; +import { finalizeInlineXcodebuild } from '../../../utils/xcodebuild-output.ts'; +import { formatToolPreflight } from '../../../utils/build-preflight.ts'; -// Unified schema: XOR between projectPath and workspacePath, and XOR between simulatorId and simulatorName const baseOptions = { scheme: z.string().describe('The scheme to use (Required)'), simulatorId: z @@ -75,19 +77,20 @@ const buildSimulatorSchema = z.preprocess( export type BuildSimulatorParams = z.infer; -// Internal logic for building Simulator apps. -async function _handleSimulatorBuildLogic( +export async function build_simLogic( params: BuildSimulatorParams, - executor: CommandExecutor = getDefaultCommandExecutor(), -): Promise { + executor: CommandExecutor, +): Promise { + const ctx = getHandlerContext(); + const configuration = params.configuration ?? 'Debug'; + const useLatestOS = params.useLatestOS ?? true; const projectType = params.projectPath ? 'project' : 'workspace'; const filePath = params.projectPath ?? params.workspacePath; - // Log warning if useLatestOS is provided with simulatorId if (params.simulatorId && params.useLatestOS !== undefined) { log( 'warn', - `useLatestOS parameter is ignored when using simulatorId (UUID implies exact device/OS)`, + 'useLatestOS parameter is ignored when using simulatorId (UUID implies exact device/OS)', ); } @@ -108,44 +111,76 @@ async function _handleSimulatorBuildLogic( log('info', `Starting ${logPrefix} for scheme ${params.scheme} from ${projectType}: ${filePath}`); log('info', `Inferred simulator platform: ${detectedPlatform} (source: ${inferred.source})`); - // Ensure configuration has a default value for SharedBuildParams compatibility - const sharedBuildParams = { - ...params, - configuration: params.configuration ?? 'Debug', + const sharedBuildParams = { ...params, configuration }; + + const platformOptions = { + platform: detectedPlatform, + simulatorName: params.simulatorName, + simulatorId: params.simulatorId, + useLatestOS: params.simulatorId ? false : useLatestOS, + logPrefix, + }; + + const preflightText = formatToolPreflight({ + operation: 'Build', + scheme: params.scheme, + workspacePath: params.workspacePath, + projectPath: params.projectPath, + configuration, + platform: String(detectedPlatform), + simulatorName: params.simulatorName, + simulatorId: params.simulatorId, + }); + + const pipelineParams = { + scheme: params.scheme, + workspacePath: params.workspacePath, + projectPath: params.projectPath, + configuration, + platform: String(detectedPlatform), + simulatorName: params.simulatorName, + simulatorId: params.simulatorId, + preflight: preflightText, }; - // executeXcodeBuildCommand handles both simulatorId and simulatorName - return executeXcodeBuildCommand( + const started = startBuildPipeline({ + operation: 'BUILD', + toolName: 'build_sim', + params: pipelineParams, + message: preflightText, + }); + + const buildResult = await executeXcodeBuildCommand( sharedBuildParams, - { - platform: detectedPlatform, - simulatorName: params.simulatorName, - simulatorId: params.simulatorId, - useLatestOS: params.simulatorId ? false : params.useLatestOS, // Ignore useLatestOS with ID - logPrefix, - }, + platformOptions, params.preferXcodebuild ?? false, 'build', executor, + undefined, + started.pipeline, ); -} - -export async function build_simLogic( - params: BuildSimulatorParams, - executor: CommandExecutor, -): Promise { - // Provide defaults - const processedParams: BuildSimulatorParams = { - ...params, - configuration: params.configuration ?? 'Debug', - useLatestOS: params.useLatestOS ?? true, // May be ignored if simulatorId is provided - preferXcodebuild: params.preferXcodebuild ?? false, - }; - return _handleSimulatorBuildLogic(processedParams, executor); + finalizeInlineXcodebuild({ + started, + emit: ctx.emit, + succeeded: !buildResult.isError, + durationMs: Date.now() - started.startedAt, + responseContent: buildResult.content, + }); + + if (!buildResult.isError) { + ctx.nextStepParams = { + get_sim_app_path: { + ...(params.simulatorId + ? { simulatorId: params.simulatorId } + : { simulatorName: params.simulatorName ?? '' }), + scheme: params.scheme, + platform: String(detectedPlatform), + }, + }; + } } -// Public schema = internal minus session-managed fields const publicSchemaObject = baseSchemaObject.omit({ projectPath: true, workspacePath: true, diff --git a/src/mcp/tools/simulator/get_sim_app_path.ts b/src/mcp/tools/simulator/get_sim_app_path.ts index 020ada0b..a1a0679a 100644 --- a/src/mcp/tools/simulator/get_sim_app_path.ts +++ b/src/mcp/tools/simulator/get_sim_app_path.ts @@ -8,17 +8,22 @@ import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; -import { createTextResponse } from '../../../utils/responses/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import type { ToolResponse } from '../../../types/common.ts'; import { XcodePlatform } from '../../../types/common.ts'; import { constructDestinationString } from '../../../utils/xcode.ts'; +import { displayPath } from '../../../utils/build-preflight.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; +import { resolveAppPathFromBuildSettings } from '../../../utils/app-path-resolver.ts'; +import { extractQueryErrorMessages } from '../../../utils/xcodebuild-error-utils.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine, detailTree, section } from '../../../utils/tool-event-builders.ts'; +import type { PipelineEvent } from '../../../types/pipeline-events.ts'; const SIMULATOR_PLATFORMS = [ XcodePlatform.iOSSimulator, @@ -76,7 +81,6 @@ const getSimulatorAppPathSchema = z.preprocess( }), ); -// Use z.infer for type safety type GetSimulatorAppPathParams = z.infer; /** @@ -85,111 +89,104 @@ type GetSimulatorAppPathParams = z.infer; export async function get_sim_app_pathLogic( params: GetSimulatorAppPathParams, executor: CommandExecutor, -): Promise { - // Set defaults - Zod validation already ensures required params are present - const projectPath = params.projectPath; - const workspacePath = params.workspacePath; - const scheme = params.scheme; - const platform = params.platform; - const simulatorId = params.simulatorId; - const simulatorName = params.simulatorName; +): Promise { const configuration = params.configuration ?? 'Debug'; const useLatestOS = params.useLatestOS ?? true; - // Log warning if useLatestOS is provided with simulatorId - if (simulatorId && params.useLatestOS !== undefined) { + if (params.simulatorId && params.useLatestOS !== undefined) { log( 'warn', `useLatestOS parameter is ignored when using simulatorId (UUID implies exact device/OS)`, ); } - log('info', `Getting app path for scheme ${scheme} on platform ${platform}`); - - try { - // Create the command array for xcodebuild with -showBuildSettings option - const command = ['xcodebuild', '-showBuildSettings']; - - // Add the workspace or project (XOR validation ensures exactly one is provided) - if (workspacePath) { - command.push('-workspace', workspacePath); - } else if (projectPath) { - command.push('-project', projectPath); - } - - // Add the scheme and configuration - command.push('-scheme', scheme); - command.push('-configuration', configuration); - - // Handle destination for simulator platforms - let destinationString = ''; - - if (simulatorId) { - destinationString = constructDestinationString(platform, undefined, simulatorId); - } else if (simulatorName) { - destinationString = constructDestinationString( - platform, - simulatorName, - undefined, - useLatestOS, - ); - } else { - return createTextResponse( - `For ${platform} platform, either simulatorId or simulatorName must be provided`, - true, - ); - } - - command.push('-destination', destinationString); - - // Execute the command directly - const result = await executor(command, 'Get App Path', false, undefined); - - if (!result.success) { - return createTextResponse(`Failed to get app path: ${result.error}`, true); - } - - if (!result.output) { - return createTextResponse('Failed to extract build settings output from the result.', true); - } - - const buildSettingsOutput = result.output; - const builtProductsDirMatch = buildSettingsOutput.match(/^\s*BUILT_PRODUCTS_DIR\s*=\s*(.+)$/m); - const fullProductNameMatch = buildSettingsOutput.match(/^\s*FULL_PRODUCT_NAME\s*=\s*(.+)$/m); - - if (!builtProductsDirMatch || !fullProductNameMatch) { - return createTextResponse( - 'Failed to extract app path from build settings. Make sure the app has been built first.', - true, - ); - } - - const builtProductsDir = builtProductsDirMatch[1].trim(); - const fullProductName = fullProductNameMatch[1].trim(); - const appPath = `${builtProductsDir}/${fullProductName}`; - - const nextStepParams: Record> = { - get_app_bundle_id: { appPath }, - boot_sim: { simulatorId: 'SIMULATOR_UUID' }, - install_app_sim: { simulatorId: 'SIMULATOR_UUID', appPath }, - launch_app_sim: { simulatorId: 'SIMULATOR_UUID', bundleId: 'BUNDLE_ID' }, - }; - - return { - content: [ - { - type: 'text', - text: `✅ App path retrieved successfully: ${appPath}`, - }, - ], - nextStepParams, - isError: false, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error retrieving app path: ${errorMessage}`); - return createTextResponse(`Error retrieving app path: ${errorMessage}`, true); + log('info', `Getting app path for scheme ${params.scheme} on platform ${params.platform}`); + + const headerParams: Array<{ label: string; value: string }> = [ + { label: 'Scheme', value: params.scheme }, + ]; + if (params.workspacePath) { + headerParams.push({ label: 'Workspace', value: params.workspacePath }); + } else if (params.projectPath) { + headerParams.push({ label: 'Project', value: params.projectPath }); + } + headerParams.push({ label: 'Configuration', value: configuration }); + headerParams.push({ label: 'Platform', value: params.platform }); + if (params.simulatorName) { + headerParams.push({ label: 'Simulator', value: params.simulatorName }); + } else if (params.simulatorId) { + headerParams.push({ label: 'Simulator', value: params.simulatorId }); } + + const headerEvent = header('Get App Path', headerParams); + + function buildErrorEvents(rawOutput: string): PipelineEvent[] { + const messages = extractQueryErrorMessages(rawOutput); + return [ + headerEvent, + section(`Errors (${messages.length}):`, [...messages.map((m) => `\u{2717} ${m}`), ''], { + blankLineAfterTitle: true, + }), + statusLine('error', 'Failed to get app path'), + ]; + } + + const startedAt = Date.now(); + + const ctx = getHandlerContext(); + + return withErrorHandling( + ctx, + async () => { + const destination = params.simulatorId + ? constructDestinationString(params.platform, undefined, params.simulatorId) + : constructDestinationString(params.platform, params.simulatorName, undefined, useLatestOS); + + let appPath: string; + try { + appPath = await resolveAppPathFromBuildSettings( + { + projectPath: params.projectPath, + workspacePath: params.workspacePath, + scheme: params.scheme, + configuration, + platform: params.platform, + destination, + }, + executor, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + for (const event of buildErrorEvents(message)) { + ctx.emit(event); + } + return; + } + + const durationMs = Date.now() - startedAt; + const durationStr = (durationMs / 1000).toFixed(1); + + ctx.emit(headerEvent); + ctx.emit(statusLine('success', `Get app path successful (\u{23F1}\u{FE0F} ${durationStr}s)`)); + ctx.emit(detailTree([{ label: 'App Path', value: displayPath(appPath) }])); + ctx.nextStepParams = { + get_app_bundle_id: { appPath }, + boot_sim: { simulatorId: 'SIMULATOR_UUID' }, + install_app_sim: { simulatorId: 'SIMULATOR_UUID', appPath }, + launch_app_sim: { simulatorId: 'SIMULATOR_UUID', bundleId: 'BUNDLE_ID' }, + }; + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Error retrieving app path: ${message}`, + logMessage: ({ message }) => `Error retrieving app path: ${message}`, + mapError: ({ message, emit }) => { + for (const event of buildErrorEvents(message)) { + emit?.(event); + } + }, + }, + ); } const publicSchemaObject = baseGetSimulatorAppPathSchema.omit({ diff --git a/src/mcp/tools/simulator/install_app_sim.ts b/src/mcp/tools/simulator/install_app_sim.ts index 79e02e6f..1a60ab55 100644 --- a/src/mcp/tools/simulator/install_app_sim.ts +++ b/src/mcp/tools/simulator/install_app_sim.ts @@ -1,13 +1,17 @@ import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; -import { validateFileExists } from '../../../utils/validation/index.ts'; +import { validateFileExists } from '../../../utils/validation.ts'; import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; +import { displayPath } from '../../../utils/build-preflight.ts'; +import { installAppOnSimulator } from '../../../utils/simulator-steps.ts'; const baseSchemaObject = z.object({ simulatorId: z @@ -25,7 +29,6 @@ const baseSchemaObject = z.object({ appPath: z.string().describe('Path to the .app bundle to install'), }); -// Internal schema requires simulatorId (factory resolves simulatorName → simulatorId) const internalSchemaObject = z.object({ simulatorId: z.string(), simulatorName: z.string().optional(), @@ -45,74 +48,74 @@ export async function install_app_simLogic( params: InstallAppSimParams, executor: CommandExecutor, fileSystem?: FileSystemExecutor, -): Promise { +): Promise { + const simulatorDisplayName = params.simulatorName + ? `"${params.simulatorName}" (${params.simulatorId})` + : params.simulatorId; + + const headerEvent = header('Install App', [ + { label: 'Simulator', value: simulatorDisplayName }, + { label: 'App Path', value: displayPath(params.appPath) }, + ]); + + const ctx = getHandlerContext(); + const appPathExistsValidation = validateFileExists(params.appPath, fileSystem); if (!appPathExistsValidation.isValid) { - return { - content: [{ type: 'text', text: appPathExistsValidation.errorMessage! }], - isError: true, - }; + ctx.emit(headerEvent); + ctx.emit(statusLine('error', appPathExistsValidation.errorMessage!)); + return; } log('info', `Starting xcrun simctl install request for simulator ${params.simulatorId}`); - try { - const command = ['xcrun', 'simctl', 'install', params.simulatorId, params.appPath]; - const result = await executor(command, 'Install App in Simulator', false, undefined); + return withErrorHandling( + ctx, + async () => { + const installResult = await installAppOnSimulator( + params.simulatorId, + params.appPath, + executor, + ); - if (!result.success) { - return { - content: [ - { - type: 'text', - text: `Install app in simulator operation failed: ${result.error}`, - }, - ], - }; - } + if (!installResult.success) { + ctx.emit(headerEvent); + ctx.emit( + statusLine('error', `Install app in simulator operation failed: ${installResult.error}`), + ); + return; + } - let bundleId = ''; - try { - const bundleIdResult = await executor( - ['defaults', 'read', `${params.appPath}/Info`, 'CFBundleIdentifier'], - 'Extract Bundle ID', - false, - undefined, - ); - if (bundleIdResult.success) { - bundleId = bundleIdResult.output.trim(); + let bundleId = ''; + try { + const bundleIdResult = await executor( + ['defaults', 'read', `${params.appPath}/Info`, 'CFBundleIdentifier'], + 'Extract Bundle ID', + false, + ); + if (bundleIdResult.success) { + bundleId = bundleIdResult.output.trim(); + } + } catch (error) { + log('warn', `Could not extract bundle ID from app: ${error}`); } - } catch (error) { - log('warn', `Could not extract bundle ID from app: ${error}`); - } - return { - content: [ - { - type: 'text', - text: `App installed successfully in simulator ${params.simulatorId}.`, - }, - ], - nextStepParams: { + ctx.emit(headerEvent); + ctx.emit(statusLine('success', 'App installed successfully')); + ctx.nextStepParams = { open_sim: {}, launch_app_sim: { simulatorId: params.simulatorId, bundleId: bundleId || 'YOUR_APP_BUNDLE_ID', }, - }, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error during install app in simulator operation: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `Install app in simulator operation failed: ${errorMessage}`, - }, - ], - }; - } + }; + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Install app in simulator operation failed: ${message}`, + logMessage: ({ message }) => `Error during install app in simulator operation: ${message}`, + }, + ); } export const schema = getSessionAwareToolSchemaShape({ diff --git a/src/mcp/tools/simulator/launch_app_sim.ts b/src/mcp/tools/simulator/launch_app_sim.ts index 09a1b959..46abfa0f 100644 --- a/src/mcp/tools/simulator/launch_app_sim.ts +++ b/src/mcp/tools/simulator/launch_app_sim.ts @@ -1,13 +1,19 @@ import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; -import { normalizeSimctlChildEnv } from '../../../utils/environment.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine, detailTree } from '../../../utils/tool-event-builders.ts'; +import { + launchSimulatorAppWithLogging, + type LaunchWithLoggingResult, +} from '../../../utils/simulator-steps.ts'; +import { displayPath } from '../../../utils/build-preflight.ts'; const baseSchemaObject = z.object({ simulatorId: z @@ -32,26 +38,23 @@ const baseSchemaObject = z.object({ ), }); -// Internal schema requires simulatorId (factory resolves simulatorName → simulatorId) const internalSchemaObject = z.object({ simulatorId: z.string(), simulatorName: z.string().optional(), bundleId: z.string(), args: z.array(z.string()).optional(), - env: z - .record(z.string(), z.string()) - .optional() - .describe( - 'Environment variables to pass to the launched app (SIMCTL_CHILD_ prefix added automatically)', - ), + env: z.record(z.string(), z.string()).optional(), }); export type LaunchAppSimParams = z.infer; +export type SimulatorLauncher = typeof launchSimulatorAppWithLogging; + export async function launch_app_simLogic( params: LaunchAppSimParams, executor: CommandExecutor, -): Promise { + launcher: SimulatorLauncher = launchSimulatorAppWithLogging, +): Promise { const simulatorId = params.simulatorId; const simulatorDisplayName = params.simulatorName ? `"${params.simulatorName}" (${simulatorId})` @@ -59,6 +62,13 @@ export async function launch_app_simLogic( log('info', `Starting xcrun simctl launch request for simulator ${simulatorId}`); + const headerEvent = header('Launch App', [ + { label: 'Simulator', value: simulatorDisplayName }, + { label: 'Bundle ID', value: params.bundleId }, + ]); + + const ctx = getHandlerContext(); + try { const getAppContainerCmd = [ 'xcrun', @@ -68,82 +78,71 @@ export async function launch_app_simLogic( params.bundleId, 'app', ]; - const getAppContainerResult = await executor( - getAppContainerCmd, - 'Check App Installed', - false, - undefined, - ); + const getAppContainerResult = await executor(getAppContainerCmd, 'Check App Installed', false); if (!getAppContainerResult.success) { - return { - content: [ - { - type: 'text', - text: `App is not installed on the simulator. Please use install_app_sim before launching.\n\nWorkflow: build → install → launch.`, - }, - ], - isError: true, - }; + ctx.emit(headerEvent); + ctx.emit( + statusLine( + 'error', + 'App is not installed on the simulator. Please use install_app_sim before launching. Workflow: build -> install -> launch.', + ), + ); + return; } } catch { - return { - content: [ - { - type: 'text', - text: `App is not installed on the simulator (check failed). Please use install_app_sim before launching.\n\nWorkflow: build → install → launch.`, - }, - ], - isError: true, - }; + ctx.emit(headerEvent); + ctx.emit( + statusLine( + 'error', + 'App is not installed on the simulator (check failed). Please use install_app_sim before launching. Workflow: build -> install -> launch.', + ), + ); + return; } - try { - const command = ['xcrun', 'simctl', 'launch', simulatorId, params.bundleId]; - if (params.args && params.args.length > 0) { - command.push(...params.args); - } + return withErrorHandling( + ctx, + async () => { + const launchResult: LaunchWithLoggingResult = await launcher(simulatorId, params.bundleId, { + args: params.args, + env: params.env, + }); - const execOpts = params.env ? { env: normalizeSimctlChildEnv(params.env) } : undefined; - const result = await executor(command, 'Launch App in Simulator', false, execOpts); - - if (!result.success) { - return { - content: [ - { - type: 'text', - text: `Launch app in simulator operation failed: ${result.error}`, - }, - ], - }; - } + if (!launchResult.success) { + ctx.emit(headerEvent); + ctx.emit( + statusLine('error', `Launch app in simulator operation failed: ${launchResult.error}`), + ); + return; + } + + const detailItems: Array<{ label: string; value: string }> = []; + if (launchResult.processId !== undefined) { + detailItems.push({ label: 'Process ID', value: String(launchResult.processId) }); + } + if (launchResult.logFilePath) { + detailItems.push({ label: 'Runtime Logs', value: displayPath(launchResult.logFilePath) }); + } + if (launchResult.osLogPath) { + detailItems.push({ label: 'OSLog', value: displayPath(launchResult.osLogPath) }); + } - return { - content: [ - { - type: 'text', - text: `App launched successfully in simulator ${simulatorDisplayName}.`, - }, - ], - nextStepParams: { + ctx.emit(headerEvent); + ctx.emit(statusLine('success', 'App launched successfully')); + if (detailItems.length > 0) { + ctx.emit(detailTree(detailItems)); + } + ctx.nextStepParams = { open_sim: {}, - start_sim_log_cap: [ - { simulatorId, bundleId: params.bundleId }, - { simulatorId, bundleId: params.bundleId, captureConsole: true }, - ], - }, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error during launch app in simulator operation: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `Launch app in simulator operation failed: ${errorMessage}`, - }, - ], - }; - } + stop_app_sim: { simulatorId, bundleId: params.bundleId }, + }; + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Launch app in simulator operation failed: ${message}`, + logMessage: ({ message }) => `Error during launch app in simulator operation: ${message}`, + }, + ); } const publicSchemaObject = z.strictObject( diff --git a/src/mcp/tools/simulator/list_sims.ts b/src/mcp/tools/simulator/list_sims.ts index 2d88b9f8..09bc6a73 100644 --- a/src/mcp/tools/simulator/list_sims.ts +++ b/src/mcp/tools/simulator/list_sims.ts @@ -1,16 +1,16 @@ import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; +import type { PipelineEvent } from '../../../types/pipeline-events.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { createTypedTool, getHandlerContext } from '../../../utils/typed-tool-factory.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, section, statusLine } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const listSimsSchema = z.object({ enabled: z.boolean().optional(), }); -// Use z.infer for type safety type ListSimsParams = z.infer; interface SimulatorDevice { @@ -32,22 +32,18 @@ interface SimulatorData { devices: Record; } -// Parse text output as fallback for Apple simctl JSON bugs (e.g., duplicate runtime IDs) function parseTextOutput(textOutput: string): SimulatorDevice[] { const devices: SimulatorDevice[] = []; const lines = textOutput.split('\n'); let currentRuntime = ''; for (const line of lines) { - // Match runtime headers like "-- iOS 26.0 --" or "-- iOS 18.6 --" const runtimeMatch = line.match(/^-- ([\w\s.]+) --$/); if (runtimeMatch) { currentRuntime = runtimeMatch[1]; continue; } - // Match device lines like " iPhone 17 Pro (UUID) (Booted)" - // UUID pattern is flexible to handle test UUIDs like "test-uuid-123" const deviceMatch = line.match( /^\s+(.+?)\s+\(([^)]+)\)\s+\((Booted|Shutdown|Booting|Shutting Down)\)(\s+\(unavailable.*\))?$/i, ); @@ -169,16 +165,63 @@ export async function listSimulators(executor: CommandExecutor): Promise = { + iOS: { label: 'iOS Simulators', emoji: '\u{1F4F1}', order: 0 }, + visionOS: { label: 'visionOS Simulators', emoji: '\u{1F97D}', order: 1 }, + watchOS: { label: 'watchOS Simulators', emoji: '\u{231A}\u{FE0F}', order: 2 }, + tvOS: { label: 'tvOS Simulators', emoji: '\u{1F4FA}', order: 3 }, +}; + +function detectPlatform(runtimeName: string): string { + if (/xrOS|visionOS/i.test(runtimeName)) return 'visionOS'; + if (/watchOS/i.test(runtimeName)) return 'watchOS'; + if (/tvOS/i.test(runtimeName)) return 'tvOS'; + return 'iOS'; +} + +function getPlatformInfo(platform: string): PlatformInfo { + return ( + PLATFORM_MAP[platform] ?? { label: `${platform} Simulators`, emoji: '\u{1F4F1}', order: 99 } + ); +} + +const NEXT_STEP_PARAMS = { + boot_sim: { simulatorId: 'UUID_FROM_ABOVE' }, + open_sim: {}, + build_sim: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }, + get_sim_app_path: { + scheme: 'YOUR_SCHEME', + platform: 'iOS Simulator', + simulatorId: 'UUID_FROM_ABOVE', + }, +} as const; + export async function list_simsLogic( _params: ListSimsParams, executor: CommandExecutor, -): Promise { +): Promise { log('info', 'Starting xcrun simctl list devices request'); - try { + const ctx = getHandlerContext(); + const headerEvent = header('List Simulators'); + + const buildEvents = async (): Promise => { const simulators = await listSimulators(executor); - let responseText = 'Available iOS Simulators:\n\n'; const grouped = new Map(); for (const simulator of simulators) { const runtimeGroup = grouped.get(simulator.runtime) ?? []; @@ -186,62 +229,83 @@ export async function list_simsLogic( grouped.set(simulator.runtime, runtimeGroup); } + const platformGroups = new Map>(); for (const [runtime, devices] of grouped.entries()) { if (devices.length === 0) continue; - - responseText += `${runtime}:\n`; - for (const device of devices) { - responseText += `- ${device.name} (${device.udid})${device.state === 'Booted' ? ' [Booted]' : ''}\n`; + const runtimeName = formatRuntimeName(runtime); + const platform = detectPlatform(runtimeName); + let platformMap = platformGroups.get(platform); + if (!platformMap) { + platformMap = new Map(); + platformGroups.set(platform, platformMap); } - responseText += '\n'; + platformMap.set(runtimeName, devices); } - responseText += - "Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName).\n"; - responseText += - 'Before running build/run/test/UI automation tools, set the desired simulator identifier in session defaults.'; - - return { - content: [ - { - type: 'text', - text: responseText, - }, - ], - nextStepParams: { - boot_sim: { simulatorId: 'UUID_FROM_ABOVE' }, - open_sim: {}, - build_sim: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }, - get_sim_app_path: { - scheme: 'YOUR_SCHEME', - platform: 'iOS Simulator', - simulatorId: 'UUID_FROM_ABOVE', - }, - }, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.startsWith('Failed to list simulators:')) { - return { - content: [ - { - type: 'text', - text: errorMessage, - }, - ], - }; + const platformCounts: Record = {}; + let totalCount = 0; + + const sortedPlatforms = [...platformGroups.entries()].sort( + ([a], [b]) => getPlatformInfo(a).order - getPlatformInfo(b).order, + ); + + const events: PipelineEvent[] = [headerEvent]; + + for (const [platform, runtimes] of sortedPlatforms) { + const info = getPlatformInfo(platform); + const lines: string[] = []; + let platformTotal = 0; + + for (const [runtimeName, devices] of runtimes.entries()) { + lines.push(''); + lines.push(`${runtimeName}:`); + + for (const device of devices) { + lines.push(''); + const marker = device.state === 'Booted' ? '\u{2713}' : '\u{2717}'; + lines.push(` ${info.emoji} [${marker}] ${device.name} (${device.state})`); + lines.push(` UDID: ${device.udid}`); + platformTotal++; + } + } + + platformCounts[platform] = platformTotal; + totalCount += platformTotal; + events.push(section(`${info.label}:`, lines)); } - log('error', `Error listing simulators: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `Failed to list simulators: ${errorMessage}`, - }, - ], - }; - } + const countParts = sortedPlatforms + .map(([platform]) => `${platformCounts[platform]} ${platform}`) + .join(', '); + const summaryMsg = `${totalCount} simulators available (${countParts}).`; + + events.push(statusLine('success', summaryMsg)); + events.push( + section('Hints', [ + 'Use the simulator ID/UDID from above when required by other tools.', + "Save a default simulator with session-set-defaults { simulatorId: 'SIMULATOR_UDID' }.", + 'Before running boot/build/run tools, set the desired simulator identifier in session defaults.', + ]), + ); + + return events; + }; + + await withErrorHandling( + ctx, + async () => { + const events = await buildEvents(); + for (const event of events) { + ctx.emit(event); + } + ctx.nextStepParams = { ...NEXT_STEP_PARAMS }; + }, + { + header: headerEvent, + errorMessage: ({ message }: { message: string }) => `Failed to list simulators: ${message}`, + logMessage: ({ message }: { message: string }) => `Error listing simulators: ${message}`, + }, + ); } export const schema = listSimsSchema.shape; diff --git a/src/mcp/tools/simulator/open_sim.ts b/src/mcp/tools/simulator/open_sim.ts index cfde7a7b..4707b56f 100644 --- a/src/mcp/tools/simulator/open_sim.ts +++ b/src/mcp/tools/simulator/open_sim.ts @@ -1,65 +1,49 @@ import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { createTypedTool, getHandlerContext } from '../../../utils/typed-tool-factory.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const openSimSchema = z.object({}); -// Use z.infer for type safety type OpenSimParams = z.infer; export async function open_simLogic( - params: OpenSimParams, + _params: OpenSimParams, executor: CommandExecutor, -): Promise { +): Promise { log('info', 'Starting open simulator request'); - try { - const command = ['open', '-a', 'Simulator']; - const result = await executor(command, 'Open Simulator', false); + const headerEvent = header('Open Simulator'); - if (!result.success) { - return { - content: [ - { - type: 'text', - text: `Open simulator operation failed: ${result.error}`, - }, - ], - }; - } + const ctx = getHandlerContext(); + + return withErrorHandling( + ctx, + async () => { + const command = ['open', '-a', 'Simulator']; + const result = await executor(command, 'Open Simulator', false); - return { - content: [ - { - type: 'text', - text: `Simulator app opened successfully.`, - }, - ], - nextStepParams: { + if (!result.success) { + ctx.emit(headerEvent); + ctx.emit(statusLine('error', `Open simulator operation failed: ${result.error}`)); + return; + } + + ctx.emit(headerEvent); + ctx.emit(statusLine('success', 'Simulator opened successfully')); + ctx.nextStepParams = { boot_sim: { simulatorId: 'UUID_FROM_LIST_SIMS' }, - start_sim_log_cap: [ - { simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' }, - { simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID', captureConsole: true }, - ], - launch_app_logs_sim: { simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' }, - }, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error during open simulator operation: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `Open simulator operation failed: ${errorMessage}`, - }, - ], - }; - } + }; + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Open simulator operation failed: ${message}`, + logMessage: ({ message }) => `Error during open simulator operation: ${message}`, + }, + ); } export const schema = openSimSchema.shape; diff --git a/src/mcp/tools/simulator/record_sim_video.ts b/src/mcp/tools/simulator/record_sim_video.ts index 9ec863a3..287e4d4c 100644 --- a/src/mcp/tools/simulator/record_sim_video.ts +++ b/src/mcp/tools/simulator/record_sim_video.ts @@ -1,6 +1,4 @@ import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; -import { createTextResponse } from '../../../utils/responses/index.ts'; import { getDefaultCommandExecutor, getDefaultFileSystemExecutor, @@ -9,7 +7,7 @@ import type { CommandExecutor, FileSystemExecutor } from '../../../utils/executi import { areAxeToolsAvailable, isAxeAtLeastVersion, - createAxeNotAvailableResponse, + AXE_NOT_AVAILABLE_MESSAGE, } from '../../../utils/axe/index.ts'; import { startSimulatorVideoCapture, @@ -18,8 +16,10 @@ import { import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; import { dirname } from 'path'; +import { header, statusLine, detailTree, section } from '../../../utils/tool-event-builders.ts'; // Base schema object (used for MCP schema exposure) const recordSimVideoSchemaObject = z.object({ @@ -59,11 +59,9 @@ export async function record_sim_videoLogic( axe: { areAxeToolsAvailable(): boolean; isAxeAtLeastVersion(v: string, e: CommandExecutor): Promise; - createAxeNotAvailableResponse(): ToolResponse; } = { areAxeToolsAvailable, isAxeAtLeastVersion, - createAxeNotAvailableResponse, }, video: { startSimulatorVideoCapture: typeof startSimulatorVideoCapture; @@ -73,17 +71,25 @@ export async function record_sim_videoLogic( stopSimulatorVideoCapture, }, fs: FileSystemExecutor = getDefaultFileSystemExecutor(), -): Promise { - // Preflight checks for AXe availability and version +): Promise { + const ctx = getHandlerContext(); + const headerEvent = header('Record Video', [{ label: 'Simulator', value: params.simulatorId }]); + if (!axe.areAxeToolsAvailable()) { - return axe.createAxeNotAvailableResponse(); + ctx.emit(headerEvent); + ctx.emit(statusLine('error', AXE_NOT_AVAILABLE_MESSAGE)); + return; } const hasVersion = await axe.isAxeAtLeastVersion('1.1.0', executor); if (!hasVersion) { - return createTextResponse( - 'AXe v1.1.0 or newer is required for simulator video capture. Please update bundled AXe artifacts.', - true, + ctx.emit(headerEvent); + ctx.emit( + statusLine( + 'error', + 'AXe v1.1.0 or newer is required for simulator video capture. Please update bundled AXe artifacts.', + ), ); + return; } if (params.start) { @@ -94,10 +100,9 @@ export async function record_sim_videoLogic( ); if (!startRes.started) { - return createTextResponse( - `Failed to start video recording: ${startRes.error ?? 'Unknown error'}`, - true, - ); + ctx.emit(headerEvent); + ctx.emit(statusLine('error', `Failed to start video recording: ${startRes.error}`)); + return; } const notes: string[] = []; @@ -110,30 +115,30 @@ export async function record_sim_videoLogic( notes.push(startRes.warning); } - return { - content: [ - { - type: 'text', - text: `Video recording started for simulator ${params.simulatorId} at ${fpsUsed} fps.\nSession: ${startRes.sessionId}`, - }, - ...(notes.length > 0 - ? [ - { - type: 'text' as const, - text: notes.join('\n'), - }, - ] - : []), - ], - nextStepParams: { - record_sim_video: { - simulatorId: params.simulatorId, - stop: true, - outputFile: '/path/to/output.mp4', - }, + ctx.emit(headerEvent); + ctx.emit( + detailTree([ + { label: 'FPS', value: String(fpsUsed) }, + { label: 'Session', value: startRes.sessionId }, + ]), + ); + if (notes.length > 0) { + ctx.emit(section('Notes', notes)); + } + ctx.emit( + statusLine( + 'success', + `Video recording started for simulator ${params.simulatorId} at ${fpsUsed} fps`, + ), + ); + ctx.nextStepParams = { + record_sim_video: { + simulatorId: params.simulatorId, + stop: true, + outputFile: '/path/to/output.mp4', }, - isError: false, }; + return; } // params.stop must be true here per schema @@ -143,22 +148,24 @@ export async function record_sim_videoLogic( ); if (!stopRes.stopped) { - return createTextResponse( - `Failed to stop video recording: ${stopRes.error ?? 'Unknown error'}`, - true, - ); + ctx.emit(headerEvent); + ctx.emit(statusLine('error', `Failed to stop video recording: ${stopRes.error}`)); + return; } - // Attempt to move/rename the recording if we parsed a source path and an outputFile was given const outputs: string[] = []; let finalSavedPath = params.outputFile ?? stopRes.parsedPath ?? ''; try { if (params.outputFile) { if (!stopRes.parsedPath) { - return createTextResponse( - `Recording stopped but could not determine the recorded file path from AXe output.\nRaw output:\n${stopRes.stdout ?? '(no output captured)'}`, - true, + ctx.emit(headerEvent); + ctx.emit( + statusLine( + 'error', + `Recording stopped but could not determine the recorded file path from AXe output. Raw output: ${stopRes.stdout ?? '(no output captured)'}`, + ), ); + return; } const src = stopRes.parsedPath; @@ -180,38 +187,20 @@ export async function record_sim_videoLogic( } } catch (e) { const msg = e instanceof Error ? e.message : String(e); - return createTextResponse( - `Recording stopped but failed to save/move the video file: ${msg}`, - true, + ctx.emit(headerEvent); + ctx.emit( + statusLine('error', `Recording stopped but failed to save/move the video file: ${msg}`), ); + return; } - return { - content: [ - { - type: 'text', - text: `✅ Video recording stopped for simulator ${params.simulatorId}.`, - }, - ...(outputs.length > 0 - ? [ - { - type: 'text' as const, - text: outputs.join('\n'), - }, - ] - : []), - ...(!outputs.length && stopRes.stdout - ? [ - { - type: 'text' as const, - text: `AXe output:\n${stopRes.stdout}`, - }, - ] - : []), - ], - isError: false, - _meta: finalSavedPath ? { outputFile: finalSavedPath } : undefined, - }; + ctx.emit(headerEvent); + if (outputs.length > 0) { + ctx.emit(section('Output', outputs)); + } else if (stopRes.stdout) { + ctx.emit(section('AXe Output', [stopRes.stdout])); + } + ctx.emit(statusLine('success', `Video recording stopped for simulator ${params.simulatorId}`)); } const publicSchemaObject = z.strictObject( diff --git a/src/mcp/tools/simulator/stop_app_sim.ts b/src/mcp/tools/simulator/stop_app_sim.ts index 1f7997a7..ff8efcb0 100644 --- a/src/mcp/tools/simulator/stop_app_sim.ts +++ b/src/mcp/tools/simulator/stop_app_sim.ts @@ -1,12 +1,14 @@ import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; const baseSchemaObject = z.object({ simulatorId: z @@ -24,7 +26,6 @@ const baseSchemaObject = z.object({ bundleId: z.string().describe('Bundle identifier of the app to stop'), }); -// Internal schema requires simulatorId (factory resolves simulatorName → simulatorId) const internalSchemaObject = z.object({ simulatorId: z.string(), simulatorName: z.string().optional(), @@ -36,7 +37,7 @@ export type StopAppSimParams = z.infer; export async function stop_app_simLogic( params: StopAppSimParams, executor: CommandExecutor, -): Promise { +): Promise { const simulatorId = params.simulatorId; const simulatorDisplayName = params.simulatorName ? `"${params.simulatorName}" (${simulatorId})` @@ -44,43 +45,34 @@ export async function stop_app_simLogic( log('info', `Stopping app ${params.bundleId} in simulator ${simulatorId}`); - try { - const command = ['xcrun', 'simctl', 'terminate', simulatorId, params.bundleId]; - const result = await executor(command, 'Stop App in Simulator', false, undefined); + const headerEvent = header('Stop App', [ + { label: 'Simulator', value: simulatorDisplayName }, + { label: 'Bundle ID', value: params.bundleId }, + ]); - if (!result.success) { - return { - content: [ - { - type: 'text', - text: `Stop app in simulator operation failed: ${result.error}`, - }, - ], - isError: true, - }; - } + const ctx = getHandlerContext(); - return { - content: [ - { - type: 'text', - text: `App ${params.bundleId} stopped successfully in simulator ${simulatorDisplayName}`, - }, - ], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error stopping app in simulator: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `Stop app in simulator operation failed: ${errorMessage}`, - }, - ], - isError: true, - }; - } + return withErrorHandling( + ctx, + async () => { + const command = ['xcrun', 'simctl', 'terminate', simulatorId, params.bundleId]; + const result = await executor(command, 'Stop App in Simulator', false); + + if (!result.success) { + ctx.emit(headerEvent); + ctx.emit(statusLine('error', `Stop app in simulator operation failed: ${result.error}`)); + return; + } + + ctx.emit(headerEvent); + ctx.emit(statusLine('success', 'App stopped successfully')); + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Stop app in simulator operation failed: ${message}`, + logMessage: ({ message }) => `Error stopping app in simulator: ${message}`, + }, + ); } const publicSchemaObject = z.strictObject( diff --git a/src/mcp/tools/simulator/test_sim.ts b/src/mcp/tools/simulator/test_sim.ts index e3c5ecf4..ae05d40d 100644 --- a/src/mcp/tools/simulator/test_sim.ts +++ b/src/mcp/tools/simulator/test_sim.ts @@ -9,17 +9,22 @@ import * as z from 'zod'; import { handleTestLogic } from '../../../utils/test/index.ts'; import { log } from '../../../utils/logging/index.ts'; -import type { ToolResponse } from '../../../types/common.ts'; -import type { CommandExecutor } from '../../../utils/execution/index.ts'; -import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; +import { + getDefaultCommandExecutor, + getDefaultFileSystemExecutor, +} from '../../../utils/execution/index.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; import { inferPlatform } from '../../../utils/infer-platform.ts'; +import { resolveTestPreflight } from '../../../utils/test-preflight.ts'; +import { resolveSimulatorIdOrName } from '../../../utils/simulator-resolver.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; -// Define base schema object with all fields const baseSchemaObject = z.object({ projectPath: z .string() @@ -56,9 +61,12 @@ const baseSchemaObject = z.object({ .describe( 'Environment variables to pass to the test runner (TEST_RUNNER_ prefix added automatically)', ), + progress: z + .boolean() + .optional() + .describe('Show detailed test progress output (MCP defaults to true, CLI defaults to false)'), }); -// Apply XOR validation: exactly one of projectPath OR workspacePath, and exactly one of simulatorId OR simulatorName required const testSimulatorSchema = z.preprocess( nullifyEmptyStrings, baseSchemaObject @@ -76,18 +84,17 @@ const testSimulatorSchema = z.preprocess( }), ); -// Use z.infer for type safety type TestSimulatorParams = z.infer; export async function test_simLogic( params: TestSimulatorParams, executor: CommandExecutor, -): Promise { - // Log warning if useLatestOS is provided with simulatorId + fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), +): Promise { if (params.simulatorId && params.useLatestOS !== undefined) { log( 'warn', - `useLatestOS parameter is ignored when using simulatorId (UUID implies exact device/OS)`, + 'useLatestOS parameter is ignored when using simulatorId (UUID implies exact device/OS)', ); } @@ -106,22 +113,53 @@ export async function test_simLogic( `Inferred simulator platform for tests: ${inferred.platform} (source: ${inferred.source})`, ); - return handleTestLogic( + const ctx = getHandlerContext(); + + const simulatorResolution = await resolveSimulatorIdOrName( + executor, + params.simulatorId, + params.simulatorName, + ); + if (!simulatorResolution.success) { + ctx.emit(header('Test Simulator')); + ctx.emit(statusLine('error', simulatorResolution.error)); + return; + } + + const destinationName = params.simulatorName ?? simulatorResolution.simulatorName; + const preflight = await resolveTestPreflight( { projectPath: params.projectPath, workspacePath: params.workspacePath, scheme: params.scheme, - simulatorId: params.simulatorId, + configuration: params.configuration ?? 'Debug', + extraArgs: params.extraArgs, + destinationName, + }, + fileSystemExecutor, + ); + + await handleTestLogic( + { + projectPath: params.projectPath, + workspacePath: params.workspacePath, + scheme: params.scheme, + simulatorId: simulatorResolution.simulatorId, simulatorName: params.simulatorName, configuration: params.configuration ?? 'Debug', derivedDataPath: params.derivedDataPath, extraArgs: params.extraArgs, - useLatestOS: params.simulatorId ? false : (params.useLatestOS ?? false), + useLatestOS: false, preferXcodebuild: params.preferXcodebuild ?? false, platform: inferred.platform, testRunnerEnv: params.testRunnerEnv, + progress: params.progress, }, executor, + { + preflight: preflight ?? undefined, + toolName: 'test_sim', + }, ); } @@ -144,7 +182,8 @@ export const schema = getSessionAwareToolSchemaShape({ export const handler = createSessionAwareTool({ internalSchema: testSimulatorSchema as unknown as z.ZodType, - logicFunction: test_simLogic, + logicFunction: (params, executor) => + test_simLogic(params, executor, getDefaultFileSystemExecutor()), getExecutor: getDefaultCommandExecutor, requirements: [ { allOf: ['scheme'], message: 'scheme is required' }, diff --git a/src/utils/__tests__/simulator-utils.test.ts b/src/utils/__tests__/simulator-utils.test.ts index b67f347e..da92b93c 100644 --- a/src/utils/__tests__/simulator-utils.test.ts +++ b/src/utils/__tests__/simulator-utils.test.ts @@ -120,7 +120,7 @@ describe('determineSimulatorUuid', () => { expect(result.uuid).toBeUndefined(); expect(result.error).toBeDefined(); - expect(result.error?.content[0].text).toContain('exists but is not available'); + expect(result.error).toContain('exists but is not available'); }); it('should error for non-existent simulator', async () => { @@ -133,7 +133,7 @@ describe('determineSimulatorUuid', () => { expect(result.uuid).toBeUndefined(); expect(result.error).toBeDefined(); - expect(result.error?.content[0].text).toContain('not found'); + expect(result.error).toContain('not found'); }); it('should handle simctl list failure', async () => { @@ -146,7 +146,7 @@ describe('determineSimulatorUuid', () => { expect(result.uuid).toBeUndefined(); expect(result.error).toBeDefined(); - expect(result.error?.content[0].text).toContain('Failed to list simulators'); + expect(result.error).toContain('Failed to list simulators'); }); it('should handle invalid JSON from simctl', async () => { @@ -159,7 +159,7 @@ describe('determineSimulatorUuid', () => { expect(result.uuid).toBeUndefined(); expect(result.error).toBeDefined(); - expect(result.error?.content[0].text).toContain('Failed to parse simulator list'); + expect(result.error).toContain('Failed to parse simulator list'); }); }); @@ -173,7 +173,7 @@ describe('determineSimulatorUuid', () => { expect(result.uuid).toBeUndefined(); expect(result.error).toBeDefined(); - expect(result.error?.content[0].text).toContain('No simulator identifier provided'); + expect(result.error).toContain('No simulator identifier provided'); }); }); }); diff --git a/src/utils/simulator-resolver.ts b/src/utils/simulator-resolver.ts index 80839336..f0b9b580 100644 --- a/src/utils/simulator-resolver.ts +++ b/src/utils/simulator-resolver.ts @@ -1,8 +1,3 @@ -/** - * Shared utility for resolving simulator names to UUIDs. - * Centralizes the lookup logic used across multiple tools. - */ - import type { CommandExecutor } from './execution/index.ts'; import { log } from './logger.ts'; @@ -10,19 +5,11 @@ export type SimulatorResolutionResult = | { success: true; simulatorId: string; simulatorName: string } | { success: false; error: string }; -/** - * Resolves a simulator name to its UUID by querying simctl. - * - * @param executor - Command executor for running simctl - * @param simulatorName - The human-readable simulator name (e.g., "iPhone 17") - * @returns Resolution result with simulatorId on success, or error message on failure - */ -export async function resolveSimulatorNameToId( - executor: CommandExecutor, - simulatorName: string, -): Promise { - log('info', `Looking up simulator by name: ${simulatorName}`); +type SimulatorDevice = { udid: string; name: string }; +async function fetchSimulatorDevices( + executor: CommandExecutor, +): Promise<{ devices: Record } | { error: string }> { const result = await executor( ['xcrun', 'simctl', 'list', 'devices', 'available', '--json'], 'List Simulators', @@ -30,33 +17,42 @@ export async function resolveSimulatorNameToId( ); if (!result.success) { - return { - success: false, - error: `Failed to list simulators: ${result.error}`, - }; + return { error: `Failed to list simulators: ${result.error}` }; } - let simulatorsData: { devices: Record> }; try { - simulatorsData = JSON.parse(result.output) as typeof simulatorsData; + return JSON.parse(result.output) as { devices: Record }; } catch (parseError) { - return { - success: false, - error: `Failed to parse simulator list: ${parseError}`, - }; + return { error: `Failed to parse simulator list: ${parseError}` }; } +} +function findSimulator( + simulatorsData: { devices: Record }, + predicate: (device: SimulatorDevice) => boolean, +): SimulatorDevice | undefined { for (const runtime in simulatorsData.devices) { - const devices = simulatorsData.devices[runtime]; - const simulator = devices.find((device) => device.name === simulatorName); - if (simulator) { - log('info', `Resolved simulator "${simulatorName}" to UUID: ${simulator.udid}`); - return { - success: true, - simulatorId: simulator.udid, - simulatorName: simulator.name, - }; - } + const found = simulatorsData.devices[runtime].find(predicate); + if (found) return found; + } + return undefined; +} + +/** + * Resolves a simulator name to its UUID by querying simctl. + */ +export async function resolveSimulatorNameToId( + executor: CommandExecutor, + simulatorName: string, +): Promise { + log('info', `Looking up simulator by name: ${simulatorName}`); + const data = await fetchSimulatorDevices(executor); + if ('error' in data) return { success: false, error: data.error }; + + const simulator = findSimulator(data, (d) => d.name === simulatorName); + if (simulator) { + log('info', `Resolved simulator "${simulatorName}" to UUID: ${simulator.udid}`); + return { success: true, simulatorId: simulator.udid, simulatorName: simulator.name }; } return { @@ -67,51 +63,19 @@ export async function resolveSimulatorNameToId( /** * Resolves a simulator UUID to its name by querying simctl. - * - * @param executor - Command executor for running simctl - * @param simulatorId - The simulator UUID - * @returns Resolution result with simulatorName on success, or error message on failure */ export async function resolveSimulatorIdToName( executor: CommandExecutor, simulatorId: string, ): Promise { log('info', `Looking up simulator by UUID: ${simulatorId}`); + const data = await fetchSimulatorDevices(executor); + if ('error' in data) return { success: false, error: data.error }; - const result = await executor( - ['xcrun', 'simctl', 'list', 'devices', 'available', '--json'], - 'List Simulators', - false, - ); - - if (!result.success) { - return { - success: false, - error: `Failed to list simulators: ${result.error}`, - }; - } - - let simulatorsData: { devices: Record> }; - try { - simulatorsData = JSON.parse(result.output) as typeof simulatorsData; - } catch (parseError) { - return { - success: false, - error: `Failed to parse simulator list: ${parseError}`, - }; - } - - for (const runtime in simulatorsData.devices) { - const devices = simulatorsData.devices[runtime]; - const simulator = devices.find((device) => device.udid === simulatorId); - if (simulator) { - log('info', `Resolved simulator UUID "${simulatorId}" to name: ${simulator.name}`); - return { - success: true, - simulatorId: simulator.udid, - simulatorName: simulator.name, - }; - } + const simulator = findSimulator(data, (d) => d.udid === simulatorId); + if (simulator) { + log('info', `Resolved simulator UUID "${simulatorId}" to name: ${simulator.name}`); + return { success: true, simulatorId: simulator.udid, simulatorName: simulator.name }; } return { @@ -121,14 +85,9 @@ export async function resolveSimulatorIdToName( } /** - * Helper to resolve simulatorId from either simulatorId or simulatorName. + * Resolves a simulator from either simulatorId or simulatorName. * If simulatorId is provided, returns it directly. - * If only simulatorName is provided, resolves it to simulatorId. - * - * @param executor - Command executor for running simctl - * @param simulatorId - Optional simulator UUID - * @param simulatorName - Optional simulator name - * @returns Resolution result with simulatorId, or error if neither provided or lookup fails + * If only simulatorName is provided, resolves it via simctl. */ export async function resolveSimulatorIdOrName( executor: CommandExecutor, diff --git a/src/utils/simulator-utils.ts b/src/utils/simulator-utils.ts index 547cebf2..f553abf4 100644 --- a/src/utils/simulator-utils.ts +++ b/src/utils/simulator-utils.ts @@ -1,44 +1,57 @@ -/** - * Simulator utility functions for name to UUID resolution - */ - import type { CommandExecutor } from './execution/index.ts'; -import type { ToolResponse } from '../types/common.ts'; import { log } from './logging/index.ts'; -import { createErrorResponse } from './responses/index.ts'; -/** - * UUID regex pattern to check if a string looks like a UUID - */ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -/** - * Determines the simulator UUID from either a UUID or name. - * - * Behavior: - * - If simulatorUuid provided: return it directly - * - Else if simulatorName looks like a UUID (regex): treat it as UUID and return it - * - Else: resolve name → UUID via simctl and return the match (isAvailable === true) - * - * @param params Object containing optional simulatorUuid or simulatorName - * @param executor Command executor for running simctl commands - * @returns Object with uuid, optional warning, or error - */ +export async function validateAvailableSimulatorId( + simulatorId: string, + executor: CommandExecutor, +): Promise<{ error?: string }> { + const listResult = await executor( + ['xcrun', 'simctl', 'list', 'devices', 'available', '-j'], + 'List available simulators', + ); + + if (!listResult.success) { + return { + error: `Failed to list simulators: ${listResult.error ?? 'Unknown error'}`, + }; + } + + try { + const devicesData = JSON.parse(listResult.output ?? '{}') as { + devices: Record>; + }; + const matchedDevice = Object.values(devicesData.devices) + .flat() + .find((device) => device.udid === simulatorId && device.isAvailable === true); + + if (matchedDevice) { + return {}; + } + + return { + error: `No available simulator matched: ${simulatorId}. Tip: run "xcrun simctl list devices available" to see names and UDIDs.`, + }; + } catch (parseError) { + return { + error: `Failed to parse simulator list: ${parseError instanceof Error ? parseError.message : String(parseError)}`, + }; + } +} + export async function determineSimulatorUuid( params: { simulatorUuid?: string; simulatorId?: string; simulatorName?: string }, executor: CommandExecutor, -): Promise<{ uuid?: string; warning?: string; error?: ToolResponse }> { +): Promise<{ uuid?: string; warning?: string; error?: string }> { const directUuid = params.simulatorUuid ?? params.simulatorId; - // If UUID is provided directly, use it if (directUuid) { log('info', `Using provided simulator UUID: ${directUuid}`); return { uuid: directUuid }; } - // If name is provided, check if it's actually a UUID if (params.simulatorName) { - // Check if the "name" is actually a UUID string if (UUID_REGEX.test(params.simulatorName)) { log( 'info', @@ -50,7 +63,6 @@ export async function determineSimulatorUuid( }; } - // Resolve name to UUID via simctl log('info', `Looking up simulator UUID for name: ${params.simulatorName}`); const listResult = await executor( @@ -60,10 +72,7 @@ export async function determineSimulatorUuid( if (!listResult.success) { return { - error: createErrorResponse( - 'Failed to list simulators', - listResult.error ?? 'Unknown error', - ), + error: `Failed to list simulators: ${listResult.error ?? 'Unknown error'}`, }; } @@ -80,63 +89,33 @@ export async function determineSimulatorUuid( const devicesData = JSON.parse(listResult.output ?? '{}') as DevicesData; - // Search through all runtime sections for the named device - for (const runtime of Object.keys(devicesData.devices)) { - const devices = devicesData.devices[runtime]; - if (!Array.isArray(devices)) continue; + const allDevices = Object.values(devicesData.devices).filter(Array.isArray).flat(); - // Look for exact name match with isAvailable === true - const device = devices.find( - (d) => d.name === params.simulatorName && d.isAvailable === true, - ); + const namedDevices = allDevices.filter((d) => d.name === params.simulatorName); - if (device) { - log('info', `Found simulator '${params.simulatorName}' with UUID: ${device.udid}`); - return { uuid: device.udid }; - } + const availableDevice = namedDevices.find((d) => d.isAvailable); + if (availableDevice) { + log('info', `Found simulator '${params.simulatorName}' with UUID: ${availableDevice.udid}`); + return { uuid: availableDevice.udid }; } - // If no available device found, check if device exists but is unavailable - for (const runtime of Object.keys(devicesData.devices)) { - const devices = devicesData.devices[runtime]; - if (!Array.isArray(devices)) continue; - - const unavailableDevice = devices.find( - (d) => d.name === params.simulatorName && d.isAvailable === false, - ); - - if (unavailableDevice) { - return { - error: createErrorResponse( - `Simulator '${params.simulatorName}' exists but is not available`, - 'The simulator may need to be downloaded or is incompatible with the current Xcode version', - ), - }; - } + if (namedDevices.length > 0) { + return { + error: `Simulator '${params.simulatorName}' exists but is not available. The simulator may need to be downloaded or is incompatible with the current Xcode version`, + }; } - // Device not found at all return { - error: createErrorResponse( - `Simulator '${params.simulatorName}' not found`, - 'Please check the simulator name or use "xcrun simctl list devices" to see available simulators', - ), + error: `Simulator '${params.simulatorName}' not found. Please check the simulator name or use "xcrun simctl list devices" to see available simulators`, }; } catch (parseError) { return { - error: createErrorResponse( - 'Failed to parse simulator list', - parseError instanceof Error ? parseError.message : String(parseError), - ), + error: `Failed to parse simulator list: ${parseError instanceof Error ? parseError.message : String(parseError)}`, }; } } - // Neither UUID nor name provided return { - error: createErrorResponse( - 'No simulator identifier provided', - 'Either simulatorUuid or simulatorName is required', - ), + error: 'No simulator identifier provided. Either simulatorUuid or simulatorName is required', }; } From c0f43c619999742c5226bdf04ee750975cfc3614 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 9 Apr 2026 08:44:43 +0100 Subject: [PATCH 2/4] fix: pass simulatorId to inferPlatform in build_run_sim The simulatorId parameter was not being passed to inferPlatform, causing platform detection to fall back to scheme-based inference even when a specific simulator UUID was provided. This broke non-iOS simulator detection (e.g. watchOS, tvOS) when specified by UUID. --- src/mcp/tools/simulator/build_run_sim.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/mcp/tools/simulator/build_run_sim.ts b/src/mcp/tools/simulator/build_run_sim.ts index 8da3bcff..66d0bdac 100644 --- a/src/mcp/tools/simulator/build_run_sim.ts +++ b/src/mcp/tools/simulator/build_run_sim.ts @@ -129,7 +129,8 @@ export async function build_run_simLogic( projectPath: params.projectPath, workspacePath: params.workspacePath, scheme: params.scheme, - simulatorName: params.simulatorId ? undefined : params.simulatorName, + simulatorId: params.simulatorId, + simulatorName: params.simulatorName, }, executor, ); From b93f757ad8ff5793d055334df0a3a2769c28a2cf Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 9 Apr 2026 08:47:34 +0100 Subject: [PATCH 3/4] refactor: use shared runLogic helper in simulator test files Replace 12 identical local runLogic definitions with the shared import from test-helpers.ts. --- .../__tests__/erase_sims.test.ts | 34 +----------------- .../__tests__/reset_sim_location.test.ts | 36 ++----------------- .../__tests__/set_sim_appearance.test.ts | 36 ++----------------- .../__tests__/set_sim_location.test.ts | 36 ++----------------- .../__tests__/sim_statusbar.test.ts | 36 ++----------------- .../simulator/__tests__/boot_sim.test.ts | 36 ++----------------- .../__tests__/get_sim_app_path.test.ts | 36 ++----------------- .../__tests__/install_app_sim.test.ts | 36 ++----------------- .../__tests__/launch_app_sim.test.ts | 36 ++----------------- .../simulator/__tests__/open_sim.test.ts | 36 ++----------------- .../simulator/__tests__/screenshot.test.ts | 36 ++----------------- .../simulator/__tests__/stop_app_sim.test.ts | 36 ++----------------- 12 files changed, 23 insertions(+), 407 deletions(-) diff --git a/src/mcp/tools/simulator-management/__tests__/erase_sims.test.ts b/src/mcp/tools/simulator-management/__tests__/erase_sims.test.ts index af06b71e..20aac033 100644 --- a/src/mcp/tools/simulator-management/__tests__/erase_sims.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/erase_sims.test.ts @@ -1,40 +1,8 @@ import { describe, it, expect } from 'vitest'; import { schema, erase_simsLogic } from '../erase_sims.ts'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; -import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; -const runLogic = async (logic: () => Promise) => { - const { result, run } = createMockToolHandlerContext(); - const response = await run(logic); - - if ( - response && - typeof response === 'object' && - 'content' in (response as Record) - ) { - return response as { - content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; - isError?: boolean; - nextStepParams?: unknown; - }; - } - - const text = result.text(); - const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; - const imageContent = result.attachments.map((attachment) => ({ - type: 'image' as const, - data: attachment.data, - mimeType: attachment.mimeType, - })); - - return { - content: [...textContent, ...imageContent], - isError: result.isError() ? true : undefined, - nextStepParams: result.nextStepParams, - attachments: result.attachments, - text, - }; -}; describe('erase_sims tool (single simulator)', () => { describe('Plugin Structure', () => { diff --git a/src/mcp/tools/simulator-management/__tests__/reset_sim_location.test.ts b/src/mcp/tools/simulator-management/__tests__/reset_sim_location.test.ts index 125954f2..4e9870fb 100644 --- a/src/mcp/tools/simulator-management/__tests__/reset_sim_location.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/reset_sim_location.test.ts @@ -2,40 +2,8 @@ import { describe, it, expect } from 'vitest'; import * as z from 'zod'; import { schema, reset_sim_locationLogic } from '../reset_sim_location.ts'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; -import { createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; - -const runLogic = async (logic: () => Promise) => { - const { result, run } = createMockToolHandlerContext(); - const response = await run(logic); - - if ( - response && - typeof response === 'object' && - 'content' in (response as Record) - ) { - return response as { - content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; - isError?: boolean; - nextStepParams?: unknown; - }; - } - - const text = result.text(); - const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; - const imageContent = result.attachments.map((attachment) => ({ - type: 'image' as const, - data: attachment.data, - mimeType: attachment.mimeType, - })); - - return { - content: [...textContent, ...imageContent], - isError: result.isError() ? true : undefined, - nextStepParams: result.nextStepParams, - attachments: result.attachments, - text, - }; -}; +import { runLogic } from '../../../../test-utils/test-helpers.ts'; + describe('reset_sim_location plugin', () => { describe('Schema Validation', () => { diff --git a/src/mcp/tools/simulator-management/__tests__/set_sim_appearance.test.ts b/src/mcp/tools/simulator-management/__tests__/set_sim_appearance.test.ts index 2d612a9b..8bae4041 100644 --- a/src/mcp/tools/simulator-management/__tests__/set_sim_appearance.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/set_sim_appearance.test.ts @@ -1,40 +1,8 @@ import { describe, it, expect } from 'vitest'; import * as z from 'zod'; import { schema, handler, set_sim_appearanceLogic } from '../set_sim_appearance.ts'; -import { createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; - -const runLogic = async (logic: () => Promise) => { - const { result, run } = createMockToolHandlerContext(); - const response = await run(logic); - - if ( - response && - typeof response === 'object' && - 'content' in (response as Record) - ) { - return response as { - content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; - isError?: boolean; - nextStepParams?: unknown; - }; - } - - const text = result.text(); - const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; - const imageContent = result.attachments.map((attachment) => ({ - type: 'image' as const, - data: attachment.data, - mimeType: attachment.mimeType, - })); - - return { - content: [...textContent, ...imageContent], - isError: result.isError() ? true : undefined, - nextStepParams: result.nextStepParams, - attachments: result.attachments, - text, - }; -}; +import { runLogic } from '../../../../test-utils/test-helpers.ts'; + import { createMockCommandResponse, diff --git a/src/mcp/tools/simulator-management/__tests__/set_sim_location.test.ts b/src/mcp/tools/simulator-management/__tests__/set_sim_location.test.ts index de07cfbb..4bddb112 100644 --- a/src/mcp/tools/simulator-management/__tests__/set_sim_location.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/set_sim_location.test.ts @@ -6,40 +6,8 @@ import { createNoopExecutor, } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, set_sim_locationLogic } from '../set_sim_location.ts'; -import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; - -const runLogic = async (logic: () => Promise) => { - const { result, run } = createMockToolHandlerContext(); - const response = await run(logic); - - if ( - response && - typeof response === 'object' && - 'content' in (response as Record) - ) { - return response as { - content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; - isError?: boolean; - nextStepParams?: unknown; - }; - } - - const text = result.text(); - const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; - const imageContent = result.attachments.map((attachment) => ({ - type: 'image' as const, - data: attachment.data, - mimeType: attachment.mimeType, - })); - - return { - content: [...textContent, ...imageContent], - isError: result.isError() ? true : undefined, - nextStepParams: result.nextStepParams, - attachments: result.attachments, - text, - }; -}; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; + describe('set_sim_location tool', () => { describe('Export Field Validation (Literal)', () => { diff --git a/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts b/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts index 77877ed0..9241a7f7 100644 --- a/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts @@ -6,40 +6,8 @@ import { type CommandExecutor, } from '../../../../test-utils/mock-executors.ts'; import { schema, sim_statusbarLogic } from '../sim_statusbar.ts'; -import { createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; - -const runLogic = async (logic: () => Promise) => { - const { result, run } = createMockToolHandlerContext(); - const response = await run(logic); - - if ( - response && - typeof response === 'object' && - 'content' in (response as Record) - ) { - return response as { - content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; - isError?: boolean; - nextStepParams?: unknown; - }; - } - - const text = result.text(); - const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; - const imageContent = result.attachments.map((attachment) => ({ - type: 'image' as const, - data: attachment.data, - mimeType: attachment.mimeType, - })); - - return { - content: [...textContent, ...imageContent], - isError: result.isError() ? true : undefined, - nextStepParams: result.nextStepParams, - attachments: result.attachments, - text, - }; -}; +import { runLogic } from '../../../../test-utils/test-helpers.ts'; + describe('sim_statusbar tool', () => { describe('Schema Validation', () => { diff --git a/src/mcp/tools/simulator/__tests__/boot_sim.test.ts b/src/mcp/tools/simulator/__tests__/boot_sim.test.ts index 914c7e35..ddbfc065 100644 --- a/src/mcp/tools/simulator/__tests__/boot_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/boot_sim.test.ts @@ -6,40 +6,8 @@ import { } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, boot_simLogic } from '../boot_sim.ts'; -import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; - -const runLogic = async (logic: () => Promise) => { - const { result, run } = createMockToolHandlerContext(); - const response = await run(logic); - - if ( - response && - typeof response === 'object' && - 'content' in (response as Record) - ) { - return response as { - content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; - isError?: boolean; - nextStepParams?: unknown; - }; - } - - const text = result.text(); - const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; - const imageContent = result.attachments.map((attachment) => ({ - type: 'image' as const, - data: attachment.data, - mimeType: attachment.mimeType, - })); - - return { - content: [...textContent, ...imageContent], - isError: result.isError() ? true : undefined, - nextStepParams: result.nextStepParams, - attachments: result.attachments, - text, - }; -}; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; + describe('boot_sim tool', () => { beforeEach(() => { diff --git a/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts b/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts index 829a49c0..e62f7ed9 100644 --- a/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts +++ b/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts @@ -7,40 +7,8 @@ import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, get_sim_app_pathLogic } from '../get_sim_app_path.ts'; import type { CommandExecutor } from '../../../../utils/CommandExecutor.ts'; import { XcodePlatform } from '../../../../types/common.ts'; -import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; - -const runLogic = async (logic: () => Promise) => { - const { result, run } = createMockToolHandlerContext(); - const response = await run(logic); - - if ( - response && - typeof response === 'object' && - 'content' in (response as Record) - ) { - return response as { - content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; - isError?: boolean; - nextStepParams?: unknown; - }; - } - - const text = result.text(); - const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; - const imageContent = result.attachments.map((attachment) => ({ - type: 'image' as const, - data: attachment.data, - mimeType: attachment.mimeType, - })); - - return { - content: [...textContent, ...imageContent], - isError: result.isError() ? true : undefined, - nextStepParams: result.nextStepParams, - attachments: result.attachments, - text, - }; -}; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; + describe('get_sim_app_path tool', () => { beforeEach(() => { diff --git a/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts b/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts index 9671fd09..af27caed 100644 --- a/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts @@ -9,40 +9,8 @@ import { import { sessionStore } from '../../../../utils/session-store.ts'; import type { CommandExecutor } from '../../../../utils/execution/index.ts'; import { schema, handler, install_app_simLogic } from '../install_app_sim.ts'; -import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; - -const runLogic = async (logic: () => Promise) => { - const { result, run } = createMockToolHandlerContext(); - const response = await run(logic); - - if ( - response && - typeof response === 'object' && - 'content' in (response as Record) - ) { - return response as { - content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; - isError?: boolean; - nextStepParams?: unknown; - }; - } - - const text = result.text(); - const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; - const imageContent = result.attachments.map((attachment) => ({ - type: 'image' as const, - data: attachment.data, - mimeType: attachment.mimeType, - })); - - return { - content: [...textContent, ...imageContent], - isError: result.isError() ? true : undefined, - nextStepParams: result.nextStepParams, - attachments: result.attachments, - text, - }; -}; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; + describe('install_app_sim tool', () => { beforeEach(() => { diff --git a/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts b/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts index 7e65626c..cdbcfc52 100644 --- a/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts @@ -4,40 +4,8 @@ import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, launch_app_simLogic, type SimulatorLauncher } from '../launch_app_sim.ts'; import type { LaunchWithLoggingResult } from '../../../../utils/simulator-steps.ts'; -import { createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; - -const runLogic = async (logic: () => Promise) => { - const { result, run } = createMockToolHandlerContext(); - const response = await run(logic); - - if ( - response && - typeof response === 'object' && - 'content' in (response as Record) - ) { - return response as { - content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; - isError?: boolean; - nextStepParams?: unknown; - }; - } - - const text = result.text(); - const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; - const imageContent = result.attachments.map((attachment) => ({ - type: 'image' as const, - data: attachment.data, - mimeType: attachment.mimeType, - })); - - return { - content: [...textContent, ...imageContent], - isError: result.isError() ? true : undefined, - nextStepParams: result.nextStepParams, - attachments: result.attachments, - text, - }; -}; +import { runLogic } from '../../../../test-utils/test-helpers.ts'; + function createMockLauncher(overrides?: Partial): SimulatorLauncher { return async (_uuid, _bundleId, _opts?) => ({ diff --git a/src/mcp/tools/simulator/__tests__/open_sim.test.ts b/src/mcp/tools/simulator/__tests__/open_sim.test.ts index 13321b71..516c1f91 100644 --- a/src/mcp/tools/simulator/__tests__/open_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/open_sim.test.ts @@ -6,40 +6,8 @@ import { type CommandExecutor, } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, open_simLogic } from '../open_sim.ts'; -import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; - -const runLogic = async (logic: () => Promise) => { - const { result, run } = createMockToolHandlerContext(); - const response = await run(logic); - - if ( - response && - typeof response === 'object' && - 'content' in (response as Record) - ) { - return response as { - content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; - isError?: boolean; - nextStepParams?: unknown; - }; - } - - const text = result.text(); - const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; - const imageContent = result.attachments.map((attachment) => ({ - type: 'image' as const, - data: attachment.data, - mimeType: attachment.mimeType, - })); - - return { - content: [...textContent, ...imageContent], - isError: result.isError() ? true : undefined, - nextStepParams: result.nextStepParams, - attachments: result.attachments, - text, - }; -}; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; + describe('open_sim tool', () => { describe('Export Field Validation (Literal)', () => { diff --git a/src/mcp/tools/simulator/__tests__/screenshot.test.ts b/src/mcp/tools/simulator/__tests__/screenshot.test.ts index 2d098e30..52e992e7 100644 --- a/src/mcp/tools/simulator/__tests__/screenshot.test.ts +++ b/src/mcp/tools/simulator/__tests__/screenshot.test.ts @@ -10,40 +10,8 @@ import type { CommandExecutor } from '../../../../utils/execution/index.ts'; import { SystemError } from '../../../../utils/errors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, screenshotLogic } from '../../ui-automation/screenshot.ts'; -import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; - -const runLogic = async (logic: () => Promise) => { - const { result, run } = createMockToolHandlerContext(); - const response = await run(logic); - - if ( - response && - typeof response === 'object' && - 'content' in (response as Record) - ) { - return response as { - content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; - isError?: boolean; - nextStepParams?: unknown; - }; - } - - const text = result.text(); - const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; - const imageContent = result.attachments.map((attachment) => ({ - type: 'image' as const, - data: attachment.data, - mimeType: attachment.mimeType, - })); - - return { - content: [...textContent, ...imageContent], - isError: result.isError() ? true : undefined, - nextStepParams: result.nextStepParams, - attachments: result.attachments, - text, - }; -}; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; + describe('screenshot plugin', () => { beforeEach(() => { diff --git a/src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts b/src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts index 0246e9ba..9b9ddf9d 100644 --- a/src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts @@ -7,40 +7,8 @@ import { import type { CommandExecutor } from '../../../../utils/execution/index.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, stop_app_simLogic } from '../stop_app_sim.ts'; -import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; - -const runLogic = async (logic: () => Promise) => { - const { result, run } = createMockToolHandlerContext(); - const response = await run(logic); - - if ( - response && - typeof response === 'object' && - 'content' in (response as Record) - ) { - return response as { - content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; - isError?: boolean; - nextStepParams?: unknown; - }; - } - - const text = result.text(); - const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; - const imageContent = result.attachments.map((attachment) => ({ - type: 'image' as const, - data: attachment.data, - mimeType: attachment.mimeType, - })); - - return { - content: [...textContent, ...imageContent], - isError: result.isError() ? true : undefined, - nextStepParams: result.nextStepParams, - attachments: result.attachments, - text, - }; -}; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; + describe('stop_app_sim tool', () => { beforeEach(() => { From 465a59535abb1111f542a5dccc0618c69837b67b Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 9 Apr 2026 23:15:23 +0100 Subject: [PATCH 4/4] fix: export AXE_NOT_AVAILABLE_MESSAGE from axe barrel --- src/utils/axe/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/utils/axe/index.ts b/src/utils/axe/index.ts index b53b8b66..ee26b0c3 100644 --- a/src/utils/axe/index.ts +++ b/src/utils/axe/index.ts @@ -1,5 +1,6 @@ export { createAxeNotAvailableResponse, + AXE_NOT_AVAILABLE_MESSAGE, getAxePath, getBundledAxeEnvironment, areAxeToolsAvailable,