From a13bedb9d6e1c8e9768a7bfa0197b15c9cccd5fa Mon Sep 17 00:00:00 2001 From: Ian Rahman <16245367+ianrahman@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:16:53 -0400 Subject: [PATCH] fix: make test product retention configurable --- CHANGELOG.md | 4 + .../__tests__/register-tool-commands.test.ts | 2 + src/cli/__tests__/session-defaults.test.ts | 2 + .../device/__tests__/test_device.test.ts | 48 +- src/mcp/tools/device/build_device.ts | 135 +++-- .../tools/macos/__tests__/test_macos.test.ts | 61 +- src/mcp/tools/macos/build_macos.ts | 185 +++--- src/mcp/tools/simulator/build_sim.ts | 134 +++-- src/utils/__tests__/config-store.test.ts | 38 ++ .../__tests__/prepared-test-execution.test.ts | 12 +- src/utils/__tests__/test-common.test.ts | 23 +- .../__tests__/test-products-lifecycle.test.ts | 380 ++++++++++++- .../__tests__/test-products-purge.test.ts | 40 ++ src/utils/__tests__/tool-registry.test.ts | 2 + src/utils/config-store.ts | 81 +++ src/utils/purge-storage/execution.ts | 11 +- src/utils/test-common.ts | 215 +++---- src/utils/test-products-lifecycle.ts | 530 +++++++++++++++++- src/utils/workspace-filesystem-lifecycle.ts | 1 - src/visibility/__tests__/exposure.test.ts | 2 + .../__tests__/predicate-registry.test.ts | 2 + 21 files changed, 1529 insertions(+), 379 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1130fa825..9df6412b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/cli/__tests__/register-tool-commands.test.ts b/src/cli/__tests__/register-tool-commands.test.ts index 616166a18..550661efd 100644 --- a/src/cli/__tests__/register-tool-commands.test.ts +++ b/src/cli/__tests__/register-tool-commands.test.ts @@ -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, diff --git a/src/cli/__tests__/session-defaults.test.ts b/src/cli/__tests__/session-defaults.test.ts index f30f298c5..f832f11ec 100644 --- a/src/cli/__tests__/session-defaults.test.ts +++ b/src/cli/__tests__/session-defaults.test.ts @@ -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, diff --git a/src/mcp/tools/device/__tests__/test_device.test.ts b/src/mcp/tools/device/__tests__/test_device.test.ts index 9c7cb833f..f38582af9 100644 --- a/src/mcp/tools/device/__tests__/test_device.test.ts +++ b/src/mcp/tools/device/__tests__/test_device.test.ts @@ -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 { @@ -27,6 +28,22 @@ const runTestDeviceLogic = ( fileSystemExecutor: Parameters[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 { + return createMockExecutor({ + success: true, + output, + onExecute: createRequestedTestProducts, + }); +} + function createSpyExecutor(): { commandCalls: Array<{ args: string[]; logPrefix?: string }>; executor: ReturnType; @@ -36,6 +53,7 @@ function createSpyExecutor(): { success: true, output: 'Test Succeeded', onExecute: (command, logPrefix) => { + createRequestedTestProducts(command); commandCalls.push({ args: command, logPrefix }); }, }); @@ -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( { @@ -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( { @@ -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( { @@ -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( { @@ -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( { @@ -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( { diff --git a/src/mcp/tools/device/build_device.ts b/src/mcp/tools/device/build_device.ts index 5c566617d..e52f1d23d 100644 --- a/src/mcp/tools/device/build_device.ts +++ b/src/mcp/tools/device/build_device.ts @@ -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 ? { @@ -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, @@ -140,58 +137,67 @@ export function createBuildDeviceExecutor( prepared?: PreparedBuildDeviceExecution, ): StreamingExecutor { 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 => { + 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 { + prepared: PreparedBuildDeviceExecution, +): Promise { const ctx = getHandlerContext(); - const prepared = prepareBuildDeviceExecution(params); - ctx.emit(createBuildInvocationFragment('build-result', 'BUILD', prepared.invocationRequest)); const executionContext = createStreamingExecutionContext(ctx); const executeBuildDevice = createBuildDeviceExecutor(executor, prepared); @@ -222,6 +228,27 @@ export async function buildDeviceLogic( ctx.nextStepConditionKeys = ['app_build_succeeded']; } } + return result; +} + +export async function buildDeviceLogic( + params: BuildDeviceParams, + executor: CommandExecutor, +): Promise { + 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({ diff --git a/src/mcp/tools/macos/__tests__/test_macos.test.ts b/src/mcp/tools/macos/__tests__/test_macos.test.ts index 8c79fdca3..94200a5ba 100644 --- a/src/mcp/tools/macos/__tests__/test_macos.test.ts +++ b/src/mcp/tools/macos/__tests__/test_macos.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; +import { mkdirSync } from 'node:fs'; import * as z from 'zod'; import { createMockCommandResponse, @@ -27,6 +28,22 @@ const runTestMacosLogic = ( fileSystemExecutor: Parameters[2], ) => runToolLogic(() => testMacosLogic(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 { + return createMockExecutor({ + success: true, + output, + onExecute: createRequestedTestProducts, + }); +} + describe('test_macos plugin (unified)', () => { beforeEach(() => { sessionStore.clear(); @@ -112,10 +129,7 @@ describe('test_macos plugin (unified)', () => { }); it('should allow only projectPath', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: 'Test Suite All Tests passed', - }); + const mockExecutor = createSuccessfulTestExecutor('Test Suite All Tests passed'); const { result } = await runTestMacosLogic( { @@ -131,10 +145,7 @@ describe('test_macos plugin (unified)', () => { }); it('should allow only workspacePath', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: 'Test Suite All Tests passed', - }); + const mockExecutor = createSuccessfulTestExecutor('Test Suite All Tests passed'); const { result } = await runTestMacosLogic( { @@ -152,10 +163,7 @@ describe('test_macos plugin (unified)', () => { describe('Handler Behavior (Complete Literal Returns)', () => { it('should return pending response with workspace when xcodebuild succeeds', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: 'Test Suite All Tests passed', - }); + const mockExecutor = createSuccessfulTestExecutor('Test Suite All Tests passed'); const { result } = await runTestMacosLogic( { @@ -172,10 +180,7 @@ describe('test_macos plugin (unified)', () => { }); it('should return pending response with project when xcodebuild succeeds', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: 'Test Suite All Tests passed', - }); + const mockExecutor = createSuccessfulTestExecutor('Test Suite All Tests passed'); const { result } = await runTestMacosLogic( { @@ -192,10 +197,7 @@ describe('test_macos plugin (unified)', () => { }); it('should use default configuration when not provided', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: 'Test Suite All Tests passed', - }); + const mockExecutor = createSuccessfulTestExecutor('Test Suite All Tests passed'); const { result } = await runTestMacosLogic( { @@ -211,10 +213,7 @@ describe('test_macos plugin (unified)', () => { }); it('should handle optional parameters correctly', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: 'Test Suite All Tests passed', - }); + const mockExecutor = createSuccessfulTestExecutor('Test Suite All Tests passed'); const { result } = await runTestMacosLogic( { @@ -234,10 +233,7 @@ describe('test_macos plugin (unified)', () => { }); it('should handle successful test execution with minimal parameters', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: 'Test Suite All Tests passed', - }); + const mockExecutor = createSuccessfulTestExecutor('Test Suite All Tests passed'); const { result } = await runTestMacosLogic( { @@ -263,6 +259,7 @@ describe('test_macos plugin (unified)', () => { _detached?: boolean, ) => { commandCalls.push({ command, logPrefix, cwd: opts?.cwd }); + createRequestedTestProducts(command); return createMockCommandResponse({ success: true, output: 'Test Succeeded', @@ -332,18 +329,20 @@ describe('test_macos plugin (unified)', () => { it('should return pending response with optional parameters', async () => { const mockExecutor = async ( - _command: string[], + command: string[], _logPrefix?: string, _useShell?: boolean, _opts?: { env?: Record }, _detached?: boolean, - ) => - createMockCommandResponse({ + ) => { + createRequestedTestProducts(command); + return createMockCommandResponse({ success: true, output: 'Test Succeeded', error: undefined, exitCode: 0, }); + }; const { result } = await runTestMacosLogic( { diff --git a/src/mcp/tools/macos/build_macos.ts b/src/mcp/tools/macos/build_macos.ts index 09bf25f6e..b168cecaa 100644 --- a/src/mcp/tools/macos/build_macos.ts +++ b/src/mcp/tools/macos/build_macos.ts @@ -26,27 +26,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 PreparedBuildMacOSExecution { buildAction: 'build' | 'build-for-testing'; invocationRequest: BuildInvocationRequest; - isManagedTestProductsPath: boolean; logLabel: 'Build' | 'Build for Testing'; sharedBuildParams: BuildMacOSParams; testProductsPath?: string; } -function prepareBuildMacOSExecution(params: BuildMacOSParams): PreparedBuildMacOSExecution { +function prepareBuildMacOSExecution( + params: BuildMacOSParams, + managedTestProductsPath?: string, +): PreparedBuildMacOSExecution { const buildForTesting = params.buildForTesting ?? false; - const isManagedTestProductsPath = buildForTesting && params.testProductsPath === undefined; const testProductsPath = buildForTesting - ? (resolvePathFromCwd(params.testProductsPath) ?? createDefaultTestProductsPath('build_macos')) + ? (resolvePathFromCwd(params.testProductsPath) ?? managedTestProductsPath) : undefined; const sharedBuildParams = testProductsPath ? { @@ -62,7 +60,6 @@ function prepareBuildMacOSExecution(params: BuildMacOSParams): PreparedBuildMacO return { buildAction: buildForTesting ? 'build-for-testing' : 'build', invocationRequest: createBuildMacOSRequest(params, testProductsPath), - isManagedTestProductsPath, logLabel: buildForTesting ? 'Build for Testing' : 'Build', sharedBuildParams, testProductsPath, @@ -135,87 +132,96 @@ export function createBuildMacOSExecutor( prepared?: PreparedBuildMacOSExecution, ): StreamingExecutor { return async (params, ctx) => { - const resolved = prepared ?? prepareBuildMacOSExecution(params); - const configuration = params.configuration; - const started = createDomainStreamingPipeline('build_macos', 'BUILD', ctx, 'build-result'); - const buildResult = await executeXcodeBuildCommand( - { ...resolved.sharedBuildParams, configuration }, - { - platform: XcodePlatform.macOS, - arch: params.arch, - logPrefix: `macOS ${resolved.logLabel}`, - }, - params.preferXcodebuild ?? false, - resolved.buildAction, - executor, - undefined, - started.pipeline, - ); + const executePrepared = async ( + resolved: PreparedBuildMacOSExecution, + ): Promise => { + const configuration = params.configuration; + const started = createDomainStreamingPipeline('build_macos', 'BUILD', ctx, 'build-result'); + const buildResult = await executeXcodeBuildCommand( + { ...resolved.sharedBuildParams, configuration }, + { + platform: XcodePlatform.macOS, + arch: params.arch, + logPrefix: `macOS ${resolved.logLabel}`, + }, + params.preferXcodebuild ?? false, + resolved.buildAction, + executor, + undefined, + started.pipeline, + ); - let bundleId: string | undefined; - if (!buildResult.isError && !params.buildForTesting) { - try { - const appPath = await resolveAppPathFromBuildSettings( - { - projectPath: params.projectPath, - workspacePath: params.workspacePath, - scheme: params.scheme, - configuration, - platform: XcodePlatform.macOS, - derivedDataPath: params.derivedDataPath, - extraArgs: params.extraArgs, - }, - executor, - ); + let bundleId: string | undefined; + if (!buildResult.isError && !params.buildForTesting) { + try { + const appPath = await resolveAppPathFromBuildSettings( + { + projectPath: params.projectPath, + workspacePath: params.workspacePath, + scheme: params.scheme, + configuration, + platform: XcodePlatform.macOS, + derivedDataPath: params.derivedDataPath, + extraArgs: params.extraArgs, + }, + executor, + ); - const plistResult = await executor( - ['defaults', 'read', `${appPath}/Contents/Info`, 'CFBundleIdentifier'], - 'Extract Bundle ID', - false, - ); - if (plistResult.success && plistResult.output) { - bundleId = plistResult.output.trim(); + const plistResult = await executor( + ['defaults', 'read', `${appPath}/Contents/Info`, 'CFBundleIdentifier'], + 'Extract Bundle ID', + false, + ); + if (plistResult.success && plistResult.output) { + bundleId = plistResult.output.trim(); + } + } catch { + // bundle ID is informational only } - } catch { - // bundle ID is informational only } - } - const succeeded = !buildResult.isError; + const succeeded = !buildResult.isError; + const xctestrunPaths = + succeeded && resolved.testProductsPath + ? await findXctestrunPaths(resolved.testProductsPath) + : []; - if (resolved.isManagedTestProductsPath) { - markTestProductsPathCompleted(resolved.testProductsPath); - } + return createBuildDomainResult({ + started, + succeeded, + target: 'macos', + artifacts: { + ...(bundleId ? { bundleId } : {}), + 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: 'macos', - artifacts: { - ...(bundleId ? { bundleId } : {}), - 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_macos', + (testProductsPath) => + executePrepared(prepareBuildMacOSExecution(params, testProductsPath)), + { isSuccessful: (result) => !result.didError }, + ) + : executePrepared(prepareBuildMacOSExecution(params)); }; } -export async function buildMacOSLogic( +async function executeBuildMacOSLogic( params: BuildMacOSParams, executor: CommandExecutor, -): Promise { + prepared: PreparedBuildMacOSExecution, +): Promise { const ctx = getHandlerContext(); - const prepared = prepareBuildMacOSExecution(params); - log('info', `Starting macOS build for scheme ${params.scheme}`); ctx.emit(createBuildInvocationFragment('build-result', 'BUILD', prepared.invocationRequest)); @@ -241,6 +247,27 @@ export async function buildMacOSLogic( ctx.nextStepConditionKeys = ['app_build_succeeded']; } } + return result; +} + +export async function buildMacOSLogic( + params: BuildMacOSParams, + executor: CommandExecutor, +): Promise { + if (params.buildForTesting && params.testProductsPath === undefined) { + await withManagedTestProductsOutput( + 'build_macos', + (testProductsPath) => + executeBuildMacOSLogic( + params, + executor, + prepareBuildMacOSExecution(params, testProductsPath), + ), + { isSuccessful: (result) => !result.didError }, + ); + return; + } + await executeBuildMacOSLogic(params, executor, prepareBuildMacOSExecution(params)); } export const schema = getSessionAwareToolSchemaShape({ diff --git a/src/mcp/tools/simulator/build_sim.ts b/src/mcp/tools/simulator/build_sim.ts index c89e343f4..27efd8c9c 100644 --- a/src/mcp/tools/simulator/build_sim.ts +++ b/src/mcp/tools/simulator/build_sim.ts @@ -37,11 +37,8 @@ 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'; const baseOptions = { @@ -113,7 +110,6 @@ export interface PreparedBuildSimExecution { logPrefix: string; }; invocationRequest: BuildInvocationRequest; - isManagedTestProductsPath: boolean; testProductsPath?: string; warningMessage?: string; } @@ -121,6 +117,7 @@ export interface PreparedBuildSimExecution { export async function prepareBuildSimExecution( params: BuildSimulatorParams, executor: CommandExecutor, + managedTestProductsPath?: string, ): Promise { const configuration = params.configuration; const useLatestOS = params.useLatestOS ?? true; @@ -137,9 +134,8 @@ export async function prepareBuildSimExecution( const detectedPlatform = inferred.platform; const platformName = detectedPlatform.replace(' Simulator', ''); const buildForTesting = params.buildForTesting ?? false; - const isManagedTestProductsPath = buildForTesting && params.testProductsPath === undefined; const testProductsPath = buildForTesting - ? (resolvePathFromCwd(params.testProductsPath) ?? createDefaultTestProductsPath('build_sim')) + ? (resolvePathFromCwd(params.testProductsPath) ?? managedTestProductsPath) : undefined; const sharedBuildParams = testProductsPath ? { @@ -178,7 +174,6 @@ export async function prepareBuildSimExecution( simulatorId: params.simulatorId, ...(testProductsPath ? { testProductsPath: displayPath(testProductsPath) } : {}), }, - isManagedTestProductsPath, testProductsPath, warningMessage: params.simulatorId && params.useLatestOS !== undefined @@ -204,62 +199,70 @@ export function createBuildSimExecutor( prepared?: PreparedBuildSimExecution, ): StreamingExecutor { return async (params, ctx) => { - const resolved = prepared ?? (await prepareBuildSimExecution(params, executor)); + const executePrepared = async ( + resolved: PreparedBuildSimExecution, + ): Promise => { + if (resolved.warningMessage) { + log('warn', resolved.warningMessage); + ctx.emitFragment({ + kind: 'build-result', + fragment: 'warning', + message: resolved.warningMessage, + }); + } - if (resolved.warningMessage) { - log('warn', resolved.warningMessage); - ctx.emitFragment({ - kind: 'build-result', - fragment: 'warning', - message: resolved.warningMessage, - }); - } + const started = createDomainStreamingPipeline('build_sim', 'BUILD', ctx, 'build-result'); + const buildResult = await executeXcodeBuildCommand( + resolved.sharedBuildParams, + resolved.platformOptions, + params.preferXcodebuild ?? false, + resolved.buildAction, + executor, + undefined, + started.pipeline, + ); + const succeeded = !buildResult.isError; + const xctestrunPaths = + succeeded && resolved.testProductsPath + ? await findXctestrunPaths(resolved.testProductsPath) + : []; - const started = createDomainStreamingPipeline('build_sim', 'BUILD', ctx, 'build-result'); - const buildResult = await executeXcodeBuildCommand( - resolved.sharedBuildParams, - resolved.platformOptions, - params.preferXcodebuild ?? false, - resolved.buildAction, - executor, - undefined, - started.pipeline, - ); - const succeeded = !buildResult.isError; + return createBuildDomainResult({ + started, + succeeded, + target: 'simulator', + 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 (resolved.isManagedTestProductsPath) { - markTestProductsPathCompleted(resolved.testProductsPath); + if (prepared) { + return executePrepared(prepared); } - - const xctestrunPaths = - succeeded && resolved.testProductsPath - ? await findXctestrunPaths(resolved.testProductsPath) - : []; - - return createBuildDomainResult({ - started, - succeeded, - target: 'simulator', - 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 params.buildForTesting && params.testProductsPath === undefined + ? withManagedTestProductsOutput( + 'build_sim', + async (testProductsPath) => + executePrepared(await prepareBuildSimExecution(params, executor, testProductsPath)), + { isSuccessful: (result) => !result.didError }, + ) + : executePrepared(await prepareBuildSimExecution(params, executor)); }; } -export async function build_simLogic( +async function executeBuildSimLogic( params: BuildSimulatorParams, executor: CommandExecutor, -): Promise { + prepared: PreparedBuildSimExecution, +): Promise { const ctx = getHandlerContext(); - const prepared = await prepareBuildSimExecution(params, executor); - ctx.emit(createBuildInvocationFragment('build-result', 'BUILD', prepared.invocationRequest)); const executionContext = createStreamingExecutionContext(ctx); const executeBuildSim = createBuildSimExecutor(executor, prepared); @@ -292,6 +295,27 @@ export async function build_simLogic( ctx.nextStepConditionKeys = ['app_build_succeeded']; } } + return result; +} + +export async function build_simLogic( + params: BuildSimulatorParams, + executor: CommandExecutor, +): Promise { + if (params.buildForTesting && params.testProductsPath === undefined) { + await withManagedTestProductsOutput( + 'build_sim', + async (testProductsPath) => + executeBuildSimLogic( + params, + executor, + await prepareBuildSimExecution(params, executor, testProductsPath), + ), + { isSuccessful: (result) => !result.didError }, + ); + return; + } + await executeBuildSimLogic(params, executor, await prepareBuildSimExecution(params, executor)); } export const schema = getSessionAwareToolSchemaShape({ diff --git a/src/utils/__tests__/config-store.test.ts b/src/utils/__tests__/config-store.test.ts index 8d953de4c..f2d803f46 100644 --- a/src/utils/__tests__/config-store.test.ts +++ b/src/utils/__tests__/config-store.test.ts @@ -7,6 +7,7 @@ import { initConfigStore, persistActiveSessionDefaultsProfile, persistSessionDefaultsPatch, + type RuntimeConfigOverrides, } from '../config-store.ts'; const cwd = '/repo'; @@ -38,6 +39,8 @@ describe('config-store', () => { const config = getConfig(); expect(config.debug).toBe(false); expect(config.incrementalBuildsEnabled).toBe(false); + expect(config.testProductsMaxCount).toBe(3); + expect(config.testProductsMaxAgeDays).toBe(1); expect(config.dapRequestTimeoutMs).toBe(30000); expect(config.dapLogEvents).toBe(false); expect(config.launchJsonWaitMs).toBe(8000); @@ -49,6 +52,8 @@ describe('config-store', () => { XCODEBUILDMCP_DEBUG: 'true', XCODEBUILDMCP_SENTRY_DISABLED: 'true', INCREMENTAL_BUILDS_ENABLED: '1', + XCODEBUILDMCP_TEST_PRODUCTS_MAX_COUNT: '4', + XCODEBUILDMCP_TEST_PRODUCTS_MAX_AGE_DAYS: '0.5', XCODEBUILDMCP_DAP_REQUEST_TIMEOUT_MS: '12345', XCODEBUILDMCP_DAP_LOG_EVENTS: 'true', XBMCP_LAUNCH_JSON_WAIT_MS: '9000', @@ -65,6 +70,8 @@ describe('config-store', () => { expect(config.debug).toBe(true); expect(config.sentryDisabled).toBe(true); expect(config.incrementalBuildsEnabled).toBe(true); + expect(config.testProductsMaxCount).toBe(4); + expect(config.testProductsMaxAgeDays).toBe(0.5); expect(config.dapRequestTimeoutMs).toBe(12345); expect(config.dapLogEvents).toBe(true); expect(config.launchJsonWaitMs).toBe(9000); @@ -86,6 +93,8 @@ describe('config-store', () => { ].join('\n'); const env = { XCODEBUILDMCP_DEBUG: 'true', + XCODEBUILDMCP_TEST_PRODUCTS_MAX_COUNT: '9', + XCODEBUILDMCP_TEST_PRODUCTS_MAX_AGE_DAYS: '3', XCODEBUILDMCP_DAP_REQUEST_TIMEOUT_MS: '999', XCODEBUILDMCP_FILE_PATH_RENDER_STYLE: 'list', XCODEBUILDMCP_AXE_SOURCE_PATH: '/env/AXe', @@ -96,6 +105,8 @@ describe('config-store', () => { fs: createFs(yaml), overrides: { debug: true, + testProductsMaxCount: 7, + testProductsMaxAgeDays: 1.5, dapRequestTimeoutMs: 12345, filePathRenderStyle: 'list', axeSourcePath: '/override/AXe', @@ -105,11 +116,38 @@ describe('config-store', () => { const config = getConfig(); expect(config.debug).toBe(true); + expect(config.testProductsMaxCount).toBe(7); + expect(config.testProductsMaxAgeDays).toBe(1.5); expect(config.dapRequestTimeoutMs).toBe(12345); expect(config.filePathRenderStyle).toBe('list'); expect(config.axeSourcePath).toBe('/override/AXe'); }); + it.each([ + ['XCODEBUILDMCP_TEST_PRODUCTS_MAX_COUNT', '0'], + ['XCODEBUILDMCP_TEST_PRODUCTS_MAX_COUNT', '1.5'], + ['XCODEBUILDMCP_TEST_PRODUCTS_MAX_COUNT', 'not-a-number'], + ['XCODEBUILDMCP_TEST_PRODUCTS_MAX_AGE_DAYS', '0'], + ['XCODEBUILDMCP_TEST_PRODUCTS_MAX_AGE_DAYS', 'Infinity'], + ['XCODEBUILDMCP_TEST_PRODUCTS_MAX_AGE_DAYS', ''], + ])('rejects invalid %s environment values', async (name, value) => { + await expect(initConfigStore({ cwd, fs: createFs(), env: { [name]: value } })).rejects.toThrow( + name, + ); + }); + + it.each([ + [{ testProductsMaxCount: 0 }, 'testProductsMaxCount'], + [{ testProductsMaxCount: 1.5 }, 'testProductsMaxCount'], + [{ testProductsMaxAgeDays: Number.NaN }, 'testProductsMaxAgeDays'], + [{ testProductsMaxAgeDays: 0 }, 'testProductsMaxAgeDays'], + ] satisfies ReadonlyArray)( + 'rejects invalid runtime retention overrides', + async (overrides, name) => { + await expect(initConfigStore({ cwd, fs: createFs(), overrides })).rejects.toThrow(name); + }, + ); + it('uses file config before env when no override is provided', async () => { const yaml = [ 'schemaVersion: 1', diff --git a/src/utils/__tests__/prepared-test-execution.test.ts b/src/utils/__tests__/prepared-test-execution.test.ts index cfc5b838a..49ccb8744 100644 --- a/src/utils/__tests__/prepared-test-execution.test.ts +++ b/src/utils/__tests__/prepared-test-execution.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -11,6 +11,14 @@ import { createTestExecutor } from '../test-common.ts'; import { resetWorkspaceFilesystemLifecycleStateForTests } from '../workspace-filesystem-lifecycle.ts'; import { XcodePlatform } from '../xcode.ts'; +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 }); + } +} + describe('prepared test execution', () => { let tempAppDir: string; @@ -81,6 +89,7 @@ describe('prepared test execution', () => { const commands: string[][] = []; const executor: CommandExecutor = async (command) => { commands.push(command); + createRequestedTestProducts(command); return createMockCommandResponse({ success: true, output: '', exitCode: 0 }); }; const executeTest = createTestExecutor(executor, { @@ -112,6 +121,7 @@ describe('prepared test execution', () => { const commands: string[][] = []; const executor: CommandExecutor = async (command) => { commands.push(command); + createRequestedTestProducts(command); return createMockCommandResponse({ success: true, output: '', exitCode: 0 }); }; const executeTest = createTestExecutor(executor, { diff --git a/src/utils/__tests__/test-common.test.ts b/src/utils/__tests__/test-common.test.ts index 991cb85a5..450306826 100644 --- a/src/utils/__tests__/test-common.test.ts +++ b/src/utils/__tests__/test-common.test.ts @@ -23,7 +23,13 @@ vi.mock('../xcresult-test-failures.ts', () => ({ extractTestSummaryCountsFromXcresult: vi.fn(() => null), })); -function createSuccessfulCommandResponse(): CommandResponse { +function createSuccessfulCommandResponse(command?: readonly string[]): CommandResponse { + if (command?.at(-1) === 'build-for-testing') { + const testProductsPath = command[command.indexOf('-testProductsPath') + 1]; + if (testProductsPath) { + mkdirSync(testProductsPath, { recursive: true }); + } + } return { success: true, output: '', @@ -155,7 +161,7 @@ describe('createTestExecutor', () => { opts?.onStdout?.('Ld /tmp/Weather.build/Weather normal arm64\n'); } - return createSuccessfulCommandResponse(); + return createSuccessfulCommandResponse(command); }; const executeTest = createTestExecutor(executor, { @@ -202,7 +208,7 @@ describe('createTestExecutor', () => { const commands: string[][] = []; const executor: CommandExecutor = async (command) => { commands.push(command); - return createSuccessfulCommandResponse(); + return createSuccessfulCommandResponse(command); }; const executeTest = createTestExecutor(executor, { @@ -277,7 +283,7 @@ describe('createTestExecutor', () => { const commands: string[][] = []; const executor: CommandExecutor = async (command) => { commands.push(command); - return createSuccessfulCommandResponse(); + return createSuccessfulCommandResponse(command); }; const executeTest = createTestExecutor(executor, { @@ -355,14 +361,15 @@ describe('createTestExecutor', () => { expect(commands).toHaveLength(1); expect(commands[0]).not.toContain('-resultBundlePath'); expect(result.artifacts.xcresultPath).toBeUndefined(); - expect(existsSync(getTestProductsCompletionMarkerPath(testProductsPath!))).toBe(true); + expect(existsSync(testProductsPath!)).toBe(false); + expect(existsSync(getTestProductsCompletionMarkerPath(testProductsPath!))).toBe(false); }); it('injects the default result bundle only into the simulator test execution phase', async () => { const commands: string[][] = []; const executor: CommandExecutor = async (command) => { commands.push(command); - return createSuccessfulCommandResponse(); + return createSuccessfulCommandResponse(command); }; const executeTest = createTestExecutor(executor, { @@ -407,7 +414,7 @@ describe('createTestExecutor', () => { const commands: string[][] = []; const executor: CommandExecutor = async (command) => { commands.push(command); - return createSuccessfulCommandResponse(); + return createSuccessfulCommandResponse(command); }; const executeTest = createTestExecutor(executor, { @@ -452,7 +459,7 @@ describe('createTestExecutor', () => { const testProductsIndex = command.indexOf('-testProductsPath'); testProductsPath = command[testProductsIndex + 1]; mkdirSync(testProductsPath!); - return createSuccessfulCommandResponse(); + return createSuccessfulCommandResponse(command); } expect(existsSync(getTestProductsCompletionMarkerPath(testProductsPath!))).toBe(false); diff --git a/src/utils/__tests__/test-products-lifecycle.test.ts b/src/utils/__tests__/test-products-lifecycle.test.ts index 587072f93..896b65430 100644 --- a/src/utils/__tests__/test-products-lifecycle.test.ts +++ b/src/utils/__tests__/test-products-lifecycle.test.ts @@ -1,16 +1,35 @@ -import { existsSync, mkdirSync, mkdtempSync, utimesSync, writeFileSync } from 'node:fs'; -import { rm } from 'node:fs/promises'; +import { + type Dirent, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + utimesSync, + writeFileSync, +} from 'node:fs'; +import * as fileSystem from 'node:fs/promises'; import { tmpdir } from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createMockFileSystemExecutor } from '../../test-utils/mock-executors.ts'; import { TEST_PRODUCTS_MAX_AGE_MS, pruneManagedTestProductsDirectory, + type ManagedTestProductsFileSystem, + type ManagedTestProductsLifecycleDependencies, + withManagedTestProductsOutput, + withManagedTestProductsReader, } from '../test-products-lifecycle.ts'; import { getTestProductsCompletionMarkerPath, isXcodeBuildMCPManagedTestProductsName, } from '../test-products-path.ts'; +import { + getWorkspaceFilesystemLayout, + setXcodeBuildMCPAppDirOverrideForTests, +} from '../log-paths.ts'; +import { setRuntimeInstanceForTests } from '../runtime-instance.ts'; +import { tryAcquireFsLock, type AcquiredFsLock } from '../fs-lock.ts'; const DAY_MS = 24 * 60 * 60 * 1000; const DEAD_OWNER_PID = 999_999_999; @@ -29,18 +48,72 @@ function writeTestProducts(directory: string, mtimeMs: number, completed = false } } +function createTestFileSystem(): ManagedTestProductsFileSystem { + const mkdir: ManagedTestProductsFileSystem['mkdir'] = async (filePath, options) => { + await fileSystem.mkdir(filePath, options); + }; + const readdir: ManagedTestProductsFileSystem['readdir'] = (filePath, options) => + fileSystem.readdir(filePath, options); + const rm: ManagedTestProductsFileSystem['rm'] = (filePath, options) => + fileSystem.rm(filePath, options); + const stat: ManagedTestProductsFileSystem['stat'] = (filePath) => fileSystem.stat(filePath); + const writeFile: ManagedTestProductsFileSystem['writeFile'] = (filePath, content, options) => + fileSystem.writeFile(filePath, content, options); + const executor = createMockFileSystemExecutor({ + mkdir: async (filePath, options) => { + await mkdir(filePath, options); + }, + readdir, + rm, + stat, + writeFile: (filePath, content, encoding) => fileSystem.writeFile(filePath, content, encoding), + }); + return { + mkdir: executor.mkdir, + readdir: (filePath, options) => executor.readdir(filePath, options) as Promise, + rename: (oldPath, newPath) => fileSystem.rename(oldPath, newPath), + rmdir: (filePath) => fileSystem.rmdir(filePath), + rm: executor.rm, + stat, + writeFile, + }; +} + +function acquiredLock(): AcquiredFsLock { + return { + owner: { + token: 'test-lock', + pid: process.pid, + purpose: 'filesystem-lifecycle', + acquiredAtMs: 0, + expiresAtMs: 10 * 60 * 1000, + }, + release: async () => undefined, + }; +} + describe('test products lifecycle', () => { let root: string; + let dependencies: Partial; beforeEach(() => { root = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-test-products-lifecycle-')); + setXcodeBuildMCPAppDirOverrideForTests(root); + setRuntimeInstanceForTests({ + instanceId: 'test-products-lifecycle', + pid: process.pid, + workspaceKey: 'workspace-a', + }); + dependencies = { fileSystem: createTestFileSystem() }; }); afterEach(async () => { - await rm(root, { recursive: true, force: true }); + setRuntimeInstanceForTests(null); + setXcodeBuildMCPAppDirOverrideForTests(null); + await fileSystem.rm(root, { recursive: true, force: true }); }); - it('prunes managed products after three days while preserving caller-owned paths', async () => { + it('prunes managed products after one day while preserving caller-owned paths', async () => { const now = Date.UTC(2026, 4, 6, 12); const oldManaged = path.join(root, managedName('old')); const recentManaged = path.join(root, managedName('recent')); @@ -50,7 +123,7 @@ describe('test products lifecycle', () => { `${path.basename(root)}-external-caller.xctestproducts`, ); writeTestProducts(oldManaged, now - TEST_PRODUCTS_MAX_AGE_MS - 1, true); - writeTestProducts(recentManaged, now - 2 * DAY_MS, true); + writeTestProducts(recentManaged, now - DAY_MS + 1, true); writeTestProducts(callerOwned, now - 10 * DAY_MS, true); writeTestProducts(externalCallerOwned, now - 10 * DAY_MS, true); @@ -58,6 +131,7 @@ describe('test products lifecycle', () => { testProductsDir: root, now, minVisibleMs: 0, + dependencies, }); expect(result).toEqual({ scanned: 2, deleted: 1 }); @@ -65,7 +139,7 @@ describe('test products lifecycle', () => { expect(existsSync(recentManaged)).toBe(true); expect(existsSync(callerOwned)).toBe(true); expect(existsSync(externalCallerOwned)).toBe(true); - await rm(externalCallerOwned, { recursive: true, force: true }); + await fileSystem.rm(externalCallerOwned, { recursive: true, force: true }); }); it('protects live in-progress products until their completion marker exists', async () => { @@ -75,13 +149,23 @@ describe('test products lifecycle', () => { expect(isXcodeBuildMCPManagedTestProductsName(path.basename(live))).toBe(true); expect( - await pruneManagedTestProductsDirectory({ testProductsDir: root, now, minVisibleMs: 0 }), + await pruneManagedTestProductsDirectory({ + testProductsDir: root, + now, + minVisibleMs: 0, + dependencies, + }), ).toEqual({ scanned: 1, deleted: 0 }); expect(existsSync(live)).toBe(true); writeFileSync(getTestProductsCompletionMarkerPath(live), 'completed'); expect( - await pruneManagedTestProductsDirectory({ testProductsDir: root, now, minVisibleMs: 0 }), + await pruneManagedTestProductsDirectory({ + testProductsDir: root, + now, + minVisibleMs: 0, + dependencies, + }), ).toEqual({ scanned: 1, deleted: 1 }); expect(existsSync(live)).toBe(false); }); @@ -101,6 +185,7 @@ describe('test products lifecycle', () => { minVisibleMs: 0, maxAgeMs: 10 * DAY_MS, maxCount: 2, + dependencies, }); expect(result).toEqual({ scanned: 3, deleted: 1 }); @@ -108,4 +193,283 @@ describe('test products lifecycle', () => { expect(existsSync(middle)).toBe(true); expect(existsSync(newest)).toBe(true); }); + + it('prunes before and after managed output production while preserving the new bundle', async () => { + const now = Date.now(); + const layout = getWorkspaceFilesystemLayout('workspace-a'); + const oldest = path.join(layout.testProducts, managedName('oldest')); + const middle = path.join(layout.testProducts, managedName('middle')); + const newest = path.join(layout.testProducts, managedName('newest')); + writeTestProducts(oldest, now - 3 * DAY_MS, true); + writeTestProducts(middle, now - 2 * DAY_MS, true); + writeTestProducts(newest, now - DAY_MS, true); + + let resolveOperationLock!: (lock: AcquiredFsLock) => void; + const operationLockAcquired = new Promise((resolve) => { + resolveOperationLock = resolve; + }); + let resolveLockWait!: () => void; + const lockWaitObserved = new Promise((resolve) => { + resolveLockWait = resolve; + }); + const outputPromise = withManagedTestProductsOutput( + 'build_sim', + async (testProductsPath) => { + mkdirSync(testProductsPath); + writeFileSync(path.join(testProductsPath, 'Tests.xctestrun'), 'stub'); + const lock = await tryAcquireFsLock({ + lockDir: layout.filesystemLifecycle.lockDir, + purpose: 'filesystem-lifecycle', + leaseMs: 10 * 60 * 1000, + }); + if (!lock) throw new Error('Unable to acquire test lifecycle lock'); + resolveOperationLock(lock); + return testProductsPath; + }, + { + workspaceKey: 'workspace-a', + maxAgeMs: 10 * DAY_MS, + maxCount: 2, + onLockWait: resolveLockWait, + dependencies: { ...dependencies, now: () => now }, + }, + ); + const operationLock = await operationLockAcquired; + try { + await lockWaitObserved; + const pendingOutputPath = readdirSync(layout.testProducts) + .filter(isXcodeBuildMCPManagedTestProductsName) + .map((name) => path.join(layout.testProducts, name)) + .find((candidate) => !existsSync(getTestProductsCompletionMarkerPath(candidate))); + expect(pendingOutputPath).toBeDefined(); + } finally { + await operationLock.release(); + } + const outputPath = await outputPromise; + + const retained = readdirSync(layout.testProducts).filter( + isXcodeBuildMCPManagedTestProductsName, + ); + expect(retained).toHaveLength(2); + expect(existsSync(outputPath)).toBe(true); + expect(existsSync(getTestProductsCompletionMarkerPath(outputPath))).toBe(true); + expect(existsSync(newest)).toBe(true); + expect(existsSync(middle)).toBe(false); + expect(existsSync(oldest)).toBe(false); + }); + + it('protects a managed bundle while its nested xctestrun file is active', async () => { + const now = Date.now(); + const layout = getWorkspaceFilesystemLayout('workspace-a'); + const testProductsPath = path.join(layout.testProducts, managedName('prepared')); + writeTestProducts(testProductsPath, now - 2 * DAY_MS, true); + + await withManagedTestProductsReader( + path.join(testProductsPath, 'Tests.xctestrun'), + async () => { + expect( + await pruneManagedTestProductsDirectory({ + testProductsDir: layout.testProducts, + now, + minVisibleMs: 0, + maxAgeMs: 10 * DAY_MS, + maxCount: 0, + dependencies, + }), + ).toEqual({ scanned: 1, deleted: 0 }); + }, + { + workspaceKey: 'workspace-a', + maxAgeMs: 10 * DAY_MS, + maxCount: 3, + dependencies: { ...dependencies, now: () => now }, + }, + ); + + expect(existsSync(testProductsPath)).toBe(true); + expect( + await pruneManagedTestProductsDirectory({ + testProductsDir: layout.testProducts, + now, + minVisibleMs: 0, + maxAgeMs: 10 * DAY_MS, + maxCount: 0, + dependencies, + }), + ).toEqual({ scanned: 1, deleted: 1 }); + }); + + it('removes a managed bundle when its completion marker cannot be published', async () => { + let outputPath = ''; + await expect( + withManagedTestProductsOutput( + 'build_sim', + async (testProductsPath) => { + outputPath = testProductsPath; + mkdirSync(testProductsPath); + mkdirSync(getTestProductsCompletionMarkerPath(testProductsPath)); + }, + { dependencies }, + ), + ).rejects.toThrow(); + + expect(existsSync(outputPath)).toBe(false); + expect(existsSync(getTestProductsCompletionMarkerPath(outputPath))).toBe(false); + }); + + it('fails loudly when a successful operation does not create its managed output', async () => { + await expect( + withManagedTestProductsOutput('build_sim', async () => 'success', { dependencies }), + ).rejects.toThrow('Managed test products output was not created'); + }); + + it('preserves an operation error when managed-output finalization also fails', async () => { + const operationError = new Error('xcodebuild failed'); + const injectedFileSystem = dependencies.fileSystem!; + const finalizationFailureFileSystem: ManagedTestProductsFileSystem = { + ...injectedFileSystem, + rm: async () => { + throw new Error('cleanup failed'); + }, + }; + + await expect( + withManagedTestProductsOutput( + 'build_sim', + async () => { + throw operationError; + }, + { + dependencies: { ...dependencies, fileSystem: finalizationFailureFileSystem }, + }, + ), + ).rejects.toBe(operationError); + }); + + it('retries lifecycle lock acquisition until its timeout is exhausted', async () => { + let attempts = 0; + let now = 0; + + await expect( + withManagedTestProductsOutput('build_sim', async () => undefined, { + lockTimeoutMs: 2, + dependencies: { + ...dependencies, + now: () => now, + tryAcquireLock: async () => { + attempts += 1; + now += 1; + return null; + }, + sleep: async () => undefined, + }, + }), + ).rejects.toThrow('Timed out waiting for managed test products lifecycle lock'); + + expect(attempts).toBe(2); + }); + + it('uses fresh time when pruning after managed output production', async () => { + let now = Date.UTC(2026, 4, 6, 12); + const layout = getWorkspaceFilesystemLayout('workspace-a'); + const crossingAgeLimit = path.join(layout.testProducts, managedName('crossing-age-limit')); + writeTestProducts(crossingAgeLimit, now - DAY_MS + 1, true); + + await withManagedTestProductsOutput( + 'build_sim', + async (testProductsPath) => { + mkdirSync(testProductsPath); + writeFileSync(path.join(testProductsPath, 'Tests.xctestrun'), 'stub'); + now += 2; + }, + { + workspaceKey: 'workspace-a', + maxAgeMs: DAY_MS, + maxCount: 3, + dependencies: { ...dependencies, now: () => now }, + }, + ); + + expect(existsSync(crossingAgeLimit)).toBe(false); + }); + + it('removes its reader marker before a release-time lock timeout', async () => { + const now = Date.now(); + const layout = getWorkspaceFilesystemLayout('workspace-a'); + const testProductsPath = path.join(layout.testProducts, managedName('prepared')); + const readerDirectory = path.join( + layout.state, + 'test-products-readers', + path.basename(testProductsPath), + ); + writeTestProducts(testProductsPath, now, true); + const operationError = new Error('test operation failed'); + let lockAttempts = 0; + + await expect( + withManagedTestProductsReader( + testProductsPath, + async () => { + throw operationError; + }, + { + workspaceKey: 'workspace-a', + lockTimeoutMs: 0, + dependencies: { + ...dependencies, + now: () => now, + randomUUID: () => 'abcdef12', + tryAcquireLock: async () => { + lockAttempts += 1; + return lockAttempts === 1 ? acquiredLock() : null; + }, + }, + }, + ), + ).rejects.toBe(operationError); + + expect(lockAttempts).toBe(2); + expect(existsSync(readerDirectory)).toBe(false); + }); + + it('cleans dead reader markers before pruning', async () => { + const now = Date.now(); + const layout = getWorkspaceFilesystemLayout('workspace-a'); + const testProductsPath = path.join(layout.testProducts, managedName('prepared')); + const readerDir = path.join( + layout.state, + 'test-products-readers', + path.basename(testProductsPath), + ); + writeTestProducts(testProductsPath, now - 2 * DAY_MS, true); + mkdirSync(readerDir, { recursive: true }); + writeFileSync(path.join(readerDir, `pid${DEAD_OWNER_PID}_abcdef12.reader`), 'reader'); + + expect( + await pruneManagedTestProductsDirectory({ + testProductsDir: layout.testProducts, + now, + minVisibleMs: 0, + maxAgeMs: 10 * DAY_MS, + maxCount: 0, + dependencies, + }), + ).toEqual({ scanned: 1, deleted: 1 }); + expect(existsSync(readerDir)).toBe(false); + }); + + it('leaves managed-looking reader paths outside the active workspace caller-owned', async () => { + const otherWorkspacePath = path.join(root, 'other-workspace', managedName('prepared')); + let operationRan = false; + + await withManagedTestProductsReader( + otherWorkspacePath, + async () => { + operationRan = true; + }, + { workspaceKey: 'workspace-a', dependencies }, + ); + + expect(operationRan).toBe(true); + }); }); diff --git a/src/utils/__tests__/test-products-purge.test.ts b/src/utils/__tests__/test-products-purge.test.ts index c94ab8c4e..f525fc69e 100644 --- a/src/utils/__tests__/test-products-purge.test.ts +++ b/src/utils/__tests__/test-products-purge.test.ts @@ -12,6 +12,7 @@ import { getWorkspaceFilesystemLayout, setXcodeBuildMCPAppDirOverrideForTests, } from '../log-paths.ts'; +import { withManagedTestProductsReader } from '../test-products-lifecycle.ts'; import { getTestProductsCompletionMarkerPath } from '../test-products-path.ts'; const WORKSPACE_KEY = 'Demo-aaaaaaaaaaaa'; @@ -70,4 +71,43 @@ describe('test products purge storage', () => { expect(existsSync(callerOwned)).toBe(true); expect(existsSync(externalCallerOwned)).toBe(true); }); + + it('skips a managed bundle while an active reader is consuming it', async () => { + const now = Date.UTC(2026, 4, 6, 12); + const layout = getWorkspaceFilesystemLayout(WORKSPACE_KEY); + const managed = path.join( + layout.testProducts, + 'test_sim_2026-05-02T12-00-00-000Z_pid999999999_abcdef12.xctestproducts', + ); + writeTestProducts(managed, now - 4 * 24 * 60 * 60 * 1000); + writeFileSync(getTestProductsCompletionMarkerPath(managed), 'completed'); + const report = await enumeratePurgeStorage({ + scope: { type: 'workspace', workspaceKey: WORKSPACE_KEY }, + }); + const plan = await planPurgeStorage({ + report, + scope: { type: 'workspace', workspaceKey: WORKSPACE_KEY }, + classes: ['testProducts'], + now, + }); + + const result = await withManagedTestProductsReader( + managed, + () => executePurgeStoragePlan(plan, { now }), + { + workspaceKey: WORKSPACE_KEY, + maxAgeMs: 365 * 24 * 60 * 60 * 1000, + maxCount: 3, + }, + ); + + expect(result.deleted).toEqual([]); + expect(result.skipped).toEqual([ + expect.objectContaining({ + path: managed, + reason: 'test products candidate is protected by active lifecycle owner', + }), + ]); + expect(existsSync(managed)).toBe(true); + }); }); diff --git a/src/utils/__tests__/tool-registry.test.ts b/src/utils/__tests__/tool-registry.test.ts index 1ee111700..fe4e7205a 100644 --- a/src/utils/__tests__/tool-registry.test.ts +++ b/src/utils/__tests__/tool-registry.test.ts @@ -108,6 +108,8 @@ function createPredicateContext(): PredicateContext { showTestTiming: false, uiDebuggerGuardMode: 'error', incrementalBuildsEnabled: false, + testProductsMaxCount: 3, + testProductsMaxAgeDays: 1, dapRequestTimeoutMs: 30_000, dapLogEvents: false, launchJsonWaitMs: 8000, diff --git a/src/utils/config-store.ts b/src/utils/config-store.ts index c7e0aadc5..36443bc37 100644 --- a/src/utils/config-store.ts +++ b/src/utils/config-store.ts @@ -12,6 +12,9 @@ import type { FilePathRenderStyle, UiDebuggerGuardMode } from './runtime-config- import { isFilePathRenderStyle } from './file-path-render-style.ts'; import { normalizeSessionDefaultsProfileName } from './session-defaults-profile.ts'; +export const DEFAULT_TEST_PRODUCTS_MAX_COUNT = 3; +export const DEFAULT_TEST_PRODUCTS_MAX_AGE_DAYS = 1; + export type RuntimeConfigOverrides = Partial<{ enabledWorkflows: string[]; customWorkflows: Record; @@ -24,6 +27,8 @@ export type RuntimeConfigOverrides = Partial<{ filePathRenderStyle: FilePathRenderStyle; uiDebuggerGuardMode: UiDebuggerGuardMode; incrementalBuildsEnabled: boolean; + testProductsMaxCount: number; + testProductsMaxAgeDays: number; dapRequestTimeoutMs: number; dapLogEvents: boolean; launchJsonWaitMs: number; @@ -51,6 +56,8 @@ export type ResolvedRuntimeConfig = { filePathRenderStyle?: FilePathRenderStyle; uiDebuggerGuardMode: UiDebuggerGuardMode; incrementalBuildsEnabled: boolean; + testProductsMaxCount: number; + testProductsMaxAgeDays: number; dapRequestTimeoutMs: number; dapLogEvents: boolean; launchJsonWaitMs: number; @@ -87,6 +94,8 @@ const DEFAULT_CONFIG: ResolvedRuntimeConfig = { showTestTiming: false, uiDebuggerGuardMode: 'error', incrementalBuildsEnabled: false, + testProductsMaxCount: DEFAULT_TEST_PRODUCTS_MAX_COUNT, + testProductsMaxAgeDays: DEFAULT_TEST_PRODUCTS_MAX_AGE_DAYS, dapRequestTimeoutMs: 30_000, dapLogEvents: false, launchJsonWaitMs: 8000, @@ -128,6 +137,47 @@ function parseNonNegativeInt(value: string | undefined): number | undefined { return Math.floor(parsed); } +function parsePositiveIntegerEnvironmentValue( + name: string, + value: string | undefined, +): number | undefined { + if (value === undefined) return undefined; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1) { + throw new Error(`${name} must be a positive integer`); + } + return parsed; +} + +function parsePositiveNumberEnvironmentValue( + name: string, + value: string | undefined, +): number | undefined { + if (value === undefined) return undefined; + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`${name} must be a positive finite number`); + } + return parsed; +} + +function validateTestProductsRetentionOverrides( + overrides: RuntimeConfigOverrides | undefined, +): void { + if (hasOwnProperty(overrides, 'testProductsMaxCount')) { + const maxCount = overrides.testProductsMaxCount; + if (typeof maxCount !== 'number' || !Number.isInteger(maxCount) || maxCount < 1) { + throw new Error('testProductsMaxCount must be a positive integer'); + } + } + if (hasOwnProperty(overrides, 'testProductsMaxAgeDays')) { + const maxAgeDays = overrides.testProductsMaxAgeDays; + if (typeof maxAgeDays !== 'number' || !Number.isFinite(maxAgeDays) || maxAgeDays <= 0) { + throw new Error('testProductsMaxAgeDays must be a positive finite number'); + } + } +} + function parseEnabledWorkflows(value: string | undefined): string[] | undefined { if (value == null) return undefined; const normalized = value @@ -226,6 +276,24 @@ function readEnvConfig(env: NodeJS.ProcessEnv): RuntimeConfigOverrides { setIfDefined(config, 'incrementalBuildsEnabled', parseBoolean(env.INCREMENTAL_BUILDS_ENABLED)); + setIfDefined( + config, + 'testProductsMaxCount', + parsePositiveIntegerEnvironmentValue( + 'XCODEBUILDMCP_TEST_PRODUCTS_MAX_COUNT', + env.XCODEBUILDMCP_TEST_PRODUCTS_MAX_COUNT, + ), + ); + + setIfDefined( + config, + 'testProductsMaxAgeDays', + parsePositiveNumberEnvironmentValue( + 'XCODEBUILDMCP_TEST_PRODUCTS_MAX_AGE_DAYS', + env.XCODEBUILDMCP_TEST_PRODUCTS_MAX_AGE_DAYS, + ), + ); + const axePath = env.XCODEBUILDMCP_AXE_PATH ?? env.AXE_PATH; if (axePath) config.axePath = axePath; @@ -456,6 +524,7 @@ function resolveConfig(opts: { overrides?: RuntimeConfigOverrides; env?: NodeJS.ProcessEnv; }): ResolvedRuntimeConfig { + validateTestProductsRetentionOverrides(opts.overrides); const envConfig = readEnvConfig(opts.env ?? process.env); return { @@ -535,6 +604,18 @@ function resolveConfig(opts: { envConfig, fallback: DEFAULT_CONFIG.incrementalBuildsEnabled, }), + testProductsMaxCount: resolveFromLayers({ + key: 'testProductsMaxCount', + overrides: opts.overrides, + envConfig, + fallback: DEFAULT_CONFIG.testProductsMaxCount, + }), + testProductsMaxAgeDays: resolveFromLayers({ + key: 'testProductsMaxAgeDays', + overrides: opts.overrides, + envConfig, + fallback: DEFAULT_CONFIG.testProductsMaxAgeDays, + }), dapRequestTimeoutMs: resolveFromLayers({ key: 'dapRequestTimeoutMs', overrides: opts.overrides, diff --git a/src/utils/purge-storage/execution.ts b/src/utils/purge-storage/execution.ts index a22bbccd8..e69f1a450 100644 --- a/src/utils/purge-storage/execution.ts +++ b/src/utils/purge-storage/execution.ts @@ -13,7 +13,10 @@ import { isTestProductsCompletionMarkerTempName, isXcodeBuildMCPManagedTestProductsName, } from '../test-products-path.ts'; -import { isProtectedManagedTestProducts } from '../test-products-lifecycle.ts'; +import { + getManagedTestProductsReaderStateDir, + isProtectedManagedTestProducts, +} from '../test-products-lifecycle.ts'; import { WORKSPACE_FILESYSTEM_LIFECYCLE_LOCK_LEASE_MS, WORKSPACE_FILESYSTEM_LIFECYCLE_MIN_VISIBLE_MS, @@ -185,7 +188,11 @@ async function validateClassSpecificDeletionCandidate( if ( await isProtectedManagedTestProducts( { name, path: candidate.path, mtimeMs: candidate.mtimeMs }, - { now, minVisibleMs: 0 }, + { + now, + minVisibleMs: 0, + readerStateDir: getManagedTestProductsReaderStateDir(candidate.workspaceKey), + }, ) ) { return 'test products candidate is protected by active lifecycle owner'; diff --git a/src/utils/test-common.ts b/src/utils/test-common.ts index 8dab52859..e9ba3d0a2 100644 --- a/src/utils/test-common.ts +++ b/src/utils/test-common.ts @@ -22,9 +22,9 @@ import { markResultBundlePathCompleted, } from './result-bundle-path.ts'; import { - createDefaultTestProductsPath, - markTestProductsPathCompleted, -} from './test-products-path.ts'; + withManagedTestProductsOutput, + withManagedTestProductsReader, +} from './test-products-lifecycle.ts'; import { resolvePathFromCwd } from './path.ts'; import { displayPath } from './build-preflight.ts'; import { @@ -206,19 +206,29 @@ async function executePreparedTestCommand( 'test-without-building', ]; const sourceWorkingDirectory = resolveSourceWorkingDirectory(params); - const response = await executor(command, 'Test Run', false, { - ...execOpts, - ...(sourceWorkingDirectory ? { cwd: sourceWorkingDirectory } : {}), - onStdout: (chunk) => pipeline.onStdout(chunk), - onStderr: (chunk) => pipeline.onStderr(chunk), - }); - - return response.success - ? { content: [{ type: 'text', text: 'Test Run test-without-building succeeded.' }] } - : { - content: [{ type: 'text', text: 'Test Run test-without-building failed.' }], - isError: true, - }; + const execute = async (): Promise<{ + content: Array<{ type: 'text'; text: string }>; + isError?: boolean; + }> => { + const response = await executor(command, 'Test Run', false, { + ...execOpts, + ...(sourceWorkingDirectory ? { cwd: sourceWorkingDirectory } : {}), + onStdout: (chunk) => pipeline.onStdout(chunk), + onStderr: (chunk) => pipeline.onStderr(chunk), + }); + + return response.success + ? { content: [{ type: 'text' as const, text: 'Test Run test-without-building succeeded.' }] } + : { + content: [{ type: 'text' as const, text: 'Test Run test-without-building failed.' }], + isError: true, + }; + }; + + const preparedSourcePath = params.testProductsPath ?? params.xctestrunPath; + return preparedSourcePath + ? withManagedTestProductsReader(resolvePathFromCwd(preparedSourcePath), execute) + : execute(); } type PreparedTestCommandResult = Awaited>; @@ -261,95 +271,94 @@ export function createTestExecutor( parsedResultBundleArgs.resultBundlePath ?? createDefaultResultBundlePath(toolName); if (!hasPreparedTestSource) { - const testProductsPath = createDefaultTestProductsPath(toolName); - const executionPlan = createSimulatorTwoPhaseExecutionPlan({ - extraArgs: parsedResultBundleArgs.remainingArgs, - preflight: options.preflight, - }); - - let buildForTestingResult: Awaited>; - try { - buildForTestingResult = await executeXcodeBuildCommand( - { - ...params, - scheme: params.scheme!, - extraArgs: [ - ...filterTestProductsPathArgs(executionPlan.buildArgs), - '-testProductsPath', - testProductsPath, - ], - }, - platformOptions, - params.preferXcodebuild, - 'build-for-testing', - executor, - execOpts, - started.pipeline, - { propagateInfrastructureErrors: true }, - ); - } catch (error) { - markTestProductsPathCompleted(testProductsPath); - throw error; - } + let managedOutputWasProduced = false; + return withManagedTestProductsOutput( + toolName, + async (testProductsPath) => { + const executionPlan = createSimulatorTwoPhaseExecutionPlan({ + extraArgs: parsedResultBundleArgs.remainingArgs, + preflight: options.preflight, + }); - if (buildForTestingResult.isError) { - markTestProductsPathCompleted(testProductsPath); - return createDisplayedTestDomainResult({ - started, - succeeded: false, - target, - artifacts: createXcodebuildTestArtifacts(params, started), - fallbackErrorMessages: getFallbackErrorMessages( - started.stderrLines, - buildForTestingResult.content, - ), - includeDetectedXcresult: false, - preflight: options.preflight, - request: options.request, - }); - } + const buildForTestingResult = await executeXcodeBuildCommand( + { + ...params, + scheme: params.scheme!, + extraArgs: [ + ...filterTestProductsPathArgs(executionPlan.buildArgs), + '-testProductsPath', + testProductsPath, + ], + }, + platformOptions, + params.preferXcodebuild, + 'build-for-testing', + executor, + execOpts, + started.pipeline, + { propagateInfrastructureErrors: true }, + ); - started.pipeline.emitFragment({ - kind: 'test-result', - fragment: 'build-stage', - operation: 'TEST', - stage: 'RUN_TESTS', - message: 'Running tests', - }); - - let testWithoutBuildingResult: PreparedTestCommandResult; - try { - testWithoutBuildingResult = await executePreparedTestCommand( - { ...params, testProductsPath }, - filterPreparedTestExtraArgs(executionPlan.testArgs), - resultBundlePath, - executor, - execOpts, - started.pipeline, - getPreparedTestDestinationArgs(executionPlan.testArgs), - ); - } finally { - markTestProductsPathCompleted(testProductsPath); - if (shouldUseDefaultResultBundlePath) { - markResultBundlePathCompleted(resultBundlePath); - } - } - emitXcresultFailures(started.pipeline, resultBundlePath); - - return createDisplayedTestDomainResult({ - started, - succeeded: !testWithoutBuildingResult.isError, - target, - artifacts: createXcodebuildTestArtifacts(params, started, resultBundlePath, { - testProductsPath, - }), - fallbackErrorMessages: getFallbackErrorMessages( - started.stderrLines, - testWithoutBuildingResult.content, - ), - preflight: options.preflight, - request: options.request, - }); + if (buildForTestingResult.isError) { + return createDisplayedTestDomainResult({ + started, + succeeded: false, + target, + artifacts: createXcodebuildTestArtifacts(params, started), + fallbackErrorMessages: getFallbackErrorMessages( + started.stderrLines, + buildForTestingResult.content, + ), + includeDetectedXcresult: false, + preflight: options.preflight, + request: options.request, + }); + } + managedOutputWasProduced = true; + + started.pipeline.emitFragment({ + kind: 'test-result', + fragment: 'build-stage', + operation: 'TEST', + stage: 'RUN_TESTS', + message: 'Running tests', + }); + + let testWithoutBuildingResult: PreparedTestCommandResult; + try { + testWithoutBuildingResult = await executePreparedTestCommand( + { ...params, testProductsPath }, + filterPreparedTestExtraArgs(executionPlan.testArgs), + resultBundlePath, + executor, + execOpts, + started.pipeline, + getPreparedTestDestinationArgs(executionPlan.testArgs), + ); + } finally { + if (shouldUseDefaultResultBundlePath) { + markResultBundlePathCompleted(resultBundlePath); + } + } + emitXcresultFailures(started.pipeline, resultBundlePath); + + return createDisplayedTestDomainResult({ + started, + succeeded: !testWithoutBuildingResult.isError, + target, + artifacts: createXcodebuildTestArtifacts(params, started, resultBundlePath, { + testProductsPath, + }), + fallbackErrorMessages: getFallbackErrorMessages( + started.stderrLines, + testWithoutBuildingResult.content, + ), + preflight: options.preflight, + request: options.request, + }); + }, + { isSuccessful: () => managedOutputWasProduced }, + ); } started.pipeline.emitFragment({ diff --git a/src/utils/test-products-lifecycle.ts b/src/utils/test-products-lifecycle.ts index c03ddcaaf..0e9e2ac97 100644 --- a/src/utils/test-products-lifecycle.ts +++ b/src/utils/test-products-lifecycle.ts @@ -1,14 +1,35 @@ +import { randomUUID } from 'node:crypto'; +import type { Dirent } from 'node:fs'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; +import { + DEFAULT_TEST_PRODUCTS_MAX_AGE_DAYS, + DEFAULT_TEST_PRODUCTS_MAX_COUNT, + getConfig, +} from './config-store.ts'; +import { tryAcquireFsLock, type AcquiredFsLock, type TryAcquireFsLockOptions } from './fs-lock.ts'; +import { getWorkspaceFilesystemLayout } from './log-paths.ts'; +import { log } from './logger.ts'; import { isPidAlive } from './process-liveness.ts'; +import { getRuntimeInstanceIfConfigured } from './runtime-instance.ts'; import { + createDefaultTestProductsPath, getManagedTestProductsOwnerPid, getTestProductsCompletionMarkerPath, isXcodeBuildMCPManagedTestProductsName, } from './test-products-path.ts'; +import { workspaceKeyForRoot } from './workspace-identity.ts'; + +export const TEST_PRODUCTS_DAY_MS = 24 * 60 * 60 * 1000; +export const TEST_PRODUCTS_MAX_AGE_MS = DEFAULT_TEST_PRODUCTS_MAX_AGE_DAYS * TEST_PRODUCTS_DAY_MS; +export const TEST_PRODUCTS_MAX_COUNT = DEFAULT_TEST_PRODUCTS_MAX_COUNT; -export const TEST_PRODUCTS_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000; -export const TEST_PRODUCTS_MAX_COUNT = 100; +const TEST_PRODUCTS_READER_STATE_DIR = 'test-products-readers'; +const TEST_PRODUCTS_LOCK_PURPOSE = 'filesystem-lifecycle'; +const TEST_PRODUCTS_LOCK_LEASE_MS = 10 * 60 * 1000; +const TEST_PRODUCTS_LOCK_TIMEOUT_MS = TEST_PRODUCTS_LOCK_LEASE_MS; +const TEST_PRODUCTS_LOCK_RETRY_MS = 50; +const READER_MARKER_PATTERN = /^pid(\d+)_[a-f0-9-]+\.reader$/u; interface RetainedTestProducts { path: string; @@ -19,6 +40,9 @@ interface RetainedTestProducts { export interface TestProductsProtectionOptions { now: number; minVisibleMs: number; + protectedPaths?: ReadonlySet; + readerStateDir?: string; + dependencies?: Partial; } export interface PruneManagedTestProductsOptions extends TestProductsProtectionOptions { @@ -27,31 +51,244 @@ export interface PruneManagedTestProductsOptions extends TestProductsProtectionO maxCount?: number; } -async function hasCompletionMarker(testProductsPath: string): Promise { +export interface ManagedTestProductsLifecycleOptions { + workspaceKey?: string; + maxAgeMs?: number; + maxCount?: number; + lockTimeoutMs?: number; + onLockWait?: () => void; + dependencies?: Partial; +} + +export type ManagedTestProductsOutputOptions = ManagedTestProductsLifecycleOptions & { + isSuccessful?: (result: T) => boolean; +}; + +export interface ManagedTestProductsFileSystem { + mkdir(path: string, options?: { recursive?: boolean; mode?: number }): Promise; + readdir(path: string, options: { withFileTypes: true }): Promise; + rename(oldPath: string, newPath: string): Promise; + rmdir(path: string): Promise; + rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise; + stat(path: string): Promise<{ isDirectory(): boolean; isFile(): boolean; mtimeMs: number }>; + writeFile( + path: string, + content: string, + options: { encoding: BufferEncoding; flag?: string; mode?: number }, + ): Promise; +} + +export interface ManagedTestProductsLifecycleDependencies { + fileSystem: ManagedTestProductsFileSystem; + now(): number; + randomUUID(): string; + pid(): number; + isPidAlive(pid: number): boolean; + tryAcquireLock(options: TryAcquireFsLockOptions): Promise; + sleep(ms: number): Promise; + createTestProductsPath(toolName: string): string; + cwd(): string; +} + +interface ResolvedManagedTestProductsLifecycle { + testProductsDir: string; + readerStateDir: string; + lockDir: string; + maxAgeMs: number; + maxCount: number; + lockTimeoutMs: number; + onLockWait?: () => void; + dependencies: ManagedTestProductsLifecycleDependencies; +} + +const defaultFileSystem: ManagedTestProductsFileSystem = { + mkdir: (filePath, options) => fs.mkdir(filePath, options), + readdir: (filePath, options) => fs.readdir(filePath, options), + rename: (oldPath, newPath) => fs.rename(oldPath, newPath), + rmdir: (filePath) => fs.rmdir(filePath), + rm: (filePath, options) => fs.rm(filePath, options), + stat: (filePath) => fs.stat(filePath), + writeFile: (filePath, content, options) => fs.writeFile(filePath, content, options), +}; + +const defaultDependencies: ManagedTestProductsLifecycleDependencies = { + fileSystem: defaultFileSystem, + now: () => Date.now(), + randomUUID, + pid: () => process.pid, + isPidAlive, + tryAcquireLock: tryAcquireFsLock, + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + createTestProductsPath: createDefaultTestProductsPath, + cwd: () => process.cwd(), +}; + +export function getManagedTestProductsReaderStateDir(workspaceKey: string): string { + return path.join( + getWorkspaceFilesystemLayout(workspaceKey).state, + TEST_PRODUCTS_READER_STATE_DIR, + ); +} + +function resolveDependencies( + overrides: Partial | undefined, +): ManagedTestProductsLifecycleDependencies { + return { ...defaultDependencies, ...overrides }; +} + +function resolveWorkspaceKey( + workspaceKey: string | undefined, + dependencies: ManagedTestProductsLifecycleDependencies, +): string { + return ( + workspaceKey ?? + getRuntimeInstanceIfConfigured()?.workspaceKey ?? + workspaceKeyForRoot(dependencies.cwd()) + ); +} + +function resolveManagedTestProductsLifecycle( + options: ManagedTestProductsLifecycleOptions, +): ResolvedManagedTestProductsLifecycle { + const dependencies = resolveDependencies(options.dependencies); + const layout = getWorkspaceFilesystemLayout( + resolveWorkspaceKey(options.workspaceKey, dependencies), + ); + const config = getConfig(); + const maxAgeMs = options.maxAgeMs ?? config.testProductsMaxAgeDays * TEST_PRODUCTS_DAY_MS; + const maxCount = options.maxCount ?? config.testProductsMaxCount; + if (!Number.isFinite(maxAgeMs) || maxAgeMs <= 0) { + throw new Error('Managed test products maxAgeMs must be a positive finite number'); + } + if (!Number.isInteger(maxCount) || maxCount < 1) { + throw new Error('Managed test products maxCount must be a positive integer'); + } + return { + testProductsDir: layout.testProducts, + readerStateDir: getManagedTestProductsReaderStateDir( + resolveWorkspaceKey(options.workspaceKey, dependencies), + ), + lockDir: layout.filesystemLifecycle.lockDir, + maxAgeMs, + maxCount, + lockTimeoutMs: options.lockTimeoutMs ?? TEST_PRODUCTS_LOCK_TIMEOUT_MS, + onLockWait: options.onLockWait, + dependencies, + }; +} + +async function hasCompletionMarker( + testProductsPath: string, + dependencies: ManagedTestProductsLifecycleDependencies, +): Promise { try { - return (await fs.stat(getTestProductsCompletionMarkerPath(testProductsPath))).isFile(); + return ( + await dependencies.fileSystem.stat(getTestProductsCompletionMarkerPath(testProductsPath)) + ).isFile(); } catch { return false; } } +function readerDirectory(readerStateDir: string, artifactName: string): string { + return path.join(readerStateDir, artifactName); +} + +async function removeReaderDirectoryIfEmpty( + directory: string, + dependencies: ManagedTestProductsLifecycleDependencies, +): Promise { + try { + await dependencies.fileSystem.rmdir(directory); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT' && code !== 'ENOTEMPTY') { + throw error; + } + } +} + +async function hasLiveReader( + artifact: RetainedTestProducts, + readerStateDir: string, + dependencies: ManagedTestProductsLifecycleDependencies, +): Promise { + const directory = readerDirectory(readerStateDir, artifact.name); + let entries: Dirent[]; + try { + entries = await dependencies.fileSystem.readdir(directory, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false; + } + throw error; + } + + let liveReader = false; + for (const entry of entries) { + if (!entry.isFile()) { + continue; + } + const match = entry.name.match(READER_MARKER_PATTERN); + if (!match) { + continue; + } + const pid = Number(match[1]); + if (Number.isInteger(pid) && pid > 0 && dependencies.isPidAlive(pid)) { + liveReader = true; + } else { + await dependencies.fileSystem.rm(path.join(directory, entry.name), { force: true }); + } + } + if (!liveReader) { + await removeReaderDirectoryIfEmpty(directory, dependencies); + } + return liveReader; +} + export async function isProtectedManagedTestProducts( artifact: RetainedTestProducts, options: TestProductsProtectionOptions, ): Promise { + const dependencies = resolveDependencies(options.dependencies); + if (options.protectedPaths?.has(path.resolve(artifact.path))) { + return true; + } + if ( + options.readerStateDir && + (await hasLiveReader(artifact, options.readerStateDir, dependencies)) + ) { + return true; + } if (options.now - artifact.mtimeMs < options.minVisibleMs) { return true; } const ownerPid = getManagedTestProductsOwnerPid(artifact.name); - return Boolean(ownerPid && isPidAlive(ownerPid) && !(await hasCompletionMarker(artifact.path))); + return Boolean( + ownerPid && + dependencies.isPidAlive(ownerPid) && + !(await hasCompletionMarker(artifact.path, dependencies)), + ); } export async function pruneManagedTestProductsDirectory( options: PruneManagedTestProductsOptions, ): Promise<{ scanned: number; deleted: number }> { - await fs.mkdir(options.testProductsDir, { recursive: true, mode: 0o700 }); - const entries = await fs.readdir(options.testProductsDir, { withFileTypes: true }); + const dependencies = resolveDependencies(options.dependencies); + const fileSystem = dependencies.fileSystem; + const config = getConfig(); + const maxAgeMs = options.maxAgeMs ?? config.testProductsMaxAgeDays * TEST_PRODUCTS_DAY_MS; + const maxCount = options.maxCount ?? config.testProductsMaxCount; + const readerStateDir = + options.readerStateDir ?? + path.join(path.dirname(options.testProductsDir), 'state', TEST_PRODUCTS_READER_STATE_DIR); + const protectedPaths = new Set( + [...(options.protectedPaths ?? [])].map((protectedPath) => path.resolve(protectedPath)), + ); + + await fileSystem.mkdir(options.testProductsDir, { recursive: true, mode: 0o700 }); + const entries = await fileSystem.readdir(options.testProductsDir, { withFileTypes: true }); const candidates = entries .filter((entry) => entry.isDirectory() && isXcodeBuildMCPManagedTestProductsName(entry.name)) .map((entry) => ({ @@ -63,7 +300,7 @@ export async function pruneManagedTestProductsDirectory( try { return { ...candidate, - mtimeMs: (await fs.stat(candidate.path)).mtimeMs, + mtimeMs: (await fileSystem.stat(candidate.path)).mtimeMs, } satisfies RetainedTestProducts; } catch { return null; @@ -71,20 +308,32 @@ export async function pruneManagedTestProductsDirectory( }), ); + const protectedArtifacts: RetainedTestProducts[] = []; const retained: RetainedTestProducts[] = []; const expired: RetainedTestProducts[] = []; for (const artifact of stats) { - if (!artifact || (await isProtectedManagedTestProducts(artifact, options))) { + if (!artifact) { continue; } - if (options.now - artifact.mtimeMs > (options.maxAgeMs ?? TEST_PRODUCTS_MAX_AGE_MS)) { + if ( + await isProtectedManagedTestProducts(artifact, { + now: options.now, + minVisibleMs: options.minVisibleMs, + protectedPaths, + readerStateDir, + dependencies, + }) + ) { + protectedArtifacts.push(artifact); + } else if (options.now - artifact.mtimeMs > maxAgeMs) { expired.push(artifact); } else { retained.push(artifact); } } - const excessCount = retained.length - (options.maxCount ?? TEST_PRODUCTS_MAX_COUNT); + const retainedCapacity = Math.max(0, maxCount - protectedArtifacts.length); + const excessCount = retained.length - retainedCapacity; const overflow = excessCount > 0 ? retained @@ -95,8 +344,12 @@ export async function pruneManagedTestProductsDirectory( const deletions = await Promise.all( [...expired, ...overflow].map(async (artifact) => { try { - await fs.rm(artifact.path, { recursive: true, force: true }); - await fs.rm(getTestProductsCompletionMarkerPath(artifact.path), { force: true }); + await fileSystem.rm(artifact.path, { recursive: true, force: true }); + await fileSystem.rm(getTestProductsCompletionMarkerPath(artifact.path), { force: true }); + await fileSystem.rm(readerDirectory(readerStateDir, artifact.name), { + recursive: true, + force: true, + }); return true; } catch { return false; @@ -109,3 +362,254 @@ export async function pruneManagedTestProductsDirectory( deleted: deletions.filter(Boolean).length, }; } + +async function acquireLifecycleLock( + lifecycle: ResolvedManagedTestProductsLifecycle, +): Promise { + const { dependencies } = lifecycle; + const deadline = dependencies.now() + lifecycle.lockTimeoutMs; + while (true) { + const lock = await dependencies.tryAcquireLock({ + lockDir: lifecycle.lockDir, + purpose: TEST_PRODUCTS_LOCK_PURPOSE, + leaseMs: TEST_PRODUCTS_LOCK_LEASE_MS, + now: dependencies.now(), + pid: dependencies.pid(), + }); + if (lock) { + return lock; + } + lifecycle.onLockWait?.(); + if (dependencies.now() >= deadline) { + throw new Error( + `Timed out waiting for managed test products lifecycle lock at ${lifecycle.lockDir}`, + ); + } + await dependencies.sleep(TEST_PRODUCTS_LOCK_RETRY_MS); + } +} + +async function withLifecycleLock( + lifecycle: ResolvedManagedTestProductsLifecycle, + operation: () => Promise, +): Promise { + const lock = await acquireLifecycleLock(lifecycle); + try { + return await operation(); + } finally { + await lock.release(); + } +} + +async function pruneForLifecycle( + lifecycle: ResolvedManagedTestProductsLifecycle, + maxCount: number, + protectedPaths?: ReadonlySet, +): Promise { + await pruneManagedTestProductsDirectory({ + testProductsDir: lifecycle.testProductsDir, + readerStateDir: lifecycle.readerStateDir, + now: lifecycle.dependencies.now(), + minVisibleMs: 0, + maxAgeMs: lifecycle.maxAgeMs, + maxCount, + protectedPaths, + dependencies: lifecycle.dependencies, + }); +} + +async function finalizeAfterOperation( + operationFailed: boolean, + finalize: () => Promise, +): Promise { + try { + await finalize(); + } catch (finalizeError) { + if (!operationFailed) { + throw finalizeError; + } + const message = finalizeError instanceof Error ? finalizeError.message : String(finalizeError); + log('warn', `Managed test products finalization failed after operation error: ${message}`); + } +} + +async function markManagedTestProductsCompleted( + testProductsPath: string, + dependencies: ManagedTestProductsLifecycleDependencies, +): Promise { + try { + if (!(await dependencies.fileSystem.stat(testProductsPath)).isDirectory()) { + throw new Error(`Managed test products output is not a directory: ${testProductsPath}`); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error(`Managed test products output was not created: ${testProductsPath}`, { + cause: error, + }); + } + throw error; + } + + const markerPath = getTestProductsCompletionMarkerPath(testProductsPath); + const tempPath = `${markerPath}.${dependencies.pid()}_${dependencies.randomUUID()}.tmp`; + await dependencies.fileSystem.writeFile(tempPath, `${dependencies.now()}\n`, { + encoding: 'utf8', + mode: 0o600, + }); + try { + await dependencies.fileSystem.rename(tempPath, markerPath); + } catch (error) { + await dependencies.fileSystem.rm(tempPath, { force: true }); + throw error; + } +} + +async function removeManagedTestProductsOutput( + testProductsPath: string, + lifecycle: ResolvedManagedTestProductsLifecycle, +): Promise { + await lifecycle.dependencies.fileSystem.rm(testProductsPath, { + recursive: true, + force: true, + }); + await lifecycle.dependencies.fileSystem.rm( + getTestProductsCompletionMarkerPath(testProductsPath), + { recursive: true, force: true }, + ); +} + +export async function withManagedTestProductsOutput( + toolName: string, + operation: (testProductsPath: string) => Promise, + options: ManagedTestProductsOutputOptions = {}, +): Promise { + const lifecycle = resolveManagedTestProductsLifecycle(options); + let testProductsPath = ''; + await withLifecycleLock(lifecycle, async () => { + await pruneForLifecycle(lifecycle, lifecycle.maxCount - 1); + testProductsPath = lifecycle.dependencies.createTestProductsPath(toolName); + }); + + let result: T | undefined; + let operationFailed = false; + let operationError: unknown; + try { + result = await operation(testProductsPath); + } catch (error) { + operationFailed = true; + operationError = error; + } + + const operationSucceeded = !operationFailed && (options.isSuccessful?.(result as T) ?? true); + + if (!operationSucceeded) { + await finalizeAfterOperation(operationFailed, async () => { + await withLifecycleLock(lifecycle, async () => { + await removeManagedTestProductsOutput(testProductsPath, lifecycle); + await pruneForLifecycle(lifecycle, lifecycle.maxCount); + }); + }); + if (operationFailed) { + throw operationError; + } + return result as T; + } + + await finalizeAfterOperation(false, async () => { + await withLifecycleLock(lifecycle, async () => { + try { + await markManagedTestProductsCompleted(testProductsPath, lifecycle.dependencies); + } catch (error) { + await removeManagedTestProductsOutput(testProductsPath, lifecycle); + throw error; + } + await pruneForLifecycle( + lifecycle, + lifecycle.maxCount, + new Set([path.resolve(testProductsPath)]), + ); + }); + }); + + return result as T; +} + +function resolveManagedReaderPath( + sourcePath: string, + lifecycle: ResolvedManagedTestProductsLifecycle, +): string | null { + const managedRoot = path.resolve(lifecycle.testProductsDir); + const resolvedSourcePath = path.resolve(sourcePath); + const relative = path.relative(managedRoot, resolvedSourcePath); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + return null; + } + + let candidate = resolvedSourcePath; + while (true) { + if ( + path.dirname(candidate) === managedRoot && + isXcodeBuildMCPManagedTestProductsName(path.basename(candidate)) + ) { + return candidate; + } + if (candidate === managedRoot) { + return null; + } + const parent = path.dirname(candidate); + if (parent === candidate) { + return null; + } + candidate = parent; + } +} + +export async function withManagedTestProductsReader( + sourcePath: string, + operation: () => Promise, + options: ManagedTestProductsLifecycleOptions = {}, +): Promise { + const lifecycle = resolveManagedTestProductsLifecycle(options); + const testProductsPath = resolveManagedReaderPath(sourcePath, lifecycle); + if (!testProductsPath) { + return operation(); + } + + const resolvedPath = path.resolve(testProductsPath); + const directory = readerDirectory(lifecycle.readerStateDir, path.basename(resolvedPath)); + const markerPath = path.join( + directory, + `pid${lifecycle.dependencies.pid()}_${lifecycle.dependencies.randomUUID()}.reader`, + ); + await withLifecycleLock(lifecycle, async () => { + await lifecycle.dependencies.fileSystem.mkdir(directory, { recursive: true, mode: 0o700 }); + await lifecycle.dependencies.fileSystem.writeFile(markerPath, `${resolvedPath}\n`, { + encoding: 'utf8', + flag: 'wx', + mode: 0o600, + }); + }); + + let result: T | undefined; + let operationFailed = false; + let operationError: unknown; + try { + result = await operation(); + } catch (error) { + operationFailed = true; + operationError = error; + } + + await finalizeAfterOperation(operationFailed, async () => { + await lifecycle.dependencies.fileSystem.rm(markerPath, { force: true }); + await removeReaderDirectoryIfEmpty(directory, lifecycle.dependencies); + await withLifecycleLock(lifecycle, async () => { + await pruneForLifecycle(lifecycle, lifecycle.maxCount); + }); + }); + + if (operationFailed) { + throw operationError; + } + return result as T; +} diff --git a/src/utils/workspace-filesystem-lifecycle.ts b/src/utils/workspace-filesystem-lifecycle.ts index 7637263cf..998b09b84 100644 --- a/src/utils/workspace-filesystem-lifecycle.ts +++ b/src/utils/workspace-filesystem-lifecycle.ts @@ -643,7 +643,6 @@ export async function runWorkspaceFilesystemLifecycleSweep( testProductsDir: resolved.testProductsDir, now: resolved.now, minVisibleMs: resolved.minVisibleMs, - maxAgeMs: resolved.maxAgeMs, }) : { scanned: 0, deleted: 0 }; await touchCleanupMarker(resolved.markerPath, resolved.now); diff --git a/src/visibility/__tests__/exposure.test.ts b/src/visibility/__tests__/exposure.test.ts index 5f20979ac..923a997fd 100644 --- a/src/visibility/__tests__/exposure.test.ts +++ b/src/visibility/__tests__/exposure.test.ts @@ -29,6 +29,8 @@ function createDefaultConfig( showTestTiming: false, uiDebuggerGuardMode: 'error', incrementalBuildsEnabled: false, + testProductsMaxCount: 3, + testProductsMaxAgeDays: 1, dapRequestTimeoutMs: 30000, dapLogEvents: false, launchJsonWaitMs: 8000, diff --git a/src/visibility/__tests__/predicate-registry.test.ts b/src/visibility/__tests__/predicate-registry.test.ts index 25158bd46..5bbf4a090 100644 --- a/src/visibility/__tests__/predicate-registry.test.ts +++ b/src/visibility/__tests__/predicate-registry.test.ts @@ -22,6 +22,8 @@ function createDefaultConfig( showTestTiming: false, uiDebuggerGuardMode: 'error', incrementalBuildsEnabled: false, + testProductsMaxCount: 3, + testProductsMaxAgeDays: 1, dapRequestTimeoutMs: 30000, dapLogEvents: false, launchJsonWaitMs: 8000,