diff --git a/docs/RELOADEROO_FOR_XCODEBUILDMCP.md b/docs/RELOADEROO_FOR_XCODEBUILDMCP.md index 51ace00e..f3ad0776 100644 --- a/docs/RELOADEROO_FOR_XCODEBUILDMCP.md +++ b/docs/RELOADEROO_FOR_XCODEBUILDMCP.md @@ -60,7 +60,7 @@ npx reloaderoo@latest --help - **`boot_sim`**: Boots a simulator. ```bash - npx reloaderoo@latest inspect call-tool boot_sim --params '{"simulatorUuid": "SIMULATOR_UUID"}' -- node build/index.js + npx reloaderoo@latest inspect call-tool boot_sim --params '{"simulatorId": "SIMULATOR_UUID"}' -- node build/index.js ``` - **`build_run_sim`**: Builds and runs an app on a simulator. ```bash @@ -76,11 +76,11 @@ npx reloaderoo@latest --help ``` - **`install_app_sim`**: Installs an app on a simulator. ```bash - npx reloaderoo@latest inspect call-tool install_app_sim --params '{"simulatorUuid": "SIMULATOR_UUID", "appPath": "/path/to/MyApp.app"}' -- node build/index.js + npx reloaderoo@latest inspect call-tool install_app_sim --params '{"simulatorId": "SIMULATOR_UUID", "appPath": "/path/to/MyApp.app"}' -- node build/index.js ``` - **`launch_app_logs_sim`**: Launches an app on a simulator with log capture. ```bash - npx reloaderoo@latest inspect call-tool launch_app_logs_sim --params '{"simulatorUuid": "SIMULATOR_UUID", "bundleId": "com.example.MyApp"}' -- node build/index.js + npx reloaderoo@latest inspect call-tool launch_app_logs_sim --params '{"simulatorId": "SIMULATOR_UUID", "bundleId": "com.example.MyApp"}' -- node build/index.js ``` - **`launch_app_sim`**: Launches an app on a simulator. ```bash diff --git a/docs/session-aware-migration-todo.md b/docs/session-aware-migration-todo.md index 4edd090e..4f49e753 100644 --- a/docs/session-aware-migration-todo.md +++ b/docs/session-aware-migration-todo.md @@ -33,12 +33,12 @@ Reference: `docs/session_management_plan.md` - [x] `src/mcp/tools/simulator/get_sim_app_path.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `simulatorId`, `simulatorName`, `configuration`, `useLatestOS`, `arch`. ## Simulator Runtime Actions -- [ ] `src/mcp/tools/simulator/boot_sim.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). -- [ ] `src/mcp/tools/simulator/install_app_sim.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). -- [ ] `src/mcp/tools/simulator/launch_app_sim.ts` — session defaults: `simulatorId`, `simulatorName` (hydrate `simulatorUuid`). -- [ ] `src/mcp/tools/simulator/launch_app_logs_sim.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). -- [ ] `src/mcp/tools/simulator/stop_app_sim.ts` — session defaults: `simulatorId`, `simulatorName` (hydrate `simulatorUuid`). -- [ ] `src/mcp/tools/simulator/record_sim_video.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [x] `src/mcp/tools/simulator/boot_sim.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [x] `src/mcp/tools/simulator/install_app_sim.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [x] `src/mcp/tools/simulator/launch_app_sim.ts` — session defaults: `simulatorId`, `simulatorName` (hydrate `simulatorUuid`). +- [x] `src/mcp/tools/simulator/launch_app_logs_sim.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [x] `src/mcp/tools/simulator/stop_app_sim.ts` — session defaults: `simulatorId`, `simulatorName` (hydrate `simulatorUuid`). +- [x] `src/mcp/tools/simulator/record_sim_video.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). ## Simulator Management - [ ] `src/mcp/tools/simulator-management/erase_sims.ts` — session defaults: `simulatorId` (covers `simulatorUdid`). diff --git a/src/mcp/tools/simulator/__tests__/boot_sim.test.ts b/src/mcp/tools/simulator/__tests__/boot_sim.test.ts index 4f258031..1fe630ed 100644 --- a/src/mcp/tools/simulator/__tests__/boot_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/boot_sim.test.ts @@ -1,57 +1,54 @@ /** - * Tests for boot_sim plugin - * Following CLAUDE.md testing standards with literal validation - * Using dependency injection for deterministic testing + * Tests for boot_sim plugin (session-aware version) + * Follows CLAUDE.md guidance: dependency injection, no vi-mocks, literal validation. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { z } from 'zod'; -import { - createMockExecutor, - createMockFileSystemExecutor, - createNoopExecutor, -} from '../../../../test-utils/mock-executors.ts'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; import bootSim, { boot_simLogic } from '../boot_sim.ts'; describe('boot_sim tool', () => { + beforeEach(() => { + sessionStore.clear(); + }); + describe('Export Field Validation (Literal)', () => { it('should have correct name', () => { expect(bootSim.name).toBe('boot_sim'); }); - it('should have correct description', () => { - expect(bootSim.description).toBe( - "Boots an iOS simulator. After booting, use open_sim() to make the simulator visible. IMPORTANT: You MUST provide the simulatorUuid parameter. Example: boot_sim({ simulatorUuid: 'YOUR_UUID_HERE' })", - ); - }); - - it('should have handler function', () => { - expect(typeof bootSim.handler).toBe('function'); + it('should have concise description', () => { + expect(bootSim.description).toBe('Boots an iOS simulator.'); }); - it('should have correct schema with simulatorUuid string field', () => { + it('should expose empty public schema', () => { const schema = z.object(bootSim.schema); + expect(schema.safeParse({}).success).toBe(true); + expect(Object.keys(bootSim.schema)).toHaveLength(0); + }); + }); - // Valid inputs - expect(schema.safeParse({ simulatorUuid: 'test-uuid-123' }).success).toBe(true); - expect(schema.safeParse({ simulatorUuid: 'ABC123-DEF456' }).success).toBe(true); + describe('Handler Requirements', () => { + it('should require simulatorId when not provided', async () => { + const result = await bootSim.handler({}); - // Invalid inputs - expect(schema.safeParse({ simulatorUuid: 123 }).success).toBe(false); - expect(schema.safeParse({ simulatorUuid: null }).success).toBe(false); - expect(schema.safeParse({ simulatorUuid: undefined }).success).toBe(false); - expect(schema.safeParse({}).success).toBe(false); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('simulatorId is required'); + expect(result.content[0].text).toContain('session-set-defaults'); }); }); - describe('Handler Behavior (Complete Literal Returns)', () => { + describe('Logic Behavior (Literal Results)', () => { it('should handle successful boot', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'Simulator booted successfully', }); - const result = await boot_simLogic({ simulatorUuid: 'test-uuid-123' }, mockExecutor); + const result = await boot_simLogic({ simulatorId: 'test-uuid-123' }, mockExecutor); expect(result).toEqual({ content: [ @@ -61,24 +58,10 @@ describe('boot_sim tool', () => { Next steps: 1. Open the Simulator app (makes it visible): open_sim() -2. Install an app: install_app_sim({ simulatorUuid: "test-uuid-123", appPath: "PATH_TO_YOUR_APP" }) -3. Launch an app: launch_app_sim({ simulatorUuid: "test-uuid-123", bundleId: "YOUR_APP_BUNDLE_ID" })`, - }, - ], - }); - }); - - it('should handle validation failure via handler', async () => { - const result = await bootSim.handler({ simulatorUuid: undefined }); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nsimulatorUuid: Required', +2. Install an app: install_app_sim({ simulatorId: "test-uuid-123", appPath: "PATH_TO_YOUR_APP" }) +3. Launch an app: launch_app_sim({ simulatorId: "test-uuid-123", bundleId: "YOUR_APP_BUNDLE_ID" })`, }, ], - isError: true, }); }); @@ -88,7 +71,7 @@ Next steps: error: 'Simulator not found', }); - const result = await boot_simLogic({ simulatorUuid: 'invalid-uuid' }, mockExecutor); + const result = await boot_simLogic({ simulatorId: 'invalid-uuid' }, mockExecutor); expect(result).toEqual({ content: [ @@ -105,7 +88,7 @@ Next steps: throw new Error('Connection failed'); }; - const result = await boot_simLogic({ simulatorUuid: 'test-uuid-123' }, mockExecutor); + const result = await boot_simLogic({ simulatorId: 'test-uuid-123' }, mockExecutor); expect(result).toEqual({ content: [ @@ -122,7 +105,7 @@ Next steps: throw 'String error'; }; - const result = await boot_simLogic({ simulatorUuid: 'test-uuid-123' }, mockExecutor); + const result = await boot_simLogic({ simulatorId: 'test-uuid-123' }, mockExecutor); expect(result).toEqual({ content: [ @@ -135,7 +118,12 @@ Next steps: }); it('should verify command generation with mock executor', async () => { - const calls: any[] = []; + const calls: Array<{ + command: string[]; + description: string; + allowStderr: boolean; + timeout?: number; + }> = []; const mockExecutor = async ( command: string[], description: string, @@ -151,7 +139,7 @@ Next steps: }; }; - await boot_simLogic({ simulatorUuid: 'test-uuid-123' }, mockExecutor); + await boot_simLogic({ simulatorId: 'test-uuid-123' }, mockExecutor); expect(calls).toHaveLength(1); expect(calls[0]).toEqual({ 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 173ec986..5d49f757 100644 --- a/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts @@ -1,81 +1,66 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { z } from 'zod'; import { createMockExecutor, createMockFileSystemExecutor, createNoopExecutor, } from '../../../../test-utils/mock-executors.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; import installAppSim, { install_app_simLogic } from '../install_app_sim.ts'; describe('install_app_sim tool', () => { + beforeEach(() => { + sessionStore.clear(); + }); + describe('Export Field Validation (Literal)', () => { it('should have correct name', () => { expect(installAppSim.name).toBe('install_app_sim'); }); - it('should have correct description', () => { - expect(installAppSim.description).toBe( - "Installs an app in an iOS simulator. IMPORTANT: You MUST provide both the simulatorUuid and appPath parameters. Example: install_app_sim({ simulatorUuid: 'YOUR_UUID_HERE', appPath: '/path/to/your/app.app' })", - ); + it('should have concise description', () => { + expect(installAppSim.description).toBe('Installs an app in an iOS simulator.'); }); - it('should have handler function', () => { - expect(typeof installAppSim.handler).toBe('function'); + it('should expose public schema with only appPath', () => { + const schema = z.object(installAppSim.schema); + + expect(schema.safeParse({ appPath: '/path/to/app.app' }).success).toBe(true); + expect(schema.safeParse({ appPath: 42 }).success).toBe(false); + expect(schema.safeParse({}).success).toBe(false); + + expect(Object.keys(installAppSim.schema)).toEqual(['appPath']); }); + }); - it('should have correct schema with simulatorUuid and appPath string fields', () => { - const schema = z.object(installAppSim.schema); + describe('Handler Requirements', () => { + it('should require simulatorId when not provided', async () => { + const result = await installAppSim.handler({ appPath: '/path/to/app.app' }); - // Valid inputs - expect( - schema.safeParse({ - simulatorUuid: 'test-uuid-123', - appPath: '/path/to/app.app', - }).success, - ).toBe(true); - - expect( - schema.safeParse({ - simulatorUuid: 'ABC123-DEF456', - appPath: '/another/path/app.app', - }).success, - ).toBe(true); - - // Invalid inputs - expect( - schema.safeParse({ - simulatorUuid: 123, - appPath: '/path/to/app.app', - }).success, - ).toBe(false); - - expect( - schema.safeParse({ - simulatorUuid: 'test-uuid-123', - appPath: 123, - }).success, - ).toBe(false); - - expect( - schema.safeParse({ - simulatorUuid: 'test-uuid-123', - }).success, - ).toBe(false); - - expect( - schema.safeParse({ - appPath: '/path/to/app.app', - }).success, - ).toBe(false); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('simulatorId is required'); + expect(result.content[0].text).toContain('session-set-defaults'); + }); - expect(schema.safeParse({}).success).toBe(false); + it('should validate appPath when simulatorId default exists', async () => { + sessionStore.setDefaults({ simulatorId: 'SIM-UUID' }); + + const result = await installAppSim.handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('appPath: Required'); + expect(result.content[0].text).toContain( + 'Tip: set session defaults via session-set-defaults', + ); }); }); describe('Command Generation', () => { it('should generate correct simctl install command', async () => { - const executorCalls: any[] = []; - const mockExecutor = (...args: any[]) => { + const executorCalls: unknown[] = []; + const mockExecutor = (...args: unknown[]) => { executorCalls.push(args); return Promise.resolve({ success: true, @@ -91,7 +76,7 @@ describe('install_app_sim tool', () => { await install_app_simLogic( { - simulatorUuid: 'test-uuid-123', + simulatorId: 'test-uuid-123', appPath: '/path/to/app.app', }, mockExecutor, @@ -114,9 +99,9 @@ describe('install_app_sim tool', () => { ]); }); - it('should generate command with different simulator UUID', async () => { - const executorCalls: any[] = []; - const mockExecutor = (...args: any[]) => { + it('should generate command with different simulator identifier', async () => { + const executorCalls: unknown[] = []; + const mockExecutor = (...args: unknown[]) => { executorCalls.push(args); return Promise.resolve({ success: true, @@ -132,7 +117,7 @@ describe('install_app_sim tool', () => { await install_app_simLogic( { - simulatorUuid: 'different-uuid-456', + simulatorId: 'different-uuid-456', appPath: '/different/path/MyApp.app', }, mockExecutor, @@ -156,58 +141,7 @@ describe('install_app_sim tool', () => { }); }); - describe('Handler Behavior (Complete Literal Returns)', () => { - it('should test Zod validation through handler (missing simulatorUuid)', async () => { - // Test Zod validation by calling the handler with invalid params - const result = await installAppSim.handler({ - appPath: '/path/to/app.app', - // simulatorUuid missing - }); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nsimulatorUuid: Required', - }, - ], - isError: true, - }); - }); - - it('should test Zod validation through handler (missing appPath)', async () => { - // Test Zod validation by calling the handler with invalid params - const result = await installAppSim.handler({ - simulatorUuid: 'test-uuid-123', - // appPath missing - }); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nappPath: Required', - }, - ], - isError: true, - }); - }); - - it('should test Zod validation through handler (both parameters missing)', async () => { - // Test Zod validation by calling the handler with no params - const result = await installAppSim.handler({}); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nsimulatorUuid: Required\nappPath: Required', - }, - ], - isError: true, - }); - }); - + describe('Logic Behavior (Literal Returns)', () => { it('should handle file does not exist', async () => { const mockFileSystem = createMockFileSystemExecutor({ existsSync: () => false, @@ -215,7 +149,7 @@ describe('install_app_sim tool', () => { const result = await install_app_simLogic( { - simulatorUuid: 'test-uuid-123', + simulatorId: 'test-uuid-123', appPath: '/path/to/app.app', }, createNoopExecutor(), @@ -238,22 +172,19 @@ describe('install_app_sim tool', () => { const mockExecutor = () => { callCount++; if (callCount === 1) { - // First call: simctl install return Promise.resolve({ success: true, output: 'App installed', error: undefined, process: { pid: 12345 }, }); - } else { - // Second call: defaults read for bundle ID - return Promise.resolve({ - success: true, - output: 'com.example.myapp', - error: undefined, - process: { pid: 12345 }, - }); } + return Promise.resolve({ + success: true, + output: 'com.example.myapp', + error: undefined, + process: { pid: 12345 }, + }); }; const mockFileSystem = createMockFileSystemExecutor({ @@ -262,7 +193,7 @@ describe('install_app_sim tool', () => { const result = await install_app_simLogic( { - simulatorUuid: 'test-uuid-123', + simulatorId: 'test-uuid-123', appPath: '/path/to/app.app', }, mockExecutor, @@ -279,21 +210,20 @@ describe('install_app_sim tool', () => { type: 'text', text: `Next Steps: 1. Open the Simulator app: open_sim({}) -2. Launch the app: launch_app_sim({ simulatorUuid: "test-uuid-123", bundleId: "com.example.myapp" })`, +2. Launch the app: launch_app_sim({ simulatorId: "test-uuid-123", bundleId: "com.example.myapp" })`, }, ], }); }); it('should handle command failure', async () => { - const mockExecutor = () => { - return Promise.resolve({ + const mockExecutor = () => + Promise.resolve({ success: false, output: '', error: 'Install failed', process: { pid: 12345 }, }); - }; const mockFileSystem = createMockFileSystemExecutor({ existsSync: () => true, @@ -301,7 +231,7 @@ describe('install_app_sim tool', () => { const result = await install_app_simLogic( { - simulatorUuid: 'test-uuid-123', + simulatorId: 'test-uuid-123', appPath: '/path/to/app.app', }, mockExecutor, @@ -319,9 +249,7 @@ describe('install_app_sim tool', () => { }); it('should handle exception with Error object', async () => { - const mockExecutor = () => { - return Promise.reject(new Error('Command execution failed')); - }; + const mockExecutor = () => Promise.reject(new Error('Command execution failed')); const mockFileSystem = createMockFileSystemExecutor({ existsSync: () => true, @@ -329,7 +257,7 @@ describe('install_app_sim tool', () => { const result = await install_app_simLogic( { - simulatorUuid: 'test-uuid-123', + simulatorId: 'test-uuid-123', appPath: '/path/to/app.app', }, mockExecutor, @@ -347,9 +275,7 @@ describe('install_app_sim tool', () => { }); it('should handle exception with string error', async () => { - const mockExecutor = () => { - return Promise.reject('String error'); - }; + const mockExecutor = () => Promise.reject('String error'); const mockFileSystem = createMockFileSystemExecutor({ existsSync: () => true, @@ -357,7 +283,7 @@ describe('install_app_sim tool', () => { const result = await install_app_simLogic( { - simulatorUuid: 'test-uuid-123', + simulatorId: 'test-uuid-123', appPath: '/path/to/app.app', }, mockExecutor, diff --git a/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts b/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts index 27cfcff3..ff50d8e7 100644 --- a/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts @@ -1,7 +1,6 @@ /** - * Tests for launch_app_logs_sim plugin - * Following CLAUDE.md testing standards with literal validation - * Using dependency injection for deterministic testing + * Tests for launch_app_logs_sim plugin (session-aware version) + * Follows CLAUDE.md guidance with literal validation and DI. */ import { describe, it, expect, beforeEach } from 'vitest'; @@ -11,76 +10,63 @@ import launchAppLogsSim, { LogCaptureFunction, } from '../launch_app_logs_sim.ts'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; describe('launch_app_logs_sim tool', () => { + beforeEach(() => { + sessionStore.clear(); + }); + describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { + it('should expose correct metadata', () => { expect(launchAppLogsSim.name).toBe('launch_app_logs_sim'); - }); - - it('should have correct description', () => { expect(launchAppLogsSim.description).toBe( 'Launches an app in an iOS simulator and captures its logs.', ); }); - it('should have handler function', () => { - expect(typeof launchAppLogsSim.handler).toBe('function'); - }); - - it('should have correct schema with required fields', () => { + it('should expose only non-session fields in public schema', () => { const schema = z.object(launchAppLogsSim.schema); - // Valid inputs - expect( - schema.safeParse({ - simulatorUuid: 'abc123', - bundleId: 'com.example.app', - }).success, - ).toBe(true); + expect(schema.safeParse({ bundleId: 'com.example.app' }).success).toBe(true); + expect(schema.safeParse({ bundleId: 'com.example.app', args: ['--debug'] }).success).toBe( + true, + ); + expect(schema.safeParse({}).success).toBe(false); + expect(schema.safeParse({ bundleId: 42 }).success).toBe(false); + + expect(Object.keys(launchAppLogsSim.schema).sort()).toEqual(['args', 'bundleId'].sort()); + }); + }); - expect( - schema.safeParse({ - simulatorUuid: 'abc123', - bundleId: 'com.example.app', - args: ['--debug', '--verbose'], - }).success, - ).toBe(true); + describe('Handler Requirements', () => { + it('should require simulatorId when not provided', async () => { + const result = await launchAppLogsSim.handler({ bundleId: 'com.example.testapp' }); - // Invalid inputs - expect( - schema.safeParse({ - simulatorUuid: 123, - bundleId: 'com.example.app', - }).success, - ).toBe(false); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('simulatorId is required'); + expect(result.content[0].text).toContain('session-set-defaults'); + }); - expect( - schema.safeParse({ - simulatorUuid: 'abc123', - bundleId: 123, - }).success, - ).toBe(false); + it('should validate bundleId when simulatorId default exists', async () => { + sessionStore.setDefaults({ simulatorId: 'SIM-UUID' }); - expect( - schema.safeParse({ - bundleId: 'com.example.app', - }).success, - ).toBe(false); + const result = await launchAppLogsSim.handler({}); - expect( - schema.safeParse({ - simulatorUuid: 'abc123', - }).success, - ).toBe(false); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('bundleId: Required'); + expect(result.content[0].text).toContain( + 'Tip: set session defaults via session-set-defaults', + ); }); }); - describe('Handler Behavior (Complete Literal Returns)', () => { + describe('Logic Behavior (Literal Returns)', () => { it('should handle successful app launch with log capture', async () => { - // Create pure mock function without vitest mocking - let capturedParams: any = null; - const logCaptureStub: LogCaptureFunction = async (params: any, executor: any) => { + let capturedParams: unknown = null; + const logCaptureStub: LogCaptureFunction = async (params) => { capturedParams = params; return { sessionId: 'test-session-123', @@ -94,7 +80,7 @@ describe('launch_app_logs_sim tool', () => { const result = await launch_app_logs_simLogic( { - simulatorUuid: 'test-uuid-123', + simulatorId: 'test-uuid-123', bundleId: 'com.example.testapp', }, mockExecutor, @@ -118,10 +104,9 @@ describe('launch_app_logs_sim tool', () => { }); }); - it('should handle app launch with additional arguments', async () => { - // Create pure mock function for this test case - let capturedParams: any = null; - const logCaptureStub: LogCaptureFunction = async (params: any, executor: any) => { + it('should ignore args for log capture setup', async () => { + let capturedParams: unknown = null; + const logCaptureStub: LogCaptureFunction = async (params) => { capturedParams = params; return { sessionId: 'test-session-456', @@ -133,11 +118,11 @@ describe('launch_app_logs_sim tool', () => { const mockExecutor = createMockExecutor({ success: true, output: '' }); - const result = await launch_app_logs_simLogic( + await launch_app_logs_simLogic( { - simulatorUuid: 'test-uuid-123', + simulatorId: 'test-uuid-123', bundleId: 'com.example.testapp', - args: ['--debug', '--verbose'], + args: ['--debug'], }, mockExecutor, logCaptureStub, @@ -147,24 +132,23 @@ describe('launch_app_logs_sim tool', () => { simulatorUuid: 'test-uuid-123', bundleId: 'com.example.testapp', captureConsole: true, + args: ['--debug'], }); }); - it('should handle log capture failure', async () => { - const logCaptureStub: LogCaptureFunction = async (params: any, executor: any) => { - return { - sessionId: '', - logFilePath: '', - processes: [], - error: 'Failed to start log capture', - }; - }; + it('should surface log capture failure', async () => { + const logCaptureStub: LogCaptureFunction = async () => ({ + sessionId: '', + logFilePath: '', + processes: [], + error: 'Failed to start log capture', + }); const mockExecutor = createMockExecutor({ success: true, output: '' }); const result = await launch_app_logs_simLogic( { - simulatorUuid: 'test-uuid-123', + simulatorId: 'test-uuid-123', bundleId: 'com.example.testapp', }, mockExecutor, @@ -181,101 +165,5 @@ describe('launch_app_logs_sim tool', () => { isError: true, }); }); - - it('should handle validation failure for simulatorUuid via handler', async () => { - const result = await launchAppLogsSim.handler({ - simulatorUuid: undefined, - bundleId: 'com.example.testapp', - }); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nsimulatorUuid: Required', - }, - ], - isError: true, - }); - }); - - it('should handle validation failure for bundleId via handler', async () => { - const result = await launchAppLogsSim.handler({ - simulatorUuid: 'test-uuid-123', - bundleId: undefined, - }); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nbundleId: Required', - }, - ], - isError: true, - }); - }); - - it('should pass correct parameters to startLogCapture', async () => { - let capturedParams: any = null; - const logCaptureStub: LogCaptureFunction = async (params: any, executor: any) => { - capturedParams = params; - return { - sessionId: 'test-session-789', - logFilePath: '/tmp/xcodemcp_sim_log_test-session-789.log', - processes: [], - error: undefined, - }; - }; - - const mockExecutor = createMockExecutor({ success: true, output: '' }); - - await launch_app_logs_simLogic( - { - simulatorUuid: 'uuid-456', - bundleId: 'com.test.myapp', - }, - mockExecutor, - logCaptureStub, - ); - - expect(capturedParams).toEqual({ - simulatorUuid: 'uuid-456', - bundleId: 'com.test.myapp', - captureConsole: true, - }); - }); - - it('should include session ID and next steps in success message', async () => { - const logCaptureStub: LogCaptureFunction = async (params: any, executor: any) => { - return { - sessionId: 'session-abc-def', - logFilePath: '/tmp/xcodemcp_sim_log_session-abc-def.log', - processes: [], - error: undefined, - }; - }; - - const mockExecutor = createMockExecutor({ success: true, output: '' }); - - const result = await launch_app_logs_simLogic( - { - simulatorUuid: 'test-uuid-789', - bundleId: 'com.example.testapp', - }, - mockExecutor, - logCaptureStub, - ); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: `App launched successfully in simulator test-uuid-789 with log capture enabled.\n\nLog capture session ID: session-abc-def\n\nNext Steps:\n1. Interact with your app in the simulator.\n2. Use 'stop_and_get_simulator_log({ logSessionId: "session-abc-def" })' to stop capture and retrieve logs.`, - }, - ], - isError: false, - }); - }); }); }); 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 4ad74fd6..8d4dcd20 100644 --- a/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts @@ -1,88 +1,105 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { z } from 'zod'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; import launchAppSim, { launch_app_simLogic } from '../launch_app_sim.ts'; describe('launch_app_sim tool', () => { + beforeEach(() => { + sessionStore.clear(); + }); + describe('Export Field Validation (Literal)', () => { - it('should have correct name field', () => { + it('should expose correct name and description', () => { expect(launchAppSim.name).toBe('launch_app_sim'); + expect(launchAppSim.description).toBe('Launches an app in an iOS simulator.'); }); - it('should have correct description field', () => { - expect(launchAppSim.description).toBe( - "Launches an app in an iOS simulator by UUID or name. If simulator window isn't visible, use open_sim() first. IMPORTANT: Provide either simulatorUuid OR simulatorName, plus bundleId. Note: You must install the app in the simulator before launching. The typical workflow is: build → install → launch. Example: launch_app_sim({ simulatorUuid: 'YOUR_UUID_HERE', bundleId: 'com.example.MyApp' }) or launch_app_sim({ simulatorName: 'iPhone 16', bundleId: 'com.example.MyApp' })", - ); - }); - - it('should have handler function', () => { - expect(typeof launchAppSim.handler).toBe('function'); - }); - - it('should have correct schema validation', () => { + it('should expose only non-session fields in public schema', () => { const schema = z.object(launchAppSim.schema); expect( schema.safeParse({ - simulatorUuid: 'abc123', - bundleId: 'com.example.app', + bundleId: 'com.example.testapp', }).success, ).toBe(true); expect( schema.safeParse({ - simulatorUuid: 'abc123', - bundleId: 'com.example.app', - args: ['--debug', '--verbose'], + bundleId: 'com.example.testapp', + args: ['--debug'], }).success, ).toBe(true); - expect( - schema.safeParse({ - simulatorUuid: 123, - bundleId: 'com.example.app', - }).success, - ).toBe(false); + expect(schema.safeParse({}).success).toBe(false); + expect(schema.safeParse({ bundleId: 123 }).success).toBe(false); + expect(schema.safeParse({ args: ['--debug'] }).success).toBe(false); - expect( - schema.safeParse({ - simulatorUuid: 'abc123', - bundleId: 123, - }).success, - ).toBe(false); + expect(Object.keys(launchAppSim.schema).sort()).toEqual(['args', 'bundleId'].sort()); }); }); - describe('Handler Behavior (Complete Literal Returns)', () => { - it('should handle successful app launch', async () => { - let callCount = 0; - const mockExecutor = createMockExecutor({}); - const originalExecutor = mockExecutor; + describe('Handler Requirements', () => { + it('should require simulator identifier when not provided', async () => { + const result = await launchAppSim.handler({ bundleId: 'com.example.testapp' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('Provide simulatorId or simulatorName'); + expect(result.content[0].text).toContain('session-set-defaults'); + }); + + it('should validate bundleId when simulatorId default exists', async () => { + sessionStore.setDefaults({ simulatorId: 'SIM-UUID' }); - const sequencedExecutor = async (command: string[], logPrefix?: string) => { + const result = await launchAppSim.handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('bundleId: Required'); + expect(result.content[0].text).toContain( + 'Tip: set session defaults via session-set-defaults', + ); + }); + + it('should reject when both simulatorId and simulatorName provided explicitly', async () => { + const result = await launchAppSim.handler({ + simulatorId: 'SIM-UUID', + simulatorName: 'iPhone 16', + bundleId: 'com.example.testapp', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); + expect(result.content[0].text).toContain('simulatorId'); + expect(result.content[0].text).toContain('simulatorName'); + }); + }); + + 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) { - // First call - app container check return { success: true, output: '/path/to/app/container', error: '', process: {} as any, }; - } else { - // Second call - launch command - return { - success: true, - output: 'App launched successfully', - error: '', - process: {} as any, - }; } + return { + success: true, + output: 'App launched successfully', + error: '', + process: {} as any, + }; }; const result = await launch_app_simLogic( { - simulatorUuid: 'test-uuid-123', + simulatorId: 'test-uuid-123', bundleId: 'com.example.testapp', }, sequencedExecutor, @@ -104,35 +121,32 @@ Next Steps: }); }); - it('should handle app launch with additional arguments', async () => { + it('should append additional arguments when provided', async () => { let callCount = 0; const commands: string[][] = []; - const sequencedExecutor = async (command: string[], logPrefix?: string) => { + const sequencedExecutor = async (command: string[]) => { commands.push(command); callCount++; if (callCount === 1) { - // First call - app container check return { success: true, output: '/path/to/app/container', error: '', process: {} as any, }; - } else { - // Second call - launch command - return { - success: true, - output: 'App launched successfully', - error: '', - process: {} as any, - }; } + return { + success: true, + output: 'App launched successfully', + error: '', + process: {} as any, + }; }; - const result = await launch_app_simLogic( + await launch_app_simLogic( { - simulatorUuid: 'test-uuid-123', + simulatorId: 'test-uuid-123', bundleId: 'com.example.testapp', args: ['--debug', '--verbose'], }, @@ -150,7 +164,7 @@ Next Steps: ]); }); - it('should handle app not installed error', async () => { + it('should surface app-not-installed error', async () => { const mockExecutor = createMockExecutor({ success: false, output: '', @@ -159,7 +173,7 @@ Next Steps: const result = await launch_app_simLogic( { - simulatorUuid: 'test-uuid-123', + simulatorId: 'test-uuid-123', bundleId: 'com.example.testapp', }, mockExecutor, @@ -169,40 +183,36 @@ Next Steps: content: [ { type: 'text', - text: 'App is not installed on the simulator. Please use install_app_in_simulator before launching.\n\nWorkflow: build → install → launch.', + text: 'App is not installed on the simulator. Please use install_app_sim before launching.\n\nWorkflow: build → install → launch.', }, ], isError: true, }); }); - it('should handle app launch failure', async () => { + it('should return launch failure message when simctl launch fails', async () => { let callCount = 0; - - const sequencedExecutor = async (command: string[], logPrefix?: string) => { + const sequencedExecutor = async (command: string[]) => { callCount++; if (callCount === 1) { - // First call - app container check succeeds return { success: true, output: '/path/to/app/container', error: '', process: {} as any, }; - } else { - // Second call - launch command fails - return { - success: false, - output: '', - error: 'Launch failed', - process: {} as any, - }; } + return { + success: false, + output: '', + error: 'Launch failed', + process: {} as any, + }; }; const result = await launch_app_simLogic( { - simulatorUuid: 'test-uuid-123', + simulatorId: 'test-uuid-123', bundleId: 'com.example.testapp', }, sequencedExecutor, @@ -218,107 +228,11 @@ Next Steps: }); }); - it('should handle validation failures for simulatorUuid', async () => { - // Test the actual handler which includes Zod validation - const result = await launchAppSim.handler({ - bundleId: 'com.example.testapp', - // simulatorUuid is missing - }); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Parameter validation failed'); - expect(result.content[0].text).toContain('simulatorUuid'); - expect(result.content[0].text).toContain('required'); - }); - - it('should handle validation failures for bundleId', async () => { - // Test the actual handler which includes Zod validation - const result = await launchAppSim.handler({ - simulatorUuid: 'test-uuid-123', - // bundleId is missing - }); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Parameter validation failed'); - expect(result.content[0].text).toContain('bundleId'); - expect(result.content[0].text).toContain('Required'); - }); - - it('should handle command failure during app container check', async () => { - const mockExecutor = createMockExecutor({ - success: false, - output: '', - error: 'Network error', - }); - - const result = await launch_app_simLogic( - { - simulatorUuid: 'test-uuid-123', - bundleId: 'com.example.testapp', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'App is not installed on the simulator. Please use install_app_in_simulator before launching.\n\nWorkflow: build → install → launch.', - }, - ], - isError: true, - }); - }); - - it('should handle command failure during launch', async () => { - let callCount = 0; - - const sequencedExecutor = async (command: string[], logPrefix?: string) => { - callCount++; - if (callCount === 1) { - // First call - app container check succeeds - return { - success: true, - output: '/path/to/app/container', - error: '', - process: {} as any, - }; - } else { - // Second call - launch command fails - return { - success: false, - output: '', - error: 'Launch operation failed', - process: {} as any, - }; - } - }; - - const result = await launch_app_simLogic( - { - simulatorUuid: 'test-uuid-123', - bundleId: 'com.example.testapp', - }, - sequencedExecutor, - ); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Launch app in simulator operation failed: Launch operation failed', - }, - ], - }); - }); - - it('should show consistent parameter style in hints based on user input (simulatorName)', async () => { - // Mock simctl list to return simulator data + it('should launch using simulatorName by resolving UUID', async () => { let callCount = 0; - const sequencedExecutor = async (command: string[], logPrefix?: string) => { + const sequencedExecutor = async (command: string[]) => { callCount++; if (callCount === 1) { - // First call - simulator lookup by name return { success: true, output: JSON.stringify({ @@ -326,7 +240,7 @@ Next Steps: 'iOS 17.0': [ { name: 'iPhone 16', - udid: 'test-uuid-456', + udid: 'resolved-uuid', isAvailable: true, state: 'Shutdown', }, @@ -336,39 +250,36 @@ Next Steps: error: '', process: {} as any, }; - } else if (callCount === 2) { - // Second call - app container check + } + if (callCount === 2) { return { success: true, output: '/path/to/app/container', error: '', process: {} as any, }; - } else { - // Third call - launch command - return { - success: true, - output: 'App launched successfully', - error: '', - process: {} as any, - }; } + return { + success: true, + output: 'App launched successfully', + error: '', + process: {} as any, + }; }; const result = await launch_app_simLogic( { - simulatorName: 'iPhone 16', // User provided simulatorName + simulatorName: 'iPhone 16', bundleId: 'com.example.testapp', }, sequencedExecutor, ); - // Verify hints use simulatorName (user's preference) not simulatorUuid expect(result).toEqual({ content: [ { type: 'text', - text: `✅ App launched successfully in simulator "iPhone 16" (test-uuid-456). + text: `✅ App launched successfully in simulator "iPhone 16" (resolved-uuid). Next Steps: 1. To see simulator: open_sim() @@ -379,5 +290,58 @@ Next Steps: ], }); }); + + it('should return error when simulator name is not found', async () => { + const mockListExecutor = async () => ({ + success: true, + output: JSON.stringify({ devices: {} }), + error: '', + process: {} as any, + }); + + const result = await launch_app_simLogic( + { + simulatorName: 'Missing Simulator', + bundleId: 'com.example.testapp', + }, + mockListExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Simulator named "Missing Simulator" not found. Use list_sims to see available simulators.', + }, + ], + isError: true, + }); + }); + + it('should return error when simctl list fails', async () => { + const mockExecutor = createMockExecutor({ + success: false, + output: '', + error: 'simctl list failed', + }); + + const result = await launch_app_simLogic( + { + simulatorName: 'iPhone 16', + bundleId: 'com.example.testapp', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to list simulators: simctl list failed', + }, + ], + isError: true, + }); + }); }); }); 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 467ebc3f..fbd8d65e 100644 --- a/src/mcp/tools/simulator/__tests__/record_sim_video.test.ts +++ b/src/mcp/tools/simulator/__tests__/record_sim_video.test.ts @@ -1,12 +1,11 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; -import { z } from 'zod'; // Import the tool and logic import tool, { record_sim_videoLogic } from '../record_sim_video.ts'; import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; const DUMMY_EXECUTOR: any = (async () => ({ success: true })) as any; // CommandExecutor stub -const VALID_UUID = '00000000-0000-0000-0000-000000000000'; +const VALID_SIM_ID = '00000000-0000-0000-0000-000000000000'; afterEach(() => { vi.restoreAllMocks(); @@ -15,7 +14,7 @@ afterEach(() => { describe('record_sim_video tool - validation', () => { it('errors when start and stop are both true (mutually exclusive)', async () => { const res = await tool.handler({ - simulatorUuid: VALID_UUID, + simulatorId: VALID_SIM_ID, start: true, stop: true, } as any); @@ -27,7 +26,7 @@ describe('record_sim_video tool - validation', () => { it('errors when stop=true but outputFile is missing', async () => { const res = await tool.handler({ - simulatorUuid: VALID_UUID, + simulatorId: VALID_SIM_ID, stop: true, } as any); @@ -63,7 +62,7 @@ describe('record_sim_video logic - start behavior', () => { const res = await record_sim_videoLogic( { - simulatorUuid: VALID_UUID, + simulatorId: VALID_SIM_ID, start: true, // fps omitted to hit default 30 outputFile: '/tmp/ignored.mp4', // should be ignored with a note @@ -114,7 +113,7 @@ describe('record_sim_video logic - end-to-end stop with rename', () => { // Start (not strictly required for stop path, but included to mimic flow) const startRes = await record_sim_videoLogic( { - simulatorUuid: VALID_UUID, + simulatorId: VALID_SIM_ID, start: true, } as any, DUMMY_EXECUTOR, @@ -128,7 +127,7 @@ describe('record_sim_video logic - end-to-end stop with rename', () => { const outputFile = '/var/videos/final.mp4'; const stopRes = await record_sim_videoLogic( { - simulatorUuid: VALID_UUID, + simulatorId: VALID_SIM_ID, stop: true, outputFile, } as any, @@ -173,7 +172,7 @@ describe('record_sim_video logic - version gate', () => { const res = await record_sim_videoLogic( { - simulatorUuid: VALID_UUID, + simulatorId: VALID_SIM_ID, start: true, } as any, DUMMY_EXECUTOR, 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 4147ca6d..25ad8ae3 100644 --- a/src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts @@ -1,97 +1,198 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { z } from 'zod'; -import { - createMockExecutor, - createMockFileSystemExecutor, - createNoopExecutor, -} from '../../../../test-utils/mock-executors.ts'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; import plugin, { stop_app_simLogic } from '../stop_app_sim.ts'; -describe('stop_app_sim plugin', () => { +describe('stop_app_sim tool', () => { + beforeEach(() => { + sessionStore.clear(); + }); + describe('Export Field Validation (Literal)', () => { - it('should have correct name field', () => { + it('should expose correct metadata', () => { expect(plugin.name).toBe('stop_app_sim'); + expect(plugin.description).toBe('Stops an app running in an iOS simulator.'); }); - it('should have correct description field', () => { - expect(plugin.description).toBe( - 'Stops an app running in an iOS simulator by UUID or name. IMPORTANT: Provide either simulatorUuid OR simulatorName, plus bundleId. Example: stop_app_sim({ simulatorUuid: "UUID", bundleId: "com.example.MyApp" }) or stop_app_sim({ simulatorName: "iPhone 16", bundleId: "com.example.MyApp" })', - ); + it('should expose public schema with only bundleId', () => { + const schema = z.object(plugin.schema); + + expect(schema.safeParse({ bundleId: 'com.example.app' }).success).toBe(true); + expect(schema.safeParse({}).success).toBe(false); + expect(schema.safeParse({ bundleId: 42 }).success).toBe(false); + expect(Object.keys(plugin.schema)).toEqual(['bundleId']); }); + }); - it('should have handler function', () => { - expect(typeof plugin.handler).toBe('function'); + describe('Handler Requirements', () => { + it('should require simulator identifier when not provided', async () => { + const result = await plugin.handler({ bundleId: 'com.example.app' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('Provide simulatorId or simulatorName'); + expect(result.content[0].text).toContain('session-set-defaults'); }); - it('should have correct schema validation', () => { - const schema = z.object(plugin.schema); + it('should validate bundleId when simulatorId default exists', async () => { + sessionStore.setDefaults({ simulatorId: 'SIM-UUID' }); + + const result = await plugin.handler({}); - expect( - schema.safeParse({ - simulatorUuid: 'abc123', - bundleId: 'com.example.app', - }).success, - ).toBe(true); - - expect( - schema.safeParse({ - simulatorUuid: 123, - bundleId: 'com.example.app', - }).success, - ).toBe(false); - - expect( - schema.safeParse({ - simulatorUuid: 'abc123', - bundleId: 123, - }).success, - ).toBe(false); - - expect( - schema.safeParse({ - simulatorUuid: 'abc123', - }).success, - ).toBe(false); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('bundleId: Required'); + expect(result.content[0].text).toContain( + 'Tip: set session defaults via session-set-defaults', + ); + }); + + it('should reject mutually exclusive simulator parameters', async () => { + const result = await plugin.handler({ + simulatorId: 'SIM-UUID', + simulatorName: 'iPhone 16', + bundleId: 'com.example.app', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); + expect(result.content[0].text).toContain('simulatorId'); + expect(result.content[0].text).toContain('simulatorName'); }); }); - describe('Handler Behavior (Complete Literal Returns)', () => { - it('should stop app successfully', async () => { - const mockExecutor = createMockExecutor({ + describe('Logic Behavior (Literal Returns)', () => { + it('should stop app successfully with simulatorId', async () => { + const mockExecutor = createMockExecutor({ success: true, output: '' }); + + const result = await stop_app_simLogic( + { + simulatorId: 'test-uuid', + bundleId: 'com.example.App', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ App com.example.App stopped successfully in simulator test-uuid', + }, + ], + }); + }); + + it('should stop app successfully when resolving simulatorName', async () => { + let callCount = 0; + const sequencedExecutor = async (command: string[]) => { + callCount++; + if (callCount === 1) { + return { + success: true, + output: JSON.stringify({ + devices: { + 'iOS 17.0': [ + { name: 'iPhone 16', udid: 'resolved-uuid', isAvailable: true, state: 'Booted' }, + ], + }, + }), + error: '', + process: {} as any, + }; + } + return { + success: true, + output: '', + error: '', + process: {} as any, + }; + }; + + const result = await stop_app_simLogic( + { + simulatorName: 'iPhone 16', + bundleId: 'com.example.App', + }, + sequencedExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ App com.example.App stopped successfully in simulator "iPhone 16" (resolved-uuid)', + }, + ], + }); + }); + + it('should handle simulator lookup failure', async () => { + const listExecutor = createMockExecutor({ success: true, + output: JSON.stringify({ devices: {} }), + error: '', + }); + + const result = await stop_app_simLogic( + { + simulatorName: 'Unknown Simulator', + bundleId: 'com.example.App', + }, + listExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Simulator named "Unknown Simulator" not found. Use list_sims to see available simulators.', + }, + ], + isError: true, + }); + }); + + it('should handle simulator list command failure', async () => { + const listExecutor = createMockExecutor({ + success: false, output: '', + error: 'simctl list failed', }); const result = await stop_app_simLogic( { - simulatorUuid: 'test-uuid', + simulatorName: 'iPhone 16', bundleId: 'com.example.App', }, - mockExecutor, + listExecutor, ); expect(result).toEqual({ content: [ { type: 'text', - text: '✅ App com.example.App stopped successfully in simulator test-uuid', + text: 'Failed to list simulators: simctl list failed', }, ], + isError: true, }); }); - it('should handle command failure', async () => { - const mockExecutor = createMockExecutor({ + it('should surface terminate failures', async () => { + const terminateExecutor = createMockExecutor({ success: false, + output: '', error: 'Simulator not found', }); const result = await stop_app_simLogic( { - simulatorUuid: 'invalid-uuid', + simulatorId: 'invalid-uuid', bundleId: 'com.example.App', }, - mockExecutor, + terminateExecutor, ); expect(result).toEqual({ @@ -105,21 +206,17 @@ describe('stop_app_sim plugin', () => { }); }); - // Note: Parameter validation tests removed because validation is now handled - // by the createTypedTool wrapper using Zod schema validation. - // Invalid parameters are caught before reaching the logic function. - - it('should handle exception during execution', async () => { - const mockExecutor = async () => { + it('should handle unexpected exceptions', async () => { + const throwingExecutor = async () => { throw new Error('Unexpected error'); }; const result = await stop_app_simLogic( { - simulatorUuid: 'test-uuid', + simulatorId: 'test-uuid', bundleId: 'com.example.App', }, - mockExecutor, + throwingExecutor, ); expect(result).toEqual({ @@ -133,9 +230,15 @@ describe('stop_app_sim plugin', () => { }); }); - it('should call correct command', async () => { - const calls: any[] = []; - const mockExecutor = async ( + it('should call correct terminate command', async () => { + const calls: Array<{ + command: string[]; + description: string; + suppressErrorLogging: boolean; + timeout?: number; + }> = []; + + const trackingExecutor = async ( command: string[], description: string, suppressErrorLogging: boolean, @@ -152,10 +255,10 @@ describe('stop_app_sim plugin', () => { await stop_app_simLogic( { - simulatorUuid: 'test-uuid', + simulatorId: 'test-uuid', bundleId: 'com.example.App', }, - mockExecutor, + trackingExecutor, ); expect(calls).toEqual([ diff --git a/src/mcp/tools/simulator/boot_sim.ts b/src/mcp/tools/simulator/boot_sim.ts index 5c036211..350ecaa3 100644 --- a/src/mcp/tools/simulator/boot_sim.ts +++ b/src/mcp/tools/simulator/boot_sim.ts @@ -3,26 +3,27 @@ import { 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 { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; -// Define schema as ZodObject -const bootSimSchema = z.object({ - simulatorUuid: z - .string() - .describe('UUID of the simulator to use (obtained from list_simulators)'), +const bootSimSchemaObject = z.object({ + simulatorId: z.string().describe('UUID of the simulator to boot'), }); // Use z.infer for type safety -type BootSimParams = z.infer; +type BootSimParams = z.infer; + +const publicSchemaObject = bootSimSchemaObject.omit({ + simulatorId: true, +} as const); export async function boot_simLogic( params: BootSimParams, executor: CommandExecutor, ): Promise { - log('info', `Starting xcrun simctl boot request for simulator ${params.simulatorUuid}`); + log('info', `Starting xcrun simctl boot request for simulator ${params.simulatorId}`); try { - const command = ['xcrun', 'simctl', 'boot', params.simulatorUuid]; + const command = ['xcrun', 'simctl', 'boot', params.simulatorId]; const result = await executor(command, 'Boot Simulator', true); if (!result.success) { @@ -44,8 +45,8 @@ export async function boot_simLogic( Next steps: 1. Open the Simulator app (makes it visible): open_sim() -2. Install an app: install_app_sim({ simulatorUuid: "${params.simulatorUuid}", appPath: "PATH_TO_YOUR_APP" }) -3. Launch an app: launch_app_sim({ simulatorUuid: "${params.simulatorUuid}", bundleId: "YOUR_APP_BUNDLE_ID" })`, +2. Install an app: install_app_sim({ simulatorId: "${params.simulatorId}", appPath: "PATH_TO_YOUR_APP" }) +3. Launch an app: launch_app_sim({ simulatorId: "${params.simulatorId}", bundleId: "YOUR_APP_BUNDLE_ID" })`, }, ], }; @@ -65,8 +66,12 @@ Next steps: export default { name: 'boot_sim', - description: - "Boots an iOS simulator. After booting, use open_sim() to make the simulator visible. IMPORTANT: You MUST provide the simulatorUuid parameter. Example: boot_sim({ simulatorUuid: 'YOUR_UUID_HERE' })", - schema: bootSimSchema.shape, // MCP SDK compatibility - handler: createTypedTool(bootSimSchema, boot_simLogic, getDefaultCommandExecutor), + description: 'Boots an iOS simulator.', + schema: publicSchemaObject.shape, + handler: createSessionAwareTool({ + internalSchema: bootSimSchemaObject, + logicFunction: boot_simLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], + }), }; diff --git a/src/mcp/tools/simulator/install_app_sim.ts b/src/mcp/tools/simulator/install_app_sim.ts index b1a58750..2b537683 100644 --- a/src/mcp/tools/simulator/install_app_sim.ts +++ b/src/mcp/tools/simulator/install_app_sim.ts @@ -4,20 +4,18 @@ import { log } from '../../../utils/logging/index.ts'; import { validateFileExists } from '../../../utils/validation/index.ts'; import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; -// Define schema as ZodObject -const installAppSimSchema = z.object({ - simulatorUuid: z - .string() - .describe('UUID of the simulator to use (obtained from list_simulators)'), - appPath: z - .string() - .describe('Path to the .app bundle to install (full path to the .app directory)'), +const installAppSimSchemaObject = z.object({ + simulatorId: z.string().describe('UUID of the simulator to target'), + appPath: z.string().describe('Path to the .app bundle to install'), }); -// Use z.infer for type safety -type InstallAppSimParams = z.infer; +type InstallAppSimParams = z.infer; + +const publicSchemaObject = installAppSimSchemaObject.omit({ + simulatorId: true, +} as const); export async function install_app_simLogic( params: InstallAppSimParams, @@ -29,10 +27,10 @@ export async function install_app_simLogic( return appPathExistsValidation.errorResponse!; } - log('info', `Starting xcrun simctl install request for simulator ${params.simulatorUuid}`); + log('info', `Starting xcrun simctl install request for simulator ${params.simulatorId}`); try { - const command = ['xcrun', 'simctl', 'install', params.simulatorUuid, params.appPath]; + const command = ['xcrun', 'simctl', 'install', params.simulatorId, params.appPath]; const result = await executor(command, 'Install App in Simulator', true, undefined); if (!result.success) { @@ -65,13 +63,15 @@ export async function install_app_simLogic( content: [ { type: 'text', - text: `App installed successfully in simulator ${params.simulatorUuid}`, + text: `App installed successfully in simulator ${params.simulatorId}`, }, { type: 'text', text: `Next Steps: 1. Open the Simulator app: open_sim({}) -2. Launch the app: launch_app_sim({ simulatorUuid: "${params.simulatorUuid}"${bundleId ? `, bundleId: "${bundleId}"` : ', bundleId: "YOUR_APP_BUNDLE_ID"'} })`, +2. Launch the app: launch_app_sim({ simulatorId: "${params.simulatorId}"${ + bundleId ? `, bundleId: "${bundleId}"` : ', bundleId: "YOUR_APP_BUNDLE_ID"' + } })`, }, ], }; @@ -91,8 +91,12 @@ export async function install_app_simLogic( export default { name: 'install_app_sim', - description: - "Installs an app in an iOS simulator. IMPORTANT: You MUST provide both the simulatorUuid and appPath parameters. Example: install_app_sim({ simulatorUuid: 'YOUR_UUID_HERE', appPath: '/path/to/your/app.app' })", - schema: installAppSimSchema.shape, // MCP SDK compatibility - handler: createTypedTool(installAppSimSchema, install_app_simLogic, getDefaultCommandExecutor), + description: 'Installs an app in an iOS simulator.', + schema: publicSchemaObject.shape, + handler: createSessionAwareTool({ + internalSchema: installAppSimSchemaObject, + logicFunction: install_app_simLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], + }), }; diff --git a/src/mcp/tools/simulator/launch_app_logs_sim.ts b/src/mcp/tools/simulator/launch_app_logs_sim.ts index 4cdb81a7..7532612c 100644 --- a/src/mcp/tools/simulator/launch_app_logs_sim.ts +++ b/src/mcp/tools/simulator/launch_app_logs_sim.ts @@ -4,53 +4,47 @@ import { log } from '../../../utils/logging/index.ts'; import { startLogCapture } from '../../../utils/log-capture/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 { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; -/** - * Log capture function type for dependency injection - */ export type LogCaptureFunction = ( params: { simulatorUuid: string; bundleId: string; captureConsole?: boolean; + args?: string[]; }, executor: CommandExecutor, ) => Promise<{ sessionId: string; logFilePath: string; processes: unknown[]; error?: string }>; -// Define schema as ZodObject -const launchAppLogsSimSchema = z.object({ - simulatorUuid: z - .string() - .describe('UUID of the simulator to use (obtained from list_simulators)'), +const launchAppLogsSimSchemaObject = z.object({ + simulatorId: z.string().describe('UUID of the simulator to target'), bundleId: z .string() .describe("Bundle identifier of the app to launch (e.g., 'com.example.MyApp')"), args: z.array(z.string()).optional().describe('Additional arguments to pass to the app'), }); -// Use z.infer for type safety -type LaunchAppLogsSimParams = z.infer; +type LaunchAppLogsSimParams = z.infer; + +const publicSchemaObject = launchAppLogsSimSchemaObject.omit({ + simulatorId: true, +} as const); -/** - * Business logic for launching app with logs in simulator - */ export async function launch_app_logs_simLogic( params: LaunchAppLogsSimParams, executor: CommandExecutor = getDefaultCommandExecutor(), logCaptureFunction: LogCaptureFunction = startLogCapture, ): Promise { - log('info', `Starting app launch with logs for simulator ${params.simulatorUuid}`); + log('info', `Starting app launch with logs for simulator ${params.simulatorId}`); + + const captureParams = { + simulatorUuid: params.simulatorId, + bundleId: params.bundleId, + captureConsole: true, + ...(params.args && params.args.length > 0 ? { args: params.args } : {}), + } as const; - // Start log capture session - const { sessionId, error } = await logCaptureFunction( - { - simulatorUuid: params.simulatorUuid, - bundleId: params.bundleId, - captureConsole: true, - }, - executor, - ); + const { sessionId, error } = await logCaptureFunction(captureParams, executor); if (error) { return { content: [createTextContent(`App was launched but log capture failed: ${error}`)], @@ -61,7 +55,7 @@ export async function launch_app_logs_simLogic( return { content: [ createTextContent( - `App launched successfully in simulator ${params.simulatorUuid} with log capture enabled.\n\nLog capture session ID: ${sessionId}\n\nNext Steps:\n1. Interact with your app in the simulator.\n2. Use 'stop_and_get_simulator_log({ logSessionId: "${sessionId}" })' to stop capture and retrieve logs.`, + `App launched successfully in simulator ${params.simulatorId} with log capture enabled.\n\nLog capture session ID: ${sessionId}\n\nNext Steps:\n1. Interact with your app in the simulator.\n2. Use 'stop_and_get_simulator_log({ logSessionId: "${sessionId}" })' to stop capture and retrieve logs.`, ), ], isError: false, @@ -71,10 +65,11 @@ export async function launch_app_logs_simLogic( export default { name: 'launch_app_logs_sim', description: 'Launches an app in an iOS simulator and captures its logs.', - schema: launchAppLogsSimSchema.shape, // MCP SDK compatibility - handler: createTypedTool( - launchAppLogsSimSchema, - launch_app_logs_simLogic, - getDefaultCommandExecutor, - ), + schema: publicSchemaObject.shape, + handler: createSessionAwareTool({ + internalSchema: launchAppLogsSimSchemaObject, + logicFunction: launch_app_logs_simLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], + }), }; diff --git a/src/mcp/tools/simulator/launch_app_sim.ts b/src/mcp/tools/simulator/launch_app_sim.ts index c4548441..1de4a70c 100644 --- a/src/mcp/tools/simulator/launch_app_sim.ts +++ b/src/mcp/tools/simulator/launch_app_sim.ts @@ -4,66 +4,47 @@ import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; +import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; -// Unified schema: XOR between simulatorUuid and simulatorName -const baseOptions = { - simulatorUuid: z +const baseSchemaObject = z.object({ + simulatorId: z .string() .optional() .describe( - 'UUID of the simulator to use (obtained from list_simulators). Provide EITHER this OR simulatorName, not both', + 'UUID of the simulator to use (obtained from list_sims). Provide EITHER this OR simulatorName, not both', ), simulatorName: z .string() .optional() .describe( - "Name of the simulator (e.g., 'iPhone 16'). Provide EITHER this OR simulatorUuid, not both", + "Name of the simulator (e.g., 'iPhone 16'). Provide EITHER this OR simulatorId, not both", ), bundleId: z .string() .describe("Bundle identifier of the app to launch (e.g., 'com.example.MyApp')"), args: z.array(z.string()).optional().describe('Additional arguments to pass to the app'), -}; +}); -const baseSchemaObject = z.object(baseOptions); +const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); -const launchAppSimSchema = baseSchemaObject - .transform(nullifyEmptyStrings) - .refine( - (val) => - (val as LaunchAppSimParams).simulatorUuid !== undefined || - (val as LaunchAppSimParams).simulatorName !== undefined, - { - message: 'Either simulatorUuid or simulatorName is required.', - }, - ) - .refine( - (val) => - !( - (val as LaunchAppSimParams).simulatorUuid !== undefined && - (val as LaunchAppSimParams).simulatorName !== undefined - ), - { - message: 'simulatorUuid and simulatorName are mutually exclusive. Provide only one.', - }, - ); +const launchAppSimSchema = baseSchema + .refine((val) => val.simulatorId !== undefined || val.simulatorName !== undefined, { + message: 'Either simulatorId or simulatorName is required.', + }) + .refine((val) => !(val.simulatorId !== undefined && val.simulatorName !== undefined), { + message: 'simulatorId and simulatorName are mutually exclusive. Provide only one.', + }); -export type LaunchAppSimParams = { - simulatorUuid?: string; - simulatorName?: string; - bundleId: string; - args?: string[]; -}; +export type LaunchAppSimParams = z.infer; export async function launch_app_simLogic( params: LaunchAppSimParams, executor: CommandExecutor, ): Promise { - let simulatorUuid = params.simulatorUuid; - let simulatorDisplayName = simulatorUuid ?? ''; + let simulatorId = params.simulatorId; + let simulatorDisplayName = simulatorId ?? ''; - // If simulatorName is provided, look it up - if (params.simulatorName && !simulatorUuid) { + if (params.simulatorName && !simulatorId) { log('info', `Looking up simulator by name: ${params.simulatorName}`); const simulatorListResult = await executor( @@ -82,15 +63,14 @@ export async function launch_app_simLogic( isError: true, }; } + const simulatorsData = JSON.parse(simulatorListResult.output) as { - devices: Record; + devices: Record>; }; let foundSimulator: { udid: string; name: string } | null = null; - - // Find the target simulator by name for (const runtime in simulatorsData.devices) { - const devices = simulatorsData.devices[runtime] as Array<{ udid: string; name: string }>; + const devices = simulatorsData.devices[runtime]; const simulator = devices.find((device) => device.name === params.simulatorName); if (simulator) { foundSimulator = simulator; @@ -110,31 +90,30 @@ export async function launch_app_simLogic( }; } - simulatorUuid = foundSimulator.udid; + simulatorId = foundSimulator.udid; simulatorDisplayName = `"${params.simulatorName}" (${foundSimulator.udid})`; } - if (!simulatorUuid) { + if (!simulatorId) { return { content: [ { type: 'text', - text: 'No simulator UUID or name provided', + text: 'No simulator identifier provided', }, ], isError: true, }; } - log('info', `Starting xcrun simctl launch request for simulator ${simulatorUuid}`); + log('info', `Starting xcrun simctl launch request for simulator ${simulatorId}`); - // Check if the app is installed in the simulator try { const getAppContainerCmd = [ 'xcrun', 'simctl', 'get_app_container', - simulatorUuid, + simulatorId, params.bundleId, 'app', ]; @@ -149,7 +128,7 @@ export async function launch_app_simLogic( content: [ { type: 'text', - text: `App is not installed on the simulator. Please use install_app_in_simulator before launching.\n\nWorkflow: build → install → launch.`, + text: `App is not installed on the simulator. Please use install_app_sim before launching.\n\nWorkflow: build → install → launch.`, }, ], isError: true, @@ -160,7 +139,7 @@ export async function launch_app_simLogic( content: [ { type: 'text', - text: `App is not installed on the simulator (check failed). Please use install_app_in_simulator before launching.\n\nWorkflow: build → install → launch.`, + text: `App is not installed on the simulator (check failed). Please use install_app_sim before launching.\n\nWorkflow: build → install → launch.`, }, ], isError: true, @@ -168,8 +147,7 @@ export async function launch_app_simLogic( } try { - const command = ['xcrun', 'simctl', 'launch', simulatorUuid, params.bundleId]; - + const command = ['xcrun', 'simctl', 'launch', simulatorId, params.bundleId]; if (params.args && params.args.length > 0) { command.push(...params.args); } @@ -187,15 +165,14 @@ export async function launch_app_simLogic( }; } - // Use the same parameter style that the user provided for consistency - const userParamName = params.simulatorUuid ? 'simulatorUuid' : 'simulatorName'; - const userParamValue = params.simulatorUuid ?? params.simulatorName; + const userParamName = params.simulatorName ? 'simulatorName' : 'simulatorUuid'; + const userParamValue = params.simulatorName ?? simulatorId; return { content: [ { type: 'text', - text: `✅ App launched successfully in simulator ${simulatorDisplayName ?? simulatorUuid}. + text: `✅ App launched successfully in simulator ${simulatorDisplayName || simulatorId}. Next Steps: 1. To see simulator: open_sim() @@ -219,47 +196,22 @@ Next Steps: } } +const publicSchemaObject = baseSchemaObject.omit({ + simulatorId: true, + simulatorName: true, +} as const); + export default { name: 'launch_app_sim', - description: - "Launches an app in an iOS simulator by UUID or name. If simulator window isn't visible, use open_sim() first. IMPORTANT: Provide either simulatorUuid OR simulatorName, plus bundleId. Note: You must install the app in the simulator before launching. The typical workflow is: build → install → launch. Example: launch_app_sim({ simulatorUuid: 'YOUR_UUID_HERE', bundleId: 'com.example.MyApp' }) or launch_app_sim({ simulatorName: 'iPhone 16', bundleId: 'com.example.MyApp' })", - schema: baseSchemaObject.shape, // MCP SDK compatibility - handler: async (args: Record): Promise => { - try { - // Runtime validation with XOR constraints - const validatedParams = launchAppSimSchema.parse(args); - return await launch_app_simLogic( - validatedParams as LaunchAppSimParams, - getDefaultCommandExecutor(), - ); - } catch (error) { - if (error instanceof z.ZodError) { - // Format validation errors in a user-friendly way - const errorMessages = error.errors.map((e) => { - return `${e.path.join('.')}: ${e.message}`; - }); - return { - content: [ - { - type: 'text', - text: `Parameter validation failed:\n${errorMessages.join('\n')}`, - }, - ], - isError: true, - }; - } - - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error in launch_app_sim handler: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `Launch app operation failed: ${errorMessage}`, - }, - ], - isError: true, - }; - } - }, + description: 'Launches an app in an iOS simulator.', + schema: publicSchemaObject.shape, + handler: createSessionAwareTool({ + internalSchema: launchAppSimSchema as unknown as z.ZodType, + logicFunction: launch_app_simLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, + ], + exclusivePairs: [['simulatorId', 'simulatorName']], + }), }; diff --git a/src/mcp/tools/simulator/record_sim_video.ts b/src/mcp/tools/simulator/record_sim_video.ts index 2db256db..270aecc6 100644 --- a/src/mcp/tools/simulator/record_sim_video.ts +++ b/src/mcp/tools/simulator/record_sim_video.ts @@ -15,12 +15,12 @@ import { startSimulatorVideoCapture, stopSimulatorVideoCapture, } from '../../../utils/video-capture/index.ts'; -import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; import { dirname } from 'path'; // Base schema object (used for MCP schema exposure) const recordSimVideoSchemaObject = z.object({ - simulatorUuid: z + simulatorId: z .string() .uuid('Invalid Simulator UUID format') .describe('UUID of the simulator to record'), @@ -92,7 +92,7 @@ export async function record_sim_videoLogic( if (params.start) { const fpsUsed = Number.isFinite(params.fps as number) ? Number(params.fps) : 30; const startRes = await video.startSimulatorVideoCapture( - { simulatorUuid: params.simulatorUuid, fps: fpsUsed }, + { simulatorUuid: params.simulatorId, fps: fpsUsed }, executor, ); @@ -115,13 +115,13 @@ export async function record_sim_videoLogic( const nextSteps = `Next Steps: Stop and save the recording: -record_sim_video({ simulatorUuid: "${params.simulatorUuid}", stop: true, outputFile: "/path/to/output.mp4" })`; +record_sim_video({ simulatorId: "${params.simulatorId}", stop: true, outputFile: "/path/to/output.mp4" })`; return { content: [ { type: 'text', - text: `🎥 Video recording started for simulator ${params.simulatorUuid} at ${fpsUsed} fps.\nSession: ${startRes.sessionId}`, + text: `🎥 Video recording started for simulator ${params.simulatorId} at ${fpsUsed} fps.\nSession: ${startRes.sessionId}`, }, ...(notes.length > 0 ? [ @@ -142,7 +142,7 @@ record_sim_video({ simulatorUuid: "${params.simulatorUuid}", stop: true, outputF // params.stop must be true here per schema const stopRes = await video.stopSimulatorVideoCapture( - { simulatorUuid: params.simulatorUuid }, + { simulatorUuid: params.simulatorId }, executor, ); @@ -194,7 +194,7 @@ record_sim_video({ simulatorUuid: "${params.simulatorUuid}", stop: true, outputF content: [ { type: 'text', - text: `✅ Video recording stopped for simulator ${params.simulatorUuid}.`, + text: `✅ Video recording stopped for simulator ${params.simulatorId}.`, }, ...(outputs.length > 0 ? [ @@ -218,10 +218,18 @@ record_sim_video({ simulatorUuid: "${params.simulatorUuid}", stop: true, outputF }; } +const publicSchemaObject = recordSimVideoSchemaObject.omit({ + simulatorId: true, +} as const); + export default { name: 'record_sim_video', - description: - 'Starts or stops video capture for an iOS simulator using AXe. Provide exactly one of start=true or stop=true. On stop, outputFile is required. fps defaults to 30.', - schema: recordSimVideoSchemaObject.shape, - handler: createTypedTool(recordSimVideoSchema, record_sim_videoLogic, getDefaultCommandExecutor), + description: 'Starts or stops video capture for an iOS simulator.', + schema: publicSchemaObject.shape, + handler: createSessionAwareTool({ + internalSchema: recordSimVideoSchema as unknown as z.ZodType, + logicFunction: record_sim_videoLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], + }), }; diff --git a/src/mcp/tools/simulator/stop_app_sim.ts b/src/mcp/tools/simulator/stop_app_sim.ts index 45bb88bd..b8d37a5e 100644 --- a/src/mcp/tools/simulator/stop_app_sim.ts +++ b/src/mcp/tools/simulator/stop_app_sim.ts @@ -4,62 +4,44 @@ import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; +import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; -// Unified schema: XOR between simulatorUuid and simulatorName -const baseOptions = { - simulatorUuid: z +const baseSchemaObject = z.object({ + simulatorId: z .string() .optional() .describe( - 'UUID of the simulator (obtained from list_simulators). Provide EITHER this OR simulatorName, not both', + 'UUID of the simulator to use (obtained from list_sims). Provide EITHER this OR simulatorName, not both', ), simulatorName: z .string() .optional() .describe( - "Name of the simulator (e.g., 'iPhone 16'). Provide EITHER this OR simulatorUuid, not both", + "Name of the simulator (e.g., 'iPhone 16'). Provide EITHER this OR simulatorId, not both", ), bundleId: z.string().describe("Bundle identifier of the app to stop (e.g., 'com.example.MyApp')"), -}; +}); -const baseSchemaObject = z.object(baseOptions); - -const stopAppSimSchema = baseSchemaObject - .transform(nullifyEmptyStrings) - .refine( - (val) => - (val as StopAppSimParams).simulatorUuid !== undefined || - (val as StopAppSimParams).simulatorName !== undefined, - { - message: 'Either simulatorUuid or simulatorName is required.', - }, - ) - .refine( - (val) => - !( - (val as StopAppSimParams).simulatorUuid !== undefined && - (val as StopAppSimParams).simulatorName !== undefined - ), - { - message: 'simulatorUuid and simulatorName are mutually exclusive. Provide only one.', - }, - ); - -export type StopAppSimParams = { - simulatorUuid?: string; - simulatorName?: string; - bundleId: string; -}; +const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); + +const stopAppSimSchema = baseSchema + .refine((val) => val.simulatorId !== undefined || val.simulatorName !== undefined, { + message: 'Either simulatorId or simulatorName is required.', + }) + .refine((val) => !(val.simulatorId !== undefined && val.simulatorName !== undefined), { + message: 'simulatorId and simulatorName are mutually exclusive. Provide only one.', + }); + +export type StopAppSimParams = z.infer; export async function stop_app_simLogic( params: StopAppSimParams, executor: CommandExecutor, ): Promise { - let simulatorUuid = params.simulatorUuid; - let simulatorDisplayName = simulatorUuid ?? ''; + let simulatorId = params.simulatorId; + let simulatorDisplayName = simulatorId ?? ''; - // If simulatorName is provided, look it up - if (params.simulatorName && !simulatorUuid) { + if (params.simulatorName && !simulatorId) { log('info', `Looking up simulator by name: ${params.simulatorName}`); const simulatorListResult = await executor( @@ -78,15 +60,14 @@ export async function stop_app_simLogic( isError: true, }; } + const simulatorsData = JSON.parse(simulatorListResult.output) as { - devices: Record; + devices: Record>; }; let foundSimulator: { udid: string; name: string } | null = null; - - // Find the target simulator by name for (const runtime in simulatorsData.devices) { - const devices = simulatorsData.devices[runtime] as Array<{ udid: string; name: string }>; + const devices = simulatorsData.devices[runtime]; const simulator = devices.find((device) => device.name === params.simulatorName); if (simulator) { foundSimulator = simulator; @@ -106,26 +87,26 @@ export async function stop_app_simLogic( }; } - simulatorUuid = foundSimulator.udid; + simulatorId = foundSimulator.udid; simulatorDisplayName = `"${params.simulatorName}" (${foundSimulator.udid})`; } - if (!simulatorUuid) { + if (!simulatorId) { return { content: [ { type: 'text', - text: 'No simulator UUID or name provided', + text: 'No simulator identifier provided', }, ], isError: true, }; } - log('info', `Stopping app ${params.bundleId} in simulator ${simulatorUuid}`); + log('info', `Stopping app ${params.bundleId} in simulator ${simulatorId}`); try { - const command = ['xcrun', 'simctl', 'terminate', simulatorUuid, params.bundleId]; + const command = ['xcrun', 'simctl', 'terminate', simulatorId, params.bundleId]; const result = await executor(command, 'Stop App in Simulator', true, undefined); if (!result.success) { @@ -144,7 +125,9 @@ export async function stop_app_simLogic( content: [ { type: 'text', - text: `✅ App ${params.bundleId} stopped successfully in simulator ${simulatorDisplayName || simulatorUuid}`, + text: `✅ App ${params.bundleId} stopped successfully in simulator ${ + simulatorDisplayName || simulatorId + }`, }, ], }; @@ -163,47 +146,22 @@ export async function stop_app_simLogic( } } +const publicSchemaObject = baseSchemaObject.omit({ + simulatorId: true, + simulatorName: true, +} as const); + export default { name: 'stop_app_sim', - description: - 'Stops an app running in an iOS simulator by UUID or name. IMPORTANT: Provide either simulatorUuid OR simulatorName, plus bundleId. Example: stop_app_sim({ simulatorUuid: "UUID", bundleId: "com.example.MyApp" }) or stop_app_sim({ simulatorName: "iPhone 16", bundleId: "com.example.MyApp" })', - schema: baseSchemaObject.shape, // MCP SDK compatibility - handler: async (args: Record): Promise => { - try { - // Runtime validation with XOR constraints - const validatedParams = stopAppSimSchema.parse(args); - return await stop_app_simLogic( - validatedParams as StopAppSimParams, - getDefaultCommandExecutor(), - ); - } catch (error) { - if (error instanceof z.ZodError) { - // Format validation errors in a user-friendly way - const errorMessages = error.errors.map((e) => { - return `${e.path.join('.')}: ${e.message}`; - }); - return { - content: [ - { - type: 'text', - text: `Parameter validation failed:\n${errorMessages.join('\n')}`, - }, - ], - isError: true, - }; - } - - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error in stop_app_sim handler: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `Stop app operation failed: ${errorMessage}`, - }, - ], - isError: true, - }; - } - }, + description: 'Stops an app running in an iOS simulator.', + schema: publicSchemaObject.shape, + handler: createSessionAwareTool({ + internalSchema: stopAppSimSchema as unknown as z.ZodType, + logicFunction: stop_app_simLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, + ], + exclusivePairs: [['simulatorId', 'simulatorName']], + }), }; diff --git a/src/utils/log_capture.ts b/src/utils/log_capture.ts index dadfb4bf..6588aa42 100644 --- a/src/utils/log_capture.ts +++ b/src/utils/log_capture.ts @@ -32,13 +32,14 @@ export async function startLogCapture( simulatorUuid: string; bundleId: string; captureConsole?: boolean; + args?: string[]; }, executor: CommandExecutor = getDefaultCommandExecutor(), ): Promise<{ sessionId: string; logFilePath: string; processes: ChildProcess[]; error?: string }> { // Clean up old logs before starting a new session await cleanOldLogs(); - const { simulatorUuid, bundleId, captureConsole = false } = params; + const { simulatorUuid, bundleId, captureConsole = false, args = [] } = params; const logSessionId = uuidv4(); const logFileName = `${LOG_FILE_PREFIX}${logSessionId}.log`; const logFilePath = path.join(os.tmpdir(), logFileName); @@ -51,16 +52,21 @@ export async function startLogCapture( logStream.write('\n--- Log capture for bundle ID: ' + bundleId + ' ---\n'); if (captureConsole) { + const launchCommand = [ + 'xcrun', + 'simctl', + 'launch', + '--console-pty', + '--terminate-running-process', + simulatorUuid, + bundleId, + ]; + if (args.length > 0) { + launchCommand.push(...args); + } + const stdoutLogResult = await executor( - [ - 'xcrun', - 'simctl', - 'launch', - '--console-pty', - '--terminate-running-process', - simulatorUuid, - bundleId, - ], + launchCommand, 'Console Log Capture', true, // useShell undefined, // env