diff --git a/packages/platform-apple/src/runner/__tests__/apple-toolchain-fixtures.ts b/packages/platform-apple/src/runner/__tests__/apple-toolchain-fixtures.ts new file mode 100644 index 000000000..2921d0cfc --- /dev/null +++ b/packages/platform-apple/src/runner/__tests__/apple-toolchain-fixtures.ts @@ -0,0 +1,41 @@ +import { beforeEach, vi } from 'vitest'; +import { appleRunnerTestHost } from '../test-host.ts'; +import type { ExecResult } from '../host.ts'; + +const STUBBED_APPLE_TOOLCHAIN = { + xcodeVersion: '26.2', + xcodeBuildVersion: '17C52', + sdkVersion: '26.2', + sdkBuildVersion: '23C53', +} as const; + +export function appleToolchainProbeResult(command: string, args: readonly string[]): ExecResult { + if (command === 'xcodebuild' && args[0] === '-version') { + return { + exitCode: 0, + stdout: `Xcode ${STUBBED_APPLE_TOOLCHAIN.xcodeVersion}\nBuild version ${STUBBED_APPLE_TOOLCHAIN.xcodeBuildVersion}\n`, + stderr: '', + }; + } + if (command === 'xcrun' && args.includes('--show-sdk-build-version')) { + return { exitCode: 0, stdout: `${STUBBED_APPLE_TOOLCHAIN.sdkBuildVersion}\n`, stderr: '' }; + } + if (command === 'xcrun' && args.includes('--show-sdk-version')) { + return { exitCode: 0, stdout: `${STUBBED_APPLE_TOOLCHAIN.sdkVersion}\n`, stderr: '' }; + } + throw new Error(`Unexpected Apple toolchain probe: ${command} ${args.join(' ')}`); +} + +/** + * Answers the runner cache's toolchain probes from a fixed toolchain, so cases + * that key the cache neither read the host's Xcode nor depend on one existing. + * Returns the mock so a case can make a probe fail. + */ +export function stubAppleToolchainProbes(): ReturnType { + const runCmdSync = vi.fn(appleToolchainProbeResult); + beforeEach(() => { + runCmdSync.mockImplementation(appleToolchainProbeResult); + appleRunnerTestHost.update({ runCmdSync }); + }); + return runCmdSync; +} diff --git a/packages/platform-apple/src/runner/__tests__/runner-cache-metadata.test.ts b/packages/platform-apple/src/runner/__tests__/runner-cache-metadata.test.ts index c34536a30..dbcba191f 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-cache-metadata.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-cache-metadata.test.ts @@ -1,9 +1,9 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { onTestFinished, test } from 'vitest'; +import { expect, test } from 'vitest'; import assert from 'node:assert/strict'; +import { AppError } from '@agent-device/kernel/errors'; import { IOS_DEVICE, IOS_SIMULATOR, MACOS_DEVICE } from './device-fixtures.ts'; import { + diffComparableRunnerCacheMetadata, resolveRunnerBundleBuildSettings, resolveRunnerMaxConcurrentDestinationsFlag, resolveRunnerSigningBuildSettings, @@ -11,7 +11,9 @@ import { resolveRunnerSandboxBuildArgs, resolveExpectedRunnerCacheMetadata, } from '../runner-cache-metadata.ts'; -import { mkdtempForTestSync } from './tmp-dir.ts'; +import { appleToolchainProbeResult, stubAppleToolchainProbes } from './apple-toolchain-fixtures.ts'; + +const runCmdSync = stubAppleToolchainProbes(); test('resolveRunnerMaxConcurrentDestinationsFlag uses simulator flag for simulators', () => { assert.equal( @@ -40,7 +42,10 @@ test('resolveRunnerSigningBuildSettings returns empty args without env overrides test('resolveRunnerSigningBuildSettings disables signing for macOS desktop builds', () => { assert.deepEqual( - resolveRunnerSigningBuildSettings({}, true, { platform: 'apple', appleOs: 'macos' }), + resolveRunnerSigningBuildSettings({}, true, { + platform: 'apple', + appleOs: 'macos', + }), [ 'CODE_SIGNING_ALLOWED=NO', 'CODE_SIGNING_REQUIRED=NO', @@ -153,83 +158,229 @@ test('resolveRunnerBundleBuildSettings uses AGENT_DEVICE_IOS_BUNDLE_ID when prov ); }); -test('runner cache metadata fingerprints shared snapshot presentation sources', () => { - const root = mkdtempForTestSync('agent-device-runner-cache-fingerprint-'); - onTestFinished(() => fs.rmSync(root, { recursive: true, force: true })); - fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ version: '0.0.0' })); - fs.mkdirSync(path.join(root, 'apple', 'runner', 'AgentDeviceRunner'), { recursive: true }); - fs.mkdirSync(path.join(root, 'apple', 'snapshot-presentation', 'Sources'), { recursive: true }); - fs.writeFileSync( - path.join(root, 'apple', 'runner', 'AgentDeviceRunner', 'Runner.swift'), - 'runner\n', - ); - const sharedSource = path.join( - root, - 'apple', - 'snapshot-presentation', - 'Sources', - 'Presentation.swift', - ); - fs.writeFileSync(sharedSource, 'shared-one\n'); - - const before = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR, root).runnerSourceFingerprint; - fs.writeFileSync(sharedSource, 'shared-two\n'); - const after = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR, root).runnerSourceFingerprint; - - assert.notEqual(after, before); -}); - -test('runner cache metadata ignores development-only SwiftPM trees but keeps runner unit tests', () => { - const root = mkdtempForTestSync('agent-device-runner-cache-source-roots-'); - onTestFinished(() => fs.rmSync(root, { recursive: true, force: true })); - fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ version: '0.0.0' })); - - const runnerRoot = path.join(root, 'apple', 'runner', 'AgentDeviceRunner'); - const runnerUnitTest = path.join( - runnerRoot, - 'AgentDeviceRunnerUITests', - 'UnitTests', - 'Invariant.swift', - ); - const sharedRoot = path.join(root, 'apple', 'snapshot-presentation'); - fs.mkdirSync(path.dirname(runnerUnitTest), { recursive: true }); - fs.mkdirSync(path.join(sharedRoot, 'Sources'), { recursive: true }); - fs.writeFileSync(path.join(runnerRoot, 'Runner.swift'), 'runner\n'); - fs.writeFileSync(runnerUnitTest, 'unit-one\n'); - fs.writeFileSync(path.join(sharedRoot, 'Sources', 'Presentation.swift'), 'shared\n'); - - for (const directory of [ - 'Tests', - 'SnapshotPresentationConformance', - '.build', - '.swiftpm', - 'xcuserdata', - ]) { - const file = path.join(sharedRoot, directory, 'Ignored.swift'); - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, 'ignored-one\n'); +test('metadata diff names only the comparable keys that differ, with expected and actual', () => { + const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); + const actual = { + ...expected, + packageVersion: `${expected.packageVersion}-next`, + xcodeBuildVersion: '17A100', + runnerPerformanceBuildSettings: ['ENABLE_CODE_COVERAGE=YES'], + artifacts: { + xctestrunPath: '/tmp/derived/Runner.xctestrun', + xctestrunMtimeMs: 1, + xctestrunSize: 2, + productPaths: [{ path: '/tmp/derived/Runner.app', mtimeMs: 1, size: 2 }], + }, + }; + + assert.deepEqual(diffComparableRunnerCacheMetadata(expected, actual), [ + { + key: 'runnerPerformanceBuildSettings', + expected: JSON.stringify(expected.runnerPerformanceBuildSettings), + actual: '["ENABLE_CODE_COVERAGE=YES"]', + }, + { key: 'xcodeBuildVersion', expected: '"17C52"', actual: '"17A100"' }, + ]); +}); + +test('metadata diff reports a key only one side carries as absent', () => { + const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); + const { sdkBuildVersion: _sdkBuildVersion, ...withoutSdkBuildVersion } = expected; + + assert.deepEqual( + diffComparableRunnerCacheMetadata(expected, withoutSdkBuildVersion as typeof expected), + [{ key: 'sdkBuildVersion', expected: '"23C53"', actual: '(absent)' }], + ); +}); + +test('metadata diff is empty for identical metadata', () => { + const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); + + assert.deepEqual(diffComparableRunnerCacheMetadata(expected, { ...expected }), []); +}); + +test('metadata diff elides an over-long value in the middle so both ends stay comparable', () => { + const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); + const longSetting = (suffix: string) => [`${'A'.repeat(400)}=${suffix}`]; + + const [difference] = diffComparableRunnerCacheMetadata( + { ...expected, runnerBundleBuildSettings: longSetting('one') }, + { ...expected, runnerBundleBuildSettings: longSetting('two') }, + ); + + assert.equal(difference?.key, 'runnerBundleBuildSettings'); + assert.ok((difference?.expected.length ?? 0) <= 300); + assert.ok(difference?.expected.startsWith('["AAA')); + assert.ok(difference?.expected.endsWith('=one"]')); + assert.ok(difference?.actual.endsWith('=two"]')); +}); + +function unavailableProbes(): { probe: string; reason: string }[] { + try { + resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); + return []; + } catch (error) { + assert.ok(error instanceof AppError); + assert.equal(error.details?.reason, 'apple_toolchain_probe_unavailable'); + const probes = error.details?.probes as { probe: string; reason: string }[]; + return probes.map(({ probe, reason }) => ({ probe, reason })); } +} + +test('a timed-out probe leaves the toolchain unavailable instead of a comparable value', () => { + runCmdSync.mockImplementation((command: string, args: readonly string[]) => { + if (command === 'xcodebuild') { + throw new AppError('COMMAND_FAILED', 'xcodebuild timed out after 5000ms', { + timeoutMs: 5_000, + }); + } + return appleToolchainProbeResult(command, args); + }); + + assert.deepEqual(unavailableProbes(), [{ probe: 'xcodebuild -version', reason: 'probe_error' }]); +}); + +test('a failing probe reports its exit status rather than a fabricated SDK version', () => { + runCmdSync.mockImplementation((command: string, args: readonly string[]) => + command === 'xcrun' + ? { + exitCode: 70, + stdout: '', + stderr: 'xcrun: error: SDK cannot be located\n', + } + : appleToolchainProbeResult(command, args), + ); - const before = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR, root).runnerSourceFingerprint; - for (const directory of [ - 'Tests', - 'SnapshotPresentationConformance', - '.build', - '.swiftpm', - 'xcuserdata', - ]) { - fs.writeFileSync(path.join(sharedRoot, directory, 'Ignored.swift'), 'ignored-two\n'); + assert.deepEqual(unavailableProbes(), [ + { + probe: 'xcrun --sdk iphonesimulator --show-sdk-version', + reason: 'nonzero_exit', + }, + { + probe: 'xcrun --sdk iphonesimulator --show-sdk-build-version', + reason: 'nonzero_exit', + }, + ]); +}); + +test('unrecognized xcodebuild output is unavailable, not a partially parsed fingerprint', () => { + runCmdSync.mockImplementation((command: string, args: readonly string[]) => + command === 'xcodebuild' + ? { + exitCode: 0, + stdout: 'xcode-select: error: tool not configured\n', + stderr: '', + } + : appleToolchainProbeResult(command, args), + ); + + assert.deepEqual(unavailableProbes(), [ + { probe: 'xcodebuild -version', reason: 'unparsable_output' }, + ]); +}); + +test('an empty probe answer is unavailable rather than an empty cache key field', () => { + runCmdSync.mockImplementation((command: string, args: readonly string[]) => + command === 'xcrun' && args.includes('--show-sdk-build-version') + ? { exitCode: 0, stdout: '\n', stderr: '' } + : appleToolchainProbeResult(command, args), + ); + + assert.deepEqual(unavailableProbes(), [ + { + probe: 'xcrun --sdk iphonesimulator --show-sdk-build-version', + reason: 'empty_output', + }, + ]); +}); + +test('an unavailable toolchain fails the cache decision with a retriable typed error', () => { + runCmdSync.mockImplementation(() => { + throw new AppError('COMMAND_FAILED', 'xcodebuild timed out after 5000ms', {}); + }); + + try { + resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); + assert.fail('expected an unavailable toolchain to fail the cache decision'); + } catch (error) { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.equal(error.details?.retriable, true); + expect(error.message).toContain('xcodebuild -version'); + expect(String(error.details?.hint)).toContain('xcode-select'); } - const afterIgnoredChanges = resolveExpectedRunnerCacheMetadata( - IOS_SIMULATOR, - root, - ).runnerSourceFingerprint; - assert.equal(afterIgnoredChanges, before); - - fs.writeFileSync(runnerUnitTest, 'unit-two\n'); - const afterRunnerTestChange = resolveExpectedRunnerCacheMetadata( - IOS_SIMULATOR, - root, - ).runnerSourceFingerprint; - assert.notEqual(afterRunnerTestChange, afterIgnoredChanges); +}); + +test('an unavailable probe never reaches cache metadata, and is not memoized as one', () => { + runCmdSync.mockImplementation(() => { + throw new AppError('COMMAND_FAILED', 'xcodebuild timed out after 5000ms', {}); + }); + expect(() => resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR)).toThrow( + /Could not read the Xcode toolchain versions/, + ); + + runCmdSync.mockImplementation(appleToolchainProbeResult); + const metadata = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); + + assert.equal(metadata.xcodeVersion, '26.2'); + assert.equal(metadata.xcodeBuildVersion, '17C52'); + assert.equal(metadata.sdkVersion, '26.2'); + assert.equal(metadata.sdkBuildVersion, '23C53'); +}); + +test('a malformed xcodebuild answer is not memoized: the next request re-probes and recovers', () => { + runCmdSync.mockImplementation((command: string, args: readonly string[]) => + command === 'xcodebuild' + ? { + exitCode: 0, + stdout: 'xcode-select: error: tool not configured\n', + stderr: '', + } + : appleToolchainProbeResult(command, args), + ); + assert.deepEqual(unavailableProbes(), [ + { probe: 'xcodebuild -version', reason: 'unparsable_output' }, + ]); + const probeCallsWhileMalformed = runCmdSync.mock.calls.length; + + runCmdSync.mockImplementation(appleToolchainProbeResult); + const metadata = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); + + assert.equal(metadata.xcodeVersion, '26.2'); + assert.equal(metadata.xcodeBuildVersion, '17C52'); + expect(runCmdSync.mock.calls.slice(probeCallsWhileMalformed).map(([command]) => command)).toEqual( + ['xcodebuild', 'xcrun', 'xcrun'], + ); +}); + +test('only a complete, parsed toolchain fingerprint is memoized', () => { + runCmdSync.mockImplementation((command: string, args: readonly string[]) => + command === 'xcrun' && args.includes('--show-sdk-build-version') + ? { exitCode: 0, stdout: '\n', stderr: '' } + : appleToolchainProbeResult(command, args), + ); + assert.deepEqual(unavailableProbes(), [ + { + probe: 'xcrun --sdk iphonesimulator --show-sdk-build-version', + reason: 'empty_output', + }, + ]); + + runCmdSync.mockImplementation(appleToolchainProbeResult); + runCmdSync.mockClear(); + const first = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); + // The healthy xcodebuild answer from the failed round was not kept either: all three re-run. + expect(runCmdSync.mock.calls.map(([command]) => command)).toEqual([ + 'xcodebuild', + 'xcrun', + 'xcrun', + ]); + + runCmdSync.mockClear(); + const second = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); + expect(runCmdSync).not.toHaveBeenCalled(); + assert.deepEqual( + [second.xcodeVersion, second.xcodeBuildVersion, second.sdkVersion, second.sdkBuildVersion], + [first.xcodeVersion, first.xcodeBuildVersion, first.sdkVersion, first.sdkBuildVersion], + ); }); diff --git a/packages/platform-apple/src/runner/__tests__/runner-cache.test.ts b/packages/platform-apple/src/runner/__tests__/runner-cache.test.ts new file mode 100644 index 000000000..b184b2fda --- /dev/null +++ b/packages/platform-apple/src/runner/__tests__/runner-cache.test.ts @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { onTestFinished, test } from 'vitest'; +import { + evaluateExistingXctestrun, + writeRunnerCacheMetadata, + type ExistingXctestrunState, +} from '../runner-cache.ts'; +import { resolveExpectedRunnerCacheMetadata } from '../runner-cache-metadata.ts'; +import { IOS_SIMULATOR } from './device-fixtures.ts'; +import { mkdtempForTestSync } from './tmp-dir.ts'; +import { stubAppleToolchainProbes } from './apple-toolchain-fixtures.ts'; + +stubAppleToolchainProbes(); + +function evaluateAgainstCachedMetadata( + cached: Record, +): Promise { + const derived = mkdtempForTestSync('agent-device-runner-cache-eval-'); + onTestFinished(() => fs.rmSync(derived, { recursive: true, force: true })); + const xctestrunPath = path.join(derived, 'Runner.xctestrun'); + fs.writeFileSync(xctestrunPath, 'xctestrun'); + writeRunnerCacheMetadata(derived, cached as never); + return evaluateExistingXctestrun({ + derived, + projectRoot: process.cwd(), + expectedCacheMetadata: resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR), + findXctestrun: () => xctestrunPath, + xctestrunReferencesProjectRoot: () => true, + resolveExistingXctestrunProductPaths: () => Promise.resolve([path.join(derived, 'Runner.app')]), + }); +} + +test('a metadata mismatch names the differing keys with expected and actual', async () => { + const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); + + const state = await evaluateAgainstCachedMetadata({ + ...expected, + xcodeBuildVersion: '17A100', + runnerSandboxBuildArgs: [...expected.runnerSandboxBuildArgs, 'EXTRA=1'], + }); + + assert.equal(state.reason, 'cache_metadata_mismatch'); + assert.deepEqual(state.reason === 'cache_metadata_mismatch' ? state.metadataDifferences : null, [ + { + key: 'runnerSandboxBuildArgs', + expected: JSON.stringify(expected.runnerSandboxBuildArgs), + actual: JSON.stringify([...expected.runnerSandboxBuildArgs, 'EXTRA=1']), + }, + { key: 'xcodeBuildVersion', expected: '"17C52"', actual: '"17A100"' }, + ]); +}); + +test('metadata that differs only in the non-comparable fields still reuses the cache', async () => { + const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); + + const state = await evaluateAgainstCachedMetadata({ + ...expected, + packageVersion: `${expected.packageVersion}-next`, + }); + + assert.equal(state.reason, 'reuse_ready'); +}); diff --git a/packages/platform-apple/src/runner/__tests__/runner-source.test.ts b/packages/platform-apple/src/runner/__tests__/runner-source.test.ts index 94baf98c3..116a51748 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-source.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-source.test.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { onTestFinished, test } from 'vitest'; import { + computeRunnerSourceFingerprint, resolveAppleRunnerProjectPath, resolveAppleRunnerSourceRoot, resolveAppleSnapshotPresentationSourceRoot, @@ -53,6 +54,71 @@ test('resolveAppleSnapshotPresentationSourceRoot falls back to packaged source', assert.equal(resolveAppleSnapshotPresentationSourceRoot(root), packagedSource); }); +test('computeRunnerSourceFingerprint covers the shared snapshot presentation sources', () => { + const root = makeTempRoot(); + fs.mkdirSync(path.join(root, 'apple', 'runner', 'AgentDeviceRunner'), { recursive: true }); + fs.mkdirSync(path.join(root, 'apple', 'snapshot-presentation', 'Sources'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'apple', 'runner', 'AgentDeviceRunner', 'Runner.swift'), + 'runner\n', + ); + const sharedSource = path.join( + root, + 'apple', + 'snapshot-presentation', + 'Sources', + 'Presentation.swift', + ); + fs.writeFileSync(sharedSource, 'shared-one\n'); + + const before = computeRunnerSourceFingerprint(root); + fs.writeFileSync(sharedSource, 'shared-two-changed\n'); + + assert.notEqual(computeRunnerSourceFingerprint(root), before); +}); + +test('computeRunnerSourceFingerprint ignores development-only SwiftPM trees but keeps runner unit tests', () => { + const root = makeTempRoot(); + const runnerRoot = path.join(root, 'apple', 'runner', 'AgentDeviceRunner'); + const runnerUnitTest = path.join( + runnerRoot, + 'AgentDeviceRunnerUITests', + 'UnitTests', + 'Invariant.swift', + ); + const sharedRoot = path.join(root, 'apple', 'snapshot-presentation'); + fs.mkdirSync(path.dirname(runnerUnitTest), { recursive: true }); + fs.mkdirSync(path.join(sharedRoot, 'Sources'), { recursive: true }); + fs.writeFileSync(path.join(runnerRoot, 'Runner.swift'), 'runner\n'); + fs.writeFileSync(runnerUnitTest, 'unit-one\n'); + fs.writeFileSync(path.join(sharedRoot, 'Sources', 'Presentation.swift'), 'shared\n'); + + for (const directory of IGNORED_SOURCE_DIRECTORY_NAMES) { + const file = path.join(sharedRoot, directory, 'Ignored.swift'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, 'ignored-one\n'); + } + + const before = computeRunnerSourceFingerprint(root); + for (const directory of IGNORED_SOURCE_DIRECTORY_NAMES) { + fs.writeFileSync(path.join(sharedRoot, directory, 'Ignored.swift'), 'ignored-two-changed\n'); + } + const afterIgnoredChanges = computeRunnerSourceFingerprint(root); + assert.equal(afterIgnoredChanges, before); + + fs.writeFileSync(runnerUnitTest, 'unit-two-changed\n'); + + assert.notEqual(computeRunnerSourceFingerprint(root), afterIgnoredChanges); +}); + +const IGNORED_SOURCE_DIRECTORY_NAMES = [ + 'Tests', + 'SnapshotPresentationConformance', + '.build', + '.swiftpm', + 'xcuserdata', +]; + function makeTempRoot(): string { const root = mkdtempForTestSync('agent-device-runner-source-'); onTestFinished(() => fs.rmSync(root, { recursive: true, force: true })); diff --git a/packages/platform-apple/src/runner/runner-artifact.ts b/packages/platform-apple/src/runner/runner-artifact.ts index f090e6124..b80182ed5 100644 --- a/packages/platform-apple/src/runner/runner-artifact.ts +++ b/packages/platform-apple/src/runner/runner-artifact.ts @@ -20,6 +20,7 @@ import { cleanRunnerDerivedArtifacts, cleanRunnerDerivedBeforeEvaluation, emitRunnerXctestrunDecision, + emitRunnerXctestrunRebuildDecision, evaluateExistingXctestrun, resolveExpectedRunnerCacheMetadata, resolveRunnerBundleBuildSettings, @@ -169,10 +170,7 @@ async function ensureXctestrunUnderCacheLock(params: { 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, - }); + emitRunnerXctestrunRebuildDecision(existing, derived); } const reusable = await resolveReusableXctestrunArtifact({ device, diff --git a/packages/platform-apple/src/runner/runner-cache-metadata.ts b/packages/platform-apple/src/runner/runner-cache-metadata.ts index 9c39fa50e..a0461ad24 100644 --- a/packages/platform-apple/src/runner/runner-cache-metadata.ts +++ b/packages/platform-apple/src/runner/runner-cache-metadata.ts @@ -1,14 +1,14 @@ import crypto from 'node:crypto'; -import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; import { - runCmdSync, - isEnvTruthy, createTtlMemo, + isEnvTruthy, findProjectRoot, readVersion, + runCmdSync, type TtlMemo, } from './host.ts'; import { @@ -17,23 +17,18 @@ import { resolveRunnerPlatformName, resolveRunnerSdkName, } from './apple-runner-platform.ts'; -import { - resolveAppleRunnerSourceRoot, - resolveAppleSnapshotPresentationSourceRoot, -} from './runner-source.ts'; +import { computeRunnerSourceFingerprint } from './runner-source.ts'; const DEFAULT_IOS_RUNNER_APP_BUNDLE_ID = 'com.callstack.agentdevice.runner'; const RUNNER_DERIVED_ROOT = path.join(os.homedir(), '.agent-device', 'apple-runner'); export const RUNNER_CACHE_METADATA_FILE = '.agent-device-runner-cache.json'; const RUNNER_CACHE_SCHEMA_VERSION = 2; -const RUNNER_SOURCE_IGNORED_DIR_NAMES = new Set(['.build', '.swiftpm', 'xcuserdata']); -const SNAPSHOT_PRESENTATION_SOURCE_IGNORED_DIR_NAMES = new Set([ - '.build', - '.swiftpm', - 'SnapshotPresentationConformance', - 'Tests', - 'xcuserdata', -]); +const RUNNER_CACHE_METADATA_VALUE_MAX_LENGTH = 300; +const TOOLCHAIN_PROBE_TIMEOUT_MS = 5_000; +const TOOLCHAIN_PROBE_MAX_BUFFER = 128 * 1024; +const TOOLCHAIN_PROBE_DETAIL_MAX_LENGTH = 200; +const TOOLCHAIN_PROBE_HINT = + 'The Apple runner cache is keyed on the toolchain version, so a cache decision cannot be made without it. Retry once the host is less loaded, or check `xcode-select -p` and `xcodebuild -version`.'; const RUNNER_SANDBOX_BUILD_ARGS = [ '-IDEPackageSupportDisableManifestSandbox=1', '-IDEPackageSupportDisablePluginExecutionSandbox=1', @@ -43,23 +38,29 @@ const RUNNER_RUNTIME_SWIFT_FLAGS = '$(inherited) -disable-sandbox'; const RUNNER_UNIT_TEST_SWIFT_FLAGS = '$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_UNIT_TESTS'; -// Lazy: createTtlMemo is a host capability, and module evaluation happens -// before the composition root binds the host. -let lazyAppleToolFingerprintCache: TtlMemo | undefined; -function appleToolFingerprintCache(): TtlMemo { - lazyAppleToolFingerprintCache ??= createTtlMemo(); - return lazyAppleToolFingerprintCache; -} - -export type RunnerXctestrunCacheMetadata = { - schemaVersion: number; - packageVersion: string; - runnerSourceFingerprint: string; +/** Toolchain half of the runner cache key. Every field is a probed value. */ +export type RunnerToolchainFingerprint = { xcodeVersion: string; xcodeBuildVersion: string; sdkName: string; sdkVersion: string; sdkBuildVersion: string; +}; + +type ToolchainProbeFailure = { + probe: string; + reason: 'probe_error' | 'nonzero_exit' | 'empty_output' | 'unparsable_output'; + detail: string; +}; + +type ProbeResult = + | { ok: true; value: Value } + | { ok: false; failure: ToolchainProbeFailure }; + +export type RunnerXctestrunCacheMetadata = RunnerToolchainFingerprint & { + schemaVersion: number; + packageVersion: string; + runnerSourceFingerprint: string; platformName: string; deviceKind: DeviceInfo['kind']; target: NonNullable; @@ -130,7 +131,7 @@ export function resolveExpectedRunnerCacheMetadata( schemaVersion: RUNNER_CACHE_SCHEMA_VERSION, packageVersion: readVersion(projectRoot), runnerSourceFingerprint: computeRunnerSourceFingerprint(projectRoot), - ...resolveRunnerToolchainFingerprint(platformName, device.kind), + ...requireRunnerToolchainFingerprint(resolveRunnerSdkName(platformName, device.kind)), platformName, deviceKind: device.kind, target: device.target ?? 'mobile', @@ -146,54 +147,129 @@ export function resolveExpectedRunnerCacheMetadata( }; } -function resolveRunnerToolchainFingerprint( - platformName: ReturnType, - 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, +// Lazy: createTtlMemo is a host capability, and module evaluation happens +// before the composition root binds the host. Only a complete, parsed +// fingerprint is ever memoized, so nothing unavailable can outlive the probe +// that could not answer. +let lazyToolchainFingerprintCache: TtlMemo | undefined; +function toolchainFingerprintCache(): TtlMemo { + lazyToolchainFingerprintCache ??= createTtlMemo(); + return lazyToolchainFingerprintCache; +} + +/** + * The toolchain half of the cache key, or a failure. A probe that timed out or + * could not be read has no value to compare or persist, and the same + * fingerprint also names the derived-data directory, so an unreadable + * toolchain fails the cache decision instead of standing in for one. + */ +function requireRunnerToolchainFingerprint(sdkName: string): RunnerToolchainFingerprint { + const cached = toolchainFingerprintCache().get(sdkName); + if (cached) return cached; + const fingerprint = readRunnerToolchainFingerprint(sdkName); + if (!fingerprint.ok) throw unavailableToolchainError(fingerprint.failures); + toolchainFingerprintCache().set(sdkName, fingerprint.value); + return fingerprint.value; +} + +function readRunnerToolchainFingerprint( + sdkName: string, +): + | { ok: true; value: RunnerToolchainFingerprint } + | { ok: false; failures: readonly ToolchainProbeFailure[] } { + const xcode = parseXcodeVersionOutput(runToolchainProbe('xcodebuild', ['-version'])); + const sdkVersion = runToolchainProbe('xcrun', ['--sdk', sdkName, '--show-sdk-version']); + const sdkBuildVersion = runToolchainProbe('xcrun', [ + '--sdk', sdkName, - sdkVersion: runAppleToolFingerprintCommand('xcrun', ['--sdk', sdkName, '--show-sdk-version']), - sdkBuildVersion: runAppleToolFingerprintCommand('xcrun', [ - '--sdk', + '--show-sdk-build-version', + ]); + if (!xcode.ok || !sdkVersion.ok || !sdkBuildVersion.ok) { + return { + ok: false, + failures: [xcode, sdkVersion, sdkBuildVersion].flatMap((probe) => + probe.ok ? [] : [probe.failure], + ), + }; + } + return { + ok: true, + value: { + xcodeVersion: xcode.value.version, + xcodeBuildVersion: xcode.value.buildVersion, sdkName, - '--show-sdk-build-version', - ]), + sdkVersion: sdkVersion.value, + sdkBuildVersion: sdkBuildVersion.value, + }, }; } -function runAppleToolFingerprintCommand(cmd: string, args: string[]): string { - const cacheKey = JSON.stringify([cmd, args]); - const cached = appleToolFingerprintCache().get(cacheKey); - if (cached !== undefined) return cached; +function unavailableToolchainError(failures: readonly ToolchainProbeFailure[]): AppError { + return new AppError( + 'COMMAND_FAILED', + `Could not read the Xcode toolchain versions the Apple runner cache is keyed on (${failures + .map((failure) => `${failure.probe}: ${failure.detail}`) + .join('; ')})`, + { + reason: 'apple_toolchain_probe_unavailable', + retriable: true, + probes: failures, + hint: TOOLCHAIN_PROBE_HINT, + }, + ); +} + +function runToolchainProbe(cmd: string, args: string[]): ProbeResult { + const probe = [cmd, ...args].join(' '); + let output: { exitCode: number; stdout: string; stderr: string }; try { - const result = runCmdSync(cmd, args, { + output = runCmdSync(cmd, args, { allowFailure: true, - timeoutMs: 5_000, - maxBuffer: 128 * 1024, + timeoutMs: TOOLCHAIN_PROBE_TIMEOUT_MS, + maxBuffer: TOOLCHAIN_PROBE_MAX_BUFFER, }); - const value = result.exitCode === 0 ? result.stdout.trim() || 'unknown' : 'unknown'; - appleToolFingerprintCache().set(cacheKey, value); - return value; - } catch { - appleToolFingerprintCache().set(cacheKey, 'unknown'); - return 'unknown'; + } catch (error) { + return probeFailure(probe, 'probe_error', error instanceof Error ? error.message : `${error}`); + } + if (output.exitCode !== 0) { + return probeFailure( + probe, + 'nonzero_exit', + `exit ${output.exitCode}${output.stderr.trim() ? `: ${output.stderr.trim()}` : ''}`, + ); + } + const value = output.stdout.trim(); + return value ? { ok: true, value } : probeFailure(probe, 'empty_output', 'no output'); +} + +function parseXcodeVersionOutput( + output: ProbeResult, +): ProbeResult<{ version: string; buildVersion: string }> { + if (!output.ok) { + return output; + } + const version = output.value.match(/^Xcode\s+(.+)$/m)?.[1]?.trim(); + const buildVersion = output.value.match(/^Build version\s+(.+)$/m)?.[1]?.trim(); + if (!version || !buildVersion) { + return probeFailure( + 'xcodebuild -version', + 'unparsable_output', + `unrecognized output: ${output.value.replaceAll('\n', ' ')}`, + ); } + return { ok: true, value: { version, buildVersion } }; } -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 }; +function probeFailure( + probe: string, + reason: ToolchainProbeFailure['reason'], + detail: string, +): { ok: false; failure: ToolchainProbeFailure } { + const bounded = + detail.length > TOOLCHAIN_PROBE_DETAIL_MAX_LENGTH + ? `${detail.slice(0, TOOLCHAIN_PROBE_DETAIL_MAX_LENGTH)}…` + : detail; + return { ok: false, failure: { probe, reason, detail: bounded } }; } export function resolveRunnerDerivedPath( @@ -228,6 +304,49 @@ export function comparableRunnerCacheMetadata( return comparable; } +export type RunnerCacheMetadataDifference = { + key: string; + expected: string; + actual: string; +}; + +export function diffComparableRunnerCacheMetadata( + expected: RunnerXctestrunCacheMetadata, + actual: RunnerXctestrunCacheMetadata, +): RunnerCacheMetadataDifference[] { + const expectedComparable: Record = comparableRunnerCacheMetadata(expected); + const actualComparable: Record = comparableRunnerCacheMetadata(actual); + return [...new Set([...Object.keys(expectedComparable), ...Object.keys(actualComparable)])] + .sort((left, right) => left.localeCompare(right)) + .flatMap((key) => { + const expectedValue = renderRunnerCacheMetadataValue(expectedComparable[key]); + const actualValue = renderRunnerCacheMetadataValue(actualComparable[key]); + return expectedValue === actualValue + ? [] + : [ + { + key, + expected: elideRunnerCacheMetadataValue(expectedValue), + actual: elideRunnerCacheMetadataValue(actualValue), + }, + ]; + }); +} + +function renderRunnerCacheMetadataValue(value: unknown): string { + return value === undefined ? '(absent)' : stableJsonStringify(value); +} + +// Elides the middle: build-setting lists differ in their last entry as often as +// their first, and a head-only cut would render both sides identically. +function elideRunnerCacheMetadataValue(value: string): string { + if (value.length <= RUNNER_CACHE_METADATA_VALUE_MAX_LENGTH) { + return value; + } + const half = Math.floor((RUNNER_CACHE_METADATA_VALUE_MAX_LENGTH - 1) / 2); + return `${value.slice(0, half)}…${value.slice(-half)}`; +} + export function stableJsonStringify(value: unknown): string { return JSON.stringify(sortJsonKeys(value)); } @@ -246,124 +365,6 @@ function sortJsonKeys(value: unknown): unknown { ); } -type RunnerSourceFingerprintCacheEntry = { - fileStatsFingerprint: string; - sourceFingerprint: string; -}; - -const runnerSourceFingerprintCache = new Map(); - -function computeRunnerSourceFingerprint(projectRoot: string): string { - const sourceRoots = [ - { - path: resolveAppleRunnerSourceRoot(projectRoot), - ignoredDirectoryNames: RUNNER_SOURCE_IGNORED_DIR_NAMES, - }, - { - path: resolveAppleSnapshotPresentationSourceRoot(projectRoot), - ignoredDirectoryNames: SNAPSHOT_PRESENTATION_SOURCE_IGNORED_DIR_NAMES, - }, - ]; - const files = collectRunnerSourceFiles(sourceRoots); - const fileStatsFingerprint = computeRunnerSourceFileStatsFingerprint(projectRoot, files); - const cacheKey = JSON.stringify(sourceRoots.map(({ path: sourcePath }) => sourcePath)); - const cached = runnerSourceFingerprintCache.get(cacheKey); - if (cached?.fileStatsFingerprint === fileStatsFingerprint) { - return cached.sourceFingerprint; - } - const hash = crypto.createHash('sha256'); - for (const file of files) { - const relativePath = path.relative(projectRoot, file); - hash.update(relativePath); - hash.update('\0'); - hash.update(fs.readFileSync(file)); - hash.update('\0'); - } - const sourceFingerprint = hash.digest('hex'); - runnerSourceFingerprintCache.set(cacheKey, { fileStatsFingerprint, sourceFingerprint }); - return sourceFingerprint; -} - -function computeRunnerSourceFileStatsFingerprint( - projectRoot: string, - files: readonly string[], -): string { - const hash = crypto.createHash('sha256'); - for (const file of files) { - const relativePath = path.relative(projectRoot, file); - const stat = fs.statSync(file); - hash.update(relativePath); - hash.update('\0'); - hash.update(String(stat.size)); - hash.update('\0'); - hash.update(String(Math.trunc(stat.mtimeMs))); - hash.update('\0'); - } - return hash.digest('hex'); -} - -type RunnerSourceRoot = Readonly<{ - path: string; - ignoredDirectoryNames: ReadonlySet; -}>; - -function collectRunnerSourceFiles(roots: readonly RunnerSourceRoot[]): string[] { - return [ - ...new Set( - roots.flatMap(({ path: sourcePath, ignoredDirectoryNames }) => - collectRunnerSourceFilesUnderRoot(sourcePath, ignoredDirectoryNames), - ), - ), - ].sort((a, b) => a.localeCompare(b)); -} - -function collectRunnerSourceFilesUnderRoot( - root: string, - ignoredDirectoryNames: ReadonlySet, -): string[] { - return fs.existsSync(root) - ? collectRunnerSourceFilesInDirectory(root, ignoredDirectoryNames) - : []; -} - -function collectRunnerSourceFilesInDirectory( - directory: string, - ignoredDirectoryNames: ReadonlySet, -): string[] { - const files: string[] = []; - for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { - const fullPath = path.join(directory, entry.name); - if (entry.isDirectory()) { - if (!ignoredDirectoryNames.has(entry.name)) { - files.push(...collectRunnerSourceFilesInDirectory(fullPath, ignoredDirectoryNames)); - } - } else if (entry.isFile() && isRunnerSourceFile(entry.name, fullPath)) { - files.push(fullPath); - } - } - return files; -} - -function isRunnerSourceFile(fileName: string, filePath: string): boolean { - if (fileName === 'project.pbxproj') { - return filePath.includes(`${path.sep}.xcodeproj${path.sep}`); - } - return [ - '.jpg', - '.json', - '.png', - '.swift', - '.m', - '.h', - '.plist', - '.entitlements', - '.xctestplan', - '.xcconfig', - '.storyboard', - '.xib', - ].includes(path.extname(fileName)); -} - export function resolveRunnerMaxConcurrentDestinationsFlag(device: DeviceInfo): string { if (isMacOs(device)) { return '-maximum-concurrent-test-device-destinations'; diff --git a/packages/platform-apple/src/runner/runner-cache.ts b/packages/platform-apple/src/runner/runner-cache.ts index 554ca781d..94f0431f5 100644 --- a/packages/platform-apple/src/runner/runner-cache.ts +++ b/packages/platform-apple/src/runner/runner-cache.ts @@ -12,7 +12,9 @@ import { import { RUNNER_CACHE_METADATA_FILE, comparableRunnerCacheMetadata, + diffComparableRunnerCacheMetadata, stableJsonStringify, + type RunnerCacheMetadataDifference, type RunnerXctestrunCacheArtifacts, type RunnerXctestrunCacheMetadata, type RunnerXctestrunCacheProductArtifact, @@ -48,14 +50,18 @@ export type ExistingXctestrunState = source: 'manifest' | 'scan'; } | { - reason: - | 'project_root_mismatch' - | 'missing_products' - | 'cache_metadata_missing' - | 'cache_metadata_mismatch'; + reason: 'project_root_mismatch' | 'missing_products' | 'cache_metadata_missing'; xctestrunPath: string; productPaths: string[]; source: 'manifest' | 'scan'; + } + | { + reason: 'cache_metadata_mismatch'; + xctestrunPath: string; + productPaths: string[]; + source: 'manifest' | 'scan'; + /** Which comparable keys differ, so a rebuild names its cause. */ + metadataDifferences: RunnerCacheMetadataDifference[]; }; type RunnerXctestrunArtifactIdentity = { @@ -210,12 +216,19 @@ function readRunnerCacheMetadata(derived: string): RunnerXctestrunCacheMetadata } } +type RunnerCacheMetadataEvaluation = + | { ok: true; metadata: RunnerXctestrunCacheMetadata } + | { ok: false; reason: 'cache_metadata_missing' } + | { + ok: false; + reason: 'cache_metadata_mismatch'; + differences: RunnerCacheMetadataDifference[]; + }; + function evaluateRunnerCacheMetadata( derived: string, expected: RunnerXctestrunCacheMetadata, -): - | { ok: true; metadata: RunnerXctestrunCacheMetadata } - | { ok: false; reason: 'cache_metadata_missing' | 'cache_metadata_mismatch' } { +): RunnerCacheMetadataEvaluation { const actual = readRunnerCacheMetadata(derived); if (!actual) { return { ok: false, reason: 'cache_metadata_missing' }; @@ -224,7 +237,11 @@ function evaluateRunnerCacheMetadata( stableJsonStringify(comparableRunnerCacheMetadata(actual)) !== stableJsonStringify(comparableRunnerCacheMetadata(expected)) ) { - return { ok: false, reason: 'cache_metadata_mismatch' }; + return { + ok: false, + reason: 'cache_metadata_mismatch', + differences: diffComparableRunnerCacheMetadata(expected, actual), + }; } return { ok: true, metadata: actual }; } @@ -408,11 +425,36 @@ export async function evaluateExistingXctestrun(options: { return { reason: 'project_root_mismatch', xctestrunPath, productPaths, source }; } if (!cacheMetadata.ok) { - return { reason: cacheMetadata.reason, xctestrunPath, productPaths, source }; + return cacheMetadata.reason === 'cache_metadata_mismatch' + ? { + reason: cacheMetadata.reason, + xctestrunPath, + productPaths, + source, + metadataDifferences: cacheMetadata.differences, + } + : { reason: cacheMetadata.reason, xctestrunPath, productPaths, source }; } return { reason: 'reuse_ready', xctestrunPath, productPaths, source }; } +/** + * Reports why a cache state cannot be reused, naming the differing keys when the + * cause is a metadata mismatch. + */ +export function emitRunnerXctestrunRebuildDecision( + existing: Exclude, + derived: string, +): void { + emitRunnerXctestrunDecision('rebuild', existing.reason, { + derived, + xctestrunPath: existing.xctestrunPath, + ...(existing.reason === 'cache_metadata_mismatch' + ? { metadataDifferences: existing.metadataDifferences } + : {}), + }); +} + export function emitRunnerXctestrunDecision( action: 'clean' | 'reuse' | 'rebuild' | 'build' | 'preserve', reason: diff --git a/packages/platform-apple/src/runner/runner-source.ts b/packages/platform-apple/src/runner/runner-source.ts index a0e4406be..2512e2513 100644 --- a/packages/platform-apple/src/runner/runner-source.ts +++ b/packages/platform-apple/src/runner/runner-source.ts @@ -1,3 +1,4 @@ +import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; @@ -29,3 +30,130 @@ export function resolveAppleSnapshotPresentationSourceRoot(projectRoot: string): } return path.join(projectRoot, PACKAGED_APPLE_SNAPSHOT_PRESENTATION_SOURCE_ROOT); } + +const RUNNER_SOURCE_IGNORED_DIR_NAMES = new Set(['.build', '.swiftpm', 'xcuserdata']); +const SNAPSHOT_PRESENTATION_SOURCE_IGNORED_DIR_NAMES = new Set([ + '.build', + '.swiftpm', + 'SnapshotPresentationConformance', + 'Tests', + 'xcuserdata', +]); + +type RunnerSourceFingerprintCacheEntry = { + fileStatsFingerprint: string; + sourceFingerprint: string; +}; + +const runnerSourceFingerprintCache = new Map(); + +export function computeRunnerSourceFingerprint(projectRoot: string): string { + const sourceRoots = [ + { + path: resolveAppleRunnerSourceRoot(projectRoot), + ignoredDirectoryNames: RUNNER_SOURCE_IGNORED_DIR_NAMES, + }, + { + path: resolveAppleSnapshotPresentationSourceRoot(projectRoot), + ignoredDirectoryNames: SNAPSHOT_PRESENTATION_SOURCE_IGNORED_DIR_NAMES, + }, + ]; + const files = collectRunnerSourceFiles(sourceRoots); + const fileStatsFingerprint = computeRunnerSourceFileStatsFingerprint(projectRoot, files); + const cacheKey = JSON.stringify(sourceRoots.map(({ path: sourcePath }) => sourcePath)); + const cached = runnerSourceFingerprintCache.get(cacheKey); + if (cached?.fileStatsFingerprint === fileStatsFingerprint) { + return cached.sourceFingerprint; + } + const hash = crypto.createHash('sha256'); + for (const file of files) { + const relativePath = path.relative(projectRoot, file); + hash.update(relativePath); + hash.update('\0'); + hash.update(fs.readFileSync(file)); + hash.update('\0'); + } + const sourceFingerprint = hash.digest('hex'); + runnerSourceFingerprintCache.set(cacheKey, { fileStatsFingerprint, sourceFingerprint }); + return sourceFingerprint; +} + +function computeRunnerSourceFileStatsFingerprint( + projectRoot: string, + files: readonly string[], +): string { + const hash = crypto.createHash('sha256'); + for (const file of files) { + const relativePath = path.relative(projectRoot, file); + const stat = fs.statSync(file); + hash.update(relativePath); + hash.update('\0'); + hash.update(String(stat.size)); + hash.update('\0'); + hash.update(String(Math.trunc(stat.mtimeMs))); + hash.update('\0'); + } + return hash.digest('hex'); +} + +type RunnerSourceRoot = Readonly<{ + path: string; + ignoredDirectoryNames: ReadonlySet; +}>; + +function collectRunnerSourceFiles(roots: readonly RunnerSourceRoot[]): string[] { + return [ + ...new Set( + roots.flatMap(({ path: sourcePath, ignoredDirectoryNames }) => + collectRunnerSourceFilesUnderRoot(sourcePath, ignoredDirectoryNames), + ), + ), + ].sort((a, b) => a.localeCompare(b)); +} + +function collectRunnerSourceFilesUnderRoot( + root: string, + ignoredDirectoryNames: ReadonlySet, +): string[] { + return fs.existsSync(root) + ? collectRunnerSourceFilesInDirectory(root, ignoredDirectoryNames) + : []; +} + +function collectRunnerSourceFilesInDirectory( + directory: string, + ignoredDirectoryNames: ReadonlySet, +): string[] { + const files: string[] = []; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + if (!ignoredDirectoryNames.has(entry.name)) { + files.push(...collectRunnerSourceFilesInDirectory(fullPath, ignoredDirectoryNames)); + } + } else if (entry.isFile() && isRunnerSourceFile(entry.name, fullPath)) { + files.push(fullPath); + } + } + return files; +} + +function isRunnerSourceFile(fileName: string, filePath: string): boolean { + if (fileName === 'project.pbxproj') { + return filePath.includes(`${path.sep}.xcodeproj${path.sep}`); + } + return [ + '.jpg', + '.json', + '.png', + '.swift', + '.m', + '.h', + '.plist', + '.entitlements', + '.xctestplan', + '.xcconfig', + '.storyboard', + '.xib', + ].includes(path.extname(fileName)); +} diff --git a/scripts/write-xcuitest-cache-metadata.mjs b/scripts/write-xcuitest-cache-metadata.mjs index 8520802af..18c94ec69 100644 --- a/scripts/write-xcuitest-cache-metadata.mjs +++ b/scripts/write-xcuitest-cache-metadata.mjs @@ -170,25 +170,31 @@ function resolveRunnerSdkName() { } function runAppleToolFingerprintCommand(command, args) { + const probe = [command, ...args].join(' '); + let output; try { - return ( - execFileSync(command, args, { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - timeout: 5000, - maxBuffer: 128 * 1024, - }).trim() || 'unknown' - ); - } catch { - return 'unknown'; + output = execFileSync(command, args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5000, + maxBuffer: 128 * 1024, + }).trim(); + } catch (error) { + throw new Error(`Apple toolchain probe failed: ${probe} (${error?.message ?? error})`); } + if (!output) { + throw new Error(`Apple toolchain probe produced no output: ${probe}`); + } + return output; } function parseXcodeVersionOutput(output) { - return { - version: output.match(/^Xcode\s+(.+)$/m)?.[1]?.trim() || 'unknown', - buildVersion: output.match(/^Build version\s+(.+)$/m)?.[1]?.trim() || 'unknown', - }; + const version = output.match(/^Xcode\s+(.+)$/m)?.[1]?.trim(); + const buildVersion = output.match(/^Build version\s+(.+)$/m)?.[1]?.trim(); + if (!version || !buildVersion) { + throw new Error('Apple toolchain probe produced unrecognized output: xcodebuild -version'); + } + return { version, buildVersion }; } function resolveRunnerToolchainFingerprint() {