BuildTools.ts•69.3 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';
import { getWorkspaceByPathScript } from '../utils/JXAHelpers.js';
import type { McpResult, OpenProjectCallback } from '../types/index.js';
export class BuildTools {
public static async build(
projectPath: string,
schemeName: string,
destination: string | null = null,
openProject: OpenProjectCallback
): Promise<McpResult> {
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() {
${getWorkspaceByPathScript(projectPath)}
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 as 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(projectPath);
// 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() {
${getWorkspaceByPathScript(projectPath)}
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 as 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: string[] = [];
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(projectPath);
}
}
} else {
availableDestinations = await this._getAvailableDestinations(projectPath);
}
// 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() {
${getWorkspaceByPathScript(projectPath)}
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 as 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 }] };
}
public static async clean(projectPath: string, openProject: OpenProjectCallback): Promise<McpResult> {
const validationError = PathValidator.validateProjectPath(projectPath);
if (validationError) return validationError;
await openProject(projectPath);
const script = `
(function() {
${getWorkspaceByPathScript(projectPath)}
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 }] };
}
public static async test(
projectPath: string,
destination: string,
commandLineArguments: string[] = [],
openProject: OpenProjectCallback,
options?: {
testPlanPath?: string;
selectedTests?: string[];
selectedTestClasses?: string[];
testTargetIdentifier?: string;
testTargetName?: string;
}
): Promise<McpResult> {
const validationError = PathValidator.validateProjectPath(projectPath);
if (validationError) return validationError;
await openProject(projectPath);
// Set the destination for testing
{
// Normalize the destination name for better matching
const normalizedDestination = ParameterNormalizer.normalizeDestinationName(destination);
const setDestinationScript = `
(function() {
${getWorkspaceByPathScript(projectPath)}
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 errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes('Destination not found')) {
// Extract available destinations from error message
try {
const availableMatch = errorMessage.match(/Available: (\[.*\])/);
if (availableMatch) {
const availableDestinations = JSON.parse(availableMatch[1]!);
const bestMatch = ParameterNormalizer.findBestMatch(destination, availableDestinations);
let message = `❌ Destination '${destination}' not found\n\nAvailable destinations:\n`;
availableDestinations.forEach((dest: string) => {
if (dest === bestMatch) {
message += ` • ${dest} ← Did you mean this?\n`;
} else {
message += ` • ${dest}\n`;
}
});
return { content: [{ type: 'text', text: message }] };
}
} catch {
// Fall through to generic error
}
}
return { content: [{ type: 'text', text: `Failed to set destination '${destination}': ${errorMessage}` }] };
}
}
// Handle test plan modification if selective tests are requested
let originalTestPlan: string | null = null;
let shouldRestoreTestPlan = false;
if (options?.testPlanPath && (options?.selectedTests?.length || options?.selectedTestClasses?.length)) {
if (!options.testTargetIdentifier && !options.testTargetName) {
return {
content: [{
type: 'text',
text: 'Error: either test_target_identifier or test_target_name is required when using test filtering'
}]
};
}
// If target name is provided but no identifier, look up the identifier
let targetIdentifier = options.testTargetIdentifier;
let targetName = options.testTargetName;
if (options.testTargetName && !options.testTargetIdentifier) {
try {
const { ProjectTools } = await import('./ProjectTools.js');
const targetInfo = await ProjectTools.getTestTargets(projectPath);
// Parse the target info to find the identifier
const targetText = targetInfo.content?.[0]?.type === 'text' ? targetInfo.content[0].text : '';
const namePattern = new RegExp(`\\*\\*${options.testTargetName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\*\\*\\s*\\n\\s*•\\s*Identifier:\\s*([A-F0-9]{24})`, 'i');
const match = targetText.match(namePattern);
if (match && match[1]) {
targetIdentifier = match[1];
targetName = options.testTargetName;
} else {
return {
content: [{
type: 'text',
text: `Error: Test target '${options.testTargetName}' not found. Use 'xcode_get_test_targets' to see available targets.`
}]
};
}
} catch (lookupError) {
return {
content: [{
type: 'text',
text: `Error: Failed to lookup test target '${options.testTargetName}': ${lookupError instanceof Error ? lookupError.message : String(lookupError)}`
}]
};
}
}
try {
// Import filesystem operations
const { promises: fs } = await import('fs');
// Backup original test plan
originalTestPlan = await fs.readFile(options.testPlanPath, 'utf8');
shouldRestoreTestPlan = true;
// Build selected tests array
let selectedTests: string[] = [];
// Add individual tests
if (options.selectedTests?.length) {
selectedTests.push(...options.selectedTests);
}
// Add all tests from selected test classes
if (options.selectedTestClasses?.length) {
// For now, add the class names - we'd need to scan for specific test methods later
selectedTests.push(...options.selectedTestClasses);
}
// Get project name from path for container reference
const { basename } = await import('path');
const projectName = basename(projectPath, '.xcodeproj');
// Create test target configuration
const testTargets = [{
target: {
containerPath: `container:${projectName}.xcodeproj`,
identifier: targetIdentifier!,
name: targetName || targetIdentifier!
},
selectedTests: selectedTests
}];
// Update test plan temporarily
const { TestPlanTools } = await import('./TestPlanTools.js');
await TestPlanTools.updateTestPlanAndReload(
options.testPlanPath,
projectPath,
testTargets
);
Logger.info(`Temporarily modified test plan to run ${selectedTests.length} selected tests`);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return {
content: [{
type: 'text',
text: `Failed to modify test plan: ${errorMsg}`
}]
};
}
}
// 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() {
${getWorkspaceByPathScript(projectPath)}
${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: string[] = [];
let totalWarnings: string[] = [];
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() {
${getWorkspaceByPathScript(projectPath)}
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: string, error: string | undefined } = { 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 --xcresult-path <path>\n`;
message += ` • Get console output: xcresult-browser-get-console --xcresult-path <path> --test-id <test-id>\n`;
message += ` • Get screenshots: xcresult-get-screenshot --xcresult-path <path> --test-id <test-id> --timestamp <timestamp>\n`;
message += ` • Get UI hierarchy: xcresult-get-ui-hierarchy --xcresult-path <path> --test-id <test-id> --timestamp <timestamp>\n`;
message += ` • Get element details: xcresult-get-ui-element --hierarchy-json <hierarchy-json> --index <index>\n`;
message += ` • List attachments: xcresult-list-attachments --xcresult-path <path> --test-id <test-id>\n`;
message += ` • Export attachments: xcresult-export-attachment --xcresult-path <path> --test-id <test-id> --index <index>\n`;
message += ` • Quick summary: xcresult-summary --xcresult-path <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 --xcresult-path <path>\n`;
message += ` • Get console output: xcresult-browser-get-console --xcresult-path <path> --test-id <test-id>\n`;
message += ` • Get screenshots: xcresult-get-screenshot --xcresult-path <path> --test-id <test-id> --timestamp <timestamp>\n`;
message += ` • Get UI hierarchy: xcresult-get-ui-hierarchy --xcresult-path <path> --test-id <test-id> --timestamp <timestamp>\n`;
message += ` • Get element details: xcresult-get-ui-element --hierarchy-json <hierarchy-json> --index <index>\n`;
message += ` • List attachments: xcresult-list-attachments --xcresult-path <path> --test-id <test-id>\n`;
message += ` • Export attachments: xcresult-export-attachment --xcresult-path <path> --test-id <test-id> --index <index>\n`;
message += ` • Quick summary: xcresult-summary --xcresult-path <path>`;
}
return await this._restoreTestPlanAndReturn({ content: [{ type: 'text', text: message }] }, shouldRestoreTestPlan, originalTestPlan, options?.testPlanPath);
} 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 await this._restoreTestPlanAndReturn({ content: [{ type: 'text', text: message }] }, shouldRestoreTestPlan, originalTestPlan, options?.testPlanPath);
}
} 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 await this._restoreTestPlanAndReturn({ content: [{ type: 'text', text: message }] }, shouldRestoreTestPlan, originalTestPlan, options?.testPlanPath);
}
} else {
// No xcresult found - fall back to basic result
if (testResult.status === 'failed') {
return await this._restoreTestPlanAndReturn({ content: [{ type: 'text', text: `❌ TEST FAILED\n\n${testResult.error || 'Test execution failed'}\n\nNote: No XCResult file found for detailed analysis.` }] }, shouldRestoreTestPlan, originalTestPlan, options?.testPlanPath);
}
const message = `🧪 TESTS COMPLETED${hasArgs ? ` with arguments ${JSON.stringify(commandLineArguments)}` : ''}\n\nStatus: ${testResult.status}\n\nNote: No XCResult file found for detailed analysis.`;
return await this._restoreTestPlanAndReturn({ content: [{ type: 'text', text: message }] }, shouldRestoreTestPlan, originalTestPlan, options?.testPlanPath);
}
} catch (error) {
// Restore test plan even on error
if (shouldRestoreTestPlan && originalTestPlan && options?.testPlanPath) {
try {
const { promises: fs } = await import('fs');
await fs.writeFile(options.testPlanPath, originalTestPlan, 'utf8');
Logger.info('Restored original test plan after error');
} catch (restoreError) {
Logger.error(`Failed to restore test plan: ${restoreError}`);
}
}
// Re-throw McpErrors to properly signal build failures
if (error instanceof McpError) {
throw error;
}
const enhancedError = ErrorHelper.parseCommonErrors(error as 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}` }] };
}
}
/**
* Helper method to restore test plan and return result
*/
private static async _restoreTestPlanAndReturn(
result: McpResult,
shouldRestoreTestPlan: boolean,
originalTestPlan: string | null,
testPlanPath?: string
): Promise<McpResult> {
if (shouldRestoreTestPlan && originalTestPlan && testPlanPath) {
try {
const { promises: fs } = await import('fs');
await fs.writeFile(testPlanPath, originalTestPlan, 'utf8');
Logger.info('Restored original test plan');
// Trigger reload after restoration
const { TestPlanTools } = await import('./TestPlanTools.js');
await TestPlanTools.triggerTestPlanReload(testPlanPath, testPlanPath);
} catch (restoreError) {
Logger.error(`Failed to restore test plan: ${restoreError}`);
// Append restoration error to result
if (result.content?.[0]?.type === 'text') {
result.content[0].text += `\n\n⚠️ Warning: Failed to restore original test plan: ${restoreError}`;
}
}
}
return result;
}
public static async run(
projectPath: string,
schemeName: string,
commandLineArguments: string[] = [],
openProject: OpenProjectCallback
): Promise<McpResult> {
const validationError = PathValidator.validateProjectPath(projectPath);
if (validationError) return validationError;
await openProject(projectPath);
// Set the scheme
const normalizedSchemeName = ParameterNormalizer.normalizeSchemeName(schemeName);
const setSchemeScript = `
(function() {
${getWorkspaceByPathScript(projectPath)}
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 as 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(projectPath);
// 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() {
${getWorkspaceByPathScript(projectPath)}
${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() {
${getWorkspaceByPathScript(projectPath)}
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 }] };
}
public static async debug(
projectPath: string,
scheme?: string,
skipBuilding = false,
openProject?: OpenProjectCallback
): Promise<McpResult> {
const validationError = PathValidator.validateProjectPath(projectPath);
if (validationError) return validationError;
if (openProject) {
await openProject(projectPath);
}
const hasParams = scheme || skipBuilding;
let paramsObj: { scheme?: string; skipBuilding?: boolean } = {};
if (scheme) paramsObj.scheme = scheme;
if (skipBuilding) paramsObj.skipBuilding = skipBuilding;
const script = `
(function() {
${getWorkspaceByPathScript(projectPath)}
${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 }] };
}
public static async stop(projectPath: string): Promise<McpResult> {
const script = `
(function() {
${getWorkspaceByPathScript(projectPath)}
workspace.stop();
return 'Stop command sent';
})()
`;
const result = await JXAExecutor.execute(script);
return { content: [{ type: 'text', text: result }] };
}
private static async _getAvailableSchemes(projectPath: string): Promise<string[]> {
const script = `
(function() {
${getWorkspaceByPathScript(projectPath)}
if (!workspace) return JSON.stringify([]);
const schemes = workspace.schemes();
const schemeNames = schemes.map(scheme => scheme.name());
return JSON.stringify(schemeNames);
})()
`;
try {
const result = await JXAExecutor.execute(script);
return JSON.parse(result);
} catch {
return [];
}
}
private static async _getAvailableDestinations(projectPath: string): Promise<string[]> {
const script = `
(function() {
${getWorkspaceByPathScript(projectPath)}
if (!workspace) return [];
const destinations = workspace.runDestinations();
return destinations.map(dest => dest.name());
})()
`;
try {
const result = await JXAExecutor.execute(script);
return JSON.parse(result);
} catch {
return [];
}
}
private static async _findXCResultFiles(projectPath: string): Promise<{ path: string; mtime: number; size?: number }[]> {
const xcresultFiles: { path: string; mtime: number; size?: number }[] = [];
try {
// Use existing BuildLogParser logic to find the correct DerivedData directory
const derivedData = await BuildLogParser.findProjectDerivedData(projectPath);
if (derivedData) {
// Look for xcresult files in the Test logs directory
const testLogsDir = join(derivedData, 'Logs', 'Test');
try {
const files = await readdir(testLogsDir);
const xcresultDirs = files.filter(file => file.endsWith('.xcresult'));
for (const xcresultDir of xcresultDirs) {
const fullPath = join(testLogsDir, xcresultDir);
try {
const stats = await stat(fullPath);
xcresultFiles.push({
path: fullPath,
mtime: stats.mtime.getTime(),
size: stats.size
});
} catch {
// Ignore files we can't stat
}
}
} catch (error) {
Logger.debug(`Could not read test logs directory: ${error}`);
}
}
} catch (error) {
Logger.warn(`Error finding xcresult files: ${error}`);
}
return xcresultFiles.sort((a, b) => b.mtime - a.mtime);
}
private static async _findNewXCResultFile(
projectPath: string,
initialFiles: { path: string; mtime: number }[],
testStartTime: number
): Promise<string | null> {
const maxAttempts = 30; // 30 seconds
let attempts = 0;
while (attempts < maxAttempts) {
const currentFiles = await this._findXCResultFiles(projectPath);
// Look for new files created after test start
for (const file of currentFiles) {
const wasInitialFile = initialFiles.some(initial =>
initial.path === file.path && initial.mtime === file.mtime
);
if (!wasInitialFile && file.mtime >= testStartTime - 5000) { // 5s buffer
Logger.info(`Found new xcresult file: ${file.path}, mtime: ${new Date(file.mtime)}, test start: ${new Date(testStartTime)}`);
return file.path;
} else if (!wasInitialFile) {
Logger.warn(`Found xcresult file but too old: ${file.path}, mtime: ${new Date(file.mtime)}, test start: ${new Date(testStartTime)}, diff: ${file.mtime - testStartTime}ms`);
} else {
Logger.debug(`Skipping initial file: ${file.path}, mtime: ${new Date(file.mtime)}`);
}
}
await new Promise(resolve => setTimeout(resolve, 1000));
attempts++;
}
// If no new file found, look for files created AFTER test start time
const allFiles = await this._findXCResultFiles(projectPath);
// Find files created after the test started (not just within the timeframe)
const filesAfterTestStart = allFiles.filter(file => file.mtime > testStartTime);
if (filesAfterTestStart.length > 0) {
// Return the newest file that was created after the test started
const mostRecentAfterTest = filesAfterTestStart[0]; // Already sorted newest first
if (mostRecentAfterTest) {
Logger.warn(`Using most recent xcresult file created after test start: ${mostRecentAfterTest.path}`);
return mostRecentAfterTest.path;
}
} else if (allFiles.length > 0) {
const mostRecent = allFiles[0];
if (mostRecent) {
Logger.debug(`Most recent file too old: ${mostRecent.path}, mtime: ${new Date(mostRecent.mtime)}, test start: ${new Date(testStartTime)}`);
}
}
return null;
}
/**
* Find XCResult files for a given project
*/
public static async findXCResults(projectPath: string): Promise<McpResult> {
const validationError = PathValidator.validateProjectPath(projectPath);
if (validationError) return validationError;
try {
const xcresultFiles = await this._findXCResultFiles(projectPath);
if (xcresultFiles.length === 0) {
return {
content: [{
type: 'text',
text: `No XCResult files found for project: ${projectPath}\n\nXCResult files are created when you run tests. Try running tests first with 'xcode_test'.`
}]
};
}
let message = `🔍 Found ${xcresultFiles.length} XCResult file(s) for project: ${projectPath}\n\n`;
message += `📁 XCResult Files (sorted by newest first):\n`;
message += '='.repeat(80) + '\n';
xcresultFiles.forEach((file, index) => {
const date = new Date(file.mtime);
const timeAgo = this._getTimeAgo(file.mtime);
message += `${index + 1}. ${file.path}\n`;
message += ` 📅 Created: ${date.toLocaleString()} (${timeAgo})\n`;
message += ` 📊 Size: ${this._formatFileSize(file.size || 0)}\n\n`;
});
message += `💡 Usage:\n`;
message += ` • View results: xcresult-browse --xcresult-path "<path>"\n`;
message += ` • Get console: xcresult-browser-get-console --xcresult-path "<path>" --test-id <test-id>\n`;
return { content: [{ type: 'text', text: message }] };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
content: [{
type: 'text',
text: `Failed to find XCResult files: ${errorMessage}`
}]
};
}
}
private static _getTimeAgo(timestamp: number): string {
const now = Date.now();
const diff = now - timestamp;
const minutes = Math.floor(diff / (1000 * 60));
const hours = Math.floor(diff / (1000 * 60 * 60));
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`;
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`;
return `${days} day${days === 1 ? '' : 's'} ago`;
}
private static _formatFileSize(bytes: number): string {
if (bytes === 0) return '0 bytes';
const k = 1024;
const sizes = ['bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
/**
* Handle alerts that appear when starting builds/tests while another operation is in progress.
* This includes "replace existing build" alerts and similar dialog overlays.
*/
private static async _handleReplaceExistingBuildAlert(): Promise<void> {
const alertScript = `
(function() {
try {
// Use System Events approach first as it's more reliable for sheet dialogs
const systemEvents = Application('System Events');
const xcodeProcesses = systemEvents.processes.whose({name: 'Xcode'});
if (xcodeProcesses.length > 0) {
const xcodeProcess = xcodeProcesses[0];
const windows = xcodeProcess.windows();
// Check for sheets in regular windows (most common case)
for (let i = 0; i < windows.length; i++) {
try {
const window = windows[i];
const sheets = window.sheets();
if (sheets && sheets.length > 0) {
const sheet = sheets[0];
const buttons = sheet.buttons();
// Look for Replace, Continue, OK, Yes buttons (in order of preference)
const preferredButtons = ['Replace', 'Continue', 'OK', 'Yes', 'Stop and Replace'];
for (const preferredButton of preferredButtons) {
for (let b = 0; b < buttons.length; b++) {
try {
const button = buttons[b];
const buttonTitle = button.title();
if (buttonTitle === preferredButton) {
button.click();
return 'Sheet alert handled: clicked ' + buttonTitle;
}
} catch (e) {
// Continue to next button
}
}
}
// If no preferred button found, try partial matches
for (let b = 0; b < buttons.length; b++) {
try {
const button = buttons[b];
const buttonTitle = button.title();
if (buttonTitle && (
buttonTitle.toLowerCase().includes('replace') ||
buttonTitle.toLowerCase().includes('continue') ||
buttonTitle.toLowerCase().includes('stop') ||
buttonTitle.toLowerCase() === 'ok' ||
buttonTitle.toLowerCase() === 'yes'
)) {
button.click();
return 'Sheet alert handled: clicked ' + buttonTitle + ' (partial match)';
}
} catch (e) {
// Continue to next button
}
}
// Log available buttons for debugging
const availableButtons = [];
for (let b = 0; b < buttons.length; b++) {
try {
availableButtons.push(buttons[b].title());
} catch (e) {
availableButtons.push('(unnamed)');
}
}
return 'Sheet found but no suitable button. Available: ' + JSON.stringify(availableButtons);
}
} catch (e) {
// Continue to next window
}
}
// Check for modal dialogs
const dialogs = xcodeProcess.windows.whose({subrole: 'AXDialog'});
for (let d = 0; d < dialogs.length; d++) {
try {
const dialog = dialogs[d];
const buttons = dialog.buttons();
for (let b = 0; b < buttons.length; b++) {
try {
const button = buttons[b];
const buttonTitle = button.title();
if (buttonTitle && (
buttonTitle.toLowerCase().includes('replace') ||
buttonTitle.toLowerCase().includes('continue') ||
buttonTitle.toLowerCase().includes('stop') ||
buttonTitle.toLowerCase() === 'ok' ||
buttonTitle.toLowerCase() === 'yes'
)) {
button.click();
return 'Dialog alert handled: clicked ' + buttonTitle;
}
} catch (e) {
// Continue to next button
}
}
} catch (e) {
// Continue to next dialog
}
}
}
// Fallback to Xcode app approach for embedded alerts
const app = Application('Xcode');
const windows = app.windows();
for (let i = 0; i < windows.length; i++) {
try {
const window = windows[i];
const sheets = window.sheets();
if (sheets && sheets.length > 0) {
const sheet = sheets[0];
const buttons = sheet.buttons();
for (let j = 0; j < buttons.length; j++) {
try {
const button = buttons[j];
const buttonName = button.name();
if (buttonName && (
buttonName.toLowerCase().includes('replace') ||
buttonName.toLowerCase().includes('continue') ||
buttonName.toLowerCase().includes('stop') ||
buttonName.toLowerCase() === 'ok' ||
buttonName.toLowerCase() === 'yes'
)) {
button.click();
return 'Xcode app sheet handled: clicked ' + buttonName;
}
} catch (e) {
// Continue to next button
}
}
}
} catch (e) {
// Continue to next window
}
}
return 'No alert found';
} catch (error) {
return 'Alert check failed: ' + error.message;
}
})()
`;
try {
Logger.info('Running alert detection script...');
const result = await JXAExecutor.execute(alertScript);
Logger.info(`Alert detection result: ${result}`);
if (result && result !== 'No alert found') {
Logger.info(`Alert handling: ${result}`);
} else {
Logger.info('No alerts detected');
}
} catch (error) {
// Don't fail the main operation if alert handling fails
Logger.info(`Alert handling failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
}