From 8cf28f55619b52b3041e41d6a65a78fe3341326b Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 30 Apr 2026 17:23:32 +0200 Subject: [PATCH 1/4] feat: cache simulator XCTest agent artifacts --- .github/workflows/e2e-tests.yml | 16 + .../src/__tests__/xctest-agent.test.ts | 251 +++++++++++++-- packages/platform-ios/src/xctest-agent.ts | 302 ++++++++++++++++-- .../src/__tests__/harness-artifacts.test.ts | 17 +- packages/tools/src/harness-artifacts.ts | 24 +- 5 files changed, 543 insertions(+), 67 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index a9aa0af..8bbbd41 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -161,6 +161,15 @@ jobs: path: ./apps/playground/ios/build/Build/Products/Debug-iphonesimulator/HarnessPlayground.app key: ios-app-playground + - name: Restore XCTest agent cache + id: cache-xctest-agent-restore + uses: actions/cache/restore@v4 + with: + path: ./apps/playground/.harness/cache/xctest-agent-simulator + key: xctest-agent-${{ runner.os }}-xcode-26.0-${{ hashFiles('packages/platform-ios/xctest-agent/**', 'packages/platform-ios/src/xctest-agent.ts') }} + restore-keys: | + xctest-agent-${{ runner.os }}-xcode-26. + - name: CocoaPods cache if: steps.cache-app-restore.outputs.cache-hit != 'true' uses: actions/cache@v4 @@ -205,6 +214,13 @@ jobs: echo "HARNESS_RUNNER=$HARNESS_RUNNER" echo "HARNESS_EXIT_CODE=$HARNESS_EXIT_CODE" + - name: Save XCTest agent cache + if: steps.cache-xctest-agent-restore.outputs.cache-hit != 'true' && success() + uses: actions/cache/save@v4 + with: + path: ./apps/playground/.harness/cache/xctest-agent-simulator + key: xctest-agent-${{ runner.os }}-xcode-26.0-${{ hashFiles('packages/platform-ios/xctest-agent/**', 'packages/platform-ios/src/xctest-agent.ts') }} + - name: Upload Harness logs if: always() uses: actions/upload-artifact@v4 diff --git a/packages/platform-ios/src/__tests__/xctest-agent.test.ts b/packages/platform-ios/src/__tests__/xctest-agent.test.ts index 8d82358..77ee4b3 100644 --- a/packages/platform-ios/src/__tests__/xctest-agent.test.ts +++ b/packages/platform-ios/src/__tests__/xctest-agent.test.ts @@ -63,11 +63,15 @@ const projectRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), '..', '..', - 'xctest-agent', + 'xctest-agent' ); -let buildRoot = ''; +let simulatorBuildRoot = ''; +let deviceBuildRoot = ''; let tempProjectRoot = ''; const originalCwd = process.cwd(); +const simulatorRuntime = 'com.apple.CoreSimulator.SimRuntime.iOS-26-0'; +const simulatorSdkVersion = '26.0'; +const xcodeVersion = 'Xcode 26.0\nBuild version 17A123'; const createLongRunningSubprocess = (options?: { ignoreSignal?: NodeJS.Signals; @@ -131,19 +135,98 @@ describe('xctest-agent orchestration', () => { beforeEach(() => { vi.clearAllMocks(); tempProjectRoot = fs.mkdtempSync( - path.join(os.tmpdir(), 'rn-harness-xctest-agent-'), + path.join(os.tmpdir(), 'rn-harness-xctest-agent-') ); process.chdir(tempProjectRoot); - buildRoot = path.join(tempProjectRoot, '.harness', 'xctest-agent'); + simulatorBuildRoot = path.join( + tempProjectRoot, + '.harness', + 'cache', + 'xctest-agent-simulator' + ); + deviceBuildRoot = path.join(tempProjectRoot, '.harness', 'xctest-agent'); rmBuildRoot(); mocks.activeAgentStops.length = 0; mocks.spawn.mockImplementation((file: string, args?: string[]) => { + if (file === 'xcodebuild' && args?.join(' ') === '-version') { + return Promise.resolve({ stdout: xcodeVersion }); + } + + if ( + file === 'xcodebuild' && + args?.join(' ') === '-version -sdk iphonesimulator SDKVersion' + ) { + return Promise.resolve({ stdout: simulatorSdkVersion }); + } + + if ( + file === 'xcrun' && + args?.join(' ') === 'simctl list devices --json' + ) { + return Promise.resolve({ + stdout: JSON.stringify({ + devices: { + [simulatorRuntime]: [ + { + isAvailable: true, + name: 'iPhone 16', + state: 'Shutdown', + udid: 'sim-123', + }, + { + isAvailable: true, + name: 'iPhone 16 Pro', + state: 'Shutdown', + udid: 'sim-999', + }, + { + isAvailable: true, + name: 'iPhone 16 Plus', + state: 'Shutdown', + udid: 'sim-timeout', + }, + { + isAvailable: true, + name: 'iPhone 16 Mini', + state: 'Shutdown', + udid: 'sim-404', + }, + ], + }, + }), + }); + } + if (file === 'xcodebuild' && args?.[0] === 'test-without-building') { const process = createLongRunningSubprocess(); mocks.activeAgentStops.push(process.stop); return process.subprocess; } + if (file === 'xcodebuild' && args?.[0] === 'build-for-testing') { + const derivedDataIndex = args.indexOf('-derivedDataPath'); + const derivedDataPath = + derivedDataIndex === -1 ? undefined : args[derivedDataIndex + 1]; + + if (derivedDataPath) { + const buildProductsPath = path.join( + derivedDataPath, + 'Build', + 'Products' + ); + fs.mkdirSync(path.join(buildProductsPath, 'Debug-iphonesimulator'), { + recursive: true, + }); + fs.writeFileSync( + path.join( + buildProductsPath, + 'HarnessXCTestAgent_HarnessXCTestAgent_iphonesimulator26.0-arm64.xctestrun' + ), + 'cached xctestrun' + ); + } + } + return createLongRunningSubprocess().subprocess; }); }); @@ -166,32 +249,32 @@ describe('xctest-agent orchestration', () => { await controller.prepare(); expect(mocks.spawn).toHaveBeenNthCalledWith( - 1, + 4, 'xcodebuild', expect.arrayContaining([ 'build-for-testing', '-destination', 'platform=iOS Simulator,id=sim-123', - ]), + ]) + ); + expect(fs.existsSync(path.join(simulatorBuildRoot, 'cache.json'))).toBe( + true ); - expect( - fs.existsSync(path.join(buildRoot, 'simulator', 'build-manifest.json')), - ).toBe(true); }); it('reuses cached build artifacts for repeated prepares on the same destination kind', async () => { - fs.mkdirSync(path.join(buildRoot, 'device', 'Build', 'Products'), { + fs.mkdirSync(path.join(deviceBuildRoot, 'device', 'Build', 'Products'), { recursive: true, }); fs.writeFileSync( - path.join(buildRoot, 'device', 'build-manifest.json'), + path.join(deviceBuildRoot, 'device', 'build-manifest.json'), JSON.stringify({ buildInputsHash: getCurrentInputsHash(), codeSign: { teamId: 'TESTTEAM01', }, destinationKind: 'device', - }), + }) ); const controller = createXCTestAgentController({ @@ -233,7 +316,7 @@ describe('xctest-agent orchestration', () => { await controller.ensureStarted(); await controller.ensureStarted(); - expect(mocks.spawn).toHaveBeenCalledTimes(2); + expect(mocks.spawn).toHaveBeenCalledTimes(8); expect(mocks.spawn).toHaveBeenLastCalledWith( 'xcodebuild', expect.arrayContaining([ @@ -246,7 +329,7 @@ describe('xctest-agent orchestration', () => { TEST_RUNNER_HARNESS_XCTEST_AGENT_MODE: 'test', TEST_RUNNER_HARNESS_XCTEST_AGENT_PORT: '49152', }), - }), + }) ); expect(createSimulatorXCTestAgentTransport).toHaveBeenCalledWith({ port: 49152, @@ -255,7 +338,9 @@ describe('xctest-agent orchestration', () => { expect(mocks.configurePermissions).toHaveBeenCalledWith({ autoAcceptPermissions: true, }); - const logDirectories = fs.readdirSync(path.join(tempProjectRoot, '.harness', 'logs')); + const logDirectories = fs.readdirSync( + path.join(tempProjectRoot, '.harness', 'logs') + ); expect(logDirectories).toHaveLength(1); const xcodebuildLogPath = path.join( tempProjectRoot, @@ -312,11 +397,57 @@ describe('xctest-agent orchestration', () => { it('force kills the agent process when graceful shutdown times out', async () => { mocks.spawn.mockImplementation((file: string, args?: string[]) => { + if (file === 'xcodebuild' && args?.join(' ') === '-version') { + return Promise.resolve({ stdout: xcodeVersion }); + } + + if ( + file === 'xcodebuild' && + args?.join(' ') === '-version -sdk iphonesimulator SDKVersion' + ) { + return Promise.resolve({ stdout: simulatorSdkVersion }); + } + + if ( + file === 'xcrun' && + args?.join(' ') === 'simctl list devices --json' + ) { + return Promise.resolve({ + stdout: JSON.stringify({ + devices: { + [simulatorRuntime]: [ + { + isAvailable: true, + name: 'iPhone 16 Plus', + state: 'Shutdown', + udid: 'sim-timeout', + }, + ], + }, + }), + }); + } + if (file === 'xcodebuild' && args?.[0] === 'test-without-building') { return createLongRunningSubprocess({ ignoreSignal: 'SIGTERM' }) .subprocess; } + if (file === 'xcodebuild' && args?.[0] === 'build-for-testing') { + fs.mkdirSync(path.join(simulatorBuildRoot, 'Build', 'Products'), { + recursive: true, + }); + fs.writeFileSync( + path.join( + simulatorBuildRoot, + 'Build', + 'Products', + 'HarnessXCTestAgent_HarnessXCTestAgent_iphonesimulator26.0-arm64.xctestrun' + ), + 'cached xctestrun' + ); + } + return createLongRunningSubprocess().subprocess; }); @@ -338,15 +469,32 @@ describe('xctest-agent orchestration', () => { }); it('rebuilds when the cached build manifest no longer matches project inputs', async () => { - fs.mkdirSync(path.join(buildRoot, 'simulator', 'Build', 'Products'), { + fs.mkdirSync(path.join(simulatorBuildRoot, 'Build', 'Products'), { recursive: true, }); fs.writeFileSync( - path.join(buildRoot, 'simulator', 'build-manifest.json'), + path.join( + simulatorBuildRoot, + 'Build', + 'Products', + 'HarnessXCTestAgent_HarnessXCTestAgent_iphonesimulator26.0-arm64.xctestrun' + ), + 'cached xctestrun' + ); + fs.writeFileSync( + path.join(simulatorBuildRoot, 'cache.json'), JSON.stringify({ + artifactName: 'xctest-agent-simulator', buildInputsHash: 'stale-manifest-hash', destinationKind: 'simulator', - }), + hostArchitecture: process.arch, + schemaVersion: 1, + simulatorRuntime, + simulatorSdkVersion, + xcodeVersion, + xctestrunRelativePath: + 'HarnessXCTestAgent_HarnessXCTestAgent_iphonesimulator26.0-arm64.xctestrun', + }) ); const controller = createXCTestAgentController({ @@ -358,19 +506,60 @@ describe('xctest-agent orchestration', () => { await controller.prepare(); - expect(mocks.spawn).toHaveBeenCalledTimes(1); + expect(mocks.spawn).toHaveBeenCalledTimes(7); expect(mocks.spawn).toHaveBeenNthCalledWith( - 1, + 4, 'xcodebuild', - expect.arrayContaining(['build-for-testing']), + expect.arrayContaining(['build-for-testing']) + ); + }); + + it('reuses simulator build artifacts only when the cache metadata matches', async () => { + fs.mkdirSync(path.join(simulatorBuildRoot, 'Build', 'Products'), { + recursive: true, + }); + fs.writeFileSync( + path.join( + simulatorBuildRoot, + 'Build', + 'Products', + 'HarnessXCTestAgent_HarnessXCTestAgent_iphonesimulator26.0-arm64.xctestrun' + ), + 'cached xctestrun' ); + fs.writeFileSync( + path.join(simulatorBuildRoot, 'cache.json'), + JSON.stringify({ + artifactName: 'xctest-agent-simulator', + buildInputsHash: getCurrentInputsHash(), + destinationKind: 'simulator', + hostArchitecture: process.arch, + schemaVersion: 1, + simulatorRuntime, + simulatorSdkVersion, + xcodeVersion, + xctestrunRelativePath: + 'HarnessXCTestAgent_HarnessXCTestAgent_iphonesimulator26.0-arm64.xctestrun', + }) + ); + + const controller = createXCTestAgentController({ + target: { + kind: 'simulator', + id: 'sim-123', + }, + }); + + await controller.prepare(); + + expect(mocks.spawn).toHaveBeenCalledTimes(3); }); it('fails fast when the checked-in xcode project is missing', async () => { const projectPath = path.join(projectRoot, 'HarnessXCTestAgent.xcodeproj'); const hiddenProjectPath = path.join( projectRoot, - 'HarnessXCTestAgent.xcodeproj.test-hidden', + 'HarnessXCTestAgent.xcodeproj.test-hidden' ); fs.renameSync(projectPath, hiddenProjectPath); @@ -384,7 +573,7 @@ describe('xctest-agent orchestration', () => { }); await expect(controller.prepare()).rejects.toThrow( - 'Missing checked-in XCTest agent project', + 'Missing checked-in XCTest agent project' ); expect(mocks.spawn).not.toHaveBeenCalled(); } finally { @@ -408,7 +597,11 @@ describe('xctest-agent orchestration', () => { }); const rmBuildRoot = () => { - fs.rmSync(buildRoot, { + fs.rmSync(simulatorBuildRoot, { + force: true, + recursive: true, + }); + fs.rmSync(deviceBuildRoot, { force: true, recursive: true, }); @@ -424,6 +617,16 @@ const getCurrentInputsHash = (): string => { hash.update('\0'); } + const sourceFilePath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + 'xctest-agent.ts' + ); + hash.update(path.basename(sourceFilePath)); + hash.update('\0'); + hash.update(fs.readFileSync(sourceFilePath)); + hash.update('\0'); + return hash.digest('hex'); }; diff --git a/packages/platform-ios/src/xctest-agent.ts b/packages/platform-ios/src/xctest-agent.ts index e455bd6..dd83561 100644 --- a/packages/platform-ios/src/xctest-agent.ts +++ b/packages/platform-ios/src/xctest-agent.ts @@ -1,5 +1,6 @@ import { createHarnessArtifactDirectory, + getHarnessCacheArtifactPath, getAvailablePort, logger, spawn, @@ -16,6 +17,7 @@ import { type XCTestAgentPermissionsConfiguration, } from './xctest-agent-client.js'; import type { ApplePhysicalDeviceCodeSign } from './config.js'; +import { getSimulators } from './xcrun/simctl.js'; import type { XCTestAgentTransport } from './xctest-agent-transport.js'; import { createDeviceXCTestAgentTransport } from './xctest-agent-transport-device.js'; import { createSimulatorXCTestAgentTransport } from './xctest-agent-transport-simulator.js'; @@ -32,18 +34,20 @@ const XCTEST_AGENT_SHUTDOWN_TIMEOUT_MS = 5_000; const XCTEST_AGENT_STARTUP_POLL_INTERVAL_MS = 250; const HARNESS_DIRNAME = '.harness'; const XCTEST_AGENT_BUILD_DIRNAME = 'xctest-agent'; +const XCTEST_AGENT_SIMULATOR_CACHE_ARTIFACT = 'xctest-agent-simulator'; +const XCTEST_AGENT_SIMULATOR_CACHE_SCHEMA_VERSION = 1; const pipelineAsync = promisify(pipeline); type XCTestAgentTarget = | { - kind: 'simulator'; - id: string; - } + kind: 'simulator'; + id: string; + } | { - kind: 'device'; - id: string; - codeSign: ApplePhysicalDeviceCodeSign; - }; + kind: 'device'; + id: string; + codeSign: ApplePhysicalDeviceCodeSign; + }; export type XCTestAgentCapability = { getLaunchEnvironment?: () => Record; @@ -62,6 +66,23 @@ type XCTestAgentBuildManifest = { codeSign?: ApplePhysicalDeviceCodeSign; }; +type SimulatorXCTestAgentCacheManifest = { + artifactName: typeof XCTEST_AGENT_SIMULATOR_CACHE_ARTIFACT; + buildInputsHash: string; + destinationKind: 'simulator'; + hostArchitecture: string; + schemaVersion: typeof XCTEST_AGENT_SIMULATOR_CACHE_SCHEMA_VERSION; + simulatorRuntime: string; + simulatorSdkVersion: string; + xcodeVersion: string; + xctestrunRelativePath: string; +}; + +type SimulatorXCTestAgentCacheContext = Omit< + SimulatorXCTestAgentCacheManifest, + 'artifactName' | 'destinationKind' | 'schemaVersion' | 'xctestrunRelativePath' +>; + export type XCTestAgentController = { prepare: () => Promise; ensureStarted: () => Promise; @@ -97,10 +118,18 @@ const getXCTestAgentBuildRoot = (): string => { }; const getXCTestAgentDerivedDataPath = (target: XCTestAgentTarget): string => { + if (target.kind === 'simulator') { + return getHarnessCacheArtifactPath(XCTEST_AGENT_SIMULATOR_CACHE_ARTIFACT); + } + return path.join(getXCTestAgentBuildRoot(), target.kind); }; const getXCTestAgentBuildManifestPath = (target: XCTestAgentTarget): string => { + if (target.kind === 'simulator') { + return path.join(getXCTestAgentDerivedDataPath(target), 'cache.json'); + } + return path.join( getXCTestAgentDerivedDataPath(target), 'build-manifest.json' @@ -149,6 +178,10 @@ const getXCTestAgentBuildProductsPath = (target: XCTestAgentTarget): string => { return path.join(getXCTestAgentDerivedDataPath(target), 'Build', 'Products'); }; +const getXCTestAgentSourceFilePath = (): string => { + return fileURLToPath(import.meta.url); +}; + const readBuildManifest = ( target: XCTestAgentTarget ): XCTestAgentBuildManifest | null => { @@ -207,13 +240,166 @@ const getProjectInputsHash = (): string => { hash.update('\0'); } + const sourceFilePath = getXCTestAgentSourceFilePath(); + hash.update(path.basename(sourceFilePath)); + hash.update('\0'); + hash.update(fs.readFileSync(sourceFilePath)); + hash.update('\0'); + return hash.digest('hex'); }; +const getXCTestRunRelativePath = (target: XCTestAgentTarget): string | null => { + const buildProductsPath = getXCTestAgentBuildProductsPath(target); + + if (!fs.existsSync(buildProductsPath)) { + return null; + } + + const entries = fs.readdirSync(buildProductsPath, { recursive: true }); + const xctestrunEntry = entries.find( + (entry) => typeof entry === 'string' && entry.endsWith('.xctestrun') + ); + + return typeof xctestrunEntry === 'string' ? xctestrunEntry : null; +}; + +const readSimulatorBuildManifest = + (): SimulatorXCTestAgentCacheManifest | null => { + const manifestPath = getXCTestAgentBuildManifestPath({ + kind: 'simulator', + id: 'unused', + }); + + if (!fs.existsSync(manifestPath)) { + return null; + } + + const manifest = JSON.parse( + fs.readFileSync(manifestPath, 'utf8') + ) as Partial; + + if ( + manifest.schemaVersion !== XCTEST_AGENT_SIMULATOR_CACHE_SCHEMA_VERSION || + manifest.artifactName !== XCTEST_AGENT_SIMULATOR_CACHE_ARTIFACT || + manifest.destinationKind !== 'simulator' || + typeof manifest.buildInputsHash !== 'string' || + typeof manifest.hostArchitecture !== 'string' || + typeof manifest.simulatorRuntime !== 'string' || + typeof manifest.simulatorSdkVersion !== 'string' || + typeof manifest.xcodeVersion !== 'string' || + typeof manifest.xctestrunRelativePath !== 'string' + ) { + return null; + } + + return manifest as SimulatorXCTestAgentCacheManifest; + }; + +const writeSimulatorBuildManifest = ( + manifest: SimulatorXCTestAgentCacheManifest +) => { + const derivedDataPath = getXCTestAgentDerivedDataPath({ + kind: 'simulator', + id: 'unused', + }); + fs.mkdirSync(derivedDataPath, { recursive: true }); + fs.writeFileSync( + path.join(derivedDataPath, 'cache.json'), + JSON.stringify(manifest, null, 2) + ); +}; + +const getCurrentXcodeVersion = async (): Promise => { + const { stdout } = await spawn('xcodebuild', ['-version']); + return stdout.trim(); +}; + +const getCurrentSimulatorSdkVersion = async (): Promise => { + const { stdout } = await spawn('xcodebuild', [ + '-version', + '-sdk', + 'iphonesimulator', + 'SDKVersion', + ]); + return stdout.trim(); +}; + +const getCurrentSimulatorRuntime = async ( + simulatorId: string +): Promise => { + const simulators = await getSimulators(); + const simulator = simulators.find( + (candidate) => candidate.udid === simulatorId + ); + + if (!simulator) { + throw new Error(`Simulator with UDID ${simulatorId} not found`); + } + + return simulator.runtime; +}; + +const getSimulatorCacheContext = async ( + target: Extract, + buildInputsHash: string +): Promise => { + const [xcodeVersion, simulatorSdkVersion, simulatorRuntime] = + await Promise.all([ + getCurrentXcodeVersion(), + getCurrentSimulatorSdkVersion(), + getCurrentSimulatorRuntime(target.id), + ]); + + return { + buildInputsHash, + hostArchitecture: process.arch, + simulatorRuntime, + simulatorSdkVersion, + xcodeVersion, + }; +}; + +const shouldReuseSimulatorBuildArtifacts = ( + context: SimulatorXCTestAgentCacheContext +): boolean => { + const manifest = readSimulatorBuildManifest(); + + if (!manifest) { + return false; + } + + if ( + manifest.buildInputsHash !== context.buildInputsHash || + manifest.hostArchitecture !== context.hostArchitecture || + manifest.simulatorRuntime !== context.simulatorRuntime || + manifest.simulatorSdkVersion !== context.simulatorSdkVersion || + manifest.xcodeVersion !== context.xcodeVersion + ) { + return false; + } + + const buildProductsPath = getXCTestAgentBuildProductsPath({ + kind: 'simulator', + id: 'unused', + }); + + return ( + fs.existsSync(buildProductsPath) && + fs.existsSync(path.join(buildProductsPath, manifest.xctestrunRelativePath)) + ); +}; + const shouldReuseBuildArtifacts = ( target: XCTestAgentTarget, buildInputsHash: string ): boolean => { + if (target.kind === 'simulator') { + throw new Error( + 'Simulator build reuse must be validated with cache compatibility metadata' + ); + } + const manifest = readBuildManifest(target); if (!manifest) { @@ -232,7 +418,7 @@ const shouldReuseBuildArtifacts = ( manifest.codeSign?.teamId !== target.codeSign.teamId || manifest.codeSign?.signingIdentity !== target.codeSign.signingIdentity || manifest.codeSign?.provisioningProfile !== - target.codeSign.provisioningProfile + target.codeSign.provisioningProfile ) { return false; } @@ -410,7 +596,11 @@ const attachProcessOutputLog = async (options: { for await (const chunk of stream) { mergedOutput.write(`[${label}] `); mergedOutput.write(chunk); - if (Buffer.isBuffer(chunk) ? !chunk.includes(0x0a) : !String(chunk).endsWith('\n')) { + if ( + Buffer.isBuffer(chunk) + ? !chunk.includes(0x0a) + : !String(chunk).endsWith('\n') + ) { mergedOutput.write('\n'); } } @@ -447,7 +637,10 @@ export const createXCTestAgentController = (options: { platformId: 'ios', runnerName: `xctest-agent-${target.kind}`, }); - const xcodebuildLogPath = path.join(logArtifacts.directoryPath, 'xcodebuild.log'); + const xcodebuildLogPath = path.join( + logArtifacts.directoryPath, + 'xcodebuild.log' + ); let prepared = false; let agentProcess: Subprocess | null = null; let agentClient: ReturnType | null = null; @@ -458,8 +651,8 @@ export const createXCTestAgentController = (options: { {}, options.appBundleId ? { - [XCTEST_AGENT_TARGET_BUNDLE_ID_ENV]: options.appBundleId, - } + [XCTEST_AGENT_TARGET_BUNDLE_ID_ENV]: options.appBundleId, + } : {}, ...capabilities.map( (capability) => capability.getLaunchEnvironment?.() ?? {} @@ -495,7 +688,25 @@ export const createXCTestAgentController = (options: { ); assertXCTestAgentProjectExists(); - if (shouldReuseBuildArtifacts(target, buildInputsHash)) { + if (target.kind === 'simulator') { + const cacheContext = await getSimulatorCacheContext( + target, + buildInputsHash + ); + + if (shouldReuseSimulatorBuildArtifacts(cacheContext)) { + prepared = true; + xctestAgentLogger.info( + 'Reusing cached XCTest agent build for %s target', + target.kind + ); + xctestAgentLogger.debug( + 'reusing cached XCTest agent build for %s', + target.kind + ); + return; + } + } else if (shouldReuseBuildArtifacts(target, buildInputsHash)) { prepared = true; xctestAgentLogger.info( 'Reusing cached XCTest agent build for %s target', @@ -508,7 +719,7 @@ export const createXCTestAgentController = (options: { return; } - fs.mkdirSync(getXCTestAgentBuildRoot(), { recursive: true }); + fs.mkdirSync(getXCTestAgentDerivedDataPath(target), { recursive: true }); xctestAgentLogger.debug('building XCTest agent for %s', target.kind); xctestAgentLogger.info('Building XCTest agent for %s target', target.kind); @@ -526,11 +737,35 @@ export const createXCTestAgentController = (options: { ...getXCTestAgentBuildSigningArgs(target), ]); - writeBuildManifest(target, { - buildInputsHash, - destinationKind: target.kind, - codeSign: target.kind === 'device' ? target.codeSign : undefined, - }); + if (target.kind === 'simulator') { + const cacheContext = await getSimulatorCacheContext( + target, + buildInputsHash + ); + const xctestrunRelativePath = getXCTestRunRelativePath(target); + + if (!xctestrunRelativePath) { + throw new Error( + `Missing generated .xctestrun file in ${getXCTestAgentBuildProductsPath( + target + )}` + ); + } + + writeSimulatorBuildManifest({ + artifactName: XCTEST_AGENT_SIMULATOR_CACHE_ARTIFACT, + destinationKind: 'simulator', + schemaVersion: XCTEST_AGENT_SIMULATOR_CACHE_SCHEMA_VERSION, + xctestrunRelativePath, + ...cacheContext, + }); + } else { + writeBuildManifest(target, { + buildInputsHash, + destinationKind: target.kind, + codeSign: target.codeSign, + }); + } xctestAgentLogger.info('Built XCTest agent for %s target', target.kind); prepared = true; }; @@ -566,26 +801,25 @@ export const createXCTestAgentController = (options: { '-derivedDataPath', getXCTestAgentDerivedDataPath(target), ]; - agentProcess = spawn( - 'xcodebuild', - xcodebuildArgs, - { - cwd: getXCTestAgentProjectRoot(), - env: { - ...process.env, - ...toTestRunnerEnv({ - [XCTEST_AGENT_PORT_ENV]: String(port), - ...getLaunchEnvironment(), - }), - }, - } - ); + agentProcess = spawn('xcodebuild', xcodebuildArgs, { + cwd: getXCTestAgentProjectRoot(), + env: { + ...process.env, + ...toTestRunnerEnv({ + [XCTEST_AGENT_PORT_ENV]: String(port), + ...getLaunchEnvironment(), + }), + }, + }); void attachProcessOutputLog({ command: ['xcodebuild', ...xcodebuildArgs].join(' '), logFilePath: xcodebuildLogPath, process: agentProcess, }); - xctestAgentLogger.info('Saving XCTest agent xcodebuild logs to %s', xcodebuildLogPath); + xctestAgentLogger.info( + 'Saving XCTest agent xcodebuild logs to %s', + xcodebuildLogPath + ); const currentProcess = agentProcess; if (typeof currentProcess.catch === 'function') { diff --git a/packages/tools/src/__tests__/harness-artifacts.test.ts b/packages/tools/src/__tests__/harness-artifacts.test.ts index a75af89..9ace917 100644 --- a/packages/tools/src/__tests__/harness-artifacts.test.ts +++ b/packages/tools/src/__tests__/harness-artifacts.test.ts @@ -2,7 +2,12 @@ import { afterEach, describe, expect, it } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import { tmpdir } from 'node:os'; -import { createHarnessArtifactDirectory } from '../harness-artifacts.js'; +import { + createHarnessArtifactDirectory, + getHarnessCacheArtifactPath, + getHarnessCacheRootPath, + getHarnessRootPath, +} from '../harness-artifacts.js'; describe('createHarnessArtifactDirectory', () => { const rootDir = fs.mkdtempSync( @@ -34,4 +39,14 @@ describe('createHarnessArtifactDirectory', () => { ); expect(fs.existsSync(artifacts.directoryPath)).toBe(true); }); + + it('resolves the shared harness cache paths', () => { + expect(getHarnessRootPath(rootDir)).toBe(path.join(rootDir, '.harness')); + expect(getHarnessCacheRootPath(rootDir)).toBe( + path.join(rootDir, '.harness', 'cache') + ); + expect(getHarnessCacheArtifactPath('xctest-agent simulator', rootDir)).toBe( + path.join(rootDir, '.harness', 'cache', 'xctest-agent-simulator') + ); + }); }); diff --git a/packages/tools/src/harness-artifacts.ts b/packages/tools/src/harness-artifacts.ts index cebeffb..3cc8827 100644 --- a/packages/tools/src/harness-artifacts.ts +++ b/packages/tools/src/harness-artifacts.ts @@ -1,7 +1,20 @@ import fs from 'node:fs'; import path from 'node:path'; -const getDefaultHarnessRoot = () => path.join(process.cwd(), '.harness'); +export const getHarnessRootPath = (projectRoot = process.cwd()) => + path.join(projectRoot, '.harness'); + +export const getHarnessCacheRootPath = (projectRoot = process.cwd()) => + path.join(getHarnessRootPath(projectRoot), 'cache'); + +export const getHarnessCacheArtifactPath = ( + artifactName: string, + projectRoot = process.cwd() +) => + path.join( + getHarnessCacheRootPath(projectRoot), + sanitizePathSegment(artifactName) + ); const sanitizePathSegment = (value: string) => value @@ -19,7 +32,7 @@ export const createHarnessArtifactDirectory = ({ artifactType, bundleId, platformId, - rootDir = getDefaultHarnessRoot(), + rootDir = getHarnessRootPath(), runTimestamp = formatRunTimestamp(new Date()), runnerName, }: { @@ -31,12 +44,7 @@ export const createHarnessArtifactDirectory = ({ runnerName: string; }) => { const artifactRoot = path.join(rootDir, sanitizePathSegment(artifactType)); - const runDirName = [ - runTimestamp, - platformId, - runnerName, - bundleId, - ] + const runDirName = [runTimestamp, platformId, runnerName, bundleId] .filter(isDefined) .map((value) => sanitizePathSegment(value)) .join('--'); From eaa4e94194fd6536d4df33418503f444c484b7b2 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 30 Apr 2026 19:39:52 +0200 Subject: [PATCH 2/4] fix: isolate Harness cache artifacts --- .github/workflows/e2e-tests.yml | 8 +- .../src/__tests__/xctest-agent.test.ts | 145 ++++++----- packages/platform-ios/src/xctest-agent.ts | 237 +++++++++++------- 3 files changed, 220 insertions(+), 170 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 8bbbd41..18ede5c 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -165,8 +165,8 @@ jobs: id: cache-xctest-agent-restore uses: actions/cache/restore@v4 with: - path: ./apps/playground/.harness/cache/xctest-agent-simulator - key: xctest-agent-${{ runner.os }}-xcode-26.0-${{ hashFiles('packages/platform-ios/xctest-agent/**', 'packages/platform-ios/src/xctest-agent.ts') }} + path: ./apps/playground/.harness/cache + key: xctest-agent-${{ runner.os }}-xcode-26.0-${{ hashFiles('apps/playground/.harness/cache/*/cache.json') }} restore-keys: | xctest-agent-${{ runner.os }}-xcode-26. @@ -218,8 +218,8 @@ jobs: if: steps.cache-xctest-agent-restore.outputs.cache-hit != 'true' && success() uses: actions/cache/save@v4 with: - path: ./apps/playground/.harness/cache/xctest-agent-simulator - key: xctest-agent-${{ runner.os }}-xcode-26.0-${{ hashFiles('packages/platform-ios/xctest-agent/**', 'packages/platform-ios/src/xctest-agent.ts') }} + path: ./apps/playground/.harness/cache + key: xctest-agent-${{ runner.os }}-xcode-26.0-${{ hashFiles('apps/playground/.harness/cache/*/cache.json') }} - name: Upload Harness logs if: always() diff --git a/packages/platform-ios/src/__tests__/xctest-agent.test.ts b/packages/platform-ios/src/__tests__/xctest-agent.test.ts index 77ee4b3..052c278 100644 --- a/packages/platform-ios/src/__tests__/xctest-agent.test.ts +++ b/packages/platform-ios/src/__tests__/xctest-agent.test.ts @@ -65,7 +65,7 @@ const projectRoot = path.resolve( '..', 'xctest-agent' ); -let simulatorBuildRoot = ''; +let simulatorCacheRoot = ''; let deviceBuildRoot = ''; let tempProjectRoot = ''; const originalCwd = process.cwd(); @@ -138,12 +138,7 @@ describe('xctest-agent orchestration', () => { path.join(os.tmpdir(), 'rn-harness-xctest-agent-') ); process.chdir(tempProjectRoot); - simulatorBuildRoot = path.join( - tempProjectRoot, - '.harness', - 'cache', - 'xctest-agent-simulator' - ); + simulatorCacheRoot = path.join(tempProjectRoot, '.harness', 'cache'); deviceBuildRoot = path.join(tempProjectRoot, '.harness', 'xctest-agent'); rmBuildRoot(); mocks.activeAgentStops.length = 0; @@ -257,9 +252,14 @@ describe('xctest-agent orchestration', () => { 'platform=iOS Simulator,id=sim-123', ]) ); - expect(fs.existsSync(path.join(simulatorBuildRoot, 'cache.json'))).toBe( - true - ); + const cacheDirectories = fs.readdirSync(simulatorCacheRoot); + expect(cacheDirectories).toHaveLength(1); + expect(cacheDirectories[0]).toMatch(/^xctest-agent-simulator-/); + expect( + fs.existsSync( + path.join(simulatorCacheRoot, cacheDirectories[0]!, 'cache.json') + ) + ).toBe(true); }); it('reuses cached build artifacts for repeated prepares on the same destination kind', async () => { @@ -434,18 +434,24 @@ describe('xctest-agent orchestration', () => { } if (file === 'xcodebuild' && args?.[0] === 'build-for-testing') { - fs.mkdirSync(path.join(simulatorBuildRoot, 'Build', 'Products'), { - recursive: true, - }); - fs.writeFileSync( - path.join( - simulatorBuildRoot, - 'Build', - 'Products', - 'HarnessXCTestAgent_HarnessXCTestAgent_iphonesimulator26.0-arm64.xctestrun' - ), - 'cached xctestrun' - ); + const derivedDataIndex = args.indexOf('-derivedDataPath'); + const derivedDataPath = + derivedDataIndex === -1 ? undefined : args[derivedDataIndex + 1]; + + if (derivedDataPath) { + fs.mkdirSync(path.join(derivedDataPath, 'Build', 'Products'), { + recursive: true, + }); + fs.writeFileSync( + path.join( + derivedDataPath, + 'Build', + 'Products', + 'HarnessXCTestAgent_HarnessXCTestAgent_iphonesimulator26.0-arm64.xctestrun' + ), + 'cached xctestrun' + ); + } } return createLongRunningSubprocess().subprocess; @@ -469,33 +475,10 @@ describe('xctest-agent orchestration', () => { }); it('rebuilds when the cached build manifest no longer matches project inputs', async () => { - fs.mkdirSync(path.join(simulatorBuildRoot, 'Build', 'Products'), { - recursive: true, + writeSimulatorCacheDirectory({ + buildInputsHash: 'stale-manifest-hash', + directoryName: 'xctest-agent-simulator-stale', }); - fs.writeFileSync( - path.join( - simulatorBuildRoot, - 'Build', - 'Products', - 'HarnessXCTestAgent_HarnessXCTestAgent_iphonesimulator26.0-arm64.xctestrun' - ), - 'cached xctestrun' - ); - fs.writeFileSync( - path.join(simulatorBuildRoot, 'cache.json'), - JSON.stringify({ - artifactName: 'xctest-agent-simulator', - buildInputsHash: 'stale-manifest-hash', - destinationKind: 'simulator', - hostArchitecture: process.arch, - schemaVersion: 1, - simulatorRuntime, - simulatorSdkVersion, - xcodeVersion, - xctestrunRelativePath: - 'HarnessXCTestAgent_HarnessXCTestAgent_iphonesimulator26.0-arm64.xctestrun', - }) - ); const controller = createXCTestAgentController({ target: { @@ -515,33 +498,10 @@ describe('xctest-agent orchestration', () => { }); it('reuses simulator build artifacts only when the cache metadata matches', async () => { - fs.mkdirSync(path.join(simulatorBuildRoot, 'Build', 'Products'), { - recursive: true, + writeSimulatorCacheDirectory({ + buildInputsHash: getCurrentInputsHash(), + directoryName: 'xctest-agent-simulator-existing', }); - fs.writeFileSync( - path.join( - simulatorBuildRoot, - 'Build', - 'Products', - 'HarnessXCTestAgent_HarnessXCTestAgent_iphonesimulator26.0-arm64.xctestrun' - ), - 'cached xctestrun' - ); - fs.writeFileSync( - path.join(simulatorBuildRoot, 'cache.json'), - JSON.stringify({ - artifactName: 'xctest-agent-simulator', - buildInputsHash: getCurrentInputsHash(), - destinationKind: 'simulator', - hostArchitecture: process.arch, - schemaVersion: 1, - simulatorRuntime, - simulatorSdkVersion, - xcodeVersion, - xctestrunRelativePath: - 'HarnessXCTestAgent_HarnessXCTestAgent_iphonesimulator26.0-arm64.xctestrun', - }) - ); const controller = createXCTestAgentController({ target: { @@ -597,7 +557,7 @@ describe('xctest-agent orchestration', () => { }); const rmBuildRoot = () => { - fs.rmSync(simulatorBuildRoot, { + fs.rmSync(simulatorCacheRoot, { force: true, recursive: true, }); @@ -630,6 +590,41 @@ const getCurrentInputsHash = (): string => { return hash.digest('hex'); }; +const writeSimulatorCacheDirectory = (options: { + buildInputsHash: string; + directoryName: string; +}) => { + const derivedDataPath = path.join(simulatorCacheRoot, options.directoryName); + + fs.mkdirSync(path.join(derivedDataPath, 'Build', 'Products'), { + recursive: true, + }); + fs.writeFileSync( + path.join( + derivedDataPath, + 'Build', + 'Products', + 'HarnessXCTestAgent_HarnessXCTestAgent_iphonesimulator26.0-arm64.xctestrun' + ), + 'cached xctestrun' + ); + fs.writeFileSync( + path.join(derivedDataPath, 'cache.json'), + JSON.stringify({ + artifactName: 'xctest-agent-simulator', + buildInputsHash: options.buildInputsHash, + destinationKind: 'simulator', + hostArchitecture: process.arch, + schemaVersion: 1, + simulatorRuntime, + simulatorSdkVersion, + xcodeVersion, + xctestrunRelativePath: + 'HarnessXCTestAgent_HarnessXCTestAgent_iphonesimulator26.0-arm64.xctestrun', + }) + ); +}; + const getInputFiles = (root: string): string[] => { const entries = fs.readdirSync(root, { withFileTypes: true }); const files: string[] = []; diff --git a/packages/platform-ios/src/xctest-agent.ts b/packages/platform-ios/src/xctest-agent.ts index dd83561..cdbb318 100644 --- a/packages/platform-ios/src/xctest-agent.ts +++ b/packages/platform-ios/src/xctest-agent.ts @@ -1,6 +1,6 @@ import { createHarnessArtifactDirectory, - getHarnessCacheArtifactPath, + getHarnessCacheRootPath, getAvailablePort, logger, spawn, @@ -117,24 +117,19 @@ const getXCTestAgentBuildRoot = (): string => { return path.join(process.cwd(), HARNESS_DIRNAME, XCTEST_AGENT_BUILD_DIRNAME); }; -const getXCTestAgentDerivedDataPath = (target: XCTestAgentTarget): string => { - if (target.kind === 'simulator') { - return getHarnessCacheArtifactPath(XCTEST_AGENT_SIMULATOR_CACHE_ARTIFACT); - } +const getXCTestAgentCacheRoot = (): string => { + return getHarnessCacheRootPath(); +}; +const getXCTestAgentDerivedDataPath = (target: XCTestAgentTarget): string => { return path.join(getXCTestAgentBuildRoot(), target.kind); }; -const getXCTestAgentBuildManifestPath = (target: XCTestAgentTarget): string => { - if (target.kind === 'simulator') { - return path.join(getXCTestAgentDerivedDataPath(target), 'cache.json'); - } +const getXCTestAgentBuildManifestPath = (derivedDataPath: string): string => + path.join(derivedDataPath, 'build-manifest.json'); - return path.join( - getXCTestAgentDerivedDataPath(target), - 'build-manifest.json' - ); -}; +const getXCTestAgentCacheManifestPath = (derivedDataPath: string): string => + path.join(derivedDataPath, 'cache.json'); const getXCTestAgentBuildDestination = (target: XCTestAgentTarget): string => { return target.kind === 'simulator' @@ -174,9 +169,8 @@ const getXCTestAgentBuildSigningArgs = ( return args; }; -const getXCTestAgentBuildProductsPath = (target: XCTestAgentTarget): string => { - return path.join(getXCTestAgentDerivedDataPath(target), 'Build', 'Products'); -}; +const getXCTestAgentBuildProductsPath = (derivedDataPath: string): string => + path.join(derivedDataPath, 'Build', 'Products'); const getXCTestAgentSourceFilePath = (): string => { return fileURLToPath(import.meta.url); @@ -185,7 +179,9 @@ const getXCTestAgentSourceFilePath = (): string => { const readBuildManifest = ( target: XCTestAgentTarget ): XCTestAgentBuildManifest | null => { - const manifestPath = getXCTestAgentBuildManifestPath(target); + const manifestPath = getXCTestAgentBuildManifestPath( + getXCTestAgentDerivedDataPath(target) + ); if (!fs.existsSync(manifestPath)) { return null; @@ -202,7 +198,7 @@ const writeBuildManifest = ( ) => { fs.mkdirSync(getXCTestAgentDerivedDataPath(target), { recursive: true }); fs.writeFileSync( - getXCTestAgentBuildManifestPath(target), + getXCTestAgentBuildManifestPath(getXCTestAgentDerivedDataPath(target)), JSON.stringify(manifest, null, 2) ); }; @@ -249,8 +245,8 @@ const getProjectInputsHash = (): string => { return hash.digest('hex'); }; -const getXCTestRunRelativePath = (target: XCTestAgentTarget): string | null => { - const buildProductsPath = getXCTestAgentBuildProductsPath(target); +const getXCTestRunRelativePath = (derivedDataPath: string): string | null => { + const buildProductsPath = getXCTestAgentBuildProductsPath(derivedDataPath); if (!fs.existsSync(buildProductsPath)) { return null; @@ -264,52 +260,131 @@ const getXCTestRunRelativePath = (target: XCTestAgentTarget): string | null => { return typeof xctestrunEntry === 'string' ? xctestrunEntry : null; }; -const readSimulatorBuildManifest = - (): SimulatorXCTestAgentCacheManifest | null => { - const manifestPath = getXCTestAgentBuildManifestPath({ - kind: 'simulator', - id: 'unused', - }); +const readSimulatorBuildManifest = ( + derivedDataPath: string +): SimulatorXCTestAgentCacheManifest | null => { + const manifestPath = getXCTestAgentCacheManifestPath(derivedDataPath); - if (!fs.existsSync(manifestPath)) { - return null; - } + if (!fs.existsSync(manifestPath)) { + return null; + } - const manifest = JSON.parse( - fs.readFileSync(manifestPath, 'utf8') - ) as Partial; + const manifest = JSON.parse( + fs.readFileSync(manifestPath, 'utf8') + ) as Partial; - if ( - manifest.schemaVersion !== XCTEST_AGENT_SIMULATOR_CACHE_SCHEMA_VERSION || - manifest.artifactName !== XCTEST_AGENT_SIMULATOR_CACHE_ARTIFACT || - manifest.destinationKind !== 'simulator' || - typeof manifest.buildInputsHash !== 'string' || - typeof manifest.hostArchitecture !== 'string' || - typeof manifest.simulatorRuntime !== 'string' || - typeof manifest.simulatorSdkVersion !== 'string' || - typeof manifest.xcodeVersion !== 'string' || - typeof manifest.xctestrunRelativePath !== 'string' - ) { - return null; - } + if ( + manifest.schemaVersion !== XCTEST_AGENT_SIMULATOR_CACHE_SCHEMA_VERSION || + manifest.artifactName !== XCTEST_AGENT_SIMULATOR_CACHE_ARTIFACT || + manifest.destinationKind !== 'simulator' || + typeof manifest.buildInputsHash !== 'string' || + typeof manifest.hostArchitecture !== 'string' || + typeof manifest.simulatorRuntime !== 'string' || + typeof manifest.simulatorSdkVersion !== 'string' || + typeof manifest.xcodeVersion !== 'string' || + typeof manifest.xctestrunRelativePath !== 'string' + ) { + return null; + } - return manifest as SimulatorXCTestAgentCacheManifest; - }; + return manifest as SimulatorXCTestAgentCacheManifest; +}; const writeSimulatorBuildManifest = ( + derivedDataPath: string, manifest: SimulatorXCTestAgentCacheManifest ) => { - const derivedDataPath = getXCTestAgentDerivedDataPath({ - kind: 'simulator', - id: 'unused', - }); fs.mkdirSync(derivedDataPath, { recursive: true }); fs.writeFileSync( - path.join(derivedDataPath, 'cache.json'), + getXCTestAgentCacheManifestPath(derivedDataPath), JSON.stringify(manifest, null, 2) ); }; +const getSimulatorCacheDirectoryName = ( + context: SimulatorXCTestAgentCacheContext +): string => { + const hash = createHash('sha256'); + hash.update(XCTEST_AGENT_SIMULATOR_CACHE_ARTIFACT); + hash.update('\0'); + hash.update(String(XCTEST_AGENT_SIMULATOR_CACHE_SCHEMA_VERSION)); + hash.update('\0'); + hash.update(context.buildInputsHash); + hash.update('\0'); + hash.update(context.hostArchitecture); + hash.update('\0'); + hash.update(context.simulatorRuntime); + hash.update('\0'); + hash.update(context.simulatorSdkVersion); + hash.update('\0'); + hash.update(context.xcodeVersion); + + return `${XCTEST_AGENT_SIMULATOR_CACHE_ARTIFACT}-${hash + .digest('hex') + .slice(0, 12)}`; +}; + +const getSimulatorCacheDerivedDataPath = ( + context: SimulatorXCTestAgentCacheContext +): string => { + return path.join( + getXCTestAgentCacheRoot(), + getSimulatorCacheDirectoryName(context) + ); +}; + +const getHarnessCacheDirectories = (): string[] => { + const cacheRoot = getXCTestAgentCacheRoot(); + + if (!fs.existsSync(cacheRoot)) { + return []; + } + + return fs + .readdirSync(cacheRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(cacheRoot, entry.name)) + .sort(); +}; + +const isCompatibleSimulatorBuildManifest = ( + manifest: SimulatorXCTestAgentCacheManifest, + context: SimulatorXCTestAgentCacheContext +): boolean => { + return ( + manifest.buildInputsHash === context.buildInputsHash && + manifest.hostArchitecture === context.hostArchitecture && + manifest.simulatorRuntime === context.simulatorRuntime && + manifest.simulatorSdkVersion === context.simulatorSdkVersion && + manifest.xcodeVersion === context.xcodeVersion + ); +}; + +const findReusableSimulatorBuildArtifacts = ( + context: SimulatorXCTestAgentCacheContext +): string | null => { + for (const derivedDataPath of getHarnessCacheDirectories()) { + const manifest = readSimulatorBuildManifest(derivedDataPath); + + if (!manifest || !isCompatibleSimulatorBuildManifest(manifest, context)) { + continue; + } + + const buildProductsPath = getXCTestAgentBuildProductsPath(derivedDataPath); + + if ( + fs.existsSync(buildProductsPath) && + fs.existsSync( + path.join(buildProductsPath, manifest.xctestrunRelativePath) + ) + ) { + return derivedDataPath; + } + } + + return null; +}; + const getCurrentXcodeVersion = async (): Promise => { const { stdout } = await spawn('xcodebuild', ['-version']); return stdout.trim(); @@ -360,36 +435,6 @@ const getSimulatorCacheContext = async ( }; }; -const shouldReuseSimulatorBuildArtifacts = ( - context: SimulatorXCTestAgentCacheContext -): boolean => { - const manifest = readSimulatorBuildManifest(); - - if (!manifest) { - return false; - } - - if ( - manifest.buildInputsHash !== context.buildInputsHash || - manifest.hostArchitecture !== context.hostArchitecture || - manifest.simulatorRuntime !== context.simulatorRuntime || - manifest.simulatorSdkVersion !== context.simulatorSdkVersion || - manifest.xcodeVersion !== context.xcodeVersion - ) { - return false; - } - - const buildProductsPath = getXCTestAgentBuildProductsPath({ - kind: 'simulator', - id: 'unused', - }); - - return ( - fs.existsSync(buildProductsPath) && - fs.existsSync(path.join(buildProductsPath, manifest.xctestrunRelativePath)) - ); -}; - const shouldReuseBuildArtifacts = ( target: XCTestAgentTarget, buildInputsHash: string @@ -424,7 +469,9 @@ const shouldReuseBuildArtifacts = ( } } - return fs.existsSync(getXCTestAgentBuildProductsPath(target)); + return fs.existsSync( + getXCTestAgentBuildProductsPath(getXCTestAgentDerivedDataPath(target)) + ); }; const getDefaultRuntimeConfiguration = (): XCTestAgentRuntimeConfiguration => { @@ -641,6 +688,7 @@ export const createXCTestAgentController = (options: { logArtifacts.directoryPath, 'xcodebuild.log' ); + let preparedDerivedDataPath = getXCTestAgentDerivedDataPath(target); let prepared = false; let agentProcess: Subprocess | null = null; let agentClient: ReturnType | null = null; @@ -693,8 +741,11 @@ export const createXCTestAgentController = (options: { target, buildInputsHash ); + const reusableDerivedDataPath = + findReusableSimulatorBuildArtifacts(cacheContext); - if (shouldReuseSimulatorBuildArtifacts(cacheContext)) { + if (reusableDerivedDataPath) { + preparedDerivedDataPath = reusableDerivedDataPath; prepared = true; xctestAgentLogger.info( 'Reusing cached XCTest agent build for %s target', @@ -706,6 +757,8 @@ export const createXCTestAgentController = (options: { ); return; } + + preparedDerivedDataPath = getSimulatorCacheDerivedDataPath(cacheContext); } else if (shouldReuseBuildArtifacts(target, buildInputsHash)) { prepared = true; xctestAgentLogger.info( @@ -719,7 +772,7 @@ export const createXCTestAgentController = (options: { return; } - fs.mkdirSync(getXCTestAgentDerivedDataPath(target), { recursive: true }); + fs.mkdirSync(preparedDerivedDataPath, { recursive: true }); xctestAgentLogger.debug('building XCTest agent for %s', target.kind); xctestAgentLogger.info('Building XCTest agent for %s target', target.kind); @@ -732,7 +785,7 @@ export const createXCTestAgentController = (options: { '-destination', getXCTestAgentBuildDestination(target), '-derivedDataPath', - getXCTestAgentDerivedDataPath(target), + preparedDerivedDataPath, ...(target.kind === 'device' ? ['-allowProvisioningUpdates'] : []), ...getXCTestAgentBuildSigningArgs(target), ]); @@ -742,17 +795,19 @@ export const createXCTestAgentController = (options: { target, buildInputsHash ); - const xctestrunRelativePath = getXCTestRunRelativePath(target); + const xctestrunRelativePath = getXCTestRunRelativePath( + preparedDerivedDataPath + ); if (!xctestrunRelativePath) { throw new Error( `Missing generated .xctestrun file in ${getXCTestAgentBuildProductsPath( - target + preparedDerivedDataPath )}` ); } - writeSimulatorBuildManifest({ + writeSimulatorBuildManifest(preparedDerivedDataPath, { artifactName: XCTEST_AGENT_SIMULATOR_CACHE_ARTIFACT, destinationKind: 'simulator', schemaVersion: XCTEST_AGENT_SIMULATOR_CACHE_SCHEMA_VERSION, @@ -799,7 +854,7 @@ export const createXCTestAgentController = (options: { '-maximum-parallel-testing-workers', '1', '-derivedDataPath', - getXCTestAgentDerivedDataPath(target), + preparedDerivedDataPath, ]; agentProcess = spawn('xcodebuild', xcodebuildArgs, { cwd: getXCTestAgentProjectRoot(), From 21fd4649145a950234fe13de70e8c3ad7f970788 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 4 May 2026 17:10:30 +0200 Subject: [PATCH 3/4] refactor: make caching generic --- .github/workflows/e2e-tests.yml | 16 ---------------- action.yml | 17 ++++++++++++++++- packages/github-action/src/action.yml | 17 ++++++++++++++++- 3 files changed, 32 insertions(+), 18 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 18ede5c..a9aa0af 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -161,15 +161,6 @@ jobs: path: ./apps/playground/ios/build/Build/Products/Debug-iphonesimulator/HarnessPlayground.app key: ios-app-playground - - name: Restore XCTest agent cache - id: cache-xctest-agent-restore - uses: actions/cache/restore@v4 - with: - path: ./apps/playground/.harness/cache - key: xctest-agent-${{ runner.os }}-xcode-26.0-${{ hashFiles('apps/playground/.harness/cache/*/cache.json') }} - restore-keys: | - xctest-agent-${{ runner.os }}-xcode-26. - - name: CocoaPods cache if: steps.cache-app-restore.outputs.cache-hit != 'true' uses: actions/cache@v4 @@ -214,13 +205,6 @@ jobs: echo "HARNESS_RUNNER=$HARNESS_RUNNER" echo "HARNESS_EXIT_CODE=$HARNESS_EXIT_CODE" - - name: Save XCTest agent cache - if: steps.cache-xctest-agent-restore.outputs.cache-hit != 'true' && success() - uses: actions/cache/save@v4 - with: - path: ./apps/playground/.harness/cache - key: xctest-agent-${{ runner.os }}-xcode-26.0-${{ hashFiles('apps/playground/.harness/cache/*/cache.json') }} - - name: Upload Harness logs if: always() uses: actions/upload-artifact@v4 diff --git a/action.yml b/action.yml index 70784db..23a260d 100644 --- a/action.yml +++ b/action.yml @@ -64,13 +64,22 @@ runs: echo "Please provide the path to the built app (.apk for Android, .app for iOS)" exit 1 fi - - name: Metro cache + - name: Metro bundler cache (.harness/metro-cache) uses: actions/cache@v4 with: path: ${{ steps.load-config.outputs.projectRoot }}/.harness/metro-cache key: ${{ runner.os }}-metro-cache-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package-lock.json', '**/npm-shrinkwrap.json', '**/pnpm-lock.yaml', '**/yarn.lock', '**/metro.config.js', '**/metro.config.cjs', '**/metro.config.mjs', '**/metro.config.ts', '**/babel.config.js', '**/babel.config.cjs', '**/babel.config.mjs', '**/babel.config.ts', '**/babel.config.json') }} restore-keys: | ${{ runner.os }}-metro-cache- + - name: Restore Harness cache + id: cache-harness-restore + if: fromJson(steps.load-config.outputs.config).platformId == 'ios' + uses: actions/cache/restore@v4 + with: + path: ${{ steps.load-config.outputs.projectRoot }}/.harness/cache + key: harness-ios-${{ runner.os }}-${{ hashFiles(format('{0}/.harness/cache/**/cache.json', steps.load-config.outputs.projectRoot)) }} + restore-keys: | + harness-ios-${{ runner.os }}- # ── iOS ────────────────────────────────────────────────────────────────── # iOS simulator boot and app installation are handled by Harness itself. @@ -227,6 +236,12 @@ runs: ~/.android/avd ~/.android/adb* key: ${{ steps.avd-key.outputs.key }} + - name: Save Harness cache + if: ${{ always() && fromJson(steps.load-config.outputs.config).platformId == 'ios' && steps.cache-harness-restore.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: ${{ steps.load-config.outputs.projectRoot }}/.harness/cache + key: harness-ios-${{ runner.os }}-${{ hashFiles(format('{0}/.harness/cache/**/cache.json', steps.load-config.outputs.projectRoot)) }} - name: Upload crash report artifacts if: always() uses: actions/upload-artifact@v4 diff --git a/packages/github-action/src/action.yml b/packages/github-action/src/action.yml index 70784db..23a260d 100644 --- a/packages/github-action/src/action.yml +++ b/packages/github-action/src/action.yml @@ -64,13 +64,22 @@ runs: echo "Please provide the path to the built app (.apk for Android, .app for iOS)" exit 1 fi - - name: Metro cache + - name: Metro bundler cache (.harness/metro-cache) uses: actions/cache@v4 with: path: ${{ steps.load-config.outputs.projectRoot }}/.harness/metro-cache key: ${{ runner.os }}-metro-cache-${{ hashFiles('**/bun.lock', '**/bun.lockb', '**/package-lock.json', '**/npm-shrinkwrap.json', '**/pnpm-lock.yaml', '**/yarn.lock', '**/metro.config.js', '**/metro.config.cjs', '**/metro.config.mjs', '**/metro.config.ts', '**/babel.config.js', '**/babel.config.cjs', '**/babel.config.mjs', '**/babel.config.ts', '**/babel.config.json') }} restore-keys: | ${{ runner.os }}-metro-cache- + - name: Restore Harness cache + id: cache-harness-restore + if: fromJson(steps.load-config.outputs.config).platformId == 'ios' + uses: actions/cache/restore@v4 + with: + path: ${{ steps.load-config.outputs.projectRoot }}/.harness/cache + key: harness-ios-${{ runner.os }}-${{ hashFiles(format('{0}/.harness/cache/**/cache.json', steps.load-config.outputs.projectRoot)) }} + restore-keys: | + harness-ios-${{ runner.os }}- # ── iOS ────────────────────────────────────────────────────────────────── # iOS simulator boot and app installation are handled by Harness itself. @@ -227,6 +236,12 @@ runs: ~/.android/avd ~/.android/adb* key: ${{ steps.avd-key.outputs.key }} + - name: Save Harness cache + if: ${{ always() && fromJson(steps.load-config.outputs.config).platformId == 'ios' && steps.cache-harness-restore.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: ${{ steps.load-config.outputs.projectRoot }}/.harness/cache + key: harness-ios-${{ runner.os }}-${{ hashFiles(format('{0}/.harness/cache/**/cache.json', steps.load-config.outputs.projectRoot)) }} - name: Upload crash report artifacts if: always() uses: actions/upload-artifact@v4 From 02e3d55be3e32979e2b5c53b9ffa85ccfce44c2f Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 4 May 2026 17:16:52 +0200 Subject: [PATCH 4/4] fix: reuse restored iOS cache key --- action.yml | 2 +- packages/github-action/src/action.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/action.yml b/action.yml index 23a260d..cd42a0b 100644 --- a/action.yml +++ b/action.yml @@ -241,7 +241,7 @@ runs: uses: actions/cache/save@v4 with: path: ${{ steps.load-config.outputs.projectRoot }}/.harness/cache - key: harness-ios-${{ runner.os }}-${{ hashFiles(format('{0}/.harness/cache/**/cache.json', steps.load-config.outputs.projectRoot)) }} + key: ${{ steps.cache-harness-restore.outputs.cache-primary-key }} - name: Upload crash report artifacts if: always() uses: actions/upload-artifact@v4 diff --git a/packages/github-action/src/action.yml b/packages/github-action/src/action.yml index 23a260d..cd42a0b 100644 --- a/packages/github-action/src/action.yml +++ b/packages/github-action/src/action.yml @@ -241,7 +241,7 @@ runs: uses: actions/cache/save@v4 with: path: ${{ steps.load-config.outputs.projectRoot }}/.harness/cache - key: harness-ios-${{ runner.os }}-${{ hashFiles(format('{0}/.harness/cache/**/cache.json', steps.load-config.outputs.projectRoot)) }} + key: ${{ steps.cache-harness-restore.outputs.cache-primary-key }} - name: Upload crash report artifacts if: always() uses: actions/upload-artifact@v4