From cca16c55f005b26c7a80c3c82fde22cd2953168e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 5 Sep 2026 20:22:25 +0200 Subject: [PATCH 1/4] fix(apple-runner): never compare an unavailable toolchain probe A timed-out or failed `xcodebuild -version` / `xcrun --show-sdk-*` probe used to fall back to the literal `unknown`, which was memoized for the process and then persisted into the rebuilt cache's metadata, so every later daemon on a healthy host mismatched again and paid a full build-for-testing. Unavailability is now a distinct outcome with no comparable value: only successful probes are memoized, an unreadable toolchain fails the cache decision with a retriable typed error naming the probe that could not answer, and the CI metadata writer refuses to persist a probe it could not read. The cache_metadata_mismatch diagnostic now lists the differing keys with expected and actual values instead of only saying the metadata differed. The runner-source fingerprint moves to the module that owns the runner's source roots, keeping the cache-metadata module within its size budget without adding a module to the Apple facades' eager closure. --- .../__tests__/apple-toolchain-fixtures.ts | 41 ++ .../__tests__/runner-cache-metadata.test.ts | 160 +++++++- .../src/runner/__tests__/runner-cache.test.ts | 64 ++++ .../src/runner/runner-artifact.ts | 3 + .../src/runner/runner-cache-metadata.ts | 356 +++++++++--------- .../platform-apple/src/runner/runner-cache.ts | 45 ++- .../src/runner/runner-source.ts | 128 +++++++ scripts/write-xcuitest-cache-metadata.mjs | 34 +- 8 files changed, 624 insertions(+), 207 deletions(-) create mode 100644 packages/platform-apple/src/runner/__tests__/apple-toolchain-fixtures.ts create mode 100644 packages/platform-apple/src/runner/__tests__/runner-cache.test.ts 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 0000000000..2921d0cfc8 --- /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 c34536a306..b16e000bd4 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,11 @@ import fs from 'node:fs'; import path from 'node:path'; -import { onTestFinished, test } from 'vitest'; +import { expect, onTestFinished, 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, @@ -12,6 +14,9 @@ import { 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( @@ -233,3 +238,156 @@ test('runner cache metadata ignores development-only SwiftPM trees but keeps run ).runnerSourceFingerprint; assert.notEqual(afterRunnerTestChange, afterIgnoredChanges); }); + +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), + ); + + 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'); + } +}); + +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'); +}); 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 0000000000..b184b2fda1 --- /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/runner-artifact.ts b/packages/platform-apple/src/runner/runner-artifact.ts index f090e6124e..0ee743c4d6 100644 --- a/packages/platform-apple/src/runner/runner-artifact.ts +++ b/packages/platform-apple/src/runner/runner-artifact.ts @@ -172,6 +172,9 @@ async function ensureXctestrunUnderCacheLock(params: { emitRunnerXctestrunDecision('rebuild', existing.reason, { derived, xctestrunPath: existing.xctestrunPath, + ...(existing.reason === 'cache_metadata_mismatch' + ? { metadataDifferences: existing.metadataDifferences } + : {}), }); } const reusable = await resolveReusableXctestrunArtifact({ diff --git a/packages/platform-apple/src/runner/runner-cache-metadata.ts b/packages/platform-apple/src/runner/runner-cache-metadata.ts index 9c39fa50ea..ef774a83f5 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,120 @@ 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); +// Lazy: createTtlMemo is a host capability, and module evaluation happens +// before the composition root binds the host. +let lazyToolchainProbeCache: TtlMemo | undefined; +function toolchainProbeCache(): TtlMemo { + lazyToolchainProbeCache ??= createTtlMemo(); + return lazyToolchainProbeCache; +} + +/** + * 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 xcode = parseXcodeVersionOutput(runToolchainProbe('xcodebuild', ['-version'])); + const sdkVersion = runToolchainProbe('xcrun', ['--sdk', sdkName, '--show-sdk-version']); + const sdkBuildVersion = runToolchainProbe('xcrun', [ + '--sdk', + sdkName, + '--show-sdk-build-version', + ]); + if (!xcode.ok || !sdkVersion.ok || !sdkBuildVersion.ok) { + throw unavailableToolchainError( + [xcode, sdkVersion, sdkBuildVersion].flatMap((probe) => (probe.ok ? [] : [probe.failure])), + ); + } return { - xcodeVersion: xcode.version, - xcodeBuildVersion: xcode.buildVersion, + xcodeVersion: xcode.value.version, + xcodeBuildVersion: xcode.value.buildVersion, sdkName, - sdkVersion: runAppleToolFingerprintCommand('xcrun', ['--sdk', sdkName, '--show-sdk-version']), - sdkBuildVersion: runAppleToolFingerprintCommand('xcrun', [ - '--sdk', - sdkName, - '--show-sdk-build-version', - ]), + sdkVersion: sdkVersion.value, + sdkBuildVersion: sdkBuildVersion.value, }; } -function runAppleToolFingerprintCommand(cmd: string, args: string[]): string { +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 cacheKey = JSON.stringify([cmd, args]); - const cached = appleToolFingerprintCache().get(cacheKey); - if (cached !== undefined) return cached; + const cached = toolchainProbeCache().get(cacheKey); + if (cached !== undefined) { + return { ok: true, value: cached }; + } + const result = readToolchainProbeOutput([cmd, ...args].join(' '), cmd, args); + if (result.ok) { + toolchainProbeCache().set(cacheKey, result.value); + } + return result; +} + +function readToolchainProbeOutput(probe: string, cmd: string, args: string[]): ProbeResult { + 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 +295,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 +356,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 554ca781d8..cbcf8a6c74 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,7 +425,15 @@ 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 }; } diff --git a/packages/platform-apple/src/runner/runner-source.ts b/packages/platform-apple/src/runner/runner-source.ts index a0e4406beb..2512e25130 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 8520802af6..18c94ec693 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() { From 9f68e0064171b30535a1855c8c8e6d85c512e604 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 5 Sep 2026 21:32:33 +0200 Subject: [PATCH 2/4] refactor(apple-runner): home the fingerprint tests and the rebuild-decision glue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests mirror source topology: the runner-source fingerprint tests move with the function into runner-source.test.ts and call it directly instead of reaching it through resolveExpectedRunnerCacheMetadata. The rebuild diagnostic's mismatch details move next to the cache state that carries them, so runner-artifact.ts — already past the 500-line extract threshold — gains no behavior. --- .../__tests__/runner-cache-metadata.test.ts | 86 +------------------ .../runner/__tests__/runner-source.test.ts | 66 ++++++++++++++ .../src/runner/runner-artifact.ts | 9 +- .../platform-apple/src/runner/runner-cache.ts | 17 ++++ 4 files changed, 86 insertions(+), 92 deletions(-) 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 b16e000bd4..41d9c3b79a 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,6 +1,4 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { expect, 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'; @@ -13,7 +11,6 @@ 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(); @@ -158,87 +155,6 @@ 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'); - } - - 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'); - } - 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('metadata diff names only the comparable keys that differ, with expected and actual', () => { const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); const actual = { 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 94baf98c37..116a51748e 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 0ee743c4d6..b80182ed52 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,13 +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, - ...(existing.reason === 'cache_metadata_mismatch' - ? { metadataDifferences: existing.metadataDifferences } - : {}), - }); + emitRunnerXctestrunRebuildDecision(existing, derived); } const reusable = await resolveReusableXctestrunArtifact({ device, diff --git a/packages/platform-apple/src/runner/runner-cache.ts b/packages/platform-apple/src/runner/runner-cache.ts index cbcf8a6c74..94f0431f50 100644 --- a/packages/platform-apple/src/runner/runner-cache.ts +++ b/packages/platform-apple/src/runner/runner-cache.ts @@ -438,6 +438,23 @@ export async function evaluateExistingXctestrun(options: { 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: From d6a89f5e330996a786b2bdd642cf95e441e8b20b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 5 Sep 2026 21:47:20 +0200 Subject: [PATCH 3/4] fix(apple-runner): memoize only a parsed toolchain fingerprint runToolchainProbe cached every nonempty zero-exit answer before parseXcodeVersionOutput could classify it, so a transient malformed xcodebuild answer stayed cached and every later cache decision in the process kept failing after the host recovered. The memo now holds the complete parsed fingerprint per SDK, written only after all three probes answered and parsed; a failed round keeps nothing, so the next request re-probes. Tests cover malformed-to-healthy recovery in one process without resetting the memo, and that a partial round is not kept. --- .../__tests__/runner-cache-metadata.test.ts | 337 ++++++++++++------ .../src/runner/runner-cache-metadata.ts | 292 +++++++++------ 2 files changed, 407 insertions(+), 222 deletions(-) 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 41d9c3b79a..fa46fc17d7 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,7 +1,7 @@ -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 { 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, @@ -10,59 +10,67 @@ import { resolveRunnerPerformanceBuildSettings, resolveRunnerSandboxBuildArgs, resolveExpectedRunnerCacheMetadata, -} from '../runner-cache-metadata.ts'; -import { appleToolchainProbeResult, stubAppleToolchainProbes } from './apple-toolchain-fixtures.ts'; +} from "../runner-cache-metadata.ts"; +import { + appleToolchainProbeResult, + stubAppleToolchainProbes, +} from "./apple-toolchain-fixtures.ts"; const runCmdSync = stubAppleToolchainProbes(); -test('resolveRunnerMaxConcurrentDestinationsFlag uses simulator flag for simulators', () => { +test("resolveRunnerMaxConcurrentDestinationsFlag uses simulator flag for simulators", () => { assert.equal( resolveRunnerMaxConcurrentDestinationsFlag(IOS_SIMULATOR), - '-maximum-concurrent-test-simulator-destinations', + "-maximum-concurrent-test-simulator-destinations", ); }); -test('resolveRunnerMaxConcurrentDestinationsFlag uses device flag for physical devices', () => { +test("resolveRunnerMaxConcurrentDestinationsFlag uses device flag for physical devices", () => { assert.equal( resolveRunnerMaxConcurrentDestinationsFlag(IOS_DEVICE), - '-maximum-concurrent-test-device-destinations', + "-maximum-concurrent-test-device-destinations", ); }); -test('resolveRunnerMaxConcurrentDestinationsFlag uses device flag for macOS desktop', () => { +test("resolveRunnerMaxConcurrentDestinationsFlag uses device flag for macOS desktop", () => { assert.equal( resolveRunnerMaxConcurrentDestinationsFlag(MACOS_DEVICE), - '-maximum-concurrent-test-device-destinations', + "-maximum-concurrent-test-device-destinations", ); }); -test('resolveRunnerSigningBuildSettings returns empty args without env overrides', () => { +test("resolveRunnerSigningBuildSettings returns empty args without env overrides", () => { assert.deepEqual(resolveRunnerSigningBuildSettings({}), []); }); -test('resolveRunnerSigningBuildSettings disables signing for macOS desktop builds', () => { +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', - 'CODE_SIGN_IDENTITY=', - 'DEVELOPMENT_TEAM=', + "CODE_SIGNING_ALLOWED=NO", + "CODE_SIGNING_REQUIRED=NO", + "CODE_SIGN_IDENTITY=", + "DEVELOPMENT_TEAM=", ], ); }); -test('resolveRunnerSigningBuildSettings enables automatic signing for device builds without forcing identity', () => { - assert.deepEqual(resolveRunnerSigningBuildSettings({}, true), ['CODE_SIGN_STYLE=Automatic']); +test("resolveRunnerSigningBuildSettings enables automatic signing for device builds without forcing identity", () => { + assert.deepEqual(resolveRunnerSigningBuildSettings({}, true), [ + "CODE_SIGN_STYLE=Automatic", + ]); }); -test('resolveRunnerSigningBuildSettings ignores device signing overrides for simulator builds', () => { +test("resolveRunnerSigningBuildSettings ignores device signing overrides for simulator builds", () => { assert.deepEqual( resolveRunnerSigningBuildSettings( { - AGENT_DEVICE_IOS_TEAM_ID: 'ABCDE12345', - AGENT_DEVICE_IOS_SIGNING_IDENTITY: 'Apple Development', - AGENT_DEVICE_IOS_PROVISIONING_PROFILE: 'My Profile', + AGENT_DEVICE_IOS_TEAM_ID: "ABCDE12345", + AGENT_DEVICE_IOS_SIGNING_IDENTITY: "Apple Development", + AGENT_DEVICE_IOS_PROVISIONING_PROFILE: "My Profile", }, false, ), @@ -70,62 +78,62 @@ test('resolveRunnerSigningBuildSettings ignores device signing overrides for sim ); }); -test('resolveRunnerSigningBuildSettings applies optional overrides when provided', () => { +test("resolveRunnerSigningBuildSettings applies optional overrides when provided", () => { const settings = resolveRunnerSigningBuildSettings( { - AGENT_DEVICE_IOS_TEAM_ID: 'ABCDE12345', - AGENT_DEVICE_IOS_SIGNING_IDENTITY: 'Apple Development', - AGENT_DEVICE_IOS_PROVISIONING_PROFILE: 'My Profile', + AGENT_DEVICE_IOS_TEAM_ID: "ABCDE12345", + AGENT_DEVICE_IOS_SIGNING_IDENTITY: "Apple Development", + AGENT_DEVICE_IOS_PROVISIONING_PROFILE: "My Profile", }, true, ); assert.deepEqual(settings, [ - 'CODE_SIGN_STYLE=Manual', - 'DEVELOPMENT_TEAM=ABCDE12345', - 'CODE_SIGN_IDENTITY=Apple Development', - 'PROVISIONING_PROFILE_SPECIFIER=My Profile', + "CODE_SIGN_STYLE=Manual", + "DEVELOPMENT_TEAM=ABCDE12345", + "CODE_SIGN_IDENTITY=Apple Development", + "PROVISIONING_PROFILE_SPECIFIER=My Profile", ]); }); -test('resolveRunnerSigningBuildSettings switches to manual signing when a profile is set without team or identity', () => { +test("resolveRunnerSigningBuildSettings switches to manual signing when a profile is set without team or identity", () => { const settings = resolveRunnerSigningBuildSettings( - { AGENT_DEVICE_IOS_PROVISIONING_PROFILE: 'My Profile' }, + { AGENT_DEVICE_IOS_PROVISIONING_PROFILE: "My Profile" }, true, ); assert.deepEqual(settings, [ - 'CODE_SIGN_STYLE=Manual', - 'PROVISIONING_PROFILE_SPECIFIER=My Profile', + "CODE_SIGN_STYLE=Manual", + "PROVISIONING_PROFILE_SPECIFIER=My Profile", ]); }); -test('resolveRunnerPerformanceBuildSettings disables indexing and code coverage', () => { +test("resolveRunnerPerformanceBuildSettings disables indexing and code coverage", () => { assert.deepEqual(resolveRunnerPerformanceBuildSettings(), [ - 'COMPILER_INDEX_STORE_ENABLE=NO', - 'ENABLE_CODE_COVERAGE=NO', - 'ONLY_ACTIVE_ARCH=YES', - 'ENABLE_PREVIEWS=NO', - 'ENABLE_DEBUG_DYLIB=NO', + "COMPILER_INDEX_STORE_ENABLE=NO", + "ENABLE_CODE_COVERAGE=NO", + "ONLY_ACTIVE_ARCH=YES", + "ENABLE_PREVIEWS=NO", + "ENABLE_DEBUG_DYLIB=NO", ]); }); -test('resolveRunnerSandboxBuildArgs disables nested Xcode and Swift sandboxing', () => { +test("resolveRunnerSandboxBuildArgs disables nested Xcode and Swift sandboxing", () => { assert.deepEqual(resolveRunnerSandboxBuildArgs(), [ - '-IDEPackageSupportDisableManifestSandbox=1', - '-IDEPackageSupportDisablePluginExecutionSandbox=1', - 'ENABLE_USER_SCRIPT_SANDBOXING=NO', - 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox', + "-IDEPackageSupportDisableManifestSandbox=1", + "-IDEPackageSupportDisablePluginExecutionSandbox=1", + "ENABLE_USER_SCRIPT_SANDBOXING=NO", + "OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox", ]); }); -test('resolveRunnerSandboxBuildArgs includes Swift runner unit tests only when requested', () => { +test("resolveRunnerSandboxBuildArgs includes Swift runner unit tests only when requested", () => { const previous = process.env.AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS; try { - process.env.AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS = '1'; + process.env.AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS = "1"; assert.deepEqual(resolveRunnerSandboxBuildArgs(), [ - '-IDEPackageSupportDisableManifestSandbox=1', - '-IDEPackageSupportDisablePluginExecutionSandbox=1', - 'ENABLE_USER_SCRIPT_SANDBOXING=NO', - 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_UNIT_TESTS', + "-IDEPackageSupportDisableManifestSandbox=1", + "-IDEPackageSupportDisablePluginExecutionSandbox=1", + "ENABLE_USER_SCRIPT_SANDBOXING=NO", + "OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_UNIT_TESTS", ]); } finally { if (previous === undefined) { @@ -136,76 +144,83 @@ test('resolveRunnerSandboxBuildArgs includes Swift runner unit tests only when r } }); -test('resolveRunnerBundleBuildSettings returns default bundle identifiers', () => { +test("resolveRunnerBundleBuildSettings returns default bundle identifiers", () => { assert.deepEqual(resolveRunnerBundleBuildSettings({}), [ - 'AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID=com.callstack.agentdevice.runner', - 'AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID=com.callstack.agentdevice.runner.uitests', + "AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID=com.callstack.agentdevice.runner", + "AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID=com.callstack.agentdevice.runner.uitests", ]); }); -test('resolveRunnerBundleBuildSettings uses AGENT_DEVICE_IOS_BUNDLE_ID when provided', () => { +test("resolveRunnerBundleBuildSettings uses AGENT_DEVICE_IOS_BUNDLE_ID when provided", () => { assert.deepEqual( resolveRunnerBundleBuildSettings({ - AGENT_DEVICE_IOS_BUNDLE_ID: 'com.example.agent-device.runner', + AGENT_DEVICE_IOS_BUNDLE_ID: "com.example.agent-device.runner", }), [ - 'AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID=com.example.agent-device.runner', - 'AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID=com.example.agent-device.runner.uitests', + "AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID=com.example.agent-device.runner", + "AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID=com.example.agent-device.runner.uitests", ], ); }); -test('metadata diff names only the comparable keys that differ, with expected and actual', () => { +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'], + xcodeBuildVersion: "17A100", + runnerPerformanceBuildSettings: ["ENABLE_CODE_COVERAGE=YES"], artifacts: { - xctestrunPath: '/tmp/derived/Runner.xctestrun', + xctestrunPath: "/tmp/derived/Runner.xctestrun", xctestrunMtimeMs: 1, xctestrunSize: 2, - productPaths: [{ path: '/tmp/derived/Runner.app', mtimeMs: 1, size: 2 }], + productPaths: [{ path: "/tmp/derived/Runner.app", mtimeMs: 1, size: 2 }], }, }; assert.deepEqual(diffComparableRunnerCacheMetadata(expected, actual), [ { - key: 'runnerPerformanceBuildSettings', + key: "runnerPerformanceBuildSettings", expected: JSON.stringify(expected.runnerPerformanceBuildSettings), actual: '["ENABLE_CODE_COVERAGE=YES"]', }, - { key: 'xcodeBuildVersion', expected: '"17C52"', actual: '"17A100"' }, + { key: "xcodeBuildVersion", expected: '"17C52"', actual: '"17A100"' }, ]); }); -test('metadata diff reports a key only one side carries as absent', () => { +test("metadata diff reports a key only one side carries as absent", () => { const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); - const { sdkBuildVersion: _sdkBuildVersion, ...withoutSdkBuildVersion } = expected; + const { sdkBuildVersion: _sdkBuildVersion, ...withoutSdkBuildVersion } = + expected; assert.deepEqual( - diffComparableRunnerCacheMetadata(expected, withoutSdkBuildVersion as typeof expected), - [{ key: 'sdkBuildVersion', expected: '"23C53"', actual: '(absent)' }], + diffComparableRunnerCacheMetadata( + expected, + withoutSdkBuildVersion as typeof expected, + ), + [{ key: "sdkBuildVersion", expected: '"23C53"', actual: "(absent)" }], ); }); -test('metadata diff is empty for identical metadata', () => { +test("metadata diff is empty for identical metadata", () => { const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); - assert.deepEqual(diffComparableRunnerCacheMetadata(expected, { ...expected }), []); + assert.deepEqual( + diffComparableRunnerCacheMetadata(expected, { ...expected }), + [], + ); }); -test('metadata diff elides an over-long value in the middle so both ends stay comparable', () => { +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 longSetting = (suffix: string) => [`${"A".repeat(400)}=${suffix}`]; const [difference] = diffComparableRunnerCacheMetadata( - { ...expected, runnerBundleBuildSettings: longSetting('one') }, - { ...expected, runnerBundleBuildSettings: longSetting('two') }, + { ...expected, runnerBundleBuildSettings: longSetting("one") }, + { ...expected, runnerBundleBuildSettings: longSetting("two") }, ); - assert.equal(difference?.key, 'runnerBundleBuildSettings'); + assert.equal(difference?.key, "runnerBundleBuildSettings"); assert.ok((difference?.expected.length ?? 0) <= 300); assert.ok(difference?.expected.startsWith('["AAA')); assert.ok(difference?.expected.endsWith('=one"]')); @@ -218,82 +233,113 @@ function unavailableProbes(): { probe: string; reason: string }[] { return []; } catch (error) { assert.ok(error instanceof AppError); - assert.equal(error.details?.reason, 'apple_toolchain_probe_unavailable'); + 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', () => { +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, - }); + 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' }]); + assert.deepEqual(unavailableProbes(), [ + { probe: "xcodebuild -version", reason: "probe_error" }, + ]); }); -test('a failing probe reports its exit status rather than a fabricated SDK version', () => { +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' } + command === "xcrun" + ? { + exitCode: 70, + stdout: "", + stderr: "xcrun: error: SDK cannot be located\n", + } : appleToolchainProbeResult(command, args), ); assert.deepEqual(unavailableProbes(), [ - { probe: 'xcrun --sdk iphonesimulator --show-sdk-version', reason: 'nonzero_exit' }, - { probe: 'xcrun --sdk iphonesimulator --show-sdk-build-version', reason: 'nonzero_exit' }, + { + 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', () => { +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: '' } + 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' }, + { probe: "xcodebuild -version", reason: "unparsable_output" }, ]); }); -test('an empty probe answer is unavailable rather than an empty cache key field', () => { +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: '' } + 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' }, + { + probe: "xcrun --sdk iphonesimulator --show-sdk-build-version", + reason: "empty_output", + }, ]); }); -test('an unavailable toolchain fails the cache decision with a retriable typed error', () => { +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', {}); + 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'); + 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.code, "COMMAND_FAILED"); assert.equal(error.details?.retriable, true); - expect(error.message).toContain('xcodebuild -version'); - expect(String(error.details?.hint)).toContain('xcode-select'); + expect(error.message).toContain("xcodebuild -version"); + expect(String(error.details?.hint)).toContain("xcode-select"); } }); -test('an unavailable probe never reaches cache metadata, and is not memoized as one', () => { +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', {}); + throw new AppError( + "COMMAND_FAILED", + "xcodebuild timed out after 5000ms", + {}, + ); }); expect(() => resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR)).toThrow( /Could not read the Xcode toolchain versions/, @@ -302,8 +348,77 @@ test('an unavailable probe never reaches cache metadata, and is not memoized as 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'); + 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/runner-cache-metadata.ts b/packages/platform-apple/src/runner/runner-cache-metadata.ts index ef774a83f5..951f7b242b 100644 --- a/packages/platform-apple/src/runner/runner-cache-metadata.ts +++ b/packages/platform-apple/src/runner/runner-cache-metadata.ts @@ -1,8 +1,8 @@ -import crypto from 'node:crypto'; -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 crypto from "node:crypto"; +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 { createTtlMemo, isEnvTruthy, @@ -10,33 +10,37 @@ import { readVersion, runCmdSync, type TtlMemo, -} from './host.ts'; +} from "./host.ts"; import { resolveRunnerBuildDestinationFamily, resolveRunnerDerivedBaseName, resolveRunnerPlatformName, resolveRunnerSdkName, -} from './apple-runner-platform.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'; +} from "./apple-runner-platform.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_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`.'; + "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', - 'ENABLE_USER_SCRIPT_SANDBOXING=NO', + "-IDEPackageSupportDisableManifestSandbox=1", + "-IDEPackageSupportDisablePluginExecutionSandbox=1", + "ENABLE_USER_SCRIPT_SANDBOXING=NO", ] as const; -const RUNNER_RUNTIME_SWIFT_FLAGS = '$(inherited) -disable-sandbox'; +const RUNNER_RUNTIME_SWIFT_FLAGS = "$(inherited) -disable-sandbox"; const RUNNER_UNIT_TEST_SWIFT_FLAGS = - '$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_UNIT_TESTS'; + "$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_UNIT_TESTS"; /** Toolchain half of the runner cache key. Every field is a probed value. */ export type RunnerToolchainFingerprint = { @@ -49,21 +53,20 @@ export type RunnerToolchainFingerprint = { type ToolchainProbeFailure = { probe: string; - reason: 'probe_error' | 'nonzero_exit' | 'empty_output' | 'unparsable_output'; + reason: "probe_error" | "nonzero_exit" | "empty_output" | "unparsable_output"; detail: string; }; type ProbeResult = - | { ok: true; value: Value } - | { ok: false; failure: ToolchainProbeFailure }; + { 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; + deviceKind: DeviceInfo["kind"]; + target: NonNullable; buildDestinationFamily: string; runnerBundleBuildSettings: string[]; runnerSigningBuildSettings: string[]; @@ -86,25 +89,33 @@ export type RunnerXctestrunCacheProductArtifact = { }; function normalizeBundleId(value: string | undefined): string { - return value?.trim() ?? ''; + return value?.trim() ?? ""; } -export function resolveRunnerAppBundleId(env: NodeJS.ProcessEnv = process.env): string { +export function resolveRunnerAppBundleId( + env: NodeJS.ProcessEnv = process.env, +): string { const configured = normalizeBundleId(env.AGENT_DEVICE_IOS_BUNDLE_ID) || normalizeBundleId(env.AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID); return configured || DEFAULT_IOS_RUNNER_APP_BUNDLE_ID; } -function resolveRunnerTestBundleId(env: NodeJS.ProcessEnv = process.env): string { - const configured = normalizeBundleId(env.AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID); +function resolveRunnerTestBundleId( + env: NodeJS.ProcessEnv = process.env, +): string { + const configured = normalizeBundleId( + env.AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID, + ); if (configured) { return configured; } return `${resolveRunnerAppBundleId(env)}.uitests`; } -function resolveRunnerContainerBundleIds(env: NodeJS.ProcessEnv = process.env): string[] { +function resolveRunnerContainerBundleIds( + env: NodeJS.ProcessEnv = process.env, +): string[] { const appBundleId = resolveRunnerAppBundleId(env); const testBundleId = resolveRunnerTestBundleId(env); return Array.from( @@ -118,9 +129,8 @@ function resolveRunnerContainerBundleIds(env: NodeJS.ProcessEnv = process.env): ); } -export const IOS_RUNNER_CONTAINER_BUNDLE_IDS: string[] = resolveRunnerContainerBundleIds( - process.env, -); +export const IOS_RUNNER_CONTAINER_BUNDLE_IDS: string[] = + resolveRunnerContainerBundleIds(process.env); export function resolveExpectedRunnerCacheMetadata( device: DeviceInfo, @@ -131,15 +141,17 @@ export function resolveExpectedRunnerCacheMetadata( schemaVersion: RUNNER_CACHE_SCHEMA_VERSION, packageVersion: readVersion(projectRoot), runnerSourceFingerprint: computeRunnerSourceFingerprint(projectRoot), - ...requireRunnerToolchainFingerprint(resolveRunnerSdkName(platformName, device.kind)), + ...requireRunnerToolchainFingerprint( + resolveRunnerSdkName(platformName, device.kind), + ), platformName, deviceKind: device.kind, - target: device.target ?? 'mobile', + target: device.target ?? "mobile", buildDestinationFamily: resolveRunnerBuildDestinationFamily(device), runnerBundleBuildSettings: resolveRunnerBundleBuildSettings(process.env), runnerSigningBuildSettings: resolveRunnerSigningBuildSettings( process.env, - device.kind === 'device', + device.kind === "device", device, ), runnerPerformanceBuildSettings: resolveRunnerPerformanceBuildSettings(), @@ -148,11 +160,20 @@ export function resolveExpectedRunnerCacheMetadata( } // Lazy: createTtlMemo is a host capability, and module evaluation happens -// before the composition root binds the host. -let lazyToolchainProbeCache: TtlMemo | undefined; -function toolchainProbeCache(): TtlMemo { - lazyToolchainProbeCache ??= createTtlMemo(); - return lazyToolchainProbeCache; +// 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< + string, + RunnerToolchainFingerprint +> { + lazyToolchainFingerprintCache ??= createTtlMemo< + string, + RunnerToolchainFingerprint + >(); + return lazyToolchainFingerprintCache; } /** @@ -161,36 +182,65 @@ function toolchainProbeCache(): TtlMemo { * 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 xcode = parseXcodeVersionOutput(runToolchainProbe('xcodebuild', ['-version'])); - const sdkVersion = runToolchainProbe('xcrun', ['--sdk', sdkName, '--show-sdk-version']); - const sdkBuildVersion = runToolchainProbe('xcrun', [ - '--sdk', +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, - '--show-sdk-build-version', + "--show-sdk-build-version", ]); if (!xcode.ok || !sdkVersion.ok || !sdkBuildVersion.ok) { - throw unavailableToolchainError( - [xcode, sdkVersion, sdkBuildVersion].flatMap((probe) => (probe.ok ? [] : [probe.failure])), - ); + return { + ok: false, + failures: [xcode, sdkVersion, sdkBuildVersion].flatMap((probe) => + probe.ok ? [] : [probe.failure], + ), + }; } return { - xcodeVersion: xcode.value.version, - xcodeBuildVersion: xcode.value.buildVersion, - sdkName, - sdkVersion: sdkVersion.value, - sdkBuildVersion: sdkBuildVersion.value, + ok: true, + value: { + xcodeVersion: xcode.value.version, + xcodeBuildVersion: xcode.value.buildVersion, + sdkName, + sdkVersion: sdkVersion.value, + sdkBuildVersion: sdkBuildVersion.value, + }, }; } -function unavailableToolchainError(failures: readonly ToolchainProbeFailure[]): AppError { +function unavailableToolchainError( + failures: readonly ToolchainProbeFailure[], +): AppError { return new AppError( - 'COMMAND_FAILED', + "COMMAND_FAILED", `Could not read the Xcode toolchain versions the Apple runner cache is keyed on (${failures .map((failure) => `${failure.probe}: ${failure.detail}`) - .join('; ')})`, + .join("; ")})`, { - reason: 'apple_toolchain_probe_unavailable', + reason: "apple_toolchain_probe_unavailable", retriable: true, probes: failures, hint: TOOLCHAIN_PROBE_HINT, @@ -199,19 +249,7 @@ function unavailableToolchainError(failures: readonly ToolchainProbeFailure[]): } function runToolchainProbe(cmd: string, args: string[]): ProbeResult { - const cacheKey = JSON.stringify([cmd, args]); - const cached = toolchainProbeCache().get(cacheKey); - if (cached !== undefined) { - return { ok: true, value: cached }; - } - const result = readToolchainProbeOutput([cmd, ...args].join(' '), cmd, args); - if (result.ok) { - toolchainProbeCache().set(cacheKey, result.value); - } - return result; -} - -function readToolchainProbeOutput(probe: string, cmd: string, args: string[]): ProbeResult { + const probe = [cmd, ...args].join(" "); let output: { exitCode: number; stdout: string; stderr: string }; try { output = runCmdSync(cmd, args, { @@ -220,17 +258,23 @@ function readToolchainProbeOutput(probe: string, cmd: string, args: string[]): P maxBuffer: TOOLCHAIN_PROBE_MAX_BUFFER, }); } catch (error) { - return probeFailure(probe, 'probe_error', error instanceof Error ? error.message : `${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()}` : ''}`, + "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'); + return value + ? { ok: true, value } + : probeFailure(probe, "empty_output", "no output"); } function parseXcodeVersionOutput( @@ -240,12 +284,14 @@ function parseXcodeVersionOutput( return output; } const version = output.value.match(/^Xcode\s+(.+)$/m)?.[1]?.trim(); - const buildVersion = output.value.match(/^Build version\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', ' ')}`, + "xcodebuild -version", + "unparsable_output", + `unrecognized output: ${output.value.replaceAll("\n", " ")}`, ); } return { ok: true, value: { version, buildVersion } }; @@ -253,7 +299,7 @@ function parseXcodeVersionOutput( function probeFailure( probe: string, - reason: ToolchainProbeFailure['reason'], + reason: ToolchainProbeFailure["reason"], detail: string, ): { ok: false; failure: ToolchainProbeFailure } { const bounded = @@ -277,21 +323,31 @@ export function resolveRunnerDerivedPath( } function resolveRunnerDerivedBasePath(device: DeviceInfo): string { - return path.join(RUNNER_DERIVED_ROOT, 'derived', resolveRunnerDerivedBaseName(device)); + return path.join( + RUNNER_DERIVED_ROOT, + "derived", + resolveRunnerDerivedBaseName(device), + ); } -function resolveRunnerDerivedCacheKey(metadata: RunnerXctestrunCacheMetadata): string { +function resolveRunnerDerivedCacheKey( + metadata: RunnerXctestrunCacheMetadata, +): string { const hash = crypto - .createHash('sha256') + .createHash("sha256") .update(stableJsonStringify(comparableRunnerCacheMetadata(metadata))) - .digest('hex'); + .digest("hex"); return `cache-${hash.slice(0, 16)}`; } export function comparableRunnerCacheMetadata( metadata: RunnerXctestrunCacheMetadata, -): Omit { - const { artifacts: _artifacts, packageVersion: _packageVersion, ...comparable } = metadata; +): Omit { + const { + artifacts: _artifacts, + packageVersion: _packageVersion, + ...comparable + } = metadata; return comparable; } @@ -305,12 +361,21 @@ 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)])] + 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 expectedValue = renderRunnerCacheMetadataValue( + expectedComparable[key], + ); const actualValue = renderRunnerCacheMetadataValue(actualComparable[key]); return expectedValue === actualValue ? [] @@ -325,7 +390,7 @@ export function diffComparableRunnerCacheMetadata( } function renderRunnerCacheMetadataValue(value: unknown): string { - return value === undefined ? '(absent)' : stableJsonStringify(value); + return value === undefined ? "(absent)" : stableJsonStringify(value); } // Elides the middle: build-setting lists differ in their last entry as often as @@ -346,7 +411,7 @@ function sortJsonKeys(value: unknown): unknown { if (Array.isArray(value)) { return value.map((item) => sortJsonKeys(item)); } - if (!value || typeof value !== 'object') { + if (!value || typeof value !== "object") { return value; } return Object.fromEntries( @@ -356,35 +421,38 @@ function sortJsonKeys(value: unknown): unknown { ); } -export function resolveRunnerMaxConcurrentDestinationsFlag(device: DeviceInfo): string { +export function resolveRunnerMaxConcurrentDestinationsFlag( + device: DeviceInfo, +): string { if (isMacOs(device)) { - return '-maximum-concurrent-test-device-destinations'; + return "-maximum-concurrent-test-device-destinations"; } - return device.kind === 'device' - ? '-maximum-concurrent-test-device-destinations' - : '-maximum-concurrent-test-simulator-destinations'; + return device.kind === "device" + ? "-maximum-concurrent-test-device-destinations" + : "-maximum-concurrent-test-simulator-destinations"; } export function resolveRunnerSigningBuildSettings( env: NodeJS.ProcessEnv = process.env, forDevice = false, - device: Pick = { platform: 'apple' }, + device: Pick = { platform: "apple" }, ): string[] { if (isMacOs(device)) { return [ - 'CODE_SIGNING_ALLOWED=NO', - 'CODE_SIGNING_REQUIRED=NO', - 'CODE_SIGN_IDENTITY=', - 'DEVELOPMENT_TEAM=', + "CODE_SIGNING_ALLOWED=NO", + "CODE_SIGNING_REQUIRED=NO", + "CODE_SIGN_IDENTITY=", + "DEVELOPMENT_TEAM=", ]; } if (!forDevice) { return []; } - const teamId = env.AGENT_DEVICE_IOS_TEAM_ID?.trim() || ''; - const configuredIdentity = env.AGENT_DEVICE_IOS_SIGNING_IDENTITY?.trim() || ''; - const profile = env.AGENT_DEVICE_IOS_PROVISIONING_PROFILE?.trim() || ''; - const args = [`CODE_SIGN_STYLE=${profile ? 'Manual' : 'Automatic'}`]; + const teamId = env.AGENT_DEVICE_IOS_TEAM_ID?.trim() || ""; + const configuredIdentity = + env.AGENT_DEVICE_IOS_SIGNING_IDENTITY?.trim() || ""; + const profile = env.AGENT_DEVICE_IOS_PROVISIONING_PROFILE?.trim() || ""; + const args = [`CODE_SIGN_STYLE=${profile ? "Manual" : "Automatic"}`]; if (teamId) { args.push(`DEVELOPMENT_TEAM=${teamId}`); } @@ -395,7 +463,9 @@ export function resolveRunnerSigningBuildSettings( return args; } -export function resolveRunnerBundleBuildSettings(env: NodeJS.ProcessEnv = process.env): string[] { +export function resolveRunnerBundleBuildSettings( + env: NodeJS.ProcessEnv = process.env, +): string[] { const appBundleId = resolveRunnerAppBundleId(env); const testBundleId = resolveRunnerTestBundleId(env); return [ @@ -406,11 +476,11 @@ export function resolveRunnerBundleBuildSettings(env: NodeJS.ProcessEnv = proces export function resolveRunnerPerformanceBuildSettings(): string[] { return [ - 'COMPILER_INDEX_STORE_ENABLE=NO', - 'ENABLE_CODE_COVERAGE=NO', - 'ONLY_ACTIVE_ARCH=YES', - 'ENABLE_PREVIEWS=NO', - 'ENABLE_DEBUG_DYLIB=NO', + "COMPILER_INDEX_STORE_ENABLE=NO", + "ENABLE_CODE_COVERAGE=NO", + "ONLY_ACTIVE_ARCH=YES", + "ENABLE_PREVIEWS=NO", + "ENABLE_DEBUG_DYLIB=NO", ]; } From 4ffd5422340cf83b03e4077e06fde8f3c9d6e6ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 5 Sep 2026 21:51:23 +0200 Subject: [PATCH 4/4] style(apple-runner): oxfmt the cache-metadata module and its tests --- .../__tests__/runner-cache-metadata.test.ts | 312 ++++++++---------- .../src/runner/runner-cache-metadata.ts | 239 +++++--------- 2 files changed, 226 insertions(+), 325 deletions(-) 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 fa46fc17d7..dbcba191fe 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,7 +1,7 @@ -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 { 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, @@ -10,67 +10,62 @@ import { resolveRunnerPerformanceBuildSettings, resolveRunnerSandboxBuildArgs, resolveExpectedRunnerCacheMetadata, -} from "../runner-cache-metadata.ts"; -import { - appleToolchainProbeResult, - stubAppleToolchainProbes, -} from "./apple-toolchain-fixtures.ts"; +} from '../runner-cache-metadata.ts'; +import { appleToolchainProbeResult, stubAppleToolchainProbes } from './apple-toolchain-fixtures.ts'; const runCmdSync = stubAppleToolchainProbes(); -test("resolveRunnerMaxConcurrentDestinationsFlag uses simulator flag for simulators", () => { +test('resolveRunnerMaxConcurrentDestinationsFlag uses simulator flag for simulators', () => { assert.equal( resolveRunnerMaxConcurrentDestinationsFlag(IOS_SIMULATOR), - "-maximum-concurrent-test-simulator-destinations", + '-maximum-concurrent-test-simulator-destinations', ); }); -test("resolveRunnerMaxConcurrentDestinationsFlag uses device flag for physical devices", () => { +test('resolveRunnerMaxConcurrentDestinationsFlag uses device flag for physical devices', () => { assert.equal( resolveRunnerMaxConcurrentDestinationsFlag(IOS_DEVICE), - "-maximum-concurrent-test-device-destinations", + '-maximum-concurrent-test-device-destinations', ); }); -test("resolveRunnerMaxConcurrentDestinationsFlag uses device flag for macOS desktop", () => { +test('resolveRunnerMaxConcurrentDestinationsFlag uses device flag for macOS desktop', () => { assert.equal( resolveRunnerMaxConcurrentDestinationsFlag(MACOS_DEVICE), - "-maximum-concurrent-test-device-destinations", + '-maximum-concurrent-test-device-destinations', ); }); -test("resolveRunnerSigningBuildSettings returns empty args without env overrides", () => { +test('resolveRunnerSigningBuildSettings returns empty args without env overrides', () => { assert.deepEqual(resolveRunnerSigningBuildSettings({}), []); }); -test("resolveRunnerSigningBuildSettings disables signing for macOS desktop builds", () => { +test('resolveRunnerSigningBuildSettings disables signing for macOS desktop builds', () => { assert.deepEqual( resolveRunnerSigningBuildSettings({}, true, { - platform: "apple", - appleOs: "macos", + platform: 'apple', + appleOs: 'macos', }), [ - "CODE_SIGNING_ALLOWED=NO", - "CODE_SIGNING_REQUIRED=NO", - "CODE_SIGN_IDENTITY=", - "DEVELOPMENT_TEAM=", + 'CODE_SIGNING_ALLOWED=NO', + 'CODE_SIGNING_REQUIRED=NO', + 'CODE_SIGN_IDENTITY=', + 'DEVELOPMENT_TEAM=', ], ); }); -test("resolveRunnerSigningBuildSettings enables automatic signing for device builds without forcing identity", () => { - assert.deepEqual(resolveRunnerSigningBuildSettings({}, true), [ - "CODE_SIGN_STYLE=Automatic", - ]); +test('resolveRunnerSigningBuildSettings enables automatic signing for device builds without forcing identity', () => { + assert.deepEqual(resolveRunnerSigningBuildSettings({}, true), ['CODE_SIGN_STYLE=Automatic']); }); -test("resolveRunnerSigningBuildSettings ignores device signing overrides for simulator builds", () => { +test('resolveRunnerSigningBuildSettings ignores device signing overrides for simulator builds', () => { assert.deepEqual( resolveRunnerSigningBuildSettings( { - AGENT_DEVICE_IOS_TEAM_ID: "ABCDE12345", - AGENT_DEVICE_IOS_SIGNING_IDENTITY: "Apple Development", - AGENT_DEVICE_IOS_PROVISIONING_PROFILE: "My Profile", + AGENT_DEVICE_IOS_TEAM_ID: 'ABCDE12345', + AGENT_DEVICE_IOS_SIGNING_IDENTITY: 'Apple Development', + AGENT_DEVICE_IOS_PROVISIONING_PROFILE: 'My Profile', }, false, ), @@ -78,62 +73,62 @@ test("resolveRunnerSigningBuildSettings ignores device signing overrides for sim ); }); -test("resolveRunnerSigningBuildSettings applies optional overrides when provided", () => { +test('resolveRunnerSigningBuildSettings applies optional overrides when provided', () => { const settings = resolveRunnerSigningBuildSettings( { - AGENT_DEVICE_IOS_TEAM_ID: "ABCDE12345", - AGENT_DEVICE_IOS_SIGNING_IDENTITY: "Apple Development", - AGENT_DEVICE_IOS_PROVISIONING_PROFILE: "My Profile", + AGENT_DEVICE_IOS_TEAM_ID: 'ABCDE12345', + AGENT_DEVICE_IOS_SIGNING_IDENTITY: 'Apple Development', + AGENT_DEVICE_IOS_PROVISIONING_PROFILE: 'My Profile', }, true, ); assert.deepEqual(settings, [ - "CODE_SIGN_STYLE=Manual", - "DEVELOPMENT_TEAM=ABCDE12345", - "CODE_SIGN_IDENTITY=Apple Development", - "PROVISIONING_PROFILE_SPECIFIER=My Profile", + 'CODE_SIGN_STYLE=Manual', + 'DEVELOPMENT_TEAM=ABCDE12345', + 'CODE_SIGN_IDENTITY=Apple Development', + 'PROVISIONING_PROFILE_SPECIFIER=My Profile', ]); }); -test("resolveRunnerSigningBuildSettings switches to manual signing when a profile is set without team or identity", () => { +test('resolveRunnerSigningBuildSettings switches to manual signing when a profile is set without team or identity', () => { const settings = resolveRunnerSigningBuildSettings( - { AGENT_DEVICE_IOS_PROVISIONING_PROFILE: "My Profile" }, + { AGENT_DEVICE_IOS_PROVISIONING_PROFILE: 'My Profile' }, true, ); assert.deepEqual(settings, [ - "CODE_SIGN_STYLE=Manual", - "PROVISIONING_PROFILE_SPECIFIER=My Profile", + 'CODE_SIGN_STYLE=Manual', + 'PROVISIONING_PROFILE_SPECIFIER=My Profile', ]); }); -test("resolveRunnerPerformanceBuildSettings disables indexing and code coverage", () => { +test('resolveRunnerPerformanceBuildSettings disables indexing and code coverage', () => { assert.deepEqual(resolveRunnerPerformanceBuildSettings(), [ - "COMPILER_INDEX_STORE_ENABLE=NO", - "ENABLE_CODE_COVERAGE=NO", - "ONLY_ACTIVE_ARCH=YES", - "ENABLE_PREVIEWS=NO", - "ENABLE_DEBUG_DYLIB=NO", + 'COMPILER_INDEX_STORE_ENABLE=NO', + 'ENABLE_CODE_COVERAGE=NO', + 'ONLY_ACTIVE_ARCH=YES', + 'ENABLE_PREVIEWS=NO', + 'ENABLE_DEBUG_DYLIB=NO', ]); }); -test("resolveRunnerSandboxBuildArgs disables nested Xcode and Swift sandboxing", () => { +test('resolveRunnerSandboxBuildArgs disables nested Xcode and Swift sandboxing', () => { assert.deepEqual(resolveRunnerSandboxBuildArgs(), [ - "-IDEPackageSupportDisableManifestSandbox=1", - "-IDEPackageSupportDisablePluginExecutionSandbox=1", - "ENABLE_USER_SCRIPT_SANDBOXING=NO", - "OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox", + '-IDEPackageSupportDisableManifestSandbox=1', + '-IDEPackageSupportDisablePluginExecutionSandbox=1', + 'ENABLE_USER_SCRIPT_SANDBOXING=NO', + 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox', ]); }); -test("resolveRunnerSandboxBuildArgs includes Swift runner unit tests only when requested", () => { +test('resolveRunnerSandboxBuildArgs includes Swift runner unit tests only when requested', () => { const previous = process.env.AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS; try { - process.env.AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS = "1"; + process.env.AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS = '1'; assert.deepEqual(resolveRunnerSandboxBuildArgs(), [ - "-IDEPackageSupportDisableManifestSandbox=1", - "-IDEPackageSupportDisablePluginExecutionSandbox=1", - "ENABLE_USER_SCRIPT_SANDBOXING=NO", - "OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_UNIT_TESTS", + '-IDEPackageSupportDisableManifestSandbox=1', + '-IDEPackageSupportDisablePluginExecutionSandbox=1', + 'ENABLE_USER_SCRIPT_SANDBOXING=NO', + 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_UNIT_TESTS', ]); } finally { if (previous === undefined) { @@ -144,83 +139,76 @@ test("resolveRunnerSandboxBuildArgs includes Swift runner unit tests only when r } }); -test("resolveRunnerBundleBuildSettings returns default bundle identifiers", () => { +test('resolveRunnerBundleBuildSettings returns default bundle identifiers', () => { assert.deepEqual(resolveRunnerBundleBuildSettings({}), [ - "AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID=com.callstack.agentdevice.runner", - "AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID=com.callstack.agentdevice.runner.uitests", + 'AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID=com.callstack.agentdevice.runner', + 'AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID=com.callstack.agentdevice.runner.uitests', ]); }); -test("resolveRunnerBundleBuildSettings uses AGENT_DEVICE_IOS_BUNDLE_ID when provided", () => { +test('resolveRunnerBundleBuildSettings uses AGENT_DEVICE_IOS_BUNDLE_ID when provided', () => { assert.deepEqual( resolveRunnerBundleBuildSettings({ - AGENT_DEVICE_IOS_BUNDLE_ID: "com.example.agent-device.runner", + AGENT_DEVICE_IOS_BUNDLE_ID: 'com.example.agent-device.runner', }), [ - "AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID=com.example.agent-device.runner", - "AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID=com.example.agent-device.runner.uitests", + 'AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID=com.example.agent-device.runner', + 'AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID=com.example.agent-device.runner.uitests', ], ); }); -test("metadata diff names only the comparable keys that differ, with expected and actual", () => { +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"], + xcodeBuildVersion: '17A100', + runnerPerformanceBuildSettings: ['ENABLE_CODE_COVERAGE=YES'], artifacts: { - xctestrunPath: "/tmp/derived/Runner.xctestrun", + xctestrunPath: '/tmp/derived/Runner.xctestrun', xctestrunMtimeMs: 1, xctestrunSize: 2, - productPaths: [{ path: "/tmp/derived/Runner.app", mtimeMs: 1, size: 2 }], + productPaths: [{ path: '/tmp/derived/Runner.app', mtimeMs: 1, size: 2 }], }, }; assert.deepEqual(diffComparableRunnerCacheMetadata(expected, actual), [ { - key: "runnerPerformanceBuildSettings", + key: 'runnerPerformanceBuildSettings', expected: JSON.stringify(expected.runnerPerformanceBuildSettings), actual: '["ENABLE_CODE_COVERAGE=YES"]', }, - { key: "xcodeBuildVersion", expected: '"17C52"', actual: '"17A100"' }, + { key: 'xcodeBuildVersion', expected: '"17C52"', actual: '"17A100"' }, ]); }); -test("metadata diff reports a key only one side carries as absent", () => { +test('metadata diff reports a key only one side carries as absent', () => { const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); - const { sdkBuildVersion: _sdkBuildVersion, ...withoutSdkBuildVersion } = - expected; + const { sdkBuildVersion: _sdkBuildVersion, ...withoutSdkBuildVersion } = expected; assert.deepEqual( - diffComparableRunnerCacheMetadata( - expected, - withoutSdkBuildVersion as typeof expected, - ), - [{ key: "sdkBuildVersion", expected: '"23C53"', actual: "(absent)" }], + diffComparableRunnerCacheMetadata(expected, withoutSdkBuildVersion as typeof expected), + [{ key: 'sdkBuildVersion', expected: '"23C53"', actual: '(absent)' }], ); }); -test("metadata diff is empty for identical metadata", () => { +test('metadata diff is empty for identical metadata', () => { const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); - assert.deepEqual( - diffComparableRunnerCacheMetadata(expected, { ...expected }), - [], - ); + assert.deepEqual(diffComparableRunnerCacheMetadata(expected, { ...expected }), []); }); -test("metadata diff elides an over-long value in the middle so both ends stay comparable", () => { +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 longSetting = (suffix: string) => [`${'A'.repeat(400)}=${suffix}`]; const [difference] = diffComparableRunnerCacheMetadata( - { ...expected, runnerBundleBuildSettings: longSetting("one") }, - { ...expected, runnerBundleBuildSettings: longSetting("two") }, + { ...expected, runnerBundleBuildSettings: longSetting('one') }, + { ...expected, runnerBundleBuildSettings: longSetting('two') }, ); - assert.equal(difference?.key, "runnerBundleBuildSettings"); + assert.equal(difference?.key, 'runnerBundleBuildSettings'); assert.ok((difference?.expected.length ?? 0) <= 300); assert.ok(difference?.expected.startsWith('["AAA')); assert.ok(difference?.expected.endsWith('=one"]')); @@ -233,113 +221,99 @@ function unavailableProbes(): { probe: string; reason: string }[] { return []; } catch (error) { assert.ok(error instanceof AppError); - assert.equal(error.details?.reason, "apple_toolchain_probe_unavailable"); + 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", () => { +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, - }, - ); + 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" }, - ]); + assert.deepEqual(unavailableProbes(), [{ probe: 'xcodebuild -version', reason: 'probe_error' }]); }); -test("a failing probe reports its exit status rather than a fabricated SDK version", () => { +test('a failing probe reports its exit status rather than a fabricated SDK version', () => { runCmdSync.mockImplementation((command: string, args: readonly string[]) => - command === "xcrun" + command === 'xcrun' ? { exitCode: 70, - stdout: "", - stderr: "xcrun: error: SDK cannot be located\n", + stdout: '', + stderr: 'xcrun: error: SDK cannot be located\n', } : appleToolchainProbeResult(command, args), ); assert.deepEqual(unavailableProbes(), [ { - probe: "xcrun --sdk iphonesimulator --show-sdk-version", - reason: "nonzero_exit", + probe: 'xcrun --sdk iphonesimulator --show-sdk-version', + reason: 'nonzero_exit', }, { - probe: "xcrun --sdk iphonesimulator --show-sdk-build-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", () => { +test('unrecognized xcodebuild output is unavailable, not a partially parsed fingerprint', () => { runCmdSync.mockImplementation((command: string, args: readonly string[]) => - command === "xcodebuild" + command === 'xcodebuild' ? { exitCode: 0, - stdout: "xcode-select: error: tool not configured\n", - stderr: "", + stdout: 'xcode-select: error: tool not configured\n', + stderr: '', } : appleToolchainProbeResult(command, args), ); assert.deepEqual(unavailableProbes(), [ - { probe: "xcodebuild -version", reason: "unparsable_output" }, + { probe: 'xcodebuild -version', reason: 'unparsable_output' }, ]); }); -test("an empty probe answer is unavailable rather than an empty cache key field", () => { +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: "" } + 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", + probe: 'xcrun --sdk iphonesimulator --show-sdk-build-version', + reason: 'empty_output', }, ]); }); -test("an unavailable toolchain fails the cache decision with a retriable typed error", () => { +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", - {}, - ); + 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"); + 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.code, 'COMMAND_FAILED'); assert.equal(error.details?.retriable, true); - expect(error.message).toContain("xcodebuild -version"); - expect(String(error.details?.hint)).toContain("xcode-select"); + expect(error.message).toContain('xcodebuild -version'); + expect(String(error.details?.hint)).toContain('xcode-select'); } }); -test("an unavailable probe never reaches cache metadata, and is not memoized as one", () => { +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", - {}, - ); + throw new AppError('COMMAND_FAILED', 'xcodebuild timed out after 5000ms', {}); }); expect(() => resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR)).toThrow( /Could not read the Xcode toolchain versions/, @@ -348,49 +322,47 @@ test("an unavailable probe never reaches cache metadata, and is not memoized as 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"); + 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", () => { +test('a malformed xcodebuild answer is not memoized: the next request re-probes and recovers', () => { runCmdSync.mockImplementation((command: string, args: readonly string[]) => - command === "xcodebuild" + command === 'xcodebuild' ? { exitCode: 0, - stdout: "xcode-select: error: tool not configured\n", - stderr: "", + stdout: 'xcode-select: error: tool not configured\n', + stderr: '', } : appleToolchainProbeResult(command, args), ); assert.deepEqual(unavailableProbes(), [ - { probe: "xcodebuild -version", reason: "unparsable_output" }, + { 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"]); + 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", () => { +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: "" } + 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", + probe: 'xcrun --sdk iphonesimulator --show-sdk-build-version', + reason: 'empty_output', }, ]); @@ -399,26 +371,16 @@ test("only a complete, parsed toolchain fingerprint is memoized", () => { 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", + '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, - ], + [second.xcodeVersion, second.xcodeBuildVersion, second.sdkVersion, second.sdkBuildVersion], + [first.xcodeVersion, first.xcodeBuildVersion, first.sdkVersion, first.sdkBuildVersion], ); }); diff --git a/packages/platform-apple/src/runner/runner-cache-metadata.ts b/packages/platform-apple/src/runner/runner-cache-metadata.ts index 951f7b242b..a0461ad244 100644 --- a/packages/platform-apple/src/runner/runner-cache-metadata.ts +++ b/packages/platform-apple/src/runner/runner-cache-metadata.ts @@ -1,8 +1,8 @@ -import crypto from "node:crypto"; -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 crypto from 'node:crypto'; +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 { createTtlMemo, isEnvTruthy, @@ -10,37 +10,33 @@ import { readVersion, runCmdSync, type TtlMemo, -} from "./host.ts"; +} from './host.ts'; import { resolveRunnerBuildDestinationFamily, resolveRunnerDerivedBaseName, resolveRunnerPlatformName, resolveRunnerSdkName, -} from "./apple-runner-platform.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"; +} from './apple-runner-platform.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_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`."; + '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", - "ENABLE_USER_SCRIPT_SANDBOXING=NO", + '-IDEPackageSupportDisableManifestSandbox=1', + '-IDEPackageSupportDisablePluginExecutionSandbox=1', + 'ENABLE_USER_SCRIPT_SANDBOXING=NO', ] as const; -const RUNNER_RUNTIME_SWIFT_FLAGS = "$(inherited) -disable-sandbox"; +const RUNNER_RUNTIME_SWIFT_FLAGS = '$(inherited) -disable-sandbox'; const RUNNER_UNIT_TEST_SWIFT_FLAGS = - "$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_UNIT_TESTS"; + '$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_UNIT_TESTS'; /** Toolchain half of the runner cache key. Every field is a probed value. */ export type RunnerToolchainFingerprint = { @@ -53,20 +49,21 @@ export type RunnerToolchainFingerprint = { type ToolchainProbeFailure = { probe: string; - reason: "probe_error" | "nonzero_exit" | "empty_output" | "unparsable_output"; + reason: 'probe_error' | 'nonzero_exit' | 'empty_output' | 'unparsable_output'; detail: string; }; type ProbeResult = - { ok: true; value: Value } | { ok: false; failure: ToolchainProbeFailure }; + | { 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; + deviceKind: DeviceInfo['kind']; + target: NonNullable; buildDestinationFamily: string; runnerBundleBuildSettings: string[]; runnerSigningBuildSettings: string[]; @@ -89,33 +86,25 @@ export type RunnerXctestrunCacheProductArtifact = { }; function normalizeBundleId(value: string | undefined): string { - return value?.trim() ?? ""; + return value?.trim() ?? ''; } -export function resolveRunnerAppBundleId( - env: NodeJS.ProcessEnv = process.env, -): string { +export function resolveRunnerAppBundleId(env: NodeJS.ProcessEnv = process.env): string { const configured = normalizeBundleId(env.AGENT_DEVICE_IOS_BUNDLE_ID) || normalizeBundleId(env.AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID); return configured || DEFAULT_IOS_RUNNER_APP_BUNDLE_ID; } -function resolveRunnerTestBundleId( - env: NodeJS.ProcessEnv = process.env, -): string { - const configured = normalizeBundleId( - env.AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID, - ); +function resolveRunnerTestBundleId(env: NodeJS.ProcessEnv = process.env): string { + const configured = normalizeBundleId(env.AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID); if (configured) { return configured; } return `${resolveRunnerAppBundleId(env)}.uitests`; } -function resolveRunnerContainerBundleIds( - env: NodeJS.ProcessEnv = process.env, -): string[] { +function resolveRunnerContainerBundleIds(env: NodeJS.ProcessEnv = process.env): string[] { const appBundleId = resolveRunnerAppBundleId(env); const testBundleId = resolveRunnerTestBundleId(env); return Array.from( @@ -129,8 +118,9 @@ function resolveRunnerContainerBundleIds( ); } -export const IOS_RUNNER_CONTAINER_BUNDLE_IDS: string[] = - resolveRunnerContainerBundleIds(process.env); +export const IOS_RUNNER_CONTAINER_BUNDLE_IDS: string[] = resolveRunnerContainerBundleIds( + process.env, +); export function resolveExpectedRunnerCacheMetadata( device: DeviceInfo, @@ -141,17 +131,15 @@ export function resolveExpectedRunnerCacheMetadata( schemaVersion: RUNNER_CACHE_SCHEMA_VERSION, packageVersion: readVersion(projectRoot), runnerSourceFingerprint: computeRunnerSourceFingerprint(projectRoot), - ...requireRunnerToolchainFingerprint( - resolveRunnerSdkName(platformName, device.kind), - ), + ...requireRunnerToolchainFingerprint(resolveRunnerSdkName(platformName, device.kind)), platformName, deviceKind: device.kind, - target: device.target ?? "mobile", + target: device.target ?? 'mobile', buildDestinationFamily: resolveRunnerBuildDestinationFamily(device), runnerBundleBuildSettings: resolveRunnerBundleBuildSettings(process.env), runnerSigningBuildSettings: resolveRunnerSigningBuildSettings( process.env, - device.kind === "device", + device.kind === 'device', device, ), runnerPerformanceBuildSettings: resolveRunnerPerformanceBuildSettings(), @@ -163,16 +151,9 @@ export function resolveExpectedRunnerCacheMetadata( // 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< - string, - RunnerToolchainFingerprint -> { - lazyToolchainFingerprintCache ??= createTtlMemo< - string, - RunnerToolchainFingerprint - >(); +let lazyToolchainFingerprintCache: TtlMemo | undefined; +function toolchainFingerprintCache(): TtlMemo { + lazyToolchainFingerprintCache ??= createTtlMemo(); return lazyToolchainFingerprintCache; } @@ -182,9 +163,7 @@ function toolchainFingerprintCache(): TtlMemo< * 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 { +function requireRunnerToolchainFingerprint(sdkName: string): RunnerToolchainFingerprint { const cached = toolchainFingerprintCache().get(sdkName); if (cached) return cached; const fingerprint = readRunnerToolchainFingerprint(sdkName); @@ -198,18 +177,12 @@ function readRunnerToolchainFingerprint( ): | { ok: true; value: RunnerToolchainFingerprint } | { ok: false; failures: readonly ToolchainProbeFailure[] } { - const xcode = parseXcodeVersionOutput( - runToolchainProbe("xcodebuild", ["-version"]), - ); - const sdkVersion = runToolchainProbe("xcrun", [ - "--sdk", + const xcode = parseXcodeVersionOutput(runToolchainProbe('xcodebuild', ['-version'])); + const sdkVersion = runToolchainProbe('xcrun', ['--sdk', sdkName, '--show-sdk-version']); + const sdkBuildVersion = runToolchainProbe('xcrun', [ + '--sdk', sdkName, - "--show-sdk-version", - ]); - const sdkBuildVersion = runToolchainProbe("xcrun", [ - "--sdk", - sdkName, - "--show-sdk-build-version", + '--show-sdk-build-version', ]); if (!xcode.ok || !sdkVersion.ok || !sdkBuildVersion.ok) { return { @@ -231,16 +204,14 @@ function readRunnerToolchainFingerprint( }; } -function unavailableToolchainError( - failures: readonly ToolchainProbeFailure[], -): AppError { +function unavailableToolchainError(failures: readonly ToolchainProbeFailure[]): AppError { return new AppError( - "COMMAND_FAILED", + 'COMMAND_FAILED', `Could not read the Xcode toolchain versions the Apple runner cache is keyed on (${failures .map((failure) => `${failure.probe}: ${failure.detail}`) - .join("; ")})`, + .join('; ')})`, { - reason: "apple_toolchain_probe_unavailable", + reason: 'apple_toolchain_probe_unavailable', retriable: true, probes: failures, hint: TOOLCHAIN_PROBE_HINT, @@ -249,7 +220,7 @@ function unavailableToolchainError( } function runToolchainProbe(cmd: string, args: string[]): ProbeResult { - const probe = [cmd, ...args].join(" "); + const probe = [cmd, ...args].join(' '); let output: { exitCode: number; stdout: string; stderr: string }; try { output = runCmdSync(cmd, args, { @@ -258,23 +229,17 @@ function runToolchainProbe(cmd: string, args: string[]): ProbeResult { maxBuffer: TOOLCHAIN_PROBE_MAX_BUFFER, }); } catch (error) { - return probeFailure( - probe, - "probe_error", - error instanceof Error ? error.message : `${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()}` : ""}`, + '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"); + return value ? { ok: true, value } : probeFailure(probe, 'empty_output', 'no output'); } function parseXcodeVersionOutput( @@ -284,14 +249,12 @@ function parseXcodeVersionOutput( return output; } const version = output.value.match(/^Xcode\s+(.+)$/m)?.[1]?.trim(); - const buildVersion = output.value - .match(/^Build version\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", " ")}`, + 'xcodebuild -version', + 'unparsable_output', + `unrecognized output: ${output.value.replaceAll('\n', ' ')}`, ); } return { ok: true, value: { version, buildVersion } }; @@ -299,7 +262,7 @@ function parseXcodeVersionOutput( function probeFailure( probe: string, - reason: ToolchainProbeFailure["reason"], + reason: ToolchainProbeFailure['reason'], detail: string, ): { ok: false; failure: ToolchainProbeFailure } { const bounded = @@ -323,31 +286,21 @@ export function resolveRunnerDerivedPath( } function resolveRunnerDerivedBasePath(device: DeviceInfo): string { - return path.join( - RUNNER_DERIVED_ROOT, - "derived", - resolveRunnerDerivedBaseName(device), - ); + return path.join(RUNNER_DERIVED_ROOT, 'derived', resolveRunnerDerivedBaseName(device)); } -function resolveRunnerDerivedCacheKey( - metadata: RunnerXctestrunCacheMetadata, -): string { +function resolveRunnerDerivedCacheKey(metadata: RunnerXctestrunCacheMetadata): string { const hash = crypto - .createHash("sha256") + .createHash('sha256') .update(stableJsonStringify(comparableRunnerCacheMetadata(metadata))) - .digest("hex"); + .digest('hex'); return `cache-${hash.slice(0, 16)}`; } export function comparableRunnerCacheMetadata( metadata: RunnerXctestrunCacheMetadata, -): Omit { - const { - artifacts: _artifacts, - packageVersion: _packageVersion, - ...comparable - } = metadata; +): Omit { + const { artifacts: _artifacts, packageVersion: _packageVersion, ...comparable } = metadata; return comparable; } @@ -361,21 +314,12 @@ 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), - ]), - ] + 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 expectedValue = renderRunnerCacheMetadataValue(expectedComparable[key]); const actualValue = renderRunnerCacheMetadataValue(actualComparable[key]); return expectedValue === actualValue ? [] @@ -390,7 +334,7 @@ export function diffComparableRunnerCacheMetadata( } function renderRunnerCacheMetadataValue(value: unknown): string { - return value === undefined ? "(absent)" : stableJsonStringify(value); + return value === undefined ? '(absent)' : stableJsonStringify(value); } // Elides the middle: build-setting lists differ in their last entry as often as @@ -411,7 +355,7 @@ function sortJsonKeys(value: unknown): unknown { if (Array.isArray(value)) { return value.map((item) => sortJsonKeys(item)); } - if (!value || typeof value !== "object") { + if (!value || typeof value !== 'object') { return value; } return Object.fromEntries( @@ -421,38 +365,35 @@ function sortJsonKeys(value: unknown): unknown { ); } -export function resolveRunnerMaxConcurrentDestinationsFlag( - device: DeviceInfo, -): string { +export function resolveRunnerMaxConcurrentDestinationsFlag(device: DeviceInfo): string { if (isMacOs(device)) { - return "-maximum-concurrent-test-device-destinations"; + return '-maximum-concurrent-test-device-destinations'; } - return device.kind === "device" - ? "-maximum-concurrent-test-device-destinations" - : "-maximum-concurrent-test-simulator-destinations"; + return device.kind === 'device' + ? '-maximum-concurrent-test-device-destinations' + : '-maximum-concurrent-test-simulator-destinations'; } export function resolveRunnerSigningBuildSettings( env: NodeJS.ProcessEnv = process.env, forDevice = false, - device: Pick = { platform: "apple" }, + device: Pick = { platform: 'apple' }, ): string[] { if (isMacOs(device)) { return [ - "CODE_SIGNING_ALLOWED=NO", - "CODE_SIGNING_REQUIRED=NO", - "CODE_SIGN_IDENTITY=", - "DEVELOPMENT_TEAM=", + 'CODE_SIGNING_ALLOWED=NO', + 'CODE_SIGNING_REQUIRED=NO', + 'CODE_SIGN_IDENTITY=', + 'DEVELOPMENT_TEAM=', ]; } if (!forDevice) { return []; } - const teamId = env.AGENT_DEVICE_IOS_TEAM_ID?.trim() || ""; - const configuredIdentity = - env.AGENT_DEVICE_IOS_SIGNING_IDENTITY?.trim() || ""; - const profile = env.AGENT_DEVICE_IOS_PROVISIONING_PROFILE?.trim() || ""; - const args = [`CODE_SIGN_STYLE=${profile ? "Manual" : "Automatic"}`]; + const teamId = env.AGENT_DEVICE_IOS_TEAM_ID?.trim() || ''; + const configuredIdentity = env.AGENT_DEVICE_IOS_SIGNING_IDENTITY?.trim() || ''; + const profile = env.AGENT_DEVICE_IOS_PROVISIONING_PROFILE?.trim() || ''; + const args = [`CODE_SIGN_STYLE=${profile ? 'Manual' : 'Automatic'}`]; if (teamId) { args.push(`DEVELOPMENT_TEAM=${teamId}`); } @@ -463,9 +404,7 @@ export function resolveRunnerSigningBuildSettings( return args; } -export function resolveRunnerBundleBuildSettings( - env: NodeJS.ProcessEnv = process.env, -): string[] { +export function resolveRunnerBundleBuildSettings(env: NodeJS.ProcessEnv = process.env): string[] { const appBundleId = resolveRunnerAppBundleId(env); const testBundleId = resolveRunnerTestBundleId(env); return [ @@ -476,11 +415,11 @@ export function resolveRunnerBundleBuildSettings( export function resolveRunnerPerformanceBuildSettings(): string[] { return [ - "COMPILER_INDEX_STORE_ENABLE=NO", - "ENABLE_CODE_COVERAGE=NO", - "ONLY_ACTIVE_ARCH=YES", - "ENABLE_PREVIEWS=NO", - "ENABLE_DEBUG_DYLIB=NO", + 'COMPILER_INDEX_STORE_ENABLE=NO', + 'ENABLE_CODE_COVERAGE=NO', + 'ONLY_ACTIVE_ARCH=YES', + 'ENABLE_PREVIEWS=NO', + 'ENABLE_DEBUG_DYLIB=NO', ]; }