diff --git a/AGENTS.md b/AGENTS.md index 11957ae7..108093fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ When reading issues: - ## Tools - GitHub CLI for issues/PRs +- CLI design note: do not rely on CLI session-default writes. CLI is intentionally deterministic for CI/scripting and should use explicit command arguments as the primary input surface. - When working on skill sources in `skills/`, use the `skill-creator` skill workflow. - After modifying any skill source, run `npx skill-check ` and address all errors/warnings before handoff. - diff --git a/docs/TOOLS-CLI.md b/docs/TOOLS-CLI.md index b0819971..9e453729 100644 --- a/docs/TOOLS-CLI.md +++ b/docs/TOOLS-CLI.md @@ -25,6 +25,7 @@ XcodeBuildMCP provides 75 canonical tools organized into 14 workflow groups. **Purpose**: Complete iOS development workflow for physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). (16 tools) - `build` - Build for device. +- `build-and-run` - Build, install, and launch on physical device. Preferred single-step run tool when defaults are set. - `clean` - Clean build products. - `discover-projects` - Scans a directory (defaults to workspace root) to find Xcode project (.xcodeproj) and workspace (.xcworkspace) files. Use when project/workspace path is unknown. - `get-app-bundle-id` - Extract bundle id from .app. diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 62b3a449..a6a1f536 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -23,6 +23,7 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov **Purpose**: Complete iOS development workflow for physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). (16 tools) - `build_device` - Build for device. +- `build_run_device` - Build, install, and launch on physical device. Preferred single-step run tool when defaults are set. - `clean` - Clean build products. - `discover_projs` - Scans a directory (defaults to workspace root) to find Xcode project (.xcodeproj) and workspace (.xcworkspace) files. Use when project/workspace path is unknown. - `get_app_bundle_id` - Extract bundle id from .app. diff --git a/example_projects/iOS_Calculator/.xcodebuildmcp/config.yaml b/example_projects/iOS_Calculator/.xcodebuildmcp/config.yaml index c0a9f17c..6dddfe2e 100644 --- a/example_projects/iOS_Calculator/.xcodebuildmcp/config.yaml +++ b/example_projects/iOS_Calculator/.xcodebuildmcp/config.yaml @@ -4,6 +4,7 @@ enabledWorkflows: - simulator - ui-automation - xcode-ide + - device debug: false sentryDisabled: false sessionDefaults: diff --git a/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/ContentView.swift b/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/ContentView.swift index e7bbee37..7d4c8eae 100644 --- a/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/ContentView.swift +++ b/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/ContentView.swift @@ -70,6 +70,8 @@ public struct ContentView: View { } private func handleButtonPress(_ button: String) { + print("[CalculatorApp] Button pressed: \(button)") + // Process input through the input handler inputHandler.handleInput(button) diff --git a/manifests/tools/build_run_device.yaml b/manifests/tools/build_run_device.yaml new file mode 100644 index 00000000..01558ec7 --- /dev/null +++ b/manifests/tools/build_run_device.yaml @@ -0,0 +1,18 @@ +id: build_run_device +module: mcp/tools/device/build_run_device +names: + mcp: build_run_device + cli: build-and-run +description: Build, install, and launch on physical device. Preferred single-step run tool when defaults are set. +predicates: + - hideWhenXcodeAgentMode +annotations: + title: Build Run Device + destructiveHint: false +nextSteps: + - label: Capture device logs + toolId: start_device_log_cap + priority: 1 + - label: Stop app on device + toolId: stop_app_device + priority: 2 diff --git a/manifests/tools/build_run_sim.yaml b/manifests/tools/build_run_sim.yaml index 420ea4f5..8b072f13 100644 --- a/manifests/tools/build_run_sim.yaml +++ b/manifests/tools/build_run_sim.yaml @@ -13,9 +13,12 @@ nextSteps: - label: Capture structured logs (app continues running) toolId: start_sim_log_cap priority: 1 + - label: Stop app in simulator + toolId: stop_app_sim + priority: 2 - label: Capture console + structured logs (app restarts) toolId: start_sim_log_cap - priority: 2 + priority: 3 - label: Launch app with logs in one step toolId: launch_app_logs_sim - priority: 3 + priority: 4 diff --git a/manifests/workflows/device.yaml b/manifests/workflows/device.yaml index 9dd6fdec..60c8c329 100644 --- a/manifests/workflows/device.yaml +++ b/manifests/workflows/device.yaml @@ -3,6 +3,7 @@ title: iOS Device Development description: Complete iOS development workflow for physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). tools: - build_device + - build_run_device - test_device - list_devices - install_app_device diff --git a/src/mcp/tools/device/__tests__/build_run_device.test.ts b/src/mcp/tools/device/__tests__/build_run_device.test.ts new file mode 100644 index 00000000..bebe15f4 --- /dev/null +++ b/src/mcp/tools/device/__tests__/build_run_device.test.ts @@ -0,0 +1,254 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import * as z from 'zod'; +import { + createMockCommandResponse, + createMockFileSystemExecutor, +} from '../../../../test-utils/mock-executors.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'; + +describe('build_run_device tool', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + 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({ 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']); + }); + + 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'); + }); + + 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 }, + }); + + 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('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('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' }, + }); + 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('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('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'); + }); +}); 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 231f07c7..67927372 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 @@ -131,7 +131,7 @@ describe('get_device_app_path plugin', () => { ], logPrefix: 'Get App Path', useShell: false, - opts: undefined, + opts: { cwd: '/path/to' }, }); }); @@ -186,7 +186,7 @@ describe('get_device_app_path plugin', () => { ], logPrefix: 'Get App Path', useShell: false, - opts: undefined, + opts: { cwd: '/path/to' }, }); }); @@ -240,7 +240,7 @@ describe('get_device_app_path plugin', () => { ], logPrefix: 'Get App Path', useShell: false, - opts: undefined, + opts: { cwd: '/path/to' }, }); }); @@ -378,7 +378,7 @@ describe('get_device_app_path plugin', () => { ], logPrefix: 'Get App Path', useShell: false, - opts: undefined, + opts: { cwd: '/path/to' }, }); }); diff --git a/src/mcp/tools/device/__tests__/list_devices.test.ts b/src/mcp/tools/device/__tests__/list_devices.test.ts index d3769a79..276ee0be 100644 --- a/src/mcp/tools/device/__tests__/list_devices.test.ts +++ b/src/mcp/tools/device/__tests__/list_devices.test.ts @@ -221,11 +221,12 @@ describe('list_devices plugin (device-shared)', () => { 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' }.\n", + 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' }, }, diff --git a/src/mcp/tools/device/__tests__/re-exports.test.ts b/src/mcp/tools/device/__tests__/re-exports.test.ts index def310cd..8da20225 100644 --- a/src/mcp/tools/device/__tests__/re-exports.test.ts +++ b/src/mcp/tools/device/__tests__/re-exports.test.ts @@ -9,6 +9,7 @@ import * as launchAppDevice from '../launch_app_device.ts'; import * as stopAppDevice from '../stop_app_device.ts'; import * as listDevices from '../list_devices.ts'; import * as installAppDevice from '../install_app_device.ts'; +import * as buildRunDevice from '../build_run_device.ts'; describe('device tool named exports', () => { describe('launch_app_device exports', () => { @@ -39,12 +40,20 @@ describe('device tool named exports', () => { }); }); + describe('build_run_device exports', () => { + it('should export schema and handler', () => { + expect(buildRunDevice.schema).toBeDefined(); + expect(typeof buildRunDevice.handler).toBe('function'); + }); + }); + describe('All exports validation', () => { const modules = [ { mod: launchAppDevice, name: 'launch_app_device' }, { mod: stopAppDevice, name: 'stop_app_device' }, { mod: listDevices, name: 'list_devices' }, { mod: installAppDevice, name: 'install_app_device' }, + { mod: buildRunDevice, name: 'build_run_device' }, ]; it('should have callable handlers', () => { diff --git a/src/mcp/tools/device/build-settings.ts b/src/mcp/tools/device/build-settings.ts new file mode 100644 index 00000000..9a6f64fd --- /dev/null +++ b/src/mcp/tools/device/build-settings.ts @@ -0,0 +1,107 @@ +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; + } + + return path.resolve(process.cwd(), pathValue); +} + +export type DevicePlatform = 'iOS' | 'watchOS' | 'tvOS' | 'visionOS'; + +export function mapDevicePlatform(platform?: DevicePlatform): XcodePlatform { + switch (platform) { + case 'watchOS': + return XcodePlatform.watchOS; + case 'tvOS': + return XcodePlatform.tvOS; + case 'visionOS': + return XcodePlatform.visionOS; + case 'iOS': + case undefined: + default: + 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_run_device.ts b/src/mcp/tools/device/build_run_device.ts new file mode 100644 index 00000000..a8f8d9ba --- /dev/null +++ b/src/mcp/tools/device/build_run_device.ts @@ -0,0 +1,235 @@ +/** + * Device Shared Plugin: Build and Run Device (Unified) + * + * Builds, installs, and launches an app on a physical Apple device. + */ + +import * as z from 'zod'; +import type { ToolResponse, SharedBuildParams } from '../../../types/common.ts'; +import { XcodePlatform } from '../../../types/common.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 { + getDefaultCommandExecutor, + getDefaultFileSystemExecutor, +} from '../../../utils/execution/index.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} 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'; + +const baseSchemaObject = z.object({ + projectPath: z.string().optional().describe('Path to the .xcodeproj file'), + workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), + scheme: z.string().describe('The scheme to build and run'), + deviceId: z.string().describe('UDID of the device (obtained from list_devices)'), + platform: z.enum(['iOS', 'watchOS', 'tvOS', 'visionOS']).optional().describe('default: iOS'), + configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'), + derivedDataPath: z.string().optional(), + extraArgs: z.array(z.string()).optional(), + preferXcodebuild: z.boolean().optional(), + env: z + .record(z.string(), z.string()) + .optional() + .describe('Environment variables to pass to the launched app (as key-value dictionary)'), +}); + +const buildRunDeviceSchema = z.preprocess( + nullifyEmptyStrings, + baseSchemaObject + .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, { + message: 'Either projectPath or workspacePath is required.', + }) + .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), { + message: 'projectPath and workspacePath are mutually exclusive. Provide only one.', + }), +); + +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.`; +} + +export async function build_run_deviceLogic( + params: BuildRunDeviceParams, + executor: CommandExecutor, + fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), +): Promise { + 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; + } + + 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 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, + ); + } + } 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, + ); + } + + const launchResult = await launch_app_deviceLogic( + { + deviceId: params.deviceId, + bundleId, + env: params.env, + }, + executor, + fileSystemExecutor, + ); + + 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, + ); + + return { + content: [ + { + type: 'text', + text: successText, + }, + ], + nextStepParams: { + ...launchNextSteps, + start_device_log_cap: { + deviceId: params.deviceId, + bundleId, + }, + }, + isError: false, + }; +} + +const publicSchemaObject = baseSchemaObject.omit({ + projectPath: true, + workspacePath: true, + scheme: true, + deviceId: true, + platform: true, + configuration: true, + derivedDataPath: true, + preferXcodebuild: true, +} as const); + +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, +}); + +export const handler = createSessionAwareTool({ + internalSchema: buildRunDeviceSchema as unknown as z.ZodType, + logicFunction: build_run_deviceLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['scheme', 'deviceId'], message: 'Provide scheme and deviceId' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + ], + exclusivePairs: [['projectPath', 'workspacePath']], +}); diff --git a/src/mcp/tools/device/get_device_app_path.ts b/src/mcp/tools/device/get_device_app_path.ts index 2cad6f51..c613a52d 100644 --- a/src/mcp/tools/device/get_device_app_path.ts +++ b/src/mcp/tools/device/get_device_app_path.ts @@ -7,7 +7,6 @@ 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 { createTextResponse } from '../../../utils/responses/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; @@ -17,6 +16,7 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; +import { mapDevicePlatform, resolveAppPathFromBuildSettings } from './build-settings.ts'; // Unified schema: XOR between projectPath and workspacePath, sharing common options const baseOptions = { @@ -57,76 +57,22 @@ export async function get_device_app_pathLogic( params: GetDeviceAppPathParams, executor: CommandExecutor, ): Promise { - const platformMap = { - iOS: XcodePlatform.iOS, - watchOS: XcodePlatform.watchOS, - tvOS: XcodePlatform.tvOS, - visionOS: XcodePlatform.visionOS, - }; - - const platform = platformMap[params.platform ?? 'iOS']; + const platform = mapDevicePlatform(params.platform); const configuration = params.configuration ?? 'Debug'; log('info', `Getting app path for scheme ${params.scheme} on platform ${platform}`); 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); - - // Map platform to destination string - const destinationMap: Record = { - [XcodePlatform.iOS]: 'generic/platform=iOS', - [XcodePlatform.watchOS]: 'generic/platform=watchOS', - [XcodePlatform.tvOS]: 'generic/platform=tvOS', - [XcodePlatform.visionOS]: 'generic/platform=visionOS', - }; - - const destinationString = destinationMap[platform]; - if (!destinationString) { - return createTextResponse(`Unsupported platform: ${platform}`, true); - } - - command.push('-destination', destinationString); - - // Execute the command directly - const result = await executor(command, 'Get App Path', false); - - if (!result.success) { - return createTextResponse(`Failed to get app path: ${result.error}`, true); - } - - if (!result.output) { - return createTextResponse('Failed to extract build settings output from the result.', true); - } - - const buildSettingsOutput = result.output; - const builtProductsDirMatch = buildSettingsOutput.match(/^\s*BUILT_PRODUCTS_DIR\s*=\s*(.+)$/m); - const fullProductNameMatch = buildSettingsOutput.match(/^\s*FULL_PRODUCT_NAME\s*=\s*(.+)$/m); - - if (!builtProductsDirMatch || !fullProductNameMatch) { - return createTextResponse( - 'Failed to extract app path from build settings. Make sure the app has been built first.', - true, - ); - } - - const builtProductsDir = builtProductsDirMatch[1].trim(); - const fullProductName = fullProductNameMatch[1].trim(); - const appPath = `${builtProductsDir}/${fullProductName}`; + const appPath = await resolveAppPathFromBuildSettings( + { + projectPath: params.projectPath, + workspacePath: params.workspacePath, + scheme: params.scheme, + configuration, + platform, + }, + executor, + ); return { content: [ @@ -144,6 +90,18 @@ export async function get_device_app_pathLogic( } 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); } } diff --git a/src/mcp/tools/device/list_devices.ts b/src/mcp/tools/device/list_devices.ts index d685a6c8..22b1812f 100644 --- a/src/mcp/tools/device/list_devices.ts +++ b/src/mcp/tools/device/list_devices.ts @@ -21,11 +21,37 @@ 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'; +} + +function getPlatformLabel(platformIdentifier?: string): string { + const platformId = platformIdentifier?.toLowerCase() ?? ''; + + if (platformId.includes('ios') || platformId.includes('iphone')) { + return 'iOS'; + } + if (platformId.includes('ipad')) { + return 'iPadOS'; + } + if (platformId.includes('watch')) { + return 'watchOS'; + } + if (platformId.includes('tv') || platformId.includes('apple tv')) { + return 'tvOS'; + } + if (platformId.includes('vision')) { + return 'visionOS'; + } + + return 'Unknown'; +} + /** * Business logic for listing connected devices */ export async function list_devicesLogic( - params: ListDevicesParams, + _params: ListDevicesParams, executor: CommandExecutor, pathDeps?: { tmpdir?: () => string; join?: (...paths: string[]) => string }, fsDeps?: { @@ -216,20 +242,7 @@ export async function list_devicesLogic( continue; } - // Determine platform from platformIdentifier - let platform = 'Unknown'; - const platformId = device.deviceProperties?.platformIdentifier?.toLowerCase() ?? ''; - if (platformId.includes('ios') || platformId.includes('iphone')) { - platform = 'iOS'; - } else if (platformId.includes('ipad')) { - platform = 'iPadOS'; - } else if (platformId.includes('watch')) { - platform = 'watchOS'; - } else if (platformId.includes('tv') || platformId.includes('apple tv')) { - platform = 'tvOS'; - } else if (platformId.includes('vision')) { - platform = 'visionOS'; - } + const platform = getPlatformLabel(device.deviceProperties?.platformIdentifier); // Determine connection state const pairingState = device.connectionProperties?.pairingState ?? ''; @@ -328,9 +341,7 @@ export async function list_devicesLogic( responseText += 'For simulators, use the list_sims tool instead.\n'; } else { // Group devices by availability status - const availableDevices = uniqueDevices.filter( - (d) => d.state === 'Available' || d.state === 'Available (WiFi)' || d.state === 'Connected', - ); + 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'); @@ -376,9 +387,7 @@ export async function list_devicesLogic( } // Add next steps - const availableDevicesExist = uniqueDevices.some( - (d) => d.state === 'Available' || d.state === 'Available (WiFi)' || d.state === 'Connected', - ); + const availableDevicesExist = uniqueDevices.some((d) => isAvailableState(d.state)); let nextStepParams: Record> | undefined; @@ -386,9 +395,12 @@ export async function list_devicesLogic( 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' }, }; diff --git a/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts b/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts index c1fa0f4b..ccdbfb2c 100644 --- a/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts +++ b/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts @@ -141,6 +141,9 @@ describe('start_device_log_cap plugin', () => { mockFileSystemExecutor, ); + expect(result.content[0].text).toContain( + 'Do not call launch_app_device during this capture session', + ); expect(result.content[0].text).toContain('Interact with your app'); const responseText = String(result.content[0].text); const sessionIdMatch = responseText.match(/Session ID: ([a-f0-9-]{36})/); diff --git a/src/mcp/tools/logging/start_device_log_cap.ts b/src/mcp/tools/logging/start_device_log_cap.ts index 1f8c2ee2..cf2fa0d5 100644 --- a/src/mcp/tools/logging/start_device_log_cap.ts +++ b/src/mcp/tools/logging/start_device_log_cap.ts @@ -649,7 +649,7 @@ export async function start_device_log_capLogic( content: [ { type: 'text', - text: `āœ… Device log capture started successfully\n\nSession ID: ${sessionId}\n\nNote: The app has been launched on the device with console output capture enabled.\n\nInteract with your app on the device, then stop capture to retrieve logs.`, + text: `āœ… Device log capture started successfully\n\nSession ID: ${sessionId}\n\nNote: The app has been launched on the device with console output capture enabled.\nDo not call launch_app_device during this capture session; relaunching can interrupt captured output.\n\nInteract with your app on the device, then stop capture to retrieve logs.`, }, ], nextStepParams: { diff --git a/src/mcp/tools/project-discovery/get_app_bundle_id.ts b/src/mcp/tools/project-discovery/get_app_bundle_id.ts index 2da85cad..60c81738 100644 --- a/src/mcp/tools/project-discovery/get_app_bundle_id.ts +++ b/src/mcp/tools/project-discovery/get_app_bundle_id.ts @@ -12,6 +12,7 @@ import type { CommandExecutor } from '../../../utils/command.ts'; import { getDefaultFileSystemExecutor, getDefaultCommandExecutor } from '../../../utils/command.ts'; import type { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { extractBundleIdFromAppPath } from '../../../utils/bundle-id.ts'; // Define schema as ZodObject const getAppBundleIdSchema = z.object({ @@ -21,17 +22,6 @@ const getAppBundleIdSchema = z.object({ // Use z.infer for type safety type GetAppBundleIdParams = z.infer; -/** - * Sync wrapper for CommandExecutor to handle synchronous commands - */ -async function executeSyncCommand(command: string, executor: CommandExecutor): Promise { - const result = await executor(['/bin/sh', '-c', command], 'Bundle ID Extraction'); - if (!result.success) { - throw new Error(result.error ?? 'Command failed'); - } - return result.output || ''; -} - /** * Business logic for extracting bundle ID from app. * Separated for testing and reusability. @@ -62,21 +52,11 @@ export async function get_app_bundle_idLogic( let bundleId; try { - bundleId = await executeSyncCommand( - `defaults read "${appPath}/Info" CFBundleIdentifier`, - executor, + bundleId = await extractBundleIdFromAppPath(appPath, executor); + } catch (innerError) { + throw new Error( + `Could not extract bundle ID from Info.plist: ${innerError instanceof Error ? innerError.message : String(innerError)}`, ); - } catch { - try { - bundleId = await executeSyncCommand( - `/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "${appPath}/Info.plist"`, - executor, - ); - } catch (innerError) { - throw new Error( - `Could not extract bundle ID from Info.plist: ${innerError instanceof Error ? innerError.message : String(innerError)}`, - ); - } } log('info', `Extracted app bundle ID: ${bundleId}`); diff --git a/src/mcp/tools/simulator/__tests__/list_sims.test.ts b/src/mcp/tools/simulator/__tests__/list_sims.test.ts index 4d93b43e..bc3bc7f6 100644 --- a/src/mcp/tools/simulator/__tests__/list_sims.test.ts +++ b/src/mcp/tools/simulator/__tests__/list_sims.test.ts @@ -151,7 +151,8 @@ describe('list_sims tool', () => { iOS 17.0: - iPhone 15 (test-uuid-123) -Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName).`, +Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName). +Before running build/run/test/UI automation tools, set the desired simulator identifier in session defaults.`, }, ], nextStepParams: { @@ -211,7 +212,8 @@ Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FR iOS 17.0: - iPhone 15 (test-uuid-123) [Booted] -Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName).`, +Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName). +Before running build/run/test/UI automation tools, set the desired simulator identifier in session defaults.`, }, ], nextStepParams: { @@ -277,7 +279,8 @@ iOS 18.6: iOS 26.0: - iPhone 17 Pro (text-uuid-456) -Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName).`, +Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName). +Before running build/run/test/UI automation tools, set the desired simulator identifier in session defaults.`, }, ], nextStepParams: { @@ -348,7 +351,8 @@ Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FR iOS 17.0: - iPhone 15 (test-uuid-456) -Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName).`, +Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName). +Before running build/run/test/UI automation tools, set the desired simulator identifier in session defaults.`, }, ], nextStepParams: { diff --git a/src/mcp/tools/simulator/build_run_sim.ts b/src/mcp/tools/simulator/build_run_sim.ts index df8574a3..08bd0136 100644 --- a/src/mcp/tools/simulator/build_run_sim.ts +++ b/src/mcp/tools/simulator/build_run_sim.ts @@ -497,6 +497,7 @@ export async function build_run_simLogic( { simulatorId, bundleId }, { simulatorId, bundleId, captureConsole: true }, ], + stop_app_sim: { simulatorId, bundleId }, launch_app_logs_sim: { simulatorId, bundleId }, }, isError: false, diff --git a/src/mcp/tools/simulator/list_sims.ts b/src/mcp/tools/simulator/list_sims.ts index 24f13f30..2d88b9f8 100644 --- a/src/mcp/tools/simulator/list_sims.ts +++ b/src/mcp/tools/simulator/list_sims.ts @@ -197,7 +197,9 @@ export async function list_simsLogic( } responseText += - "Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName)."; + "Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName).\n"; + responseText += + 'Before running build/run/test/UI automation tools, set the desired simulator identifier in session defaults.'; return { content: [ diff --git a/src/runtime/__tests__/tool-invoker.test.ts b/src/runtime/__tests__/tool-invoker.test.ts index 5aae7aae..cb3da448 100644 --- a/src/runtime/__tests__/tool-invoker.test.ts +++ b/src/runtime/__tests__/tool-invoker.test.ts @@ -375,6 +375,67 @@ describe('DefaultToolInvoker next steps post-processing', () => { ]); }); + it('preserves daemon-provided next-step params when nextStepParams are already consumed', async () => { + daemonClientMock.invokeTool.mockResolvedValue({ + content: [{ type: 'text', text: 'ok' }], + nextSteps: [ + { + tool: 'stop_sim_log_cap', + label: 'Stop capture and retrieve logs', + params: { logSessionId: 'session-123' }, + priority: 1, + }, + ], + } satisfies ToolResponse); + + const catalog = createToolCatalog([ + makeTool({ + id: 'start_sim_log_cap', + cliName: 'start-simulator-log-capture', + mcpName: 'start_sim_log_cap', + workflow: 'logging', + stateful: true, + nextStepTemplates: [ + { + label: 'Stop capture and retrieve logs', + toolId: 'stop_sim_log_cap', + priority: 1, + }, + ], + handler: vi.fn().mockResolvedValue(textResponse('start')), + }), + makeTool({ + id: 'stop_sim_log_cap', + cliName: 'stop-simulator-log-capture', + mcpName: 'stop_sim_log_cap', + workflow: 'logging', + stateful: true, + handler: vi.fn().mockResolvedValue(textResponse('stop')), + }), + ]); + + const invoker = new DefaultToolInvoker(catalog); + const response = await invoker.invoke( + 'start-simulator-log-capture', + {}, + { + runtime: 'cli', + socketPath: '/tmp/xcodebuildmcp.sock', + }, + ); + + expect(response.nextSteps).toEqual([ + { + tool: 'stop_sim_log_cap', + label: 'Stop capture and retrieve logs', + params: { logSessionId: 'session-123' }, + priority: 1, + workflow: 'logging', + cliTool: 'stop-simulator-log-capture', + }, + ]); + }); + it('overrides unresolved template placeholders with dynamic next-step params', async () => { const directHandler = vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'ok' }], diff --git a/src/runtime/tool-invoker.ts b/src/runtime/tool-invoker.ts index a0023b6a..3c34d5ac 100644 --- a/src/runtime/tool-invoker.ts +++ b/src/runtime/tool-invoker.ts @@ -147,13 +147,14 @@ export function postProcessToolResponse(params: { response: ToolResponse; catalog: ToolCatalog; runtime: InvokeOptions['runtime']; + applyTemplateNextSteps?: boolean; }): ToolResponse { - const { tool, response, catalog, runtime } = params; + const { tool, response, catalog, runtime, applyTemplateNextSteps = true } = params; const templateSteps = buildTemplateNextSteps(tool, catalog); const withTemplates = - templateSteps.length > 0 + applyTemplateNextSteps && templateSteps.length > 0 ? { ...response, nextSteps: mergeTemplateAndResponseNextSteps(templateSteps, response.nextStepParams), @@ -303,6 +304,7 @@ export class DefaultToolInvoker implements ToolInvoker { return postProcessToolResponse({ ...context.postProcessParams, response, + applyTemplateNextSteps: false, }); } catch (error) { log( diff --git a/src/smoke-tests/__tests__/cli-surface.test.ts b/src/smoke-tests/__tests__/cli-surface.test.ts index cfbea240..86bc3322 100644 --- a/src/smoke-tests/__tests__/cli-surface.test.ts +++ b/src/smoke-tests/__tests__/cli-surface.test.ts @@ -85,7 +85,7 @@ describe('CLI Surface (e2e)', () => { { workflow: 'simulator', tool: 'list-sims', expected: '--help' }, { workflow: 'device', tool: 'build', expected: '--scheme' }, { workflow: 'swift-package', tool: 'build', expected: '--package-path' }, - { workflow: 'project-discovery', tool: 'list-schemes', expected: '--project-path' }, + { workflow: 'project-discovery', tool: 'list-schemes', expected: 'List Xcode schemes.' }, { workflow: 'ui-automation', tool: 'tap', expected: '--simulator-id' }, { workflow: 'utilities', tool: 'clean', expected: '--scheme' }, ]; diff --git a/src/smoke-tests/__tests__/e2e-mcp-device-macos.test.ts b/src/smoke-tests/__tests__/e2e-mcp-device-macos.test.ts index 4642665a..e72fb2a0 100644 --- a/src/smoke-tests/__tests__/e2e-mcp-device-macos.test.ts +++ b/src/smoke-tests/__tests__/e2e-mcp-device-macos.test.ts @@ -1,18 +1,27 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { promises as fs } from 'node:fs'; import { createMcpTestHarness, type McpTestHarness } from '../mcp-test-harness.ts'; import { isErrorResponse, expectContent } from '../test-helpers.ts'; let harness: McpTestHarness; beforeAll(async () => { + await fs.mkdir('/tmp/build/MyApp.app', { recursive: true }); + await fs.writeFile('/tmp/build/MyApp.app/Info.plist', 'plist'); + harness = await createMcpTestHarness({ commandResponses: { + 'xcodebuild -showBuildSettings': { + success: true, + output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyApp.app\n', + }, xcodebuild: { success: true, output: 'Build Succeeded' }, devicectl: { success: true, output: '{}' }, 'xctrace list devices': { success: true, output: 'No devices found.' }, open: { success: true, output: '' }, kill: { success: true, output: '' }, pkill: { success: true, output: '' }, + '/bin/sh': { success: true, output: 'io.sentry.MyApp' }, 'defaults read': { success: true, output: 'io.sentry.MyApp' }, PlistBuddy: { success: true, output: 'io.sentry.MyApp' }, xcresulttool: { success: true, output: '{}' }, @@ -47,6 +56,37 @@ describe('MCP Device and macOS Tool Invocation (e2e)', () => { expect(commandStrs.some((c) => c.includes('xcodebuild') && c.includes('MyApp'))).toBe(true); }); + it('build_run_device captures build, install, and launch commands', async () => { + await harness.client.callTool({ + name: 'session_set_defaults', + arguments: { + scheme: 'MyApp', + projectPath: '/path/to/MyApp.xcodeproj', + deviceId: 'AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE', + }, + }); + + harness.resetCapturedCommands(); + const result = await harness.client.callTool({ + name: 'build_run_device', + arguments: {}, + }); + + expectContent(result); + + const commandStrs = harness.capturedCommands.map((c) => c.command.join(' ')); + expect(commandStrs.some((c) => c.includes('xcodebuild') && c.includes('build'))).toBe(true); + expect( + commandStrs.some((c) => c.includes('xcodebuild') && c.includes('-showBuildSettings')), + ).toBe(true); + + const hasInstall = commandStrs.some((c) => c.includes('devicectl') && c.includes('install')); + const hasLaunch = commandStrs.some((c) => c.includes('devicectl') && c.includes('launch')); + if (!hasInstall || !hasLaunch) { + throw new Error(`Missing expected device commands. Captured: ${commandStrs.join(' || ')}`); + } + }); + it('test_device captures xcodebuild test command', async () => { await harness.client.callTool({ name: 'session_set_defaults', diff --git a/src/utils/bundle-id.ts b/src/utils/bundle-id.ts new file mode 100644 index 00000000..a7423fc6 --- /dev/null +++ b/src/utils/bundle-id.ts @@ -0,0 +1,23 @@ +import type { CommandExecutor } from './command.ts'; + +async function executeSyncCommand(command: string, executor: CommandExecutor): Promise { + const result = await executor(['/bin/sh', '-c', command], 'Bundle ID Extraction'); + if (!result.success) { + throw new Error(result.error ?? 'Command failed'); + } + return result.output || ''; +} + +export async function extractBundleIdFromAppPath( + appPath: string, + executor: CommandExecutor, +): Promise { + try { + return await executeSyncCommand(`defaults read "${appPath}/Info" CFBundleIdentifier`, executor); + } catch { + return await executeSyncCommand( + `/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "${appPath}/Info.plist"`, + executor, + ); + } +}