From 99224a2a758c954ce8c214396d1ff27e78721912 Mon Sep 17 00:00:00 2001 From: Ian Rahman Date: Fri, 11 Sep 2026 16:01:33 -0400 Subject: [PATCH] fix: make test product retention configurable --- CHANGELOG.md | 4 ++ config.example.yaml | 2 + src/utils/__tests__/config-store.test.ts | 14 ++++++ src/utils/__tests__/project-config.test.ts | 15 ++++++ .../__tests__/test-products-lifecycle.test.ts | 4 +- .../workspace-filesystem-lifecycle.test.ts | 47 ++++++++++++++++++- src/utils/config-store.ts | 43 +++++++++++++++++ src/utils/runtime-config-schema.ts | 2 + src/utils/test-products-lifecycle.ts | 6 ++- src/utils/workspace-filesystem-lifecycle.ts | 21 ++++++++- 10 files changed, 151 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1130fa825..17860954b 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 + +- Reduced managed test-product retention to three bundles for one day and added `testProductsMaxCount` and `testProductsMaxAgeDays` project configuration, with matching environment overrides, to substantially reduce disk growth from repeated test runs ([#524](https://github.com/getsentry/XcodeBuildMCP/issues/524)). + ## [2.7.0] ### New! Xcode 27 Device Hub simulator support diff --git a/config.example.yaml b/config.example.yaml index 94af77fb7..36930941f 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -8,6 +8,8 @@ customWorkflows: experimentalWorkflowDiscovery: false disableSessionDefaults: false incrementalBuildsEnabled: false +testProductsMaxCount: 3 # managed paths only; caller-provided paths are never pruned +testProductsMaxAgeDays: 1 debug: false sentryDisabled: false filePathRenderStyle: 'list' # text output file artifacts: list or tree diff --git a/src/utils/__tests__/config-store.test.ts b/src/utils/__tests__/config-store.test.ts index 8d953de4c..cddeefa89 100644 --- a/src/utils/__tests__/config-store.test.ts +++ b/src/utils/__tests__/config-store.test.ts @@ -38,6 +38,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 +51,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 +69,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); @@ -79,6 +85,8 @@ describe('config-store', () => { const yaml = [ 'schemaVersion: 1', 'debug: false', + 'testProductsMaxCount: 8', + 'testProductsMaxAgeDays: 2', 'dapRequestTimeoutMs: 4000', 'filePathRenderStyle: tree', 'axeSourcePath: /file/AXe', @@ -86,6 +94,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 +106,8 @@ describe('config-store', () => { fs: createFs(yaml), overrides: { debug: true, + testProductsMaxCount: 7, + testProductsMaxAgeDays: 1.5, dapRequestTimeoutMs: 12345, filePathRenderStyle: 'list', axeSourcePath: '/override/AXe', @@ -105,6 +117,8 @@ 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'); diff --git a/src/utils/__tests__/project-config.test.ts b/src/utils/__tests__/project-config.test.ts index aaba24b59..77b1a70b8 100644 --- a/src/utils/__tests__/project-config.test.ts +++ b/src/utils/__tests__/project-config.test.ts @@ -278,6 +278,21 @@ describe('project-config', () => { } }); + it('should reject negative test-products retention limits', async () => { + const yaml = [ + 'schemaVersion: 1', + 'testProductsMaxCount: -1', + 'testProductsMaxAgeDays: -1', + '', + ].join('\n'); + const { fs } = createFsFixture({ exists: true, readFile: yaml }); + + const result = await loadProjectConfig({ fs, cwd }); + + expect(result.found).toBe(false); + expect('error' in result).toBe(true); + }); + it('should return an error result when YAML does not parse to an object', async () => { const { fs } = createFsFixture({ exists: true, readFile: '- item' }); diff --git a/src/utils/__tests__/test-products-lifecycle.test.ts b/src/utils/__tests__/test-products-lifecycle.test.ts index 587072f93..75a375026 100644 --- a/src/utils/__tests__/test-products-lifecycle.test.ts +++ b/src/utils/__tests__/test-products-lifecycle.test.ts @@ -40,7 +40,7 @@ describe('test products lifecycle', () => { await rm(root, { recursive: true, force: true }); }); - it('prunes managed products after three days while preserving caller-owned paths', async () => { + it('prunes managed products after the default retention age 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 +50,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 - TEST_PRODUCTS_MAX_AGE_MS / 2, true); writeTestProducts(callerOwned, now - 10 * DAY_MS, true); writeTestProducts(externalCallerOwned, now - 10 * DAY_MS, true); diff --git a/src/utils/__tests__/workspace-filesystem-lifecycle.test.ts b/src/utils/__tests__/workspace-filesystem-lifecycle.test.ts index 2e05c4d03..a2641f241 100644 --- a/src/utils/__tests__/workspace-filesystem-lifecycle.test.ts +++ b/src/utils/__tests__/workspace-filesystem-lifecycle.test.ts @@ -5,6 +5,8 @@ import { existsSync, mkdirSync, mkdtempSync, writeFileSync, utimesSync } from 'n import { rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import * as path from 'node:path'; +import { writeDaemonRegistryEntry } from '../../daemon/daemon-registry.ts'; +import { createMockFileSystemExecutor } from '../../test-utils/mock-executors.ts'; import { cleanupOwnedWorkspaceFilesystemArtifacts, getManagedResultBundleOwnerPid, @@ -23,13 +25,13 @@ import { import { getResultBundleCompletionMarkerPath } from '../result-bundle-path.ts'; import { getTestProductsCompletionMarkerPath } from '../test-products-path.ts'; import { TEST_PRODUCTS_MAX_COUNT } from '../test-products-lifecycle.ts'; -import { writeDaemonRegistryEntry } from '../../daemon/daemon-registry.ts'; import { setRuntimeInstanceForTests } from '../runtime-instance.ts'; import { clearAllSimulatorLaunchOsLogSessionsForTests, registerSimulatorLaunchOsLogSession, } from '../log-capture/simulator-launch-oslog-sessions.ts'; import { setSimulatorLaunchOsLogRecordActiveOverrideForTests } from '../log-capture/simulator-launch-oslog-registry.ts'; +import { __resetConfigStoreForTests, initConfigStore } from '../config-store.ts'; let appDir: string; const DEAD_OWNER_PID = 999_999_999; @@ -87,6 +89,7 @@ describe('workspace filesystem lifecycle', () => { pid: process.pid, workspaceKey: 'workspace-a', }); + __resetConfigStoreForTests(); resetWorkspaceFilesystemLifecycleStateForTests(); }); @@ -95,6 +98,7 @@ describe('workspace filesystem lifecycle', () => { setSimulatorLaunchOsLogRecordActiveOverrideForTests(null); await clearAllSimulatorLaunchOsLogSessionsForTests(); setRuntimeInstanceForTests(null); + __resetConfigStoreForTests(); setXcodeBuildMCPAppDirOverrideForTests(null); await rm(appDir, { recursive: true, force: true }); }); @@ -370,6 +374,47 @@ describe('workspace filesystem lifecycle', () => { expect(existsSync(getTestProductsCompletionMarkerPath(products[0]!))).toBe(false); }); + it('applies configured age and count limits to managed test products', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const configPath = path.join('/repo', '.xcodebuildmcp', 'config.yaml'); + await initConfigStore({ + cwd: '/repo', + fs: createMockFileSystemExecutor({ + existsSync: (targetPath) => targetPath === configPath, + readFile: async () => + [ + 'schemaVersion: 1', + 'testProductsMaxCount: 1', + 'testProductsMaxAgeDays: 1', + '', + ].join('\n'), + }), + }); + const layout = getWorkspaceFilesystemLayout('workspace-a'); + const oldest = path.join(layout.testProducts, managedTestProductsName('test_oldest')); + const middle = path.join(layout.testProducts, managedTestProductsName('test_middle')); + const newest = path.join(layout.testProducts, managedTestProductsName('test_newest')); + writeTestProductsWithMtime(oldest, now - 2 * 24 * 60 * 60 * 1000); + writeTestProductsWithMtime(middle, now - 2 * 60 * 60 * 1000); + writeTestProductsWithMtime(newest, now - 60 * 60 * 1000); + for (const productsPath of [oldest, middle, newest]) { + writeFileSync(getTestProductsCompletionMarkerPath(productsPath), 'completed'); + } + + const result = await runWorkspaceFilesystemLifecycleSweep({ + workspaceKey: 'workspace-a', + trigger: 'manual', + now, + force: true, + minVisibleMs: 0, + }); + + expect(result).toMatchObject({ scanned: 3, deleted: 2 }); + expect(existsSync(oldest)).toBe(false); + expect(existsSync(middle)).toBe(false); + expect(existsSync(newest)).toBe(true); + }); + it('protects live managed result bundles until their completion marker exists', async () => { const now = Date.UTC(2026, 4, 2, 12); const layout = getWorkspaceFilesystemLayout('workspace-a'); diff --git a/src/utils/config-store.ts b/src/utils/config-store.ts index c7e0aadc5..3060a12a6 100644 --- a/src/utils/config-store.ts +++ b/src/utils/config-store.ts @@ -11,6 +11,10 @@ import type { DebuggerBackendKind } from './debugger/types.ts'; import type { FilePathRenderStyle, UiDebuggerGuardMode } from './runtime-config-types.ts'; import { isFilePathRenderStyle } from './file-path-render-style.ts'; import { normalizeSessionDefaultsProfileName } from './session-defaults-profile.ts'; +import { + TEST_PRODUCTS_MAX_AGE_DAYS, + TEST_PRODUCTS_MAX_COUNT, +} from './test-products-lifecycle.ts'; export type RuntimeConfigOverrides = Partial<{ enabledWorkflows: string[]; @@ -24,6 +28,8 @@ export type RuntimeConfigOverrides = Partial<{ filePathRenderStyle: FilePathRenderStyle; uiDebuggerGuardMode: UiDebuggerGuardMode; incrementalBuildsEnabled: boolean; + testProductsMaxCount: number; + testProductsMaxAgeDays: number; dapRequestTimeoutMs: number; dapLogEvents: boolean; launchJsonWaitMs: number; @@ -51,6 +57,8 @@ export type ResolvedRuntimeConfig = { filePathRenderStyle?: FilePathRenderStyle; uiDebuggerGuardMode: UiDebuggerGuardMode; incrementalBuildsEnabled: boolean; + testProductsMaxCount?: number; + testProductsMaxAgeDays?: number; dapRequestTimeoutMs: number; dapLogEvents: boolean; launchJsonWaitMs: number; @@ -87,6 +95,8 @@ const DEFAULT_CONFIG: ResolvedRuntimeConfig = { showTestTiming: false, uiDebuggerGuardMode: 'error', incrementalBuildsEnabled: false, + testProductsMaxCount: TEST_PRODUCTS_MAX_COUNT, + testProductsMaxAgeDays: TEST_PRODUCTS_MAX_AGE_DAYS, dapRequestTimeoutMs: 30_000, dapLogEvents: false, launchJsonWaitMs: 8000, @@ -128,6 +138,13 @@ function parseNonNegativeInt(value: string | undefined): number | undefined { return Math.floor(parsed); } +function parseNonNegativeNumber(value: string | undefined): number | undefined { + if (!value) return undefined; + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) return undefined; + return parsed; +} + function parseEnabledWorkflows(value: string | undefined): string[] | undefined { if (value == null) return undefined; const normalized = value @@ -226,6 +243,18 @@ function readEnvConfig(env: NodeJS.ProcessEnv): RuntimeConfigOverrides { setIfDefined(config, 'incrementalBuildsEnabled', parseBoolean(env.INCREMENTAL_BUILDS_ENABLED)); + setIfDefined( + config, + 'testProductsMaxCount', + parseNonNegativeInt(env.XCODEBUILDMCP_TEST_PRODUCTS_MAX_COUNT), + ); + + setIfDefined( + config, + 'testProductsMaxAgeDays', + parseNonNegativeNumber(env.XCODEBUILDMCP_TEST_PRODUCTS_MAX_AGE_DAYS), + ); + const axePath = env.XCODEBUILDMCP_AXE_PATH ?? env.AXE_PATH; if (axePath) config.axePath = axePath; @@ -535,6 +564,20 @@ function resolveConfig(opts: { envConfig, fallback: DEFAULT_CONFIG.incrementalBuildsEnabled, }), + testProductsMaxCount: resolveFromLayers({ + key: 'testProductsMaxCount', + overrides: opts.overrides, + fileConfig: opts.fileConfig, + envConfig, + fallback: DEFAULT_CONFIG.testProductsMaxCount, + }), + testProductsMaxAgeDays: resolveFromLayers({ + key: 'testProductsMaxAgeDays', + overrides: opts.overrides, + fileConfig: opts.fileConfig, + envConfig, + fallback: DEFAULT_CONFIG.testProductsMaxAgeDays, + }), dapRequestTimeoutMs: resolveFromLayers({ key: 'dapRequestTimeoutMs', overrides: opts.overrides, diff --git a/src/utils/runtime-config-schema.ts b/src/utils/runtime-config-schema.ts index 9fb123c83..734abcc1e 100644 --- a/src/utils/runtime-config-schema.ts +++ b/src/utils/runtime-config-schema.ts @@ -15,6 +15,8 @@ export const runtimeConfigFileSchema = z filePathRenderStyle: z.enum(['tree', 'list']).optional(), uiDebuggerGuardMode: z.enum(['error', 'warn', 'off']).optional(), incrementalBuildsEnabled: z.boolean().optional(), + testProductsMaxCount: z.number().int().nonnegative().optional(), + testProductsMaxAgeDays: z.number().nonnegative().optional(), dapRequestTimeoutMs: z.number().int().positive().optional(), dapLogEvents: z.boolean().optional(), launchJsonWaitMs: z.number().int().nonnegative().optional(), diff --git a/src/utils/test-products-lifecycle.ts b/src/utils/test-products-lifecycle.ts index c03ddcaaf..4b3c91de6 100644 --- a/src/utils/test-products-lifecycle.ts +++ b/src/utils/test-products-lifecycle.ts @@ -7,8 +7,10 @@ import { isXcodeBuildMCPManagedTestProductsName, } from './test-products-path.ts'; -export const TEST_PRODUCTS_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000; -export const TEST_PRODUCTS_MAX_COUNT = 100; +export const TEST_PRODUCTS_DAY_MS = 24 * 60 * 60 * 1000; +export const TEST_PRODUCTS_MAX_AGE_DAYS = 1; +export const TEST_PRODUCTS_MAX_AGE_MS = TEST_PRODUCTS_MAX_AGE_DAYS * TEST_PRODUCTS_DAY_MS; +export const TEST_PRODUCTS_MAX_COUNT = 3; interface RetainedTestProducts { path: string; diff --git a/src/utils/workspace-filesystem-lifecycle.ts b/src/utils/workspace-filesystem-lifecycle.ts index 7637263cf..2a66099ec 100644 --- a/src/utils/workspace-filesystem-lifecycle.ts +++ b/src/utils/workspace-filesystem-lifecycle.ts @@ -15,7 +15,13 @@ import { getRuntimeInstance, getRuntimeInstanceIfConfigured } from './runtime-in import { tryAcquireFsLock, type AcquiredFsLock } from './fs-lock.ts'; import { isPidAlive } from './process-liveness.ts'; import { getResultBundleCompletionMarkerPath } from './result-bundle-path.ts'; -import { pruneManagedTestProductsDirectory } from './test-products-lifecycle.ts'; +import { getConfig } from './config-store.ts'; +import { + pruneManagedTestProductsDirectory, + TEST_PRODUCTS_DAY_MS, + TEST_PRODUCTS_MAX_AGE_DAYS, + TEST_PRODUCTS_MAX_COUNT, +} from './test-products-lifecycle.ts'; export const WORKSPACE_FILESYSTEM_LIFECYCLE_LOG_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000; export const WORKSPACE_FILESYSTEM_LIFECYCLE_LOG_MAX_FILES = 10_000; @@ -63,6 +69,8 @@ export interface WorkspaceFilesystemLifecycleOptions { now?: number; maxAgeMs?: number; maxFiles?: number; + testProductsMaxAgeMs?: number; + testProductsMaxCount?: number; cooldownMs?: number; force?: boolean; minVisibleMs?: number; @@ -100,6 +108,8 @@ interface ResolvedWorkspaceFilesystemLifecycleOptions { now: number; maxAgeMs: number; maxFiles: number; + testProductsMaxAgeMs: number; + testProductsMaxCount: number; cooldownMs: number; force: boolean; minVisibleMs: number; @@ -159,6 +169,7 @@ function resolveOptions( const workspaceKey = resolveWorkspaceKey(options); const layout = options.logDir ? null : getWorkspaceFilesystemLayout(workspaceKey); const logDir = options.logDir ?? layout?.logs; + const config = getConfig(); if (!logDir) { throw new Error('Workspace filesystem lifecycle requires a log directory'); } @@ -180,6 +191,11 @@ function resolveOptions( now: options.now ?? Date.now(), maxAgeMs: options.maxAgeMs ?? WORKSPACE_FILESYSTEM_LIFECYCLE_LOG_MAX_AGE_MS, maxFiles: options.maxFiles ?? WORKSPACE_FILESYSTEM_LIFECYCLE_LOG_MAX_FILES, + testProductsMaxAgeMs: + options.testProductsMaxAgeMs ?? + (config.testProductsMaxAgeDays ?? TEST_PRODUCTS_MAX_AGE_DAYS) * TEST_PRODUCTS_DAY_MS, + testProductsMaxCount: + options.testProductsMaxCount ?? config.testProductsMaxCount ?? TEST_PRODUCTS_MAX_COUNT, cooldownMs: options.cooldownMs ?? WORKSPACE_FILESYSTEM_LIFECYCLE_COOLDOWN_MS, force: options.force ?? false, minVisibleMs: options.minVisibleMs ?? WORKSPACE_FILESYSTEM_LIFECYCLE_MIN_VISIBLE_MS, @@ -643,7 +659,8 @@ export async function runWorkspaceFilesystemLifecycleSweep( testProductsDir: resolved.testProductsDir, now: resolved.now, minVisibleMs: resolved.minVisibleMs, - maxAgeMs: resolved.maxAgeMs, + maxAgeMs: resolved.testProductsMaxAgeMs, + maxCount: resolved.testProductsMaxCount, }) : { scanned: 0, deleted: 0 }; await touchCleanupMarker(resolved.markerPath, resolved.now);