import { spawnSync } from 'node:child_process';
import { rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { randomUUID } from 'node:crypto';
import type {
ResultBundleSummary,
BuildResultSummary,
TestResultSummary,
BuildIssue,
TestFailure,
} from '../types/xcodebuild.js';
/**
* Execute xcresulttool with proper argument escaping.
*/
function xcresulttool(args: string[]): string | null {
const result = spawnSync('xcrun', ['xcresulttool', ...args], {
encoding: 'utf-8',
timeout: 30000,
});
if (result.status !== 0) {
return null;
}
return result.stdout;
}
/**
* Generate a unique temporary path for a result bundle.
*/
export function generateResultBundlePath(): string {
return join(tmpdir(), `xcodebuild-${randomUUID()}.xcresult`);
}
/**
* Clean up a result bundle.
*/
export function cleanupResultBundle(path: string): void {
try {
if (existsSync(path)) {
rmSync(path, { recursive: true, force: true });
}
} catch {
// Ignore cleanup errors
}
}
/**
* Parse build results from a result bundle using xcresulttool.
*/
export function parseBuildResults(bundlePath: string): BuildResultSummary | null {
if (!existsSync(bundlePath)) {
return null;
}
// Try with --legacy flag first
let output = xcresulttool(['get', 'build-results', '--path', bundlePath, '--legacy']);
// Try without --legacy flag for newer Xcode versions
if (output === null) {
output = xcresulttool(['get', 'build-results', '--path', bundlePath]);
}
if (output === null) {
return null;
}
try {
const data = JSON.parse(output);
return extractBuildSummary(data);
} catch {
return null;
}
}
/**
* Parse test results from a result bundle using xcresulttool.
*/
export function parseTestResults(bundlePath: string): TestResultSummary | null {
if (!existsSync(bundlePath)) {
return null;
}
const output = xcresulttool(['get', 'test-results', 'summary', '--path', bundlePath]);
if (output === null) {
return null;
}
try {
const data = JSON.parse(output);
return extractTestSummary(data);
} catch {
return null;
}
}
/**
* Parse a result bundle for both build and test results.
*/
export function parseResultBundle(bundlePath: string): ResultBundleSummary {
return {
buildResult: parseBuildResults(bundlePath) ?? undefined,
testResult: parseTestResults(bundlePath) ?? undefined,
};
}
/**
* Format a result summary as human-readable text.
*/
export function formatResultSummary(summary: ResultBundleSummary): string {
const lines: string[] = [];
if (summary.buildResult) {
const build = summary.buildResult;
lines.push(`Build: ${build.status.toUpperCase()}`);
if (build.errorCount > 0 || build.warningCount > 0) {
const parts: string[] = [];
if (build.errorCount > 0) parts.push(`${build.errorCount} error(s)`);
if (build.warningCount > 0) parts.push(`${build.warningCount} warning(s)`);
if (build.analyzerWarningCount > 0) parts.push(`${build.analyzerWarningCount} analyzer warning(s)`);
lines.push(` ${parts.join(', ')}`);
}
for (const error of build.errors.slice(0, 10)) {
const location = error.documentPath
? `${error.documentPath}${error.lineNumber ? `:${error.lineNumber}` : ''}: `
: '';
lines.push(` ERROR: ${location}${error.message}`);
}
if (build.errors.length > 10) {
lines.push(` ... and ${build.errors.length - 10} more error(s)`);
}
}
if (summary.testResult) {
const test = summary.testResult;
lines.push(`Tests: ${test.status.toUpperCase()}`);
lines.push(` Total: ${test.totalTests}, Passed: ${test.passedTests}, Failed: ${test.failedTests}, Skipped: ${test.skippedTests}`);
if (test.duration > 0) {
lines.push(` Duration: ${test.duration.toFixed(2)}s`);
}
for (const failure of test.failures.slice(0, 10)) {
const location = failure.location ? ` (${failure.location})` : '';
lines.push(` FAILED: ${failure.testName}${location}`);
lines.push(` ${failure.message}`);
}
if (test.failures.length > 10) {
lines.push(` ... and ${test.failures.length - 10} more failure(s)`);
}
}
return lines.join('\n');
}
// Helper functions to extract data from xcresulttool JSON output
function extractBuildSummary(data: unknown): BuildResultSummary {
const obj = data as Record<string, unknown>;
// Handle different xcresulttool output formats
const issues = obj.issues as Record<string, unknown>[] | undefined;
const errors: BuildIssue[] = [];
const warnings: BuildIssue[] = [];
let errorCount = 0;
let warningCount = 0;
let analyzerWarningCount = 0;
if (Array.isArray(issues)) {
for (const issue of issues) {
const issueType = issue.issueType as string | undefined;
const message = (issue.message as string) || (issue.description as string) || '';
const documentPath = issue.documentPath as string | undefined;
const lineNumber = issue.lineNumber as number | undefined;
const buildIssue: BuildIssue = { message, documentPath, lineNumber };
if (issueType === 'error' || issueType === 'Error') {
errors.push(buildIssue);
errorCount++;
} else if (issueType === 'analyzerWarning') {
warnings.push(buildIssue);
analyzerWarningCount++;
} else {
warnings.push(buildIssue);
warningCount++;
}
}
}
// Determine status
let status: 'succeeded' | 'failed' | 'cancelled' = 'succeeded';
if (obj.status === 'failed' || obj.succeeded === false || errorCount > 0) {
status = 'failed';
} else if (obj.status === 'cancelled') {
status = 'cancelled';
}
return {
status,
errorCount,
warningCount,
analyzerWarningCount,
errors,
warnings,
};
}
function extractTestSummary(data: unknown): TestResultSummary {
const obj = data as Record<string, unknown>;
const totalTests = (obj.totalTestCount as number) || (obj.total as number) || 0;
const passedTests = (obj.passedTestCount as number) || (obj.passed as number) || 0;
const failedTests = (obj.failedTestCount as number) || (obj.failed as number) || 0;
const skippedTests = (obj.skippedTestCount as number) || (obj.skipped as number) || 0;
const expectedFailures = (obj.expectedFailureCount as number) || 0;
const duration = (obj.totalDuration as number) || (obj.duration as number) || 0;
// Determine status
let status: 'passed' | 'failed' | 'mixed' | 'skipped';
if (totalTests === 0 || totalTests === skippedTests) {
status = 'skipped';
} else if (failedTests === 0) {
status = 'passed';
} else if (passedTests === 0) {
status = 'failed';
} else {
status = 'mixed';
}
// Extract failures
const failures: TestFailure[] = [];
const failedTestList = obj.failedTests as Record<string, unknown>[] | undefined;
if (Array.isArray(failedTestList)) {
for (const test of failedTestList) {
failures.push({
testName: (test.name as string) || (test.identifier as string) || 'Unknown',
message: (test.failureMessage as string) || (test.message as string) || 'Test failed',
location: test.location as string | undefined,
});
}
}
return {
status,
totalTests,
passedTests,
failedTests,
skippedTests,
expectedFailures,
duration,
failures,
};
}