diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2b727a1c65..6f71c193c5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,6 +55,11 @@ Append `:clean` to any platform build when DerivedData may be stale, for example `pnpm build:xcuitest:macos:clean`. `pnpm build:xcuitest` remains the shared iOS-and-macOS gate for changes that affect both runners; it is not an all-platform build. +`pnpm clean:xcuitest[:ios|:macos|:tvos|:visionos]` cleans the runner derived-data directory and prints +it. When `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` is set, the scripts clean only that directory, for +every platform, and keep entries that are not build output. Otherwise they clean the default +directory under `~/.agent-device/apple-runner/derived`. + Android and macOS helper builds remain separate because they require their native toolchains: ```bash diff --git a/package.json b/package.json index 07a4584c2b..ab0968810a 100644 --- a/package.json +++ b/package.json @@ -90,11 +90,11 @@ "clean:daemon": "node --experimental-strip-types scripts/clean-daemon.ts", "daemon:state-dir": "node --experimental-strip-types scripts/print-daemon-state-dir.ts", "explain:command": "node --experimental-strip-types scripts/explain-command.ts", - "clean:xcuitest": "node scripts/clean-xcuitest-derived.mjs", - "clean:xcuitest:ios": "node scripts/clean-xcuitest-derived.mjs ios", - "clean:xcuitest:macos": "node scripts/clean-xcuitest-derived.mjs macos", - "clean:xcuitest:tvos": "node scripts/clean-xcuitest-derived.mjs tvos", - "clean:xcuitest:visionos": "node scripts/clean-xcuitest-derived.mjs visionos", + "clean:xcuitest": "node --experimental-strip-types scripts/clean-xcuitest-derived.mjs", + "clean:xcuitest:ios": "node --experimental-strip-types scripts/clean-xcuitest-derived.mjs ios", + "clean:xcuitest:macos": "node --experimental-strip-types scripts/clean-xcuitest-derived.mjs macos", + "clean:xcuitest:tvos": "node --experimental-strip-types scripts/clean-xcuitest-derived.mjs tvos", + "clean:xcuitest:visionos": "node --experimental-strip-types scripts/clean-xcuitest-derived.mjs visionos", "rebuild:cli": "pnpm build && pnpm clean:daemon", "build:xcuitest": "pnpm build:xcuitest:ios && pnpm build:xcuitest:macos", "build:xcuitest:ios": "AGENT_DEVICE_XCUITEST_PLATFORM=ios sh ./scripts/build-xcuitest-apple.sh", diff --git a/packages/platform-apple/src/core/runner-client.ts b/packages/platform-apple/src/core/runner-client.ts index 5aff792e8d..fa69b05d38 100644 --- a/packages/platform-apple/src/core/runner-client.ts +++ b/packages/platform-apple/src/core/runner-client.ts @@ -47,10 +47,12 @@ export { resolveRunnerBundleBuildSettings, resolveRunnerPerformanceBuildSettings, resolveRunnerSandboxBuildArgs, + resolveRunnerScriptDefaultDerivedPath, resolveRunnerSigningBuildSettings, } from '../runner/runner-cache-metadata.ts'; export { isRunnerXcuitestScriptPlatform, + resolveRunnerDerivedOverride, resolveRunnerScriptDevice, } from '../runner/apple-runner-platform.ts'; export { diff --git a/packages/platform-apple/src/runner-operations-facade.ts b/packages/platform-apple/src/runner-operations-facade.ts index 277672a80f..6656ad336e 100644 --- a/packages/platform-apple/src/runner-operations-facade.ts +++ b/packages/platform-apple/src/runner-operations-facade.ts @@ -19,6 +19,8 @@ export { resolveRunnerPerformanceBuildSettings, resolveRunnerSandboxBuildArgs, isRunnerXcuitestScriptPlatform, + resolveRunnerDerivedOverride, + resolveRunnerScriptDefaultDerivedPath, resolveRunnerScriptDevice, resolveRunnerSigningBuildSettings, requireCertifiedRunnerCacheArtifacts, diff --git a/packages/platform-apple/src/runner/apple-runner-platform.ts b/packages/platform-apple/src/runner/apple-runner-platform.ts index 28180c4ddd..a25ee90ef2 100644 --- a/packages/platform-apple/src/runner/apple-runner-platform.ts +++ b/packages/platform-apple/src/runner/apple-runner-platform.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; import { isMacOs, @@ -136,6 +137,16 @@ const RUNNER_PLATFORM_PROFILES: Record` builds into when no override is set. + * iOS builds into the shared base; the other platforms build into a subdirectory of it. + */ +export function resolveRunnerScriptDefaultDerivedPath( + platform: RunnerXcuitestScriptPlatform, +): string { + const base = path.join(RUNNER_DERIVED_ROOT, 'derived'); + return platform === 'ios' ? base : path.join(base, platform); } export function resolveRunnerKeyedDerivedPath( diff --git a/packages/platform-apple/src/runner/runner-cache-retention.ts b/packages/platform-apple/src/runner/runner-cache-retention.ts index 3d543b73bf..aafc8ceaaa 100644 --- a/packages/platform-apple/src/runner/runner-cache-retention.ts +++ b/packages/platform-apple/src/runner/runner-cache-retention.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import { resolveRunnerDerivedOverride } from './apple-runner-platform.ts'; import { hostEnvironment, emitDiagnostic } from './host.ts'; import { acquireRunnerXctestrunCacheLock, @@ -39,7 +40,7 @@ export async function evictStaleRunnerCaches( nowMs: number = Date.now(), ): Promise { const keep = resolveRunnerCacheKeepCount(env); - if (keep === 0 || env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim()) return []; + if (keep === 0 || resolveRunnerDerivedOverride(env)) return []; const base = path.dirname(currentDerived); const candidates = listCacheKeyDirectories(base) .filter((entry) => entry.derived !== currentDerived) diff --git a/packages/platform-apple/src/runner/runner-cache.ts b/packages/platform-apple/src/runner/runner-cache.ts index f16b744920..426a0c1f7f 100644 --- a/packages/platform-apple/src/runner/runner-cache.ts +++ b/packages/platform-apple/src/runner/runner-cache.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; +import { resolveRunnerDerivedOverride } from './apple-runner-platform.ts'; import { emitDiagnostic, readProcessStartTime, @@ -295,8 +296,7 @@ export function assertSafeDerivedCleanup( derivedPath: string, env: NodeJS.ProcessEnv = hostEnvironment().env, ): void { - const override = env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim(); - if (!override) { + if (!resolveRunnerDerivedOverride(env)) { return; } if (isPathInsideProjectTmp(derivedPath)) { diff --git a/scripts/build-xcuitest-apple.sh b/scripts/build-xcuitest-apple.sh index e306a8391a..c4919c6de8 100644 --- a/scripts/build-xcuitest-apple.sh +++ b/scripts/build-xcuitest-apple.sh @@ -98,7 +98,7 @@ resolve_default_derived_path() { } resolve_clean_path() { - if [ -n "${AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH:-}" ]; then + if [ -n "$DERIVED_OVERRIDE" ]; then printf '%s\n' "$DERIVED_PATH" return fi @@ -118,7 +118,9 @@ resolve_clean_path() { } DESTINATION="${AGENT_DEVICE_XCUITEST_DESTINATION:-$(resolve_default_destination)}" -DERIVED_PATH="${AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH:-$(resolve_default_derived_path)}" +# Trimmed like resolveRunnerDerivedOverride, so a blank value means "not set" everywhere. +DERIVED_OVERRIDE="$(printf '%s' "${AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH:-}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" +DERIVED_PATH="${DERIVED_OVERRIDE:-$(resolve_default_derived_path)}" CLEAN_PATH="$(resolve_clean_path)" if is_truthy "${AGENT_DEVICE_IOS_CLEAN_DERIVED:-}"; then diff --git a/scripts/clean-xcuitest-derived.mjs b/scripts/clean-xcuitest-derived.mjs index 7c9902ff34..d3a503bafc 100644 --- a/scripts/clean-xcuitest-derived.mjs +++ b/scripts/clean-xcuitest-derived.mjs @@ -1,9 +1,12 @@ #!/usr/bin/env node import fs from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; +import { + resolveRunnerDerivedOverride, + resolveRunnerScriptDefaultDerivedPath, +} from '@agent-device/platform-apple/runner/operations'; -const DERIVED_ROOT = path.join(os.homedir(), '.agent-device', 'apple-runner', 'derived'); +const DERIVED_OVERRIDE = resolveRunnerDerivedOverride(process.env); const ROOT_TRANSIENT_ENTRY_NAMES = new Set([ '.agent-device-runner-cache.json', 'Build', @@ -19,12 +22,13 @@ const ROOT_TRANSIENT_ENTRY_NAMES = new Set([ const platforms = process.argv.slice(2); const requested = platforms.length > 0 ? platforms : ['ios', 'macos', 'tvos', 'visionos']; const supported = new Set(['ios', 'macos', 'tvos', 'visionos']); -const DERIVED_PATHS = new Map([ - ['ios', DERIVED_ROOT], - ['macos', path.join(DERIVED_ROOT, 'macos')], - ['tvos', path.join(DERIVED_ROOT, 'tvos')], - ['visionos', path.join(DERIVED_ROOT, 'visionos')], -]); +// An override pins one directory for every platform, as it does for the runner build. +const DERIVED_PATHS = new Map( + [...supported].map((platform) => [ + platform, + DERIVED_OVERRIDE ?? resolveRunnerScriptDefaultDerivedPath(platform), + ]), +); const PLATFORM_LABELS = new Map([ ['ios', 'iOS'], ['macos', 'macOS'], @@ -55,7 +59,7 @@ function cleanDerivedPath(platform, targetPath) { if (!fs.existsSync(targetPath)) { return { status: 'skipped', reason: 'not-found' }; } - if (platform !== 'ios') { + if (wholeDirectoryClean(platform)) { fs.rmSync(targetPath, { recursive: true, force: true }); return { status: 'removed' }; } @@ -85,10 +89,17 @@ function cleanDerivedPath(platform, targetPath) { function formatCleanupResult(platform, targetPath, result) { const platformLabel = resolvePlatformLabel(platform); - if (platform !== 'ios') return formatWholeDerivedCleanupResult(platformLabel, targetPath, result); + if (wholeDirectoryClean(platform)) + return formatWholeDerivedCleanupResult(platformLabel, targetPath, result); return formatIosCleanupResult(platformLabel, targetPath, result); } +// Only a platform's own default subdirectory is removed whole; the shared base and an override +// keep entries that are not build output. +function wholeDirectoryClean(platform) { + return platform !== 'ios' && !DERIVED_OVERRIDE; +} + function formatWholeDerivedCleanupResult(platformLabel, targetPath, result) { return result.status === 'removed' ? `Removed ${platformLabel} XCTest derived data: ${targetPath}` diff --git a/test/integration/smoke-clean-xcuitest-derived.test.ts b/test/integration/smoke-clean-xcuitest-derived.test.ts index fade8b5b5f..e716c94287 100644 --- a/test/integration/smoke-clean-xcuitest-derived.test.ts +++ b/test/integration/smoke-clean-xcuitest-derived.test.ts @@ -84,6 +84,59 @@ test('clean-xcuitest macos removes the entire platform directory when present', } }); +for (const platform of ['ios', 'macos']) { + test(`clean-xcuitest ${platform} cleans the override directory and leaves the default untouched`, () => { + const homeDir = fs.mkdtempSync( + path.join(os.tmpdir(), `agent-device-clean-xcuitest-override-${platform}-`), + ); + try { + const defaultRoot = path.join(homeDir, '.agent-device', 'apple-runner', 'derived'); + const defaultPlatformDir = path.join(defaultRoot, 'macos'); + fs.mkdirSync(path.join(defaultRoot, 'Build'), { recursive: true }); + fs.mkdirSync(defaultPlatformDir, { recursive: true }); + const overrideRoot = path.join(homeDir, 'worktree', '.tmp', 'ios-runner-derived'); + fs.mkdirSync(path.join(overrideRoot, 'Build'), { recursive: true }); + fs.mkdirSync(path.join(overrideRoot, 'Logs'), { recursive: true }); + fs.writeFileSync(path.join(overrideRoot, '.agent-device-runner-cache.json'), '{}', 'utf8'); + fs.mkdirSync(path.join(overrideRoot, 'user-data'), { recursive: true }); + + const result = runCleanXcuitest(homeDir, platform, { + env: { AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ` ${overrideRoot}/ ` }, + }); + const label = platform === 'ios' ? 'iOS' : 'macOS'; + assert.equal(result.exitCode, 0, result.stderr); + assert.equal( + result.stdout.trim(), + `Removed ${label} XCTest transient entries under ${overrideRoot}: .agent-device-runner-cache.json, Build, Logs; kept user-data`, + ); + assert.deepEqual(fs.readdirSync(overrideRoot), ['user-data']); + assert.equal(fs.existsSync(path.join(defaultRoot, 'Build')), true); + assert.equal(fs.existsSync(defaultPlatformDir), true); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + }); +} + +test('clean-xcuitest ignores a blank override and keeps the default directory', () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-clean-xcuitest-blank-')); + try { + const derivedRoot = path.join(homeDir, '.agent-device', 'apple-runner', 'derived'); + fs.mkdirSync(path.join(derivedRoot, 'Build'), { recursive: true }); + + const result = runCleanXcuitest(homeDir, 'ios', { + env: { AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ' ' }, + }); + assert.equal(result.exitCode, 0, result.stderr); + assert.equal( + result.stdout.trim(), + `Removed iOS XCTest transient entries under ${derivedRoot}: Build`, + ); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } +}); + // Root bypasses directory permissions, so chmod cannot force the cleanup // failure this test provokes (common in containerized agent environments). const isRoot = process.getuid?.() === 0; @@ -112,19 +165,33 @@ test( }, ); -function runCleanXcuitest(homeDir: string, ...args: Array) { +function runCleanXcuitest( + homeDir: string, + ...args: Array }> +) { const lastArg = args.at(-1); const options = typeof lastArg === 'object' ? lastArg : {}; const platforms = (options === lastArg ? args.slice(0, -1) : args) as string[]; - return runCmdSync(process.execPath, [scriptPath, ...platforms], { - cwd: repoRoot, - env: { - ...process.env, - HOME: homeDir, + return runCmdSync( + process.execPath, + [ + '--experimental-strip-types', + '--disable-warning=ExperimentalWarning', + scriptPath, + ...platforms, + ], + { + cwd: repoRoot, + env: { + ...process.env, + AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: undefined, + HOME: homeDir, + ...options?.env, + }, + allowFailure: options?.allowFailure, + timeoutMs: 30_000, }, - allowFailure: options?.allowFailure, - timeoutMs: 30_000, - }); + ); } function escapeRegExp(value: string) {