From d0c517bd02b0ba4e6dbb802da0e07d2e91639000 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Wed, 8 Apr 2026 22:03:59 +0100 Subject: [PATCH 1/3] refactor: migrate device and macOS tools to event-based handler contract --- .../device/__tests__/build_device.test.ts | 356 +++++------- .../device/__tests__/build_run_device.test.ts | 537 +++++++++++------- .../__tests__/get_device_app_path.test.ts | 239 ++++---- .../__tests__/install_app_device.test.ts | 216 +++---- .../__tests__/launch_app_device.test.ts | 339 ++++------- .../device/__tests__/list_devices.test.ts | 106 ++-- .../tools/device/__tests__/re-exports.test.ts | 5 - .../device/__tests__/stop_app_device.test.ts | 215 +++---- .../device/__tests__/test_device.test.ts | 312 +++------- src/mcp/tools/device/build-settings.ts | 93 +-- src/mcp/tools/device/build_device.ts | 65 ++- src/mcp/tools/device/build_run_device.ts | 366 +++++++----- src/mcp/tools/device/get_device_app_path.ts | 117 ++-- src/mcp/tools/device/install_app_device.ts | 76 +-- src/mcp/tools/device/launch_app_device.ts | 138 ++--- src/mcp/tools/device/list_devices.ts | 492 +++++++--------- src/mcp/tools/device/stop_app_device.ts | 94 ++- src/mcp/tools/device/test_device.ts | 279 ++------- .../tools/macos/__tests__/build_macos.test.ts | 217 +++---- .../macos/__tests__/build_run_macos.test.ts | 308 ++++------ .../macos/__tests__/get_mac_app_path.test.ts | 241 ++++---- .../macos/__tests__/launch_mac_app.test.ts | 253 ++++----- .../tools/macos/__tests__/re-exports.test.ts | 6 - .../macos/__tests__/stop_mac_app.test.ts | 188 +++--- .../tools/macos/__tests__/test_macos.test.ts | 508 ++++------------- src/mcp/tools/macos/build_macos.ts | 134 +++-- src/mcp/tools/macos/build_run_macos.ts | 314 +++++----- src/mcp/tools/macos/get_mac_app_path.ts | 186 +++--- src/mcp/tools/macos/launch_mac_app.ts | 89 ++- src/mcp/tools/macos/stop_mac_app.ts | 97 ++-- src/mcp/tools/macos/test_macos.ts | 253 ++------- 31 files changed, 2732 insertions(+), 4107 deletions(-) diff --git a/src/mcp/tools/device/__tests__/build_device.test.ts b/src/mcp/tools/device/__tests__/build_device.test.ts index aa2153d9..4e33390b 100644 --- a/src/mcp/tools/device/__tests__/build_device.test.ts +++ b/src/mcp/tools/device/__tests__/build_device.test.ts @@ -1,19 +1,26 @@ -/** - * Tests for build_device plugin (unified) - * Following CLAUDE.md testing standards with literal validation - * Using dependency injection for deterministic testing - */ - import { describe, it, expect, beforeEach } from 'vitest'; +import { DERIVED_DATA_DIR } from '../../../../utils/log-paths.ts'; import * as z from 'zod'; -import { - createMockCommandResponse, - createMockExecutor, - createNoopExecutor, -} from '../../../../test-utils/mock-executors.ts'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { expectPendingBuildResponse, runToolLogic } from '../../../../test-utils/test-helpers.ts'; import { schema, handler, buildDeviceLogic } from '../build_device.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; +function createSpyExecutor(): { + commandCalls: Array<{ args: string[]; logPrefix?: string }>; + executor: ReturnType; +} { + const commandCalls: Array<{ args: string[]; logPrefix?: string }> = []; + const executor = createMockExecutor({ + success: true, + output: 'Build succeeded', + onExecute: (command, logPrefix) => { + commandCalls.push({ args: command, logPrefix }); + }, + }); + return { commandCalls, executor }; +} + describe('build_device plugin', () => { beforeEach(() => { sessionStore.clear(); @@ -93,17 +100,18 @@ describe('build_device plugin', () => { output: 'Build succeeded', }); - const result = await buildDeviceLogic( - { - projectPath: '/path/to/MyProject.xcodeproj', - scheme: 'MyScheme', - }, - mockExecutor, + const { result } = await runToolLogic(() => + buildDeviceLogic( + { + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyScheme', + }, + mockExecutor, + ), ); - expect(result.isError).toBeUndefined(); - expect(result.content).toHaveLength(2); - expect(result.content[0].text).toContain('✅ iOS Device Build build succeeded'); + expect(result.isError()).toBeFalsy(); + expectPendingBuildResponse(result, 'get_device_app_path'); }); it('should pass validation and execute successfully with valid workspace parameters', async () => { @@ -112,121 +120,82 @@ describe('build_device plugin', () => { output: 'Build succeeded', }); - const result = await buildDeviceLogic( - { - workspacePath: '/path/to/MyProject.xcworkspace', - scheme: 'MyScheme', - }, - mockExecutor, + const { result } = await runToolLogic(() => + buildDeviceLogic( + { + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyScheme', + }, + mockExecutor, + ), ); - expect(result.isError).toBeUndefined(); - expect(result.content).toHaveLength(2); - expect(result.content[0].text).toContain('✅ iOS Device Build build succeeded'); + expect(result.isError()).toBeFalsy(); + expectPendingBuildResponse(result, 'get_device_app_path'); }); it('should verify workspace command generation with mock executor', async () => { - const commandCalls: Array<{ - args: string[]; - logPrefix?: string; - silent?: boolean; - opts: { cwd?: string } | undefined; - }> = []; - - const stubExecutor = async ( - args: string[], - logPrefix?: string, - silent?: boolean, - opts?: { cwd?: string }, - _detached?: boolean, - ) => { - commandCalls.push({ args, logPrefix, silent, opts }); - return createMockCommandResponse({ - success: true, - output: 'Build succeeded', - error: undefined, - }); - }; - - await buildDeviceLogic( - { - workspacePath: '/path/to/MyProject.xcworkspace', - scheme: 'MyScheme', - }, - stubExecutor, + const spy = createSpyExecutor(); + + await runToolLogic(() => + buildDeviceLogic( + { + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyScheme', + }, + spy.executor, + ), ); - expect(commandCalls).toHaveLength(1); - expect(commandCalls[0]).toEqual({ - args: [ - 'xcodebuild', - '-workspace', - '/path/to/MyProject.xcworkspace', - '-scheme', - 'MyScheme', - '-configuration', - 'Debug', - '-skipMacroValidation', - '-destination', - 'generic/platform=iOS', - 'build', - ], - logPrefix: 'iOS Device Build', - silent: false, - opts: { cwd: '/path/to' }, - }); + expect(spy.commandCalls).toHaveLength(1); + expect(spy.commandCalls[0].args).toEqual([ + 'xcodebuild', + '-workspace', + '/path/to/MyProject.xcworkspace', + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'generic/platform=iOS', + '-derivedDataPath', + DERIVED_DATA_DIR, + 'build', + ]); + expect(spy.commandCalls[0].logPrefix).toBe('iOS Device Build'); }); it('should verify command generation with mock executor', async () => { - const commandCalls: Array<{ - args: string[]; - logPrefix?: string; - silent?: boolean; - opts: { cwd?: string } | undefined; - }> = []; - - const stubExecutor = async ( - args: string[], - logPrefix?: string, - silent?: boolean, - opts?: { cwd?: string }, - _detached?: boolean, - ) => { - commandCalls.push({ args, logPrefix, silent, opts }); - return createMockCommandResponse({ - success: true, - output: 'Build succeeded', - error: undefined, - }); - }; - - await buildDeviceLogic( - { - projectPath: '/path/to/MyProject.xcodeproj', - scheme: 'MyScheme', - }, - stubExecutor, + const spy = createSpyExecutor(); + + await runToolLogic(() => + buildDeviceLogic( + { + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyScheme', + }, + spy.executor, + ), ); - expect(commandCalls).toHaveLength(1); - expect(commandCalls[0]).toEqual({ - args: [ - 'xcodebuild', - '-project', - '/path/to/MyProject.xcodeproj', - '-scheme', - 'MyScheme', - '-configuration', - 'Debug', - '-skipMacroValidation', - '-destination', - 'generic/platform=iOS', - 'build', - ], - logPrefix: 'iOS Device Build', - silent: false, - opts: { cwd: '/path/to' }, - }); + expect(spy.commandCalls).toHaveLength(1); + expect(spy.commandCalls[0].args).toEqual([ + 'xcodebuild', + '-project', + '/path/to/MyProject.xcodeproj', + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'generic/platform=iOS', + '-derivedDataPath', + DERIVED_DATA_DIR, + 'build', + ]); + expect(spy.commandCalls[0].logPrefix).toBe('iOS Device Build'); }); it('should return exact successful build response', async () => { @@ -235,26 +204,18 @@ describe('build_device plugin', () => { output: 'Build succeeded', }); - const result = await buildDeviceLogic( - { - projectPath: '/path/to/MyProject.xcodeproj', - scheme: 'MyScheme', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const { result } = await runToolLogic(() => + buildDeviceLogic( { - type: 'text', - text: '✅ iOS Device Build build succeeded for scheme MyScheme.', + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyScheme', }, - { - type: 'text', - text: "Next Steps:\n1. Get app path: get_device_app_path({ scheme: 'MyScheme' })\n2. Get bundle ID: get_app_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_app_device({ bundleId: 'BUNDLE_ID_FROM_STEP_2' })", - }, - ], - }); + mockExecutor, + ), + ); + + expect(result.isError()).toBeFalsy(); + expectPendingBuildResponse(result, 'get_device_app_path'); }); it('should return exact build failure response', async () => { @@ -263,85 +224,54 @@ describe('build_device plugin', () => { error: 'Compilation error', }); - const result = await buildDeviceLogic( - { - projectPath: '/path/to/MyProject.xcodeproj', - scheme: 'MyScheme', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: '❌ [stderr] Compilation error', - }, + const { result } = await runToolLogic(() => + buildDeviceLogic( { - type: 'text', - text: '❌ iOS Device Build build failed for scheme MyScheme.', + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyScheme', }, - ], - isError: true, - }); + mockExecutor, + ), + ); + + expect(result.isError()).toBe(true); + expectPendingBuildResponse(result); }); it('should include optional parameters in command', async () => { - const commandCalls: Array<{ - args: string[]; - logPrefix?: string; - silent?: boolean; - opts: { cwd?: string } | undefined; - }> = []; - - const stubExecutor = async ( - args: string[], - logPrefix?: string, - silent?: boolean, - opts?: { cwd?: string }, - _detached?: boolean, - ) => { - commandCalls.push({ args, logPrefix, silent, opts }); - return createMockCommandResponse({ - success: true, - output: 'Build succeeded', - error: undefined, - }); - }; - - await buildDeviceLogic( - { - projectPath: '/path/to/MyProject.xcodeproj', - scheme: 'MyScheme', - configuration: 'Release', - derivedDataPath: '/tmp/derived-data', - extraArgs: ['--verbose'], - }, - stubExecutor, + const spy = createSpyExecutor(); + + await runToolLogic(() => + buildDeviceLogic( + { + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyScheme', + configuration: 'Release', + derivedDataPath: '/tmp/derived-data', + extraArgs: ['--verbose'], + }, + spy.executor, + ), ); - expect(commandCalls).toHaveLength(1); - expect(commandCalls[0]).toEqual({ - args: [ - 'xcodebuild', - '-project', - '/path/to/MyProject.xcodeproj', - '-scheme', - 'MyScheme', - '-configuration', - 'Release', - '-skipMacroValidation', - '-destination', - 'generic/platform=iOS', - '-derivedDataPath', - '/tmp/derived-data', - '--verbose', - 'build', - ], - logPrefix: 'iOS Device Build', - silent: false, - opts: { cwd: '/path/to' }, - }); + expect(spy.commandCalls).toHaveLength(1); + expect(spy.commandCalls[0].args).toEqual([ + 'xcodebuild', + '-project', + '/path/to/MyProject.xcodeproj', + '-scheme', + 'MyScheme', + '-configuration', + 'Release', + '-skipMacroValidation', + '-destination', + 'generic/platform=iOS', + '-derivedDataPath', + '/tmp/derived-data', + '--verbose', + 'build', + ]); + expect(spy.commandCalls[0].logPrefix).toBe('iOS Device Build'); }); }); }); diff --git a/src/mcp/tools/device/__tests__/build_run_device.test.ts b/src/mcp/tools/device/__tests__/build_run_device.test.ts index bebe15f4..07ce0bed 100644 --- a/src/mcp/tools/device/__tests__/build_run_device.test.ts +++ b/src/mcp/tools/device/__tests__/build_run_device.test.ts @@ -3,252 +3,347 @@ import * as z from 'zod'; import { createMockCommandResponse, createMockFileSystemExecutor, + createMockExecutor, } from '../../../../test-utils/mock-executors.ts'; +import { runToolLogic, type MockToolHandlerResult } from '../../../../test-utils/test-helpers.ts'; import type { CommandExecutor } from '../../../../utils/execution/index.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, build_run_deviceLogic } from '../build_run_device.ts'; +const runBuildRunDeviceLogic = ( + params: Parameters[0], + executor: Parameters[1], + fileSystemExecutor: Parameters[2], +) => runToolLogic(() => build_run_deviceLogic(params, executor, fileSystemExecutor)); + +function expectPendingBuildRunResponse(result: MockToolHandlerResult, isError: boolean): void { + expect(result.isError()).toBe(isError); + expect(result.events.some((event) => event.type === 'summary')).toBe(true); +} + describe('build_run_device tool', () => { beforeEach(() => { sessionStore.clear(); }); - it('exposes only non-session fields in public schema', () => { - const schemaObj = z.strictObject(schema); + describe('Export Field Validation', () => { + it('exposes only non-session fields in public schema', () => { + const schemaObj = z.strictObject(schema); - expect(schemaObj.safeParse({}).success).toBe(true); - expect(schemaObj.safeParse({ extraArgs: ['-quiet'] }).success).toBe(true); - expect(schemaObj.safeParse({ env: { FOO: 'bar' } }).success).toBe(true); + expect(schemaObj.safeParse({}).success).toBe(true); + expect(schemaObj.safeParse({ extraArgs: ['-quiet'] }).success).toBe(true); + expect(schemaObj.safeParse({ env: { FOO: 'bar' } }).success).toBe(true); - expect(schemaObj.safeParse({ scheme: 'App' }).success).toBe(false); - expect(schemaObj.safeParse({ deviceId: 'device-id' }).success).toBe(false); + expect(schemaObj.safeParse({ scheme: 'App' }).success).toBe(false); + expect(schemaObj.safeParse({ deviceId: 'device-id' }).success).toBe(false); - const schemaKeys = Object.keys(schema).sort(); - expect(schemaKeys).toEqual(['env', 'extraArgs']); + const schemaKeys = Object.keys(schema).sort(); + expect(schemaKeys).toEqual(['env', 'extraArgs']); + }); }); - it('requires scheme + deviceId and project/workspace via handler', async () => { - const missingAll = await handler({}); - expect(missingAll.isError).toBe(true); - expect(missingAll.content[0].text).toContain('Provide scheme and deviceId'); + describe('Handler Requirements', () => { + it('requires scheme + deviceId and project/workspace via handler', async () => { + const missingAll = await handler({}); + expect(missingAll.isError).toBe(true); + expect(missingAll.content[0].text).toContain('Provide scheme and deviceId'); - const missingSource = await handler({ scheme: 'MyApp', deviceId: 'DEVICE-UDID' }); - expect(missingSource.isError).toBe(true); - expect(missingSource.content[0].text).toContain('Provide a project or workspace'); + const missingSource = await handler({ scheme: 'MyApp', deviceId: 'DEVICE-UDID' }); + expect(missingSource.isError).toBe(true); + expect(missingSource.content[0].text).toContain('Provide a project or workspace'); + }); }); - it('builds, installs, and launches successfully', async () => { - const commands: string[] = []; - const mockExecutor: CommandExecutor = async (command) => { - commands.push(command.join(' ')); - - if (command.includes('-showBuildSettings')) { - return createMockCommandResponse({ - success: true, - output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyApp.app\n', - }); - } - - if (command[0] === '/bin/sh') { - return createMockCommandResponse({ success: true, output: 'io.sentry.MyApp' }); - } - - return createMockCommandResponse({ success: true, output: 'OK' }); - }; - - const result = await build_run_deviceLogic( - { - projectPath: '/tmp/MyApp.xcodeproj', - scheme: 'MyApp', - deviceId: 'DEVICE-UDID', - }, - mockExecutor, - createMockFileSystemExecutor({ - existsSync: () => true, - readFile: async () => JSON.stringify({ result: { process: { processIdentifier: 1234 } } }), - }), - ); - - expect(result.isError).toBe(false); - expect(result.content[0].text).toContain('device build and run succeeded'); - expect(result.nextStepParams).toMatchObject({ - start_device_log_cap: { deviceId: 'DEVICE-UDID', bundleId: 'io.sentry.MyApp' }, - stop_app_device: { deviceId: 'DEVICE-UDID', processId: 1234 }, + describe('Handler Behavior (Pending Pipeline Contract)', () => { + it('handles build failure as pending error', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'Build failed with error', + }); + + const { result } = await runBuildRunDeviceLogic( + { + projectPath: '/tmp/MyApp.xcodeproj', + scheme: 'MyApp', + deviceId: 'DEVICE-UDID', + }, + mockExecutor, + createMockFileSystemExecutor(), + ); + + expectPendingBuildRunResponse(result, true); + expect(result.nextStepParams).toBeUndefined(); }); - expect(commands.some((c) => c.includes('xcodebuild') && c.includes('build'))).toBe(true); - expect(commands.some((c) => c.includes('xcodebuild') && c.includes('-showBuildSettings'))).toBe( - true, - ); - expect(commands.some((c) => c.includes('devicectl') && c.includes('install'))).toBe(true); - expect(commands.some((c) => c.includes('devicectl') && c.includes('launch'))).toBe(true); - }); + it('handles build settings failure as pending error', async () => { + const mockExecutor: CommandExecutor = async (command) => { + if (command.includes('-showBuildSettings')) { + return createMockCommandResponse({ success: false, error: 'no build settings' }); + } + return createMockCommandResponse({ success: true, output: 'OK' }); + }; + + const { result } = await runBuildRunDeviceLogic( + { + projectPath: '/tmp/MyApp.xcodeproj', + scheme: 'MyApp', + deviceId: 'DEVICE-UDID', + }, + mockExecutor, + createMockFileSystemExecutor(), + ); + + expectPendingBuildRunResponse(result, true); + expect(result.nextStepParams).toBeUndefined(); + }); - it('uses generic destination for build-settings lookup', async () => { - const commandCalls: string[][] = []; - const mockExecutor: CommandExecutor = async (command) => { - commandCalls.push(command); - - if (command.includes('-showBuildSettings')) { - return createMockCommandResponse({ - success: true, - output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyWatchApp.app\n', - }); - } - - if (command[0] === '/bin/sh') { - return createMockCommandResponse({ success: true, output: 'io.sentry.MyWatchApp' }); - } - - if (command.includes('launch')) { - return createMockCommandResponse({ - success: true, - output: JSON.stringify({ result: { process: { processIdentifier: 9876 } } }), - }); - } - - return createMockCommandResponse({ success: true, output: 'OK' }); - }; - - const result = await build_run_deviceLogic( - { - projectPath: '/tmp/MyWatchApp.xcodeproj', - scheme: 'MyWatchApp', - platform: 'watchOS', - deviceId: 'DEVICE-UDID', - }, - mockExecutor, - createMockFileSystemExecutor({ existsSync: () => true }), - ); - - expect(result.isError).toBe(false); - - const showBuildSettingsCommand = commandCalls.find((command) => - command.includes('-showBuildSettings'), - ); - expect(showBuildSettingsCommand).toBeDefined(); - expect(showBuildSettingsCommand).toContain('-destination'); - - const destinationIndex = showBuildSettingsCommand!.indexOf('-destination'); - expect(showBuildSettingsCommand![destinationIndex + 1]).toBe('generic/platform=watchOS'); - }); + it('handles install failure as pending error', async () => { + const mockExecutor: CommandExecutor = async (command) => { + if (command.includes('-showBuildSettings')) { + return createMockCommandResponse({ + success: true, + output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyApp.app\n', + }); + } + + if (command[0] === '/bin/sh') { + return createMockCommandResponse({ success: true, output: 'io.sentry.MyApp' }); + } + + if (command.includes('install')) { + return createMockCommandResponse({ success: false, error: 'install failed' }); + } + + return createMockCommandResponse({ success: true, output: 'OK' }); + }; + + const { result } = await runBuildRunDeviceLogic( + { + projectPath: '/tmp/MyApp.xcodeproj', + scheme: 'MyApp', + deviceId: 'DEVICE-UDID', + }, + mockExecutor, + createMockFileSystemExecutor(), + ); + + expectPendingBuildRunResponse(result, true); + expect(result.nextStepParams).toBeUndefined(); + }); - it('includes fallback stop guidance when process id is unavailable', async () => { - const mockExecutor: CommandExecutor = async (command) => { - if (command.includes('-showBuildSettings')) { - return createMockCommandResponse({ - success: true, - output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyApp.app\n', - }); - } - - if (command[0] === '/bin/sh') { - return createMockCommandResponse({ success: true, output: 'io.sentry.MyApp' }); - } - - return createMockCommandResponse({ success: true, output: 'OK' }); - }; - - const result = await build_run_deviceLogic( - { - projectPath: '/tmp/MyApp.xcodeproj', - scheme: 'MyApp', - deviceId: 'DEVICE-UDID', - }, - mockExecutor, - createMockFileSystemExecutor({ - existsSync: () => true, - readFile: async () => 'not-json', - }), - ); - - expect(result.isError).toBe(false); - expect(result.content[0].text).toContain('Process ID was unavailable'); - expect(result.nextStepParams).toMatchObject({ - start_device_log_cap: { deviceId: 'DEVICE-UDID', bundleId: 'io.sentry.MyApp' }, + it('handles launch failure as pending error', async () => { + const mockExecutor: CommandExecutor = async (command) => { + if (command.includes('-showBuildSettings')) { + return createMockCommandResponse({ + success: true, + output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyApp.app\n', + }); + } + + if (command[0] === '/bin/sh') { + return createMockCommandResponse({ success: true, output: 'io.sentry.MyApp' }); + } + + if (command.includes('launch')) { + return createMockCommandResponse({ success: false, error: 'launch failed' }); + } + + return createMockCommandResponse({ success: true, output: 'OK' }); + }; + + const { result } = await runBuildRunDeviceLogic( + { + projectPath: '/tmp/MyApp.xcodeproj', + scheme: 'MyApp', + deviceId: 'DEVICE-UDID', + }, + mockExecutor, + createMockFileSystemExecutor(), + ); + + expectPendingBuildRunResponse(result, true); + expect(result.nextStepParams).toBeUndefined(); }); - expect(result.nextStepParams?.stop_app_device).toBeUndefined(); - }); - it('returns an error when app-path lookup fails after successful build', async () => { - const mockExecutor: CommandExecutor = async (command) => { - if (command.includes('-showBuildSettings')) { - return createMockCommandResponse({ success: false, error: 'no build settings' }); - } - return createMockCommandResponse({ success: true, output: 'OK' }); - }; - - const result = await build_run_deviceLogic( - { - projectPath: '/tmp/MyApp.xcodeproj', - scheme: 'MyApp', - deviceId: 'DEVICE-UDID', - }, - mockExecutor, - createMockFileSystemExecutor({ existsSync: () => true }), - ); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('failed to get app path'); - }); + it('handles successful build, install, and launch', async () => { + const mockExecutor: CommandExecutor = async (command) => { + if (command.includes('-showBuildSettings')) { + return createMockCommandResponse({ + success: true, + output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyApp.app\n', + }); + } + + if (command[0] === '/bin/sh') { + return createMockCommandResponse({ success: true, output: 'io.sentry.MyApp' }); + } + + return createMockCommandResponse({ success: true, output: 'OK' }); + }; + + const { result } = await runBuildRunDeviceLogic( + { + projectPath: '/tmp/MyApp.xcodeproj', + scheme: 'MyApp', + deviceId: 'DEVICE-UDID', + }, + mockExecutor, + createMockFileSystemExecutor({ + existsSync: () => true, + readFile: async () => + JSON.stringify({ result: { process: { processIdentifier: 1234 } } }), + }), + ); + + expectPendingBuildRunResponse(result, false); + expect(result.nextStepParams).toMatchObject({ + stop_app_device: { deviceId: 'DEVICE-UDID', processId: 1234 }, + }); + expect(result.events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'status-line', + level: 'success', + message: 'Build & Run complete', + }), + expect.objectContaining({ + type: 'detail-tree', + items: expect.arrayContaining([ + expect.objectContaining({ label: 'App Path', value: '/tmp/build/MyApp.app' }), + expect.objectContaining({ label: 'Bundle ID', value: 'io.sentry.MyApp' }), + expect.objectContaining({ label: 'Process ID', value: '1234' }), + ]), + }), + ]), + ); + }); - it('returns an error when install fails', async () => { - const mockExecutor: CommandExecutor = async (command) => { - if (command.includes('-showBuildSettings')) { - return createMockCommandResponse({ - success: true, - output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyApp.app\n', - }); - } - - if (command.includes('install')) { - return createMockCommandResponse({ success: false, error: 'install failed' }); - } - - return createMockCommandResponse({ success: true, output: 'io.sentry.MyApp' }); - }; - - const result = await build_run_deviceLogic( - { - projectPath: '/tmp/MyApp.xcodeproj', - scheme: 'MyApp', - deviceId: 'DEVICE-UDID', - }, - mockExecutor, - createMockFileSystemExecutor({ existsSync: () => true }), - ); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('error installing app on device'); - }); + it('succeeds without processId when launch JSON is unparseable', async () => { + const mockExecutor: CommandExecutor = async (command) => { + if (command.includes('-showBuildSettings')) { + return createMockCommandResponse({ + success: true, + output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyApp.app\n', + }); + } + + if (command[0] === '/bin/sh') { + return createMockCommandResponse({ success: true, output: 'io.sentry.MyApp' }); + } + + return createMockCommandResponse({ success: true, output: 'OK' }); + }; + + const { result } = await runBuildRunDeviceLogic( + { + projectPath: '/tmp/MyApp.xcodeproj', + scheme: 'MyApp', + deviceId: 'DEVICE-UDID', + }, + mockExecutor, + createMockFileSystemExecutor({ + existsSync: () => true, + readFile: async () => 'not-json', + }), + ); + + expectPendingBuildRunResponse(result, false); + expect(result.nextStepParams?.stop_app_device).toBeUndefined(); + + const completionEvent = result.events.find( + (event) => + event.type === 'status-line' && + event.level === 'success' && + event.message === 'Build & Run complete', + ); + expect(completionEvent).toBeDefined(); + + const detailTrees = result.events.filter((event) => event.type === 'detail-tree'); + const detailTree = detailTrees[detailTrees.length - 1] as + | { type: 'detail-tree'; items: Array<{ label: string; value: string }> } + | undefined; + expect(detailTree).toBeDefined(); + expect(detailTree?.items.some((item) => item.label === 'Process ID')).toBe(false); + }); + + it('uses generic destination for build-settings lookup', async () => { + const commandCalls: string[][] = []; + const mockExecutor: CommandExecutor = async (command) => { + commandCalls.push(command); + + if (command.includes('-showBuildSettings')) { + return createMockCommandResponse({ + success: true, + output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyWatchApp.app\n', + }); + } + + if (command[0] === '/bin/sh') { + return createMockCommandResponse({ success: true, output: 'io.sentry.MyWatchApp' }); + } + + if (command.includes('launch')) { + return createMockCommandResponse({ + success: true, + output: JSON.stringify({ result: { process: { processIdentifier: 9876 } } }), + }); + } + + return createMockCommandResponse({ success: true, output: 'OK' }); + }; + + const { result } = await runBuildRunDeviceLogic( + { + projectPath: '/tmp/MyWatchApp.xcodeproj', + scheme: 'MyWatchApp', + platform: 'watchOS', + deviceId: 'DEVICE-UDID', + }, + mockExecutor, + createMockFileSystemExecutor({ existsSync: () => true }), + ); + + expectPendingBuildRunResponse(result, false); + + const showBuildSettingsCommand = commandCalls.find((command) => + command.includes('-showBuildSettings'), + ); + expect(showBuildSettingsCommand).toBeDefined(); + expect(showBuildSettingsCommand).toContain('-destination'); + + const destinationIndex = showBuildSettingsCommand!.indexOf('-destination'); + expect(showBuildSettingsCommand![destinationIndex + 1]).toBe('generic/platform=watchOS'); + }); - it('returns an error when launch fails', async () => { - const mockExecutor: CommandExecutor = async (command) => { - if (command.includes('-showBuildSettings')) { - return createMockCommandResponse({ - success: true, - output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyApp.app\n', - }); - } - - if (command.includes('launch')) { - return createMockCommandResponse({ success: false, error: 'launch failed' }); - } - - return createMockCommandResponse({ success: true, output: 'io.sentry.MyApp' }); - }; - - const result = await build_run_deviceLogic( - { - projectPath: '/tmp/MyApp.xcodeproj', - scheme: 'MyApp', - deviceId: 'DEVICE-UDID', - }, - mockExecutor, - createMockFileSystemExecutor({ existsSync: () => true }), - ); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('error launching app on device'); + it('handles spawn error as pending error', async () => { + const mockExecutor = ( + command: string[], + description?: string, + logOutput?: boolean, + opts?: { cwd?: string }, + detached?: boolean, + ) => { + void command; + void description; + void logOutput; + void opts; + void detached; + return Promise.reject(new Error('spawn xcodebuild ENOENT')); + }; + + const { response, result } = await runBuildRunDeviceLogic( + { + projectPath: '/tmp/MyApp.xcodeproj', + scheme: 'MyApp', + deviceId: 'DEVICE-UDID', + }, + mockExecutor, + createMockFileSystemExecutor(), + ); + + expect(response).toBeUndefined(); + expectPendingBuildRunResponse(result, true); + expect(result.nextStepParams).toBeUndefined(); + }); }); }); diff --git a/src/mcp/tools/device/__tests__/get_device_app_path.test.ts b/src/mcp/tools/device/__tests__/get_device_app_path.test.ts index 67927372..bb136d8a 100644 --- a/src/mcp/tools/device/__tests__/get_device_app_path.test.ts +++ b/src/mcp/tools/device/__tests__/get_device_app_path.test.ts @@ -1,10 +1,5 @@ -/** - * Tests for get_device_app_path plugin (unified) - * Following CLAUDE.md testing standards with literal validation - * Using dependency injection for deterministic testing - */ - import { describe, it, expect, beforeEach } from 'vitest'; +import { DERIVED_DATA_DIR } from '../../../../utils/log-paths.ts'; import * as z from 'zod'; import { createMockCommandResponse, @@ -12,6 +7,40 @@ import { } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, get_device_app_pathLogic } from '../get_device_app_path.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; +import { createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; + +const runLogic = async (logic: () => Promise) => { + const { result, run } = createMockToolHandlerContext(); + const response = await run(logic); + + if ( + response && + typeof response === 'object' && + 'content' in (response as Record) + ) { + return response as { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + isError?: boolean; + nextStepParams?: unknown; + }; + } + + const text = result.text(); + const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; + const imageContent = result.attachments.map((attachment) => ({ + type: 'image' as const, + data: attachment.data, + mimeType: attachment.mimeType, + })); + + return { + content: [...textContent, ...imageContent], + isError: result.isError() ? true : undefined, + nextStepParams: result.nextStepParams, + attachments: result.attachments, + text, + }; +}; describe('get_device_app_path plugin', () => { beforeEach(() => { @@ -107,12 +136,14 @@ describe('get_device_app_path plugin', () => { ); }; - await get_device_app_pathLogic( - { - projectPath: '/path/to/project.xcodeproj', - scheme: 'MyScheme', - }, - mockExecutor, + await runLogic(() => + get_device_app_pathLogic( + { + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyScheme', + }, + mockExecutor, + ), ); expect(calls).toHaveLength(1); @@ -128,6 +159,8 @@ describe('get_device_app_path plugin', () => { 'Debug', '-destination', 'generic/platform=iOS', + '-derivedDataPath', + DERIVED_DATA_DIR, ], logPrefix: 'Get App Path', useShell: false, @@ -161,13 +194,15 @@ describe('get_device_app_path plugin', () => { ); }; - await get_device_app_pathLogic( - { - projectPath: '/path/to/project.xcodeproj', - scheme: 'MyScheme', - platform: 'watchOS', - }, - mockExecutor, + await runLogic(() => + get_device_app_pathLogic( + { + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyScheme', + platform: 'watchOS', + }, + mockExecutor, + ), ); expect(calls).toHaveLength(1); @@ -183,6 +218,8 @@ describe('get_device_app_path plugin', () => { 'Debug', '-destination', 'generic/platform=watchOS', + '-derivedDataPath', + DERIVED_DATA_DIR, ], logPrefix: 'Get App Path', useShell: false, @@ -216,12 +253,14 @@ describe('get_device_app_path plugin', () => { ); }; - await get_device_app_pathLogic( - { - workspacePath: '/path/to/workspace.xcworkspace', - scheme: 'MyScheme', - }, - mockExecutor, + await runLogic(() => + get_device_app_pathLogic( + { + workspacePath: '/path/to/workspace.xcworkspace', + scheme: 'MyScheme', + }, + mockExecutor, + ), ); expect(calls).toHaveLength(1); @@ -237,6 +276,8 @@ describe('get_device_app_path plugin', () => { 'Debug', '-destination', 'generic/platform=iOS', + '-derivedDataPath', + DERIVED_DATA_DIR, ], logPrefix: 'Get App Path', useShell: false, @@ -251,29 +292,24 @@ describe('get_device_app_path plugin', () => { 'Build settings for scheme "MyScheme"\n\nBUILT_PRODUCTS_DIR = /path/to/build/Debug-iphoneos\nFULL_PRODUCT_NAME = MyApp.app\n', }); - const result = await get_device_app_pathLogic( - { - projectPath: '/path/to/project.xcodeproj', - scheme: 'MyScheme', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + get_device_app_pathLogic( { - type: 'text', - text: '✅ App path retrieved successfully: /path/to/build/Debug-iphoneos/MyApp.app', + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyScheme', }, - ], - nextStepParams: { - get_app_bundle_id: { appPath: '/path/to/build/Debug-iphoneos/MyApp.app' }, - install_app_device: { - deviceId: 'DEVICE_UDID', - appPath: '/path/to/build/Debug-iphoneos/MyApp.app', - }, - launch_app_device: { deviceId: 'DEVICE_UDID', bundleId: 'BUNDLE_ID' }, + mockExecutor, + ), + ); + + expect(result.isError).toBeFalsy(); + expect(result.nextStepParams).toEqual({ + get_app_bundle_id: { appPath: '/path/to/build/Debug-iphoneos/MyApp.app' }, + install_app_device: { + deviceId: 'DEVICE_UDID', + appPath: '/path/to/build/Debug-iphoneos/MyApp.app', }, + launch_app_device: { deviceId: 'DEVICE_UDID', bundleId: 'BUNDLE_ID' }, }); }); @@ -283,23 +319,18 @@ describe('get_device_app_path plugin', () => { error: 'xcodebuild: error: The project does not exist.', }); - const result = await get_device_app_pathLogic( - { - projectPath: '/path/to/nonexistent.xcodeproj', - scheme: 'MyScheme', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + get_device_app_pathLogic( { - type: 'text', - text: 'Failed to get app path: xcodebuild: error: The project does not exist.', + projectPath: '/path/to/nonexistent.xcodeproj', + scheme: 'MyScheme', }, - ], - isError: true, - }); + mockExecutor, + ), + ); + + expect(result.isError).toBe(true); + expect(result.nextStepParams).toBeUndefined(); }); it('should return exact parse failure response', async () => { @@ -308,23 +339,18 @@ describe('get_device_app_path plugin', () => { output: 'Build settings without required fields', }); - const result = await get_device_app_pathLogic( - { - projectPath: '/path/to/project.xcodeproj', - scheme: 'MyScheme', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + get_device_app_pathLogic( { - type: 'text', - text: 'Failed to extract app path from build settings. Make sure the app has been built first.', + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyScheme', }, - ], - isError: true, - }); + mockExecutor, + ), + ); + + expect(result.isError).toBe(true); + expect(result.nextStepParams).toBeUndefined(); }); it('should include optional configuration parameter in command', async () => { @@ -353,13 +379,15 @@ describe('get_device_app_path plugin', () => { ); }; - await get_device_app_pathLogic( - { - projectPath: '/path/to/project.xcodeproj', - scheme: 'MyScheme', - configuration: 'Release', - }, - mockExecutor, + await runLogic(() => + get_device_app_pathLogic( + { + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyScheme', + configuration: 'Release', + }, + mockExecutor, + ), ); expect(calls).toHaveLength(1); @@ -375,6 +403,8 @@ describe('get_device_app_path plugin', () => { 'Release', '-destination', 'generic/platform=iOS', + '-derivedDataPath', + DERIVED_DATA_DIR, ], logPrefix: 'Get App Path', useShell: false, @@ -393,47 +423,18 @@ describe('get_device_app_path plugin', () => { return Promise.reject(new Error('Network error')); }; - const result = await get_device_app_pathLogic( - { - projectPath: '/path/to/project.xcodeproj', - scheme: 'MyScheme', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + get_device_app_pathLogic( { - type: 'text', - text: 'Error retrieving app path: Network error', + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyScheme', }, - ], - isError: true, - }); - }); - - it('should return exact string error handling response', async () => { - const mockExecutor = () => { - return Promise.reject('String error'); - }; - - const result = await get_device_app_pathLogic( - { - projectPath: '/path/to/project.xcodeproj', - scheme: 'MyScheme', - }, - mockExecutor, + mockExecutor, + ), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error retrieving app path: String error', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(result.nextStepParams).toBeUndefined(); }); }); }); diff --git a/src/mcp/tools/device/__tests__/install_app_device.test.ts b/src/mcp/tools/device/__tests__/install_app_device.test.ts index 0806bb2e..03f99491 100644 --- a/src/mcp/tools/device/__tests__/install_app_device.test.ts +++ b/src/mcp/tools/device/__tests__/install_app_device.test.ts @@ -1,14 +1,42 @@ -/** - * Tests for install_app_device plugin (device-shared) - * Following CLAUDE.md testing standards with literal validation - * Using dependency injection for deterministic testing - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, install_app_deviceLogic } from '../install_app_device.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; +import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; + +const runLogic = async (logic: () => Promise) => { + const { result, run } = createMockToolHandlerContext(); + const response = await run(logic); + + if ( + response && + typeof response === 'object' && + 'content' in (response as Record) + ) { + return response as { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + isError?: boolean; + nextStepParams?: unknown; + }; + } + + const text = result.text(); + const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; + const imageContent = result.attachments.map((attachment) => ({ + type: 'image' as const, + data: attachment.data, + mimeType: attachment.mimeType, + })); + + return { + content: [...textContent, ...imageContent], + isError: result.isError() ? true : undefined, + nextStepParams: result.nextStepParams, + attachments: result.attachments, + text, + }; +}; describe('install_app_device plugin', () => { beforeEach(() => { @@ -68,12 +96,14 @@ describe('install_app_device plugin', () => { return mockExecutor(command, description, useShell, opts, _detached); }; - await install_app_deviceLogic( - { - deviceId: 'test-device-123', - appPath: '/path/to/test.app', - }, - trackingExecutor, + await runLogic(() => + install_app_deviceLogic( + { + deviceId: 'test-device-123', + appPath: '/path/to/test.app', + }, + trackingExecutor, + ), ); expect(capturedCommand).toEqual([ @@ -105,12 +135,14 @@ describe('install_app_device plugin', () => { return mockExecutor(command); }; - await install_app_deviceLogic( - { - deviceId: 'different-device-uuid', - appPath: '/apps/MyApp.app', - }, - trackingExecutor, + await runLogic(() => + install_app_deviceLogic( + { + deviceId: 'different-device-uuid', + appPath: '/apps/MyApp.app', + }, + trackingExecutor, + ), ); expect(capturedCommand).toEqual([ @@ -139,12 +171,14 @@ describe('install_app_device plugin', () => { return mockExecutor(command); }; - await install_app_deviceLogic( - { - deviceId: 'test-device-123', - appPath: '/path/to/My App.app', - }, - trackingExecutor, + await runLogic(() => + install_app_deviceLogic( + { + deviceId: 'test-device-123', + appPath: '/path/to/My App.app', + }, + trackingExecutor, + ), ); expect(capturedCommand).toEqual([ @@ -167,71 +201,17 @@ describe('install_app_device plugin', () => { output: 'App installation successful', }); - const result = await install_app_deviceLogic( - { - deviceId: 'test-device-123', - appPath: '/path/to/test.app', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + install_app_deviceLogic( { - type: 'text', - text: '✅ App installed successfully on device test-device-123\n\nApp installation successful', + deviceId: 'test-device-123', + appPath: '/path/to/test.app', }, - ], - }); - }); - - it('should return successful installation with detailed output', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: - 'Installing app...\nApp bundle: /path/to/test.app\nInstallation completed successfully', - }); - - const result = await install_app_deviceLogic( - { - deviceId: 'device-456', - appPath: '/apps/TestApp.app', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: '✅ App installed successfully on device device-456\n\nInstalling app...\nApp bundle: /path/to/test.app\nInstallation completed successfully', - }, - ], - }); - }); - - it('should return successful installation with empty output', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: '', - }); - - const result = await install_app_deviceLogic( - { - deviceId: 'empty-output-device', - appPath: '/path/to/app.app', - }, - mockExecutor, + mockExecutor, + ), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: '✅ App installed successfully on device empty-output-device\n\n', - }, - ], - }); + expect(result.isError).toBeFalsy(); }); }); @@ -242,67 +222,33 @@ describe('install_app_device plugin', () => { error: 'Installation failed: App not found', }); - const result = await install_app_deviceLogic( - { - deviceId: 'test-device-123', - appPath: '/path/to/nonexistent.app', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + install_app_deviceLogic( { - type: 'text', - text: 'Failed to install app: Installation failed: App not found', + deviceId: 'test-device-123', + appPath: '/path/to/nonexistent.app', }, - ], - isError: true, - }); + mockExecutor, + ), + ); + + expect(result.isError).toBe(true); }); it('should return exception handling response', async () => { const mockExecutor = createMockExecutor(new Error('Network error')); - const result = await install_app_deviceLogic( - { - deviceId: 'test-device-123', - appPath: '/path/to/test.app', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + install_app_deviceLogic( { - type: 'text', - text: 'Failed to install app on device: Network error', + deviceId: 'test-device-123', + appPath: '/path/to/test.app', }, - ], - isError: true, - }); - }); - - it('should return string error handling response', async () => { - const mockExecutor = createMockExecutor('String error'); - - const result = await install_app_deviceLogic( - { - deviceId: 'test-device-123', - appPath: '/path/to/test.app', - }, - mockExecutor, + mockExecutor, + ), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to install app on device: String error', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); }); }); }); diff --git a/src/mcp/tools/device/__tests__/launch_app_device.test.ts b/src/mcp/tools/device/__tests__/launch_app_device.test.ts index 5798fe76..b3d94dc4 100644 --- a/src/mcp/tools/device/__tests__/launch_app_device.test.ts +++ b/src/mcp/tools/device/__tests__/launch_app_device.test.ts @@ -1,12 +1,3 @@ -/** - * Pure dependency injection test for launch_app_device plugin (device-shared) - * - * Tests plugin structure and app launching functionality including parameter validation, - * command generation, file operations, and response formatting. - * - * Uses createMockExecutor for command execution and manual stubs for file operations. - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { @@ -15,6 +6,40 @@ import { } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, launch_app_deviceLogic } from '../launch_app_device.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; +import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; + +const runLogic = async (logic: () => Promise) => { + const { result, run } = createMockToolHandlerContext(); + const response = await run(logic); + + if ( + response && + typeof response === 'object' && + 'content' in (response as Record) + ) { + return response as { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + isError?: boolean; + nextStepParams?: unknown; + }; + } + + const text = result.text(); + const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; + const imageContent = result.attachments.map((attachment) => ({ + type: 'image' as const, + data: attachment.data, + mimeType: attachment.mimeType, + })); + + return { + content: [...textContent, ...imageContent], + isError: result.isError() ? true : undefined, + nextStepParams: result.nextStepParams, + attachments: result.attachments, + text, + }; +}; describe('launch_app_device plugin (device-shared)', () => { beforeEach(() => { @@ -70,13 +95,15 @@ describe('launch_app_device plugin (device-shared)', () => { return mockExecutor(command, logPrefix, useShell, opts, _detached); }; - await launch_app_deviceLogic( - { - deviceId: 'test-device-123', - bundleId: 'io.sentry.app', - }, - trackingExecutor, - createMockFileSystemExecutor(), + await runLogic(() => + launch_app_deviceLogic( + { + deviceId: 'test-device-123', + bundleId: 'io.sentry.app', + }, + trackingExecutor, + createMockFileSystemExecutor(), + ), ); expect(calls).toHaveLength(1); @@ -98,44 +125,7 @@ describe('launch_app_device plugin (device-shared)', () => { expect(calls[0].env).toBeUndefined(); }); - it('should generate command with different device and bundle parameters', async () => { - const calls: any[] = []; - const mockExecutor = createMockExecutor({ - success: true, - output: 'Launch successful', - process: { pid: 54321 }, - }); - - const trackingExecutor = async (command: string[]) => { - calls.push({ command }); - return mockExecutor(command); - }; - - await launch_app_deviceLogic( - { - deviceId: '00008030-001E14BE2288802E', - bundleId: 'com.apple.mobilesafari', - }, - trackingExecutor, - createMockFileSystemExecutor(), - ); - - expect(calls[0].command).toEqual([ - 'xcrun', - 'devicectl', - 'device', - 'process', - 'launch', - '--device', - '00008030-001E14BE2288802E', - '--json-output', - expect.stringMatching(/^\/.*\/launch-\d+\.json$/), - '--terminate-existing', - 'com.apple.mobilesafari', - ]); - }); - - it('should append a JSON --environment-variables payload before bundleId when env is provided', async () => { + it('should append --environment-variables when env is provided', async () => { const calls: any[] = []; const mockExecutor = createMockExecutor({ success: true, @@ -148,26 +138,22 @@ describe('launch_app_device plugin (device-shared)', () => { return mockExecutor(command); }; - await launch_app_deviceLogic( - { - deviceId: 'test-device-123', - bundleId: 'io.sentry.app', - env: { STAGING_ENABLED: '1', DEBUG: 'true' }, - }, - trackingExecutor, - createMockFileSystemExecutor(), + await runLogic(() => + launch_app_deviceLogic( + { + deviceId: 'test-device-123', + bundleId: 'io.sentry.app', + env: { STAGING_ENABLED: '1', DEBUG: 'true' }, + }, + trackingExecutor, + createMockFileSystemExecutor(), + ), ); - expect(calls).toHaveLength(1); const cmd = calls[0].command; - // bundleId should be the last element expect(cmd[cmd.length - 1]).toBe('io.sentry.app'); - // --environment-variables should be provided exactly once as JSON - const envFlagIndices = cmd - .map((part: string, index: number) => (part === '--environment-variables' ? index : -1)) - .filter((index: number) => index >= 0); - expect(envFlagIndices).toHaveLength(1); - const envIdx = envFlagIndices[0]; + expect(cmd).toContain('--environment-variables'); + const envIdx = cmd.indexOf('--environment-variables'); expect(JSON.parse(cmd[envIdx + 1])).toEqual({ STAGING_ENABLED: '1', DEBUG: 'true' }); }); @@ -184,13 +170,15 @@ describe('launch_app_device plugin (device-shared)', () => { return mockExecutor(command); }; - await launch_app_deviceLogic( - { - deviceId: 'test-device-123', - bundleId: 'io.sentry.app', - }, - trackingExecutor, - createMockFileSystemExecutor(), + await runLogic(() => + launch_app_deviceLogic( + { + deviceId: 'test-device-123', + bundleId: 'io.sentry.app', + }, + trackingExecutor, + createMockFileSystemExecutor(), + ), ); expect(calls[0].command).not.toContain('--environment-variables'); @@ -204,59 +192,27 @@ describe('launch_app_device plugin (device-shared)', () => { output: 'App launched successfully', }); - const result = await launch_app_deviceLogic( - { - deviceId: 'test-device-123', - bundleId: 'io.sentry.app', - }, - mockExecutor, - createMockFileSystemExecutor(), - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + launch_app_deviceLogic( { - type: 'text', - text: '✅ App launched successfully\n\nApp launched successfully', + deviceId: 'test-device-123', + bundleId: 'io.sentry.app', }, - ], - }); - }); - - it('should return successful launch response with detailed output', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: 'Launch succeeded with detailed output', - }); - - const result = await launch_app_deviceLogic( - { - deviceId: 'test-device-123', - bundleId: 'io.sentry.app', - }, - mockExecutor, - createMockFileSystemExecutor(), + mockExecutor, + createMockFileSystemExecutor(), + ), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: '✅ App launched successfully\n\nLaunch succeeded with detailed output', - }, - ], - }); + expect(result.isError).toBeFalsy(); + expect(result.nextStepParams).toBeUndefined(); }); it('should handle successful launch with process ID information', async () => { const mockFileSystem = createMockFileSystemExecutor({ + existsSync: () => true, readFile: async () => JSON.stringify({ - result: { - process: { - processIdentifier: 12345, - }, - }, + result: { process: { processIdentifier: 12345 } }, }), rm: async () => {}, }); @@ -266,50 +222,20 @@ describe('launch_app_device plugin (device-shared)', () => { output: 'App launched successfully', }); - const result = await launch_app_deviceLogic( - { - deviceId: 'test-device-123', - bundleId: 'io.sentry.app', - }, - mockExecutor, - mockFileSystem, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + launch_app_deviceLogic( { - type: 'text', - text: '✅ App launched successfully\n\nApp launched successfully\n\nProcess ID: 12345\n\nInteract with your app on the device.', + deviceId: 'test-device-123', + bundleId: 'io.sentry.app', }, - ], - nextStepParams: { - stop_app_device: { deviceId: 'test-device-123', processId: 12345 }, - }, - }); - }); - - it('should handle successful launch with command output', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: 'App "io.sentry.app" launched on device "test-device-123"', - }); - - const result = await launch_app_deviceLogic( - { - deviceId: 'test-device-123', - bundleId: 'io.sentry.app', - }, - mockExecutor, - createMockFileSystemExecutor(), + mockExecutor, + mockFileSystem, + ), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: '✅ App launched successfully\n\nApp "io.sentry.app" launched on device "test-device-123"', - }, - ], + expect(result.isError).toBeFalsy(); + expect(result.nextStepParams).toEqual({ + stop_app_device: { deviceId: 'test-device-123', processId: 12345 }, }); }); }); @@ -321,96 +247,35 @@ describe('launch_app_device plugin (device-shared)', () => { error: 'Launch failed: App not found', }); - const result = await launch_app_deviceLogic( - { - deviceId: 'test-device-123', - bundleId: 'com.nonexistent.app', - }, - mockExecutor, - createMockFileSystemExecutor(), - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + launch_app_deviceLogic( { - type: 'text', - text: 'Failed to launch app: Launch failed: App not found', + deviceId: 'test-device-123', + bundleId: 'com.nonexistent.app', }, - ], - isError: true, - }); - }); - - it('should return command failure response with specific error', async () => { - const mockExecutor = createMockExecutor({ - success: false, - error: 'Device not found: test-device-invalid', - }); - - const result = await launch_app_deviceLogic( - { - deviceId: 'test-device-invalid', - bundleId: 'io.sentry.app', - }, - mockExecutor, - createMockFileSystemExecutor(), + mockExecutor, + createMockFileSystemExecutor(), + ), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to launch app: Device not found: test-device-invalid', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); }); it('should handle executor exception with Error object', async () => { const mockExecutor = createMockExecutor(new Error('Network error')); - const result = await launch_app_deviceLogic( - { - deviceId: 'test-device-123', - bundleId: 'io.sentry.app', - }, - mockExecutor, - createMockFileSystemExecutor(), - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + launch_app_deviceLogic( { - type: 'text', - text: 'Failed to launch app on device: Network error', + deviceId: 'test-device-123', + bundleId: 'io.sentry.app', }, - ], - isError: true, - }); - }); - - it('should handle executor exception with string error', async () => { - const mockExecutor = createMockExecutor('String error'); - - const result = await launch_app_deviceLogic( - { - deviceId: 'test-device-123', - bundleId: 'io.sentry.app', - }, - mockExecutor, - createMockFileSystemExecutor(), + mockExecutor, + createMockFileSystemExecutor(), + ), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to launch app on device: String error', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); }); }); }); diff --git a/src/mcp/tools/device/__tests__/list_devices.test.ts b/src/mcp/tools/device/__tests__/list_devices.test.ts index 276ee0be..503a1d6f 100644 --- a/src/mcp/tools/device/__tests__/list_devices.test.ts +++ b/src/mcp/tools/device/__tests__/list_devices.test.ts @@ -1,19 +1,27 @@ -/** - * Tests for list_devices plugin (device-shared) - * This tests the re-exported plugin from device-workspace - * Following CLAUDE.md testing standards with literal validation - * - * Note: This is a re-export test. Comprehensive handler tests are in device-workspace/list_devices.test.ts - */ - import { describe, it, expect } from 'vitest'; import { createMockCommandResponse, createMockExecutor, } from '../../../../test-utils/mock-executors.ts'; -// Import the logic function and named exports import { schema, handler, list_devicesLogic } from '../list_devices.ts'; +import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; +import type { CommandExecutor } from '../../../../utils/execution/index.ts'; + +async function runListDevicesLogic( + params: Record, + executor: CommandExecutor, + pathDeps?: Parameters[2], + fsDeps?: Parameters[3], +) { + const { ctx, result, run } = createMockToolHandlerContext(); + await run(() => list_devicesLogic(params, executor, pathDeps, fsDeps)); + return { + content: [{ type: 'text' as const, text: result.text() }], + isError: result.isError() || undefined, + nextStepParams: ctx.nextStepParams, + }; +} describe('list_devices plugin (device-shared)', () => { describe('Export Field Validation (Literal)', () => { @@ -56,7 +64,6 @@ describe('list_devices plugin (device-shared)', () => { }, }; - // Track command calls const commandCalls: Array<{ command: string[]; logPrefix?: string; @@ -64,13 +71,11 @@ describe('list_devices plugin (device-shared)', () => { env?: Record; }> = []; - // Create mock executor const mockExecutor = createMockExecutor({ success: true, output: '', }); - // Wrap to track calls const trackingExecutor = async ( command: string[], logPrefix?: string, @@ -82,19 +87,17 @@ describe('list_devices plugin (device-shared)', () => { return mockExecutor(command, logPrefix, useShell, opts, _detached); }; - // Create mock path dependencies const mockPathDeps = { tmpdir: () => '/tmp', join: (...paths: string[]) => paths.join('/'), }; - // Create mock filesystem with specific behavior const mockFsDeps = { readFile: async (_path: string, _encoding?: string) => JSON.stringify(devicectlJson), unlink: async () => {}, }; - await list_devicesLogic({}, trackingExecutor, mockPathDeps, mockFsDeps); + await runListDevicesLogic({}, trackingExecutor, mockPathDeps, mockFsDeps); expect(commandCalls).toHaveLength(1); expect(commandCalls[0].command).toEqual([ @@ -111,7 +114,6 @@ describe('list_devices plugin (device-shared)', () => { }); it('should generate correct xctrace fallback command', async () => { - // Track command calls const commandCalls: Array<{ command: string[]; logPrefix?: string; @@ -119,7 +121,6 @@ describe('list_devices plugin (device-shared)', () => { env?: Record; }> = []; - // Create tracking executor with call count behavior let callCount = 0; const trackingExecutor = async ( command: string[], @@ -132,14 +133,12 @@ describe('list_devices plugin (device-shared)', () => { commandCalls.push({ command, logPrefix, useShell, env: opts?.env }); if (callCount === 1) { - // First call fails (devicectl) return createMockCommandResponse({ success: false, output: '', error: 'devicectl failed', }); } else { - // Second call succeeds (xctrace) return createMockCommandResponse({ success: true, output: 'iPhone 15 (12345678-1234-1234-1234-123456789012)', @@ -148,13 +147,11 @@ describe('list_devices plugin (device-shared)', () => { } }; - // Create mock path dependencies const mockPathDeps = { tmpdir: () => '/tmp', join: (...paths: string[]) => paths.join('/'), }; - // Create mock filesystem that throws for readFile const mockFsDeps = { readFile: async () => { throw new Error('File not found'); @@ -162,7 +159,7 @@ describe('list_devices plugin (device-shared)', () => { unlink: async () => {}, }; - await list_devicesLogic({}, trackingExecutor, mockPathDeps, mockFsDeps); + await runListDevicesLogic({}, trackingExecutor, mockPathDeps, mockFsDeps); expect(commandCalls).toHaveLength(2); expect(commandCalls[1].command).toEqual(['xcrun', 'xctrace', 'list', 'devices']); @@ -203,38 +200,26 @@ describe('list_devices plugin (device-shared)', () => { output: '', }); - // Create mock path dependencies const mockPathDeps = { tmpdir: () => '/tmp', join: (...paths: string[]) => paths.join('/'), }; - // Create mock filesystem with specific behavior const mockFsDeps = { readFile: async (_path: string, _encoding?: string) => JSON.stringify(devicectlJson), unlink: async () => {}, }; - const result = await list_devicesLogic({}, mockExecutor, mockPathDeps, mockFsDeps); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: "Connected Devices:\n\n✅ Available Devices:\n\n📱 Test iPhone\n UDID: test-device-123\n Model: iPhone15,2\n Product Type: iPhone15,2\n Platform: iOS 17.0\n Connection: USB\n\nNote: Use the device ID/UDID from above when required by other tools.\nHint: Save a default device with session-set-defaults { deviceId: 'DEVICE_UDID' }.\nBefore running build/run/test/UI automation tools, set the desired device identifier in session defaults.\n", - }, - ], - nextStepParams: { - build_device: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' }, - build_run_device: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' }, - test_device: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' }, - get_device_app_path: { scheme: 'SCHEME' }, - }, - }); + const result = await runListDevicesLogic({}, mockExecutor, mockPathDeps, mockFsDeps); + + expect(result.isError).toBeFalsy(); + const text = allText(result); + expect(text).toContain('Test iPhone'); + expect(text).toContain('test-device-123'); + expect(result.nextStepParams).toBeUndefined(); }); it('should return successful xctrace fallback response', async () => { - // Create executor with call count behavior let callCount = 0; const mockExecutor = async ( _command: string[], @@ -245,14 +230,12 @@ describe('list_devices plugin (device-shared)', () => { ) => { callCount++; if (callCount === 1) { - // First call fails (devicectl) return createMockCommandResponse({ success: false, output: '', error: 'devicectl failed', }); } else { - // Second call succeeds (xctrace) return createMockCommandResponse({ success: true, output: 'iPhone 15 (12345678-1234-1234-1234-123456789012)', @@ -261,13 +244,11 @@ describe('list_devices plugin (device-shared)', () => { } }; - // Create mock path dependencies const mockPathDeps = { tmpdir: () => '/tmp', join: (...paths: string[]) => paths.join('/'), }; - // Create mock filesystem that throws for readFile const mockFsDeps = { readFile: async () => { throw new Error('File not found'); @@ -275,16 +256,12 @@ describe('list_devices plugin (device-shared)', () => { unlink: async () => {}, }; - const result = await list_devicesLogic({}, mockExecutor, mockPathDeps, mockFsDeps); + const result = await runListDevicesLogic({}, mockExecutor, mockPathDeps, mockFsDeps); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Device listing (xctrace output):\n\niPhone 15 (12345678-1234-1234-1234-123456789012)\n\nNote: For better device information, please upgrade to Xcode 15 or later which supports the modern devicectl command.', - }, - ], - }); + expect(result.isError).toBeFalsy(); + const text = allText(result); + expect(text).toContain('xctrace output'); + expect(text).toContain('iPhone 15 (12345678-1234-1234-1234-123456789012)'); }); it('should return successful no devices found response', async () => { @@ -294,7 +271,6 @@ describe('list_devices plugin (device-shared)', () => { }, }; - // Create executor with call count behavior let callCount = 0; const mockExecutor = async ( _command: string[], @@ -305,14 +281,12 @@ describe('list_devices plugin (device-shared)', () => { ) => { callCount++; if (callCount === 1) { - // First call succeeds (devicectl) return createMockCommandResponse({ success: true, output: '', error: undefined, }); } else { - // Second call succeeds (xctrace) with empty output return createMockCommandResponse({ success: true, output: '', @@ -321,31 +295,21 @@ describe('list_devices plugin (device-shared)', () => { } }; - // Create mock path dependencies const mockPathDeps = { tmpdir: () => '/tmp', join: (...paths: string[]) => paths.join('/'), }; - // Create mock filesystem with empty devices response const mockFsDeps = { readFile: async (_path: string, _encoding?: string) => JSON.stringify(devicectlJson), unlink: async () => {}, }; - const result = await list_devicesLogic({}, mockExecutor, mockPathDeps, mockFsDeps); + const result = await runListDevicesLogic({}, mockExecutor, mockPathDeps, mockFsDeps); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Device listing (xctrace output):\n\n\n\nNote: For better device information, please upgrade to Xcode 15 or later which supports the modern devicectl command.', - }, - ], - }); + expect(result.isError).toBeFalsy(); + const text = allText(result); + expect(text).toContain('xctrace output'); }); }); - - // Note: Handler functionality is thoroughly tested in device-workspace/list_devices.test.ts - // This test file only verifies the re-export works correctly }); diff --git a/src/mcp/tools/device/__tests__/re-exports.test.ts b/src/mcp/tools/device/__tests__/re-exports.test.ts index 8da20225..c6df73f3 100644 --- a/src/mcp/tools/device/__tests__/re-exports.test.ts +++ b/src/mcp/tools/device/__tests__/re-exports.test.ts @@ -1,10 +1,5 @@ -/** - * Tests for device tool named exports - * Verifies that device tools export schema and handler as named exports - */ import { describe, it, expect } from 'vitest'; -// Import all tools as modules to check named exports import * as launchAppDevice from '../launch_app_device.ts'; import * as stopAppDevice from '../stop_app_device.ts'; import * as listDevices from '../list_devices.ts'; diff --git a/src/mcp/tools/device/__tests__/stop_app_device.test.ts b/src/mcp/tools/device/__tests__/stop_app_device.test.ts index 0ae186c4..28558f3f 100644 --- a/src/mcp/tools/device/__tests__/stop_app_device.test.ts +++ b/src/mcp/tools/device/__tests__/stop_app_device.test.ts @@ -1,14 +1,42 @@ -/** - * Tests for stop_app_device plugin (device-shared) - * Following CLAUDE.md testing standards with literal validation - * Using dependency injection for deterministic testing - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, stop_app_deviceLogic } from '../stop_app_device.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; +import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; + +const runLogic = async (logic: () => Promise) => { + const { result, run } = createMockToolHandlerContext(); + const response = await run(logic); + + if ( + response && + typeof response === 'object' && + 'content' in (response as Record) + ) { + return response as { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + isError?: boolean; + nextStepParams?: unknown; + }; + } + + const text = result.text(); + const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; + const imageContent = result.attachments.map((attachment) => ({ + type: 'image' as const, + data: attachment.data, + mimeType: attachment.mimeType, + })); + + return { + content: [...textContent, ...imageContent], + isError: result.isError() ? true : undefined, + nextStepParams: result.nextStepParams, + attachments: result.attachments, + text, + }; +}; describe('stop_app_device plugin', () => { beforeEach(() => { @@ -66,12 +94,14 @@ describe('stop_app_device plugin', () => { return mockExecutor(command, description, useShell, opts, _detached); }; - await stop_app_deviceLogic( - { - deviceId: 'test-device-123', - processId: 12345, - }, - trackingExecutor, + await runLogic(() => + stop_app_deviceLogic( + { + deviceId: 'test-device-123', + processId: 12345, + }, + trackingExecutor, + ), ); expect(capturedCommand).toEqual([ @@ -104,12 +134,14 @@ describe('stop_app_device plugin', () => { return mockExecutor(command); }; - await stop_app_deviceLogic( - { - deviceId: 'different-device-uuid', - processId: 99999, - }, - trackingExecutor, + await runLogic(() => + stop_app_deviceLogic( + { + deviceId: 'different-device-uuid', + processId: 99999, + }, + trackingExecutor, + ), ); expect(capturedCommand).toEqual([ @@ -139,12 +171,14 @@ describe('stop_app_device plugin', () => { return mockExecutor(command); }; - await stop_app_deviceLogic( - { - deviceId: 'test-device-123', - processId: 2147483647, - }, - trackingExecutor, + await runLogic(() => + stop_app_deviceLogic( + { + deviceId: 'test-device-123', + processId: 2147483647, + }, + trackingExecutor, + ), ); expect(capturedCommand).toEqual([ @@ -168,70 +202,17 @@ describe('stop_app_device plugin', () => { output: 'App terminated successfully', }); - const result = await stop_app_deviceLogic( - { - deviceId: 'test-device-123', - processId: 12345, - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + stop_app_deviceLogic( { - type: 'text', - text: '✅ App stopped successfully\n\nApp terminated successfully', + deviceId: 'test-device-123', + processId: 12345, }, - ], - }); - }); - - it('should return successful stop with detailed output', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: 'Terminating process...\nProcess ID: 12345\nTermination completed successfully', - }); - - const result = await stop_app_deviceLogic( - { - deviceId: 'device-456', - processId: 67890, - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: '✅ App stopped successfully\n\nTerminating process...\nProcess ID: 12345\nTermination completed successfully', - }, - ], - }); - }); - - it('should return successful stop with empty output', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: '', - }); - - const result = await stop_app_deviceLogic( - { - deviceId: 'empty-output-device', - processId: 54321, - }, - mockExecutor, + mockExecutor, + ), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: '✅ App stopped successfully\n\n', - }, - ], - }); + expect(result.isError).toBeFalsy(); }); }); @@ -242,67 +223,33 @@ describe('stop_app_device plugin', () => { error: 'Terminate failed: Process not found', }); - const result = await stop_app_deviceLogic( - { - deviceId: 'test-device-123', - processId: 99999, - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + stop_app_deviceLogic( { - type: 'text', - text: 'Failed to stop app: Terminate failed: Process not found', + deviceId: 'test-device-123', + processId: 99999, }, - ], - isError: true, - }); + mockExecutor, + ), + ); + + expect(result.isError).toBe(true); }); it('should return exception handling response', async () => { const mockExecutor = createMockExecutor(new Error('Network error')); - const result = await stop_app_deviceLogic( - { - deviceId: 'test-device-123', - processId: 12345, - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + stop_app_deviceLogic( { - type: 'text', - text: 'Failed to stop app on device: Network error', + deviceId: 'test-device-123', + processId: 12345, }, - ], - isError: true, - }); - }); - - it('should return string error handling response', async () => { - const mockExecutor = createMockExecutor('String error'); - - const result = await stop_app_deviceLogic( - { - deviceId: 'test-device-123', - processId: 12345, - }, - mockExecutor, + mockExecutor, + ), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to stop app on device: String error', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); }); }); }); diff --git a/src/mcp/tools/device/__tests__/test_device.test.ts b/src/mcp/tools/device/__tests__/test_device.test.ts index 9c46ac7b..d1de4372 100644 --- a/src/mcp/tools/device/__tests__/test_device.test.ts +++ b/src/mcp/tools/device/__tests__/test_device.test.ts @@ -1,19 +1,27 @@ -/** - * Tests for test_device plugin - * Following CLAUDE.md testing standards with literal validation - * Using dependency injection for deterministic testing - */ - import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { - createMockCommandResponse, createMockExecutor, createMockFileSystemExecutor, } from '../../../../test-utils/mock-executors.ts'; +import { expectPendingBuildResponse, runToolLogic } from '../../../../test-utils/test-helpers.ts'; import { schema, handler, testDeviceLogic } from '../test_device.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; +const mockFs = () => + createMockFileSystemExecutor({ + mkdtemp: async () => '/tmp/test-123', + rm: async () => {}, + tmpdir: () => '/tmp', + stat: async () => ({ isDirectory: () => false, mtimeMs: 0 }), + }); + +const runTestDeviceLogic = ( + params: Parameters[0], + executor: Parameters[1], + fileSystemExecutor: Parameters[2], +) => runToolLogic(() => testDeviceLogic(params, executor, fileSystemExecutor)); + describe('test_device plugin', () => { beforeEach(() => { sessionStore.clear(); @@ -41,58 +49,38 @@ describe('test_device plugin', () => { ); const schemaKeys = Object.keys(schema).sort(); - expect(schemaKeys).toEqual(['extraArgs', 'testRunnerEnv']); + expect(schemaKeys).toEqual(['extraArgs', 'progress', 'testRunnerEnv']); }); it('should validate XOR between projectPath and workspacePath', async () => { - // This would be validated at the schema level via createTypedTool - // We test the schema validation through successful logic calls instead const mockExecutor = createMockExecutor({ success: true, - output: JSON.stringify({ - title: 'Test Schema', - result: 'SUCCESS', - totalTestCount: 1, - passedTests: 1, - failedTests: 0, - skippedTests: 0, - expectedFailures: 0, - }), + output: 'Test Succeeded', }); - // Valid: project path only - const projectResult = await testDeviceLogic( + const { result: projectResult } = await runTestDeviceLogic( { projectPath: '/path/to/project.xcodeproj', scheme: 'MyScheme', deviceId: 'test-device-123', }, mockExecutor, - createMockFileSystemExecutor({ - mkdtemp: async () => '/tmp/xcodebuild-test-123', - tmpdir: () => '/tmp', - stat: async () => ({ isDirectory: () => false, mtimeMs: 0 }), - rm: async () => {}, - }), + mockFs(), ); - expect(projectResult.isError).toBeFalsy(); + expectPendingBuildResponse(projectResult); + expect(projectResult.isError()).toBeFalsy(); - // Valid: workspace path only - const workspaceResult = await testDeviceLogic( + const { result: workspaceResult } = await runTestDeviceLogic( { workspacePath: '/path/to/workspace.xcworkspace', scheme: 'MyScheme', deviceId: 'test-device-123', }, mockExecutor, - createMockFileSystemExecutor({ - mkdtemp: async () => '/tmp/xcodebuild-test-456', - tmpdir: () => '/tmp', - stat: async () => ({ isDirectory: () => false, mtimeMs: 0 }), - rm: async () => {}, - }), + mockFs(), ); - expect(workspaceResult.isError).toBeFalsy(); + expectPendingBuildResponse(workspaceResult); + expect(workspaceResult.isError()).toBeFalsy(); }); }); @@ -129,26 +117,13 @@ describe('test_device plugin', () => { }); describe('Handler Behavior (Complete Literal Returns)', () => { - beforeEach(() => { - // Clean setup for standard testing pattern - }); - - it('should return successful test response with parsed results', async () => { - // Mock xcresulttool output + it('should return pending response for successful tests', async () => { const mockExecutor = createMockExecutor({ success: true, - output: JSON.stringify({ - title: 'MyScheme Tests', - result: 'SUCCESS', - totalTestCount: 5, - passedTests: 5, - failedTests: 0, - skippedTests: 0, - expectedFailures: 0, - }), + output: 'Test Succeeded', }); - const result = await testDeviceLogic( + const { result } = await runTestDeviceLogic( { projectPath: '/path/to/project.xcodeproj', scheme: 'MyScheme', @@ -158,43 +133,21 @@ describe('test_device plugin', () => { platform: 'iOS', }, mockExecutor, - createMockFileSystemExecutor({ - mkdtemp: async () => '/tmp/xcodebuild-test-123456', - tmpdir: () => '/tmp', - stat: async () => ({ isDirectory: () => false, mtimeMs: 0 }), - rm: async () => {}, - }), + mockFs(), ); - expect(result.content).toHaveLength(2); - expect(result.content[0].text).toContain('Test Results Summary:'); - expect(result.content[0].text).toContain('MyScheme Tests'); - expect(result.content[1].text).toContain('✅'); + expectPendingBuildResponse(result); + expect(result.isError()).toBeFalsy(); }); - it('should handle test failure scenarios', async () => { - // Mock xcresulttool output for failed tests + it('should return pending response for test failures', async () => { const mockExecutor = createMockExecutor({ - success: true, - output: JSON.stringify({ - title: 'MyScheme Tests', - result: 'FAILURE', - totalTestCount: 5, - passedTests: 3, - failedTests: 2, - skippedTests: 0, - expectedFailures: 0, - testFailures: [ - { - testName: 'testExample', - targetName: 'MyTarget', - failureText: 'Expected true but was false', - }, - ], - }), + success: false, + output: '', + error: 'error: Test failed', }); - const result = await testDeviceLogic( + const { result } = await runTestDeviceLogic( { projectPath: '/path/to/project.xcodeproj', scheme: 'MyScheme', @@ -204,103 +157,21 @@ describe('test_device plugin', () => { platform: 'iOS', }, mockExecutor, - createMockFileSystemExecutor({ - mkdtemp: async () => '/tmp/xcodebuild-test-123456', - tmpdir: () => '/tmp', - stat: async () => ({ isDirectory: () => false, mtimeMs: 0 }), - rm: async () => {}, - }), + mockFs(), ); - expect(result.content).toHaveLength(2); - expect(result.content[0].text).toContain('Test Failures:'); - expect(result.content[0].text).toContain('testExample'); + expectPendingBuildResponse(result); + expect(result.isError()).toBe(true); }); - it('should handle xcresult parsing failures gracefully', async () => { - // Create a multi-call mock that handles different commands - let callCount = 0; - const mockExecutor = async ( - _args: string[], - _description?: string, - _useShell?: boolean, - _opts?: { cwd?: string }, - _detached?: boolean, - ) => { - callCount++; - - // First call is for xcodebuild test (successful) - if (callCount === 1) { - return createMockCommandResponse({ success: true, output: 'BUILD SUCCEEDED' }); - } - - // Second call is for xcresulttool (fails) - return createMockCommandResponse({ success: false, error: 'xcresulttool failed' }); - }; - - const result = await testDeviceLogic( - { - projectPath: '/path/to/project.xcodeproj', - scheme: 'MyScheme', - deviceId: 'test-device-123', - configuration: 'Debug', - preferXcodebuild: false, - platform: 'iOS', - }, - mockExecutor, - createMockFileSystemExecutor({ - mkdtemp: async () => '/tmp/xcodebuild-test-123456', - tmpdir: () => '/tmp', - stat: async () => { - throw new Error('File not found'); - }, - rm: async () => {}, - }), - ); - - // When xcresult parsing fails, it falls back to original test result only - expect(result.content).toHaveLength(1); - expect(result.content[0].text).toContain('✅'); - }); + it('should handle build failure with pending response', async () => { + const mockExecutor = createMockExecutor({ + success: false, + output: '', + error: 'error: missing argument for parameter in call', + }); - it('should preserve stderr when xcresult reports zero tests (build failure)', async () => { - // When the build fails, xcresult exists but has totalTestCount: 0. - // stderr contains the actual compilation errors and must be preserved. - let callCount = 0; - const mockExecutor = async ( - _args: string[], - _description?: string, - _useShell?: boolean, - _opts?: { cwd?: string }, - _detached?: boolean, - ) => { - callCount++; - - // First call: xcodebuild test fails with compilation error - if (callCount === 1) { - return createMockCommandResponse({ - success: false, - output: '', - error: 'error: missing argument for parameter in call', - }); - } - - // Second call: xcresulttool succeeds but reports 0 tests - return createMockCommandResponse({ - success: true, - output: JSON.stringify({ - title: 'Test Results', - result: 'unknown', - totalTestCount: 0, - passedTests: 0, - failedTests: 0, - skippedTests: 0, - expectedFailures: 0, - }), - }); - }; - - const result = await testDeviceLogic( + const { result } = await runTestDeviceLogic( { projectPath: '/path/to/project.xcodeproj', scheme: 'MyScheme', @@ -310,39 +181,20 @@ describe('test_device plugin', () => { platform: 'iOS', }, mockExecutor, - createMockFileSystemExecutor({ - mkdtemp: async () => '/tmp/xcodebuild-test-buildfail', - tmpdir: () => '/tmp', - stat: async () => ({ isDirectory: () => false, mtimeMs: 0 }), - rm: async () => {}, - }), + mockFs(), ); - // stderr with compilation error must be preserved - const allText = result.content.map((c) => c.text).join('\n'); - expect(allText).toContain('[stderr]'); - expect(allText).toContain('missing argument'); - - // xcresult summary should NOT be present - expect(allText).not.toContain('Test Results Summary:'); + expectPendingBuildResponse(result); + expect(result.isError()).toBe(true); }); it('should support different platforms', async () => { - // Mock xcresulttool output const mockExecutor = createMockExecutor({ success: true, - output: JSON.stringify({ - title: 'WatchApp Tests', - result: 'SUCCESS', - totalTestCount: 3, - passedTests: 3, - failedTests: 0, - skippedTests: 0, - expectedFailures: 0, - }), + output: 'Test Succeeded', }); - const result = await testDeviceLogic( + const { result } = await runTestDeviceLogic( { projectPath: '/path/to/project.xcodeproj', scheme: 'WatchApp', @@ -352,34 +204,20 @@ describe('test_device plugin', () => { platform: 'watchOS', }, mockExecutor, - createMockFileSystemExecutor({ - mkdtemp: async () => '/tmp/xcodebuild-test-123456', - tmpdir: () => '/tmp', - stat: async () => ({ isDirectory: () => false, mtimeMs: 0 }), - rm: async () => {}, - }), + mockFs(), ); - expect(result.content).toHaveLength(2); - expect(result.content[0].text).toContain('WatchApp Tests'); + expectPendingBuildResponse(result); + expect(result.isError()).toBeFalsy(); }); it('should handle optional parameters', async () => { - // Mock xcresulttool output const mockExecutor = createMockExecutor({ success: true, - output: JSON.stringify({ - title: 'Tests', - result: 'SUCCESS', - totalTestCount: 1, - passedTests: 1, - failedTests: 0, - skippedTests: 0, - expectedFailures: 0, - }), + output: 'Test Succeeded', }); - const result = await testDeviceLogic( + const { result } = await runTestDeviceLogic( { projectPath: '/path/to/project.xcodeproj', scheme: 'MyScheme', @@ -391,35 +229,20 @@ describe('test_device plugin', () => { platform: 'iOS', }, mockExecutor, - createMockFileSystemExecutor({ - mkdtemp: async () => '/tmp/xcodebuild-test-123456', - tmpdir: () => '/tmp', - stat: async () => ({ isDirectory: () => false, mtimeMs: 0 }), - rm: async () => {}, - }), + mockFs(), ); - expect(result.content).toHaveLength(2); - expect(result.content[0].text).toContain('Test Results Summary:'); - expect(result.content[1].text).toContain('✅'); + expectPendingBuildResponse(result); + expect(result.isError()).toBeFalsy(); }); it('should handle workspace testing successfully', async () => { - // Mock xcresulttool output const mockExecutor = createMockExecutor({ success: true, - output: JSON.stringify({ - title: 'WorkspaceScheme Tests', - result: 'SUCCESS', - totalTestCount: 10, - passedTests: 10, - failedTests: 0, - skippedTests: 0, - expectedFailures: 0, - }), + output: 'Test Succeeded', }); - const result = await testDeviceLogic( + const { result } = await runTestDeviceLogic( { workspacePath: '/path/to/workspace.xcworkspace', scheme: 'WorkspaceScheme', @@ -429,18 +252,11 @@ describe('test_device plugin', () => { platform: 'iOS', }, mockExecutor, - createMockFileSystemExecutor({ - mkdtemp: async () => '/tmp/xcodebuild-test-workspace-123', - tmpdir: () => '/tmp', - stat: async () => ({ isDirectory: () => false, mtimeMs: 0 }), - rm: async () => {}, - }), + mockFs(), ); - expect(result.content).toHaveLength(2); - expect(result.content[0].text).toContain('Test Results Summary:'); - expect(result.content[0].text).toContain('WorkspaceScheme Tests'); - expect(result.content[1].text).toContain('✅'); + expectPendingBuildResponse(result); + expect(result.isError()).toBeFalsy(); }); }); }); diff --git a/src/mcp/tools/device/build-settings.ts b/src/mcp/tools/device/build-settings.ts index 9a6f64fd..30ac2416 100644 --- a/src/mcp/tools/device/build-settings.ts +++ b/src/mcp/tools/device/build-settings.ts @@ -1,18 +1,12 @@ -import path from 'node:path'; import { XcodePlatform } from '../../../types/common.ts'; -import type { CommandExecutor } from '../../../utils/execution/index.ts'; -function resolvePathFromCwd(pathValue?: string): string | undefined { - if (!pathValue) { - return undefined; - } - - if (path.isAbsolute(pathValue)) { - return pathValue; - } +export { + getBuildSettingsDestination, + extractAppPathFromBuildSettingsOutput, + resolveAppPathFromBuildSettings, +} from '../../../utils/app-path-resolver.ts'; - return path.resolve(process.cwd(), pathValue); -} +export type { ResolveAppPathFromBuildSettingsParams } from '../../../utils/app-path-resolver.ts'; export type DevicePlatform = 'iOS' | 'watchOS' | 'tvOS' | 'visionOS'; @@ -30,78 +24,3 @@ export function mapDevicePlatform(platform?: DevicePlatform): XcodePlatform { return XcodePlatform.iOS; } } - -export function getBuildSettingsDestination(platform: XcodePlatform, deviceId?: string): string { - if (deviceId) { - return `platform=${platform},id=${deviceId}`; - } - return `generic/platform=${platform}`; -} - -export function extractAppPathFromBuildSettingsOutput(buildSettingsOutput: string): string { - const builtProductsDirMatch = buildSettingsOutput.match(/^\s*BUILT_PRODUCTS_DIR\s*=\s*(.+)$/m); - const fullProductNameMatch = buildSettingsOutput.match(/^\s*FULL_PRODUCT_NAME\s*=\s*(.+)$/m); - - if (!builtProductsDirMatch || !fullProductNameMatch) { - throw new Error('Could not extract app path from build settings.'); - } - - return `${builtProductsDirMatch[1].trim()}/${fullProductNameMatch[1].trim()}`; -} - -export type ResolveAppPathFromBuildSettingsParams = { - projectPath?: string; - workspacePath?: string; - scheme: string; - configuration?: string; - platform: XcodePlatform; - deviceId?: string; - derivedDataPath?: string; - extraArgs?: string[]; -}; - -export async function resolveAppPathFromBuildSettings( - params: ResolveAppPathFromBuildSettingsParams, - executor: CommandExecutor, -): Promise { - const command = ['xcodebuild', '-showBuildSettings']; - - const workspacePath = resolvePathFromCwd(params.workspacePath); - const projectPath = resolvePathFromCwd(params.projectPath); - const derivedDataPath = resolvePathFromCwd(params.derivedDataPath); - - let projectDir: string | undefined; - - if (projectPath) { - command.push('-project', projectPath); - projectDir = path.dirname(projectPath); - } else if (workspacePath) { - command.push('-workspace', workspacePath); - projectDir = path.dirname(workspacePath); - } - - command.push('-scheme', params.scheme); - command.push('-configuration', params.configuration ?? 'Debug'); - command.push('-destination', getBuildSettingsDestination(params.platform, params.deviceId)); - - if (derivedDataPath) { - command.push('-derivedDataPath', derivedDataPath); - } - - if (params.extraArgs && params.extraArgs.length > 0) { - command.push(...params.extraArgs); - } - - const result = await executor( - command, - 'Get App Path', - false, - projectDir ? { cwd: projectDir } : undefined, - ); - - if (!result.success) { - throw new Error(result.error ?? 'Unknown error'); - } - - return extractAppPathFromBuildSettingsOutput(result.output); -} diff --git a/src/mcp/tools/device/build_device.ts b/src/mcp/tools/device/build_device.ts index fd649122..16a537c4 100644 --- a/src/mcp/tools/device/build_device.ts +++ b/src/mcp/tools/device/build_device.ts @@ -6,7 +6,6 @@ */ import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; import { XcodePlatform } from '../../../types/common.ts'; import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; @@ -14,8 +13,12 @@ import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; +import { startBuildPipeline } from '../../../utils/xcodebuild-pipeline.ts'; +import { finalizeInlineXcodebuild } from '../../../utils/xcodebuild-output.ts'; +import { formatToolPreflight } from '../../../utils/build-preflight.ts'; // Unified schema: XOR between projectPath and workspacePath const baseSchemaObject = z.object({ @@ -57,22 +60,68 @@ const publicSchemaObject = baseSchemaObject.omit({ export async function buildDeviceLogic( params: BuildDeviceParams, executor: CommandExecutor, -): Promise { +): Promise { + const ctx = getHandlerContext(); const processedParams = { ...params, - configuration: params.configuration ?? 'Debug', // Default config + configuration: params.configuration ?? 'Debug', }; - return executeXcodeBuildCommand( + const platformOptions = { + platform: XcodePlatform.iOS, + logPrefix: 'iOS Device Build', + }; + + const preflightText = formatToolPreflight({ + operation: 'Build', + scheme: params.scheme, + workspacePath: params.workspacePath, + projectPath: params.projectPath, + configuration: processedParams.configuration, + platform: 'iOS', + }); + + const pipelineParams = { + scheme: params.scheme, + workspacePath: params.workspacePath, + projectPath: params.projectPath, + configuration: processedParams.configuration, + platform: 'iOS', + preflight: preflightText, + }; + + const started = startBuildPipeline({ + operation: 'BUILD', + toolName: 'build_device', + params: pipelineParams, + message: preflightText, + }); + + const buildResult = await executeXcodeBuildCommand( processedParams, - { - platform: XcodePlatform.iOS, - logPrefix: 'iOS Device Build', - }, + platformOptions, params.preferXcodebuild ?? false, 'build', executor, + undefined, + started.pipeline, ); + + finalizeInlineXcodebuild({ + started, + emit: ctx.emit, + succeeded: !buildResult.isError, + durationMs: Date.now() - started.startedAt, + responseContent: buildResult.content, + }); + + if (!buildResult.isError) { + ctx.nextStepParams = { + get_device_app_path: { + scheme: params.scheme, + }, + }; + } } export const schema = getSessionAwareToolSchemaShape({ diff --git a/src/mcp/tools/device/build_run_device.ts b/src/mcp/tools/device/build_run_device.ts index a8f8d9ba..b06e6342 100644 --- a/src/mcp/tools/device/build_run_device.ts +++ b/src/mcp/tools/device/build_run_device.ts @@ -5,10 +5,9 @@ */ import * as z from 'zod'; -import type { ToolResponse, SharedBuildParams } from '../../../types/common.ts'; -import { XcodePlatform } from '../../../types/common.ts'; +import type { SharedBuildParams, NextStepParamsMap } from '../../../types/common.ts'; +import type { PipelineEvent } from '../../../types/pipeline-events.ts'; import { log } from '../../../utils/logging/index.ts'; -import { createTextResponse } from '../../../utils/responses/index.ts'; import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { @@ -18,12 +17,24 @@ import { import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { extractBundleIdFromAppPath } from '../../../utils/bundle-id.ts'; -import { install_app_deviceLogic } from './install_app_device.ts'; -import { launch_app_deviceLogic } from './launch_app_device.ts'; -import { mapDevicePlatform, resolveAppPathFromBuildSettings } from './build-settings.ts'; +import { mapDevicePlatform } from './build-settings.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header } from '../../../utils/tool-event-builders.ts'; +import { startBuildPipeline } from '../../../utils/xcodebuild-pipeline.ts'; +import { formatToolPreflight } from '../../../utils/build-preflight.ts'; +import { + createBuildRunResultEvents, + emitPipelineError, + emitPipelineNotice, + finalizeInlineXcodebuild, +} from '../../../utils/xcodebuild-output.ts'; +import { resolveAppPathFromBuildSettings } from '../../../utils/app-path-resolver.ts'; +import { resolveDeviceName } from '../../../utils/device-name-resolver.ts'; +import { installAppOnDevice, launchAppOnDevice } from '../../../utils/device-steps.ts'; const baseSchemaObject = z.object({ projectPath: z.string().optional().describe('Path to the .xcodeproj file'), @@ -54,157 +65,237 @@ const buildRunDeviceSchema = z.preprocess( export type BuildRunDeviceParams = z.infer; -function extractResponseText(response: ToolResponse): string { - return String(response.content[0]?.text ?? 'Unknown error'); -} - -function getSuccessText( - platform: XcodePlatform, - scheme: string, - bundleId: string, - deviceId: string, - hasStopHint: boolean, -): string { - const summary = `${platform} device build and run succeeded for scheme ${scheme}.\n\nThe app (${bundleId}) is now running on device ${deviceId}.`; - - if (hasStopHint) { - return summary; - } - - return `${summary}\n\nNote: Process ID was unavailable, so stop_app_device could not be auto-suggested. To stop the app manually, use stop_app_device with the correct processId.`; +function bailWithError( + started: ReturnType, + emit: (event: PipelineEvent) => void, + logMessage: string, + pipelineMessage: string, +): void { + log('error', logMessage); + emitPipelineError(started, 'BUILD', pipelineMessage); + finalizeInlineXcodebuild({ + started, + emit, + succeeded: false, + durationMs: Date.now() - started.startedAt, + }); } export async function build_run_deviceLogic( params: BuildRunDeviceParams, executor: CommandExecutor, fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), -): Promise { +): Promise { + const ctx = getHandlerContext(); const platform = mapDevicePlatform(params.platform); - const sharedBuildParams: SharedBuildParams = { - projectPath: params.projectPath, - workspacePath: params.workspacePath, - scheme: params.scheme, - configuration: params.configuration ?? 'Debug', - derivedDataPath: params.derivedDataPath, - extraArgs: params.extraArgs, - }; - - const buildResult = await executeXcodeBuildCommand( - sharedBuildParams, - { - platform, - logPrefix: `${platform} Device Build`, - }, - params.preferXcodebuild ?? false, - 'build', - executor, - ); - - if (buildResult.isError) { - return buildResult; - } + return withErrorHandling( + ctx, + async () => { + const configuration = params.configuration ?? 'Debug'; - let appPath: string; - try { - appPath = await resolveAppPathFromBuildSettings( - { + const sharedBuildParams: SharedBuildParams = { projectPath: params.projectPath, workspacePath: params.workspacePath, scheme: params.scheme, - configuration: params.configuration, - platform, + configuration, derivedDataPath: params.derivedDataPath, extraArgs: params.extraArgs, - }, - executor, - ); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return createTextResponse(`Build succeeded, but failed to get app path: ${errorMessage}`, true); - } - - let bundleId: string; - try { - bundleId = (await extractBundleIdFromAppPath(appPath, executor)).trim(); - if (bundleId.length === 0) { - return createTextResponse( - 'Build succeeded, but failed to get bundle ID: Empty bundle ID.', - true, + }; + + const platformOptions = { + platform, + logPrefix: `${platform} Device Build`, + }; + + const deviceName = resolveDeviceName(params.deviceId); + + const preflightText = formatToolPreflight({ + operation: 'Build & Run', + scheme: params.scheme, + workspacePath: params.workspacePath, + projectPath: params.projectPath, + configuration, + platform: String(platform), + deviceId: params.deviceId, + deviceName, + }); + + const started = startBuildPipeline({ + operation: 'BUILD', + toolName: 'build_run_device', + params: { + scheme: params.scheme, + workspacePath: params.workspacePath, + projectPath: params.projectPath, + configuration, + platform: String(platform), + deviceId: params.deviceId, + preflight: preflightText, + }, + message: preflightText, + }); + + // Build + const buildResult = await executeXcodeBuildCommand( + sharedBuildParams, + platformOptions, + params.preferXcodebuild ?? false, + 'build', + executor, + undefined, + started.pipeline, ); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return createTextResponse( - `Build succeeded, but failed to get bundle ID: ${errorMessage}`, - true, - ); - } - - const installResult = await install_app_deviceLogic( - { - deviceId: params.deviceId, - appPath, - }, - executor, - ); - if (installResult.isError) { - return createTextResponse( - `Build succeeded, but error installing app on device: ${extractResponseText(installResult)}`, - true, - ); - } + if (buildResult.isError) { + finalizeInlineXcodebuild({ + started, + emit: ctx.emit, + succeeded: false, + durationMs: Date.now() - started.startedAt, + responseContent: buildResult.content, + errorFallbackPolicy: 'if-no-structured-diagnostics', + }); + return; + } - const launchResult = await launch_app_deviceLogic( - { - deviceId: params.deviceId, - bundleId, - env: params.env, - }, - executor, - fileSystemExecutor, - ); + // Resolve app path + emitPipelineNotice(started, 'BUILD', 'Resolving app path', 'info', { + code: 'build-run-step', + data: { step: 'resolve-app-path', status: 'started' }, + }); - if (launchResult.isError) { - return createTextResponse( - `Build and install succeeded, but error launching app on device: ${extractResponseText(launchResult)}`, - true, - ); - } - - const launchNextSteps = launchResult.nextStepParams ?? {}; - const hasStopHint = - 'stop_app_device' in launchNextSteps && - typeof launchNextSteps.stop_app_device === 'object' && - launchNextSteps.stop_app_device !== null; - - log('info', `Device build and run succeeded for scheme ${params.scheme}.`); - - const successText = getSuccessText( - platform, - params.scheme, - bundleId, - params.deviceId, - hasStopHint, - ); + let appPath: string; + try { + appPath = await resolveAppPathFromBuildSettings( + { + projectPath: params.projectPath, + workspacePath: params.workspacePath, + scheme: params.scheme, + configuration: params.configuration, + platform, + derivedDataPath: params.derivedDataPath, + extraArgs: params.extraArgs, + }, + executor, + ); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return bailWithError( + started, + ctx.emit, + 'Build succeeded, but failed to get app path to launch.', + `Failed to get app path to launch: ${errorMessage}`, + ); + } - return { - content: [ - { - type: 'text', - text: successText, - }, - ], - nextStepParams: { - ...launchNextSteps, - start_device_log_cap: { - deviceId: params.deviceId, + log('info', `App path determined as: ${appPath}`); + emitPipelineNotice(started, 'BUILD', 'App path resolved', 'success', { + code: 'build-run-step', + data: { step: 'resolve-app-path', status: 'succeeded', appPath }, + }); + + // Extract bundle ID + let bundleId: string; + try { + bundleId = (await extractBundleIdFromAppPath(appPath, executor)).trim(); + if (bundleId.length === 0) { + throw new Error('Empty bundle ID returned'); + } + log('info', `Bundle ID for run: ${bundleId}`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return bailWithError( + started, + ctx.emit, + `Failed to extract bundle ID: ${errorMessage}`, + `Failed to extract bundle ID: ${errorMessage}`, + ); + } + + // Install app on device + emitPipelineNotice(started, 'BUILD', 'Installing app', 'info', { + code: 'build-run-step', + data: { step: 'install-app', status: 'started' }, + }); + + const installResult = await installAppOnDevice(params.deviceId, appPath, executor); + if (!installResult.success) { + const errorMessage = installResult.error ?? 'Failed to install app'; + return bailWithError( + started, + ctx.emit, + `Failed to install app on device: ${errorMessage}`, + `Failed to install app on device: ${errorMessage}`, + ); + } + + emitPipelineNotice(started, 'BUILD', 'App installed', 'success', { + code: 'build-run-step', + data: { step: 'install-app', status: 'succeeded' }, + }); + + // Launch app on device + emitPipelineNotice(started, 'BUILD', 'Launching app', 'info', { + code: 'build-run-step', + data: { step: 'launch-app', status: 'started', appPath }, + }); + + const launchResult = await launchAppOnDevice( + params.deviceId, bundleId, - }, + executor, + fileSystemExecutor, + { env: params.env }, + ); + if (!launchResult.success) { + const errorMessage = launchResult.error ?? 'Failed to launch app'; + return bailWithError( + started, + ctx.emit, + `Failed to launch app on device: ${errorMessage}`, + `Failed to launch app on device: ${errorMessage}`, + ); + } + + const processId = launchResult.processId; + + log('info', `Device build and run succeeded for scheme ${params.scheme}.`); + + const nextStepParams: NextStepParamsMap = {}; + + if (processId !== undefined) { + nextStepParams.stop_app_device = { + deviceId: params.deviceId, + processId, + }; + } + + finalizeInlineXcodebuild({ + started, + emit: ctx.emit, + succeeded: true, + durationMs: Date.now() - started.startedAt, + tailEvents: createBuildRunResultEvents({ + scheme: params.scheme, + platform: String(platform), + target: `${platform} Device`, + appPath, + bundleId, + processId, + launchState: 'requested', + buildLogPath: started.pipeline.logPath, + }), + includeBuildLogFileRef: false, + }); + ctx.nextStepParams = nextStepParams; }, - isError: false, - }; + { + header: header('Build & Run Device'), + errorMessage: ({ message }) => `Error during device build and run: ${message}`, + logMessage: ({ message }) => `Error during device build & run logic: ${message}`, + }, + ); } const publicSchemaObject = baseSchemaObject.omit({ @@ -225,7 +316,8 @@ export const schema = getSessionAwareToolSchemaShape({ export const handler = createSessionAwareTool({ internalSchema: buildRunDeviceSchema as unknown as z.ZodType, - logicFunction: build_run_deviceLogic, + logicFunction: (params, executor) => + build_run_deviceLogic(params, executor, getDefaultFileSystemExecutor()), getExecutor: getDefaultCommandExecutor, requirements: [ { allOf: ['scheme', 'deviceId'], message: 'Provide scheme and deviceId' }, diff --git a/src/mcp/tools/device/get_device_app_path.ts b/src/mcp/tools/device/get_device_app_path.ts index c613a52d..ca54823e 100644 --- a/src/mcp/tools/device/get_device_app_path.ts +++ b/src/mcp/tools/device/get_device_app_path.ts @@ -6,17 +6,22 @@ */ import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; -import { createTextResponse } from '../../../utils/responses/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; -import { mapDevicePlatform, resolveAppPathFromBuildSettings } from './build-settings.ts'; +import { mapDevicePlatform } from './build-settings.ts'; +import { extractQueryErrorMessages } from '../../../utils/xcodebuild-error-utils.ts'; +import { resolveAppPathFromBuildSettings } from '../../../utils/app-path-resolver.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine, detailTree, section } from '../../../utils/tool-event-builders.ts'; +import { displayPath } from '../../../utils/build-preflight.ts'; +import type { PipelineEvent } from '../../../types/pipeline-events.ts'; // Unified schema: XOR between projectPath and workspacePath, sharing common options const baseOptions = { @@ -42,7 +47,6 @@ const getDeviceAppPathSchema = z.preprocess( }), ); -// Use z.infer for type safety type GetDeviceAppPathParams = z.infer; const publicSchemaObject = baseSchemaObject.omit({ @@ -56,54 +60,81 @@ const publicSchemaObject = baseSchemaObject.omit({ export async function get_device_app_pathLogic( params: GetDeviceAppPathParams, executor: CommandExecutor, -): Promise { +): Promise { const platform = mapDevicePlatform(params.platform); const configuration = params.configuration ?? 'Debug'; + const headerParams: Array<{ label: string; value: string }> = [ + { label: 'Scheme', value: params.scheme }, + ]; + if (params.workspacePath) { + headerParams.push({ label: 'Workspace', value: params.workspacePath }); + } else if (params.projectPath) { + headerParams.push({ label: 'Project', value: params.projectPath }); + } + headerParams.push({ label: 'Configuration', value: configuration }); + headerParams.push({ label: 'Platform', value: platform }); + + const headerEvent = header('Get App Path', headerParams); + + function buildErrorEvents(rawOutput: string): PipelineEvent[] { + const messages = extractQueryErrorMessages(rawOutput); + return [ + headerEvent, + section(`Errors (${messages.length}):`, [...messages.map((m) => `\u{2717} ${m}`), ''], { + blankLineAfterTitle: true, + }), + statusLine('error', 'Query failed.'), + ]; + } + log('info', `Getting app path for scheme ${params.scheme} on platform ${platform}`); - try { - const appPath = await resolveAppPathFromBuildSettings( - { - projectPath: params.projectPath, - workspacePath: params.workspacePath, - scheme: params.scheme, - configuration, - platform, - }, - executor, - ); - - return { - content: [ - { - type: 'text', - text: `✅ App path retrieved successfully: ${appPath}`, - }, - ], - nextStepParams: { + const ctx = getHandlerContext(); + + return withErrorHandling( + ctx, + async () => { + let appPath: string; + try { + appPath = await resolveAppPathFromBuildSettings( + { + projectPath: params.projectPath, + workspacePath: params.workspacePath, + scheme: params.scheme, + configuration, + platform, + }, + executor, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + for (const event of buildErrorEvents(message)) { + ctx.emit(event); + } + return; + } + + ctx.emit(headerEvent); + ctx.emit(statusLine('success', 'Success')); + ctx.emit(detailTree([{ label: 'App Path', value: displayPath(appPath) }])); + ctx.nextStepParams = { get_app_bundle_id: { appPath }, install_app_device: { deviceId: 'DEVICE_UDID', appPath }, launch_app_device: { deviceId: 'DEVICE_UDID', bundleId: 'BUNDLE_ID' }, + }; + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Error retrieving app path: ${message}`, + logMessage: ({ message }) => `Error retrieving app path: ${message}`, + mapError: ({ message, emit }) => { + for (const event of buildErrorEvents(message)) { + emit?.(event); + } }, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error retrieving app path: ${errorMessage}`); - - if (errorMessage.startsWith('Could not extract app path from build settings.')) { - return createTextResponse( - 'Failed to extract app path from build settings. Make sure the app has been built first.', - true, - ); - } - - if (errorMessage.includes('xcodebuild:')) { - return createTextResponse(`Failed to get app path: ${errorMessage}`, true); - } - - return createTextResponse(`Error retrieving app path: ${errorMessage}`, true); - } + }, + ); } export const schema = getSessionAwareToolSchemaShape({ diff --git a/src/mcp/tools/device/install_app_device.ts b/src/mcp/tools/device/install_app_device.ts index 3cd6d133..048e7c25 100644 --- a/src/mcp/tools/device/install_app_device.ts +++ b/src/mcp/tools/device/install_app_device.ts @@ -6,16 +6,19 @@ */ import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; +import { formatDeviceId } from '../../../utils/device-name-resolver.ts'; +import { installAppOnDevice } from '../../../utils/device-steps.ts'; -// Define schema as ZodObject const installAppDeviceSchema = z.object({ deviceId: z .string() @@ -26,61 +29,42 @@ const installAppDeviceSchema = z.object({ const publicSchemaObject = installAppDeviceSchema.omit({ deviceId: true } as const); -// Use z.infer for type safety type InstallAppDeviceParams = z.infer; -/** - * Business logic for installing an app on a physical Apple device - */ export async function install_app_deviceLogic( params: InstallAppDeviceParams, executor: CommandExecutor, -): Promise { +): Promise { const { deviceId, appPath } = params; + const headerEvent = header('Install App', [ + { label: 'Device', value: formatDeviceId(deviceId) }, + { label: 'App', value: appPath }, + ]); log('info', `Installing app on device ${deviceId}`); - try { - const result = await executor( - ['xcrun', 'devicectl', 'device', 'install', 'app', '--device', deviceId, appPath], - 'Install app on device', - false, // useShell - undefined, // env - ); + const ctx = getHandlerContext(); + + return withErrorHandling( + ctx, + async () => { + const installResult = await installAppOnDevice(deviceId, appPath, executor); - if (!result.success) { - return { - content: [ - { - type: 'text', - text: `Failed to install app: ${result.error}`, - }, - ], - isError: true, - }; - } + if (!installResult.success) { + ctx.emit(headerEvent); + ctx.emit(statusLine('error', `Failed to install app: ${installResult.error}`)); + return; + } - return { - content: [ - { - type: 'text', - text: `✅ App installed successfully on device ${deviceId}\n\n${result.output}`, - }, - ], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error installing app on device: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `Failed to install app on device: ${errorMessage}`, - }, - ], - isError: true, - }; - } + ctx.emit(headerEvent); + ctx.emit(statusLine('success', 'App installed successfully.')); + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Failed to install app on device: ${message}`, + logMessage: ({ message }) => `Error installing app on device: ${message}`, + }, + ); } export const schema = getSessionAwareToolSchemaShape({ diff --git a/src/mcp/tools/device/launch_app_device.ts b/src/mcp/tools/device/launch_app_device.ts index feb1e404..5c3c54a5 100644 --- a/src/mcp/tools/device/launch_app_device.ts +++ b/src/mcp/tools/device/launch_app_device.ts @@ -6,7 +6,6 @@ */ import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { @@ -16,19 +15,13 @@ import { import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; -import { join } from 'path'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine, detailTree } from '../../../utils/tool-event-builders.ts'; +import { formatDeviceId } from '../../../utils/device-name-resolver.ts'; +import { launchAppOnDevice } from '../../../utils/device-steps.ts'; -// Type for the launch JSON response -type LaunchDataResponse = { - result?: { - process?: { - processIdentifier?: number; - }; - }; -}; - -// Define schema as ZodObject const launchAppDeviceSchema = z.object({ deviceId: z.string().describe('UDID of the device (obtained from list_devices)'), bundleId: z.string(), @@ -43,114 +36,53 @@ const publicSchemaObject = launchAppDeviceSchema.omit({ bundleId: true, } as const); -// Use z.infer for type safety type LaunchAppDeviceParams = z.infer; export async function launch_app_deviceLogic( params: LaunchAppDeviceParams, executor: CommandExecutor, fileSystem: FileSystemExecutor, -): Promise { +): Promise { const { deviceId, bundleId } = params; log('info', `Launching app ${bundleId} on device ${deviceId}`); - try { - // Use JSON output to capture process ID - const tempJsonPath = join(fileSystem.tmpdir(), `launch-${Date.now()}.json`); - - const command = [ - 'xcrun', - 'devicectl', - 'device', - 'process', - 'launch', - '--device', - deviceId, - '--json-output', - tempJsonPath, - '--terminate-existing', - ]; + const headerEvent = header('Launch App', [ + { label: 'Device', value: formatDeviceId(deviceId) }, + { label: 'Bundle ID', value: bundleId }, + ]); - if (params.env && Object.keys(params.env).length > 0) { - command.push('--environment-variables', JSON.stringify(params.env)); - } + const ctx = getHandlerContext(); - command.push(bundleId); + return withErrorHandling( + ctx, + async () => { + const launchResult = await launchAppOnDevice(deviceId, bundleId, executor, fileSystem, { + env: params.env, + }); - const result = await executor( - command, - 'Launch app on device', - false, // useShell - undefined, // env - ); + if (!launchResult.success) { + ctx.emit(headerEvent); + ctx.emit(statusLine('error', `Failed to launch app: ${launchResult.error}`)); + return; + } - if (!result.success) { - return { - content: [ - { - type: 'text', - text: `Failed to launch app: ${result.error}`, - }, - ], - isError: true, - }; - } + const processId = launchResult.processId; - // Parse JSON to extract process ID - let processId: number | undefined; - try { - const jsonContent = await fileSystem.readFile(tempJsonPath, 'utf8'); - const parsedData: unknown = JSON.parse(jsonContent); + ctx.emit(headerEvent); + ctx.emit(statusLine('success', 'App launched successfully.')); - // Type guard to validate the parsed data structure - if ( - parsedData && - typeof parsedData === 'object' && - 'result' in parsedData && - parsedData.result && - typeof parsedData.result === 'object' && - 'process' in parsedData.result && - parsedData.result.process && - typeof parsedData.result.process === 'object' && - 'processIdentifier' in parsedData.result.process && - typeof parsedData.result.process.processIdentifier === 'number' - ) { - const launchData = parsedData as LaunchDataResponse; - processId = launchData.result?.process?.processIdentifier; + if (processId !== undefined) { + ctx.emit(detailTree([{ label: 'Process ID', value: processId.toString() }])); + ctx.nextStepParams = { stop_app_device: { deviceId, processId } }; } - } catch (error) { - log('warn', `Failed to parse launch JSON output: ${error}`); - } finally { - await fileSystem.rm(tempJsonPath, { force: true }).catch(() => {}); - } - - const responseText = processId - ? `✅ App launched successfully\n\n${result.output}\n\nProcess ID: ${processId}\n\nInteract with your app on the device.` - : `✅ App launched successfully\n\n${result.output}`; - - return { - content: [ - { - type: 'text', - text: responseText, - }, - ], - ...(processId ? { nextStepParams: { stop_app_device: { deviceId, processId } } } : {}), - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error launching app on device: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `Failed to launch app on device: ${errorMessage}`, - }, - ], - isError: true, - }; - } + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Failed to launch app on device: ${message}`, + logMessage: ({ message }) => `Error launching app on device: ${message}`, + }, + ); } export const schema = getSessionAwareToolSchemaShape({ diff --git a/src/mcp/tools/device/list_devices.ts b/src/mcp/tools/device/list_devices.ts index 22b1812f..46bc24ac 100644 --- a/src/mcp/tools/device/list_devices.ts +++ b/src/mcp/tools/device/list_devices.ts @@ -1,55 +1,121 @@ -/** - * Device Workspace Plugin: List Devices - * - * Lists connected physical Apple devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro) - * with their UUIDs, names, and connection status. Use this to discover physical devices for testing. - */ - import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; -import { promises as fs } from 'fs'; -import { tmpdir } from 'os'; -import { join } from 'path'; +import { createTypedTool, getHandlerContext } from '../../../utils/typed-tool-factory.ts'; +import { promises as fs } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { PipelineEvent } from '../../../types/pipeline-events.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine, section } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject (empty schema since this tool takes no parameters) const listDevicesSchema = z.object({}); -// Use z.infer for type safety type ListDevicesParams = z.infer; function isAvailableState(state: string): boolean { return state === 'Available' || state === 'Available (WiFi)' || state === 'Connected'; } +const PLATFORM_KEYWORDS: Array<{ keywords: string[]; label: string }> = [ + { keywords: ['iphone', 'ios'], label: 'iOS' }, + { keywords: ['ipad'], label: 'iPadOS' }, + { keywords: ['watch'], label: 'watchOS' }, + { keywords: ['appletv', 'tvos', 'apple tv'], label: 'tvOS' }, + { keywords: ['xros', 'vision'], label: 'visionOS' }, + { keywords: ['mac'], label: 'macOS' }, +]; + function getPlatformLabel(platformIdentifier?: string): string { const platformId = platformIdentifier?.toLowerCase() ?? ''; + const match = PLATFORM_KEYWORDS.find((entry) => + entry.keywords.some((keyword) => platformId.includes(keyword)), + ); + return match?.label ?? 'Unknown'; +} - if (platformId.includes('ios') || platformId.includes('iphone')) { - return 'iOS'; +function getPlatformOrder(platform: string): number { + switch (platform) { + case 'iOS': + return 0; + case 'iPadOS': + return 1; + case 'watchOS': + return 2; + case 'tvOS': + return 3; + case 'visionOS': + return 4; + case 'macOS': + return 5; + default: + return 6; } - if (platformId.includes('ipad')) { - return 'iPadOS'; - } - if (platformId.includes('watch')) { - return 'watchOS'; +} + +function getDeviceEmoji(platform: string): string { + switch (platform) { + case 'watchOS': + return '⌚️'; + case 'tvOS': + return '📺'; + case 'visionOS': + return '🥽'; + case 'macOS': + return '💻'; + default: + return '📱'; } - if (platformId.includes('tv') || platformId.includes('apple tv')) { - return 'tvOS'; +} + +function buildDevicePlatformSections( + devices: Array<{ + name: string; + identifier: string; + platform: string; + osVersion?: string; + state: string; + }>, +): { sections: PipelineEvent[]; summary: string } { + const grouped = new Map(); + + for (const device of devices) { + const group = grouped.get(device.platform) ?? []; + group.push(device); + grouped.set(device.platform, group); } - if (platformId.includes('vision')) { - return 'visionOS'; + + const orderedPlatforms = [...grouped.keys()].sort( + (a, b) => getPlatformOrder(a) - getPlatformOrder(b), + ); + + const sections: PipelineEvent[] = []; + for (const platform of orderedPlatforms) { + const platformDevices = grouped.get(platform) ?? []; + if (platformDevices.length === 0) continue; + + const lines: string[] = []; + for (const device of platformDevices) { + const availability = isAvailableState(device.state) ? '\u2713' : '\u2717'; + lines.push(`${getDeviceEmoji(platform)} [${availability}] ${device.name}`); + lines.push(` OS: ${device.osVersion ?? 'Unknown'}`); + lines.push(` UDID: ${device.identifier}`); + lines.push(''); + } + + sections.push(section(`${platform} Devices:`, lines, { blankLineAfterTitle: true })); } - return 'Unknown'; + const platformCounts = orderedPlatforms.map((platform) => { + const count = grouped.get(platform)?.length ?? 0; + return `${count} ${platform}`; + }); + + const summary = `${devices.length} physical devices discovered (${platformCounts.join(', ')}).`; + return { sections, summary }; } -/** - * Business logic for listing connected devices - */ export async function list_devicesLogic( _params: ListDevicesParams, executor: CommandExecutor, @@ -58,13 +124,15 @@ export async function list_devicesLogic( readFile?: (path: string, encoding?: string) => Promise; unlink?: (path: string) => Promise; }, -): Promise { +): Promise { log('info', 'Starting device discovery'); - try { - // Try modern devicectl with JSON output first (iOS 17+, Xcode 15+) + const ctx = getHandlerContext(); + const headerEvent = header('List Devices'); + + const buildEvents = async (): Promise => { const tempDir = pathDeps?.tmpdir ? pathDeps.tmpdir() : tmpdir(); - const timestamp = pathDeps?.join ? '123' : Date.now(); // Use fixed timestamp for tests + const timestamp = pathDeps?.join ? '123' : Date.now(); const tempJsonPath = pathDeps?.join ? pathDeps.join(tempDir, `devicectl-${timestamp}.json`) : join(tempDir, `devicectl-${timestamp}.json`); @@ -76,38 +144,23 @@ export async function list_devicesLogic( ['xcrun', 'devicectl', 'list', 'devices', '--json-output', tempJsonPath], 'List Devices (devicectl with JSON)', false, - undefined, ); if (result.success) { useDevicectl = true; - // Read and parse the JSON file const jsonContent = fsDeps?.readFile ? await fsDeps.readFile(tempJsonPath, 'utf8') : await fs.readFile(tempJsonPath, 'utf8'); const deviceCtlData: unknown = JSON.parse(jsonContent); - // Type guard to validate the device data structure - const isValidDeviceData = (data: unknown): data is { result?: { devices?: unknown[] } } => { - return ( - typeof data === 'object' && - data !== null && - 'result' in data && - typeof (data as { result?: unknown }).result === 'object' && - (data as { result?: unknown }).result !== null && - 'devices' in ((data as { result?: unknown }).result as { devices?: unknown }) && - Array.isArray( - ((data as { result?: unknown }).result as { devices?: unknown[] }).devices, - ) - ); - }; - - if (isValidDeviceData(deviceCtlData) && deviceCtlData.result?.devices) { - for (const deviceRaw of deviceCtlData.result.devices) { - // Type guard for device object - const isValidDevice = ( - device: unknown, - ): device is { + const deviceCtlResult = deviceCtlData as { result?: { devices?: unknown[] } }; + const deviceList = deviceCtlResult?.result?.devices; + + if (Array.isArray(deviceList)) { + for (const deviceRaw of deviceList) { + if (typeof deviceRaw !== 'object' || deviceRaw === null) continue; + + const device = deviceRaw as { visibilityClass?: string; connectionProperties?: { pairingState?: string; @@ -126,115 +179,8 @@ export async function list_devicesLogic( cpuType?: { name?: string }; }; identifier?: string; - } => { - if (typeof device !== 'object' || device === null) { - return false; - } - - const dev = device as Record; - - // Check if identifier exists and is a string (most critical property) - if (typeof dev.identifier !== 'string' && dev.identifier !== undefined) { - return false; - } - - // Check visibilityClass if present - if (dev.visibilityClass !== undefined && typeof dev.visibilityClass !== 'string') { - return false; - } - - // Check connectionProperties structure if present - if (dev.connectionProperties !== undefined) { - if ( - typeof dev.connectionProperties !== 'object' || - dev.connectionProperties === null - ) { - return false; - } - const connProps = dev.connectionProperties as Record; - if ( - connProps.pairingState !== undefined && - typeof connProps.pairingState !== 'string' - ) { - return false; - } - if ( - connProps.tunnelState !== undefined && - typeof connProps.tunnelState !== 'string' - ) { - return false; - } - if ( - connProps.transportType !== undefined && - typeof connProps.transportType !== 'string' - ) { - return false; - } - } - - // Check deviceProperties structure if present - if (dev.deviceProperties !== undefined) { - if (typeof dev.deviceProperties !== 'object' || dev.deviceProperties === null) { - return false; - } - const devProps = dev.deviceProperties as Record; - if ( - devProps.platformIdentifier !== undefined && - typeof devProps.platformIdentifier !== 'string' - ) { - return false; - } - if (devProps.name !== undefined && typeof devProps.name !== 'string') { - return false; - } - if ( - devProps.osVersionNumber !== undefined && - typeof devProps.osVersionNumber !== 'string' - ) { - return false; - } - if ( - devProps.developerModeStatus !== undefined && - typeof devProps.developerModeStatus !== 'string' - ) { - return false; - } - if ( - devProps.marketingName !== undefined && - typeof devProps.marketingName !== 'string' - ) { - return false; - } - } - - // Check hardwareProperties structure if present - if (dev.hardwareProperties !== undefined) { - if (typeof dev.hardwareProperties !== 'object' || dev.hardwareProperties === null) { - return false; - } - const hwProps = dev.hardwareProperties as Record; - if (hwProps.productType !== undefined && typeof hwProps.productType !== 'string') { - return false; - } - if (hwProps.cpuType !== undefined) { - if (typeof hwProps.cpuType !== 'object' || hwProps.cpuType === null) { - return false; - } - const cpuType = hwProps.cpuType as Record; - if (cpuType.name !== undefined && typeof cpuType.name !== 'string') { - return false; - } - } - } - - return true; }; - if (!isValidDevice(deviceRaw)) continue; - - const device = deviceRaw; - - // Skip simulators or unavailable devices if ( device.visibilityClass === 'Simulator' || !device.connectionProperties?.pairingState @@ -242,20 +188,32 @@ export async function list_devicesLogic( continue; } - const platform = getPlatformLabel(device.deviceProperties?.platformIdentifier); + const platform = getPlatformLabel( + [ + device.deviceProperties?.platformIdentifier, + device.deviceProperties?.marketingName, + device.hardwareProperties?.productType, + device.deviceProperties?.name, + ] + .filter((value): value is string => typeof value === 'string' && value.length > 0) + .join(' '), + ); - // Determine connection state const pairingState = device.connectionProperties?.pairingState ?? ''; const tunnelState = device.connectionProperties?.tunnelState ?? ''; const transportType = device.connectionProperties?.transportType ?? ''; + const hasDirectConnection = + tunnelState === 'connected' || + transportType === 'wired' || + transportType === 'localNetwork'; let state: string; if (pairingState !== 'paired') { state = 'Unpaired'; - } else if (tunnelState === 'connected') { + } else if (hasDirectConnection) { state = 'Available'; } else { - state = 'Available (WiFi)'; + state = 'Paired (not connected)'; } devices.push({ @@ -278,7 +236,6 @@ export async function list_devicesLogic( } catch { log('info', 'devicectl with JSON failed, trying xctrace fallback'); } finally { - // Clean up temp file try { if (fsDeps?.unlink) { await fsDeps.unlink(tempJsonPath); @@ -290,150 +247,105 @@ export async function list_devicesLogic( } } - // If devicectl failed or returned no devices, fallback to xctrace if (!useDevicectl || devices.length === 0) { const result = await executor( ['xcrun', 'xctrace', 'list', 'devices'], 'List Devices (xctrace)', false, - undefined, ); if (!result.success) { - return { - content: [ - { - type: 'text', - text: `Failed to list devices: ${result.error}\n\nMake sure Xcode is installed and devices are connected and trusted.`, - }, - ], - isError: true, - }; + return [ + headerEvent, + statusLine('error', `Failed to list devices: ${result.error}`), + section('Troubleshooting', [ + 'Make sure Xcode is installed and devices are connected and trusted.', + ]), + ]; } - // Return raw xctrace output without parsing - return { - content: [ - { - type: 'text', - text: `Device listing (xctrace output):\n\n${result.output}\n\nNote: For better device information, please upgrade to Xcode 15 or later which supports the modern devicectl command.`, - }, - ], - }; + return [ + headerEvent, + section('Device listing (xctrace output)', [result.output]), + statusLine( + 'info', + 'For better device information, please upgrade to Xcode 15 or later which supports the modern devicectl command.', + ), + ]; } - // Format the response - let responseText = 'Connected Devices:\n\n'; + const uniqueDevices = [...new Map(devices.map((d) => [d.identifier, d])).values()]; - // Filter out duplicates - const uniqueDevices = devices.filter( - (device, index, self) => index === self.findIndex((d) => d.identifier === device.identifier), - ); + const events: PipelineEvent[] = [headerEvent]; if (uniqueDevices.length === 0) { - responseText += 'No physical Apple devices found.\n\n'; - responseText += 'Make sure:\n'; - responseText += '1. Devices are connected via USB or WiFi\n'; - responseText += '2. Devices are unlocked and trusted\n'; - responseText += '3. "Trust this computer" has been accepted on the device\n'; - responseText += '4. Developer mode is enabled on the device (iOS 16+)\n'; - responseText += '5. Xcode is properly installed\n\n'; - responseText += 'For simulators, use the list_sims tool instead.\n'; - } else { - // Group devices by availability status - const availableDevices = uniqueDevices.filter((d) => isAvailableState(d.state)); - const pairedDevices = uniqueDevices.filter((d) => d.state === 'Paired (not connected)'); - const unpairedDevices = uniqueDevices.filter((d) => d.state === 'Unpaired'); - - if (availableDevices.length > 0) { - responseText += '✅ Available Devices:\n'; - for (const device of availableDevices) { - responseText += `\n📱 ${device.name}\n`; - responseText += ` UDID: ${device.identifier}\n`; - responseText += ` Model: ${device.model ?? 'Unknown'}\n`; - if (device.productType) { - responseText += ` Product Type: ${device.productType}\n`; - } - responseText += ` Platform: ${device.platform} ${device.osVersion ?? ''}\n`; - if (device.cpuArchitecture) { - responseText += ` CPU Architecture: ${device.cpuArchitecture}\n`; - } - responseText += ` Connection: ${device.connectionType ?? 'Unknown'}\n`; - if (device.developerModeStatus) { - responseText += ` Developer Mode: ${device.developerModeStatus}\n`; - } - } - responseText += '\n'; - } - - if (pairedDevices.length > 0) { - responseText += '🔗 Paired but Not Connected:\n'; - for (const device of pairedDevices) { - responseText += `\n📱 ${device.name}\n`; - responseText += ` UDID: ${device.identifier}\n`; - responseText += ` Model: ${device.model ?? 'Unknown'}\n`; - responseText += ` Platform: ${device.platform} ${device.osVersion ?? ''}\n`; - } - responseText += '\n'; - } - - if (unpairedDevices.length > 0) { - responseText += '❌ Unpaired Devices:\n'; - for (const device of unpairedDevices) { - responseText += `- ${device.name} (${device.identifier})\n`; - } - responseText += '\n'; - } + events.push( + statusLine('warning', 'No physical Apple devices found.'), + section('Troubleshooting', [ + 'Make sure:', + '1. Devices are connected via USB or WiFi', + '2. Devices are unlocked and trusted', + '3. "Trust this computer" has been accepted on the device', + '4. Developer mode is enabled on the device (iOS 16+)', + '5. Xcode is properly installed', + '', + 'For simulators, use the list_sims tool instead.', + ]), + ); + return events; } - // Add next steps const availableDevicesExist = uniqueDevices.some((d) => isAvailableState(d.state)); - let nextStepParams: Record> | undefined; - if (availableDevicesExist) { - responseText += 'Note: Use the device ID/UDID from above when required by other tools.\n'; - responseText += - "Hint: Save a default device with session-set-defaults { deviceId: 'DEVICE_UDID' }.\n"; - responseText += - 'Before running build/run/test/UI automation tools, set the desired device identifier in session defaults.\n'; - - nextStepParams = { - build_device: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' }, - build_run_device: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' }, - test_device: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' }, - get_device_app_path: { scheme: 'SCHEME' }, - }; - } else if (uniqueDevices.length > 0) { - responseText += - 'Note: No devices are currently available for testing. Make sure devices are:\n'; - responseText += '- Connected via USB\n'; - responseText += '- Unlocked and trusted\n'; - responseText += '- Have developer mode enabled (iOS 16+)\n'; + const { sections: platformSections, summary } = buildDevicePlatformSections( + uniqueDevices.map((device) => ({ + name: device.name, + identifier: device.identifier, + platform: device.platform, + osVersion: device.osVersion, + state: device.state, + })), + ); + + events.push( + ...platformSections, + statusLine('success', summary), + section('Hints', [ + 'Use the device ID/UDID from above when required by other tools.', + "Save a default device with session-set-defaults { deviceId: 'DEVICE_UDID' }.", + 'Before running build/run/test/UI automation tools, set the desired device identifier in session defaults.', + ]), + ); + } else { + events.push( + statusLine('warning', 'No devices are currently available for testing.'), + section('Troubleshooting', [ + 'Make sure devices are:', + '- Connected via USB', + '- Unlocked and trusted', + '- Have developer mode enabled (iOS 16+)', + ]), + ); } - return { - content: [ - { - type: 'text', - text: responseText, - }, - ], - ...(nextStepParams ? { nextStepParams } : {}), - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error listing devices: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `Failed to list devices: ${errorMessage}`, - }, - ], - isError: true, - }; - } + return events; + }; + + await withErrorHandling( + ctx, + async () => { + const events = await buildEvents(); + for (const event of events) { + ctx.emit(event); + } + }, + { + header: headerEvent, + errorMessage: ({ message }: { message: string }) => `Failed to list devices: ${message}`, + logMessage: ({ message }: { message: string }) => `Error listing devices: ${message}`, + }, + ); } export const schema = listDevicesSchema.shape; diff --git a/src/mcp/tools/device/stop_app_device.ts b/src/mcp/tools/device/stop_app_device.ts index 4bbf4319..1a488ca8 100644 --- a/src/mcp/tools/device/stop_app_device.ts +++ b/src/mcp/tools/device/stop_app_device.ts @@ -6,22 +6,23 @@ */ import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; +import { formatDeviceId } from '../../../utils/device-name-resolver.ts'; -// Define schema as ZodObject const stopAppDeviceSchema = z.object({ deviceId: z.string().describe('UDID of the device (obtained from list_devices)'), processId: z.number(), }); -// Use z.infer for type safety type StopAppDeviceParams = z.infer; const publicSchemaObject = stopAppDeviceSchema.omit({ deviceId: true } as const); @@ -29,62 +30,51 @@ const publicSchemaObject = stopAppDeviceSchema.omit({ deviceId: true } as const) export async function stop_app_deviceLogic( params: StopAppDeviceParams, executor: CommandExecutor, -): Promise { +): Promise { const { deviceId, processId } = params; + const headerEvent = header('Stop App', [ + { label: 'Device', value: formatDeviceId(deviceId) }, + { label: 'PID', value: processId.toString() }, + ]); log('info', `Stopping app with PID ${processId} on device ${deviceId}`); - try { - const result = await executor( - [ - 'xcrun', - 'devicectl', - 'device', - 'process', - 'terminate', - '--device', - deviceId, - '--pid', - processId.toString(), - ], - 'Stop app on device', - false, // useShell - undefined, // env - ); + const ctx = getHandlerContext(); - if (!result.success) { - return { - content: [ - { - type: 'text', - text: `Failed to stop app: ${result.error}`, - }, + return withErrorHandling( + ctx, + async () => { + const result = await executor( + [ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'terminate', + '--device', + deviceId, + '--pid', + processId.toString(), ], - isError: true, - }; - } + 'Stop app on device', + false, + ); - return { - content: [ - { - type: 'text', - text: `✅ App stopped successfully\n\n${result.output}`, - }, - ], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error stopping app on device: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `Failed to stop app on device: ${errorMessage}`, - }, - ], - isError: true, - }; - } + if (!result.success) { + ctx.emit(headerEvent); + ctx.emit(statusLine('error', `Failed to stop app: ${result.error}`)); + return; + } + + ctx.emit(headerEvent); + ctx.emit(statusLine('success', 'App stopped successfully')); + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Failed to stop app on device: ${message}`, + logMessage: ({ message }) => `Error stopping app on device: ${message}`, + }, + ); } export const schema = getSessionAwareToolSchemaShape({ diff --git a/src/mcp/tools/device/test_device.ts b/src/mcp/tools/device/test_device.ts index 7d9732fb..18b88fad 100644 --- a/src/mcp/tools/device/test_device.ts +++ b/src/mcp/tools/device/test_device.ts @@ -6,18 +6,9 @@ */ import * as z from 'zod'; -import { join } from 'path'; -import type { ToolResponse } from '../../../types/common.ts'; import { XcodePlatform } from '../../../types/common.ts'; -import { log } from '../../../utils/logging/index.ts'; -import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; -import { createTextResponse } from '../../../utils/responses/index.ts'; -import { normalizeTestRunnerEnv } from '../../../utils/environment.ts'; -import type { - CommandExecutor, - FileSystemExecutor, - CommandExecOptions, -} from '../../../utils/execution/index.ts'; +import { handleTestLogic } from '../../../utils/test/index.ts'; +import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor, getDefaultFileSystemExecutor, @@ -27,9 +18,8 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; -import { filterStderrContent, type XcresultSummary } from '../../../utils/test-result-content.ts'; +import { resolveTestPreflight } from '../../../utils/test-preflight.ts'; -// Unified schema: XOR between projectPath and workspacePath const baseSchemaObject = z.object({ projectPath: z.string().optional().describe('Path to the .xcodeproj file'), workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), @@ -46,6 +36,10 @@ const baseSchemaObject = z.object({ .describe( 'Environment variables to pass to the test runner (TEST_RUNNER_ prefix added automatically)', ), + progress: z + .boolean() + .optional() + .describe('Show detailed test progress output (MCP defaults to true, CLI defaults to false)'), }); const testDeviceSchema = z.preprocess( @@ -72,225 +66,47 @@ const publicSchemaObject = baseSchemaObject.omit({ platform: true, } as const); -/** - * Parse xcresult bundle using xcrun xcresulttool - */ -async function parseXcresultBundle( - resultBundlePath: string, - executor: CommandExecutor = getDefaultCommandExecutor(), -): Promise { - try { - const result = await executor( - ['xcrun', 'xcresulttool', 'get', 'test-results', 'summary', '--path', resultBundlePath], - 'Parse xcresult bundle', - ); - if (!result.success) { - throw new Error(result.error ?? 'Failed to execute xcresulttool'); - } - if (!result.output || result.output.trim().length === 0) { - throw new Error('xcresulttool returned no output'); - } - - // Parse JSON response and format as human-readable - const summaryData = JSON.parse(result.output) as Record; - return { - formatted: formatTestSummary(summaryData), - totalTestCount: - typeof summaryData.totalTestCount === 'number' ? summaryData.totalTestCount : 0, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error parsing xcresult bundle: ${errorMessage}`); - throw error; - } -} - -/** - * Format test summary JSON into human-readable text - */ -function formatTestSummary(summary: Record): string { - const lines = []; - - lines.push(`Test Summary: ${summary.title ?? 'Unknown'}`); - lines.push(`Overall Result: ${summary.result ?? 'Unknown'}`); - lines.push(''); - - lines.push('Test Counts:'); - lines.push(` Total: ${summary.totalTestCount ?? 0}`); - lines.push(` Passed: ${summary.passedTests ?? 0}`); - lines.push(` Failed: ${summary.failedTests ?? 0}`); - lines.push(` Skipped: ${summary.skippedTests ?? 0}`); - lines.push(` Expected Failures: ${summary.expectedFailures ?? 0}`); - lines.push(''); - - if (summary.environmentDescription) { - lines.push(`Environment: ${summary.environmentDescription}`); - lines.push(''); - } - - if ( - summary.devicesAndConfigurations && - Array.isArray(summary.devicesAndConfigurations) && - summary.devicesAndConfigurations.length > 0 - ) { - const deviceConfig = summary.devicesAndConfigurations[0] as Record; - const device = deviceConfig.device as Record | undefined; - if (device) { - lines.push( - `Device: ${device.deviceName ?? 'Unknown'} (${device.platform ?? 'Unknown'} ${device.osVersion ?? 'Unknown'})`, - ); - lines.push(''); - } - } - - if ( - summary.testFailures && - Array.isArray(summary.testFailures) && - summary.testFailures.length > 0 - ) { - lines.push('Test Failures:'); - summary.testFailures.forEach((failureItem, index) => { - const failure = failureItem as Record; - lines.push( - ` ${index + 1}. ${failure.testName ?? 'Unknown Test'} (${failure.targetName ?? 'Unknown Target'})`, - ); - if (failure.failureText) { - lines.push(` ${failure.failureText}`); - } - }); - lines.push(''); - } - - if (summary.topInsights && Array.isArray(summary.topInsights) && summary.topInsights.length > 0) { - lines.push('Insights:'); - summary.topInsights.forEach((insightItem, index) => { - const insight = insightItem as Record; - lines.push( - ` ${index + 1}. [${insight.impact ?? 'Unknown'}] ${insight.text ?? 'No description'}`, - ); - }); - } - - return lines.join('\n'); -} - -/** - * Business logic for running tests with platform-specific handling. - * Exported for direct testing and reuse. - */ export async function testDeviceLogic( params: TestDeviceParams, executor: CommandExecutor = getDefaultCommandExecutor(), fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), -): Promise { - log( - 'info', - `Starting test run for scheme ${params.scheme} on platform ${params.platform ?? 'iOS'} (internal)`, +): Promise { + const configuration = params.configuration ?? 'Debug'; + const platform = (params.platform as XcodePlatform) || XcodePlatform.iOS; + + const preflight = await resolveTestPreflight( + { + projectPath: params.projectPath, + workspacePath: params.workspacePath, + scheme: params.scheme, + configuration, + extraArgs: params.extraArgs, + destinationName: params.deviceId, + }, + fileSystemExecutor, ); - let tempDir: string | undefined; - const cleanup = async (): Promise => { - if (!tempDir) return; - try { - await fileSystemExecutor.rm(tempDir, { recursive: true, force: true }); - } catch (cleanupError) { - log('warn', `Failed to clean up temporary directory: ${cleanupError}`); - } - }; - - try { - // Create temporary directory for xcresult bundle - tempDir = await fileSystemExecutor.mkdtemp( - join(fileSystemExecutor.tmpdir(), 'xcodebuild-test-'), - ); - const resultBundlePath = join(tempDir, 'TestResults.xcresult'); - - // Add resultBundlePath to extraArgs - const extraArgs = [...(params.extraArgs ?? []), `-resultBundlePath`, resultBundlePath]; - - // Prepare execution options with TEST_RUNNER_ environment variables - const execOpts: CommandExecOptions | undefined = params.testRunnerEnv - ? { env: normalizeTestRunnerEnv(params.testRunnerEnv) } - : undefined; - - // Run the test command - const testResult = await executeXcodeBuildCommand( - { - projectPath: params.projectPath, - workspacePath: params.workspacePath, - scheme: params.scheme, - configuration: params.configuration ?? 'Debug', - derivedDataPath: params.derivedDataPath, - extraArgs, - }, - { - platform: (params.platform as XcodePlatform) || XcodePlatform.iOS, - simulatorName: undefined, - simulatorId: undefined, - deviceId: params.deviceId, - useLatestOS: false, - logPrefix: 'Test Run', - }, - params.preferXcodebuild, - 'test', - executor, - execOpts, - ); - - // Parse xcresult bundle if it exists, regardless of whether tests passed or failed - // Test failures are expected and should not prevent xcresult parsing - try { - log('info', `Attempting to parse xcresult bundle at: ${resultBundlePath}`); - - // Check if the file exists - try { - await fileSystemExecutor.stat(resultBundlePath); - log('info', `xcresult bundle exists at: ${resultBundlePath}`); - } catch { - log('warn', `xcresult bundle does not exist at: ${resultBundlePath}`); - throw new Error(`xcresult bundle not found at ${resultBundlePath}`); - } - - const xcresult = await parseXcresultBundle(resultBundlePath, executor); - log('info', 'Successfully parsed xcresult bundle'); - - // Clean up temporary directory - await cleanup(); - - // If no tests ran (for example build/setup failed), xcresult summary is not useful. - // Return raw output so the original diagnostics stay visible. - if (xcresult.totalTestCount === 0) { - log('info', 'xcresult reports 0 tests — returning raw build output'); - return testResult; - } - - // xcresult summary should be first. Drop stderr-only noise while preserving non-stderr lines. - const filteredContent = filterStderrContent(testResult.content); - return { - content: [ - { - type: 'text', - text: '\nTest Results Summary:\n' + xcresult.formatted, - }, - ...filteredContent, - ], - isError: testResult.isError, - }; - } catch (parseError) { - // If parsing fails, return original test result - log('warn', `Failed to parse xcresult bundle: ${parseError}`); - - await cleanup(); - - return testResult; - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error during test run: ${errorMessage}`); - return createTextResponse(`Error during test run: ${errorMessage}`, true); - } finally { - await cleanup(); - } + await handleTestLogic( + { + projectPath: params.projectPath, + workspacePath: params.workspacePath, + scheme: params.scheme, + deviceId: params.deviceId, + configuration, + derivedDataPath: params.derivedDataPath, + extraArgs: params.extraArgs, + preferXcodebuild: params.preferXcodebuild ?? false, + platform, + useLatestOS: false, + testRunnerEnv: params.testRunnerEnv, + progress: params.progress, + }, + executor, + { + preflight: preflight ?? undefined, + toolName: 'test_device', + }, + ); } export const schema = getSessionAwareToolSchemaShape({ @@ -300,15 +116,8 @@ export const schema = getSessionAwareToolSchemaShape({ export const handler = createSessionAwareTool({ internalSchema: testDeviceSchema as unknown as z.ZodType, - logicFunction: (params: TestDeviceParams, executor: CommandExecutor) => - testDeviceLogic( - { - ...params, - platform: params.platform ?? 'iOS', - }, - executor, - getDefaultFileSystemExecutor(), - ), + logicFunction: (params, executor) => + testDeviceLogic(params, executor, getDefaultFileSystemExecutor()), getExecutor: getDefaultCommandExecutor, requirements: [ { allOf: ['scheme', 'deviceId'], message: 'Provide scheme and deviceId' }, diff --git a/src/mcp/tools/macos/__tests__/build_macos.test.ts b/src/mcp/tools/macos/__tests__/build_macos.test.ts index 38ab2f52..d19ea4f4 100644 --- a/src/mcp/tools/macos/__tests__/build_macos.test.ts +++ b/src/mcp/tools/macos/__tests__/build_macos.test.ts @@ -1,16 +1,30 @@ -/** - * Tests for build_macos plugin (unified) - * Following CLAUDE.md testing standards with literal validation - * Using pure dependency injection for deterministic testing - * NO VITEST MOCKING ALLOWED - Only createMockExecutor and createMockFileSystemExecutor - */ - import { describe, it, expect, beforeEach } from 'vitest'; +import { DERIVED_DATA_DIR } from '../../../../utils/log-paths.ts'; import * as z from 'zod'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { expectPendingBuildResponse, runToolLogic } from '../../../../test-utils/test-helpers.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import { schema, handler } from '../build_macos.ts'; -import { buildMacOSLogic } from '../build_macos.ts'; +import { schema, handler, buildMacOSLogic } from '../build_macos.ts'; + +const runBuildMacOS = ( + params: Parameters[0], + executor: Parameters[1], +) => runToolLogic(() => buildMacOSLogic(params, executor)); + +function createSpyExecutor(): { + capturedCommand: string[]; + executor: ReturnType; +} { + const capturedCommand: string[] = []; + const executor = createMockExecutor({ + success: true, + output: 'BUILD SUCCEEDED', + onExecute: (command) => { + if (capturedCommand.length === 0) capturedCommand.push(...command); + }, + }); + return { capturedCommand, executor }; +} describe('build_macos plugin', () => { beforeEach(() => { @@ -77,7 +91,7 @@ describe('build_macos plugin', () => { output: 'BUILD SUCCEEDED', }); - const result = await buildMacOSLogic( + const { result } = await runBuildMacOS( { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme', @@ -85,18 +99,8 @@ describe('build_macos plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: '✅ macOS Build build succeeded for scheme MyScheme.', - }, - { - type: 'text', - text: "Next Steps:\n1. Get app path: get_mac_app_path({ scheme: 'MyScheme' })\n2. Get bundle ID: get_mac_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_mac_app({ appPath: 'PATH_FROM_STEP_1' })", - }, - ], - }); + expect(result.isError()).toBeFalsy(); + expectPendingBuildResponse(result, 'get_mac_app_path'); }); it('should return exact build failure response', async () => { @@ -105,7 +109,7 @@ describe('build_macos plugin', () => { error: 'error: Compilation error in main.swift', }); - const result = await buildMacOSLogic( + const { result } = await runBuildMacOS( { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme', @@ -113,19 +117,8 @@ describe('build_macos plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: '❌ [stderr] error: Compilation error in main.swift', - }, - { - type: 'text', - text: '❌ macOS Build build failed for scheme MyScheme.', - }, - ], - isError: true, - }); + expect(result.isError()).toBe(true); + expectPendingBuildResponse(result); }); it('should return exact successful build response with optional parameters', async () => { @@ -134,7 +127,7 @@ describe('build_macos plugin', () => { output: 'BUILD SUCCEEDED', }); - const result = await buildMacOSLogic( + const { result } = await runBuildMacOS( { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme', @@ -147,28 +140,16 @@ describe('build_macos plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: '✅ macOS Build build succeeded for scheme MyScheme.', - }, - { - type: 'text', - text: "Next Steps:\n1. Get app path: get_mac_app_path({ scheme: 'MyScheme' })\n2. Get bundle ID: get_mac_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_mac_app({ appPath: 'PATH_FROM_STEP_1' })", - }, - ], - }); + expect(result.isError()).toBeFalsy(); + expectPendingBuildResponse(result, 'get_mac_app_path'); }); it('should return exact exception handling response', async () => { - // Create executor that throws error during command execution - // This will be caught by executeXcodeBuildCommand's try-catch block const mockExecutor = async () => { throw new Error('Network error'); }; - const result = await buildMacOSLogic( + const { result } = await runBuildMacOS( { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme', @@ -176,25 +157,16 @@ describe('build_macos plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error during macOS Build build: Network error', - }, - ], - isError: true, - }); + expect(result.isError()).toBe(true); + expectPendingBuildResponse(result); }); it('should return exact spawn error handling response', async () => { - // Create executor that throws spawn error during command execution - // This will be caught by executeXcodeBuildCommand's try-catch block const mockExecutor = async () => { throw new Error('Spawn error'); }; - const result = await buildMacOSLogic( + const { result } = await runBuildMacOS( { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme', @@ -202,38 +174,24 @@ describe('build_macos plugin', () => { mockExecutor, ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error during macOS Build build: Spawn error', - }, - ], - isError: true, - }); + expect(result.isError()).toBe(true); + expectPendingBuildResponse(result); }); }); describe('Command Generation', () => { it('should generate correct xcodebuild command with minimal parameters', async () => { - let capturedCommand: string[] = []; - const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); - - // Override the executor to capture the command - const spyExecutor = async (command: string[]) => { - capturedCommand = command; - return mockExecutor(command); - }; + const spy = createSpyExecutor(); - const result = await buildMacOSLogic( + await runBuildMacOS( { projectPath: '/path/to/project.xcodeproj', scheme: 'MyScheme', }, - spyExecutor, + spy.executor, ); - expect(capturedCommand).toEqual([ + expect(spy.capturedCommand).toEqual([ 'xcodebuild', '-project', '/path/to/project.xcodeproj', @@ -244,21 +202,16 @@ describe('build_macos plugin', () => { '-skipMacroValidation', '-destination', 'platform=macOS', + '-derivedDataPath', + DERIVED_DATA_DIR, 'build', ]); }); it('should generate correct xcodebuild command with all parameters', async () => { - let capturedCommand: string[] = []; - const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); - - // Override the executor to capture the command - const spyExecutor = async (command: string[]) => { - capturedCommand = command; - return mockExecutor(command); - }; + const spy = createSpyExecutor(); - const result = await buildMacOSLogic( + await runBuildMacOS( { projectPath: '/path/to/project.xcodeproj', scheme: 'MyScheme', @@ -268,10 +221,10 @@ describe('build_macos plugin', () => { extraArgs: ['--verbose'], preferXcodebuild: true, }, - spyExecutor, + spy.executor, ); - expect(capturedCommand).toEqual([ + expect(spy.capturedCommand).toEqual([ 'xcodebuild', '-project', '/path/to/project.xcodeproj', @@ -290,25 +243,18 @@ describe('build_macos plugin', () => { }); it('should generate correct xcodebuild command with only derivedDataPath', async () => { - let capturedCommand: string[] = []; - const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); + const spy = createSpyExecutor(); - // Override the executor to capture the command - const spyExecutor = async (command: string[]) => { - capturedCommand = command; - return mockExecutor(command); - }; - - const result = await buildMacOSLogic( + await runBuildMacOS( { projectPath: '/path/to/project.xcodeproj', scheme: 'MyScheme', derivedDataPath: '/custom/derived/data', }, - spyExecutor, + spy.executor, ); - expect(capturedCommand).toEqual([ + expect(spy.capturedCommand).toEqual([ 'xcodebuild', '-project', '/path/to/project.xcodeproj', @@ -326,25 +272,18 @@ describe('build_macos plugin', () => { }); it('should generate correct xcodebuild command with arm64 architecture only', async () => { - let capturedCommand: string[] = []; - const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); - - // Override the executor to capture the command - const spyExecutor = async (command: string[]) => { - capturedCommand = command; - return mockExecutor(command); - }; + const spy = createSpyExecutor(); - const result = await buildMacOSLogic( + await runBuildMacOS( { projectPath: '/path/to/project.xcodeproj', scheme: 'MyScheme', arch: 'arm64', }, - spyExecutor, + spy.executor, ); - expect(capturedCommand).toEqual([ + expect(spy.capturedCommand).toEqual([ 'xcodebuild', '-project', '/path/to/project.xcodeproj', @@ -355,29 +294,24 @@ describe('build_macos plugin', () => { '-skipMacroValidation', '-destination', 'platform=macOS,arch=arm64', + '-derivedDataPath', + DERIVED_DATA_DIR, 'build', ]); }); it('should handle paths with spaces in command generation', async () => { - let capturedCommand: string[] = []; - const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); + const spy = createSpyExecutor(); - // Override the executor to capture the command - const spyExecutor = async (command: string[]) => { - capturedCommand = command; - return mockExecutor(command); - }; - - const result = await buildMacOSLogic( + await runBuildMacOS( { projectPath: '/Users/dev/My Project/MyProject.xcodeproj', scheme: 'MyScheme', }, - spyExecutor, + spy.executor, ); - expect(capturedCommand).toEqual([ + expect(spy.capturedCommand).toEqual([ 'xcodebuild', '-project', '/Users/dev/My Project/MyProject.xcodeproj', @@ -388,29 +322,24 @@ describe('build_macos plugin', () => { '-skipMacroValidation', '-destination', 'platform=macOS', + '-derivedDataPath', + DERIVED_DATA_DIR, 'build', ]); }); it('should generate correct xcodebuild workspace command with minimal parameters', async () => { - let capturedCommand: string[] = []; - const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); + const spy = createSpyExecutor(); - // Override the executor to capture the command - const spyExecutor = async (command: string[]) => { - capturedCommand = command; - return mockExecutor(command); - }; - - const result = await buildMacOSLogic( + await runBuildMacOS( { workspacePath: '/path/to/workspace.xcworkspace', scheme: 'MyScheme', }, - spyExecutor, + spy.executor, ); - expect(capturedCommand).toEqual([ + expect(spy.capturedCommand).toEqual([ 'xcodebuild', '-workspace', '/path/to/workspace.xcworkspace', @@ -421,6 +350,8 @@ describe('build_macos plugin', () => { '-skipMacroValidation', '-destination', 'platform=macOS', + '-derivedDataPath', + DERIVED_DATA_DIR, 'build', ]); }); @@ -449,7 +380,7 @@ describe('build_macos plugin', () => { output: 'BUILD SUCCEEDED', }); - const result = await buildMacOSLogic( + const { result } = await runBuildMacOS( { projectPath: '/path/to/project.xcodeproj', scheme: 'MyScheme', @@ -457,7 +388,7 @@ describe('build_macos plugin', () => { mockExecutor, ); - expect(result.isError).toBeUndefined(); + expect(result.isError()).toBeFalsy(); }); it('should succeed with valid workspacePath', async () => { @@ -466,7 +397,7 @@ describe('build_macos plugin', () => { output: 'BUILD SUCCEEDED', }); - const result = await buildMacOSLogic( + const { result } = await runBuildMacOS( { workspacePath: '/path/to/workspace.xcworkspace', scheme: 'MyScheme', @@ -474,7 +405,7 @@ describe('build_macos plugin', () => { mockExecutor, ); - expect(result.isError).toBeUndefined(); + expect(result.isError()).toBeFalsy(); }); }); }); diff --git a/src/mcp/tools/macos/__tests__/build_run_macos.test.ts b/src/mcp/tools/macos/__tests__/build_run_macos.test.ts index 552710a2..d62ce246 100644 --- a/src/mcp/tools/macos/__tests__/build_run_macos.test.ts +++ b/src/mcp/tools/macos/__tests__/build_run_macos.test.ts @@ -1,9 +1,20 @@ import { describe, it, expect, beforeEach } from 'vitest'; +import { DERIVED_DATA_DIR } from '../../../../utils/log-paths.ts'; import * as z from 'zod'; import { createMockExecutor, mockProcess } from '../../../../test-utils/mock-executors.ts'; +import { runToolLogic, type MockToolHandlerResult } from '../../../../test-utils/test-helpers.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import { schema, handler } from '../build_run_macos.ts'; -import { buildRunMacOSLogic } from '../build_run_macos.ts'; +import { schema, handler, buildRunMacOSLogic } from '../build_run_macos.ts'; + +const runBuildRunMacOSLogic = ( + params: Parameters[0], + executor: Parameters[1], +) => runToolLogic(() => buildRunMacOSLogic(params, executor)); + +function expectPendingBuildRunResponse(result: MockToolHandlerResult, isError: boolean): void { + expect(result.isError()).toBe(isError); + expect(result.events.some((event) => event.type === 'summary')).toBe(true); +} describe('build_run_macos', () => { beforeEach(() => { @@ -62,7 +73,6 @@ describe('build_run_macos', () => { describe('Command Generation and Response Logic', () => { it('should successfully build and run macOS app from project', async () => { - // Track executor calls manually let callCount = 0; const executorCalls: any[] = []; const mockExecutor = ( @@ -77,7 +87,6 @@ describe('build_run_macos', () => { void detached; if (callCount === 1) { - // First call for build return Promise.resolve({ success: true, output: 'BUILD SUCCEEDED', @@ -85,7 +94,6 @@ describe('build_run_macos', () => { process: mockProcess, }); } else if (callCount === 2) { - // Second call for build settings return Promise.resolve({ success: true, output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app', @@ -103,66 +111,49 @@ describe('build_run_macos', () => { preferXcodebuild: false, }; - const result = await buildRunMacOSLogic(args, mockExecutor); - - // Verify build command was called - expect(executorCalls[0]).toEqual({ - command: [ - 'xcodebuild', - '-project', - '/path/to/project.xcodeproj', - '-scheme', - 'MyApp', - '-configuration', - 'Debug', - '-skipMacroValidation', - '-destination', - 'platform=macOS', - 'build', - ], - description: 'macOS Build', - logOutput: false, - opts: { cwd: '/path/to' }, - }); - - // Verify build settings command was called - expect(executorCalls[1]).toEqual({ - command: [ - 'xcodebuild', - '-showBuildSettings', - '-project', - '/path/to/project.xcodeproj', - '-scheme', - 'MyApp', - '-configuration', - 'Debug', - ], - description: 'Get Build Settings for Launch', - logOutput: false, - opts: undefined, - }); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: '✅ macOS Build build succeeded for scheme MyApp.', - }, - { - type: 'text', - text: "Next Steps:\n1. Get app path: get_mac_app_path({ scheme: 'MyApp' })\n2. Get bundle ID: get_mac_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_mac_app({ appPath: 'PATH_FROM_STEP_1' })", - }, - { - type: 'text', - text: '✅ macOS build and run succeeded for scheme MyApp. App launched: /path/to/build/MyApp.app', - }, - ], - isError: false, - }); + const { result } = await runBuildRunMacOSLogic(args, mockExecutor); + + expect(executorCalls[0].command).toEqual([ + 'xcodebuild', + '-project', + '/path/to/project.xcodeproj', + '-scheme', + 'MyApp', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'platform=macOS', + '-derivedDataPath', + DERIVED_DATA_DIR, + 'build', + ]); + expect(executorCalls[0].description).toBe('macOS Build'); + + expectPendingBuildRunResponse(result, false); + expect(result.nextStepParams).toBeUndefined(); + expect(result.events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'status-line', + level: 'success', + message: 'Build & Run complete', + }), + expect.objectContaining({ + type: 'detail-tree', + items: expect.arrayContaining([ + expect.objectContaining({ label: 'App Path', value: '/path/to/build/MyApp.app' }), + expect.objectContaining({ + label: 'Build Logs', + value: expect.stringContaining('build_run_macos_'), + }), + ]), + }), + ]), + ); }); it('should successfully build and run macOS app from workspace', async () => { - // Track executor calls manually let callCount = 0; const executorCalls: any[] = []; const mockExecutor = ( @@ -177,7 +168,6 @@ describe('build_run_macos', () => { void detached; if (callCount === 1) { - // First call for build return Promise.resolve({ success: true, output: 'BUILD SUCCEEDED', @@ -185,7 +175,6 @@ describe('build_run_macos', () => { process: mockProcess, }); } else if (callCount === 2) { - // Second call for build settings return Promise.resolve({ success: true, output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app', @@ -203,62 +192,25 @@ describe('build_run_macos', () => { preferXcodebuild: false, }; - const result = await buildRunMacOSLogic(args, mockExecutor); - - // Verify build command was called - expect(executorCalls[0]).toEqual({ - command: [ - 'xcodebuild', - '-workspace', - '/path/to/workspace.xcworkspace', - '-scheme', - 'MyApp', - '-configuration', - 'Debug', - '-skipMacroValidation', - '-destination', - 'platform=macOS', - 'build', - ], - description: 'macOS Build', - logOutput: false, - opts: { cwd: '/path/to' }, - }); - - // Verify build settings command was called - expect(executorCalls[1]).toEqual({ - command: [ - 'xcodebuild', - '-showBuildSettings', - '-workspace', - '/path/to/workspace.xcworkspace', - '-scheme', - 'MyApp', - '-configuration', - 'Debug', - ], - description: 'Get Build Settings for Launch', - logOutput: false, - opts: undefined, - }); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: '✅ macOS Build build succeeded for scheme MyApp.', - }, - { - type: 'text', - text: "Next Steps:\n1. Get app path: get_mac_app_path({ scheme: 'MyApp' })\n2. Get bundle ID: get_mac_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_mac_app({ appPath: 'PATH_FROM_STEP_1' })", - }, - { - type: 'text', - text: '✅ macOS build and run succeeded for scheme MyApp. App launched: /path/to/build/MyApp.app', - }, - ], - isError: false, - }); + const { result } = await runBuildRunMacOSLogic(args, mockExecutor); + + expect(executorCalls[0].command).toEqual([ + 'xcodebuild', + '-workspace', + '/path/to/workspace.xcworkspace', + '-scheme', + 'MyApp', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'platform=macOS', + '-derivedDataPath', + DERIVED_DATA_DIR, + 'build', + ]); + + expectPendingBuildRunResponse(result, false); }); it('should handle build failure', async () => { @@ -275,19 +227,13 @@ describe('build_run_macos', () => { preferXcodebuild: false, }; - const result = await buildRunMacOSLogic(args, mockExecutor); + const { result } = await runBuildRunMacOSLogic(args, mockExecutor); - expect(result).toEqual({ - content: [ - { type: 'text', text: '❌ [stderr] error: Build failed' }, - { type: 'text', text: '❌ macOS Build build failed for scheme MyApp.' }, - ], - isError: true, - }); + expectPendingBuildRunResponse(result, true); + expect(result.nextStepParams).toBeUndefined(); }); it('should handle build settings failure', async () => { - // Track executor calls manually let callCount = 0; const mockExecutor = ( command: string[], @@ -299,7 +245,6 @@ describe('build_run_macos', () => { callCount++; void detached; if (callCount === 1) { - // First call for build succeeds return Promise.resolve({ success: true, output: 'BUILD SUCCEEDED', @@ -307,7 +252,6 @@ describe('build_run_macos', () => { process: mockProcess, }); } else if (callCount === 2) { - // Second call for build settings fails return Promise.resolve({ success: false, output: '', @@ -325,29 +269,13 @@ describe('build_run_macos', () => { preferXcodebuild: false, }; - const result = await buildRunMacOSLogic(args, mockExecutor); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: '✅ macOS Build build succeeded for scheme MyApp.', - }, - { - type: 'text', - text: "Next Steps:\n1. Get app path: get_mac_app_path({ scheme: 'MyApp' })\n2. Get bundle ID: get_mac_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_mac_app({ appPath: 'PATH_FROM_STEP_1' })", - }, - { - type: 'text', - text: '✅ Build succeeded, but failed to get app path to launch: error: Failed to get settings', - }, - ], - isError: false, - }); + const { result } = await runBuildRunMacOSLogic(args, mockExecutor); + + expectPendingBuildRunResponse(result, true); + expect(result.nextStepParams).toBeUndefined(); }); it('should handle app launch failure', async () => { - // Track executor calls manually let callCount = 0; const mockExecutor = ( command: string[], @@ -359,7 +287,6 @@ describe('build_run_macos', () => { callCount++; void detached; if (callCount === 1) { - // First call for build succeeds return Promise.resolve({ success: true, output: 'BUILD SUCCEEDED', @@ -367,7 +294,6 @@ describe('build_run_macos', () => { process: mockProcess, }); } else if (callCount === 2) { - // Second call for build settings succeeds return Promise.resolve({ success: true, output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app', @@ -375,7 +301,6 @@ describe('build_run_macos', () => { process: mockProcess, }); } else if (callCount === 3) { - // Third call for open command fails return Promise.resolve({ success: false, output: '', @@ -393,25 +318,10 @@ describe('build_run_macos', () => { preferXcodebuild: false, }; - const result = await buildRunMacOSLogic(args, mockExecutor); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: '✅ macOS Build build succeeded for scheme MyApp.', - }, - { - type: 'text', - text: "Next Steps:\n1. Get app path: get_mac_app_path({ scheme: 'MyApp' })\n2. Get bundle ID: get_mac_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_mac_app({ appPath: 'PATH_FROM_STEP_1' })", - }, - { - type: 'text', - text: '✅ Build succeeded, but failed to launch app /path/to/build/MyApp.app. Error: Failed to launch', - }, - ], - isError: false, - }); + const { result } = await runBuildRunMacOSLogic(args, mockExecutor); + + expectPendingBuildRunResponse(result, true); + expect(result.nextStepParams).toBeUndefined(); }); it('should handle spawn error', async () => { @@ -437,18 +347,14 @@ describe('build_run_macos', () => { preferXcodebuild: false, }; - const result = await buildRunMacOSLogic(args, mockExecutor); + const { response, result } = await runBuildRunMacOSLogic(args, mockExecutor); - expect(result).toEqual({ - content: [ - { type: 'text', text: 'Error during macOS Build build: spawn xcodebuild ENOENT' }, - ], - isError: true, - }); + expect(response).toBeUndefined(); + expectPendingBuildRunResponse(result, true); + expect(result.nextStepParams).toBeUndefined(); }); it('should use default configuration when not provided', async () => { - // Track executor calls manually let callCount = 0; const executorCalls: any[] = []; const mockExecutor = ( @@ -463,7 +369,6 @@ describe('build_run_macos', () => { void detached; if (callCount === 1) { - // First call for build return Promise.resolve({ success: true, output: 'BUILD SUCCEEDED', @@ -471,7 +376,6 @@ describe('build_run_macos', () => { process: mockProcess, }); } else if (callCount === 2) { - // Second call for build settings return Promise.resolve({ success: true, output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app', @@ -489,26 +393,24 @@ describe('build_run_macos', () => { preferXcodebuild: false, }; - await buildRunMacOSLogic(args, mockExecutor); - - expect(executorCalls[0]).toEqual({ - command: [ - 'xcodebuild', - '-project', - '/path/to/project.xcodeproj', - '-scheme', - 'MyApp', - '-configuration', - 'Debug', - '-skipMacroValidation', - '-destination', - 'platform=macOS', - 'build', - ], - description: 'macOS Build', - logOutput: false, - opts: { cwd: '/path/to' }, - }); + await runBuildRunMacOSLogic(args, mockExecutor); + + expect(executorCalls[0].command).toEqual([ + 'xcodebuild', + '-project', + '/path/to/project.xcodeproj', + '-scheme', + 'MyApp', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'platform=macOS', + '-derivedDataPath', + DERIVED_DATA_DIR, + 'build', + ]); + expect(executorCalls[0].description).toBe('macOS Build'); }); }); }); diff --git a/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts b/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts index e2c4da98..6ff269fd 100644 --- a/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts +++ b/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts @@ -1,9 +1,5 @@ -/** - * Tests for get_mac_app_path plugin (unified project/workspace) - * Following CLAUDE.md testing standards with literal validation - * Using dependency injection for deterministic testing - */ import { describe, it, expect, beforeEach } from 'vitest'; +import { DERIVED_DATA_DIR } from '../../../../utils/log-paths.ts'; import * as z from 'zod'; import { createMockCommandResponse, @@ -11,8 +7,41 @@ import { type CommandExecutor, } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import { schema, handler } from '../get_mac_app_path.ts'; -import { get_mac_app_pathLogic } from '../get_mac_app_path.ts'; +import { schema, handler, get_mac_app_pathLogic } from '../get_mac_app_path.ts'; +import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; + +const runLogic = async (logic: () => Promise) => { + const { result, run } = createMockToolHandlerContext(); + const response = await run(logic); + + if ( + response && + typeof response === 'object' && + 'content' in (response as Record) + ) { + return response as { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + isError?: boolean; + nextStepParams?: unknown; + }; + } + + const text = result.text(); + const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; + const imageContent = result.attachments.map((attachment) => ({ + type: 'image' as const, + data: attachment.data, + mimeType: attachment.mimeType, + })); + + return { + content: [...textContent, ...imageContent], + isError: result.isError() ? true : undefined, + nextStepParams: result.nextStepParams, + attachments: result.attachments, + text, + }; +}; describe('get_mac_app_path plugin', () => { beforeEach(() => { @@ -113,7 +142,7 @@ describe('get_mac_app_path plugin', () => { scheme: 'MyScheme', }; - await get_mac_app_pathLogic(args, mockExecutor); + await runLogic(() => get_mac_app_pathLogic(args, mockExecutor)); // Verify command generation with manual call tracking expect(calls).toHaveLength(1); @@ -127,10 +156,14 @@ describe('get_mac_app_path plugin', () => { 'MyScheme', '-configuration', 'Debug', + '-destination', + 'generic/platform=macOS', + '-derivedDataPath', + DERIVED_DATA_DIR, ], 'Get App Path', false, - undefined, + { cwd: '/path/to' }, ]); }); @@ -151,7 +184,7 @@ describe('get_mac_app_path plugin', () => { scheme: 'MyScheme', }; - await get_mac_app_pathLogic(args, mockExecutor); + await runLogic(() => get_mac_app_pathLogic(args, mockExecutor)); // Verify command generation with manual call tracking expect(calls).toHaveLength(1); @@ -165,10 +198,14 @@ describe('get_mac_app_path plugin', () => { 'MyScheme', '-configuration', 'Debug', + '-destination', + 'generic/platform=macOS', + '-derivedDataPath', + DERIVED_DATA_DIR, ], 'Get App Path', false, - undefined, + { cwd: '/path/to' }, ]); }); @@ -191,7 +228,7 @@ describe('get_mac_app_path plugin', () => { arch: 'arm64' as const, }; - await get_mac_app_pathLogic(args, mockExecutor); + await runLogic(() => get_mac_app_pathLogic(args, mockExecutor)); // Verify command generation with manual call tracking expect(calls).toHaveLength(1); @@ -207,10 +244,12 @@ describe('get_mac_app_path plugin', () => { 'Release', '-destination', 'platform=macOS,arch=arm64', + '-derivedDataPath', + DERIVED_DATA_DIR, ], 'Get App Path', false, - undefined, + { cwd: '/path/to' }, ]); }); @@ -233,7 +272,7 @@ describe('get_mac_app_path plugin', () => { arch: 'x86_64' as const, }; - await get_mac_app_pathLogic(args, mockExecutor); + await runLogic(() => get_mac_app_pathLogic(args, mockExecutor)); // Verify command generation with manual call tracking expect(calls).toHaveLength(1); @@ -249,10 +288,12 @@ describe('get_mac_app_path plugin', () => { 'Debug', '-destination', 'platform=macOS,arch=x86_64', + '-derivedDataPath', + DERIVED_DATA_DIR, ], 'Get App Path', false, - undefined, + { cwd: '/path/to' }, ]); }); @@ -276,7 +317,7 @@ describe('get_mac_app_path plugin', () => { extraArgs: ['--verbose'], }; - await get_mac_app_pathLogic(args, mockExecutor); + await runLogic(() => get_mac_app_pathLogic(args, mockExecutor)); // Verify command generation with manual call tracking expect(calls).toHaveLength(1); @@ -290,13 +331,15 @@ describe('get_mac_app_path plugin', () => { 'MyScheme', '-configuration', 'Release', + '-destination', + 'generic/platform=macOS', '-derivedDataPath', '/path/to/derived', '--verbose', ], 'Get App Path', false, - undefined, + { cwd: '/path/to' }, ]); }); @@ -318,7 +361,7 @@ describe('get_mac_app_path plugin', () => { arch: 'arm64' as const, }; - await get_mac_app_pathLogic(args, mockExecutor); + await runLogic(() => get_mac_app_pathLogic(args, mockExecutor)); // Verify command generation with manual call tracking expect(calls).toHaveLength(1); @@ -334,10 +377,12 @@ describe('get_mac_app_path plugin', () => { 'Debug', '-destination', 'platform=macOS,arch=arm64', + '-derivedDataPath', + DERIVED_DATA_DIR, ], 'Get App Path', false, - undefined, + { cwd: '/path/to' }, ]); }); }); @@ -349,8 +394,9 @@ describe('get_mac_app_path plugin', () => { }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('scheme is required'); - expect(result.content[0].text).toContain('session-set-defaults'); + const text = allText(result); + expect(text).toContain('scheme is required'); + expect(text).toContain('session-set-defaults'); }); it('should return exact successful app path response with workspace', async () => { @@ -362,31 +408,23 @@ FULL_PRODUCT_NAME = MyApp.app `, }); - const result = await get_mac_app_pathLogic( - { - workspacePath: '/path/to/MyProject.xcworkspace', - scheme: 'MyScheme', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + get_mac_app_pathLogic( { - type: 'text', - text: '✅ App path retrieved successfully: /Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app', + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyScheme', }, - ], - nextStepParams: { - get_mac_bundle_id: { - appPath: - '/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app', - }, - launch_mac_app: { - appPath: - '/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app', - }, - }, + mockExecutor, + ), + ); + + const appPath = + '/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app'; + + expect(result.isError).toBeFalsy(); + expect(result.nextStepParams).toEqual({ + get_mac_bundle_id: { appPath }, + launch_mac_app: { appPath }, }); }); @@ -399,57 +437,44 @@ FULL_PRODUCT_NAME = MyApp.app `, }); - const result = await get_mac_app_pathLogic( - { - projectPath: '/path/to/MyProject.xcodeproj', - scheme: 'MyScheme', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + get_mac_app_pathLogic( { - type: 'text', - text: '✅ App path retrieved successfully: /Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app', - }, - ], - nextStepParams: { - get_mac_bundle_id: { - appPath: - '/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app', + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyScheme', }, - launch_mac_app: { - appPath: - '/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app', - }, - }, + mockExecutor, + ), + ); + + const appPath = + '/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app'; + + expect(result.isError).toBeFalsy(); + expect(result.nextStepParams).toEqual({ + get_mac_bundle_id: { appPath }, + launch_mac_app: { appPath }, }); }); it('should return exact build settings failure response', async () => { const mockExecutor = createMockExecutor({ success: false, - error: 'error: No such scheme', + error: 'xcodebuild: error: No such scheme', }); - const result = await get_mac_app_pathLogic( - { - workspacePath: '/path/to/MyProject.xcworkspace', - scheme: 'MyScheme', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + get_mac_app_pathLogic( { - type: 'text', - text: 'Error: Failed to get macOS app path\nDetails: error: No such scheme', + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyScheme', }, - ], - isError: true, - }); + mockExecutor, + ), + ); + + expect(result.isError).toBe(true); + expect(result.nextStepParams).toBeUndefined(); }); it('should return exact missing build settings response', async () => { @@ -458,23 +483,18 @@ FULL_PRODUCT_NAME = MyApp.app output: 'OTHER_SETTING = value', }); - const result = await get_mac_app_pathLogic( - { - workspacePath: '/path/to/MyProject.xcworkspace', - scheme: 'MyScheme', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + get_mac_app_pathLogic( { - type: 'text', - text: 'Error: Failed to get macOS app path\nDetails: Could not extract app path from build settings', + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyScheme', }, - ], - isError: true, - }); + mockExecutor, + ), + ); + + expect(result.isError).toBe(true); + expect(result.nextStepParams).toBeUndefined(); }); it('should return exact exception handling response', async () => { @@ -482,23 +502,18 @@ FULL_PRODUCT_NAME = MyApp.app throw new Error('Network error'); }; - const result = await get_mac_app_pathLogic( - { - workspacePath: '/path/to/MyProject.xcworkspace', - scheme: 'MyScheme', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + get_mac_app_pathLogic( { - type: 'text', - text: 'Error: Failed to get macOS app path\nDetails: Network error', + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyScheme', }, - ], - isError: true, - }); + mockExecutor, + ), + ); + + expect(result.isError).toBe(true); + expect(result.nextStepParams).toBeUndefined(); }); }); }); diff --git a/src/mcp/tools/macos/__tests__/launch_mac_app.test.ts b/src/mcp/tools/macos/__tests__/launch_mac_app.test.ts index 39e5ee5d..cfacaeae 100644 --- a/src/mcp/tools/macos/__tests__/launch_mac_app.test.ts +++ b/src/mcp/tools/macos/__tests__/launch_mac_app.test.ts @@ -1,20 +1,44 @@ -/** - * Pure dependency injection test for launch_mac_app plugin - * - * Tests plugin structure and macOS app launching functionality including parameter validation, - * command generation, file validation, and response formatting. - * - * Uses manual call tracking and createMockFileSystemExecutor for file operations. - */ - import { describe, it, expect } from 'vitest'; import * as z from 'zod'; import { createMockCommandResponse, createMockFileSystemExecutor, } from '../../../../test-utils/mock-executors.ts'; -import { schema, handler } from '../launch_mac_app.ts'; -import { launch_mac_appLogic } from '../launch_mac_app.ts'; +import { schema, handler, launch_mac_appLogic } from '../launch_mac_app.ts'; +import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; + +const runLogic = async (logic: () => Promise) => { + const { result, run } = createMockToolHandlerContext(); + const response = await run(logic); + + if ( + response && + typeof response === 'object' && + 'content' in (response as Record) + ) { + return response as { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + isError?: boolean; + nextStepParams?: unknown; + }; + } + + const text = result.text(); + const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; + const imageContent = result.attachments.map((attachment) => ({ + type: 'image' as const, + data: attachment.data, + mimeType: attachment.mimeType, + })); + + return { + content: [...textContent, ...imageContent], + isError: result.isError() ? true : undefined, + nextStepParams: result.nextStepParams, + attachments: result.attachments, + text, + }; +}; describe('launch_mac_app plugin', () => { describe('Export Field Validation (Literal)', () => { @@ -61,23 +85,19 @@ describe('launch_mac_app plugin', () => { existsSync: () => false, }); - const result = await launch_mac_appLogic( - { - appPath: '/path/to/NonExistent.app', - }, - mockExecutor, - mockFileSystem, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + launch_mac_appLogic( { - type: 'text', - text: "File not found: '/path/to/NonExistent.app'. Please check the path and try again.", + appPath: '/path/to/NonExistent.app', }, - ], - isError: true, - }); + mockExecutor, + mockFileSystem, + ), + ); + + expect(result.isError).toBe(true); + const text = allText(result); + expect(text).toContain("File not found: '/path/to/NonExistent.app'"); }); }); @@ -93,15 +113,16 @@ describe('launch_mac_app plugin', () => { existsSync: () => true, }); - await launch_mac_appLogic( - { - appPath: '/path/to/MyApp.app', - }, - mockExecutor, - mockFileSystem, + await runLogic(() => + launch_mac_appLogic( + { + appPath: '/path/to/MyApp.app', + }, + mockExecutor, + mockFileSystem, + ), ); - expect(calls).toHaveLength(1); expect(calls[0].command).toEqual(['open', '/path/to/MyApp.app']); }); @@ -116,16 +137,17 @@ describe('launch_mac_app plugin', () => { existsSync: () => true, }); - await launch_mac_appLogic( - { - appPath: '/path/to/MyApp.app', - args: ['--debug', '--verbose'], - }, - mockExecutor, - mockFileSystem, + await runLogic(() => + launch_mac_appLogic( + { + appPath: '/path/to/MyApp.app', + args: ['--debug', '--verbose'], + }, + mockExecutor, + mockFileSystem, + ), ); - expect(calls).toHaveLength(1); expect(calls[0].command).toEqual([ 'open', '/path/to/MyApp.app', @@ -146,16 +168,17 @@ describe('launch_mac_app plugin', () => { existsSync: () => true, }); - await launch_mac_appLogic( - { - appPath: '/path/to/MyApp.app', - args: [], - }, - mockExecutor, - mockFileSystem, + await runLogic(() => + launch_mac_appLogic( + { + appPath: '/path/to/MyApp.app', + args: [], + }, + mockExecutor, + mockFileSystem, + ), ); - expect(calls).toHaveLength(1); expect(calls[0].command).toEqual(['open', '/path/to/MyApp.app']); }); @@ -170,15 +193,16 @@ describe('launch_mac_app plugin', () => { existsSync: () => true, }); - await launch_mac_appLogic( - { - appPath: '/Applications/My App.app', - }, - mockExecutor, - mockFileSystem, + await runLogic(() => + launch_mac_appLogic( + { + appPath: '/Applications/My App.app', + }, + mockExecutor, + mockFileSystem, + ), ); - expect(calls).toHaveLength(1); expect(calls[0].command).toEqual(['open', '/Applications/My App.app']); }); }); @@ -191,48 +215,17 @@ describe('launch_mac_app plugin', () => { existsSync: () => true, }); - const result = await launch_mac_appLogic( - { - appPath: '/path/to/MyApp.app', - }, - mockExecutor, - mockFileSystem, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + launch_mac_appLogic( { - type: 'text', - text: '✅ macOS app launched successfully: /path/to/MyApp.app', + appPath: '/path/to/MyApp.app', }, - ], - }); - }); - - it('should return successful launch response with args', async () => { - const mockExecutor = async () => Promise.resolve(createMockCommandResponse()); - - const mockFileSystem = createMockFileSystemExecutor({ - existsSync: () => true, - }); - - const result = await launch_mac_appLogic( - { - appPath: '/path/to/MyApp.app', - args: ['--debug', '--verbose'], - }, - mockExecutor, - mockFileSystem, + mockExecutor, + mockFileSystem, + ), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: '✅ macOS app launched successfully: /path/to/MyApp.app', - }, - ], - }); + expect(result.isError).toBeFalsy(); }); it('should handle launch failure with Error object', async () => { @@ -244,51 +237,17 @@ describe('launch_mac_app plugin', () => { existsSync: () => true, }); - const result = await launch_mac_appLogic( - { - appPath: '/path/to/MyApp.app', - }, - mockExecutor, - mockFileSystem, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + launch_mac_appLogic( { - type: 'text', - text: '❌ Launch macOS app operation failed: App not found', + appPath: '/path/to/MyApp.app', }, - ], - isError: true, - }); - }); - - it('should handle launch failure with string error', async () => { - const mockExecutor = async () => { - throw 'Permission denied'; - }; - - const mockFileSystem = createMockFileSystemExecutor({ - existsSync: () => true, - }); - - const result = await launch_mac_appLogic( - { - appPath: '/path/to/MyApp.app', - }, - mockExecutor, - mockFileSystem, + mockExecutor, + mockFileSystem, + ), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: '❌ Launch macOS app operation failed: Permission denied', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); }); it('should handle launch failure with unknown error type', async () => { @@ -300,23 +259,17 @@ describe('launch_mac_app plugin', () => { existsSync: () => true, }); - const result = await launch_mac_appLogic( - { - appPath: '/path/to/MyApp.app', - }, - mockExecutor, - mockFileSystem, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + launch_mac_appLogic( { - type: 'text', - text: '❌ Launch macOS app operation failed: 123', + appPath: '/path/to/MyApp.app', }, - ], - isError: true, - }); + mockExecutor, + mockFileSystem, + ), + ); + + expect(result.isError).toBe(true); }); }); }); diff --git a/src/mcp/tools/macos/__tests__/re-exports.test.ts b/src/mcp/tools/macos/__tests__/re-exports.test.ts index ee4540c2..c35cfe8c 100644 --- a/src/mcp/tools/macos/__tests__/re-exports.test.ts +++ b/src/mcp/tools/macos/__tests__/re-exports.test.ts @@ -1,11 +1,5 @@ -/** - * Tests for macos tool module exports - * Validates that tools export the required named exports (schema, handler) - * Note: name and description are now defined in manifests, not in modules - */ import { describe, it, expect } from 'vitest'; -// Import all tool modules using named exports import * as testMacos from '../test_macos.ts'; import * as buildMacos from '../build_macos.ts'; import * as buildRunMacos from '../build_run_macos.ts'; diff --git a/src/mcp/tools/macos/__tests__/stop_mac_app.test.ts b/src/mcp/tools/macos/__tests__/stop_mac_app.test.ts index 86086966..55e31973 100644 --- a/src/mcp/tools/macos/__tests__/stop_mac_app.test.ts +++ b/src/mcp/tools/macos/__tests__/stop_mac_app.test.ts @@ -1,18 +1,39 @@ -/** - * Pure dependency injection test for stop_mac_app plugin - * - * Tests plugin structure and macOS app stopping functionality including parameter validation, - * command generation, and response formatting. - * - * Uses manual call tracking instead of vitest mocking. - * NO VITEST MOCKING ALLOWED - Only manual stubs - */ - import { describe, it, expect } from 'vitest'; -import * as z from 'zod'; - -import { schema, handler } from '../stop_mac_app.ts'; -import { stop_mac_appLogic } from '../stop_mac_app.ts'; +import { schema, handler, stop_mac_appLogic } from '../stop_mac_app.ts'; +import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; + +const runLogic = async (logic: () => Promise) => { + const { result, run } = createMockToolHandlerContext(); + const response = await run(logic); + + if ( + response && + typeof response === 'object' && + 'content' in (response as Record) + ) { + return response as { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + isError?: boolean; + nextStepParams?: unknown; + }; + } + + const text = result.text(); + const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; + const imageContent = result.attachments.map((attachment) => ({ + type: 'image' as const, + data: attachment.data, + mimeType: attachment.mimeType, + })); + + return { + content: [...textContent, ...imageContent], + isError: result.isError() ? true : undefined, + nextStepParams: result.nextStepParams, + attachments: result.attachments, + text, + }; +}; describe('stop_mac_app plugin', () => { describe('Export Field Validation (Literal)', () => { @@ -37,17 +58,10 @@ describe('stop_mac_app plugin', () => { describe('Input Validation', () => { it('should return exact validation error for missing parameters', async () => { const mockExecutor = async () => ({ success: true, output: '', process: {} as any }); - const result = await stop_mac_appLogic({}, mockExecutor); + const result = await runLogic(() => stop_mac_appLogic({}, mockExecutor)); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Either appName or processId must be provided.', - }, - ], - isError: true, - }); + expect(result.isError).toBe(true); + expect(allText(result)).toContain('appName or processId'); }); }); @@ -59,11 +73,13 @@ describe('stop_mac_app plugin', () => { return { success: true, output: '', process: {} as any }; }; - await stop_mac_appLogic( - { - processId: 1234, - }, - mockExecutor, + await runLogic(() => + stop_mac_appLogic( + { + processId: 1234, + }, + mockExecutor, + ), ); expect(calls).toHaveLength(1); @@ -77,19 +93,17 @@ describe('stop_mac_app plugin', () => { return { success: true, output: '', process: {} as any }; }; - await stop_mac_appLogic( - { - appName: 'Calculator', - }, - mockExecutor, + await runLogic(() => + stop_mac_appLogic( + { + appName: 'Calculator', + }, + mockExecutor, + ), ); expect(calls).toHaveLength(1); - expect(calls[0].command).toEqual([ - 'sh', - '-c', - 'pkill -f "Calculator" || osascript -e \'tell application "Calculator" to quit\'', - ]); + expect(calls[0].command).toEqual(['pkill', '-f', 'Calculator']); }); it('should prioritize processId over appName', async () => { @@ -99,12 +113,14 @@ describe('stop_mac_app plugin', () => { return { success: true, output: '', process: {} as any }; }; - await stop_mac_appLogic( - { - appName: 'Calculator', - processId: 1234, - }, - mockExecutor, + await runLogic(() => + stop_mac_appLogic( + { + appName: 'Calculator', + processId: 1234, + }, + mockExecutor, + ), ); expect(calls).toHaveLength(1); @@ -116,62 +132,32 @@ describe('stop_mac_app plugin', () => { it('should return exact successful stop response by app name', async () => { const mockExecutor = async () => ({ success: true, output: '', process: {} as any }); - const result = await stop_mac_appLogic( - { - appName: 'Calculator', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + stop_mac_appLogic( { - type: 'text', - text: '✅ macOS app stopped successfully: Calculator', + appName: 'Calculator', }, - ], - }); - }); - - it('should return exact successful stop response by process ID', async () => { - const mockExecutor = async () => ({ success: true, output: '', process: {} as any }); - - const result = await stop_mac_appLogic( - { - processId: 1234, - }, - mockExecutor, + mockExecutor, + ), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: '✅ macOS app stopped successfully: PID 1234', - }, - ], - }); + expect(result.isError).toBeFalsy(); }); it('should return exact successful stop response with both parameters (processId takes precedence)', async () => { const mockExecutor = async () => ({ success: true, output: '', process: {} as any }); - const result = await stop_mac_appLogic( - { - appName: 'Calculator', - processId: 1234, - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + stop_mac_appLogic( { - type: 'text', - text: '✅ macOS app stopped successfully: PID 1234', + appName: 'Calculator', + processId: 1234, }, - ], - }); + mockExecutor, + ), + ); + + expect(result.isError).toBeFalsy(); }); it('should handle execution errors', async () => { @@ -179,22 +165,16 @@ describe('stop_mac_app plugin', () => { throw new Error('Process not found'); }; - const result = await stop_mac_appLogic( - { - processId: 9999, - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ + const result = await runLogic(() => + stop_mac_appLogic( { - type: 'text', - text: '❌ Stop macOS app operation failed: Process not found', + processId: 9999, }, - ], - isError: true, - }); + mockExecutor, + ), + ); + + expect(result.isError).toBe(true); }); }); }); diff --git a/src/mcp/tools/macos/__tests__/test_macos.test.ts b/src/mcp/tools/macos/__tests__/test_macos.test.ts index 75463ba5..173282f7 100644 --- a/src/mcp/tools/macos/__tests__/test_macos.test.ts +++ b/src/mcp/tools/macos/__tests__/test_macos.test.ts @@ -1,29 +1,28 @@ -/** - * Tests for test_macos plugin (unified project/workspace) - * Following CLAUDE.md testing standards with literal validation - * Using dependency injection for deterministic testing - */ import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { createMockCommandResponse, createMockExecutor, createMockFileSystemExecutor, - type FileSystemExecutor, } from '../../../../test-utils/mock-executors.ts'; +import { expectPendingBuildResponse, runToolLogic } from '../../../../test-utils/test-helpers.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import { schema, handler } from '../test_macos.ts'; -import { testMacosLogic } from '../test_macos.ts'; +import { schema, handler, testMacosLogic } from '../test_macos.ts'; -const createTestFileSystemExecutor = (overrides: Partial = {}) => +const mockFs = () => createMockFileSystemExecutor({ mkdtemp: async () => '/tmp/test-123', rm: async () => {}, tmpdir: () => '/tmp', - stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), - ...overrides, + stat: async () => ({ isDirectory: () => false, mtimeMs: 0 }), }); +const runTestMacosLogic = ( + params: Parameters[0], + executor: Parameters[1], + fileSystemExecutor: Parameters[2], +) => runToolLogic(() => testMacosLogic(params, executor, fileSystemExecutor)); + describe('test_macos plugin (unified)', () => { beforeEach(() => { sessionStore.clear(); @@ -51,7 +50,7 @@ describe('test_macos plugin (unified)', () => { expect(zodSchema.safeParse({ testRunnerEnv: { FOO: 123 } }).success).toBe(false); const schemaKeys = Object.keys(schema).sort(); - expect(schemaKeys).toEqual(['extraArgs', 'testRunnerEnv'].sort()); + expect(schemaKeys).toEqual(['extraArgs', 'progress', 'testRunnerEnv'].sort()); }); }); @@ -87,7 +86,6 @@ describe('test_macos plugin (unified)', () => { describe('XOR Parameter Validation', () => { it('should validate that either projectPath or workspacePath is provided', async () => { - // Should return error response when neither is provided const result = await handler({ scheme: 'MyScheme', }); @@ -97,7 +95,6 @@ describe('test_macos plugin (unified)', () => { }); it('should validate that both projectPath and workspacePath cannot be provided', async () => { - // Should return error response when both are provided const result = await handler({ projectPath: '/path/to/project.xcodeproj', workspacePath: '/path/to/workspace.xcworkspace', @@ -114,20 +111,17 @@ describe('test_macos plugin (unified)', () => { output: 'Test Suite All Tests passed', }); - const mockFileSystemExecutor = createTestFileSystemExecutor(); - - const result = await testMacosLogic( + const { result } = await runTestMacosLogic( { projectPath: '/path/to/project.xcodeproj', scheme: 'MyScheme', }, mockExecutor, - mockFileSystemExecutor, + mockFs(), ); - expect(result.content).toBeDefined(); - expect(Array.isArray(result.content)).toBe(true); - expect(result.isError).toBeUndefined(); + expectPendingBuildResponse(result); + expect(result.isError()).toBeFalsy(); }); it('should allow only workspacePath', async () => { @@ -136,70 +130,59 @@ describe('test_macos plugin (unified)', () => { output: 'Test Suite All Tests passed', }); - const mockFileSystemExecutor = createTestFileSystemExecutor(); - - const result = await testMacosLogic( + const { result } = await runTestMacosLogic( { workspacePath: '/path/to/workspace.xcworkspace', scheme: 'MyScheme', }, mockExecutor, - mockFileSystemExecutor, + mockFs(), ); - expect(result.content).toBeDefined(); - expect(Array.isArray(result.content)).toBe(true); - expect(result.isError).toBeUndefined(); + expectPendingBuildResponse(result); + expect(result.isError()).toBeFalsy(); }); }); describe('Handler Behavior (Complete Literal Returns)', () => { - it('should return successful test response with workspace when xcodebuild succeeds', async () => { + it('should return pending response with workspace when xcodebuild succeeds', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'Test Suite All Tests passed', }); - // Mock file system dependencies - const mockFileSystemExecutor = createTestFileSystemExecutor(); - - const result = await testMacosLogic( + const { result } = await runTestMacosLogic( { workspacePath: '/path/to/workspace.xcworkspace', scheme: 'MyScheme', configuration: 'Debug', }, mockExecutor, - mockFileSystemExecutor, + mockFs(), ); - expect(result.content).toBeDefined(); - expect(Array.isArray(result.content)).toBe(true); - expect(result.isError).toBeUndefined(); + expectPendingBuildResponse(result); + expect(result.isError()).toBeFalsy(); }); - it('should return successful test response with project when xcodebuild succeeds', async () => { + it('should return pending response with project when xcodebuild succeeds', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'Test Suite All Tests passed', }); - // Mock file system dependencies - const mockFileSystemExecutor = createTestFileSystemExecutor(); - - const result = await testMacosLogic( + const { result } = await runTestMacosLogic( { projectPath: '/path/to/project.xcodeproj', scheme: 'MyScheme', configuration: 'Debug', }, mockExecutor, - mockFileSystemExecutor, + mockFs(), ); - expect(result.content).toBeDefined(); - expect(Array.isArray(result.content)).toBe(true); - expect(result.isError).toBeUndefined(); + expectPendingBuildResponse(result); + expect(result.isError()).toBeFalsy(); }); it('should use default configuration when not provided', async () => { @@ -208,21 +191,17 @@ describe('test_macos plugin (unified)', () => { output: 'Test Suite All Tests passed', }); - // Mock file system dependencies - const mockFileSystemExecutor = createTestFileSystemExecutor(); - - const result = await testMacosLogic( + const { result } = await runTestMacosLogic( { workspacePath: '/path/to/workspace.xcworkspace', scheme: 'MyScheme', }, mockExecutor, - mockFileSystemExecutor, + mockFs(), ); - expect(result.content).toBeDefined(); - expect(Array.isArray(result.content)).toBe(true); - expect(result.isError).toBeUndefined(); + expectPendingBuildResponse(result); + expect(result.isError()).toBeFalsy(); }); it('should handle optional parameters correctly', async () => { @@ -231,10 +210,7 @@ describe('test_macos plugin (unified)', () => { output: 'Test Suite All Tests passed', }); - // Mock file system dependencies - const mockFileSystemExecutor = createTestFileSystemExecutor(); - - const result = await testMacosLogic( + const { result } = await runTestMacosLogic( { workspacePath: '/path/to/workspace.xcworkspace', scheme: 'MyScheme', @@ -244,12 +220,11 @@ describe('test_macos plugin (unified)', () => { preferXcodebuild: true, }, mockExecutor, - mockFileSystemExecutor, + mockFs(), ); - expect(result.content).toBeDefined(); - expect(Array.isArray(result.content)).toBe(true); - expect(result.isError).toBeUndefined(); + expectPendingBuildResponse(result); + expect(result.isError()).toBeFalsy(); }); it('should handle successful test execution with minimal parameters', async () => { @@ -258,233 +233,108 @@ describe('test_macos plugin (unified)', () => { output: 'Test Suite All Tests passed', }); - // Mock file system dependencies - const mockFileSystemExecutor = createTestFileSystemExecutor(); - - const result = await testMacosLogic( + const { result } = await runTestMacosLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyApp', }, mockExecutor, - mockFileSystemExecutor, + mockFs(), ); - expect(result.content).toBeDefined(); - expect(Array.isArray(result.content)).toBe(true); - expect(result.isError).toBeUndefined(); + expectPendingBuildResponse(result); + expect(result.isError()).toBeFalsy(); }); - it('should return exact successful test response', async () => { - // Track command execution calls - const commandCalls: any[] = []; + it('should return pending response on successful test', async () => { + const commandCalls: { command: string[]; logPrefix?: string }[] = []; - // Mock executor for successful test const mockExecutor = async ( command: string[], logPrefix?: string, - useShell?: boolean, - opts?: { env?: Record }, - detached?: boolean, + _useShell?: boolean, + _opts?: { env?: Record }, + _detached?: boolean, ) => { - commandCalls.push({ command, logPrefix, useShell, env: opts?.env }); - void detached; - - // Handle xcresulttool command - if (command.includes('xcresulttool')) { - return createMockCommandResponse({ - success: true, - output: JSON.stringify({ - title: 'Test Results', - result: 'SUCCEEDED', - totalTestCount: 5, - passedTests: 5, - failedTests: 0, - skippedTests: 0, - expectedFailures: 0, - }), - error: undefined, - }); - } - + commandCalls.push({ command, logPrefix }); return createMockCommandResponse({ success: true, output: 'Test Succeeded', error: undefined, + exitCode: 0, }); }; - // Mock file system dependencies using approved utility - const mockFileSystemExecutor = createTestFileSystemExecutor({ - mkdtemp: async () => '/tmp/xcodebuild-test-abc123', - }); - - const result = await testMacosLogic( + const { result } = await runTestMacosLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', }, mockExecutor, - mockFileSystemExecutor, + mockFs(), ); - // Verify commands were called with correct parameters - expect(commandCalls).toHaveLength(2); // xcodebuild test + xcresulttool - expect(commandCalls[0].command).toEqual([ - 'xcodebuild', - '-workspace', - '/path/to/MyProject.xcworkspace', - '-scheme', - 'MyScheme', - '-configuration', - 'Debug', - '-skipMacroValidation', - '-destination', - 'platform=macOS', - '-resultBundlePath', - '/tmp/xcodebuild-test-abc123/TestResults.xcresult', - 'test', - ]); + expect(commandCalls).toHaveLength(1); + expect(commandCalls[0].command).toContain('xcodebuild'); + expect(commandCalls[0].command).toContain('-workspace'); + expect(commandCalls[0].command).toContain('/path/to/MyProject.xcworkspace'); + expect(commandCalls[0].command).toContain('-scheme'); + expect(commandCalls[0].command).toContain('MyScheme'); + expect(commandCalls[0].command).toContain('test'); expect(commandCalls[0].logPrefix).toBe('Test Run'); - expect(commandCalls[0].useShell).toBe(false); - - // Verify xcresulttool was called - expect(commandCalls[1].command).toEqual([ - 'xcrun', - 'xcresulttool', - 'get', - 'test-results', - 'summary', - '--path', - '/tmp/xcodebuild-test-abc123/TestResults.xcresult', - ]); - expect(commandCalls[1].logPrefix).toBe('Parse xcresult bundle'); - - expect(result.content).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'text', - text: '✅ Test Run test succeeded for scheme MyScheme.', - }), - ]), - ); + + expectPendingBuildResponse(result); + expect(result.isError()).toBeFalsy(); }); - it('should return exact test failure response', async () => { - // Track command execution calls + it('should return pending response on test failure', async () => { let callCount = 0; const mockExecutor = async ( - command: string[], - logPrefix?: string, - useShell?: boolean, - opts?: { env?: Record }, - detached?: boolean, + _command: string[], + _logPrefix?: string, + _useShell?: boolean, + _opts?: { env?: Record }, + _detached?: boolean, ) => { callCount++; - void logPrefix; - void useShell; - void opts; - void detached; - - // First call is xcodebuild test - fails - if (callCount === 1) { - return createMockCommandResponse({ - success: false, - output: '', - error: 'error: Test failed', - }); - } - - // Second call is xcresulttool - if (command.includes('xcresulttool')) { - return createMockCommandResponse({ - success: true, - output: JSON.stringify({ - title: 'Test Results', - result: 'FAILED', - totalTestCount: 5, - passedTests: 3, - failedTests: 2, - skippedTests: 0, - expectedFailures: 0, - }), - error: undefined, - }); - } - - return createMockCommandResponse({ success: true, output: '', error: undefined }); + return createMockCommandResponse({ + success: false, + output: '', + error: 'error: Test failed', + exitCode: 65, + }); }; - // Mock file system dependencies - const mockFileSystemExecutor = createTestFileSystemExecutor({ - mkdtemp: async () => '/tmp/xcodebuild-test-abc123', - }); - - const result = await testMacosLogic( + const { result } = await runTestMacosLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', }, mockExecutor, - mockFileSystemExecutor, + mockFs(), ); - expect(result.content).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'text', - text: '❌ Test Run test failed for scheme MyScheme.', - }), - ]), - ); - expect(result.isError).toBe(true); + expect(callCount).toBe(1); + expectPendingBuildResponse(result); + expect(result.isError()).toBe(true); }); - it('should return exact successful test response with optional parameters', async () => { - // Track command execution calls - const commandCalls: any[] = []; - - // Mock executor for successful test with optional parameters + it('should return pending response with optional parameters', async () => { const mockExecutor = async ( - command: string[], - logPrefix?: string, - useShell?: boolean, - opts?: { env?: Record }, - detached?: boolean, - ) => { - commandCalls.push({ command, logPrefix, useShell, env: opts?.env }); - void detached; - - // Handle xcresulttool command - if (command.includes('xcresulttool')) { - return createMockCommandResponse({ - success: true, - output: JSON.stringify({ - title: 'Test Results', - result: 'SUCCEEDED', - totalTestCount: 5, - passedTests: 5, - failedTests: 0, - skippedTests: 0, - expectedFailures: 0, - }), - error: undefined, - }); - } - - return createMockCommandResponse({ + _command: string[], + _logPrefix?: string, + _useShell?: boolean, + _opts?: { env?: Record }, + _detached?: boolean, + ) => + createMockCommandResponse({ success: true, output: 'Test Succeeded', error: undefined, + exitCode: 0, }); - }; - - // Mock file system dependencies - const mockFileSystemExecutor = createTestFileSystemExecutor({ - mkdtemp: async () => '/tmp/xcodebuild-test-abc123', - }); - const result = await testMacosLogic( + const { result } = await runTestMacosLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', @@ -494,188 +344,58 @@ describe('test_macos plugin (unified)', () => { preferXcodebuild: true, }, mockExecutor, - mockFileSystemExecutor, + mockFs(), ); - expect(result.content).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'text', - text: '✅ Test Run test succeeded for scheme MyScheme.', - }), - ]), - ); + expectPendingBuildResponse(result); + expect(result.isError()).toBeFalsy(); }); - it('should filter out stderr lines when xcresult data is available', async () => { - // Regression test for #231: stderr warnings (e.g. "multiple matching destinations") - // should be dropped when xcresult parsing succeeds, since xcresult is authoritative. - let callCount = 0; + it('should handle build failure with pending response', async () => { const mockExecutor = async ( - command: string[], - logPrefix?: string, - useShell?: boolean, - opts?: { env?: Record }, - detached?: boolean, - ) => { - callCount++; - void logPrefix; - void useShell; - void opts; - void detached; - - // First call: xcodebuild test fails with stderr warning - if (callCount === 1) { - return createMockCommandResponse({ - success: false, - output: '', - error: - 'WARNING: multiple matching destinations, using first match\n' + 'error: Test failed', - }); - } - - // Second call: xcresulttool succeeds - if (command.includes('xcresulttool')) { - return createMockCommandResponse({ - success: true, - output: JSON.stringify({ - title: 'Test Results', - result: 'FAILED', - totalTestCount: 5, - passedTests: 3, - failedTests: 2, - skippedTests: 0, - expectedFailures: 0, - }), - }); - } - - return createMockCommandResponse({ success: true, output: '' }); - }; - - const mockFileSystemExecutor = createTestFileSystemExecutor({ - mkdtemp: async () => '/tmp/xcodebuild-test-stderr', - }); - - const result = await testMacosLogic( - { - workspacePath: '/path/to/MyProject.xcworkspace', - scheme: 'MyScheme', - }, - mockExecutor, - mockFileSystemExecutor, - ); - - // stderr lines should be filtered out - const allText = result.content.map((c) => c.text).join('\n'); - expect(allText).not.toContain('[stderr]'); - - // xcresult summary should be present and first - expect(result.content[0].text).toContain('Test Results Summary:'); - - // Build status line should still be present - expect(allText).toContain('Test Run test failed for scheme MyScheme'); - }); - - it('should preserve stderr when xcresult reports zero tests (build failure)', async () => { - // When the build fails, xcresult exists but has totalTestCount: 0. - // In that case stderr contains the actual compilation errors and must be preserved. - let callCount = 0; - const mockExecutor = async ( - command: string[], - logPrefix?: string, - useShell?: boolean, - opts?: { env?: Record }, - detached?: boolean, - ) => { - callCount++; - void logPrefix; - void useShell; - void opts; - void detached; - - // First call: xcodebuild test fails with compilation error on stderr - if (callCount === 1) { - return createMockCommandResponse({ - success: false, - output: '', - error: 'error: missing argument for parameter in call', - }); - } - - // Second call: xcresulttool succeeds but reports 0 tests - if (command.includes('xcresulttool')) { - return createMockCommandResponse({ - success: true, - output: JSON.stringify({ - title: 'Test Results', - result: 'unknown', - totalTestCount: 0, - passedTests: 0, - failedTests: 0, - skippedTests: 0, - expectedFailures: 0, - }), - }); - } - - return createMockCommandResponse({ success: true, output: '' }); - }; - - const mockFileSystemExecutor = createTestFileSystemExecutor({ - mkdtemp: async () => '/tmp/xcodebuild-test-buildfail', - }); + _command: string[], + _logPrefix?: string, + _useShell?: boolean, + _opts?: { env?: Record }, + _detached?: boolean, + ) => + createMockCommandResponse({ + success: false, + output: '', + error: 'error: missing argument for parameter in call', + exitCode: 65, + }); - const result = await testMacosLogic( + const { result } = await runTestMacosLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', }, mockExecutor, - mockFileSystemExecutor, + mockFs(), ); - // stderr with compilation error must be preserved (not filtered) - const allText = result.content.map((c) => c.text).join('\n'); - expect(allText).toContain('[stderr]'); - expect(allText).toContain('missing argument'); - - // xcresult summary should NOT be present (it's meaningless with 0 tests) - expect(allText).not.toContain('Test Results Summary:'); + expectPendingBuildResponse(result); + expect(result.isError()).toBe(true); }); - it('should return exact exception handling response', async () => { - // Mock executor (won't be called due to mkdtemp failure) + it('should return error response when executor throws an exception', async () => { const mockExecutor = createMockExecutor({ - success: true, - output: 'Test Succeeded', + success: false, + error: '', + shouldThrow: new Error('Network error'), }); - // Mock file system dependencies - mkdtemp fails - const mockFileSystemExecutor = createTestFileSystemExecutor({ - mkdtemp: async () => { - throw new Error('Network error'); - }, - }); - - const result = await testMacosLogic( + const { result } = await runTestMacosLogic( { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', }, mockExecutor, - mockFileSystemExecutor, + mockFs(), ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Error during test run: Network error', - }, - ], - isError: true, - }); + expect(result.isError()).toBe(true); }); }); }); diff --git a/src/mcp/tools/macos/build_macos.ts b/src/mcp/tools/macos/build_macos.ts index b91e4129..c5fde08d 100644 --- a/src/mcp/tools/macos/build_macos.ts +++ b/src/mcp/tools/macos/build_macos.ts @@ -1,34 +1,21 @@ -/** - * macOS Shared Plugin: Build macOS (Unified) - * - * Builds a macOS app using xcodebuild from a project or workspace. - * Accepts mutually exclusive `projectPath` or `workspacePath`. - */ - import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; -import type { ToolResponse } from '../../../types/common.ts'; import { XcodePlatform } from '../../../types/common.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; +import { startBuildPipeline } from '../../../utils/xcodebuild-pipeline.ts'; +import { finalizeInlineXcodebuild } from '../../../utils/xcodebuild-output.ts'; +import { formatToolPreflight } from '../../../utils/build-preflight.ts'; +import { resolveAppPathFromBuildSettings } from '../../../utils/app-path-resolver.ts'; +import { detailTree } from '../../../utils/tool-event-builders.ts'; -// Types for dependency injection -export interface BuildUtilsDependencies { - executeXcodeBuildCommand: typeof executeXcodeBuildCommand; -} - -// Default implementations -const defaultBuildUtilsDependencies: BuildUtilsDependencies = { - executeXcodeBuildCommand, -}; - -// Unified schema: XOR between projectPath and workspacePath const baseSchemaObject = z.object({ projectPath: z.string().optional().describe('Path to the .xcodeproj file'), workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), @@ -66,16 +53,12 @@ const buildMacOSSchema = z.preprocess( export type BuildMacOSParams = z.infer; -/** - * Business logic for building macOS apps from project or workspace with dependency injection. - * Exported for direct testing and reuse. - */ export async function buildMacOSLogic( params: BuildMacOSParams, executor: CommandExecutor, - buildUtilsDeps: BuildUtilsDependencies = defaultBuildUtilsDependencies, -): Promise { - log('info', `Starting macOS build for scheme ${params.scheme} (internal)`); +): Promise { + const ctx = getHandlerContext(); + log('info', `Starting macOS build for scheme ${params.scheme}`); const processedParams = { ...params, @@ -83,17 +66,102 @@ export async function buildMacOSLogic( preferXcodebuild: params.preferXcodebuild ?? false, }; - return buildUtilsDeps.executeXcodeBuildCommand( + const platformOptions = { + platform: XcodePlatform.macOS, + arch: params.arch, + logPrefix: 'macOS Build', + }; + + const preflightText = formatToolPreflight({ + operation: 'Build', + scheme: params.scheme, + workspacePath: params.workspacePath, + projectPath: params.projectPath, + configuration: processedParams.configuration, + platform: 'macOS', + arch: params.arch, + }); + + const pipelineParams = { + scheme: params.scheme, + workspacePath: params.workspacePath, + projectPath: params.projectPath, + configuration: processedParams.configuration, + platform: 'macOS', + preflight: preflightText, + }; + + const started = startBuildPipeline({ + operation: 'BUILD', + toolName: 'build_macos', + params: pipelineParams, + message: preflightText, + }); + + const buildResult = await executeXcodeBuildCommand( processedParams, - { - platform: XcodePlatform.macOS, - arch: params.arch, - logPrefix: 'macOS Build', - }, - processedParams.preferXcodebuild ?? false, + platformOptions, + processedParams.preferXcodebuild, 'build', executor, + undefined, + started.pipeline, ); + + if (buildResult.isError) { + finalizeInlineXcodebuild({ + started, + emit: ctx.emit, + succeeded: false, + durationMs: Date.now() - started.startedAt, + responseContent: buildResult.content, + }); + return; + } + + let bundleId: string | undefined; + try { + const appPath = await resolveAppPathFromBuildSettings( + { + projectPath: params.projectPath, + workspacePath: params.workspacePath, + scheme: params.scheme, + configuration: processedParams.configuration, + platform: XcodePlatform.macOS, + derivedDataPath: params.derivedDataPath, + extraArgs: params.extraArgs, + }, + executor, + ); + + const plistResult = await executor( + ['/bin/sh', '-c', `defaults read "${appPath}/Contents/Info" CFBundleIdentifier`], + 'Extract Bundle ID', + false, + ); + if (plistResult.success && plistResult.output) { + bundleId = plistResult.output.trim(); + } + } catch { + // non-fatal: bundle ID is informational + } + + const tailEvents = bundleId ? [detailTree([{ label: 'Bundle ID', value: bundleId }])] : []; + + finalizeInlineXcodebuild({ + started, + emit: ctx.emit, + succeeded: true, + durationMs: Date.now() - started.startedAt, + responseContent: buildResult.content, + tailEvents, + }); + + ctx.nextStepParams = { + get_mac_app_path: { + scheme: params.scheme, + }, + }; } export const schema = getSessionAwareToolSchemaShape({ diff --git a/src/mcp/tools/macos/build_run_macos.ts b/src/mcp/tools/macos/build_run_macos.ts index 6233b2cb..1f88ff99 100644 --- a/src/mcp/tools/macos/build_run_macos.ts +++ b/src/mcp/tools/macos/build_run_macos.ts @@ -1,25 +1,28 @@ -/** - * macOS Shared Plugin: Build and Run macOS (Unified) - * - * Builds and runs a macOS app from a project or workspace in one step. - * Accepts mutually exclusive `projectPath` or `workspacePath`. - */ - import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; -import { createTextResponse } from '../../../utils/responses/index.ts'; import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; -import type { ToolResponse } from '../../../types/common.ts'; import { XcodePlatform } from '../../../types/common.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header } from '../../../utils/tool-event-builders.ts'; +import { startBuildPipeline } from '../../../utils/xcodebuild-pipeline.ts'; +import { formatToolPreflight } from '../../../utils/build-preflight.ts'; +import { + createBuildRunResultEvents, + emitPipelineError, + emitPipelineNotice, + finalizeInlineXcodebuild, +} from '../../../utils/xcodebuild-output.ts'; +import { resolveAppPathFromBuildSettings } from '../../../utils/app-path-resolver.ts'; +import { launchMacApp } from '../../../utils/macos-steps.ts'; -// Unified schema: XOR between projectPath and workspacePath const baseSchemaObject = z.object({ projectPath: z.string().optional().describe('Path to the .xcodeproj file'), workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), @@ -57,161 +60,158 @@ const buildRunMacOSSchema = z.preprocess( export type BuildRunMacOSParams = z.infer; -/** - * Internal logic for building macOS apps. - */ -async function _handleMacOSBuildLogic( - params: BuildRunMacOSParams, - executor: CommandExecutor, -): Promise { - log('info', `Starting macOS build for scheme ${params.scheme} (internal)`); - - return executeXcodeBuildCommand( - { - ...params, - configuration: params.configuration ?? 'Debug', - }, - { - platform: XcodePlatform.macOS, - arch: params.arch, - logPrefix: 'macOS Build', - }, - params.preferXcodebuild ?? false, - 'build', - executor, - ); -} - -async function _getAppPathFromBuildSettings( - params: BuildRunMacOSParams, - executor: CommandExecutor, -): Promise<{ success: true; appPath: string } | { success: false; error: string }> { - try { - // Create the command array for xcodebuild - const command = ['xcodebuild', '-showBuildSettings']; - - // Add the project or workspace - if (params.projectPath) { - command.push('-project', params.projectPath); - } else if (params.workspacePath) { - command.push('-workspace', params.workspacePath); - } - - // Add the scheme and configuration - command.push('-scheme', params.scheme); - command.push('-configuration', params.configuration ?? 'Debug'); - - // Add derived data path if provided - if (params.derivedDataPath) { - command.push('-derivedDataPath', params.derivedDataPath); - } - - // Add extra args if provided - if (params.extraArgs && params.extraArgs.length > 0) { - command.push(...params.extraArgs); - } - - // Execute the command directly - const result = await executor(command, 'Get Build Settings for Launch', false, undefined); - - if (!result.success) { - return { - success: false, - error: result.error ?? 'Failed to get build settings', - }; - } - - // Parse the output to extract the app path - const buildSettingsOutput = result.output; - const builtProductsDirMatch = buildSettingsOutput.match(/^\s*BUILT_PRODUCTS_DIR\s*=\s*(.+)$/m); - const fullProductNameMatch = buildSettingsOutput.match(/^\s*FULL_PRODUCT_NAME\s*=\s*(.+)$/m); - - if (!builtProductsDirMatch || !fullProductNameMatch) { - return { success: false, error: 'Could not extract app path from build settings' }; - } - - const appPath = `${builtProductsDirMatch[1].trim()}/${fullProductNameMatch[1].trim()}`; - return { success: true, appPath }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return { success: false, error: errorMessage }; - } -} - -/** - * Business logic for building and running macOS apps. - */ export async function buildRunMacOSLogic( params: BuildRunMacOSParams, executor: CommandExecutor, -): Promise { - log('info', 'Handling macOS build & run logic...'); - - try { - // First, build the app - const buildResult = await _handleMacOSBuildLogic(params, executor); - - // 1. Check if the build itself failed - if (buildResult.isError) { - return buildResult; // Return build failure directly - } - const buildWarningMessages = buildResult.content?.filter((c) => c.type === 'text') ?? []; - - // 2. Build succeeded, now get the app path using the helper - const appPathResult = await _getAppPathFromBuildSettings(params, executor); - - // 3. Check if getting the app path failed - if (!appPathResult.success) { - log('error', 'Build succeeded, but failed to get app path to launch.'); - const response = createTextResponse( - `✅ Build succeeded, but failed to get app path to launch: ${appPathResult.error}`, - false, // Build succeeded, so not a full error +): Promise { + const ctx = getHandlerContext(); + return withErrorHandling( + ctx, + async () => { + const configuration = params.configuration ?? 'Debug'; + + const preflightText = formatToolPreflight({ + operation: 'Build & Run', + scheme: params.scheme, + workspacePath: params.workspacePath, + projectPath: params.projectPath, + configuration, + platform: 'macOS', + arch: params.arch, + }); + + const started = startBuildPipeline({ + operation: 'BUILD', + toolName: 'build_run_macos', + params: { + scheme: params.scheme, + workspacePath: params.workspacePath, + projectPath: params.projectPath, + configuration, + platform: 'macOS', + preflight: preflightText, + }, + message: preflightText, + }); + + const buildResult = await executeXcodeBuildCommand( + { ...params, configuration }, + { platform: XcodePlatform.macOS, arch: params.arch, logPrefix: 'macOS Build' }, + params.preferXcodebuild ?? false, + 'build', + executor, + undefined, + started.pipeline, ); - if (response.content) { - response.content.unshift(...buildWarningMessages); - } - return response; - } - const appPath = appPathResult.appPath; // success === true narrows to string - log('info', `App path determined as: ${appPath}`); + if (buildResult.isError) { + finalizeInlineXcodebuild({ + started, + emit: ctx.emit, + succeeded: false, + durationMs: Date.now() - started.startedAt, + responseContent: buildResult.content, + errorFallbackPolicy: 'if-no-structured-diagnostics', + }); + return; + } - // 4. Launch the app using CommandExecutor - const launchResult = await executor(['open', appPath], 'Launch macOS App', false); + let appPath: string; + emitPipelineNotice(started, 'BUILD', 'Resolving app path', 'info', { + code: 'build-run-step', + data: { step: 'resolve-app-path', status: 'started' }, + }); + + try { + appPath = await resolveAppPathFromBuildSettings( + { + projectPath: params.projectPath, + workspacePath: params.workspacePath, + scheme: params.scheme, + configuration: params.configuration, + platform: XcodePlatform.macOS, + derivedDataPath: params.derivedDataPath, + extraArgs: params.extraArgs, + }, + executor, + ); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', 'Build succeeded, but failed to get app path to launch.'); + emitPipelineError(started, 'BUILD', `Failed to get app path to launch: ${errorMessage}`); + finalizeInlineXcodebuild({ + started, + emit: ctx.emit, + succeeded: false, + durationMs: Date.now() - started.startedAt, + }); + return; + } - if (!launchResult.success) { - log('error', `Build succeeded, but failed to launch app ${appPath}: ${launchResult.error}`); - const errorResponse = createTextResponse( - `✅ Build succeeded, but failed to launch app ${appPath}. Error: ${launchResult.error}`, - false, // Build succeeded - ); - if (errorResponse.content) { - errorResponse.content.unshift(...buildWarningMessages); + log('info', `App path determined as: ${appPath}`); + emitPipelineNotice(started, 'BUILD', 'App path resolved', 'success', { + code: 'build-run-step', + data: { step: 'resolve-app-path', status: 'succeeded', appPath }, + }); + emitPipelineNotice(started, 'BUILD', 'Launching app', 'info', { + code: 'build-run-step', + data: { step: 'launch-app', status: 'started', appPath }, + }); + + const macLaunchResult = await launchMacApp(appPath, executor); + + if (!macLaunchResult.success) { + log( + 'error', + `Build succeeded, but failed to launch app ${appPath}: ${macLaunchResult.error}`, + ); + emitPipelineError( + started, + 'BUILD', + `Failed to launch app ${appPath}: ${macLaunchResult.error}`, + ); + finalizeInlineXcodebuild({ + started, + emit: ctx.emit, + succeeded: false, + durationMs: Date.now() - started.startedAt, + }); + return; } - return errorResponse; - } - - log('info', `✅ macOS app launched successfully: ${appPath}`); - const successResponse: ToolResponse = { - content: [ - ...buildWarningMessages, - { - type: 'text', - text: `✅ macOS build and run succeeded for scheme ${params.scheme}. App launched: ${appPath}`, - }, - ], - isError: false, - }; - return successResponse; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error during macOS build & run logic: ${errorMessage}`); - const errorResponse = createTextResponse( - `Error during macOS build and run: ${errorMessage}`, - true, - ); - return errorResponse; - } + + log('info', `macOS app launched successfully: ${appPath}`); + emitPipelineNotice(started, 'BUILD', 'App launched', 'success', { + code: 'build-run-step', + data: { step: 'launch-app', status: 'succeeded', appPath }, + }); + + const bundleId = macLaunchResult.bundleId; + const processId = macLaunchResult.processId; + + finalizeInlineXcodebuild({ + started, + emit: ctx.emit, + succeeded: true, + durationMs: Date.now() - started.startedAt, + tailEvents: createBuildRunResultEvents({ + scheme: params.scheme, + platform: 'macOS', + target: 'macOS', + appPath, + bundleId, + processId, + launchState: 'requested', + buildLogPath: started.pipeline.logPath, + }), + includeBuildLogFileRef: false, + }); + }, + { + header: header('Build & Run macOS'), + errorMessage: ({ message }) => `Error during macOS build and run: ${message}`, + logMessage: ({ message }) => `Error during macOS build & run logic: ${message}`, + }, + ); } export const schema = getSessionAwareToolSchemaShape({ diff --git a/src/mcp/tools/macos/get_mac_app_path.ts b/src/mcp/tools/macos/get_mac_app_path.ts index dfc32c7c..1d22c494 100644 --- a/src/mcp/tools/macos/get_mac_app_path.ts +++ b/src/mcp/tools/macos/get_mac_app_path.ts @@ -1,12 +1,4 @@ -/** - * macOS Shared Plugin: Get macOS App Path (Unified) - * - * Gets the app bundle path for a macOS application using either a project or workspace. - * Accepts mutually exclusive `projectPath` or `workspacePath`. - */ - import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; import { XcodePlatform } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; @@ -14,8 +6,15 @@ import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, + getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; +import { extractQueryErrorMessages } from '../../../utils/xcodebuild-error-utils.ts'; +import { resolveAppPathFromBuildSettings } from '../../../utils/app-path-resolver.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine, detailTree, section } from '../../../utils/tool-event-builders.ts'; +import { displayPath } from '../../../utils/build-preflight.ts'; +import type { PipelineEvent } from '../../../types/pipeline-events.ts'; const baseOptions = { scheme: z.string().describe('The scheme to use'), @@ -58,116 +57,87 @@ type GetMacosAppPathParams = z.infer; export async function get_mac_app_pathLogic( params: GetMacosAppPathParams, executor: CommandExecutor, -): Promise { +): Promise { const configuration = params.configuration ?? 'Debug'; - log('info', `Getting app path for scheme ${params.scheme} on platform ${XcodePlatform.macOS}`); - - try { - // Create the command array for xcodebuild with -showBuildSettings option - const command = ['xcodebuild', '-showBuildSettings']; - - // Add the project or workspace - if (params.projectPath) { - command.push('-project', params.projectPath); - } else if (params.workspacePath) { - command.push('-workspace', params.workspacePath); - } else { - // This should never happen due to schema validation - throw new Error('Either projectPath or workspacePath is required.'); - } - - // Add the scheme and configuration - command.push('-scheme', params.scheme); - command.push('-configuration', configuration); - - // Add optional derived data path - if (params.derivedDataPath) { - command.push('-derivedDataPath', params.derivedDataPath); - } - - // Handle destination for macOS when arch is specified - if (params.arch) { - const destinationString = `platform=macOS,arch=${params.arch}`; - command.push('-destination', destinationString); - } - - if (params.extraArgs) { - command.push(...params.extraArgs); - } - - // Execute the command directly with executor - const result = await executor(command, 'Get App Path', false, undefined); - - if (!result.success) { - return { - content: [ - { - type: 'text', - text: `Error: Failed to get macOS app path\nDetails: ${result.error}`, - }, - ], - isError: true, - }; - } + const headerParams: Array<{ label: string; value: string }> = [ + { label: 'Scheme', value: params.scheme }, + ]; + if (params.workspacePath) { + headerParams.push({ label: 'Workspace', value: params.workspacePath }); + } else if (params.projectPath) { + headerParams.push({ label: 'Project', value: params.projectPath }); + } + headerParams.push({ label: 'Configuration', value: configuration }); + headerParams.push({ label: 'Platform', value: 'macOS' }); + if (params.arch) { + headerParams.push({ label: 'Architecture', value: params.arch }); + } - if (!result.output) { - return { - content: [ - { - type: 'text', - text: 'Error: Failed to get macOS app path\nDetails: Failed to extract build settings output from the result', - }, - ], - isError: true, - }; - } + const headerEvent = header('Get App Path', headerParams); + + function buildErrorEvents(rawOutput: string): PipelineEvent[] { + const messages = extractQueryErrorMessages(rawOutput); + return [ + headerEvent, + section(`Errors (${messages.length}):`, [...messages.map((m) => `\u{2717} ${m}`), ''], { + blankLineAfterTitle: true, + }), + statusLine('error', 'Query failed.'), + ]; + } + + log('info', `Getting app path for scheme ${params.scheme} on platform macOS`); + + const ctx = getHandlerContext(); - const buildSettingsOutput = result.output; - const builtProductsDirMatch = buildSettingsOutput.match(/^\s*BUILT_PRODUCTS_DIR\s*=\s*(.+)$/m); - const fullProductNameMatch = buildSettingsOutput.match(/^\s*FULL_PRODUCT_NAME\s*=\s*(.+)$/m); + return withErrorHandling( + ctx, + async () => { + const destination = params.arch ? `platform=macOS,arch=${params.arch}` : undefined; - if (!builtProductsDirMatch || !fullProductNameMatch) { - return { - content: [ + let appPath: string; + try { + appPath = await resolveAppPathFromBuildSettings( { - type: 'text', - text: 'Error: Failed to get macOS app path\nDetails: Could not extract app path from build settings', + projectPath: params.projectPath, + workspacePath: params.workspacePath, + scheme: params.scheme, + configuration, + platform: XcodePlatform.macOS, + destination, + derivedDataPath: params.derivedDataPath, + extraArgs: params.extraArgs, }, - ], - isError: true, - }; - } - - const builtProductsDir = builtProductsDirMatch[1].trim(); - const fullProductName = fullProductNameMatch[1].trim(); - const appPath = `${builtProductsDir}/${fullProductName}`; - - return { - content: [ - { - type: 'text', - text: `✅ App path retrieved successfully: ${appPath}`, - }, - ], - nextStepParams: { + executor, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + for (const event of buildErrorEvents(message)) { + ctx.emit(event); + } + return; + } + + ctx.emit(headerEvent); + ctx.emit(statusLine('success', 'Success')); + ctx.emit(detailTree([{ label: 'App Path', value: displayPath(appPath) }])); + ctx.nextStepParams = { get_mac_bundle_id: { appPath }, launch_mac_app: { appPath }, + }; + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Error retrieving app path: ${message}`, + logMessage: ({ message }) => `Error retrieving app path: ${message}`, + mapError: ({ message, emit }) => { + for (const event of buildErrorEvents(message)) { + emit?.(event); + } }, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error retrieving app path: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `Error: Failed to get macOS app path\nDetails: ${errorMessage}`, - }, - ], - isError: true, - }; - } + }, + ); } export const schema = getSessionAwareToolSchemaShape({ diff --git a/src/mcp/tools/macos/launch_mac_app.ts b/src/mcp/tools/macos/launch_mac_app.ts index e7344705..afe63f6f 100644 --- a/src/mcp/tools/macos/launch_mac_app.ts +++ b/src/mcp/tools/macos/launch_mac_app.ts @@ -1,75 +1,70 @@ -/** - * macOS Workspace Plugin: Launch macOS App - * - * Launches a macOS application using the 'open' command. - * IMPORTANT: You MUST provide the appPath parameter. - */ - import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; -import { validateFileExists } from '../../../utils/validation/index.ts'; -import type { ToolResponse } from '../../../types/common.ts'; +import { validateFileExists } from '../../../utils/validation.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 { createTypedTool, getHandlerContext } from '../../../utils/typed-tool-factory.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine, detailTree } from '../../../utils/tool-event-builders.ts'; +import { launchMacApp } from '../../../utils/macos-steps.ts'; -// Define schema as ZodObject const launchMacAppSchema = z.object({ appPath: z.string(), args: z.array(z.string()).optional(), }); -// Use z.infer for type safety type LaunchMacAppParams = z.infer; export async function launch_mac_appLogic( params: LaunchMacAppParams, executor: CommandExecutor, fileSystem?: FileSystemExecutor, -): Promise { - // Validate that the app file exists +): Promise { + const headerEvent = header('Launch macOS App', [{ label: 'App', value: params.appPath }]); + const fileExistsValidation = validateFileExists(params.appPath, fileSystem); if (!fileExistsValidation.isValid) { - return { content: [{ type: 'text', text: fileExistsValidation.errorMessage! }], isError: true }; + const ctx = getHandlerContext(); + ctx.emit(headerEvent); + ctx.emit(statusLine('error', fileExistsValidation.errorMessage!)); + return; } log('info', `Starting launch macOS app request for ${params.appPath}`); - try { - // Construct the command as string array for CommandExecutor - const command = ['open', params.appPath]; + const ctx = getHandlerContext(); - // Add any additional arguments if provided - if (params.args && Array.isArray(params.args) && params.args.length > 0) { - command.push('--args', ...params.args); - } + return withErrorHandling( + ctx, + async () => { + const result = await launchMacApp(params.appPath, executor, { args: params.args }); - // Execute the command using CommandExecutor - await executor(command, 'Launch macOS App'); + if (!result.success) { + ctx.emit(headerEvent); + ctx.emit(statusLine('error', `Launch macOS app operation failed: ${result.error}`)); + return; + } - // Return success response - return { - content: [ - { - type: 'text', - text: `✅ macOS app launched successfully: ${params.appPath}`, - }, - ], - }; - } catch (error) { - // Handle errors - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error during launch macOS app operation: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `❌ Launch macOS app operation failed: ${errorMessage}`, - }, - ], - isError: true, - }; - } + const details: Array<{ label: string; value: string }> = []; + if (result.bundleId) { + details.push({ label: 'Bundle ID', value: result.bundleId }); + } + if (result.processId !== undefined) { + details.push({ label: 'Process ID', value: String(result.processId) }); + } + + ctx.emit(headerEvent); + ctx.emit(statusLine('success', 'App launched successfully')); + if (details.length > 0) { + ctx.emit(detailTree(details)); + } + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Launch macOS app operation failed: ${message}`, + logMessage: ({ message }) => `Error during launch macOS app operation: ${message}`, + }, + ); } export const schema = launchMacAppSchema.shape; diff --git a/src/mcp/tools/macos/stop_mac_app.ts b/src/mcp/tools/macos/stop_mac_app.ts index 6db67748..1190662b 100644 --- a/src/mcp/tools/macos/stop_mac_app.ts +++ b/src/mcp/tools/macos/stop_mac_app.ts @@ -1,78 +1,69 @@ import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; -import type { ToolResponse } from '../../../types/common.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { createTypedTool, getHandlerContext } from '../../../utils/typed-tool-factory.ts'; +import { withErrorHandling } from '../../../utils/tool-error-handling.ts'; +import { header, statusLine } from '../../../utils/tool-event-builders.ts'; -// Define schema as ZodObject const stopMacAppSchema = z.object({ appName: z.string().optional(), processId: z.number().optional(), }); -// Use z.infer for type safety type StopMacAppParams = z.infer; export async function stop_mac_appLogic( params: StopMacAppParams, executor: CommandExecutor, -): Promise { +): Promise { if (!params.appName && !params.processId) { - return { - content: [ - { - type: 'text', - text: 'Either appName or processId must be provided.', - }, - ], - isError: true, - }; + const ctx = getHandlerContext(); + ctx.emit(header('Stop macOS App')); + ctx.emit(statusLine('error', 'Either appName or processId must be provided.')); + return; } - log( - 'info', - `Stopping macOS app: ${params.processId ? `PID ${params.processId}` : params.appName}`, - ); + const target = params.processId ? `PID ${params.processId}` : params.appName!; + const headerEvent = header('Stop macOS App', [{ label: 'App', value: target }]); - try { - let command: string[]; + log('info', `Stopping macOS app: ${target}`); - if (params.processId) { - // Stop by process ID - command = ['kill', String(params.processId)]; - } else { - // Stop by app name - use shell command with fallback for complex logic - command = [ - 'sh', - '-c', - `pkill -f "${params.appName}" || osascript -e 'tell application "${params.appName}" to quit'`, - ]; - } + const ctx = getHandlerContext(); - await executor(command, 'Stop macOS App'); + return withErrorHandling( + ctx, + async () => { + let command: string[]; - return { - content: [ - { - type: 'text', - text: `✅ macOS app stopped successfully: ${params.processId ? `PID ${params.processId}` : params.appName}`, - }, - ], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error stopping macOS app: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `❌ Stop macOS app operation failed: ${errorMessage}`, - }, - ], - isError: true, - }; - } + if (params.processId) { + command = ['kill', String(params.processId)]; + } else { + command = ['pkill', '-f', params.appName!]; + } + + const result = await executor(command, 'Stop macOS App'); + + if (!result.success) { + ctx.emit(headerEvent); + ctx.emit( + statusLine( + 'error', + `Stop macOS app operation failed: ${result.error ?? 'Unknown error'}`, + ), + ); + return; + } + + ctx.emit(headerEvent); + ctx.emit(statusLine('success', 'App stopped successfully')); + }, + { + header: headerEvent, + errorMessage: ({ message }) => `Stop macOS app operation failed: ${message}`, + logMessage: ({ message }) => `Error stopping macOS app: ${message}`, + }, + ); } export const schema = stopMacAppSchema.shape; diff --git a/src/mcp/tools/macos/test_macos.ts b/src/mcp/tools/macos/test_macos.ts index 1dbdf7ba..abe67a8d 100644 --- a/src/mcp/tools/macos/test_macos.ts +++ b/src/mcp/tools/macos/test_macos.ts @@ -6,18 +6,9 @@ */ import * as z from 'zod'; -import { join } from 'path'; -import type { ToolResponse } from '../../../types/common.ts'; import { XcodePlatform } from '../../../types/common.ts'; -import { log } from '../../../utils/logging/index.ts'; -import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; -import { createTextResponse } from '../../../utils/responses/index.ts'; -import { normalizeTestRunnerEnv } from '../../../utils/environment.ts'; -import type { - CommandExecutor, - FileSystemExecutor, - CommandExecOptions, -} from '../../../utils/execution/index.ts'; +import { handleTestLogic } from '../../../utils/test/index.ts'; +import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor, getDefaultFileSystemExecutor, @@ -27,9 +18,8 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; -import { filterStderrContent, type XcresultSummary } from '../../../utils/test-result-content.ts'; +import { resolveTestPreflight } from '../../../utils/test-preflight.ts'; -// Unified schema: XOR between projectPath and workspacePath const baseSchemaObject = z.object({ projectPath: z.string().optional().describe('Path to the .xcodeproj file'), workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), @@ -44,6 +34,10 @@ const baseSchemaObject = z.object({ .describe( 'Environment variables to pass to the test runner (TEST_RUNNER_ prefix added automatically)', ), + progress: z + .boolean() + .optional() + .describe('Show detailed test progress output (MCP defaults to true, CLI defaults to false)'), }); const publicSchemaObject = baseSchemaObject.omit({ @@ -68,209 +62,44 @@ const testMacosSchema = z.preprocess( export type TestMacosParams = z.infer; -/** - * Parse xcresult bundle using xcrun xcresulttool - */ -async function parseXcresultBundle( - resultBundlePath: string, - executor: CommandExecutor = getDefaultCommandExecutor(), -): Promise { - try { - const result = await executor( - ['xcrun', 'xcresulttool', 'get', 'test-results', 'summary', '--path', resultBundlePath], - 'Parse xcresult bundle', - true, - ); - - if (!result.success) { - throw new Error(result.error ?? 'Failed to parse xcresult bundle'); - } - - // Parse JSON response and format as human-readable - const summary = JSON.parse(result.output || '{}') as Record; - return { - formatted: formatTestSummary(summary), - totalTestCount: typeof summary.totalTestCount === 'number' ? summary.totalTestCount : 0, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error parsing xcresult bundle: ${errorMessage}`); - throw error; - } -} - -/** - * Format test summary JSON into human-readable text - */ -function formatTestSummary(summary: Record): string { - const lines = []; - - lines.push(`Test Summary: ${summary.title ?? 'Unknown'}`); - lines.push(`Overall Result: ${summary.result ?? 'Unknown'}`); - lines.push(''); - - lines.push('Test Counts:'); - lines.push(` Total: ${summary.totalTestCount ?? 0}`); - lines.push(` Passed: ${summary.passedTests ?? 0}`); - lines.push(` Failed: ${summary.failedTests ?? 0}`); - lines.push(` Skipped: ${summary.skippedTests ?? 0}`); - lines.push(` Expected Failures: ${summary.expectedFailures ?? 0}`); - lines.push(''); - - if (summary.environmentDescription) { - lines.push(`Environment: ${summary.environmentDescription}`); - lines.push(''); - } - - if ( - summary.devicesAndConfigurations && - Array.isArray(summary.devicesAndConfigurations) && - summary.devicesAndConfigurations.length > 0 - ) { - const deviceConfig = summary.devicesAndConfigurations[0] as Record; - const device = deviceConfig.device as Record | undefined; - if (device) { - lines.push( - `Device: ${device.deviceName ?? 'Unknown'} (${device.platform ?? 'Unknown'} ${device.osVersion ?? 'Unknown'})`, - ); - lines.push(''); - } - } - - if ( - summary.testFailures && - Array.isArray(summary.testFailures) && - summary.testFailures.length > 0 - ) { - lines.push('Test Failures:'); - summary.testFailures.forEach((failureItem, index: number) => { - const failure = failureItem as Record; - lines.push( - ` ${index + 1}. ${failure.testName ?? 'Unknown Test'} (${failure.targetName ?? 'Unknown Target'})`, - ); - if (failure.failureText) { - lines.push(` ${failure.failureText}`); - } - }); - lines.push(''); - } - - if (summary.topInsights && Array.isArray(summary.topInsights) && summary.topInsights.length > 0) { - lines.push('Insights:'); - summary.topInsights.forEach((insightItem, index: number) => { - const insight = insightItem as Record; - lines.push( - ` ${index + 1}. [${insight.impact ?? 'Unknown'}] ${insight.text ?? 'No description'}`, - ); - }); - } - - return lines.join('\n'); -} - -/** - * Business logic for testing a macOS project or workspace. - * Exported for direct testing and reuse. - */ export async function testMacosLogic( params: TestMacosParams, executor: CommandExecutor = getDefaultCommandExecutor(), fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), -): Promise { - log('info', `Starting test run for scheme ${params.scheme} on platform macOS (internal)`); - - try { - // Create temporary directory for xcresult bundle - const tempDir = await fileSystemExecutor.mkdtemp( - join(fileSystemExecutor.tmpdir(), 'xcodebuild-test-'), - ); - const resultBundlePath = join(tempDir, 'TestResults.xcresult'); - - // Add resultBundlePath to extraArgs - const extraArgs = [...(params.extraArgs ?? []), `-resultBundlePath`, resultBundlePath]; - - // Prepare execution options with TEST_RUNNER_ environment variables - const execOpts: CommandExecOptions | undefined = params.testRunnerEnv - ? { env: normalizeTestRunnerEnv(params.testRunnerEnv) } - : undefined; - - // Run the test command - const testResult = await executeXcodeBuildCommand( - { - projectPath: params.projectPath, - workspacePath: params.workspacePath, - scheme: params.scheme, - configuration: params.configuration ?? 'Debug', - derivedDataPath: params.derivedDataPath, - extraArgs, - }, - { - platform: XcodePlatform.macOS, - logPrefix: 'Test Run', - }, - params.preferXcodebuild ?? false, - 'test', - executor, - execOpts, - ); - - // Parse xcresult bundle if it exists, regardless of whether tests passed or failed - // Test failures are expected and should not prevent xcresult parsing - try { - log('info', `Attempting to parse xcresult bundle at: ${resultBundlePath}`); - - // Check if the file exists - try { - await fileSystemExecutor.stat(resultBundlePath); - log('info', `xcresult bundle exists at: ${resultBundlePath}`); - } catch { - log('warn', `xcresult bundle does not exist at: ${resultBundlePath}`); - throw new Error(`xcresult bundle not found at ${resultBundlePath}`); - } - - const xcresult = await parseXcresultBundle(resultBundlePath, executor); - log('info', 'Successfully parsed xcresult bundle'); - - // Clean up temporary directory - await fileSystemExecutor.rm(tempDir, { recursive: true, force: true }); - - // If no tests ran (for example build/setup failed), xcresult summary is not useful. - // Return raw output so the original diagnostics stay visible. - if (xcresult.totalTestCount === 0) { - log('info', 'xcresult reports 0 tests — returning raw build output'); - return testResult; - } - - // xcresult summary should be first. Drop stderr-only noise while preserving non-stderr lines. - const filteredContent = filterStderrContent(testResult.content); - return { - content: [ - { - type: 'text', - text: '\nTest Results Summary:\n' + xcresult.formatted, - }, - ...filteredContent, - ], - isError: testResult.isError, - }; - } catch (parseError) { - // If parsing fails, return original test result - log('warn', `Failed to parse xcresult bundle: ${parseError}`); - - // Clean up temporary directory even if parsing fails - try { - await fileSystemExecutor.rm(tempDir, { recursive: true, force: true }); - } catch (cleanupError) { - log('warn', `Failed to clean up temporary directory: ${cleanupError}`); - } - - return testResult; - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error during test run: ${errorMessage}`); - return createTextResponse(`Error during test run: ${errorMessage}`, true); - } +): Promise { + const configuration = params.configuration ?? 'Debug'; + + const preflight = await resolveTestPreflight( + { + projectPath: params.projectPath, + workspacePath: params.workspacePath, + scheme: params.scheme, + configuration, + extraArgs: params.extraArgs, + destinationName: 'macOS', + }, + fileSystemExecutor, + ); + + await handleTestLogic( + { + projectPath: params.projectPath, + workspacePath: params.workspacePath, + scheme: params.scheme, + configuration, + derivedDataPath: params.derivedDataPath, + extraArgs: params.extraArgs, + preferXcodebuild: params.preferXcodebuild ?? false, + platform: XcodePlatform.macOS, + testRunnerEnv: params.testRunnerEnv, + progress: params.progress, + }, + executor, + { + preflight: preflight ?? undefined, + toolName: 'test_macos', + }, + ); } export const schema = getSessionAwareToolSchemaShape({ From 4f3901c65f1428af82e14a34c759a8a81f1695bc Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 9 Apr 2026 08:47:44 +0100 Subject: [PATCH 2/3] refactor: use shared runLogic helper in device and macOS test files Replace 7 identical local runLogic definitions with the shared import from test-helpers.ts. --- .../__tests__/get_device_app_path.test.ts | 36 ++----------------- .../__tests__/install_app_device.test.ts | 36 ++----------------- .../__tests__/launch_app_device.test.ts | 36 ++----------------- .../device/__tests__/stop_app_device.test.ts | 36 ++----------------- .../macos/__tests__/get_mac_app_path.test.ts | 36 ++----------------- .../macos/__tests__/launch_mac_app.test.ts | 36 ++----------------- .../macos/__tests__/stop_mac_app.test.ts | 36 ++----------------- 7 files changed, 14 insertions(+), 238 deletions(-) diff --git a/src/mcp/tools/device/__tests__/get_device_app_path.test.ts b/src/mcp/tools/device/__tests__/get_device_app_path.test.ts index bb136d8a..4da51753 100644 --- a/src/mcp/tools/device/__tests__/get_device_app_path.test.ts +++ b/src/mcp/tools/device/__tests__/get_device_app_path.test.ts @@ -7,40 +7,8 @@ import { } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, get_device_app_pathLogic } from '../get_device_app_path.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import { createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; - -const runLogic = async (logic: () => Promise) => { - const { result, run } = createMockToolHandlerContext(); - const response = await run(logic); - - if ( - response && - typeof response === 'object' && - 'content' in (response as Record) - ) { - return response as { - content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; - isError?: boolean; - nextStepParams?: unknown; - }; - } - - const text = result.text(); - const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; - const imageContent = result.attachments.map((attachment) => ({ - type: 'image' as const, - data: attachment.data, - mimeType: attachment.mimeType, - })); - - return { - content: [...textContent, ...imageContent], - isError: result.isError() ? true : undefined, - nextStepParams: result.nextStepParams, - attachments: result.attachments, - text, - }; -}; +import { runLogic } from '../../../../test-utils/test-helpers.ts'; + describe('get_device_app_path plugin', () => { beforeEach(() => { diff --git a/src/mcp/tools/device/__tests__/install_app_device.test.ts b/src/mcp/tools/device/__tests__/install_app_device.test.ts index 03f99491..0b89d7f7 100644 --- a/src/mcp/tools/device/__tests__/install_app_device.test.ts +++ b/src/mcp/tools/device/__tests__/install_app_device.test.ts @@ -3,40 +3,8 @@ import * as z from 'zod'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, install_app_deviceLogic } from '../install_app_device.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; - -const runLogic = async (logic: () => Promise) => { - const { result, run } = createMockToolHandlerContext(); - const response = await run(logic); - - if ( - response && - typeof response === 'object' && - 'content' in (response as Record) - ) { - return response as { - content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; - isError?: boolean; - nextStepParams?: unknown; - }; - } - - const text = result.text(); - const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; - const imageContent = result.attachments.map((attachment) => ({ - type: 'image' as const, - data: attachment.data, - mimeType: attachment.mimeType, - })); - - return { - content: [...textContent, ...imageContent], - isError: result.isError() ? true : undefined, - nextStepParams: result.nextStepParams, - attachments: result.attachments, - text, - }; -}; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; + describe('install_app_device plugin', () => { beforeEach(() => { diff --git a/src/mcp/tools/device/__tests__/launch_app_device.test.ts b/src/mcp/tools/device/__tests__/launch_app_device.test.ts index b3d94dc4..353d8cd3 100644 --- a/src/mcp/tools/device/__tests__/launch_app_device.test.ts +++ b/src/mcp/tools/device/__tests__/launch_app_device.test.ts @@ -6,40 +6,8 @@ import { } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, launch_app_deviceLogic } from '../launch_app_device.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; - -const runLogic = async (logic: () => Promise) => { - const { result, run } = createMockToolHandlerContext(); - const response = await run(logic); - - if ( - response && - typeof response === 'object' && - 'content' in (response as Record) - ) { - return response as { - content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; - isError?: boolean; - nextStepParams?: unknown; - }; - } - - const text = result.text(); - const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; - const imageContent = result.attachments.map((attachment) => ({ - type: 'image' as const, - data: attachment.data, - mimeType: attachment.mimeType, - })); - - return { - content: [...textContent, ...imageContent], - isError: result.isError() ? true : undefined, - nextStepParams: result.nextStepParams, - attachments: result.attachments, - text, - }; -}; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; + describe('launch_app_device plugin (device-shared)', () => { beforeEach(() => { diff --git a/src/mcp/tools/device/__tests__/stop_app_device.test.ts b/src/mcp/tools/device/__tests__/stop_app_device.test.ts index 28558f3f..d58c483e 100644 --- a/src/mcp/tools/device/__tests__/stop_app_device.test.ts +++ b/src/mcp/tools/device/__tests__/stop_app_device.test.ts @@ -3,40 +3,8 @@ import * as z from 'zod'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, stop_app_deviceLogic } from '../stop_app_device.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; - -const runLogic = async (logic: () => Promise) => { - const { result, run } = createMockToolHandlerContext(); - const response = await run(logic); - - if ( - response && - typeof response === 'object' && - 'content' in (response as Record) - ) { - return response as { - content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; - isError?: boolean; - nextStepParams?: unknown; - }; - } - - const text = result.text(); - const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; - const imageContent = result.attachments.map((attachment) => ({ - type: 'image' as const, - data: attachment.data, - mimeType: attachment.mimeType, - })); - - return { - content: [...textContent, ...imageContent], - isError: result.isError() ? true : undefined, - nextStepParams: result.nextStepParams, - attachments: result.attachments, - text, - }; -}; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; + describe('stop_app_device plugin', () => { beforeEach(() => { diff --git a/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts b/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts index 6ff269fd..cc8b4b27 100644 --- a/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts +++ b/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts @@ -8,40 +8,8 @@ import { } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, get_mac_app_pathLogic } from '../get_mac_app_path.ts'; -import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; - -const runLogic = async (logic: () => Promise) => { - const { result, run } = createMockToolHandlerContext(); - const response = await run(logic); - - if ( - response && - typeof response === 'object' && - 'content' in (response as Record) - ) { - return response as { - content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; - isError?: boolean; - nextStepParams?: unknown; - }; - } - - const text = result.text(); - const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; - const imageContent = result.attachments.map((attachment) => ({ - type: 'image' as const, - data: attachment.data, - mimeType: attachment.mimeType, - })); - - return { - content: [...textContent, ...imageContent], - isError: result.isError() ? true : undefined, - nextStepParams: result.nextStepParams, - attachments: result.attachments, - text, - }; -}; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; + describe('get_mac_app_path plugin', () => { beforeEach(() => { diff --git a/src/mcp/tools/macos/__tests__/launch_mac_app.test.ts b/src/mcp/tools/macos/__tests__/launch_mac_app.test.ts index cfacaeae..47dca2c0 100644 --- a/src/mcp/tools/macos/__tests__/launch_mac_app.test.ts +++ b/src/mcp/tools/macos/__tests__/launch_mac_app.test.ts @@ -5,40 +5,8 @@ import { createMockFileSystemExecutor, } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, launch_mac_appLogic } from '../launch_mac_app.ts'; -import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; - -const runLogic = async (logic: () => Promise) => { - const { result, run } = createMockToolHandlerContext(); - const response = await run(logic); - - if ( - response && - typeof response === 'object' && - 'content' in (response as Record) - ) { - return response as { - content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; - isError?: boolean; - nextStepParams?: unknown; - }; - } - - const text = result.text(); - const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; - const imageContent = result.attachments.map((attachment) => ({ - type: 'image' as const, - data: attachment.data, - mimeType: attachment.mimeType, - })); - - return { - content: [...textContent, ...imageContent], - isError: result.isError() ? true : undefined, - nextStepParams: result.nextStepParams, - attachments: result.attachments, - text, - }; -}; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; + describe('launch_mac_app plugin', () => { describe('Export Field Validation (Literal)', () => { diff --git a/src/mcp/tools/macos/__tests__/stop_mac_app.test.ts b/src/mcp/tools/macos/__tests__/stop_mac_app.test.ts index 55e31973..fc96ab18 100644 --- a/src/mcp/tools/macos/__tests__/stop_mac_app.test.ts +++ b/src/mcp/tools/macos/__tests__/stop_mac_app.test.ts @@ -1,39 +1,7 @@ import { describe, it, expect } from 'vitest'; import { schema, handler, stop_mac_appLogic } from '../stop_mac_app.ts'; -import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts'; - -const runLogic = async (logic: () => Promise) => { - const { result, run } = createMockToolHandlerContext(); - const response = await run(logic); - - if ( - response && - typeof response === 'object' && - 'content' in (response as Record) - ) { - return response as { - content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; - isError?: boolean; - nextStepParams?: unknown; - }; - } - - const text = result.text(); - const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : []; - const imageContent = result.attachments.map((attachment) => ({ - type: 'image' as const, - data: attachment.data, - mimeType: attachment.mimeType, - })); - - return { - content: [...textContent, ...imageContent], - isError: result.isError() ? true : undefined, - nextStepParams: result.nextStepParams, - attachments: result.attachments, - text, - }; -}; +import { allText, runLogic } from '../../../../test-utils/test-helpers.ts'; + describe('stop_mac_app plugin', () => { describe('Export Field Validation (Literal)', () => { From 64513a848e7d00c4fa3d3d68546d5d3afb24114a Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 9 Apr 2026 23:19:06 +0100 Subject: [PATCH 3/3] fix: remove dead re-exports, dead state check, add device next step params - Remove unused re-exports from build-settings.ts barrel - Remove unreachable "Available (WiFi)" check from isAvailableState - Add ctx.nextStepParams to list_devices for consistency with list_sims --- src/mcp/tools/device/build-settings.ts | 8 -------- src/mcp/tools/device/list_devices.ts | 6 +++++- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/mcp/tools/device/build-settings.ts b/src/mcp/tools/device/build-settings.ts index 30ac2416..f4213a05 100644 --- a/src/mcp/tools/device/build-settings.ts +++ b/src/mcp/tools/device/build-settings.ts @@ -1,13 +1,5 @@ import { XcodePlatform } from '../../../types/common.ts'; -export { - getBuildSettingsDestination, - extractAppPathFromBuildSettingsOutput, - resolveAppPathFromBuildSettings, -} from '../../../utils/app-path-resolver.ts'; - -export type { ResolveAppPathFromBuildSettingsParams } from '../../../utils/app-path-resolver.ts'; - export type DevicePlatform = 'iOS' | 'watchOS' | 'tvOS' | 'visionOS'; export function mapDevicePlatform(platform?: DevicePlatform): XcodePlatform { diff --git a/src/mcp/tools/device/list_devices.ts b/src/mcp/tools/device/list_devices.ts index 46bc24ac..fa0d6c14 100644 --- a/src/mcp/tools/device/list_devices.ts +++ b/src/mcp/tools/device/list_devices.ts @@ -15,7 +15,7 @@ const listDevicesSchema = z.object({}); type ListDevicesParams = z.infer; function isAvailableState(state: string): boolean { - return state === 'Available' || state === 'Available (WiFi)' || state === 'Connected'; + return state === 'Available' || state === 'Connected'; } const PLATFORM_KEYWORDS: Array<{ keywords: string[]; label: string }> = [ @@ -339,6 +339,10 @@ export async function list_devicesLogic( for (const event of events) { ctx.emit(event); } + ctx.nextStepParams = { + build_device: { scheme: 'YOUR_SCHEME', deviceId: 'UUID_FROM_ABOVE' }, + install_app_device: { deviceId: 'UUID_FROM_ABOVE', appPath: 'PATH_TO_APP' }, + }; }, { header: headerEvent,