UNPKG

xcodemcp

Version:

Model Context Protocol server for Xcode build automation and log parsing

879 lines (852 loc) 65.9 kB
import { stat } from 'fs/promises'; import { readdir } from 'fs/promises'; import { join } from 'path'; import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; import { JXAExecutor } from '../utils/JXAExecutor.js'; import { BuildLogParser } from '../utils/BuildLogParser.js'; import { PathValidator } from '../utils/PathValidator.js'; import { ErrorHelper } from '../utils/ErrorHelper.js'; import { ParameterNormalizer } from '../utils/ParameterNormalizer.js'; import { Logger } from '../utils/Logger.js'; import { XCResultParser } from '../utils/XCResultParser.js'; export class BuildTools { static async build(projectPath, schemeName, destination = null, openProject) { const validationError = PathValidator.validateProjectPath(projectPath); if (validationError) return validationError; await openProject(projectPath); // Normalize the scheme name for better matching const normalizedSchemeName = ParameterNormalizer.normalizeSchemeName(schemeName); const setSchemeScript = ` (function() { const app = Application('Xcode'); const workspace = app.activeWorkspaceDocument(); if (!workspace) throw new Error('No active workspace'); const schemes = workspace.schemes(); const schemeNames = schemes.map(scheme => scheme.name()); // Try exact match first let targetScheme = schemes.find(scheme => scheme.name() === ${JSON.stringify(normalizedSchemeName)}); // If not found, try original name if (!targetScheme) { targetScheme = schemes.find(scheme => scheme.name() === ${JSON.stringify(schemeName)}); } if (!targetScheme) { throw new Error('Scheme not found. Available: ' + JSON.stringify(schemeNames)); } workspace.activeScheme = targetScheme; return 'Scheme set to ' + targetScheme.name(); })() `; try { await JXAExecutor.execute(setSchemeScript); } catch (error) { const enhancedError = ErrorHelper.parseCommonErrors(error); if (enhancedError) { return { content: [{ type: 'text', text: enhancedError }] }; } const errorMessage = error instanceof Error ? error.message : String(error); if (errorMessage.includes('not found')) { try { // Get available schemes const availableSchemes = await this._getAvailableSchemes(); // Try to find a close match with fuzzy matching const bestMatch = ParameterNormalizer.findBestMatch(schemeName, availableSchemes); let message = `❌ Scheme '${schemeName}' not found\n\nAvailable schemes:\n`; availableSchemes.forEach(scheme => { if (scheme === bestMatch) { message += ` • ${scheme} ← Did you mean this?\n`; } else { message += ` • ${scheme}\n`; } }); return { content: [{ type: 'text', text: message }] }; } catch { return { content: [{ type: 'text', text: ErrorHelper.createErrorWithGuidance(`Scheme '${schemeName}' not found`, ErrorHelper.getSchemeNotFoundGuidance(schemeName)) }] }; } } return { content: [{ type: 'text', text: `Failed to set scheme '${schemeName}': ${errorMessage}` }] }; } if (destination) { // Normalize the destination name for better matching const normalizedDestination = ParameterNormalizer.normalizeDestinationName(destination); const setDestinationScript = ` (function() { const app = Application('Xcode'); const workspace = app.activeWorkspaceDocument(); if (!workspace) throw new Error('No active workspace'); const destinations = workspace.runDestinations(); const destinationNames = destinations.map(dest => dest.name()); // Try exact match first let targetDestination = destinations.find(dest => dest.name() === ${JSON.stringify(normalizedDestination)}); // If not found, try original name if (!targetDestination) { targetDestination = destinations.find(dest => dest.name() === ${JSON.stringify(destination)}); } if (!targetDestination) { throw new Error('Destination not found. Available: ' + JSON.stringify(destinationNames)); } workspace.activeRunDestination = targetDestination; return 'Destination set to ' + targetDestination.name(); })() `; try { await JXAExecutor.execute(setDestinationScript); } catch (error) { const enhancedError = ErrorHelper.parseCommonErrors(error); if (enhancedError) { return { content: [{ type: 'text', text: enhancedError }] }; } const errorMessage = error instanceof Error ? error.message : String(error); if (errorMessage.includes('not found')) { try { // Extract available destinations from error message if present let availableDestinations = []; if (errorMessage.includes('Available:')) { const availablePart = errorMessage.split('Available: ')[1]; // Find the JSON array part const jsonMatch = availablePart?.match(/\[.*?\]/); if (jsonMatch) { try { availableDestinations = JSON.parse(jsonMatch[0]); } catch { availableDestinations = await this._getAvailableDestinations(); } } } else { availableDestinations = await this._getAvailableDestinations(); } // Try to find a close match with fuzzy matching const bestMatch = ParameterNormalizer.findBestMatch(destination, availableDestinations); let guidance = ErrorHelper.getDestinationNotFoundGuidance(destination, availableDestinations); if (bestMatch && bestMatch !== destination) { guidance += `\n• Did you mean '${bestMatch}'?`; } return { content: [{ type: 'text', text: ErrorHelper.createErrorWithGuidance(`Destination '${destination}' not found`, guidance) }] }; } catch { return { content: [{ type: 'text', text: ErrorHelper.createErrorWithGuidance(`Destination '${destination}' not found`, ErrorHelper.getDestinationNotFoundGuidance(destination)) }] }; } } return { content: [{ type: 'text', text: `Failed to set destination '${destination}': ${errorMessage}` }] }; } } const buildScript = ` (function() { const app = Application('Xcode'); const workspace = app.activeWorkspaceDocument(); if (!workspace) throw new Error('No active workspace'); workspace.build(); return 'Build started'; })() `; const buildStartTime = Date.now(); try { await JXAExecutor.execute(buildScript); // Check for and handle "replace existing build" alert await this._handleReplaceExistingBuildAlert(); } catch (error) { const enhancedError = ErrorHelper.parseCommonErrors(error); if (enhancedError) { return { content: [{ type: 'text', text: enhancedError }] }; } const errorMessage = error instanceof Error ? error.message : String(error); return { content: [{ type: 'text', text: `Failed to start build: ${errorMessage}` }] }; } Logger.info('Waiting for new build log to appear after build start...'); let attempts = 0; let newLog = null; const initialWaitAttempts = 3600; // 1 hour max to wait for build log while (attempts < initialWaitAttempts) { const currentLog = await BuildLogParser.getLatestBuildLog(projectPath); if (currentLog) { const logTime = currentLog.mtime.getTime(); const buildTime = buildStartTime; Logger.debug(`Checking log: ${currentLog.path}, log time: ${logTime}, build time: ${buildTime}, diff: ${logTime - buildTime}ms`); if (logTime > buildTime) { newLog = currentLog; Logger.info(`Found new build log created after build start: ${newLog.path}`); break; } } else { Logger.debug(`No build log found yet, attempt ${attempts + 1}/${initialWaitAttempts}`); } await new Promise(resolve => setTimeout(resolve, 1000)); attempts++; } if (!newLog) { return { content: [{ type: 'text', text: ErrorHelper.createErrorWithGuidance(`Build started but no new build log appeared within ${initialWaitAttempts} seconds`, ErrorHelper.getBuildLogNotFoundGuidance()) }] }; } Logger.info(`Monitoring build completion for log: ${newLog.path}`); attempts = 0; const maxAttempts = 3600; // 1 hour max for build completion let lastLogSize = 0; let stableCount = 0; while (attempts < maxAttempts) { try { const logStats = await stat(newLog.path); const currentLogSize = logStats.size; if (currentLogSize === lastLogSize) { stableCount++; if (stableCount >= 1) { Logger.debug(`Log stable for ${stableCount}s, trying to parse...`); const results = await BuildLogParser.parseBuildLog(newLog.path); Logger.debug(`Parse result has ${results.errors.length} errors, ${results.warnings.length} warnings`); const isParseFailure = results.errors.some(error => typeof error === 'string' && error.includes('XCLogParser failed to parse the build log.')); if (results && !isParseFailure) { Logger.info(`Build completed, log parsed successfully: ${newLog.path}`); break; } } } else { lastLogSize = currentLogSize; stableCount = 0; } } catch (error) { const currentLog = await BuildLogParser.getLatestBuildLog(projectPath); if (currentLog && currentLog.path !== newLog.path && currentLog.mtime.getTime() > buildStartTime) { Logger.debug(`Build log changed to: ${currentLog.path}`); newLog = currentLog; lastLogSize = 0; stableCount = 0; } } await new Promise(resolve => setTimeout(resolve, 1000)); attempts++; } if (attempts >= maxAttempts) { return { content: [{ type: 'text', text: `Build timed out after ${maxAttempts} seconds` }] }; } const results = await BuildLogParser.parseBuildLog(newLog.path); let message = ''; const schemeInfo = schemeName ? ` for scheme '${schemeName}'` : ''; const destInfo = destination ? ` and destination '${destination}'` : ''; Logger.info(`Build completed${schemeInfo}${destInfo} - ${results.errors.length} errors, ${results.warnings.length} warnings, status: ${results.buildStatus || 'unknown'}`); // Handle stopped/interrupted builds if (results.buildStatus === 'stopped') { message = `⏹️ BUILD INTERRUPTED${schemeInfo}${destInfo}\n\nThe build was stopped or interrupted before completion.\n\n💡 This may happen when:\n • The build was cancelled manually\n • Xcode was closed during the build\n • System resources were exhausted\n\nTry running the build again to complete it.`; return { content: [{ type: 'text', text: message }] }; } if (results.errors.length > 0) { message = `❌ BUILD FAILED${schemeInfo}${destInfo} (${results.errors.length} errors)\n\nERRORS:\n`; results.errors.forEach(error => { message += ` • ${error}\n`; Logger.error('Build error:', error); }); throw new McpError(ErrorCode.InternalError, message); } else if (results.warnings.length > 0) { message = `⚠️ BUILD COMPLETED WITH WARNINGS${schemeInfo}${destInfo} (${results.warnings.length} warnings)\n\nWARNINGS:\n`; results.warnings.forEach(warning => { message += ` • ${warning}\n`; Logger.warn('Build warning:', warning); }); } else { message = `✅ BUILD SUCCESSFUL${schemeInfo}${destInfo}`; } return { content: [{ type: 'text', text: message }] }; } static async clean(projectPath, openProject) { const validationError = PathValidator.validateProjectPath(projectPath); if (validationError) return validationError; await openProject(projectPath); const script = ` (function() { const app = Application('Xcode'); const workspace = app.activeWorkspaceDocument(); if (!workspace) throw new Error('No active workspace'); const actionResult = workspace.clean(); while (true) { if (actionResult.completed()) { break; } delay(0.5); } return \`Clean completed. Result ID: \${actionResult.id()}\`; })() `; const result = await JXAExecutor.execute(script); return { content: [{ type: 'text', text: result }] }; } static async test(projectPath, commandLineArguments = [], openProject) { const validationError = PathValidator.validateProjectPath(projectPath); if (validationError) return validationError; await openProject(projectPath); // Get initial xcresult files to detect new ones const initialXCResults = await this._findXCResultFiles(projectPath); const testStartTime = Date.now(); Logger.info(`Test start time: ${new Date(testStartTime).toISOString()}, found ${initialXCResults.length} initial XCResult files`); // Start the test action const hasArgs = commandLineArguments && commandLineArguments.length > 0; const script = ` (function() { const app = Application('Xcode'); const workspace = app.activeWorkspaceDocument(); if (!workspace) throw new Error('No active workspace'); ${hasArgs ? `const actionResult = workspace.test({withCommandLineArguments: ${JSON.stringify(commandLineArguments)}});` : `const actionResult = workspace.test();`} // Return immediately - we'll monitor the build separately return JSON.stringify({ actionId: actionResult.id(), message: 'Test started' }); })() `; try { const startResult = await JXAExecutor.execute(script); const { actionId, message } = JSON.parse(startResult); Logger.info(`${message} with action ID: ${actionId}`); // Check for and handle "replace existing build" alert await this._handleReplaceExistingBuildAlert(); // Check for build errors with polling approach Logger.info('Monitoring for build logs...'); // Poll for build logs for up to 30 seconds let foundLogs = false; for (let i = 0; i < 6; i++) { await new Promise(resolve => setTimeout(resolve, 5000)); const logs = await BuildLogParser.getRecentBuildLogs(projectPath, testStartTime); if (logs.length > 0) { Logger.info(`Found ${logs.length} build logs after ${(i + 1) * 5} seconds`); foundLogs = true; break; } Logger.info(`No logs found after ${(i + 1) * 5} seconds, continuing to wait...`); } if (!foundLogs) { Logger.info('No build logs found after 30 seconds - build may not have started yet'); } Logger.info('Build monitoring complete, proceeding to analysis...'); // Get ALL recent build logs for analysis (test might create multiple logs) Logger.info(`DEBUG: testStartTime = ${testStartTime} (${new Date(testStartTime)})`); Logger.info(`DEBUG: projectPath = ${projectPath}`); // First check if we can find DerivedData const derivedData = await BuildLogParser.findProjectDerivedData(projectPath); Logger.info(`DEBUG: derivedData = ${derivedData}`); const recentLogs = await BuildLogParser.getRecentBuildLogs(projectPath, testStartTime); Logger.info(`DEBUG: recentLogs.length = ${recentLogs.length}`); if (recentLogs.length > 0) { Logger.info(`Analyzing ${recentLogs.length} recent build logs created during test...`); let totalErrors = []; let totalWarnings = []; let hasStoppedBuild = false; // Analyze each recent log to catch build errors in any of them for (const log of recentLogs) { try { Logger.info(`Analyzing build log: ${log.path}`); const results = await BuildLogParser.parseBuildLog(log.path); Logger.info(`Log analysis: ${results.errors.length} errors, ${results.warnings.length} warnings, status: ${results.buildStatus || 'unknown'}`); // Check for stopped builds if (results.buildStatus === 'stopped') { hasStoppedBuild = true; } // Accumulate errors and warnings from all logs totalErrors.push(...results.errors); totalWarnings.push(...results.warnings); } catch (error) { Logger.warn(`Failed to parse build log ${log.path}: ${error instanceof Error ? error.message : error}`); } } Logger.info(`Total build analysis: ${totalErrors.length} errors, ${totalWarnings.length} warnings, stopped builds: ${hasStoppedBuild}`); Logger.info(`DEBUG: totalErrors = ${JSON.stringify(totalErrors)}`); Logger.info(`DEBUG: totalErrors.length = ${totalErrors.length}`); Logger.info(`DEBUG: totalErrors.length > 0 = ${totalErrors.length > 0}`); Logger.info(`DEBUG: hasStoppedBuild = ${hasStoppedBuild}`); // Handle stopped builds first if (hasStoppedBuild && totalErrors.length === 0) { let message = `⏹️ TEST BUILD INTERRUPTED${hasArgs ? ` (test with arguments ${JSON.stringify(commandLineArguments)})` : ''}\n\nThe build was stopped or interrupted before completion.\n\n💡 This may happen when:\n • The build was cancelled manually\n • Xcode was closed during the build\n • System resources were exhausted\n\nTry running the test again to complete it.`; return { content: [{ type: 'text', text: message }] }; } if (totalErrors.length > 0) { let message = `❌ TEST BUILD FAILED (${totalErrors.length} errors)\n\nERRORS:\n`; totalErrors.forEach(error => { message += ` • ${error}\n`; Logger.error('Test build error:', error); }); if (totalWarnings.length > 0) { message += `\n⚠️ WARNINGS (${totalWarnings.length}):\n`; totalWarnings.slice(0, 10).forEach(warning => { message += ` • ${warning}\n`; Logger.warn('Test build warning:', warning); }); if (totalWarnings.length > 10) { message += ` ... and ${totalWarnings.length - 10} more warnings\n`; } } Logger.error('ABOUT TO THROW McpError for test build failure'); throw new McpError(ErrorCode.InternalError, message); } else if (totalWarnings.length > 0) { Logger.warn(`Test build completed with ${totalWarnings.length} warnings`); totalWarnings.slice(0, 10).forEach(warning => { Logger.warn('Test build warning:', warning); }); if (totalWarnings.length > 10) { Logger.warn(`... and ${totalWarnings.length - 10} more warnings`); } } } else { Logger.info(`DEBUG: No recent build logs found since ${new Date(testStartTime)}`); } // Since build passed, now wait for test execution to complete Logger.info('Build succeeded, waiting for test execution to complete...'); // Monitor test completion with proper AppleScript checking and 6-hour safety timeout const maxTestTime = 21600000; // 6 hours safety timeout let testCompleted = false; let monitoringSeconds = 0; Logger.info('Monitoring test completion with 6-hour safety timeout...'); while (!testCompleted && (Date.now() - testStartTime) < maxTestTime) { try { // Check test completion via AppleScript every 30 seconds const checkScript = ` (function() { const app = Application('Xcode'); const workspace = app.activeWorkspaceDocument(); if (!workspace) return 'No workspace'; const actions = workspace.schemeActionResults(); for (let i = 0; i < actions.length; i++) { const action = actions[i]; if (action.id() === "${actionId}") { const status = action.status(); const completed = action.completed(); return status + ':' + completed; } } return 'Action not found'; })() `; const result = await JXAExecutor.execute(checkScript, 15000); const [status, completed] = result.split(':'); // Log progress every 2 minutes if (monitoringSeconds % 120 === 0) { Logger.info(`Test monitoring: ${Math.floor(monitoringSeconds / 60)}min - Action ${actionId}: status=${status}, completed=${completed}`); } // Check if test is complete if (completed === 'true' && (status === 'succeeded' || status === 'failed' || status === 'cancelled' || status === 'error occurred')) { testCompleted = true; Logger.info(`Test completed after ${Math.floor(monitoringSeconds / 60)} minutes: status=${status}`); break; } } catch (error) { Logger.warn(`Test monitoring error at ${Math.floor(monitoringSeconds / 60)}min: ${error instanceof Error ? error.message : error}`); } // Wait 30 seconds before next check await new Promise(resolve => setTimeout(resolve, 30000)); monitoringSeconds += 30; } if (!testCompleted) { Logger.warn('Test monitoring reached 6-hour timeout - proceeding anyway'); } Logger.info('Test monitoring result: Test completion detected or timeout reached'); // Only AFTER test completion is confirmed, look for the xcresult file Logger.info('Test execution completed, now looking for XCResult file...'); let newXCResult = await this._findNewXCResultFile(projectPath, initialXCResults, testStartTime); // If no xcresult found yet, wait for it to appear (should be quick now that tests are done) if (!newXCResult) { Logger.info('No xcresult file found yet, waiting for it to appear...'); let attempts = 0; const maxWaitAttempts = 15; // 15 seconds to find the file after test completion while (attempts < maxWaitAttempts && !newXCResult) { await new Promise(resolve => setTimeout(resolve, 1000)); newXCResult = await this._findNewXCResultFile(projectPath, initialXCResults, testStartTime); attempts++; } // If still no XCResult found, the test likely didn't run at all if (!newXCResult) { Logger.warn('No XCResult file found - test may not have run or current scheme has no tests'); return { content: [{ type: 'text', text: `⚠️ TEST EXECUTION UNCLEAR\n\nNo XCResult file was created, which suggests:\n• The current scheme may not have test targets configured\n• Tests may have been skipped\n• There may be configuration issues\n\n💡 Try:\n• Use a scheme with test targets (look for schemes ending in '-Tests')\n• Check that the project has test targets configured\n• Run tests manually in Xcode first to verify setup\n\nAvailable schemes: Use 'xcode_get_schemes' to see all schemes` }] }; } } let testResult = { status: 'completed', error: undefined }; if (newXCResult) { Logger.info(`Found xcresult file: ${newXCResult}, waiting for it to be fully written...`); // Calculate how long the test took const testEndTime = Date.now(); const testDurationMs = testEndTime - testStartTime; const testDurationMinutes = Math.round(testDurationMs / 60000); // Wait 8% of test duration before even attempting to read XCResult // This gives Xcode plenty of time to finish writing everything const proportionalWaitMs = Math.round(testDurationMs * 0.08); const proportionalWaitSeconds = Math.round(proportionalWaitMs / 1000); Logger.info(`Test ran for ${testDurationMinutes} minutes`); Logger.info(`Applying 8% wait time: ${proportionalWaitSeconds} seconds before checking XCResult`); Logger.info(`This prevents premature reads that could contribute to file corruption`); await new Promise(resolve => setTimeout(resolve, proportionalWaitMs)); // Now use the robust waiting method with the test duration for context const isReady = await XCResultParser.waitForXCResultReadiness(newXCResult, testDurationMs); // Pass test duration for proportional timeouts if (isReady) { // File is ready, verify analysis works try { Logger.info('XCResult file is ready, performing final verification...'); const parser = new XCResultParser(newXCResult); const analysis = await parser.analyzeXCResult(); if (analysis && analysis.totalTests >= 0) { Logger.info(`XCResult parsing successful! Found ${analysis.totalTests} tests`); testResult = { status: 'completed', error: undefined }; } else { Logger.error('XCResult parsed but incomplete test data found'); testResult = { status: 'failed', error: `XCResult file exists but contains incomplete test data. This may indicate an Xcode bug.` }; } } catch (parseError) { Logger.error(`XCResult file appears to be corrupt: ${parseError instanceof Error ? parseError.message : parseError}`); testResult = { status: 'failed', error: `XCResult file is corrupt or unreadable. This is likely an Xcode bug. Parse error: ${parseError instanceof Error ? parseError.message : parseError}` }; } } else { Logger.error('XCResult file failed to become ready within 3 minutes'); testResult = { status: 'failed', error: `XCResult file failed to become readable within 3 minutes despite multiple verification attempts. This indicates an Xcode bug where the file remains corrupt or incomplete.` }; } } else { Logger.warn('No xcresult file found after test completion'); testResult = { status: 'completed', error: 'No XCResult file found' }; } if (newXCResult) { Logger.info(`Found xcresult: ${newXCResult}`); // Check if the xcresult file is corrupt if (testResult.status === 'failed' && testResult.error) { // XCResult file is corrupt let message = `❌ XCODE BUG DETECTED${hasArgs ? ` (test with arguments ${JSON.stringify(commandLineArguments)})` : ''}\n\n`; message += `XCResult Path: ${newXCResult}\n\n`; message += `⚠️ ${testResult.error}\n\n`; message += `This is a known Xcode issue where the .xcresult file becomes corrupt even though Xcode reports test completion.\n\n`; message += `💡 Troubleshooting steps:\n`; message += ` 1. Restart Xcode and retry\n`; message += ` 2. Delete DerivedData and retry\n\n`; message += `The corrupt XCResult file is at:\n${newXCResult}`; return { content: [{ type: 'text', text: message }] }; } // We already confirmed the xcresult is readable in our completion detection loop // No need to wait again - proceed directly to analysis if (testResult.status === 'completed') { try { // Use shared utility to format test results with individual test details const parser = new XCResultParser(newXCResult); const testSummary = await parser.formatTestResultsSummary(true, 5); let message = `🧪 TESTS COMPLETED${hasArgs ? ` with arguments ${JSON.stringify(commandLineArguments)}` : ''}\n\n`; message += `XCResult Path: ${newXCResult}\n`; message += testSummary + `\n\n`; const analysis = await parser.analyzeXCResult(); if (analysis.failedTests > 0) { message += `💡 Inspect test results:\n`; message += ` • Browse results: xcresult_browse <path>\n`; message += ` • Get console output: xcresult_browser_get_console <path> <test-id>\n`; message += ` • Get screenshots: xcresult_get_screenshot <path> <test-id> <timestamp>\n`; message += ` • Get UI hierarchy: xcresult_get_ui_hierarchy <path> <test-id> <timestamp>\n`; message += ` • Get element details: xcresult_get_ui_element <hierarchy-json> <index>\n`; message += ` • List attachments: xcresult_list_attachments <path> <test-id>\n`; message += ` • Export attachments: xcresult_export_attachment <path> <test-id> <index>\n`; message += ` • Quick summary: xcresult_summary <path>\n`; message += `\n💡 Tip: Use console output to find failure timestamps for screenshots and UI hierarchies`; } else { message += `✅ All tests passed!\n\n`; message += `💡 Explore test results:\n`; message += ` • Browse results: xcresult_browse <path>\n`; message += ` • Get console output: xcresult_browser_get_console <path> <test-id>\n`; message += ` • Get screenshots: xcresult_get_screenshot <path> <test-id> <timestamp>\n`; message += ` • Get UI hierarchy: xcresult_get_ui_hierarchy <path> <test-id> <timestamp>\n`; message += ` • Get element details: xcresult_get_ui_element <hierarchy-json> <index>\n`; message += ` • List attachments: xcresult_list_attachments <path> <test-id>\n`; message += ` • Export attachments: xcresult_export_attachment <path> <test-id> <index>\n`; message += ` • Quick summary: xcresult_summary <path>`; } return { content: [{ type: 'text', text: message }] }; } catch (parseError) { Logger.warn(`Failed to parse xcresult: ${parseError}`); // Fall back to basic result let message = `🧪 TESTS COMPLETED${hasArgs ? ` with arguments ${JSON.stringify(commandLineArguments)}` : ''}\n\n`; message += `XCResult Path: ${newXCResult}\n`; message += `Status: ${testResult.status}\n\n`; message += `Note: XCResult parsing failed, but test file is available for manual inspection.\n\n`; message += `💡 Inspect test results:\n`; message += ` • Browse results: xcresult_browse <path>\n`; message += ` • Get console output: xcresult_browser_get_console <path> <test-id>\n`; message += ` • Get screenshots: xcresult_get_screenshot <path> <test-id> <timestamp>\n`; message += ` • Get UI hierarchy: xcresult_get_ui_hierarchy <path> <test-id> <timestamp>\n`; message += ` • Get element details: xcresult_get_ui_element <hierarchy-json> <index>\n`; message += ` • List attachments: xcresult_list_attachments <path> <test-id>\n`; message += ` • Export attachments: xcresult_export_attachment <path> <test-id> <index>\n`; message += ` • Quick summary: xcresult_summary <path>`; return { content: [{ type: 'text', text: message }] }; } } else { // Test completion detection timed out let message = `🧪 TESTS ${testResult.status.toUpperCase()}${hasArgs ? ` with arguments ${JSON.stringify(commandLineArguments)}` : ''}\n\n`; message += `XCResult Path: ${newXCResult}\n`; message += `Status: ${testResult.status}\n\n`; message += `⚠️ Test completion detection timed out, but XCResult file is available.\n\n`; message += `💡 Inspect test results:\n`; message += ` • Browse results: xcresult_browse <path>\n`; message += ` • Get console output: xcresult_browser_get_console <path> <test-id>\n`; message += ` • Get screenshots: xcresult_get_screenshot <path> <test-id> <timestamp>\n`; message += ` • Get UI hierarchy: xcresult_get_ui_hierarchy <path> <test-id> <timestamp>\n`; message += ` • Get element details: xcresult_get_ui_element <hierarchy-json> <index>\n`; message += ` • List attachments: xcresult_list_attachments <path> <test-id>\n`; message += ` • Export attachments: xcresult_export_attachment <path> <test-id> <index>\n`; message += ` • Quick summary: xcresult_summary <path>`; return { content: [{ type: 'text', text: message }] }; } } else { // No xcresult found - fall back to basic result if (testResult.status === 'failed') { return { content: [{ type: 'text', text: `❌ TEST FAILED\n\n${testResult.error || 'Test execution failed'}\n\nNote: No XCResult file found for detailed analysis.` }] }; } const message = `🧪 TESTS COMPLETED${hasArgs ? ` with arguments ${JSON.stringify(commandLineArguments)}` : ''}\n\nStatus: ${testResult.status}\n\nNote: No XCResult file found for detailed analysis.`; return { content: [{ type: 'text', text: message }] }; } } catch (error) { // Re-throw McpErrors to properly signal build failures if (error instanceof McpError) { throw error; } const enhancedError = ErrorHelper.parseCommonErrors(error); if (enhancedError) { return { content: [{ type: 'text', text: enhancedError }] }; } const errorMessage = error instanceof Error ? error.message : String(error); return { content: [{ type: 'text', text: `Failed to run tests: ${errorMessage}` }] }; } } static async run(projectPath, schemeName, commandLineArguments = [], openProject) { const validationError = PathValidator.validateProjectPath(projectPath); if (validationError) return validationError; await openProject(projectPath); // Set the scheme const normalizedSchemeName = ParameterNormalizer.normalizeSchemeName(schemeName); const setSchemeScript = ` (function() { const app = Application('Xcode'); const workspace = app.activeWorkspaceDocument(); if (!workspace) throw new Error('No active workspace'); const schemes = workspace.schemes(); const schemeNames = schemes.map(scheme => scheme.name()); // Try exact match first let targetScheme = schemes.find(scheme => scheme.name() === ${JSON.stringify(normalizedSchemeName)}); // If not found, try original name if (!targetScheme) { targetScheme = schemes.find(scheme => scheme.name() === ${JSON.stringify(schemeName)}); } if (!targetScheme) { throw new Error('Scheme not found. Available: ' + JSON.stringify(schemeNames)); } workspace.activeScheme = targetScheme; return 'Scheme set to ' + targetScheme.name(); })() `; try { await JXAExecutor.execute(setSchemeScript); } catch (error) { const enhancedError = ErrorHelper.parseCommonErrors(error); if (enhancedError) { return { content: [{ type: 'text', text: enhancedError }] }; } const errorMessage = error instanceof Error ? error.message : String(error); if (errorMessage.includes('not found')) { try { // Get available schemes const availableSchemes = await this._getAvailableSchemes(); // Try to find a close match with fuzzy matching const bestMatch = ParameterNormalizer.findBestMatch(schemeName, availableSchemes); let message = `❌ Scheme '${schemeName}' not found\n\nAvailable schemes:\n`; availableSchemes.forEach(scheme => { if (scheme === bestMatch) { message += ` • ${scheme} ← Did you mean this?\n`; } else { message += ` • ${scheme}\n`; } }); return { content: [{ type: 'text', text: message }] }; } catch { return { content: [{ type: 'text', text: ErrorHelper.createErrorWithGuidance(`Scheme '${schemeName}' not found`, ErrorHelper.getSchemeNotFoundGuidance(schemeName)) }] }; } } return { content: [{ type: 'text', text: `Failed to set scheme '${schemeName}': ${errorMessage}` }] }; } // Note: No longer need to track initial log since we use AppleScript completion detection const hasArgs = commandLineArguments && commandLineArguments.length > 0; const script = ` (function() { const app = Application('Xcode'); const workspace = app.activeWorkspaceDocument(); if (!workspace) throw new Error('No active workspace'); ${hasArgs ? `const result = workspace.run({withCommandLineArguments: ${JSON.stringify(commandLineArguments)}});` : `const result = workspace.run();`} return \`Run started. Result ID: \${result.id()}\`; })() `; const runResult = await JXAExecutor.execute(script); // Extract the action ID from the result const actionIdMatch = runResult.match(/Result ID: (.+)/); const actionId = actionIdMatch ? actionIdMatch[1] : null; if (!actionId) { return { content: [{ type: 'text', text: `${runResult}\n\nError: Could not extract action ID from run result` }] }; } Logger.info(`Run started with action ID: ${actionId}`); // Check for and handle "replace existing build" alert await this._handleReplaceExistingBuildAlert(); // Monitor run completion using AppleScript instead of build log detection Logger.info(`Monitoring run completion using AppleScript for action ID: ${actionId}`); const maxRunTime = 3600000; // 1 hour safety timeout const runStartTime = Date.now(); let runCompleted = false; let monitoringSeconds = 0; while (!runCompleted && (Date.now() - runStartTime) < maxRunTime) { try { // Check run completion via AppleScript every 10 seconds const checkScript = ` (function() { const app = Application('Xcode'); const workspace = app.activeWorkspaceDocument(); if (!workspace) return 'No workspace'; const actions = workspace.schemeActionResults(); for (let i = 0; i < actions.length; i++) { const action = actions[i]; if (action.id() === "${actionId}") { const status = action.status(); const completed = action.completed(); return status + ':' + completed; } } return 'Action not found'; })() `; const result = await JXAExecutor.execute(checkScript, 15000); const [status, completed] = result.split(':'); // Log progress every 2 minutes if (monitoringSeconds % 120 === 0) { Logger.info(`Run monitoring: ${Math.floor(monitoringSeconds / 60)}min - Action ${actionId}: status=${status}, completed=${completed}`); } // For run actions, we need different completion logic than build/test // Run actions stay "running" even after successful app launch if (completed === 'true' && (status === 'failed' || status === 'cancelled' || status === 'error occurred')) { // Run failed/cancelled - this is a true completion runCompleted = true; Logger.info(`Run completed after ${Math.floor(monitoringSeconds / 60)} minutes: status=${status}`); break; } else if (status === 'running' && monitoringSeconds >= 60) { // If still running after 60 seconds, assume the app launched successfully // We'll check for build errors in the log parsing step runCompleted = true; Logger.info(`Run appears successful after ${Math.floor(monitoringSeconds / 60)} minutes (app likely launched)`); break; } else if (status === 'succeeded') { // This might happen briefly during transition, wait a bit more Logger.info(`Run status shows 'succeeded', waiting to see if it transitions to 'running'...`); } } catch (error) { Logger.warn(`Run monitoring error at ${Math.floor(monitoringSeconds / 60)}min: ${error instanceof Error ? error.message : error}`); } // Wait 10 seconds before next check await new Promise(resolve => setTimeout(resolve, 10000)); monitoringSeconds += 10; } if (!runCompleted) { Logger.warn('Run monitoring reached 1-hour timeout - proceeding anyway'); } // Now find the build log that was created during this run const newLog = await BuildLogParser.getLatestBuildLog(projectPath); if (!newLog) { return { content: [{ type: 'text', text: `${runResult}\n\nNote: Run completed but no build log found (app may have launched without building)` }] }; } Logger.info(`Run completed, parsing build log: ${newLog.path}`); const results = await BuildLogParser.parseBuildLog(newLog.path); let message = `${runResult}\n\n`; Logger.info(`Run build completed - ${results.errors.length} errors, ${results.warnings.length} warnings, status: ${results.buildStatus || 'unknown'}`); // Handle stopped/interrupted builds if (results.buildStatus === 'stopped') { message += `⏹️ BUILD INTERRUPTED\n\nThe build was stopped or interrupted before completion.\n\n💡 This may happen when:\n • The build was cancelled manually\n • Xcode was closed during the build\n • System resources were exhausted\n\nTry running the build again to complete it.`; return { content: [{ type: 'text', text: message }] }; } if (results.errors.length > 0) { message += `❌ BUILD FAILED (${results.errors.length} errors)\n\nERRORS:\n`; results.errors.forEach(error => { message += ` • ${error}\n`; }); throw new McpError(ErrorCode.InternalError, message); } else if (results.warnings.length > 0) { message += `⚠️ BUILD COMPLETED WITH WARNINGS (${results.warnings.length} warnings)\n\nWARNINGS:\n`; results.warnings.forEach(warning => { message += ` • ${warning}\n`; }); } else { message += '✅ BUILD SUCCESSFUL - App should be launching'; } return { content: [{ type: 'text', text: message }] }; } static async debug(projectPath, scheme, skipBuilding = false, openProject) { const validationError = PathValidator.validateProjectPath(projectPath); if (validationError) return validationError; if (openProject) { await openProject(projectPath); } const hasParams = scheme || skipBuilding; let paramsObj = {}; if (scheme) paramsObj.scheme = scheme; if (skipBuilding) paramsObj.skipBuilding = skipBuilding; const script = ` (function() { const app = Application('Xcode'); const workspace = app.activeWorkspaceDocument(); if (!workspace) throw new Error('No active workspace'); ${hasParams ? `const result = workspace.debug(${JSON.stringify(paramsObj)});` : `const result = workspace.debug();`} return \`Debug started. Result ID: \${result.id()}\`; })() `; const result = await JXAExecutor.execute(script); // Check for and handle "replace existing build" alert await this._handleReplaceExistingBuildAlert(); return { content: [{ type: 'text', text: result }] }; } static async stop() { const script = `