From f13251f67e6f1d92bf9dc2b970a0eed8934d301e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 22 Oct 2026 08:29:50 +0200 Subject: [PATCH 1/2] fix(apple): clean the runner derived path the runner uses pnpm clean:xcuitest ignored AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH and cleaned the shared default directory. Resolve the override through the runner's own resolver, and clean only transient entries of that directory. Co-Authored-By: Claude Sonnet 5.5 --- CONTRIBUTING.md | 5 ++ .../platform-apple/src/core/runner-client.ts | 1 + .../src/runner-operations-facade.ts | 1 + .../src/runner/apple-runner-platform.ts | 11 ++++ .../src/runner/runner-cache-metadata.ts | 10 ++-- .../src/runner/runner-cache-retention.ts | 3 +- .../platform-apple/src/runner/runner-cache.ts | 4 +- scripts/clean-xcuitest-derived.mjs | 16 +++-- .../smoke-clean-xcuitest-derived.test.ts | 60 ++++++++++++++++++- 9 files changed, 96 insertions(+), 15 deletions(-) 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/packages/platform-apple/src/core/runner-client.ts b/packages/platform-apple/src/core/runner-client.ts index 5aff792e8d..f8eea09830 100644 --- a/packages/platform-apple/src/core/runner-client.ts +++ b/packages/platform-apple/src/core/runner-client.ts @@ -51,6 +51,7 @@ export { } 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..c471fd2d9d 100644 --- a/packages/platform-apple/src/runner-operations-facade.ts +++ b/packages/platform-apple/src/runner-operations-facade.ts @@ -19,6 +19,7 @@ export { resolveRunnerPerformanceBuildSettings, resolveRunnerSandboxBuildArgs, isRunnerXcuitestScriptPlatform, + resolveRunnerDerivedOverride, 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 { 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/clean-xcuitest-derived.mjs b/scripts/clean-xcuitest-derived.mjs index 7c9902ff34..2c4147460f 100644 --- a/scripts/clean-xcuitest-derived.mjs +++ b/scripts/clean-xcuitest-derived.mjs @@ -2,7 +2,9 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { resolveRunnerDerivedOverride } from '@agent-device/platform-apple/runner/operations'; +const DERIVED_OVERRIDE = resolveRunnerDerivedOverride(process.env); const DERIVED_ROOT = path.join(os.homedir(), '.agent-device', 'apple-runner', 'derived'); const ROOT_TRANSIENT_ENTRY_NAMES = new Set([ '.agent-device-runner-cache.json', @@ -19,11 +21,12 @@ 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']); +// An override pins one directory for every platform, as it does for the runner build. 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')], + ['ios', DERIVED_OVERRIDE ?? DERIVED_ROOT], + ['macos', DERIVED_OVERRIDE ?? path.join(DERIVED_ROOT, 'macos')], + ['tvos', DERIVED_OVERRIDE ?? path.join(DERIVED_ROOT, 'tvos')], + ['visionos', DERIVED_OVERRIDE ?? path.join(DERIVED_ROOT, 'visionos')], ]); const PLATFORM_LABELS = new Map([ ['ios', 'iOS'], @@ -55,7 +58,7 @@ function cleanDerivedPath(platform, targetPath) { if (!fs.existsSync(targetPath)) { return { status: 'skipped', reason: 'not-found' }; } - if (platform !== 'ios') { + if (platform !== 'ios' && !DERIVED_OVERRIDE) { fs.rmSync(targetPath, { recursive: true, force: true }); return { status: 'removed' }; } @@ -85,7 +88,8 @@ function cleanDerivedPath(platform, targetPath) { function formatCleanupResult(platform, targetPath, result) { const platformLabel = resolvePlatformLabel(platform); - if (platform !== 'ios') return formatWholeDerivedCleanupResult(platformLabel, targetPath, result); + if (platform !== 'ios' && !DERIVED_OVERRIDE) + return formatWholeDerivedCleanupResult(platformLabel, targetPath, result); return formatIosCleanupResult(platformLabel, targetPath, result); } diff --git a/test/integration/smoke-clean-xcuitest-derived.test.ts b/test/integration/smoke-clean-xcuitest-derived.test.ts index fade8b5b5f..b698514e91 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,7 +165,10 @@ 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[]; @@ -120,7 +176,9 @@ function runCleanXcuitest(homeDir: string, ...args: Array Date: Thu, 22 Oct 2026 10:16:25 +0200 Subject: [PATCH 2/2] fix(apple): load the clean script's workspace TS on Node 22 and share the default derived layout Co-Authored-By: Claude Sonnet 5.5 --- package.json | 10 +++---- .../platform-apple/src/core/runner-client.ts | 1 + .../src/runner-operations-facade.ts | 1 + .../src/runner/runner-cache-metadata.ts | 12 ++++++++ scripts/build-xcuitest-apple.sh | 6 ++-- scripts/clean-xcuitest-derived.mjs | 29 ++++++++++++------- .../smoke-clean-xcuitest-derived.test.ts | 29 ++++++++++++------- 7 files changed, 60 insertions(+), 28 deletions(-) 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 f8eea09830..fa69b05d38 100644 --- a/packages/platform-apple/src/core/runner-client.ts +++ b/packages/platform-apple/src/core/runner-client.ts @@ -47,6 +47,7 @@ export { resolveRunnerBundleBuildSettings, resolveRunnerPerformanceBuildSettings, resolveRunnerSandboxBuildArgs, + resolveRunnerScriptDefaultDerivedPath, resolveRunnerSigningBuildSettings, } from '../runner/runner-cache-metadata.ts'; export { diff --git a/packages/platform-apple/src/runner-operations-facade.ts b/packages/platform-apple/src/runner-operations-facade.ts index c471fd2d9d..6656ad336e 100644 --- a/packages/platform-apple/src/runner-operations-facade.ts +++ b/packages/platform-apple/src/runner-operations-facade.ts @@ -20,6 +20,7 @@ export { resolveRunnerSandboxBuildArgs, isRunnerXcuitestScriptPlatform, resolveRunnerDerivedOverride, + resolveRunnerScriptDefaultDerivedPath, resolveRunnerScriptDevice, resolveRunnerSigningBuildSettings, requireCertifiedRunnerCacheArtifacts, diff --git a/packages/platform-apple/src/runner/runner-cache-metadata.ts b/packages/platform-apple/src/runner/runner-cache-metadata.ts index 468c1a588c..25614afa4e 100644 --- a/packages/platform-apple/src/runner/runner-cache-metadata.ts +++ b/packages/platform-apple/src/runner/runner-cache-metadata.ts @@ -11,6 +11,7 @@ import { resolveRunnerDerivedOverride, resolveRunnerPlatformName, resolveRunnerSdkName, + type RunnerXcuitestScriptPlatform, } from './apple-runner-platform.ts'; import { requireRunnerToolchainFingerprint, @@ -156,6 +157,17 @@ export function resolveRunnerDerivedPath( ); } +/** + * The derived-data directory `pnpm build:xcuitest:` 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( device: DeviceInfo, metadata: RunnerXctestrunCacheMetadata, 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 2c4147460f..d3a503bafc 100644 --- a/scripts/clean-xcuitest-derived.mjs +++ b/scripts/clean-xcuitest-derived.mjs @@ -1,11 +1,12 @@ #!/usr/bin/env node import fs from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; -import { resolveRunnerDerivedOverride } from '@agent-device/platform-apple/runner/operations'; +import { + resolveRunnerDerivedOverride, + resolveRunnerScriptDefaultDerivedPath, +} from '@agent-device/platform-apple/runner/operations'; const DERIVED_OVERRIDE = resolveRunnerDerivedOverride(process.env); -const DERIVED_ROOT = path.join(os.homedir(), '.agent-device', 'apple-runner', 'derived'); const ROOT_TRANSIENT_ENTRY_NAMES = new Set([ '.agent-device-runner-cache.json', 'Build', @@ -22,12 +23,12 @@ const platforms = process.argv.slice(2); const requested = platforms.length > 0 ? platforms : ['ios', 'macos', 'tvos', 'visionos']; const supported = new Set(['ios', 'macos', 'tvos', 'visionos']); // An override pins one directory for every platform, as it does for the runner build. -const DERIVED_PATHS = new Map([ - ['ios', DERIVED_OVERRIDE ?? DERIVED_ROOT], - ['macos', DERIVED_OVERRIDE ?? path.join(DERIVED_ROOT, 'macos')], - ['tvos', DERIVED_OVERRIDE ?? path.join(DERIVED_ROOT, 'tvos')], - ['visionos', DERIVED_OVERRIDE ?? path.join(DERIVED_ROOT, 'visionos')], -]); +const DERIVED_PATHS = new Map( + [...supported].map((platform) => [ + platform, + DERIVED_OVERRIDE ?? resolveRunnerScriptDefaultDerivedPath(platform), + ]), +); const PLATFORM_LABELS = new Map([ ['ios', 'iOS'], ['macos', 'macOS'], @@ -58,7 +59,7 @@ function cleanDerivedPath(platform, targetPath) { if (!fs.existsSync(targetPath)) { return { status: 'skipped', reason: 'not-found' }; } - if (platform !== 'ios' && !DERIVED_OVERRIDE) { + if (wholeDirectoryClean(platform)) { fs.rmSync(targetPath, { recursive: true, force: true }); return { status: 'removed' }; } @@ -88,11 +89,17 @@ function cleanDerivedPath(platform, targetPath) { function formatCleanupResult(platform, targetPath, result) { const platformLabel = resolvePlatformLabel(platform); - if (platform !== 'ios' && !DERIVED_OVERRIDE) + 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 b698514e91..e716c94287 100644 --- a/test/integration/smoke-clean-xcuitest-derived.test.ts +++ b/test/integration/smoke-clean-xcuitest-derived.test.ts @@ -172,17 +172,26 @@ function runCleanXcuitest( 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, - AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: undefined, - HOME: homeDir, - ...options?.env, + 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) {