UNPKG

xcodemcp

Version:

Model Context Protocol server for Xcode build automation and log parsing

905 lines • 65.3 kB
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