xcodemcp
Version:
Model Context Protocol server for Xcode build automation and log parsing
905 lines ⢠65.3 kB
JavaScript
import { existsSync } from 'fs';
import { spawn } from 'child_process';
import { tmpdir } from 'os';
import { join } from 'path';
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
import { XCResultParser } from '../utils/XCResultParser.js';
import { Logger } from '../utils/Logger.js';
export class XCResultTools {
/**
* Browse xcresult file - list tests or show specific test details
*/
static async xcresultBrowse(xcresultPath, testId, includeConsole = false) {
// Validate xcresult path
if (!existsSync(xcresultPath)) {
throw new McpError(ErrorCode.InvalidParams, `XCResult file not found: ${xcresultPath}`);
}
if (!xcresultPath.endsWith('.xcresult')) {
throw new McpError(ErrorCode.InvalidParams, `Path must be an .xcresult file: ${xcresultPath}`);
}
// Check if xcresult is readable
if (!XCResultParser.isXCResultReadable(xcresultPath)) {
throw new McpError(ErrorCode.InternalError, `XCResult file is not readable or incomplete: ${xcresultPath}`);
}
try {
const parser = new XCResultParser(xcresultPath);
if (testId) {
// Show specific test details
const details = await parser.formatTestDetails(testId, includeConsole);
return { content: [{ type: 'text', text: details }] };
}
else {
// List all tests
const testList = await parser.formatTestList();
let usage = '\n\nš” Usage:\n';
usage += ' View test details: xcresult_browse <path> <test-id-or-index>\n';
usage += ' View with console: xcresult_browse <path> <test-id-or-index> true\n';
usage += ' Get console only: xcresult_browser_get_console <path> <test-id-or-index>\n';
usage += ' Get UI hierarchy: xcresult_get_ui_hierarchy <path> <test-id-or-index> [timestamp]\n';
usage += ' Get screenshot: xcresult_get_screenshot <path> <test-id-or-index> <timestamp>\n';
usage += ' Examples:\n';
usage += ` xcresult_browse "${xcresultPath}" 5\n`;
usage += ` xcresult_browse "${xcresultPath}" "SomeTest/testMethod()" true\n`;
usage += ` xcresult_get_ui_hierarchy "${xcresultPath}" 5 120.5\n`;
usage += ` xcresult_get_screenshot "${xcresultPath}" 5 120.5\n`;
return { content: [{ type: 'text', text: testList + usage }] };
}
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes('xcresulttool')) {
throw new McpError(ErrorCode.InternalError, `XCResult parsing failed. Make sure Xcode Command Line Tools are installed: ${errorMessage}`);
}
throw new McpError(ErrorCode.InternalError, `Failed to analyze XCResult: ${errorMessage}`);
}
}
/**
* Get console output for a specific test
*/
static async xcresultBrowserGetConsole(xcresultPath, testId) {
// Validate xcresult path
if (!existsSync(xcresultPath)) {
throw new McpError(ErrorCode.InvalidParams, `XCResult file not found: ${xcresultPath}`);
}
if (!xcresultPath.endsWith('.xcresult')) {
throw new McpError(ErrorCode.InvalidParams, `Path must be an .xcresult file: ${xcresultPath}`);
}
// Check if xcresult is readable
if (!XCResultParser.isXCResultReadable(xcresultPath)) {
throw new McpError(ErrorCode.InternalError, `XCResult file is not readable or incomplete: ${xcresultPath}`);
}
if (!testId || testId.trim() === '') {
throw new McpError(ErrorCode.InvalidParams, 'Test ID or index is required');
}
try {
const parser = new XCResultParser(xcresultPath);
// First find the test node to get the actual test identifier
const testNode = await parser.findTestNode(testId);
if (!testNode) {
return {
content: [{
type: 'text',
text: `ā Test '${testId}' not found\n\nRun xcresult_browse "${xcresultPath}" to see all available tests`
}]
};
}
let output = `š Console Output for: ${testNode.name}\n`;
output += '='.repeat(80) + '\n\n';
// Get console output
const consoleOutput = await parser.getConsoleOutput(testNode.nodeIdentifier);
output += `Console Log:\n${consoleOutput}\n\n`;
// Get test activities
if (testNode.nodeIdentifier) {
output += `š¬ Test Activities:\n`;
const activities = await parser.getTestActivities(testNode.nodeIdentifier);
output += activities;
}
// Check if output is very long and should be saved to a file
const lineCount = output.split('\n').length;
const charCount = output.length;
// If output is longer than 20 lines or 2KB, save to file
if (lineCount > 20 || charCount > 2000) {
const { writeFile } = await import('fs/promises');
const { tmpdir } = await import('os');
const { join } = await import('path');
// Create a unique filename
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const safeTestName = testNode.name.replace(/[^a-zA-Z0-9]/g, '_');
const filename = `console_output_${safeTestName}_${timestamp}.txt`;
const filePath = join(tmpdir(), filename);
await writeFile(filePath, output, 'utf-8');
const fileSizeKB = Math.round(charCount / 1024);
return {
content: [{
type: 'text',
text: `š Console Output for: ${testNode.name}\n` +
`š Output saved to file (${lineCount} lines, ${fileSizeKB} KB): ${filePath}\n\n` +
`š” The console output was too large to display directly. ` +
`You can read the file to access the complete console log and test activities.`
}]
};
}
return { content: [{ type: 'text', text: output }] };
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes('xcresulttool')) {
throw new McpError(ErrorCode.InternalError, `XCResult parsing failed. Make sure Xcode Command Line Tools are installed: ${errorMessage}`);
}
throw new McpError(ErrorCode.InternalError, `Failed to get console output: ${errorMessage}`);
}
}
/**
* Get a quick summary of an xcresult file
*/
static async xcresultSummary(xcresultPath) {
// Validate xcresult path
if (!existsSync(xcresultPath)) {
throw new McpError(ErrorCode.InvalidParams, `XCResult file not found: ${xcresultPath}`);
}
if (!xcresultPath.endsWith('.xcresult')) {
throw new McpError(ErrorCode.InvalidParams, `Path must be an .xcresult file: ${xcresultPath}`);
}
// Check if xcresult is readable
if (!XCResultParser.isXCResultReadable(xcresultPath)) {
throw new McpError(ErrorCode.InternalError, `XCResult file is not readable or incomplete: ${xcresultPath}`);
}
try {
const parser = new XCResultParser(xcresultPath);
const analysis = await parser.analyzeXCResult();
let output = `š XCResult Summary - ${xcresultPath}\n`;
output += '='.repeat(80) + '\n\n';
output += `Result: ${analysis.summary.result === 'Failed' ? 'ā' : 'ā
'} ${analysis.summary.result}\n`;
output += `Total: ${analysis.totalTests} | Passed: ${analysis.passedTests} ā
| Failed: ${analysis.failedTests} ā | Skipped: ${analysis.skippedTests} āļø\n`;
output += `Pass Rate: ${analysis.passRate.toFixed(1)}%\n`;
output += `Duration: ${analysis.duration}\n\n`;
if (analysis.failedTests > 0) {
output += `ā Failed Tests:\n`;
for (const failure of analysis.summary.testFailures.slice(0, 5)) {
output += ` ⢠${failure.testName}: ${failure.failureText.substring(0, 100)}${failure.failureText.length > 100 ? '...' : ''}\n`;
}
if (analysis.summary.testFailures.length > 5) {
output += ` ... and ${analysis.summary.testFailures.length - 5} more\n`;
}
output += '\n';
}
output += `š” Use 'xcresult_browse "${xcresultPath}"' to explore detailed results.`;
return { content: [{ type: 'text', text: output }] };
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes('xcresulttool')) {
throw new McpError(ErrorCode.InternalError, `XCResult parsing failed. Make sure Xcode Command Line Tools are installed: ${errorMessage}`);
}
throw new McpError(ErrorCode.InternalError, `Failed to analyze XCResult: ${errorMessage}`);
}
}
/**
* List all attachments for a test
*/
static async xcresultListAttachments(xcresultPath, testId) {
// Validate xcresult path
if (!existsSync(xcresultPath)) {
throw new McpError(ErrorCode.InvalidParams, `XCResult file not found: ${xcresultPath}`);
}
if (!xcresultPath.endsWith('.xcresult')) {
throw new McpError(ErrorCode.InvalidParams, `Path must be an .xcresult file: ${xcresultPath}`);
}
// Check if xcresult is readable
if (!XCResultParser.isXCResultReadable(xcresultPath)) {
throw new McpError(ErrorCode.InternalError, `XCResult file is not readable or incomplete: ${xcresultPath}`);
}
if (!testId || testId.trim() === '') {
throw new McpError(ErrorCode.InvalidParams, 'Test ID or index is required');
}
try {
const parser = new XCResultParser(xcresultPath);
// First find the test node to get the actual test identifier
const testNode = await parser.findTestNode(testId);
if (!testNode) {
throw new McpError(ErrorCode.InvalidParams, `Test '${testId}' not found. Run xcresult_browse "${xcresultPath}" to see all available tests`);
}
if (!testNode.nodeIdentifier) {
throw new McpError(ErrorCode.InvalidParams, `Test '${testId}' does not have a valid identifier for attachment retrieval`);
}
// Get test attachments
const attachments = await parser.getTestAttachments(testNode.nodeIdentifier);
let output = `š Attachments for test: ${testNode.name}\n`;
output += `Found ${attachments.length} attachments\n`;
output += '='.repeat(80) + '\n\n';
if (attachments.length === 0) {
output += 'No attachments found for this test.\n';
}
else {
attachments.forEach((att, index) => {
const filename = att.name || att.filename || 'unnamed';
output += `[${index + 1}] ${filename}\n`;
// Determine type from identifier or filename
let type = att.uniform_type_identifier || att.uniformTypeIdentifier || '';
if (!type || type === 'unknown') {
// Infer type from filename extension or special patterns
const ext = filename.toLowerCase().split('.').pop();
if (ext === 'jpeg' || ext === 'jpg')
type = 'public.jpeg';
else if (ext === 'png')
type = 'public.png';
else if (ext === 'mp4')
type = 'public.mpeg-4';
else if (ext === 'mov')
type = 'com.apple.quicktime-movie';
else if (ext === 'txt')
type = 'public.plain-text';
else if (filename.toLowerCase().includes('app ui hierarchy'))
type = 'ui-hierarchy';
else if (filename.toLowerCase().includes('ui snapshot'))
type = 'ui-snapshot';
else if (filename.toLowerCase().includes('synthesized event'))
type = 'synthesized-event';
else
type = 'unknown';
}
output += ` Type: ${type}\n`;
if (att.payloadSize || att.payload_size) {
output += ` Size: ${att.payloadSize || att.payload_size} bytes\n`;
}
output += '\n';
});
output += '\nš” To export a specific attachment, use xcresult_export_attachment with the attachment index.\n';
}
return { content: [{ type: 'text', text: output }] };
}
catch (error) {
if (error instanceof McpError) {
throw error;
}
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes('xcresulttool')) {
throw new McpError(ErrorCode.InternalError, `XCResult parsing failed. Make sure Xcode Command Line Tools are installed: ${errorMessage}`);
}
throw new McpError(ErrorCode.InternalError, `Failed to list attachments: ${errorMessage}`);
}
}
/**
* Export a specific attachment by index
*/
static async xcresultExportAttachment(xcresultPath, testId, attachmentIndex, convertToJson = false) {
// Validate xcresult path
if (!existsSync(xcresultPath)) {
throw new McpError(ErrorCode.InvalidParams, `XCResult file not found: ${xcresultPath}`);
}
if (!xcresultPath.endsWith('.xcresult')) {
throw new McpError(ErrorCode.InvalidParams, `Path must be an .xcresult file: ${xcresultPath}`);
}
// Check if xcresult is readable
if (!XCResultParser.isXCResultReadable(xcresultPath)) {
throw new McpError(ErrorCode.InternalError, `XCResult file is not readable or incomplete: ${xcresultPath}`);
}
if (!testId || testId.trim() === '') {
throw new McpError(ErrorCode.InvalidParams, 'Test ID or index is required');
}
if (attachmentIndex < 1) {
throw new McpError(ErrorCode.InvalidParams, 'Attachment index must be 1 or greater');
}
try {
const parser = new XCResultParser(xcresultPath);
// First find the test node to get the actual test identifier
const testNode = await parser.findTestNode(testId);
if (!testNode) {
throw new McpError(ErrorCode.InvalidParams, `Test '${testId}' not found. Run xcresult_browse "${xcresultPath}" to see all available tests`);
}
if (!testNode.nodeIdentifier) {
throw new McpError(ErrorCode.InvalidParams, `Test '${testId}' does not have a valid identifier for attachment retrieval`);
}
// Get test attachments
const attachments = await parser.getTestAttachments(testNode.nodeIdentifier);
if (attachments.length === 0) {
throw new McpError(ErrorCode.InvalidParams, `No attachments found for test '${testNode.name}'.`);
}
if (attachmentIndex > attachments.length) {
throw new McpError(ErrorCode.InvalidParams, `Invalid attachment index ${attachmentIndex}. Test has ${attachments.length} attachments.`);
}
const attachment = attachments[attachmentIndex - 1];
if (!attachment) {
throw new McpError(ErrorCode.InternalError, `Attachment at index ${attachmentIndex} not found`);
}
const attachmentId = attachment.payloadId || attachment.payload_uuid || attachment.payloadUUID;
if (!attachmentId) {
throw new McpError(ErrorCode.InternalError, 'Attachment does not have a valid ID for export');
}
const filename = attachment.filename || attachment.name || `attachment_${attachmentIndex}`;
// Determine type from identifier or filename first
let type = attachment.uniform_type_identifier || attachment.uniformTypeIdentifier || '';
if (!type || type === 'unknown') {
// Infer type from filename extension or special patterns
const ext = filename.toLowerCase().split('.').pop();
if (ext === 'jpeg' || ext === 'jpg')
type = 'public.jpeg';
else if (ext === 'png')
type = 'public.png';
else if (ext === 'mp4')
type = 'public.mpeg-4';
else if (ext === 'mov')
type = 'com.apple.quicktime-movie';
else if (ext === 'txt')
type = 'public.plain-text';
else if (filename.toLowerCase().includes('app ui hierarchy'))
type = 'ui-hierarchy';
else if (filename.toLowerCase().includes('ui snapshot'))
type = 'ui-snapshot';
else if (filename.toLowerCase().includes('synthesized event'))
type = 'synthesized-event';
else
type = 'unknown';
}
const exportedPath = await parser.exportAttachment(attachmentId, filename);
// Handle UI hierarchy files specially
if (type === 'ui-hierarchy') {
if (convertToJson) {
const hierarchyJson = await this.convertUIHierarchyToJSON(exportedPath);
return {
content: [{
type: 'text',
text: JSON.stringify(hierarchyJson)
}]
};
}
// Return the raw UI hierarchy content (it's already AI-friendly)
const { readFile } = await import('fs/promises');
const hierarchyContent = await readFile(exportedPath, 'utf-8');
return {
content: [{
type: 'text',
text: `UI Hierarchy for: ${filename}\nType: ${type}\n\n${hierarchyContent}`
}]
};
}
return {
content: [{
type: 'text',
text: `Attachment exported to: ${exportedPath}\nFilename: ${filename}\nType: ${type}`
}]
};
}
catch (error) {
if (error instanceof McpError) {
throw error;
}
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes('xcresulttool')) {
throw new McpError(ErrorCode.InternalError, `XCResult parsing failed. Make sure Xcode Command Line Tools are installed: ${errorMessage}`);
}
throw new McpError(ErrorCode.InternalError, `Failed to export attachment: ${errorMessage}`);
}
}
/**
* Get UI hierarchy attachment from test as JSON (slim AI-readable version by default)
*/
static async xcresultGetUIHierarchy(xcresultPath, testId, timestamp, fullHierarchy = false, rawFormat = false) {
// Validate xcresult path
if (!existsSync(xcresultPath)) {
throw new McpError(ErrorCode.InvalidParams, `XCResult file not found: ${xcresultPath}`);
}
if (!xcresultPath.endsWith('.xcresult')) {
throw new McpError(ErrorCode.InvalidParams, `Path must be an .xcresult file: ${xcresultPath}`);
}
// Check if xcresult is readable
if (!XCResultParser.isXCResultReadable(xcresultPath)) {
throw new McpError(ErrorCode.InternalError, `XCResult file is not readable or incomplete: ${xcresultPath}`);
}
if (!testId || testId.trim() === '') {
throw new McpError(ErrorCode.InvalidParams, 'Test ID or index is required');
}
try {
const parser = new XCResultParser(xcresultPath);
// First find the test node to get the actual test identifier
const testNode = await parser.findTestNode(testId);
if (!testNode) {
throw new McpError(ErrorCode.InvalidParams, `Test '${testId}' not found. Run xcresult_browse "${xcresultPath}" to see all available tests`);
}
if (!testNode.nodeIdentifier) {
throw new McpError(ErrorCode.InvalidParams, `Test '${testId}' does not have a valid identifier for attachment retrieval`);
}
// Get test attachments
const attachments = await parser.getTestAttachments(testNode.nodeIdentifier);
if (attachments.length === 0) {
throw new McpError(ErrorCode.InvalidParams, `No attachments found for test '${testNode.name}'. This test may not have UI snapshots.`);
}
Logger.info(`Found ${attachments.length} attachments for test ${testNode.name}`);
// Log all attachment details for debugging
Logger.info('All attachments:');
attachments.forEach((att, index) => {
Logger.info(` ${index + 1}. Name: ${att.name || att.filename || 'unnamed'}, Type: ${att.uniform_type_identifier || att.uniformTypeIdentifier || 'unknown'}`);
});
// Look for App UI hierarchy attachments (text-based)
const uiHierarchyAttachments = this.findAppUIHierarchyAttachments(attachments);
if (uiHierarchyAttachments.length === 0) {
const attachmentNames = attachments.map(a => a.name || a.filename || 'unnamed').join(', ');
throw new McpError(ErrorCode.InvalidParams, `No App UI hierarchy attachments found for test '${testNode.name}'. Available attachments: ${attachmentNames}`);
}
// If timestamp is provided, find the closest UI hierarchy attachment
let selectedAttachment = uiHierarchyAttachments[0];
if (timestamp !== undefined && uiHierarchyAttachments.length > 1) {
Logger.info(`Looking for UI hierarchy closest to timestamp ${timestamp}s`);
const closestAttachment = this.findClosestUISnapshot(uiHierarchyAttachments, timestamp);
if (closestAttachment) {
selectedAttachment = closestAttachment;
}
}
else if (uiHierarchyAttachments.length > 1) {
Logger.info(`Multiple UI hierarchy attachments found (${uiHierarchyAttachments.length}). Using the first one. Specify a timestamp to select a specific one.`);
}
if (!selectedAttachment) {
throw new McpError(ErrorCode.InternalError, `No valid UI hierarchy found for test '${testNode.name}'`);
}
// If raw format is requested, return the original accessibility tree text
if (rawFormat) {
const attachmentId = selectedAttachment.payloadId || selectedAttachment.payload_uuid || selectedAttachment.payloadUUID;
if (!attachmentId) {
throw new McpError(ErrorCode.InternalError, 'UI hierarchy attachment does not have a valid ID for export');
}
const filename = selectedAttachment.filename || selectedAttachment.name || 'ui-hierarchy';
const exportedPath = await parser.exportAttachment(attachmentId, filename);
const { readFile } = await import('fs/promises');
const hierarchyContent = await readFile(exportedPath, 'utf-8');
const timestampInfo = timestamp !== undefined && selectedAttachment.timestamp !== undefined
? ` (closest to ${timestamp}s, actual: ${selectedAttachment.timestamp}s)`
: '';
return {
content: [{
type: 'text',
text: `š² Raw UI Hierarchy for test '${testNode.name}'${timestampInfo}:\n\n${hierarchyContent}`
}]
};
}
// Export and convert text-based UI hierarchy to JSON
const hierarchyData = await this.exportTextUIHierarchyAsJSON(parser, selectedAttachment, testNode.name);
if (fullHierarchy) {
// Save full JSON to file with warning
const jsonFilename = `ui_hierarchy_full_${testNode.name.replace(/[^a-zA-Z0-9]/g, '_')}_${Date.now()}.json`;
const jsonPath = await this.saveUIHierarchyJSON(hierarchyData, jsonFilename);
const fileSizeKB = Math.round(JSON.stringify(hierarchyData).length / 1024);
return {
content: [{
type: 'text',
text: `ā ļø LARGE FILE WARNING: Full UI hierarchy exported (${fileSizeKB} KB)\n\n` +
`š Full hierarchy: ${jsonPath}\n\n` +
`š” For AI analysis, consider using the slim version instead:\n` +
` xcresult_get_ui_hierarchy "${xcresultPath}" "${testId}" ${timestamp || ''} false`
}]
};
}
else {
// Default: Create and save slim AI-readable version
const slimData = this.createSlimUIHierarchy(hierarchyData);
const slimFilename = `ui_hierarchy_${testNode.name.replace(/[^a-zA-Z0-9]/g, '_')}_${Date.now()}.json`;
const slimPath = await this.saveUIHierarchyJSON(slimData, slimFilename);
// Also save full data for element lookup
const fullFilename = `ui_hierarchy_full_${testNode.name.replace(/[^a-zA-Z0-9]/g, '_')}_${Date.now()}.json`;
const fullPath = await this.saveUIHierarchyJSON(hierarchyData, fullFilename);
// Try to find a screenshot at the specified timestamp
let screenshotInfo = '';
const screenshotTimestamp = timestamp || selectedAttachment.timestamp;
if (screenshotTimestamp !== undefined) {
try {
const screenshotResult = await this.xcresultGetScreenshot(xcresultPath, testId, screenshotTimestamp);
if (screenshotResult && screenshotResult.content?.[0] && 'text' in screenshotResult.content[0]) {
const textContent = screenshotResult.content[0];
if (textContent.type === 'text' && typeof textContent.text === 'string') {
// Extract the screenshot path from the result text
const pathMatch = textContent.text.match(/Screenshot extracted .+: (.+)/);
if (pathMatch && pathMatch[1]) {
screenshotInfo = `\nšø Screenshot at timestamp ${screenshotTimestamp}s: ${pathMatch[1]}`;
}
}
}
}
catch (error) {
// Screenshot extraction failed, continue without it
Logger.info(`Could not extract screenshot at timestamp ${screenshotTimestamp}s: ${error}`);
}
}
return {
content: [{
type: 'text',
text: `š¤ AI-readable UI hierarchy: ${slimPath}\n\n` +
`š” Slim version properties:\n` +
` ⢠t = type (element type like Button, StaticText, etc.)\n` +
` ⢠l = label (visible text/accessibility label)\n` +
` ⢠c = children (array of child elements)\n` +
` ⢠j = index (reference to full element in original JSON)\n\n` +
`š Use xcresult_get_ui_element "${fullPath}" <index> to get full details of any element.\n` +
`ā ļø To get the full hierarchy (several MB), use: full_hierarchy=true${screenshotInfo}`
}]
};
}
}
catch (error) {
if (error instanceof McpError) {
throw error;
}
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes('xcresulttool')) {
throw new McpError(ErrorCode.InternalError, `XCResult parsing failed. Make sure Xcode Command Line Tools are installed: ${errorMessage}`);
}
throw new McpError(ErrorCode.InternalError, `Failed to get UI hierarchy: ${errorMessage}`);
}
}
/**
* Get screenshot from failed test - returns direct screenshot or extracts from video
*/
static async xcresultGetScreenshot(xcresultPath, testId, timestamp) {
// Validate xcresult path
if (!existsSync(xcresultPath)) {
throw new McpError(ErrorCode.InvalidParams, `XCResult file not found: ${xcresultPath}`);
}
if (!xcresultPath.endsWith('.xcresult')) {
throw new McpError(ErrorCode.InvalidParams, `Path must be an .xcresult file: ${xcresultPath}`);
}
// Check if xcresult is readable
if (!XCResultParser.isXCResultReadable(xcresultPath)) {
throw new McpError(ErrorCode.InternalError, `XCResult file is not readable or incomplete: ${xcresultPath}`);
}
if (!testId || testId.trim() === '') {
throw new McpError(ErrorCode.InvalidParams, 'Test ID or index is required');
}
try {
const parser = new XCResultParser(xcresultPath);
// First find the test node to get the actual test identifier
const testNode = await parser.findTestNode(testId);
if (!testNode) {
throw new McpError(ErrorCode.InvalidParams, `Test '${testId}' not found. Run xcresult_browse "${xcresultPath}" to see all available tests`);
}
if (!testNode.nodeIdentifier) {
throw new McpError(ErrorCode.InvalidParams, `Test '${testId}' does not have a valid identifier for attachment retrieval`);
}
// Get test attachments
const attachments = await parser.getTestAttachments(testNode.nodeIdentifier);
if (attachments.length === 0) {
throw new McpError(ErrorCode.InvalidParams, `No attachments found for test '${testNode.name}'. This test may not have failed or may not have generated screenshots/videos.`);
}
Logger.info(`Found ${attachments.length} attachments for test ${testNode.name}`);
// Look for video attachment first (gives us actual PNG images)
const videoAttachment = this.findVideoAttachment(attachments);
if (videoAttachment) {
const screenshotPath = await this.extractScreenshotFromVideo(parser, videoAttachment, testNode.name, timestamp);
return {
content: [{
type: 'text',
text: `Screenshot extracted from video for test '${testNode.name}' at ${timestamp}s: ${screenshotPath}`
}]
};
}
// Look for direct image attachment (PNG or JPEG) as fallback
const closestImageResult = this.findClosestImageAttachment(attachments, timestamp);
if (closestImageResult) {
const screenshotPath = await this.exportScreenshotAttachment(parser, closestImageResult.attachment);
const timeDiff = closestImageResult.timeDifference;
const timeDiffText = timeDiff === 0
? 'at exact timestamp'
: timeDiff > 0
? `${timeDiff.toFixed(2)}s after requested time`
: `${Math.abs(timeDiff).toFixed(2)}s before requested time`;
return {
content: [{
type: 'text',
text: `Screenshot exported for test '${testNode.name}' (${timeDiffText}): ${screenshotPath}`
}]
};
}
// No suitable attachments found
const attachmentTypes = attachments.map(a => a.uniform_type_identifier || a.uniformTypeIdentifier || 'unknown').join(', ');
throw new McpError(ErrorCode.InvalidParams, `No screenshot or video attachments found for test '${testNode.name}'. Available attachment types: ${attachmentTypes}`);
}
catch (error) {
if (error instanceof McpError) {
throw error;
}
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes('xcresulttool')) {
throw new McpError(ErrorCode.InternalError, `XCResult parsing failed. Make sure Xcode Command Line Tools are installed: ${errorMessage}`);
}
throw new McpError(ErrorCode.InternalError, `Failed to get screenshot: ${errorMessage}`);
}
}
/**
* Find App UI hierarchy attachments (text-based)
*/
static findAppUIHierarchyAttachments(attachments) {
return attachments.filter(attachment => {
const name = attachment.name || attachment.filename || '';
return name.includes('App UI hierarchy');
});
}
/**
* Find UI Snapshot attachments (legacy method)
*/
// private static findUISnapshotAttachments(attachments: TestAttachment[]): TestAttachment[] {
// return attachments.filter(attachment => {
// const name = attachment.name || attachment.filename || '';
// // Look for both "UI Snapshot" and "App UI hierarchy" attachments
// return name.includes('UI Snapshot') || name.includes('App UI hierarchy');
// });
// }
/**
* Find the UI snapshot closest to a given timestamp
*/
static findClosestUISnapshot(attachments, timestamp) {
if (attachments.length === 0) {
return undefined;
}
// If only one attachment, return it
if (attachments.length === 1) {
return attachments[0];
}
let closest = attachments[0];
let minDifference = Infinity;
let attachmentsWithTimestamps = 0;
// Log available attachments and their timestamps for debugging
Logger.info(`Finding closest attachment to timestamp ${timestamp}s among ${attachments.length} attachments`);
for (const attachment of attachments) {
// Use timestamp if available, otherwise try to extract from name
let attachmentTime = attachment.timestamp;
// If no direct timestamp, try alternative approaches
if (attachmentTime === undefined || isNaN(attachmentTime)) {
// Try to extract timestamp from attachment name if it contains time info
if (attachment.name) {
const timeMatch = attachment.name.match(/t\s*=\s*([\d.]+)s/);
if (timeMatch && timeMatch[1]) {
attachmentTime = parseFloat(timeMatch[1]);
}
}
}
// Log attachment details for debugging
Logger.info(` Attachment "${attachment.name}" - timestamp: ${attachmentTime}, type: ${attachment.uniform_type_identifier || attachment.uniformTypeIdentifier || 'unknown'}`);
if (attachmentTime !== undefined && !isNaN(attachmentTime)) {
attachmentsWithTimestamps++;
// Both timestamps should be in seconds
const difference = Math.abs(attachmentTime - timestamp);
Logger.info(` Time difference: ${difference}s`);
if (difference < minDifference) {
minDifference = difference;
closest = attachment;
}
}
}
// If no attachments have timestamps, use a different strategy
if (attachmentsWithTimestamps === 0) {
Logger.info(`No timestamp information found for UI hierarchy attachments. Using attachment order heuristic.`);
// For UI hierarchy attachments without timestamps, prefer the later one
// when the requested timestamp is > 60s (indicating late in test execution)
if (timestamp > 60 && attachments.length >= 2) {
const lastAttachment = attachments[attachments.length - 1];
if (lastAttachment) {
closest = lastAttachment; // Use last attachment
Logger.info(`Selected last attachment "${closest.name || 'unnamed'}" based on late timestamp heuristic (${timestamp}s > 60s)`);
}
}
else {
const firstAttachment = attachments[0];
if (firstAttachment) {
closest = firstAttachment; // Use first attachment for early timestamps
Logger.info(`Selected first attachment "${closest.name || 'unnamed'}" based on early timestamp heuristic (${timestamp}s <= 60s)`);
}
}
}
else if (closest) {
Logger.info(`Selected attachment "${closest.name}" with minimum time difference of ${minDifference}s (found timestamps on ${attachmentsWithTimestamps}/${attachments.length} attachments)`);
}
return closest;
}
/**
* Export UI hierarchy attachment and convert to JSON (legacy plist method)
*/
// private static async exportUIHierarchyAsJSON(parser: XCResultParser, attachment: TestAttachment, testName: string): Promise<any> {
// const attachmentId = attachment.payloadId || attachment.payload_uuid || attachment.payloadUUID;
// if (!attachmentId) {
// throw new Error('UI Snapshot attachment does not have a valid ID for export');
// }
// // Export the UI snapshot to a temporary file
// const filename = `ui_hierarchy_${testName.replace(/[^a-zA-Z0-9]/g, '_')}_${Date.now()}.plist`;
// const plistPath = await parser.exportAttachment(attachmentId, filename);
// Logger.info(`Exported UI hierarchy to: ${plistPath}`);
// // Convert the plist to JSON using a more robust approach
// return await this.convertUIHierarchyToJSON(plistPath);
// }
/**
* Convert UI hierarchy plist to JSON using plutil -p (readable format)
*/
static async convertUIHierarchyToJSON(plistPath) {
return new Promise((resolve, reject) => {
// Use plutil -p to get a readable format, then parse it
const process = spawn('plutil', ['-p', plistPath], {
stdio: ['pipe', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
process.stdout.on('data', (data) => {
stdout += data.toString();
});
process.stderr.on('data', (data) => {
stderr += data.toString();
});
process.on('close', (code) => {
if (code === 0) {
// Parse the plutil -p output
this.parsePlutilOutput(stdout)
.then(resolve)
.catch(reject);
}
else {
Logger.error(`plutil failed with code ${code}: ${stderr}`);
reject(new Error(`Failed to read plist: ${stderr}`));
}
});
process.on('error', (error) => {
reject(new Error(`Failed to run plutil: ${error.message}`));
});
});
}
/**
* Parse plutil -p output to extract UI hierarchy information
*/
static async parsePlutilOutput(plutilOutput) {
return new Promise((resolve) => {
try {
// Extract meaningful UI hierarchy data from plutil output
const lines = plutilOutput.split('\n');
// Look for the main UI element structure
const uiElement = {
parseMethod: 'plutil_readable',
rawPlistSize: lines.length,
};
// Extract key information
for (let i = 0; i < lines.length; i++) {
const line = lines[i]?.trim();
if (!line)
continue;
// Look for elementType
if (line.includes('"elementType"')) {
const nextLine = lines[i + 1]?.trim();
if (nextLine && nextLine.includes('value =')) {
const valueMatch = nextLine.match(/value = (\d+)/);
if (valueMatch && valueMatch[1]) {
const elementType = parseInt(valueMatch[1]);
uiElement.elementType = elementType;
// Add description
const descriptions = {
1: 'Application', 2: 'Group', 3: 'Window', 8: 'Button',
20: 'NavigationBar', 21: 'TabBar', 22: 'TabGroup', 23: 'Toolbar', 25: 'Table',
31: 'CollectionView', 36: 'SegmentedControl', 45: 'ScrollView', 47: 'StaticText', 48: 'TextField'
};
if (descriptions[elementType]) {
uiElement.elementTypeDescription = descriptions[elementType];
}
}
}
}
// Look for label
if (line.includes('"label"')) {
const nextLine = lines[i + 1]?.trim();
if (nextLine && nextLine.includes('value =')) {
const valueMatch = nextLine.match(/value = (\d+)/);
if (valueMatch && valueMatch[1]) {
const labelIndex = parseInt(valueMatch[1]);
// Find the actual label value by looking for index references
for (let j = 0; j < lines.length; j++) {
const currentLine = lines[j];
if (currentLine && currentLine.includes(`${labelIndex} =>`)) {
const labelLine = lines[j + 1]?.trim();
if (labelLine && labelLine.startsWith('"') && labelLine.endsWith('"')) {
uiElement.label = labelLine.slice(1, -1); // Remove quotes
}
break;
}
}
}
}
}
// Look for identifier
if (line.includes('"identifier"')) {
const nextLine = lines[i + 1]?.trim();
if (nextLine && nextLine.includes('value =')) {
const valueMatch = nextLine.match(/value = (\d+)/);
if (valueMatch && valueMatch[1]) {
const idIndex = parseInt(valueMatch[1]);
// Find the actual identifier value
for (let j = 0; j < lines.length; j++) {
const currentLine = lines[j];
if (currentLine && currentLine.includes(`${idIndex} =>`)) {
const idLine = lines[j + 1]?.trim();
if (idLine && idLine.startsWith('"')) {
uiElement.identifier = idLine.slice(1, -1); // Remove quotes
}
break;
}
}
}
}
}
// Look for enabled
if (line.includes('"enabled"')) {
const nextLine = lines[i + 1]?.trim();
if (nextLine && nextLine.includes('value =')) {
const valueMatch = nextLine.match(/value = (\d+)/);
if (valueMatch && valueMatch[1]) {
const enabledIndex = parseInt(valueMatch[1]);
// Find the actual enabled value
for (let j = 0; j < lines.length; j++) {
const currentLine = lines[j];
if (currentLine && currentLine.includes(`${enabledIndex} =>`)) {
const enabledLine = lines[j + 1]?.trim();
if (enabledLine === '1' || enabledLine === '0') {
uiElement.enabled = enabledLine === '1';
}
break;
}
}
}
}
}
}
// Extract some statistics
const objectCount = plutilOutput.match(/\d+ =>/g)?.length || 0;
uiElement.totalObjectsInPlist = objectCount;
// Look for Transit app information and other string values
if (plutilOutput.includes('Transit')) {
const transitMatches = plutilOutput.match(/"[^"]*Transit[^"]*"/g) || [];
uiElement.transitAppReferences = transitMatches;
}
// Try to extract the label directly from known patterns
if (plutilOutput.includes('19 => "Transit Ļ"')) {
uiElement.label = 'Transit Ļ';
}
// Extract all string values for debugging
const allStrings = plutilOutput.match(/"[^"]+"/g) || [];
uiElement.allStringsFound = allStrings.slice(0, 10); // First 10 strings
resolve(uiElement);
}
catch (error) {
resolve({
parseMethod: 'plutil_readable',
error: `Failed to parse plutil output: ${error instanceof Error ? error.message : String(error)}`,
rawOutputPreview: plutilOutput.slice(0, 500)
});
}
});
}
/**
* Export text-based UI hierarchy attachment and convert to JSON
*/
static async exportTextUIHierarchyAsJSON(parser, attachment, testName) {
const attachmentId = attachment.payloadId || attachment.payload_uuid || attachment.payloadUUID;
if (!attachmentId) {
throw new Error('App UI hierarchy attachment does not have a valid ID for export');
}
// Export the UI hierarchy to a temporary file
const filename = `ui_hierarchy_${testName.replace(/[^a-zA-Z0-9]/g, '_')}_${Date.now()}.txt`;
const textPath = await parser.exportAttachment(attachmentId, filename);
Logger.info(`Exported UI hierarchy to: ${textPath}`);
// Convert the indented text format to JSON
return await this.convertIndentedUIHierarchyToJSON(textPath);
}
/**
* Convert indented text-based UI hierarchy to structured JSON
*/
static async convertIndentedUIHierarchyToJSON(textPath) {
const fs = await import('fs');
try {
const content = fs.readFileSync(textPath, 'utf8');
const lines = content.split('\n');
const result = {
parseMethod: 'indented_text',
totalLines: lines.length,
rootElement: null,
flatElements: []
};
let elementStack = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line || !line.trim())
continue;
// Calculate indentation level
const indentLevel = line.search(/\S/);
const trimmedLine = line.trim();
// Parse element information
const element = this.parseUIElementLine(trimmedLine, in