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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [Unreleased]

### Fixed

- Managed `.xctestproducts` now prune promptly around builds and tests, defaulting to three bundles retained for one day. Set `testProductsMaxCount` or `testProductsMaxAgeDays` in `.mobilebuildmcp/config.yaml`, or use `MOBILEBUILDMCP_TEST_PRODUCTS_MAX_COUNT` and `MOBILEBUILDMCP_TEST_PRODUCTS_MAX_AGE_DAYS`, to override those limits. Project configuration takes precedence over environment variables. Reader registration and directory cleanup share the lifecycle lock. Successfully built products remain available if a later test, metadata read, or reader cleanup raises a runtime error ([#524](https://github.com/getsentry/MobileBuildMCP/issues/524)).

## [2.7.1]

### Changed
Expand Down
2 changes: 2 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ customWorkflows:
experimentalWorkflowDiscovery: false
disableSessionDefaults: false
incrementalBuildsEnabled: false
testProductsMaxCount: 3 # positive integer; active builds and readers remain protected
testProductsMaxAgeDays: 1 # positive number; fractional days are supported
debug: false
sentryDisabled: false
filePathRenderStyle: 'list' # text output file artifacts: list or tree
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
56 changes: 32 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,7 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, sep } from 'node:path';
import * as z from 'zod';
import { computeScopedDerivedDataPath } from '../../../../utils/derived-data-path.ts';
import {
Expand All @@ -12,6 +15,7 @@ import {
} from '../../../../test-utils/test-helpers.ts';
import { schema, handler, testDeviceLogic } from '../test_device.ts';
import { sessionStore } from '../../../../utils/session-store.ts';
import { getMobileBuildMCPAppDir } from '../../../../utils/log-paths.ts';

const mockFs = () =>
createMockFileSystemExecutor({
Expand All @@ -27,6 +31,23 @@ 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) {
expect(testProductsPath.startsWith(`${getMobileBuildMCPAppDir()}${sep}`)).toBe(true);
mkdirSync(testProductsPath, { recursive: true });
}
}

function createSuccessfulTestExecutor(output: string): ReturnType<typeof createMockExecutor> {
return createMockExecutor({
success: true,
output,
onExecute: createRequestedTestProducts,
});
}
Comment thread
ianrahman marked this conversation as resolved.

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

describe('test_device plugin', () => {
beforeEach(() => {
// Fail before lifecycle pruning if this suite runs without the unit-test setup.
expect(
getMobileBuildMCPAppDir().startsWith(join(tmpdir(), 'mobilebuildmcp-test-app-dir-')),
).toBe(true);
sessionStore.clear();
});

Expand Down Expand Up @@ -82,10 +108,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 +222,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 +290,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 +310,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 +332,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 +357,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
152 changes: 98 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 @@ -138,63 +135,82 @@ const publicSchemaObject = baseSchemaObject.omit({
export function createBuildDeviceExecutor(
executor: CommandExecutor,
prepared?: PreparedBuildDeviceExecution,
onTestProductsProduced?: () => void,
): 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');
let managedOutputWasProduced = false;
const executePrepared = async (
resolved: PreparedBuildDeviceExecution,
): Promise<BuildDeviceResult> => {
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 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;
if (succeeded && resolved.testProductsPath) {
managedOutputWasProduced = true;
onTestProductsProduced?.();
}
const xctestrunPaths =
succeeded && resolved.testProductsPath
? await findXctestrunPaths(resolved.testProductsPath)
: [];

if (resolved.isManagedTestProductsPath) {
markTestProductsPathCompleted(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,
});
};

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,
});
if (prepared) {
return executePrepared(prepared);
}
return params.buildForTesting && params.testProductsPath === undefined
? withManagedTestProductsOutput(
'build_device',
(testProductsPath) =>
executePrepared(prepareBuildDeviceExecution(params, testProductsPath)),
{
isSuccessful: (result) => !result.didError,
retainOnOperationError: () => managedOutputWasProduced,
},
)
: executePrepared(prepareBuildDeviceExecution(params));
};
}

export async function buildDeviceLogic(
async function executeBuildDeviceLogic(
params: BuildDeviceParams,
executor: CommandExecutor,
): Promise<void> {
prepared: PreparedBuildDeviceExecution,
onTestProductsProduced?: () => void,
): 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);
const executeBuildDevice = createBuildDeviceExecutor(executor, prepared, onTestProductsProduced);
const result = await executeBuildDevice(params, executionContext);

setXcodebuildStructuredOutput(ctx, 'build-result', result, '3');
Expand Down Expand Up @@ -222,6 +238,34 @@ 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) {
let managedOutputWasProduced = false;
await withManagedTestProductsOutput(
'build_device',
(testProductsPath) =>
executeBuildDeviceLogic(
params,
executor,
prepareBuildDeviceExecution(params, testProductsPath),
() => {
managedOutputWasProduced = true;
},
),
{
isSuccessful: (result) => !result.didError,
retainOnOperationError: () => managedOutputWasProduced,
},
);
return;
}
await executeBuildDeviceLogic(params, executor, prepareBuildDeviceExecution(params));
}

export const schema = getSessionAwareToolSchemaShape({
Expand Down
Loading