import { z } from 'zod';
import { xcodebuildWithResultBundle } from '../executor/xcodebuild.js';
import { resolveProjectArgs, getWorkingDirectory } from '../utils/project-resolver.js';
import { buildSimulatorDestination } from '../utils/device-resolver.js';
import { formatResultSummary } from '../utils/result-bundle-parser.js';
import { XCODEBUILD_TIMEOUTS } from '../types/xcodebuild.js';
export const xcodebuildTestSchema = z.object({
workspace: z
.string()
.optional()
.describe('Explicit workspace path (.xcworkspace)'),
project: z
.string()
.optional()
.describe('Explicit project path (.xcodeproj)'),
directory: z
.string()
.optional()
.describe('Directory to search for workspace/project (defaults to cwd)'),
scheme: z
.string()
.describe('Scheme to test (required)'),
destination: z
.string()
.optional()
.describe('Test destination. Can be a device name (e.g., "iPhone 16 Pro") which will be auto-resolved, or a full destination string'),
sdk: z
.string()
.optional()
.describe('SDK to use (e.g., "iphonesimulator", "macosx")'),
test_plan: z
.string()
.optional()
.describe('Test plan to run'),
only_testing: z
.array(z.string())
.optional()
.describe('Specific tests to run (e.g., ["MyTests/testExample", "MyTests/testOther"])'),
skip_testing: z
.array(z.string())
.optional()
.describe('Tests to skip'),
derived_data_path: z
.string()
.optional()
.describe('Custom derived data path'),
build_settings: z
.record(z.string(), z.string())
.optional()
.describe('Build settings to pass to xcodebuild (e.g., {"CODE_SIGN_IDENTITY": "", "CODE_SIGNING_REQUIRED": "NO"})'),
});
export type XcodebuildTestInput = z.infer<typeof xcodebuildTestSchema>;
function validateInput(input: XcodebuildTestInput): void {
if (input.workspace && input.project) {
throw new Error('Cannot specify both workspace and project');
}
}
export const xcodebuildTestTool = {
name: 'xcodebuild_test',
description: 'Run tests for an Xcode scheme. Returns structured test results with pass/fail counts and failure details.',
inputSchema: xcodebuildTestSchema,
handler: async (input: XcodebuildTestInput) => {
validateInput(input);
const projectArgs = resolveProjectArgs(input);
const cwd = getWorkingDirectory(input);
const args: string[] = [
...projectArgs,
'-scheme', input.scheme,
];
// Add destination if specified
if (input.destination) {
const destination = buildSimulatorDestination(input.destination);
args.push('-destination', destination);
}
// Add optional arguments
if (input.sdk) {
args.push('-sdk', input.sdk);
}
if (input.test_plan) {
args.push('-testPlan', input.test_plan);
}
if (input.only_testing) {
for (const test of input.only_testing) {
args.push('-only-testing', test);
}
}
if (input.skip_testing) {
for (const test of input.skip_testing) {
args.push('-skip-testing', test);
}
}
if (input.derived_data_path) {
args.push('-derivedDataPath', input.derived_data_path);
}
// Add build settings as KEY=VALUE arguments
if (input.build_settings) {
for (const [key, value] of Object.entries(input.build_settings)) {
args.push(`${key}=${value}`);
}
}
const result = xcodebuildWithResultBundle('test', args, {
cwd,
timeout: XCODEBUILD_TIMEOUTS.test,
});
// Format output
const lines: string[] = [];
if (result.exitCode === 0) {
lines.push(`Tests passed for scheme "${input.scheme}"`);
} else {
lines.push(`Tests failed for scheme "${input.scheme}"`);
}
// Add result bundle summary if available
if (result.resultBundle) {
lines.push('');
lines.push(formatResultSummary(result.resultBundle));
}
// If tests failed and no structured results, include raw output
if (result.exitCode !== 0 && !result.resultBundle?.testResult) {
lines.push('');
lines.push('Output:');
const output = result.stderr || result.stdout;
if (output.length > 5000) {
lines.push('... (earlier output truncated)');
lines.push(output.slice(-5000));
} else {
lines.push(output);
}
}
return {
content: [
{
type: 'text' as const,
text: lines.join('\n'),
},
],
};
},
};