diff --git a/action.yml b/action.yml index 70784db..cd42a0b 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: ${{ 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 70784db..cd42a0b 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: ${{ steps.cache-harness-restore.outputs.cache-primary-key }} - name: Upload crash report artifacts 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..052c278 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 simulatorCacheRoot = ''; +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,93 @@ 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'); + simulatorCacheRoot = path.join(tempProjectRoot, '.harness', 'cache'); + 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 +244,37 @@ 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', - ]), + ]) ); + const cacheDirectories = fs.readdirSync(simulatorCacheRoot); + expect(cacheDirectories).toHaveLength(1); + expect(cacheDirectories[0]).toMatch(/^xctest-agent-simulator-/); expect( - fs.existsSync(path.join(buildRoot, 'simulator', 'build-manifest.json')), + 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 () => { - 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,63 @@ 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') { + 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; }); @@ -338,16 +475,10 @@ 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'), { - recursive: true, + writeSimulatorCacheDirectory({ + buildInputsHash: 'stale-manifest-hash', + directoryName: 'xctest-agent-simulator-stale', }); - fs.writeFileSync( - path.join(buildRoot, 'simulator', 'build-manifest.json'), - JSON.stringify({ - buildInputsHash: 'stale-manifest-hash', - destinationKind: 'simulator', - }), - ); const controller = createXCTestAgentController({ target: { @@ -358,19 +489,37 @@ 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 () => { + writeSimulatorCacheDirectory({ + buildInputsHash: getCurrentInputsHash(), + directoryName: 'xctest-agent-simulator-existing', + }); + + 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 +533,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 +557,11 @@ describe('xctest-agent orchestration', () => { }); const rmBuildRoot = () => { - fs.rmSync(buildRoot, { + fs.rmSync(simulatorCacheRoot, { + force: true, + recursive: true, + }); + fs.rmSync(deviceBuildRoot, { force: true, recursive: true, }); @@ -424,9 +577,54 @@ 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'); }; +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 e455bd6..cdbb318 100644 --- a/packages/platform-ios/src/xctest-agent.ts +++ b/packages/platform-ios/src/xctest-agent.ts @@ -1,5 +1,6 @@ import { createHarnessArtifactDirectory, + getHarnessCacheRootPath, 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; @@ -96,16 +117,19 @@ const getXCTestAgentBuildRoot = (): string => { return path.join(process.cwd(), HARNESS_DIRNAME, XCTEST_AGENT_BUILD_DIRNAME); }; +const getXCTestAgentCacheRoot = (): string => { + return getHarnessCacheRootPath(); +}; + const getXCTestAgentDerivedDataPath = (target: XCTestAgentTarget): string => { return path.join(getXCTestAgentBuildRoot(), target.kind); }; -const getXCTestAgentBuildManifestPath = (target: XCTestAgentTarget): string => { - return path.join( - getXCTestAgentDerivedDataPath(target), - 'build-manifest.json' - ); -}; +const getXCTestAgentBuildManifestPath = (derivedDataPath: string): string => + path.join(derivedDataPath, 'build-manifest.json'); + +const getXCTestAgentCacheManifestPath = (derivedDataPath: string): string => + path.join(derivedDataPath, 'cache.json'); const getXCTestAgentBuildDestination = (target: XCTestAgentTarget): string => { return target.kind === 'simulator' @@ -145,14 +169,19 @@ 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); }; const readBuildManifest = ( target: XCTestAgentTarget ): XCTestAgentBuildManifest | null => { - const manifestPath = getXCTestAgentBuildManifestPath(target); + const manifestPath = getXCTestAgentBuildManifestPath( + getXCTestAgentDerivedDataPath(target) + ); if (!fs.existsSync(manifestPath)) { return null; @@ -169,7 +198,7 @@ const writeBuildManifest = ( ) => { fs.mkdirSync(getXCTestAgentDerivedDataPath(target), { recursive: true }); fs.writeFileSync( - getXCTestAgentBuildManifestPath(target), + getXCTestAgentBuildManifestPath(getXCTestAgentDerivedDataPath(target)), JSON.stringify(manifest, null, 2) ); }; @@ -207,13 +236,215 @@ 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 = (derivedDataPath: string): string | null => { + const buildProductsPath = getXCTestAgentBuildProductsPath(derivedDataPath); + + 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 = ( + derivedDataPath: string +): SimulatorXCTestAgentCacheManifest | null => { + const manifestPath = getXCTestAgentCacheManifestPath(derivedDataPath); + + 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 = ( + derivedDataPath: string, + manifest: SimulatorXCTestAgentCacheManifest +) => { + fs.mkdirSync(derivedDataPath, { recursive: true }); + fs.writeFileSync( + 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(); +}; + +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 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,13 +463,15 @@ 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; } } - return fs.existsSync(getXCTestAgentBuildProductsPath(target)); + return fs.existsSync( + getXCTestAgentBuildProductsPath(getXCTestAgentDerivedDataPath(target)) + ); }; const getDefaultRuntimeConfiguration = (): XCTestAgentRuntimeConfiguration => { @@ -410,7 +643,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 +684,11 @@ 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 preparedDerivedDataPath = getXCTestAgentDerivedDataPath(target); let prepared = false; let agentProcess: Subprocess | null = null; let agentClient: ReturnType | null = null; @@ -458,8 +699,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 +736,30 @@ export const createXCTestAgentController = (options: { ); assertXCTestAgentProjectExists(); - if (shouldReuseBuildArtifacts(target, buildInputsHash)) { + if (target.kind === 'simulator') { + const cacheContext = await getSimulatorCacheContext( + target, + buildInputsHash + ); + const reusableDerivedDataPath = + findReusableSimulatorBuildArtifacts(cacheContext); + + if (reusableDerivedDataPath) { + preparedDerivedDataPath = reusableDerivedDataPath; + 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; + } + + preparedDerivedDataPath = getSimulatorCacheDerivedDataPath(cacheContext); + } else if (shouldReuseBuildArtifacts(target, buildInputsHash)) { prepared = true; xctestAgentLogger.info( 'Reusing cached XCTest agent build for %s target', @@ -508,7 +772,7 @@ export const createXCTestAgentController = (options: { return; } - fs.mkdirSync(getXCTestAgentBuildRoot(), { 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); @@ -521,16 +785,42 @@ export const createXCTestAgentController = (options: { '-destination', getXCTestAgentBuildDestination(target), '-derivedDataPath', - getXCTestAgentDerivedDataPath(target), + preparedDerivedDataPath, ...(target.kind === 'device' ? ['-allowProvisioningUpdates'] : []), ...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( + preparedDerivedDataPath + ); + + if (!xctestrunRelativePath) { + throw new Error( + `Missing generated .xctestrun file in ${getXCTestAgentBuildProductsPath( + preparedDerivedDataPath + )}` + ); + } + + writeSimulatorBuildManifest(preparedDerivedDataPath, { + 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; }; @@ -564,28 +854,27 @@ export const createXCTestAgentController = (options: { '-maximum-parallel-testing-workers', '1', '-derivedDataPath', - getXCTestAgentDerivedDataPath(target), + preparedDerivedDataPath, ]; - 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('--');