Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

- Dictionary-shaped MCP inputs now use client-compatible wire representations ([#491](https://github.com/getsentry/XcodeBuildMCP/issues/491)). The `env` and `testRunnerEnv` inputs on build, launch, test, and session-default tools are arrays of `{ "key": "...", "value": "..." }` entries, while `xcode_ide_call_tool.arguments` is a JSON object string. XcodeBuildMCP converts these values to their existing internal objects only after MCP input validation.

### Fixed

- Managed `.xctestproducts` now prune promptly around builds and tests, defaulting to three bundles retained for one day. Set `XCODEBUILDMCP_TEST_PRODUCTS_MAX_COUNT` or `XCODEBUILDMCP_TEST_PRODUCTS_MAX_AGE_DAYS` to override those limits ([#524](https://github.com/getsentry/XcodeBuildMCP/issues/524)).

## [2.7.0]

### New! Xcode 27 Device Hub simulator support
Expand Down
2 changes: 2 additions & 0 deletions src/cli/__tests__/register-tool-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ const baseRuntimeConfig: ResolvedRuntimeConfig = {
showTestTiming: false,
uiDebuggerGuardMode: 'error',
incrementalBuildsEnabled: false,
testProductsMaxCount: 3,
testProductsMaxAgeDays: 1,
dapRequestTimeoutMs: 30_000,
dapLogEvents: false,
launchJsonWaitMs: 8_000,
Expand Down
2 changes: 2 additions & 0 deletions src/cli/__tests__/session-defaults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ describe('CLI session defaults', () => {
showTestTiming: false,
uiDebuggerGuardMode: 'error',
incrementalBuildsEnabled: false,
testProductsMaxCount: 3,
testProductsMaxAgeDays: 1,
dapRequestTimeoutMs: 30_000,
dapLogEvents: false,
launchJsonWaitMs: 8_000,
Expand Down
48 changes: 24 additions & 24 deletions src/mcp/tools/device/__tests__/test_device.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { mkdirSync } from 'node:fs';
import * as z from 'zod';
import { computeScopedDerivedDataPath } from '../../../../utils/derived-data-path.ts';
import {
Expand Down Expand Up @@ -27,6 +28,22 @@ const runTestDeviceLogic = (
fileSystemExecutor: Parameters<typeof testDeviceLogic>[2],
) => runToolLogic(() => testDeviceLogic(params, executor, fileSystemExecutor));

function createRequestedTestProducts(command: readonly string[]): void {
if (command.at(-1) !== 'build-for-testing') return;
const testProductsPath = command[command.indexOf('-testProductsPath') + 1];
if (testProductsPath) {
mkdirSync(testProductsPath, { recursive: true });
}
}

function createSuccessfulTestExecutor(output: string): ReturnType<typeof createMockExecutor> {
return createMockExecutor({
success: true,
output,
onExecute: createRequestedTestProducts,
});
}

function createSpyExecutor(): {
commandCalls: Array<{ args: string[]; logPrefix?: string }>;
executor: ReturnType<typeof createMockExecutor>;
Expand All @@ -36,6 +53,7 @@ function createSpyExecutor(): {
success: true,
output: 'Test Succeeded',
onExecute: (command, logPrefix) => {
createRequestedTestProducts(command);
commandCalls.push({ args: command, logPrefix });
},
});
Expand Down Expand Up @@ -82,10 +100,7 @@ describe('test_device plugin', () => {
});

it('should validate XOR between projectPath and workspacePath', async () => {
const mockExecutor = createMockExecutor({
success: true,
output: 'Test Succeeded',
});
const mockExecutor = createSuccessfulTestExecutor('Test Succeeded');

const { result: projectResult } = await runTestDeviceLogic(
{
Expand Down Expand Up @@ -199,10 +214,7 @@ describe('test_device plugin', () => {

describe('Handler Behavior (Complete Literal Returns)', () => {
it('should return pending response for successful tests', async () => {
const mockExecutor = createMockExecutor({
success: true,
output: 'Test Succeeded',
});
const mockExecutor = createSuccessfulTestExecutor('Test Succeeded');

const { result } = await runTestDeviceLogic(
{
Expand Down Expand Up @@ -270,10 +282,7 @@ describe('test_device plugin', () => {
});

it('should support different platforms', async () => {
const mockExecutor = createMockExecutor({
success: true,
output: 'Test Succeeded',
});
const mockExecutor = createSuccessfulTestExecutor('Test Succeeded');

const { result } = await runTestDeviceLogic(
{
Expand All @@ -293,10 +302,7 @@ describe('test_device plugin', () => {
});

it('should handle optional parameters', async () => {
const mockExecutor = createMockExecutor({
success: true,
output: 'Test Succeeded',
});
const mockExecutor = createSuccessfulTestExecutor('Test Succeeded');

const { result } = await runTestDeviceLogic(
{
Expand All @@ -318,10 +324,7 @@ describe('test_device plugin', () => {
});

it('should expose user-provided result bundle paths in test output', async () => {
const mockExecutor = createMockExecutor({
success: true,
output: 'Test Succeeded',
});
const mockExecutor = createSuccessfulTestExecutor('Test Succeeded');

const { result } = await runTestDeviceLogic(
{
Expand All @@ -346,10 +349,7 @@ describe('test_device plugin', () => {
});

it('should handle workspace testing successfully', async () => {
const mockExecutor = createMockExecutor({
success: true,
output: 'Test Succeeded',
});
const mockExecutor = createSuccessfulTestExecutor('Test Succeeded');

const { result } = await runTestDeviceLogic(
{
Expand Down
135 changes: 81 additions & 54 deletions src/mcp/tools/device/build_device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,27 +31,25 @@ import { displayPath } from '../../../utils/build-preflight.ts';
import { resolveEffectiveDerivedDataPath } from '../../../utils/derived-data-path.ts';
import { resolvePathFromCwd } from '../../../utils/path.ts';
import { filterTestProductsPathArgs } from '../../../utils/test-source.ts';
import {
createDefaultTestProductsPath,
findXctestrunPaths,
markTestProductsPathCompleted,
} from '../../../utils/test-products-path.ts';
import { findXctestrunPaths } from '../../../utils/test-products-path.ts';
import { withManagedTestProductsOutput } from '../../../utils/test-products-lifecycle.ts';
import { createBuildInvocationFragment } from '../../../utils/xcodebuild-pipeline.ts';

interface PreparedBuildDeviceExecution {
buildAction: 'build' | 'build-for-testing';
invocationRequest: BuildInvocationRequest;
isManagedTestProductsPath: boolean;
logLabel: 'Build' | 'Build for Testing';
sharedBuildParams: BuildDeviceParams;
testProductsPath?: string;
}

function prepareBuildDeviceExecution(params: BuildDeviceParams): PreparedBuildDeviceExecution {
function prepareBuildDeviceExecution(
params: BuildDeviceParams,
managedTestProductsPath?: string,
): PreparedBuildDeviceExecution {
const buildForTesting = params.buildForTesting ?? false;
const isManagedTestProductsPath = buildForTesting && params.testProductsPath === undefined;
const testProductsPath = buildForTesting
? (resolvePathFromCwd(params.testProductsPath) ?? createDefaultTestProductsPath('build_device'))
? (resolvePathFromCwd(params.testProductsPath) ?? managedTestProductsPath)
: undefined;
const sharedBuildParams = testProductsPath
? {
Expand All @@ -67,7 +65,6 @@ function prepareBuildDeviceExecution(params: BuildDeviceParams): PreparedBuildDe
return {
buildAction: buildForTesting ? 'build-for-testing' : 'build',
invocationRequest: createBuildDeviceRequest(params, testProductsPath),
isManagedTestProductsPath,
logLabel: buildForTesting ? 'Build for Testing' : 'Build',
sharedBuildParams,
testProductsPath,
Expand Down Expand Up @@ -140,58 +137,67 @@ export function createBuildDeviceExecutor(
prepared?: PreparedBuildDeviceExecution,
): StreamingExecutor<BuildDeviceParams, BuildDeviceResult> {
return async (params, ctx) => {
const resolved = prepared ?? prepareBuildDeviceExecution(params);
const platform = mapDevicePlatform(params.platform);
const started = createDomainStreamingPipeline('build_device', 'BUILD', ctx, 'build-result');

const buildResult = await executeXcodeBuildCommand(
resolved.sharedBuildParams,
{
platform,
logPrefix: `${platform} Device ${resolved.logLabel}`,
deviceId: params.buildForTesting ? params.deviceId : undefined,
},
params.preferXcodebuild ?? false,
resolved.buildAction,
executor,
undefined,
started.pipeline,
);
const succeeded = !buildResult.isError;
const executePrepared = async (
resolved: PreparedBuildDeviceExecution,
): Promise<BuildDeviceResult> => {
const platform = mapDevicePlatform(params.platform);
const started = createDomainStreamingPipeline('build_device', 'BUILD', ctx, 'build-result');

if (resolved.isManagedTestProductsPath) {
markTestProductsPathCompleted(resolved.testProductsPath);
}
const buildResult = await executeXcodeBuildCommand(
resolved.sharedBuildParams,
{
platform,
logPrefix: `${platform} Device ${resolved.logLabel}`,
deviceId: params.buildForTesting ? params.deviceId : undefined,
},
params.preferXcodebuild ?? false,
resolved.buildAction,
executor,
undefined,
started.pipeline,
);
const succeeded = !buildResult.isError;
const xctestrunPaths =
succeeded && resolved.testProductsPath
? await findXctestrunPaths(resolved.testProductsPath)
: [];

const xctestrunPaths =
succeeded && resolved.testProductsPath
? await findXctestrunPaths(resolved.testProductsPath)
: [];

return createBuildDomainResult({
started,
succeeded,
target: 'device',
artifacts: {
buildLogPath: displayPath(started.pipeline.logPath),
...(succeeded && resolved.testProductsPath
? { testProductsPath: displayPath(resolved.testProductsPath) }
: {}),
...(xctestrunPaths.length > 0 ? { xctestrunPaths: xctestrunPaths.map(displayPath) } : {}),
},
fallbackErrorMessages: collectFallbackErrorMessages(started, [], buildResult.content),
request: resolved.invocationRequest,
});
return createBuildDomainResult({
started,
succeeded,
target: 'device',
artifacts: {
buildLogPath: displayPath(started.pipeline.logPath),
...(succeeded && resolved.testProductsPath
? { testProductsPath: displayPath(resolved.testProductsPath) }
: {}),
...(xctestrunPaths.length > 0 ? { xctestrunPaths: xctestrunPaths.map(displayPath) } : {}),
},
fallbackErrorMessages: collectFallbackErrorMessages(started, [], buildResult.content),
request: resolved.invocationRequest,
});
};

if (prepared) {
return executePrepared(prepared);
}
return params.buildForTesting && params.testProductsPath === undefined
? withManagedTestProductsOutput(
'build_device',
(testProductsPath) =>
executePrepared(prepareBuildDeviceExecution(params, testProductsPath)),
{ isSuccessful: (result) => !result.didError },
)
: executePrepared(prepareBuildDeviceExecution(params));
};
}

export async function buildDeviceLogic(
async function executeBuildDeviceLogic(
params: BuildDeviceParams,
executor: CommandExecutor,
): Promise<void> {
prepared: PreparedBuildDeviceExecution,
): Promise<BuildDeviceResult> {
const ctx = getHandlerContext();
const prepared = prepareBuildDeviceExecution(params);

ctx.emit(createBuildInvocationFragment('build-result', 'BUILD', prepared.invocationRequest));
const executionContext = createStreamingExecutionContext(ctx);
const executeBuildDevice = createBuildDeviceExecutor(executor, prepared);
Expand Down Expand Up @@ -222,6 +228,27 @@ export async function buildDeviceLogic(
ctx.nextStepConditionKeys = ['app_build_succeeded'];
}
}
return result;
}

export async function buildDeviceLogic(
params: BuildDeviceParams,
executor: CommandExecutor,
): Promise<void> {
if (params.buildForTesting && params.testProductsPath === undefined) {
await withManagedTestProductsOutput(
'build_device',
(testProductsPath) =>
executeBuildDeviceLogic(
params,
executor,
prepareBuildDeviceExecution(params, testProductsPath),
),
{ isSuccessful: (result) => !result.didError },
);
return;
}
await executeBuildDeviceLogic(params, executor, prepareBuildDeviceExecution(params));
}

export const schema = getSessionAwareToolSchemaShape({
Expand Down
Loading