Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/platform-apple/src/core/runner-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions packages/platform-apple/src/runner-operations-facade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export {
resolveRunnerPerformanceBuildSettings,
resolveRunnerSandboxBuildArgs,
isRunnerXcuitestScriptPlatform,
resolveRunnerDerivedOverride,
resolveRunnerScriptDefaultDerivedPath,
resolveRunnerScriptDevice,
resolveRunnerSigningBuildSettings,
requireCertifiedRunnerCacheArtifacts,
Expand Down
11 changes: 11 additions & 0 deletions packages/platform-apple/src/runner/apple-runner-platform.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import path from 'node:path';
import { AppError } from '@agent-device/kernel/errors';
import {
isMacOs,
Expand Down Expand Up @@ -136,6 +137,16 @@ const RUNNER_PLATFORM_PROFILES: Record<RunnerApplePlatformName, RunnerPlatformPr
},
};

/**
* The derived-data directory an operator pinned with `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH`, or
* `undefined` when the managed cache layout applies. One path serves every Apple platform, so the
* runner, its cache retention, and the clean script all read the override here.
*/
export function resolveRunnerDerivedOverride(env: NodeJS.ProcessEnv): string | undefined {
const override = env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim();
return override ? path.resolve(override) : undefined;
}

const RUNNER_XCUITEST_SCRIPT_PLATFORMS = ['ios', 'macos', 'tvos', 'visionos'] as const;
export type RunnerXcuitestScriptPlatform = (typeof RUNNER_XCUITEST_SCRIPT_PLATFORMS)[number];

Expand Down
22 changes: 17 additions & 5 deletions packages/platform-apple/src/runner/runner-cache-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import { hostEnvironment, isEnvTruthy, findProjectRoot, readVersion } from './ho
import {
resolveRunnerBuildDestinationFamily,
resolveRunnerDerivedBaseName,
resolveRunnerDerivedOverride,
resolveRunnerPlatformName,
resolveRunnerSdkName,
type RunnerXcuitestScriptPlatform,
} from './apple-runner-platform.ts';
import {
requireRunnerToolchainFingerprint,
Expand Down Expand Up @@ -149,11 +151,21 @@ export function resolveRunnerDerivedPath(
device: DeviceInfo,
metadata: RunnerXctestrunCacheMetadata,
): string {
const override = hostEnvironment().env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim();
if (override) {
return path.resolve(override);
}
return resolveRunnerKeyedDerivedPath(device, metadata);
return (
resolveRunnerDerivedOverride(hostEnvironment().env) ??
resolveRunnerKeyedDerivedPath(device, metadata)
);
}

/**
* The derived-data directory `pnpm build:xcuitest:<platform>` 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(
Expand Down
3 changes: 2 additions & 1 deletion packages/platform-apple/src/runner/runner-cache-retention.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -39,7 +40,7 @@ export async function evictStaleRunnerCaches(
nowMs: number = Date.now(),
): Promise<string[]> {
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)
Expand Down
4 changes: 2 additions & 2 deletions packages/platform-apple/src/runner/runner-cache.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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)) {
Expand Down
6 changes: 4 additions & 2 deletions scripts/build-xcuitest-apple.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
31 changes: 21 additions & 10 deletions scripts/clean-xcuitest-derived.mjs
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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'],
Expand Down Expand Up @@ -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' };
}
Expand Down Expand Up @@ -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}`
Expand Down
85 changes: 76 additions & 9 deletions test/integration/smoke-clean-xcuitest-derived.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -112,19 +165,33 @@ test(
},
);

function runCleanXcuitest(homeDir: string, ...args: Array<string | { allowFailure?: boolean }>) {
function runCleanXcuitest(
homeDir: string,
...args: Array<string | { allowFailure?: boolean; env?: Record<string, string> }>
) {
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) {
Expand Down
Loading