xcodemcp
Version:
Model Context Protocol server for Xcode build automation and log parsing
879 lines (852 loc) • 65.9 kB
JavaScript
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 = `