From 73713ab7d62a91af9f55e972fe73a7c66815ed08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 3 Jun 2026 17:16:09 -0700 Subject: [PATCH] feat: cache ios runner artifacts during prepare --- .github/actions/setup-apple-replay/action.yml | 6 +- .github/workflows/ios.yml | 3 +- .github/workflows/replays-nightly.yml | 3 +- scripts/write-xcuitest-cache-metadata.mjs | 70 ++++- src/daemon/handlers/__tests__/session.test.ts | 31 ++- src/daemon/handlers/session.ts | 26 +- .../ios/__tests__/runner-client.test.ts | 53 ++++ .../__tests__/runner-command-retry.test.ts | 153 ++++++++++- .../ios/__tests__/runner-session.test.ts | 15 +- .../ios/__tests__/runner-xctestrun.test.ts | 70 ++++- src/platforms/ios/runner-client.ts | 190 ++++++++++++++ src/platforms/ios/runner-session-types.ts | 2 + src/platforms/ios/runner-session.ts | 12 +- src/platforms/ios/runner-xctestrun.ts | 240 ++++++++++++++++-- 14 files changed, 806 insertions(+), 68 deletions(-) diff --git a/.github/actions/setup-apple-replay/action.yml b/.github/actions/setup-apple-replay/action.yml index d200bc0ba..c82373032 100644 --- a/.github/actions/setup-apple-replay/action.yml +++ b/.github/actions/setup-apple-replay/action.yml @@ -27,6 +27,10 @@ inputs: description: "Optional AGENT_DEVICE_IOS_CLEAN_DERIVED value" required: false default: "" + build-on-miss: + description: "Whether this setup action should build replay artifacts on cache miss" + required: false + default: "true" outputs: agent-home-dir: @@ -68,7 +72,7 @@ runs: shell: bash - name: Build replay artifacts - if: steps.restore-prebuilt.outputs.cache-hit != 'true' + if: inputs.build-on-miss == 'true' && steps.restore-prebuilt.outputs.cache-hit != 'true' run: ${{ inputs.build-command }} shell: bash env: diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index daad3f9bc..287565782 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -47,6 +47,7 @@ jobs: xcuitest-platform: ios xcuitest-destination: generic/platform=iOS Simulator clean-derived: "1" + build-on-miss: "false" - name: Boot iOS test simulator uses: ./.github/actions/boot-ios-test-simulator @@ -57,7 +58,7 @@ jobs: - name: Prepare iOS runner run: | pnpm clean:daemon - node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout 240000 + node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout 300000 --json - name: Run iOS simulator smoke replay run: | diff --git a/.github/workflows/replays-nightly.yml b/.github/workflows/replays-nightly.yml index fe2c89f0d..663388587 100644 --- a/.github/workflows/replays-nightly.yml +++ b/.github/workflows/replays-nightly.yml @@ -70,6 +70,7 @@ jobs: xcuitest-platform: ios xcuitest-destination: generic/platform=iOS Simulator clean-derived: "1" + build-on-miss: "false" - name: Boot iOS test simulator uses: ./.github/actions/boot-ios-test-simulator @@ -80,7 +81,7 @@ jobs: - name: Prepare iOS runner run: | pnpm clean:daemon - node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout 240000 + node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout 300000 --json - name: Run iOS simulator replay suite run: node --experimental-strip-types src/bin.ts test test/integration/replays/ios/simulator --retries 2 --report-junit test/artifacts/replays-ios-simulator.junit.xml diff --git a/scripts/write-xcuitest-cache-metadata.mjs b/scripts/write-xcuitest-cache-metadata.mjs index c0bfe8c79..98e994891 100644 --- a/scripts/write-xcuitest-cache-metadata.mjs +++ b/scripts/write-xcuitest-cache-metadata.mjs @@ -131,6 +131,57 @@ function resolveBuildDestinationFamily() { return `generic/platform=${platformName}`; } +function resolveRunnerSdkName() { + const platformName = resolvePlatformName(); + if (platformName === 'macOS') return 'macosx'; + if (platformName === 'tvOS') { + return resolveDeviceKind() === 'simulator' ? 'appletvsimulator' : 'appletvos'; + } + return resolveDeviceKind() === 'simulator' ? 'iphonesimulator' : 'iphoneos'; +} + +function runAppleToolFingerprintCommand(command, args) { + try { + return execFileSync(command, args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5000, + maxBuffer: 128 * 1024, + }).trim() || 'unknown'; + } catch { + return 'unknown'; + } +} + +function parseXcodeVersionOutput(output) { + return { + version: output.match(/^Xcode\s+(.+)$/m)?.[1]?.trim() || 'unknown', + buildVersion: output.match(/^Build version\s+(.+)$/m)?.[1]?.trim() || 'unknown', + }; +} + +function resolveRunnerToolchainFingerprint() { + const xcode = parseXcodeVersionOutput( + runAppleToolFingerprintCommand('xcodebuild', ['-version']), + ); + const sdkName = resolveRunnerSdkName(); + return { + xcodeVersion: xcode.version, + xcodeBuildVersion: xcode.buildVersion, + sdkName, + sdkVersion: runAppleToolFingerprintCommand('xcrun', [ + '--sdk', + sdkName, + '--show-sdk-version', + ]), + sdkBuildVersion: runAppleToolFingerprintCommand('xcrun', [ + '--sdk', + sdkName, + '--show-sdk-build-version', + ]), + }; +} + function resolveSigningBuildSettings() { if (platform !== 'macos') { return []; @@ -149,6 +200,7 @@ const metadata = { schemaVersion: 1, packageVersion: readPackageVersion(), runnerSourceFingerprint: computeRunnerSourceFingerprint(), + ...resolveRunnerToolchainFingerprint(), platformName: resolvePlatformName(), deviceKind: resolveDeviceKind(), target: resolveTarget(), @@ -175,14 +227,16 @@ function resolveRunnerCacheArtifacts() { const productPaths = resolveExistingXctestrunProductPaths(xctestrunPath); if (!productPaths || productPaths.length === 0) return null; const xctestrunMtimeMs = readFileMtimeMs(xctestrunPath); - if (xctestrunMtimeMs === null) return null; + const xctestrunSize = readFileSize(xctestrunPath); + if (xctestrunMtimeMs === null || xctestrunSize === null) return null; const productArtifacts = []; for (const productPath of productPaths) { const mtimeMs = readFileMtimeMs(productPath); - if (mtimeMs === null) return null; - productArtifacts.push({ path: productPath, mtimeMs }); + const size = readFileSize(productPath); + if (mtimeMs === null || size === null) return null; + productArtifacts.push({ path: productPath, mtimeMs, size }); } - return { xctestrunPath, xctestrunMtimeMs, productPaths: productArtifacts }; + return { xctestrunPath, xctestrunMtimeMs, xctestrunSize, productPaths: productArtifacts }; } function findXctestrun(root) { @@ -378,6 +432,14 @@ function readFileMtimeMs(filePath) { } } +function readFileSize(filePath) { + try { + return fs.statSync(filePath).size; + } catch { + return null; + } +} + function isRecord(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index 9d13c9355..94763e561 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -17,8 +17,12 @@ vi.mock('../../../platforms/ios/runner-client.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + prepareIosRunner: vi.fn(async () => ({ + runner: { currentUptimeMs: 42 }, + connectMs: 3, + healthCheckMs: 3, + })), prewarmIosRunnerSession: vi.fn(), - runIosRunnerCommand: vi.fn(async () => ({ currentUptimeMs: 42 })), stopIosRunnerSession: vi.fn(async () => {}), }; }); @@ -96,8 +100,8 @@ import { dispatchCommand, resolveTargetDevice } from '../../../core/dispatch.ts' import { ensureDeviceReady } from '../../device-ready.ts'; import { applyRuntimeHintsToApp, clearRuntimeHintsFromApp } from '../../runtime-hints.ts'; import { + prepareIosRunner, prewarmIosRunnerSession, - runIosRunnerCommand, stopIosRunnerSession, } from '../../../platforms/ios/runner-client.ts'; import { runMacOsAlertAction } from '../../../platforms/ios/macos-helper.ts'; @@ -120,7 +124,7 @@ const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintsToApp); const mockClearRuntimeHints = vi.mocked(clearRuntimeHintsFromApp); const mockPrewarmIosRunnerSession = vi.mocked(prewarmIosRunnerSession); -const mockRunIosRunnerCommand = vi.mocked(runIosRunnerCommand); +const mockPrepareIosRunner = vi.mocked(prepareIosRunner); const mockStopIosRunner = vi.mocked(stopIosRunnerSession); const mockDismissMacOsAlert = vi.mocked(runMacOsAlertAction); const mockSettleSimulator = vi.mocked(settleIosSimulator); @@ -151,8 +155,12 @@ beforeEach(() => { mockClearRuntimeHints.mockReset(); mockClearRuntimeHints.mockResolvedValue(undefined); mockPrewarmIosRunnerSession.mockReset(); - mockRunIosRunnerCommand.mockReset(); - mockRunIosRunnerCommand.mockResolvedValue({ currentUptimeMs: 42 }); + mockPrepareIosRunner.mockReset(); + mockPrepareIosRunner.mockResolvedValue({ + runner: { currentUptimeMs: 42 }, + connectMs: 3, + healthCheckMs: 3, + }); mockStopIosRunner.mockReset(); mockStopIosRunner.mockResolvedValue(undefined); mockDismissMacOsAlert.mockReset(); @@ -2130,11 +2138,12 @@ test('prepare ios-runner starts the XCTest runner on an explicit iOS selector', expect(mockEnsureDeviceReady).toHaveBeenCalledWith( expect.objectContaining({ platform: 'ios', id: 'sim-1' }), ); - expect(mockRunIosRunnerCommand).toHaveBeenCalledTimes(1); - expect(mockRunIosRunnerCommand).toHaveBeenCalledWith( + expect(mockPrepareIosRunner).toHaveBeenCalledTimes(1); + expect(mockPrepareIosRunner).toHaveBeenCalledWith( expect.objectContaining({ platform: 'ios', id: 'sim-1' }), - { command: 'uptime' }, expect.objectContaining({ + buildTimeoutMs: 240000, + healthTimeoutMs: 90000, logPath: expect.stringMatching(/daemon\.log$/), requestId: 'prepare-request', }), @@ -2145,6 +2154,8 @@ test('prepare ios-runner starts the XCTest runner on an explicit iOS selector', deviceId: 'sim-1', deviceName: 'iPhone 17 Pro', kind: 'simulator', + connectMs: 3, + healthCheckMs: 3, runner: { currentUptimeMs: 42 }, message: 'Prepared iOS runner: iPhone 17 Pro', }); @@ -2181,7 +2192,7 @@ test('prepare ios-runner rejects non-iOS devices', async () => { expect(response.error.code).toBe('UNSUPPORTED_OPERATION'); expect(response.error.message).toBe('prepare ios-runner is only supported on iOS'); } - expect(mockRunIosRunnerCommand).not.toHaveBeenCalled(); + expect(mockPrepareIosRunner).not.toHaveBeenCalled(); }); test('prepare requires the ios-runner subcommand', async () => { @@ -2208,7 +2219,7 @@ test('prepare requires the ios-runner subcommand', async () => { expect(response.error.message).toBe('prepare requires a subcommand: ios-runner'); } expect(mockResolveTargetDevice).not.toHaveBeenCalled(); - expect(mockRunIosRunnerCommand).not.toHaveBeenCalled(); + expect(mockPrepareIosRunner).not.toHaveBeenCalled(); }); test('open web URL on iOS device session without active app falls back to Safari', async () => { diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index f58632ae7..bb0f02603 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -7,7 +7,10 @@ import { } from '../../command-catalog.ts'; import { resolvePayloadInput } from '../../utils/payload-input.ts'; import type { AndroidAdbExecutor } from '../../platforms/android/adb-executor.ts'; -import { runIosRunnerCommand } from '../../platforms/ios/runner-client.ts'; +import { + prepareIosRunner, + type PrepareIosRunnerResult, +} from '../../platforms/ios/runner-client.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import { normalizePlatformSelector } from '../../utils/device.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; @@ -41,6 +44,8 @@ const INVENTORY_COMMANDS = DAEMON_COMMAND_GROUPS.inventory; const STATE_COMMANDS = DAEMON_COMMAND_GROUPS.state; const OBSERVABILITY_COMMANDS = DAEMON_COMMAND_GROUPS.observability; const REPLAY_COMMANDS = DAEMON_COMMAND_GROUPS.replay; +const PREPARE_IOS_RUNNER_DEFAULT_BUILD_TIMEOUT_MS = 5 * 60_000; +const PREPARE_IOS_RUNNER_HEALTH_TIMEOUT_MS = 90_000; export const SESSION_COMMAND_HANDLERS = { ...Object.fromEntries([...INVENTORY_COMMANDS].map((command) => [command, true] as const)), @@ -89,9 +94,8 @@ async function handlePrepareCommand(params: { } const startedAtMs = Date.now(); - const result = await runIosRunnerCommand( + const result = await prepareIosRunner( device, - { command: 'uptime' }, buildPrepareIosRunnerOptions(req, session, logPath), ); const durationMs = Math.max(0, Date.now() - startedAtMs); @@ -105,20 +109,30 @@ function buildPrepareIosRunnerOptions( req: DaemonRequest, session: SessionState | undefined, logPath: string, -): Parameters[2] { +): Parameters[1] { + const buildTimeoutMs = readPrepareIosRunnerBuildTimeoutMs(req); return { verbose: req.flags?.verbose, logPath, traceLogPath: session?.trace?.outPath, requestId: req.meta?.requestId, + buildTimeoutMs, + healthTimeoutMs: Math.min(buildTimeoutMs, PREPARE_IOS_RUNNER_HEALTH_TIMEOUT_MS), }; } +function readPrepareIosRunnerBuildTimeoutMs(req: DaemonRequest): number { + const value = req.flags?.timeoutMs; + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? value + : PREPARE_IOS_RUNNER_DEFAULT_BUILD_TIMEOUT_MS; +} + function prepareIosRunnerResponseData( action: string, device: DeviceInfo, durationMs: number, - runner: Awaited>, + result: PrepareIosRunnerResult, ): Record { return { action, @@ -127,7 +141,7 @@ function prepareIosRunnerResponseData( deviceName: device.name, kind: device.kind, durationMs, - runner, + ...result, message: `Prepared iOS runner: ${device.name}`, }; } diff --git a/src/platforms/ios/__tests__/runner-client.test.ts b/src/platforms/ios/__tests__/runner-client.test.ts index c26126ef6..aa29935af 100644 --- a/src/platforms/ios/__tests__/runner-client.test.ts +++ b/src/platforms/ios/__tests__/runner-client.test.ts @@ -48,7 +48,9 @@ import { } from '../runner-client.ts'; import { acquireRunnerXctestrunCacheLock, + ensureXctestrunArtifact, ensureXctestrun, + markRunnerXctestrunArtifactBadForRun, resolveExpectedRunnerCacheMetadata, resolveRunnerDerivedPath, resolveRunnerCacheMetadataPath, @@ -1132,6 +1134,57 @@ test('ensureXctestrun rebuilds cached runner when metadata package version misma assert.equal(rebuiltMetadata.artifacts?.xctestrunPath, rebuiltXctestrunPath); }); +test('ensureXctestrunArtifact stress-recovers after a bad restored artifact', async () => { + const projectRoot = repoRoot; + const tmpDir = await makeProjectTmpDir(); + const derivedPath = path.join(tmpDir, 'custom-derived'); + const productPath = path.join(derivedPath, 'Runner.app'); + const cachedXctestrunPath = path.join(derivedPath, 'cached.xctestrun'); + await fs.promises.mkdir(productPath, { recursive: true }); + writeXctestrunFixture(cachedXctestrunPath, { + projectRoot, + productRelativePaths: ['Runner.app'], + }); + writeRunnerCacheMetadataWithArtifacts({ + derivedPath, + device: macOsDevice, + xctestrunPath: cachedXctestrunPath, + productPaths: [productPath], + }); + withRunnerDerivedPathEnv(derivedPath); + + const hit = await ensureXctestrunArtifact(macOsDevice, {}); + + assert.equal(hit.xctestrunPath, cachedXctestrunPath); + assert.equal(hit.cache, 'exact'); + assert.equal(hit.artifact, 'valid'); + assert.equal(hit.buildMs, 0); + assert.equal(mockRunCmdStreaming.mock.calls.length, 0); + + await markRunnerXctestrunArtifactBadForRun(hit, 'stress health failed'); + assert.equal(fs.existsSync(cachedXctestrunPath), false); + + const rebuiltXctestrunPath = path.join(derivedPath, 'rebuilt', 'rebuilt.xctestrun'); + mockRunCmdStreaming.mockImplementationOnce(async () => { + await fs.promises.mkdir(path.join(derivedPath, 'rebuilt', 'Runner.app'), { recursive: true }); + writeXctestrunFixture(rebuiltXctestrunPath, { + projectRoot, + productRelativePaths: ['Runner.app'], + }); + }); + + const rebuilt = await ensureXctestrunArtifact(macOsDevice, { + buildTimeoutMs: 300_000, + }); + + assert.equal(rebuilt.xctestrunPath, rebuiltXctestrunPath); + assert.equal(rebuilt.cache, 'miss'); + assert.equal(rebuilt.artifact, 'rebuilt'); + assert.equal(rebuilt.reason, 'missing_xctestrun'); + assert.equal(mockRunCmdStreaming.mock.calls.length, 1); + assert.equal(mockRunCmdStreaming.mock.calls[0]?.[2]?.timeoutMs, 300_000); +}); + test('ensureXctestrun rethrows unexpected cached macOS runner repair errors', async () => { const { derivedPath, existingXctestrunPath } = await makeCachedRunnerXctestrun(); diff --git a/src/platforms/ios/__tests__/runner-command-retry.test.ts b/src/platforms/ios/__tests__/runner-command-retry.test.ts index a377dc6f7..82ef304a4 100644 --- a/src/platforms/ios/__tests__/runner-command-retry.test.ts +++ b/src/platforms/ios/__tests__/runner-command-retry.test.ts @@ -9,12 +9,14 @@ const { mockExecuteRunnerCommandWithSession, mockEmitDiagnostic, mockInvalidateRunnerSession, + mockMarkRunnerXctestrunArtifactBadForRun, mockStopRunnerSession, } = vi.hoisted(() => ({ mockEnsureRunnerSession: vi.fn(), mockExecuteRunnerCommandWithSession: vi.fn(), mockEmitDiagnostic: vi.fn(), mockInvalidateRunnerSession: vi.fn(), + mockMarkRunnerXctestrunArtifactBadForRun: vi.fn(), mockStopRunnerSession: vi.fn(), })); @@ -40,10 +42,145 @@ vi.mock('../runner-session.ts', async () => { }; }); -import { runIosRunnerCommand } from '../runner-client.ts'; +vi.mock('../runner-xctestrun.ts', async () => { + const actual = + await vi.importActual('../runner-xctestrun.ts'); + return { + ...actual, + markRunnerXctestrunArtifactBadForRun: mockMarkRunnerXctestrunArtifactBadForRun, + }; +}); + +import { prepareIosRunner, runIosRunnerCommand } from '../runner-client.ts'; +import type { RunnerXctestrunArtifact } from '../runner-xctestrun.ts'; beforeEach(() => { vi.resetAllMocks(); + mockMarkRunnerXctestrunArtifactBadForRun.mockResolvedValue(undefined); +}); + +test('prepareIosRunner marks a bad restored artifact and rebuilds once after health failure', async () => { + const restoredArtifact = makeRunnerArtifact({ + xctestrunPath: '/tmp/restored.xctestrun', + cache: 'exact', + artifact: 'valid', + }); + const rebuiltArtifact = makeRunnerArtifact({ + xctestrunPath: '/tmp/rebuilt.xctestrun', + cache: 'miss', + artifact: 'rebuilt', + buildMs: 123, + }); + const restoredSession = makeRunnerSession({ + port: 8100, + xctestrunPath: restoredArtifact.xctestrunPath, + xctestrunArtifact: restoredArtifact, + }); + const rebuiltSession = makeRunnerSession({ + port: 8101, + xctestrunPath: rebuiltArtifact.xctestrunPath, + xctestrunArtifact: rebuiltArtifact, + }); + + mockEnsureRunnerSession + .mockResolvedValueOnce(restoredSession) + .mockResolvedValueOnce(rebuiltSession); + mockExecuteRunnerCommandWithSession + .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection')) + .mockResolvedValueOnce({ uptimeMs: 42 }); + + const result = await prepareIosRunner(IOS_SIMULATOR, { + healthTimeoutMs: 90_000, + buildTimeoutMs: 300_000, + }); + + assert.deepEqual(result, { + runner: { uptimeMs: 42 }, + cache: 'miss', + artifact: 'rebuilt', + buildMs: 123, + connectMs: result.connectMs, + healthCheckMs: result.healthCheckMs, + xctestrunPath: '/tmp/rebuilt.xctestrun', + failureReason: 'Runner did not accept connection', + }); + assert.equal(result.connectMs >= 0, true); + assert.equal(result.healthCheckMs >= 0, true); + assert.deepEqual(mockInvalidateRunnerSession.mock.calls[0], [ + restoredSession, + 'prepare_cached_runner_health_failed', + ]); + assert.deepEqual(mockMarkRunnerXctestrunArtifactBadForRun.mock.calls[0], [ + restoredArtifact, + 'Runner did not accept connection', + ]); + assert.deepEqual(mockEnsureRunnerSession.mock.calls[1]?.[1], { + healthTimeoutMs: 90_000, + buildTimeoutMs: 300_000, + cleanStaleBundles: true, + forceRunnerXctestrunRebuild: true, + }); + assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2); + assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[0]?.[2].command, 'uptime'); + assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[0]?.[4], 90_000); + assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[1]?.[1], rebuiltSession); + assert.ok( + mockEmitDiagnostic.mock.calls.some( + ([event]) => event.phase === 'ios_runner_prepare_bad_cache_recovered', + ), + ); +}); + +test('prepareIosRunner invalidates rebuilt sessions when bad-cache recovery health fails', async () => { + const restoredArtifact = makeRunnerArtifact({ + xctestrunPath: '/tmp/restored.xctestrun', + cache: 'restore-key', + artifact: 'valid', + }); + const rebuiltArtifact = makeRunnerArtifact({ + xctestrunPath: '/tmp/rebuilt.xctestrun', + cache: 'miss', + artifact: 'rebuilt', + }); + const restoredSession = makeRunnerSession({ + port: 8100, + xctestrunPath: restoredArtifact.xctestrunPath, + xctestrunArtifact: restoredArtifact, + }); + const rebuiltSession = makeRunnerSession({ + port: 8101, + xctestrunPath: rebuiltArtifact.xctestrunPath, + xctestrunArtifact: rebuiltArtifact, + }); + + mockEnsureRunnerSession + .mockResolvedValueOnce(restoredSession) + .mockResolvedValueOnce(rebuiltSession); + mockExecuteRunnerCommandWithSession + .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner endpoint probe failed')) + .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner health timed out')); + + await assert.rejects( + () => prepareIosRunner(IOS_SIMULATOR, { healthTimeoutMs: 90_000 }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.message, 'artifact restored but runner did not connect'); + assert.equal(error.details?.restoredFailureReason, 'Runner endpoint probe failed'); + assert.equal(error.details?.xctestrunPath, '/tmp/rebuilt.xctestrun'); + assert.equal(error.details?.artifact, 'rebuilt'); + assert.equal(error.details?.cache, 'miss'); + return true; + }, + ); + + assert.deepEqual(mockInvalidateRunnerSession.mock.calls, [ + [restoredSession, 'prepare_cached_runner_health_failed'], + [rebuiltSession, 'prepare_rebuilt_runner_health_failed'], + ]); + assert.deepEqual(mockMarkRunnerXctestrunArtifactBadForRun.mock.calls[0], [ + restoredArtifact, + 'Runner endpoint probe failed', + ]); }); test('mutating commands restart stale ready sessions when the preflight probe never reaches the runner', async () => { @@ -584,6 +721,20 @@ function makeRunnerSession(overrides: Partial = {}): RunnerSessio } as RunnerSession; } +function makeRunnerArtifact( + overrides: Partial = {}, +): RunnerXctestrunArtifact { + return { + xctestrunPath: '/tmp/runner.xctestrun', + derived: '/tmp/derived', + cache: 'exact', + artifact: 'valid', + buildMs: 0, + xctestrunPathSource: 'manifest', + ...overrides, + }; +} + async function captureDiagnostics(callback: () => Promise): Promise { await callback(); return JSON.stringify(mockEmitDiagnostic.mock.calls.map(([event]) => event)); diff --git a/src/platforms/ios/__tests__/runner-session.test.ts b/src/platforms/ios/__tests__/runner-session.test.ts index c5de0685e..db23bc12f 100644 --- a/src/platforms/ios/__tests__/runner-session.test.ts +++ b/src/platforms/ios/__tests__/runner-session.test.ts @@ -12,7 +12,7 @@ import type { RunnerSession } from '../runner-session-types.ts'; const { mockAcquireXcodebuildSimulatorSetRedirect, mockCleanupTempFile, - mockEnsureXctestrun, + mockEnsureXctestrunArtifact, mockGetFreePort, mockIsProcessAlive, mockIsProcessGroupAlive, @@ -26,7 +26,7 @@ const { } = vi.hoisted(() => ({ mockAcquireXcodebuildSimulatorSetRedirect: vi.fn(), mockCleanupTempFile: vi.fn(), - mockEnsureXctestrun: vi.fn(), + mockEnsureXctestrunArtifact: vi.fn(), mockGetFreePort: vi.fn(), mockIsProcessAlive: vi.fn(), mockIsProcessGroupAlive: vi.fn(), @@ -86,7 +86,7 @@ vi.mock('../runner-xctestrun.ts', async () => { return { ...actual, acquireXcodebuildSimulatorSetRedirect: mockAcquireXcodebuildSimulatorSetRedirect, - ensureXctestrun: mockEnsureXctestrun, + ensureXctestrunArtifact: mockEnsureXctestrunArtifact, prepareXctestrunWithEnv: mockPrepareXctestrunWithEnv, }; }); @@ -103,7 +103,14 @@ import { beforeEach(() => { vi.resetAllMocks(); mockRunXcrun.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); - mockEnsureXctestrun.mockResolvedValue('/tmp/base-runner.xctestrun'); + mockEnsureXctestrunArtifact.mockResolvedValue({ + xctestrunPath: '/tmp/base-runner.xctestrun', + derived: '/tmp/derived', + cache: 'miss', + artifact: 'rebuilt', + buildMs: 12, + xctestrunPathSource: 'build', + }); mockGetFreePort.mockResolvedValue(8123); mockPrepareXctestrunWithEnv.mockResolvedValue({ xctestrunPath: '/tmp/session-runner.xctestrun', diff --git a/src/platforms/ios/__tests__/runner-xctestrun.test.ts b/src/platforms/ios/__tests__/runner-xctestrun.test.ts index a5481db96..33912c23d 100644 --- a/src/platforms/ios/__tests__/runner-xctestrun.test.ts +++ b/src/platforms/ios/__tests__/runner-xctestrun.test.ts @@ -8,6 +8,7 @@ import path from 'node:path'; import type { DeviceInfo } from '../../../utils/device.ts'; import { withCommandExecutorOverride } from '../../../utils/exec.ts'; import { + __resetRunnerToolchainFingerprintCacheForTests, acquireXcodebuildSimulatorSetRedirect, findXctestrun, prepareXctestrunWithEnv, @@ -198,23 +199,70 @@ test('scoreXctestrunCandidate penalizes macos and env xctestrun files for simula test('setup metadata script matches expected iOS simulator cache metadata', async () => { await withTempDir('runner-cache-metadata-', async (root) => { - execFileSync( - process.execPath, - ['scripts/write-xcuitest-cache-metadata.mjs', 'ios', root, 'generic/platform=iOS Simulator'], - { cwd: process.cwd(), stdio: ['ignore', 'ignore', 'inherit'] }, + const binDir = path.join(root, 'bin'); + fs.mkdirSync(binDir); + writeExecutable( + path.join(binDir, 'xcodebuild'), + ['#!/bin/sh', 'printf "Xcode 26.2\\nBuild version 17C52\\n"'].join('\n'), ); - - const actual = JSON.parse( - fs.readFileSync(path.join(root, '.agent-device-runner-cache.json'), 'utf8'), + writeExecutable( + path.join(binDir, 'xcrun'), + [ + '#!/bin/sh', + 'case "$*" in', + ' *"--show-sdk-version"*) printf "26.2\\n" ;;', + ' *"--show-sdk-build-version"*) printf "23C53\\n" ;;', + ' *) exit 1 ;;', + 'esac', + ].join('\n'), ); - const { artifacts: _actualArtifacts, ...actualComparable } = actual; - const { artifacts: _expectedArtifacts, ...expectedComparable } = - resolveExpectedRunnerCacheMetadata(iosSimulator); + const previousPath = process.env.PATH; + process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ''}`; + __resetRunnerToolchainFingerprintCacheForTests(); + + try { + execFileSync( + process.execPath, + [ + 'scripts/write-xcuitest-cache-metadata.mjs', + 'ios', + root, + 'generic/platform=iOS Simulator', + ], + { + cwd: process.cwd(), + env: { ...process.env, PATH: process.env.PATH }, + stdio: ['ignore', 'ignore', 'inherit'], + }, + ); - assert.deepEqual(actualComparable, expectedComparable); + const actual = JSON.parse( + fs.readFileSync(path.join(root, '.agent-device-runner-cache.json'), 'utf8'), + ); + const { artifacts: _actualArtifacts, ...actualComparable } = actual; + const { artifacts: _expectedArtifacts, ...expectedComparable } = + resolveExpectedRunnerCacheMetadata(iosSimulator); + + assert.deepEqual(actualComparable, expectedComparable); + } finally { + __resetRunnerToolchainFingerprintCacheForTests(); + restoreEnvVar('PATH', previousPath); + } }); }); +function writeExecutable(filePath: string, contents: string): void { + fs.writeFileSync(filePath, `${contents}\n`, { mode: 0o755 }); +} + +function restoreEnvVar(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + return; + } + process.env[name] = value; +} + test('prepareXctestrunWithEnv avoids XCTest screen recordings for nested and legacy targets', async () => { await withTempDir('runner-xctestrun-policy-', async (root) => { const xctestrunPath = path.join(root, 'AgentDeviceRunner.xctestrun'); diff --git a/src/platforms/ios/runner-client.ts b/src/platforms/ios/runner-client.ts index 6646dd79d..99b4bec06 100644 --- a/src/platforms/ios/runner-client.ts +++ b/src/platforms/ios/runner-client.ts @@ -27,6 +27,10 @@ import { resolveAppleRunnerProvider, type AppleRunnerCommandOptions, } from './runner-provider.ts'; +import { + markRunnerXctestrunArtifactBadForRun, + type RunnerXctestrunArtifact, +} from './runner-xctestrun.ts'; export { isRetryableRunnerError, resolveRunnerEarlyExitHint, @@ -58,6 +62,21 @@ type RunnerReadinessPreflightRecoveryDetails = { readinessPreflightSkippedAgeMs?: number; }; +export type PrepareIosRunnerOptions = RunnerSessionOptions & { + healthTimeoutMs: number; +}; + +export type PrepareIosRunnerResult = { + runner: Record; + cache?: RunnerXctestrunArtifact['cache']; + artifact?: RunnerXctestrunArtifact['artifact']; + buildMs?: number; + connectMs: number; + healthCheckMs: number; + xctestrunPath?: string; + failureReason?: string; +}; + const RUNNER_STATUS_RECOVERY_TIMEOUT_MS = 3_000; // --- Runner command execution --- @@ -124,6 +143,79 @@ export function prewarmIosRunnerSession( return prewarm; } +export async function prepareIosRunner( + device: DeviceInfo, + options: PrepareIosRunnerOptions, +): Promise { + validateRunnerDevice(device); + assertRunnerRequestActive(options.requestId); + const signal = getRequestSignal(options.requestId); + const command = withRunnerCommandId({ command: 'uptime' }); + if (hasScopedAppleRunnerProvider(device, { requestId: options.requestId })) { + const provider = resolveAppleRunnerProvider( + device, + createLocalAppleRunnerProvider(executeRunnerCommand), + undefined, + { requestId: options.requestId }, + ); + const healthStartedAt = Date.now(); + const runner = await provider.runCommand(device, command, options); + return { + runner, + connectMs: 0, + healthCheckMs: Math.max(0, Date.now() - healthStartedAt), + }; + } + let session: RunnerSession | undefined; + try { + const connectStartedAt = Date.now(); + session = await ensureRunnerSession(device, options); + const connectMs = Date.now() - connectStartedAt; + return await runPrepareHealthCheck(device, session, command, options, signal, connectMs); + } catch (err) { + const appErr = err instanceof AppError ? err : new AppError('COMMAND_FAILED', String(err)); + if (!session || !shouldRecoverBadCachedRunnerArtifact(appErr, session)) { + throw err; + } + const reason = appErr.message || 'runner_health_failed'; + await invalidateRunnerSession(session, 'prepare_cached_runner_health_failed'); + await markRunnerXctestrunArtifactBadForRun(session.xctestrunArtifact, reason); + const connectStartedAt = Date.now(); + const rebuiltSession = await ensureRunnerSession(device, { + ...options, + cleanStaleBundles: true, + forceRunnerXctestrunRebuild: true, + }); + const connectMs = Date.now() - connectStartedAt; + try { + const recovered = await runPrepareHealthCheck( + device, + rebuiltSession, + command, + options, + signal, + connectMs, + reason, + ); + emitDiagnostic({ + level: 'info', + phase: 'ios_runner_prepare_bad_cache_recovered', + data: { + command: command.command, + commandId: command.commandId, + sessionId: rebuiltSession.sessionId, + xctestrunPath: rebuiltSession.xctestrunArtifact?.xctestrunPath, + reason, + }, + }); + return recovered; + } catch (retryErr) { + await invalidateRunnerSession(rebuiltSession, 'prepare_rebuilt_runner_health_failed'); + throw wrapPrepareHealthFailure(retryErr, rebuiltSession, reason); + } + } +} + // fallow-ignore-next-line complexity async function executeRunnerCommand( device: DeviceInfo, @@ -248,6 +340,104 @@ async function executeRunnerCommand( } } +async function runPrepareHealthCheck( + device: DeviceInfo, + session: RunnerSession, + command: RunnerCommand, + options: PrepareIosRunnerOptions, + signal: AbortSignal | undefined, + connectMs: number, + failureReason?: string, +): Promise { + const healthStartedAt = Date.now(); + const runner = await executeRunnerCommandWithSession( + device, + session, + command, + options.logPath, + options.healthTimeoutMs, + signal, + ); + return buildPrepareIosRunnerResult( + runner, + session, + connectMs, + Date.now() - healthStartedAt, + failureReason, + ); +} + +function shouldRecoverBadCachedRunnerArtifact( + error: AppError, + session: RunnerSession, +): session is RunnerSession & { + xctestrunArtifact: NonNullable; +} { + const artifact = session.xctestrunArtifact; + if (!artifact || artifact.cache === 'miss') return false; + return ( + isRetryableRunnerError(error) || + shouldRetryRunnerConnectError(error) || + isPrepareHealthTimeout(error) + ); +} + +function isPrepareHealthTimeout(error: AppError): boolean { + const message = error.message.toLowerCase(); + return ( + message.includes('timeout') || message.includes('timed out') || message.includes('deadline') + ); +} + +function wrapPrepareHealthFailure( + error: unknown, + session: RunnerSession, + restoredFailureReason: string, +): AppError { + const appErr = error instanceof AppError ? error : new AppError('COMMAND_FAILED', String(error)); + return new AppError( + appErr.code, + 'artifact restored but runner did not connect', + { + ...(appErr.details ?? {}), + restoredFailureReason, + xctestrunPath: session.xctestrunArtifact?.xctestrunPath, + artifact: session.xctestrunArtifact?.artifact, + cache: session.xctestrunArtifact?.cache, + reason: appErr.message, + }, + appErr, + ); +} + +function buildPrepareIosRunnerResult( + runner: Record, + session: RunnerSession, + connectMs: number, + healthCheckMs: number, + failureReason: string | undefined, +): PrepareIosRunnerResult { + const artifact = session.xctestrunArtifact; + if (!artifact) { + return { + runner, + connectMs: Math.max(0, connectMs), + healthCheckMs: Math.max(0, healthCheckMs), + ...(failureReason ? { failureReason } : {}), + }; + } + return { + runner, + cache: artifact.cache, + artifact: artifact.artifact, + buildMs: artifact.buildMs, + connectMs: Math.max(0, connectMs), + healthCheckMs: Math.max(0, healthCheckMs), + xctestrunPath: artifact.xctestrunPath, + ...(failureReason ? { failureReason } : {}), + }; +} + async function handleRunnerTransportErrorAfterCommandSend( device: DeviceInfo, session: RunnerSession, diff --git a/src/platforms/ios/runner-session-types.ts b/src/platforms/ios/runner-session-types.ts index 5bd6cf1fd..dc779579b 100644 --- a/src/platforms/ios/runner-session-types.ts +++ b/src/platforms/ios/runner-session-types.ts @@ -1,5 +1,6 @@ import type { ExecResult, ExecBackgroundResult } from '../../utils/exec.ts'; import type { DeviceInfo } from '../../utils/device.ts'; +import type { RunnerXctestrunArtifact } from './runner-xctestrun.ts'; export type RunnerSession = { sessionId: string; @@ -7,6 +8,7 @@ export type RunnerSession = { deviceId: string; port: number; xctestrunPath: string; + xctestrunArtifact?: RunnerXctestrunArtifact; jsonPath: string; testPromise: Promise; child: ExecBackgroundResult['child']; diff --git a/src/platforms/ios/runner-session.ts b/src/platforms/ios/runner-session.ts index 84099e0d3..f1bba0ad4 100644 --- a/src/platforms/ios/runner-session.ts +++ b/src/platforms/ios/runner-session.ts @@ -18,7 +18,7 @@ import { } from './runner-transport.ts'; import { acquireXcodebuildSimulatorSetRedirect, - ensureXctestrun, + ensureXctestrunArtifact, IOS_RUNNER_CONTAINER_BUNDLE_IDS, prepareXctestrunWithEnv, resolveRunnerDestination, @@ -40,6 +40,8 @@ export type RunnerSessionOptions = { traceLogPath?: string; cleanStaleBundles?: boolean; requestId?: string; + buildTimeoutMs?: number; + forceRunnerXctestrunRebuild?: boolean; }; const runnerSessions = new Map(); @@ -109,11 +111,12 @@ export async function ensureRunnerSession( phase: 'ios_runner_startup_cleanup_stale_bundles_skipped', }); } - const xctestrun = await measureRunnerStartupStep( + const xctestrunArtifact = await measureRunnerStartupStep( startupTimings, 'ensure_xctestrun', - async () => await ensureXctestrun(device, options), + async () => await ensureXctestrunArtifact(device, options), ); + startupTimings.build_xctestrun = xctestrunArtifact.buildMs; const port = await measureRunnerStartupStep( startupTimings, 'allocate_port', @@ -124,7 +127,7 @@ export async function ensureRunnerSession( 'prepare_xctestrun_env', async () => await prepareXctestrunWithEnv( - xctestrun, + xctestrunArtifact.xctestrunPath, { AGENT_DEVICE_RUNNER_PORT: String(port) }, `session-${device.id}-${port}`, ), @@ -186,6 +189,7 @@ export async function ensureRunnerSession( deviceId: device.id, port, xctestrunPath, + xctestrunArtifact, jsonPath, testPromise, child, diff --git a/src/platforms/ios/runner-xctestrun.ts b/src/platforms/ios/runner-xctestrun.ts index a4d745736..15cafd965 100644 --- a/src/platforms/ios/runner-xctestrun.ts +++ b/src/platforms/ios/runner-xctestrun.ts @@ -4,7 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import { AppError } from '../../utils/errors.ts'; import { sleep } from '../../utils/timeouts.ts'; -import { runCmdStreaming, type ExecBackgroundResult } from '../../utils/exec.ts'; +import { runCmdStreaming, runCmdSync, type ExecBackgroundResult } from '../../utils/exec.ts'; import { resolveIosSimulatorDeviceSetPath } from '../../utils/device-isolation.ts'; import { isProcessAlive, readProcessStartTime } from '../../utils/process-identity.ts'; import { isEnvTruthy } from '../../utils/retry.ts'; @@ -41,6 +41,8 @@ const RUNNER_XCTESTRUN_CAPTURE_OPTIONS = { } as const; const runnerXctestrunBuildLocks = new Map>(); +const badRunnerArtifactsForRun = new Set(); +const appleToolFingerprintCache = new Map(); export const runnerPrepProcesses = new Set(); type EnvMap = Record; @@ -87,6 +89,11 @@ export type RunnerXctestrunCacheMetadata = { schemaVersion: number; packageVersion: string; runnerSourceFingerprint: string; + xcodeVersion: string; + xcodeBuildVersion: string; + sdkName: string; + sdkVersion: string; + sdkBuildVersion: string; platformName: string; deviceKind: DeviceInfo['kind']; target: NonNullable; @@ -97,6 +104,16 @@ export type RunnerXctestrunCacheMetadata = { artifacts?: RunnerXctestrunCacheArtifacts; }; +export type RunnerXctestrunArtifact = { + xctestrunPath: string; + derived: string; + cache: 'exact' | 'restore-key' | 'miss'; + artifact: 'valid' | 'rebuilt'; + buildMs: number; + xctestrunPathSource: 'manifest' | 'scan' | 'build'; + reason?: string; +}; + type RunnerXctestrunCacheArtifacts = { xctestrunPath: string; xctestrunMtimeMs: number; @@ -500,8 +517,21 @@ function isLiveXcodebuildSimulatorSetLockOwner(owner: XcodebuildSimulatorSetLock export async function ensureXctestrun( device: DeviceInfo, - options: { verbose?: boolean; logPath?: string; traceLogPath?: string }, + options: { verbose?: boolean; logPath?: string; traceLogPath?: string; buildTimeoutMs?: number }, ): Promise { + return (await ensureXctestrunArtifact(device, options)).xctestrunPath; +} + +export async function ensureXctestrunArtifact( + device: DeviceInfo, + options: { + verbose?: boolean; + logPath?: string; + traceLogPath?: string; + buildTimeoutMs?: number; + forceRunnerXctestrunRebuild?: boolean; + }, +): Promise { const projectRoot = findProjectRoot(); const expectedCacheMetadata = resolveExpectedRunnerCacheMetadata(device, projectRoot); const derived = resolveRunnerDerivedPath(device, expectedCacheMetadata); @@ -514,6 +544,7 @@ export async function ensureXctestrun( projectRoot, expectedCacheMetadata, derived, + forceRebuild: options.forceRunnerXctestrunRebuild === true, }); } finally { await releaseCacheLock(); @@ -523,17 +554,14 @@ export async function ensureXctestrun( async function ensureXctestrunUnderCacheLock(params: { device: DeviceInfo; - options: { verbose?: boolean; logPath?: string; traceLogPath?: string }; + options: { verbose?: boolean; logPath?: string; traceLogPath?: string; buildTimeoutMs?: number }; projectRoot: string; expectedCacheMetadata: RunnerXctestrunCacheMetadata; derived: string; -}): Promise { + forceRebuild: boolean; +}): Promise { const { device, options, projectRoot, expectedCacheMetadata, derived } = params; - if (shouldCleanDerived()) { - emitRunnerXctestrunDecision('clean', 'forced_clean', { derived }); - assertSafeDerivedCleanup(derived); - cleanRunnerDerivedArtifacts(derived); - } + cleanRunnerDerivedBeforeEvaluation(derived, params.forceRebuild); const existing = await evaluateExistingXctestrun({ derived, projectRoot, @@ -542,25 +570,85 @@ async function ensureXctestrunUnderCacheLock(params: { xctestrunReferencesProjectRoot, resolveExistingXctestrunProductPaths, }); + const cache = + existing.reason === 'reuse_ready' ? 'exact' : existing.xctestrunPath ? 'restore-key' : 'miss'; if (existing.reason !== 'reuse_ready') { emitRunnerXctestrunDecision('rebuild', existing.reason, { derived, xctestrunPath: existing.xctestrunPath, }); } - if (existing.reason === 'reuse_ready') { - const reusableXctestrun = await tryReuseExistingXctestrun( - device, - derived, - expectedCacheMetadata, - existing, - ); - if (reusableXctestrun) return reusableXctestrun; - } + const reusable = await resolveReusableXctestrunArtifact({ + device, + derived, + expectedCacheMetadata, + existing, + cache, + }); + if (reusable) return reusable; if (existing.xctestrunPath) { assertSafeDerivedCleanup(derived); cleanRunnerDerivedArtifacts(derived); } + return await buildXctestrunArtifact({ + device, + options, + projectRoot, + expectedCacheMetadata, + derived, + cache, + reason: existing.reason, + }); +} + +function cleanRunnerDerivedBeforeEvaluation(derived: string, forceRebuild: boolean): void { + if (!shouldCleanDerived() && !forceRebuild && !badRunnerArtifactsForRun.has(derived)) { + return; + } + emitRunnerXctestrunDecision('clean', forceRebuild ? 'forced_rebuild' : 'forced_clean', { + derived, + }); + assertSafeDerivedCleanup(derived); + cleanRunnerDerivedArtifacts(derived); + badRunnerArtifactsForRun.delete(derived); +} + +async function resolveReusableXctestrunArtifact(params: { + device: DeviceInfo; + derived: string; + expectedCacheMetadata: RunnerXctestrunCacheMetadata; + existing: ExistingXctestrunState; + cache: RunnerXctestrunArtifact['cache']; +}): Promise { + const { device, derived, expectedCacheMetadata, existing, cache } = params; + if (existing.reason !== 'reuse_ready') return null; + const reusableXctestrun = await tryReuseExistingXctestrun( + device, + derived, + expectedCacheMetadata, + existing, + ); + if (!reusableXctestrun) return null; + return { + xctestrunPath: reusableXctestrun, + derived, + cache, + artifact: 'valid', + buildMs: 0, + xctestrunPathSource: existing.source, + }; +} + +async function buildXctestrunArtifact(params: { + device: DeviceInfo; + options: { verbose?: boolean; logPath?: string; traceLogPath?: string; buildTimeoutMs?: number }; + projectRoot: string; + expectedCacheMetadata: RunnerXctestrunCacheMetadata; + derived: string; + cache: RunnerXctestrunArtifact['cache']; + reason: ExistingXctestrunState['reason']; +}): Promise { + const { device, options, projectRoot, expectedCacheMetadata, derived, cache, reason } = params; const projectPath = path.join( projectRoot, 'ios-runner', @@ -572,7 +660,9 @@ async function ensureXctestrunUnderCacheLock(params: { throw new AppError('COMMAND_FAILED', 'iOS runner project not found', { projectPath }); } + const buildStartedAt = Date.now(); await buildRunnerXctestrun(device, projectPath, derived, options); + const buildMs = Math.max(0, Date.now() - buildStartedAt); const built = findXctestrun(derived, device); if (!built) { @@ -593,7 +683,15 @@ async function ensureXctestrunUnderCacheLock(params: { derived, xctestrunPath: built, }); - return built; + return { + xctestrunPath: built, + derived, + cache, + artifact: 'rebuilt', + buildMs, + xctestrunPathSource: 'build', + reason, + }; } async function tryReuseExistingXctestrun( @@ -666,6 +764,10 @@ const RUNNER_ROOT_TRANSIENT_ENTRY_NAMES = new Set([ 'info.plist', ]); +export function __resetRunnerToolchainFingerprintCacheForTests(): void { + appleToolFingerprintCache.clear(); +} + export function shouldDeleteRunnerDerivedRootEntry(entryName: string): boolean { return RUNNER_ROOT_TRANSIENT_ENTRY_NAMES.has(entryName); } @@ -678,11 +780,13 @@ export function resolveExpectedRunnerCacheMetadata( device: DeviceInfo, projectRoot: string = findProjectRoot(), ): RunnerXctestrunCacheMetadata { + const platformName = resolveRunnerPlatformName(device); return { schemaVersion: RUNNER_CACHE_SCHEMA_VERSION, packageVersion: readVersion(), runnerSourceFingerprint: computeRunnerSourceFingerprint(projectRoot), - platformName: resolveRunnerPlatformName(device), + ...resolveRunnerToolchainFingerprint(platformName, device.kind), + platformName, deviceKind: device.kind, target: device.target ?? 'mobile', buildDestinationFamily: resolveRunnerBuildDestinationFamily(device), @@ -696,6 +800,67 @@ export function resolveExpectedRunnerCacheMetadata( }; } +function resolveRunnerToolchainFingerprint( + platformName: 'iOS' | 'tvOS' | 'macOS', + deviceKind: DeviceInfo['kind'], +): { + xcodeVersion: string; + xcodeBuildVersion: string; + sdkName: string; + sdkVersion: string; + sdkBuildVersion: string; +} { + const xcode = parseXcodeVersionOutput(runAppleToolFingerprintCommand('xcodebuild', ['-version'])); + const sdkName = resolveRunnerSdkName(platformName, deviceKind); + return { + xcodeVersion: xcode.version, + xcodeBuildVersion: xcode.buildVersion, + sdkName, + sdkVersion: runAppleToolFingerprintCommand('xcrun', ['--sdk', sdkName, '--show-sdk-version']), + sdkBuildVersion: runAppleToolFingerprintCommand('xcrun', [ + '--sdk', + sdkName, + '--show-sdk-build-version', + ]), + }; +} + +function resolveRunnerSdkName( + platformName: 'iOS' | 'tvOS' | 'macOS', + deviceKind: DeviceInfo['kind'], +): string { + if (platformName === 'macOS') return 'macosx'; + if (platformName === 'tvOS') { + return deviceKind === 'simulator' ? 'appletvsimulator' : 'appletvos'; + } + return deviceKind === 'simulator' ? 'iphonesimulator' : 'iphoneos'; +} + +function runAppleToolFingerprintCommand(cmd: string, args: string[]): string { + const cacheKey = JSON.stringify([cmd, args]); + const cached = appleToolFingerprintCache.get(cacheKey); + if (cached !== undefined) return cached; + try { + const result = runCmdSync(cmd, args, { + allowFailure: true, + timeoutMs: 5_000, + maxBuffer: 128 * 1024, + }); + const value = result.exitCode === 0 ? result.stdout.trim() || 'unknown' : 'unknown'; + appleToolFingerprintCache.set(cacheKey, value); + return value; + } catch { + appleToolFingerprintCache.set(cacheKey, 'unknown'); + return 'unknown'; + } +} + +function parseXcodeVersionOutput(output: string): { version: string; buildVersion: string } { + const version = output.match(/^Xcode\s+(.+)$/m)?.[1]?.trim() || 'unknown'; + const buildVersion = output.match(/^Build version\s+(.+)$/m)?.[1]?.trim() || 'unknown'; + return { version, buildVersion }; +} + export function writeRunnerCacheMetadata( derived: string, metadata: RunnerXctestrunCacheMetadata, @@ -707,6 +872,25 @@ export function writeRunnerCacheMetadata( ); } +export async function markRunnerXctestrunArtifactBadForRun( + artifact: Pick, + reason: string, +): Promise { + badRunnerArtifactsForRun.add(artifact.derived); + const releaseCacheLock = await acquireRunnerXctestrunCacheLock(artifact.derived); + try { + emitRunnerXctestrunDecision('clean', 'bad_artifact', { + derived: artifact.derived, + xctestrunPath: artifact.xctestrunPath, + reason, + }); + assertSafeDerivedCleanup(artifact.derived); + cleanRunnerDerivedArtifacts(artifact.derived); + } finally { + await releaseCacheLock(); + } +} + function readRunnerCacheMetadata(derived: string): RunnerXctestrunCacheMetadata | null { try { const raw: unknown = JSON.parse( @@ -1224,7 +1408,7 @@ async function buildRunnerXctestrun( device: DeviceInfo, projectPath: string, derived: string, - options: { verbose?: boolean; logPath?: string; traceLogPath?: string }, + options: { verbose?: boolean; logPath?: string; traceLogPath?: string; buildTimeoutMs?: number }, ): Promise { const runnerBundleBuildSettings = resolveRunnerBundleBuildSettings(process.env); const signingBuildSettings = resolveRunnerSigningBuildSettings( @@ -1259,6 +1443,7 @@ async function buildRunnerXctestrun( ], { detached: true, + timeoutMs: options.buildTimeoutMs, onSpawn: (child) => { runnerPrepProcesses.add(child); child.on('close', () => { @@ -1459,6 +1644,7 @@ type ExistingXctestrunState = reason: 'reuse_ready'; xctestrunPath: string; productPaths: string[]; + source: 'manifest' | 'scan'; } | { reason: @@ -1468,6 +1654,7 @@ type ExistingXctestrunState = | 'cache_metadata_mismatch'; xctestrunPath: string; productPaths: string[]; + source: 'manifest' | 'scan'; }; // fallow-ignore-next-line complexity @@ -1488,22 +1675,23 @@ async function evaluateExistingXctestrun(options: { return { reason: 'missing_xctestrun', xctestrunPath: null }; } const hasValidatedManifest = manifest?.xctestrunPath === xctestrunPath; + const source = hasValidatedManifest ? 'manifest' : 'scan'; const productPaths = hasValidatedManifest ? manifest.productPaths : await options.resolveExistingXctestrunProductPaths(xctestrunPath); if (!productPaths) { - return { reason: 'missing_products', xctestrunPath, productPaths: [] }; + return { reason: 'missing_products', xctestrunPath, productPaths: [], source }; } if ( !options.xctestrunReferencesProjectRoot(xctestrunPath, options.projectRoot) && !hasValidatedManifest ) { - return { reason: 'project_root_mismatch', xctestrunPath, productPaths }; + return { reason: 'project_root_mismatch', xctestrunPath, productPaths, source }; } if (!cacheMetadata.ok) { - return { reason: cacheMetadata.reason, xctestrunPath, productPaths }; + return { reason: cacheMetadata.reason, xctestrunPath, productPaths, source }; } - return { reason: 'reuse_ready', xctestrunPath, productPaths }; + return { reason: 'reuse_ready', xctestrunPath, productPaths, source }; } function emitRunnerXctestrunDecision( @@ -1517,6 +1705,8 @@ function emitRunnerXctestrunDecision( | 'cache_metadata_mismatch' | 'repair_failed' | 'reuse_ready' + | 'forced_rebuild' + | 'bad_artifact' | 'built_new', data: Record, ): void {