diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d12996be5a..d3285c464e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,7 +70,8 @@ Use `pnpm build:macos-helper:clean` if a Swift cache was created in another work `pnpm publish` and package-manager pack commands run `prepack`, which first checks synchronized MCP metadata and then runs `pnpm package:npm`. This is the one completeness-oriented aggregate: it -builds the TypeScript distribution and all four Apple runner targets, clean-builds the macOS helper, +builds the TypeScript distribution and all four Apple runner targets (into a scratch directory under +`.tmp/` that it removes afterwards, not into `~/.agent-device`), clean-builds the macOS helper, packages the Apple runner source, and rebuilds both Android helper APKs. Any failed build stops packaging. It deliberately does not stop the worktree's development daemon; use `pnpm rebuild:cli` when a running daemon needs to pick up a new TypeScript build. diff --git a/package.json b/package.json index 040fd12169..5fbf2afe5d 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "build:xcuitest:tvos:clean": "pnpm clean:xcuitest:tvos && pnpm build:xcuitest:tvos", "build:xcuitest:visionos": "AGENT_DEVICE_XCUITEST_PLATFORM=visionos sh ./scripts/build-xcuitest-apple.sh", "build:xcuitest:visionos:clean": "pnpm clean:xcuitest:visionos && pnpm build:xcuitest:visionos", + "package:xcuitest": "node scripts/build-package-xcuitest.mjs", "build:android-snapshot-helper": "AGENT_DEVICE_ANDROID_HELPER=snapshot sh ./scripts/build-android-helper.sh $(node -p \"require('./package.json').version\") .tmp/android-snapshot-helper", "package:android-snapshot-helper": "AGENT_DEVICE_ANDROID_HELPER=snapshot sh ./scripts/package-android-helper.sh $(node -p \"require('./package.json').version\") v$(node -p \"require('./package.json').version\") .tmp/android-snapshot-helper", "package:android-snapshot-helper:npm": "rm -rf android/snapshot-helper/dist && AGENT_DEVICE_ANDROID_HELPER=snapshot sh ./scripts/package-android-helper.sh $(node -p \"require('./package.json').version\") v$(node -p \"require('./package.json').version\") android/snapshot-helper/dist", @@ -118,7 +119,7 @@ "test:macos-helper": "node --experimental-strip-types scripts/swift-toolchain-tmpdir.ts swift test --package-path apple/macos-helper", "check:macos-helper": "pnpm build:macos-helper && pnpm test:macos-helper", "prepare:publish-assets": "node scripts/prepare-publish-assets.mjs", - "build:package": "pnpm build && pnpm build:xcuitest:ios && pnpm build:xcuitest:macos && pnpm build:xcuitest:tvos && pnpm build:xcuitest:visionos && pnpm build:macos-helper:clean && pnpm prepare:publish-assets", + "build:package": "pnpm build && pnpm package:xcuitest && pnpm build:macos-helper:clean && pnpm prepare:publish-assets", "package:npm": "pnpm build:package && pnpm check:package", "release:prepare": "rm -rf .tmp/release && pnpm check:mcp-metadata && pnpm build:package && pnpm check:package -- --pack-destination .tmp/release && node scripts/release-workspace.mjs pack", "release:publish": "node scripts/release-mark-dev.mjs --check-release-version && pnpm release:prepare && node scripts/release-workspace.mjs publish", diff --git a/packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts b/packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts new file mode 100644 index 0000000000..e15f797632 --- /dev/null +++ b/packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts @@ -0,0 +1,260 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, beforeEach, test, vi } from 'vitest'; +import { resetAllProcessMemosForTests } from '@agent-device/kernel/ttl-memo'; +import { appleRunnerTestHost } from '../test-host.ts'; +import { resolveRunnerCacheMetadataPath } from '../runner-cache.ts'; +import { trimRunnerBuildScratch } from '../runner-cache-trim.ts'; +import { ensureXctestrunArtifact } from '../runner-xctestrun.ts'; +import { appleToolchainProbeResult } from './apple-toolchain-fixtures.ts'; +import { IOS_SIMULATOR } from './device-fixtures.ts'; +import { seedRunnerProductBundle } from './runner-xctestrun.fixtures.ts'; +import { mkdtempForTestSync } from './tmp-dir.ts'; + +const KEY = 'cache-0123456789abcdef'; + +let base: string; +let previousDerivedOverride: string | undefined; + +beforeEach(() => { + base = mkdtempForTestSync('agent-device-runner-trim-'); + previousDerivedOverride = process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH; + delete process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH; +}); + +afterEach(() => { + if (previousDerivedOverride === undefined) + delete process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH; + else process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH = previousDerivedOverride; +}); + +function seedBuiltKey(derived: string): string[] { + const products = path.join(derived, 'Build', 'Products'); + const app = path.join(products, 'Debug-iphonesimulator', 'Runner.app'); + const xctestrun = path.join(products, 'Runner.xctestrun'); + fs.mkdirSync(app, { recursive: true }); + fs.writeFileSync(path.join(app, 'Runner'), 'binary'); + fs.writeFileSync(xctestrun, ''); + fs.writeFileSync(resolveRunnerCacheMetadataPath(derived), '{}'); + for (const scratch of [ + 'Build/Intermediates.noindex/Runner.build/obj.o', + 'Build/Products/Debug-iphonesimulator/Runner.app.dSYM', + 'SDKExplicitPrecompiledModules/Foundation.pcm', + 'ModuleCache.noindex/session.timestamp', + 'Logs/Build/build.xcactivitylog', + ]) { + fs.mkdirSync(path.dirname(path.join(derived, scratch)), { recursive: true }); + fs.writeFileSync(path.join(derived, scratch), 'scratch'); + } + return [xctestrun, app]; +} + +function tree(directory: string, prefix = ''): string[] { + return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const relative = path.join(prefix, entry.name); + return entry.isDirectory() + ? [relative, ...tree(path.join(directory, entry.name), relative)] + : [relative]; + }); +} + +test('trimming keeps the products and the metadata file and removes the rest', async () => { + const derived = path.join(base, KEY); + const protectedPaths = seedBuiltKey(derived); + + const removed = await trimRunnerBuildScratch(derived, protectedPaths, derived); + + assert.deepEqual(removed.sort(), [ + 'Build/Intermediates.noindex', + 'Logs', + 'ModuleCache.noindex', + 'SDKExplicitPrecompiledModules', + ]); + assert.deepEqual( + tree(derived).sort(), + [ + '.agent-device-runner-cache.json', + 'Build', + 'Build/Products', + 'Build/Products/Debug-iphonesimulator', + 'Build/Products/Debug-iphonesimulator/Runner.app', + 'Build/Products/Debug-iphonesimulator/Runner.app.dSYM', + 'Build/Products/Debug-iphonesimulator/Runner.app/Runner', + 'Build/Products/Runner.xctestrun', + ].map((entry) => entry.replaceAll('/', path.sep)), + ); +}); + +test('a product outside the cache root leaves the tree untouched', async () => { + const derived = path.join(base, KEY); + seedBuiltKey(derived); + const outside = path.join(base, 'elsewhere.app'); + fs.mkdirSync(outside); + const before = tree(derived); + + assert.deepEqual(await trimRunnerBuildScratch(derived, [outside], derived), []); + + assert.deepEqual(tree(derived), before); +}); + +test('a directory that is not the expected key is not trimmed', async () => { + const expected = path.join(base, 'managed', KEY); + const fixed = path.join(base, 'fixed', KEY); + fs.mkdirSync(expected, { recursive: true }); + const protectedPaths = seedBuiltKey(fixed); + const before = tree(fixed); + + assert.deepEqual(await trimRunnerBuildScratch(fixed, protectedPaths, expected), []); + + assert.deepEqual(tree(fixed), before); +}); + +test('a symlinked key resolving outside the expected key is not trimmed', async () => { + const managedRoot = path.join(base, 'managed'); + const target = path.join(base, 'target'); + fs.mkdirSync(path.join(managedRoot, 'other-key'), { recursive: true }); + const protectedPaths = seedBuiltKey(target); + const link = path.join(managedRoot, KEY); + fs.symlinkSync(target, link); + const before = tree(target); + + assert.deepEqual( + await trimRunnerBuildScratch( + link, + protectedPaths.map((product) => path.join(link, path.relative(target, product))), + path.join(managedRoot, 'other-key'), + ), + [], + ); + + assert.deepEqual(tree(target), before); +}); + +test('a default key that is itself a symlink to an outside directory is not trimmed', async () => { + const managedRoot = path.join(base, 'managed'); + const outside = path.join(base, 'outside'); + fs.mkdirSync(managedRoot); + const protectedPaths = seedBuiltKey(outside); + const link = path.join(managedRoot, KEY); + fs.symlinkSync(outside, link); + const before = tree(outside); + + assert.deepEqual( + await trimRunnerBuildScratch( + link, + protectedPaths.map((product) => path.join(link, path.relative(outside, product))), + link, + ), + [], + ); + + assert.deepEqual(tree(outside), before); +}); + +test('a product that is a symlink keeps the unit it points into', async () => { + const derived = path.join(base, KEY); + const [xctestrun] = seedBuiltKey(derived); + const target = path.join(derived, 'Build', 'Intermediates.noindex', 'Runner.build'); + const link = path.join(derived, 'Build', 'Products', 'Linked.app'); + fs.symlinkSync(target, link); + + const removed = await trimRunnerBuildScratch(derived, [xctestrun!, link], derived); + + assert.deepEqual(removed.sort(), [ + 'Logs', + 'ModuleCache.noindex', + 'SDKExplicitPrecompiledModules', + ]); + assert.equal(fs.existsSync(path.join(target, 'obj.o')), true); +}); + +function mockRunnerBuild() { + resetAllProcessMemosForTests(); + const projectRoot = mkdtempForTestSync('agent-device-runner-trim-root-'); + fs.mkdirSync( + path.join(projectRoot, 'apple', 'runner', 'AgentDeviceRunner', 'AgentDeviceRunner.xcodeproj'), + { recursive: true }, + ); + const runCmdStreaming = vi.fn().mockImplementation(async (_command: string, args: string[]) => { + const symroot = args.find((arg) => arg.startsWith('SYMROOT='))!.slice('SYMROOT='.length); + const derived = path.dirname(path.dirname(symroot)); + await seedRunnerProductBundle(path.join(symroot, 'Debug-iphonesimulator', 'Runner.app')); + fs.writeFileSync( + path.join(symroot, 'Runner_iphonesimulator27.0-arm64.xctestrun'), + ` +ProductPaths +__TESTROOT__/Debug-iphonesimulator/Runner.app +`, + ); + for (const scratch of [ + 'Build/Intermediates.noindex/obj.o', + 'Logs/build.log', + 'ModuleCache.noindex/m', + ]) { + fs.mkdirSync(path.dirname(path.join(derived, scratch)), { recursive: true }); + fs.writeFileSync(path.join(derived, scratch), 'scratch'); + } + return { exitCode: 0, stdout: '', stderr: '' }; + }); + appleRunnerTestHost.update({ + runCmdSync: vi.fn().mockImplementation(appleToolchainProbeResult), + runCmdStreaming, + findProjectRoot: () => projectRoot, + readVersion: () => '0.0.0-test', + }); + + return { runCmdStreaming }; +} + +test('a runner build trims its key, and the next start reuses the products without rebuilding', async () => { + const { runCmdStreaming } = mockRunnerBuild(); + + const built = await ensureXctestrunArtifact(IOS_SIMULATOR, {}); + + assert.equal(built.artifact, 'rebuilt'); + assert.deepEqual(fs.readdirSync(built.derived).sort(), [ + '.agent-device-runner-cache.json', + 'Build', + ]); + assert.deepEqual(fs.readdirSync(path.join(built.derived, 'Build')), ['Products']); + + const reused = await ensureXctestrunArtifact(IOS_SIMULATOR, {}); + + assert.equal(reused.artifact, 'valid'); + assert.equal(reused.xctestrunPath, built.xctestrunPath); + assert.equal(runCmdStreaming.mock.calls.length, 1); +}); + +test('a derived path override named like a cache key keeps its build scratch', async () => { + mockRunnerBuild(); + const managed = await ensureXctestrunArtifact(IOS_SIMULATOR, {}); + process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH = path.join( + base, + 'fixed', + path.basename(managed.derived), + ); + + const built = await ensureXctestrunArtifact(IOS_SIMULATOR, {}); + + assert.equal(built.artifact, 'rebuilt'); + assert.equal(built.derived, process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH); + assert.equal(fs.existsSync(path.join(built.derived, 'Build', 'Intermediates.noindex')), true); + assert.equal(fs.existsSync(path.join(built.derived, 'Logs')), true); +}); + +test('a derived path override inside the managed root keeps its build scratch', async () => { + mockRunnerBuild(); + const managed = await ensureXctestrunArtifact(IOS_SIMULATOR, {}); + process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH = path.join( + path.dirname(managed.derived), + 'development', + ); + + const built = await ensureXctestrunArtifact(IOS_SIMULATOR, {}); + + assert.equal(built.artifact, 'rebuilt'); + assert.equal(built.derived, process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH); + assert.equal(fs.existsSync(path.join(built.derived, 'Build', 'Intermediates.noindex')), true); + assert.equal(fs.existsSync(path.join(built.derived, 'Logs')), true); +}); diff --git a/packages/platform-apple/src/runner/runner-artifact-manifest.ts b/packages/platform-apple/src/runner/runner-artifact-manifest.ts index b9a8486291..aa8b76e181 100644 --- a/packages/platform-apple/src/runner/runner-artifact-manifest.ts +++ b/packages/platform-apple/src/runner/runner-artifact-manifest.ts @@ -657,7 +657,7 @@ function isNonEmptyArray(value: unknown): value is Item[] { return Array.isArray(value) && value.length > 0; } -function isPathInsideDirectory(targetPath: string, directoryPath: string): boolean { +export function isPathInsideDirectory(targetPath: string, directoryPath: string): boolean { const relativePath = path.relative(path.resolve(directoryPath), path.resolve(targetPath)); return relativePath !== '' && !relativePath.startsWith('..') && !path.isAbsolute(relativePath); } diff --git a/packages/platform-apple/src/runner/runner-artifact.ts b/packages/platform-apple/src/runner/runner-artifact.ts index d13be1de36..2db0996401 100644 --- a/packages/platform-apple/src/runner/runner-artifact.ts +++ b/packages/platform-apple/src/runner/runner-artifact.ts @@ -60,7 +60,7 @@ import { resolveRunnerBuildDestination, resolveRunnerXctestrunHints, } from './apple-runner-platform.ts'; -import { resolveRunnerCacheKey } from './runner-cache-metadata.ts'; +import { resolveRunnerCacheKey, resolveRunnerKeyedDerivedPath } from './runner-cache-metadata.ts'; import { resolveAppleRunnerProjectPath } from './runner-source.ts'; export { prepareXctestrunWithEnv } from './runner-artifact-env.ts'; @@ -689,6 +689,11 @@ async function buildXctestrunArtifact(params: { ), derived, ); + await trimRunnerBuildScratchBestEffort( + resolveRunnerKeyedDerivedPath(device, expectedCacheMetadata), + derived, + [built, ...builtProductPaths], + ); emitRunnerXctestrunDecision('build', 'built_new', { derived, xctestrunPath: built, @@ -705,6 +710,26 @@ async function buildXctestrunArtifact(params: { }; } +/** Runs under the cache lock, so no rebuild or reuse of this key sees the tree mid-trim. */ +async function trimRunnerBuildScratchBestEffort( + expectedKeyPath: string, + derived: string, + protectedPaths: readonly string[], +): Promise { + try { + const { trimRunnerBuildScratch } = await import('./runner-cache-trim.ts'); + const removed = await trimRunnerBuildScratch(derived, protectedPaths, expectedKeyPath); + if (removed.length > 0) + emitRunnerXctestrunDecision('clean', 'build_scratch_trimmed', { derived }); + } catch (error) { + emitDiagnostic({ + level: 'warn', + phase: 'runner_xctestrun_cache_trim_failed', + data: { derived, error: error instanceof Error ? error.message : String(error) }, + }); + } +} + async function tryReuseExistingXctestrun( device: DeviceInfo, derived: string, diff --git a/packages/platform-apple/src/runner/runner-cache-metadata.ts b/packages/platform-apple/src/runner/runner-cache-metadata.ts index 79954c917f..66e0546a25 100644 --- a/packages/platform-apple/src/runner/runner-cache-metadata.ts +++ b/packages/platform-apple/src/runner/runner-cache-metadata.ts @@ -486,9 +486,14 @@ export function resolveRunnerDerivedPath( if (override) { return path.resolve(override); } - const cacheKey = resolveRunnerCacheKey(metadata); - const base = resolveRunnerDerivedBasePath(device); - return path.join(base, cacheKey); + return resolveRunnerKeyedDerivedPath(device, metadata); +} + +export function resolveRunnerKeyedDerivedPath( + device: DeviceInfo, + metadata: RunnerXctestrunCacheMetadata, +): string { + return path.join(resolveRunnerDerivedBasePath(device), resolveRunnerCacheKey(metadata)); } function resolveRunnerDerivedBasePath(device: DeviceInfo): string { diff --git a/packages/platform-apple/src/runner/runner-cache-trim.ts b/packages/platform-apple/src/runner/runner-cache-trim.ts new file mode 100644 index 0000000000..a5333b25b8 --- /dev/null +++ b/packages/platform-apple/src/runner/runner-cache-trim.ts @@ -0,0 +1,93 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { isPathInsideDirectory } from './runner-artifact-manifest.ts'; +import { RUNNER_CACHE_METADATA_FILE } from './runner-cache-metadata.ts'; + +/** + * Removes the build scratch from a keyed runner cache after a successful build: intermediates, + * precompiled and module caches, logs. Reuse and launch read only the products the manifest + * certifies and the metadata file, and a key that fails certification is deleted and rebuilt from + * scratch, so the scratch is never read again; a runner source or Xcode change mints a new key + * instead of building into this one. + * + * Returns the removed entries relative to `derived`. Nothing is removed unless `derived` resolves, + * by real path, to a real directory named like `expectedKeyPath` that is a direct child of the real + * parent of `expectedKeyPath` (the key this build gets with no path override), or when a product + * lies outside `derived`. A key entry that is itself a symlink is never trimmed. + */ +export async function trimRunnerBuildScratch( + derived: string, + protectedPaths: readonly string[], + expectedKeyPath: string, +): Promise { + if (!isExpectedKey(derived, expectedKeyPath)) return []; + const kept = resolveKeptPaths(derived, protectedPaths); + if (!kept) return []; + return await trimDirectory(derived, derived, kept); +} + +function isExpectedKey(derived: string, expectedKeyPath: string): boolean { + try { + const canonicalKey = path.join( + fs.realpathSync(path.dirname(expectedKeyPath)), + path.basename(expectedKeyPath), + ); + return fs.realpathSync(derived) === canonicalKey; + } catch { + return false; + } +} + +/** + * `Build/` is the unit kept under `Build`, so the products survive and their siblings do not. + * A product that is missing or lies outside the key makes the trim a no-op. A product that is a + * symlink keeps its target's unit as well. + */ +function resolveKeptPaths(derived: string, protectedPaths: readonly string[]): Set | null { + const kept = new Set([RUNNER_CACHE_METADATA_FILE]); + try { + const realDerived = fs.realpathSync(derived); + for (const protectedPath of protectedPaths) { + const realProtected = fs.realpathSync(protectedPath); + if ( + !isPathInsideDirectory(protectedPath, derived) || + !isPathInsideDirectory(realProtected, realDerived) + ) { + return null; + } + kept.add(keptUnit(path.relative(derived, protectedPath))); + kept.add(keptUnit(path.relative(realDerived, realProtected))); + } + } catch { + return null; + } + return kept; +} + +function keptUnit(relative: string): string { + const segments = relative.split(path.sep); + return segments.slice(0, segments[0] === 'Build' ? 2 : 1).join(path.sep); +} + +async function trimDirectory( + root: string, + directory: string, + kept: ReadonlySet, +): Promise { + const removed: string[] = []; + for (const entry of await fs.promises.readdir(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + const relative = path.relative(root, entryPath); + if (kept.has(relative)) continue; + if ( + entry.isDirectory() && + [...kept].some((keep) => keep.startsWith(`${relative}${path.sep}`)) + ) { + removed.push(...(await trimDirectory(root, entryPath, kept))); + continue; + } + await fs.promises.rm(entryPath, { recursive: true, force: true }); + removed.push(relative); + } + return removed; +} diff --git a/packages/platform-apple/src/runner/runner-cache.ts b/packages/platform-apple/src/runner/runner-cache.ts index b69917752c..e23a69b875 100644 --- a/packages/platform-apple/src/runner/runner-cache.ts +++ b/packages/platform-apple/src/runner/runner-cache.ts @@ -382,7 +382,8 @@ export function emitRunnerXctestrunDecision( | 'external_xctestrun' | 'external_bad_artifact' | 'uncertifiable_products' - | 'stale_cache_evicted', + | 'stale_cache_evicted' + | 'build_scratch_trimmed', data: Record, ): void { emitDiagnostic({ diff --git a/scripts/__tests__/build-package-xcuitest.test.ts b/scripts/__tests__/build-package-xcuitest.test.ts new file mode 100644 index 0000000000..eb3ad072ed --- /dev/null +++ b/scripts/__tests__/build-package-xcuitest.test.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'vitest'; +import { mkdtempForTestSync } from '../../src/__tests__/test-utils/tmp-dir.ts'; +import { buildPackageXcuitest } from '../build-package-xcuitest.mjs'; + +type Build = { root: string; platform: string; derivedPath: string }; + +test('builds every platform into a scratch directory under the repo and removes it', () => { + const root = mkdtempForTestSync('agent-device-package-xcuitest-'); + const seen: Build[] = []; + + buildPackageXcuitest({ + root, + build: (build: Build) => { + seen.push(build); + fs.mkdirSync(path.join(build.derivedPath, 'Build', 'Products'), { recursive: true }); + }, + }); + + assert.deepEqual( + seen.map(({ platform }) => platform), + ['ios', 'macos', 'tvos', 'visionos'], + ); + for (const { derivedPath } of seen) { + assert.equal(path.dirname(derivedPath), path.join(root, '.tmp', 'package-xcuitest')); + assert.equal(fs.existsSync(derivedPath), false); + } + assert.deepEqual(fs.readdirSync(path.join(root, '.tmp')), []); +}); + +test('removes the scratch directory when a platform build fails', () => { + const root = mkdtempForTestSync('agent-device-package-xcuitest-'); + + assert.throws( + () => + buildPackageXcuitest({ + root, + platforms: ['ios', 'macos'], + build: ({ platform, derivedPath }: Build) => { + fs.mkdirSync(derivedPath, { recursive: true }); + if (platform === 'macos') throw new Error('xcodebuild failed'); + }, + }), + /xcodebuild failed/, + ); + + assert.deepEqual(fs.readdirSync(path.join(root, '.tmp')), []); +}); + +test('starts from an empty scratch directory when an interrupted run left one behind', () => { + const root = mkdtempForTestSync('agent-device-package-xcuitest-'); + const leftover = path.join(root, '.tmp', 'package-xcuitest', 'ios', 'Build'); + fs.mkdirSync(leftover, { recursive: true }); + let leftoverSeen = true; + + buildPackageXcuitest({ + root, + platforms: ['ios'], + build: ({ derivedPath }: Build) => { + leftoverSeen = fs.existsSync(derivedPath); + }, + }); + + assert.equal(leftoverSeen, false); +}); diff --git a/scripts/build-package-xcuitest.mjs b/scripts/build-package-xcuitest.mjs new file mode 100644 index 0000000000..0bba87fce9 --- /dev/null +++ b/scripts/build-package-xcuitest.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const PLATFORMS = ['ios', 'macos', 'tvos', 'visionos']; + +/** + * Compiles the Apple runner for every platform the package supports, into a scratch directory + * under `/.tmp` that is removed before and after the builds, so an interrupted run leaves + * nothing past the next one. The package ships the runner source and the daemon builds into its + * own keyed cache, so nothing reads these products; building them at the default + * `~/.agent-device/apple-runner/derived` location only left about 1 GB there per machine. + */ +export function buildPackageXcuitest(options = {}) { + const root = path.resolve(options.root ?? process.cwd()); + const platforms = options.platforms ?? PLATFORMS; + const build = options.build ?? runBuildScript; + const scratch = path.join(root, '.tmp', 'package-xcuitest'); + fs.rmSync(scratch, { recursive: true, force: true }); + fs.mkdirSync(scratch, { recursive: true }); + try { + for (const platform of platforms) { + build({ root, platform, derivedPath: path.join(scratch, platform) }); + } + } finally { + fs.rmSync(scratch, { recursive: true, force: true }); + } +} + +function runBuildScript({ root, platform, derivedPath }) { + execFileSync('sh', [path.join('scripts', 'build-xcuitest-apple.sh')], { + cwd: root, + env: { + ...process.env, + AGENT_DEVICE_XCUITEST_PLATFORM: platform, + AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: derivedPath, + }, + stdio: 'inherit', + }); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + buildPackageXcuitest(); + } catch (error) { + process.exitCode = typeof error?.status === 'number' ? error.status : 1; + if (typeof error?.status !== 'number') console.error(error); + } +} diff --git a/src/__tests__/npm-package-scripts.test.ts b/src/__tests__/npm-package-scripts.test.ts index dba14093c7..7f35bea71e 100644 --- a/src/__tests__/npm-package-scripts.test.ts +++ b/src/__tests__/npm-package-scripts.test.ts @@ -50,10 +50,7 @@ test('Fallow exposes one changed-code gate and an explicit full-tree audit', () test('the npm package build covers every package-owned output before verification', () => { assert.deepEqual(script('build:package').split(' && '), [ 'pnpm build', - 'pnpm build:xcuitest:ios', - 'pnpm build:xcuitest:macos', - 'pnpm build:xcuitest:tvos', - 'pnpm build:xcuitest:visionos', + 'pnpm package:xcuitest', 'pnpm build:macos-helper:clean', 'pnpm prepare:publish-assets', ]); diff --git a/vitest.config.ts b/vitest.config.ts index 1bcd155b27..b7b676fa96 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -153,6 +153,7 @@ export default defineConfig({ // Publish preparation spawns only fixture-owned scripts and proves both Android // helper families are rebuilt through the shared release/size-report owner. 'scripts/__tests__/prepare-publish-assets.test.ts', + 'scripts/__tests__/build-package-xcuitest.test.ts', // The packager's Swift comment scanner: pure string transform, and the only place a // literal that looks like a comment (a URL, a raw or multi-line literal) is proven // to survive packaging before the npm package ships unbuildable Swift. diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index b58061ba11..b5be1a80ca 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -281,6 +281,8 @@ agent-device prepare ios-runner --platform ios --timeout 240000 - If health checking exposes a bad restored runner artifact, Agent Device marks that artifact bad and rebuilds once. - If a fresh runner launch gets stuck before accepting connections, Agent Device invalidates that runner session and launches it once more without forcing a rebuild. - CI may cache `~/.agent-device/apple-runner/derived` when the cache key includes the exact Agent Device package contents and selected Xcode version. +- After a successful build, Agent Device removes the build scratch from the new cache key (intermediates, precompiled and module caches, logs) and keeps `Build/Products` and the metadata file, which is all reuse and launch read; one iOS simulator key measured 165.7 MB before the trim and 5.9 MB after. A key that fails certification is rebuilt from scratch either way, and a runner source or Xcode change mints a new key, so the scratch is never used again. Only the key this build gets with no path override is trimmed, compared by resolved real path; a `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` pointing anywhere else, including inside the managed runner cache, is never trimmed, because a fixed path rebuilds into the same tree incrementally. +- Agent Device versions from before the Apple runner rename kept their cache in `~/.agent-device/ios-runner`. Current versions never read it, and it is not removed automatically because an older version installed on the same machine may still use it. Delete it with `rm -rf ~/.agent-device/ios-runner` once no old version runs. - Runner reuse is authorized only by the cache metadata's content manifest: a restored tree whose files no longer match the recorded digests, modes, or symlink targets is discarded and rebuilt. A cache key must stay exact — the runtime never falls back to a broader cache. - Every runner source or Xcode change creates a new cache key under `~/.agent-device/apple-runner/derived//`, where the folder names the platform and device kind (`ios-simulator`, `ios-device`, `tvos-simulator`, `tvos-device`, `macos`, `visionos-simulator`, `visionos-device`). After a build, Agent Device deletes, on a best-effort basis and without delaying the start, keys beside the new one in the same folder that are neither among the 3 most recently used (the new key included) nor used in the last day, and never one a runner lease not proven dead points at or a build is holding. A key that cannot be deleted is left in place and reported as a `runner_xctestrun_cache_eviction_failed` warning diagnostic. `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP=` changes that count and `0` keeps every key. A set `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` is never swept. - Certification is fail-closed: when a product tree cannot be certified at all — a product escaping the derived-data root, an unreadable subtree, a file over 128 MB, or a non-regular entry such as a socket — the build fails with `runner_cache_uncertifiable` naming the path instead of launching uncertified bytes. Point `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` at a plain directory the current user owns; replacing the tree (the error's hint says how) clears a refusal.