From 89cd11cdbf108e5d98db2e923caba0b9fb141d25 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 6 Oct 2025 21:38:13 +0100 Subject: [PATCH] feat(simulator): migrate test_sim and get_sim_app_path to session defaults --- docs/session-aware-migration-todo.md | 64 ++++++ .../__tests__/get_sim_app_path.test.ts | 195 ++++++++++++++++++ .../simulator/__tests__/test_sim.test.ts | 100 +++++++++ src/mcp/tools/simulator/get_sim_app_path.ts | 37 +++- src/mcp/tools/simulator/test_sim.ts | 64 +++--- 5 files changed, 420 insertions(+), 40 deletions(-) create mode 100644 docs/session-aware-migration-todo.md create mode 100644 src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts create mode 100644 src/mcp/tools/simulator/__tests__/test_sim.test.ts diff --git a/docs/session-aware-migration-todo.md b/docs/session-aware-migration-todo.md new file mode 100644 index 00000000..7f5be6c5 --- /dev/null +++ b/docs/session-aware-migration-todo.md @@ -0,0 +1,64 @@ +# Session-Aware Migration TODO + +_Audit date: October 6, 2025_ + +Reference: `docs/session_management_plan.md` + +## Utilities +- [ ] `src/mcp/tools/utilities/clean.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `configuration`. + +## Project Discovery +- [ ] `src/mcp/tools/project-discovery/list_schemes.ts` — session defaults: `projectPath`, `workspacePath`. +- [ ] `src/mcp/tools/project-discovery/show_build_settings.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`. + +## Device Workflows +- [ ] `src/mcp/tools/device/build_device.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `configuration`. +- [ ] `src/mcp/tools/device/test_device.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `deviceId`, `configuration`. +- [ ] `src/mcp/tools/device/get_device_app_path.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `configuration`. +- [ ] `src/mcp/tools/device/install_app_device.ts` — session defaults: `deviceId`. +- [ ] `src/mcp/tools/device/launch_app_device.ts` — session defaults: `deviceId`. +- [ ] `src/mcp/tools/device/stop_app_device.ts` — session defaults: `deviceId`. + +## Device Logging +- [ ] `src/mcp/tools/logging/start_device_log_cap.ts` — session defaults: `deviceId`. + +## macOS Workflows +- [ ] `src/mcp/tools/macos/build_macos.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `configuration`, `arch`. +- [ ] `src/mcp/tools/macos/build_run_macos.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `configuration`, `arch`. +- [ ] `src/mcp/tools/macos/test_macos.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `configuration`. +- [ ] `src/mcp/tools/macos/get_mac_app_path.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `configuration`, `arch`. + +## Simulator Build/Test/Path +- [x] `src/mcp/tools/simulator/test_sim.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `simulatorId`, `simulatorName`, `configuration`, `useLatestOS`. +- [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`). + +## Simulator Management +- [ ] `src/mcp/tools/simulator-management/erase_sims.ts` — session defaults: `simulatorId` (covers `simulatorUdid`). +- [ ] `src/mcp/tools/simulator-management/set_sim_location.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [ ] `src/mcp/tools/simulator-management/reset_sim_location.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [ ] `src/mcp/tools/simulator-management/set_sim_appearance.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [ ] `src/mcp/tools/simulator-management/sim_statusbar.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). + +## Simulator Logging +- [ ] `src/mcp/tools/logging/start_sim_log_cap.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). + +## AXe UI Testing Tools +- [ ] `src/mcp/tools/ui-testing/button.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [ ] `src/mcp/tools/ui-testing/describe_ui.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [ ] `src/mcp/tools/ui-testing/gesture.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [ ] `src/mcp/tools/ui-testing/key_press.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [ ] `src/mcp/tools/ui-testing/key_sequence.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [ ] `src/mcp/tools/ui-testing/long_press.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [ ] `src/mcp/tools/ui-testing/screenshot.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [ ] `src/mcp/tools/ui-testing/swipe.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [ ] `src/mcp/tools/ui-testing/tap.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [ ] `src/mcp/tools/ui-testing/touch.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [ ] `src/mcp/tools/ui-testing/type_text.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). 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 new file mode 100644 index 00000000..b8ae2822 --- /dev/null +++ b/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts @@ -0,0 +1,195 @@ +/** + * 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 { ChildProcess } from 'child_process'; +import { z } from 'zod'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; +import getSimAppPath, { get_sim_app_pathLogic } from '../get_sim_app_path.ts'; +import type { CommandExecutor } from '../../../../utils/CommandExecutor.ts'; + +describe('get_sim_app_path tool', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(getSimAppPath.name).toBe('get_sim_app_path'); + }); + + it('should have concise description', () => { + expect(getSimAppPath.description).toBe('Retrieves the built app path for an iOS simulator.'); + }); + + it('should have handler function', () => { + expect(typeof getSimAppPath.handler).toBe('function'); + }); + + it('should expose only platform in public schema', () => { + const schema = z.object(getSimAppPath.schema); + + expect(schema.safeParse({ platform: 'iOS Simulator' }).success).toBe(true); + expect(schema.safeParse({}).success).toBe(false); + expect(schema.safeParse({ platform: 'iOS' }).success).toBe(false); + + const schemaKeys = Object.keys(getSimAppPath.schema).sort(); + expect(schemaKeys).toEqual(['platform']); + }); + }); + + describe('Handler Requirements', () => { + it('should require scheme when not provided', async () => { + const result = await getSimAppPath.handler({ + platform: 'iOS Simulator', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('scheme is required'); + }); + + it('should require project or workspace when scheme default exists', async () => { + sessionStore.setDefaults({ scheme: 'MyScheme' }); + + const result = await getSimAppPath.handler({ + platform: 'iOS Simulator', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Provide a project or workspace'); + }); + + it('should require simulator identifier when scheme and project defaults exist', async () => { + sessionStore.setDefaults({ + scheme: 'MyScheme', + projectPath: '/path/to/project.xcodeproj', + }); + + const result = await getSimAppPath.handler({ + platform: 'iOS Simulator', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Provide simulatorId or simulatorName'); + }); + + it('should error when both projectPath and workspacePath provided explicitly', async () => { + sessionStore.setDefaults({ scheme: 'MyScheme' }); + + const result = await getSimAppPath.handler({ + platform: 'iOS Simulator', + projectPath: '/path/project.xcodeproj', + workspacePath: '/path/workspace.xcworkspace', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); + expect(result.content[0].text).toContain('projectPath'); + expect(result.content[0].text).toContain('workspacePath'); + }); + + it('should error when both simulatorId and simulatorName provided explicitly', async () => { + sessionStore.setDefaults({ + scheme: 'MyScheme', + workspacePath: '/path/to/workspace.xcworkspace', + }); + + const result = await getSimAppPath.handler({ + platform: 'iOS Simulator', + simulatorId: 'SIM-UUID', + simulatorName: 'iPhone 16', + }); + + 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', () => { + it('should return app path with simulator name destination', async () => { + const callHistory: Array<{ + command: string[]; + logPrefix?: string; + useShell?: boolean; + opts?: unknown; + }> = []; + + const trackingExecutor: CommandExecutor = async ( + command, + logPrefix, + useShell, + opts, + ): Promise<{ + success: boolean; + output: string; + process: ChildProcess; + }> => { + callHistory.push({ command, logPrefix, useShell, opts }); + return { + success: true, + output: + ' BUILT_PRODUCTS_DIR = /tmp/DerivedData/Build\n FULL_PRODUCT_NAME = MyApp.app\n', + process: { pid: 12345 } as unknown as ChildProcess, + }; + }; + + const result = await get_sim_app_pathLogic( + { + workspacePath: '/path/to/workspace.xcworkspace', + scheme: 'MyScheme', + platform: 'iOS Simulator', + simulatorName: 'iPhone 16', + useLatestOS: true, + }, + trackingExecutor, + ); + + expect(callHistory).toHaveLength(1); + expect(callHistory[0].logPrefix).toBe('Get App Path'); + expect(callHistory[0].useShell).toBe(true); + expect(callHistory[0].command).toEqual([ + 'xcodebuild', + '-showBuildSettings', + '-workspace', + '/path/to/workspace.xcworkspace', + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + '-destination', + 'platform=iOS Simulator,name=iPhone 16,OS=latest', + ]); + + expect(result.isError).toBe(false); + expect(result.content[0].text).toContain( + '✅ App path retrieved successfully: /tmp/DerivedData/Build/MyApp.app', + ); + }); + + it('should surface executor failures when build settings cannot be retrieved', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'Failed to run xcodebuild', + }); + + const result = await get_sim_app_pathLogic( + { + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyScheme', + platform: 'iOS Simulator', + 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'); + }); + }); +}); diff --git a/src/mcp/tools/simulator/__tests__/test_sim.test.ts b/src/mcp/tools/simulator/__tests__/test_sim.test.ts new file mode 100644 index 00000000..fed2ff2c --- /dev/null +++ b/src/mcp/tools/simulator/__tests__/test_sim.test.ts @@ -0,0 +1,100 @@ +/** + * 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 { z } from 'zod'; +import { sessionStore } from '../../../../utils/session-store.ts'; +import testSim from '../test_sim.ts'; + +describe('test_sim tool', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(testSim.name).toBe('test_sim'); + }); + + it('should have concise description', () => { + expect(testSim.description).toBe('Runs tests on an iOS simulator.'); + }); + + it('should have handler function', () => { + expect(typeof testSim.handler).toBe('function'); + }); + + it('should expose only non-session fields in public schema', () => { + const schema = z.object(testSim.schema); + + expect(schema.safeParse({}).success).toBe(true); + expect( + schema.safeParse({ + derivedDataPath: '/tmp/derived', + extraArgs: ['--quiet'], + preferXcodebuild: true, + testRunnerEnv: { FOO: 'BAR' }, + }).success, + ).toBe(true); + + expect(schema.safeParse({ derivedDataPath: 123 }).success).toBe(false); + expect(schema.safeParse({ extraArgs: ['--ok', 42] }).success).toBe(false); + expect(schema.safeParse({ preferXcodebuild: 'yes' }).success).toBe(false); + expect(schema.safeParse({ testRunnerEnv: { FOO: 123 } }).success).toBe(false); + + const schemaKeys = Object.keys(testSim.schema).sort(); + expect(schemaKeys).toEqual( + ['derivedDataPath', 'extraArgs', 'preferXcodebuild', 'testRunnerEnv'].sort(), + ); + }); + }); + + describe('Handler Requirements', () => { + it('should require scheme when not provided', async () => { + const result = await testSim.handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('scheme is required'); + }); + + it('should require project or workspace when scheme default exists', async () => { + sessionStore.setDefaults({ scheme: 'MyScheme' }); + + const result = await testSim.handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Provide a project or workspace'); + }); + + it('should require simulator identifier when scheme and project defaults exist', async () => { + sessionStore.setDefaults({ + scheme: 'MyScheme', + projectPath: '/path/to/project.xcodeproj', + }); + + const result = await testSim.handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Provide simulatorId or simulatorName'); + }); + + it('should error when both simulatorId and simulatorName provided explicitly', async () => { + sessionStore.setDefaults({ + scheme: 'MyScheme', + workspacePath: '/path/to/workspace.xcworkspace', + }); + + const result = await testSim.handler({ + simulatorId: 'SIM-UUID', + simulatorName: 'iPhone 16', + }); + + 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'); + }); + }); +}); diff --git a/src/mcp/tools/simulator/get_sim_app_path.ts b/src/mcp/tools/simulator/get_sim_app_path.ts index 44a1df75..5c3b78d1 100644 --- a/src/mcp/tools/simulator/get_sim_app_path.ts +++ b/src/mcp/tools/simulator/get_sim_app_path.ts @@ -12,7 +12,7 @@ import { createTextResponse } from '../../../utils/responses/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { ToolResponse } from '../../../types/common.ts'; -import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; const XcodePlatform = { @@ -289,14 +289,33 @@ export async function get_sim_app_pathLogic( } } +const publicSchemaObject = baseGetSimulatorAppPathSchema.omit({ + projectPath: true, + workspacePath: true, + scheme: true, + simulatorId: true, + simulatorName: true, + configuration: true, + useLatestOS: true, + arch: true, +} as const); + export default { name: 'get_sim_app_path', - description: - "Gets the app bundle path for a simulator by UUID or name using either a project or workspace file. IMPORTANT: Requires either projectPath OR workspacePath (not both), plus scheme, platform, and either simulatorId OR simulatorName (not both). Example: get_sim_app_path({ projectPath: '/path/to/project.xcodeproj', scheme: 'MyScheme', platform: 'iOS Simulator', simulatorName: 'iPhone 16' })", - schema: baseGetSimulatorAppPathSchema.shape, // MCP SDK compatibility - handler: createTypedTool( - getSimulatorAppPathSchema as z.ZodType, - get_sim_app_pathLogic, - getDefaultCommandExecutor, - ), + description: 'Retrieves the built app path for an iOS simulator.', + schema: publicSchemaObject.shape, + handler: createSessionAwareTool({ + internalSchema: getSimulatorAppPathSchema as unknown as z.ZodType, + logicFunction: get_sim_app_pathLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['scheme'], message: 'scheme is required' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, + ], + exclusivePairs: [ + ['projectPath', 'workspacePath'], + ['simulatorId', 'simulatorName'], + ], + }), }; diff --git a/src/mcp/tools/simulator/test_sim.ts b/src/mcp/tools/simulator/test_sim.ts index a89fb02c..b482570a 100644 --- a/src/mcp/tools/simulator/test_sim.ts +++ b/src/mcp/tools/simulator/test_sim.ts @@ -14,6 +14,7 @@ import { ToolResponse } from '../../../types/common.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'; // Define base schema object with all fields const baseSchemaObject = z.object({ @@ -72,6 +73,12 @@ const testSimulatorSchema = baseSchema }) .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), { message: 'projectPath and workspacePath are mutually exclusive. Provide only one.', + }) + .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.', }); // Use z.infer for type safety @@ -108,37 +115,32 @@ export async function test_simLogic( ); } +const publicSchemaObject = baseSchemaObject.omit({ + projectPath: true, + workspacePath: true, + scheme: true, + simulatorId: true, + simulatorName: true, + configuration: true, + useLatestOS: true, +} as const); + export default { name: 'test_sim', - description: - 'Runs tests on a simulator by UUID or name using xcodebuild test and parses xcresult output. Works with both Xcode projects (.xcodeproj) and workspaces (.xcworkspace). IMPORTANT: Requires either projectPath or workspacePath, plus scheme and either simulatorId or simulatorName. Example: test_sim({ projectPath: "/path/to/MyProject.xcodeproj", scheme: "MyScheme", simulatorName: "iPhone 16" })', - schema: baseSchemaObject.shape, // MCP SDK compatibility - handler: async (args: Record): Promise => { - try { - // Runtime validation with XOR constraints - const validatedParams = testSimulatorSchema.parse(args); - return await test_simLogic(validatedParams, getDefaultCommandExecutor()); - } catch (error) { - if (error instanceof z.ZodError) { - // Format validation errors in a user-friendly way - const errorMessages = error.errors.map((e) => { - const path = e.path.length > 0 ? `${e.path.join('.')}` : 'root'; - return `${path}: ${e.message}`; - }); - - return { - content: [ - { - type: 'text', - text: `Parameter validation failed. Invalid parameters:\n${errorMessages.join('\n')}`, - }, - ], - isError: true, - }; - } - - // Re-throw unexpected errors - throw error; - } - }, + description: 'Runs tests on an iOS simulator.', + schema: publicSchemaObject.shape, + handler: createSessionAwareTool({ + internalSchema: testSimulatorSchema as unknown as z.ZodType, + logicFunction: test_simLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['scheme'], message: 'scheme is required' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, + ], + exclusivePairs: [ + ['projectPath', 'workspacePath'], + ['simulatorId', 'simulatorName'], + ], + }), };