diff --git a/packages/capture-kit/src/capture-admission/audio-probe-resource-recovery.ts b/packages/capture-kit/src/capture-admission/audio-probe-resource-recovery.ts index 1db2c18a15..07e5f7a625 100644 --- a/packages/capture-kit/src/capture-admission/audio-probe-resource-recovery.ts +++ b/packages/capture-kit/src/capture-admission/audio-probe-resource-recovery.ts @@ -11,10 +11,8 @@ import { } from '@agent-device/contracts/platform-runtime'; import type { PlatformRequestScope } from '@agent-device/contracts/platform-runtime-host'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; -import type { - DurableCaptureRecoveryControl, - DurableCaptureRecoveryDiagnostic, -} from '../durable-capture/index.ts'; +import type { DurableCaptureRecoveryControl } from '../durable-capture/index.ts'; +import type { ResourceDiagnostic } from '@agent-device/host-kit/diagnostics'; import { audioProbeDurableResource } from './audio-probe-session-resource.ts'; import { acquireExactDurableCaptureRecoveryControl } from './durable-capture-runtime-recovery.ts'; @@ -26,7 +24,7 @@ export function recoverAudioProbeResourceAfterDaemonLock(params: { gateway: DeviceRuntimeGateway; scope: PlatformRequestScope; perRecordDeadlineMs?: number; - onDiagnostic?: (diagnostic: DurableCaptureRecoveryDiagnostic) => void; + onDiagnostic?: (diagnostic: ResourceDiagnostic) => void; }) { return audioProbeDurableResource.recoverOne( { diff --git a/packages/capture-kit/src/capture-admission/perf-capture-resource-recovery.ts b/packages/capture-kit/src/capture-admission/perf-capture-resource-recovery.ts index 895bfd581f..9f32428218 100644 --- a/packages/capture-kit/src/capture-admission/perf-capture-resource-recovery.ts +++ b/packages/capture-kit/src/capture-admission/perf-capture-resource-recovery.ts @@ -12,10 +12,8 @@ import { } from '@agent-device/contracts/platform-runtime'; import type { PlatformRequestScope } from '@agent-device/contracts/platform-runtime-host'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; -import type { - DurableCaptureRecoveryControl, - DurableCaptureRecoveryDiagnostic, -} from '../durable-capture/index.ts'; +import type { DurableCaptureRecoveryControl } from '../durable-capture/index.ts'; +import type { ResourceDiagnostic } from '@agent-device/host-kit/diagnostics'; import { perfCaptureDurableResource } from './perf-capture-session-resource.ts'; import { acquireExactDurableCaptureRecoveryControl } from './durable-capture-runtime-recovery.ts'; @@ -27,7 +25,7 @@ export function recoverPerfCaptureResourceAfterDaemonLock(params: { gateway: DeviceRuntimeGateway; scope: PlatformRequestScope; perRecordDeadlineMs?: number; - onDiagnostic?: (diagnostic: DurableCaptureRecoveryDiagnostic) => void; + onDiagnostic?: (diagnostic: ResourceDiagnostic) => void; }) { return perfCaptureDurableResource.recoverOne( { diff --git a/packages/capture-kit/src/capture-admission/screen-recording-resource-recovery.ts b/packages/capture-kit/src/capture-admission/screen-recording-resource-recovery.ts index 27fa96d67a..0b16ebb988 100644 --- a/packages/capture-kit/src/capture-admission/screen-recording-resource-recovery.ts +++ b/packages/capture-kit/src/capture-admission/screen-recording-resource-recovery.ts @@ -11,10 +11,8 @@ import type { ScreenRecordingLiveHandle, } from '@agent-device/contracts/screen-recording-runtime'; import { screenRecordingRecoveryUse } from '@agent-device/contracts/screen-recording-runtime-plan'; -import type { - DurableCaptureRecoveryControl, - DurableCaptureRecoveryDiagnostic, -} from '../durable-capture/index.ts'; +import type { DurableCaptureRecoveryControl } from '../durable-capture/index.ts'; +import type { ResourceDiagnostic } from '@agent-device/host-kit/diagnostics'; import { acquireExactDurableCaptureRecoveryControl } from './durable-capture-runtime-recovery.ts'; import { screenRecordingDurableResource } from './screen-recording-session-resource.ts'; @@ -26,7 +24,7 @@ export function recoverScreenRecordingResourceAfterDaemonLock(params: { gateway: DeviceRuntimeGateway; scope: PlatformRequestScope; perRecordDeadlineMs?: number; - onDiagnostic?: (diagnostic: DurableCaptureRecoveryDiagnostic) => void; + onDiagnostic?: (diagnostic: ResourceDiagnostic) => void; }) { return screenRecordingDurableResource.recoverOne( { diff --git a/packages/capture-kit/src/durable-capture/index.ts b/packages/capture-kit/src/durable-capture/index.ts index ddc447b373..09013e180a 100644 --- a/packages/capture-kit/src/durable-capture/index.ts +++ b/packages/capture-kit/src/durable-capture/index.ts @@ -18,7 +18,6 @@ export type { } from './definition.ts'; export type { FinishRecoveredDurableCaptureParams } from './finish-recovered.ts'; export type { - DurableCaptureRecoveryDiagnostic, DurableCaptureRecoveryOutcome, DurableCaptureRecoveryParams, DurableCaptureRecoverySummary, diff --git a/packages/capture-kit/src/durable-capture/recovery.ts b/packages/capture-kit/src/durable-capture/recovery.ts index d13fdac066..4f09648e0a 100644 --- a/packages/capture-kit/src/durable-capture/recovery.ts +++ b/packages/capture-kit/src/durable-capture/recovery.ts @@ -6,7 +6,7 @@ import { isConfirmedCleanup, } from '@agent-device/contracts/durable-resource'; import type { PlatformRequestScope } from '@agent-device/contracts/platform-runtime-host'; -import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import { emitDiagnostic, type ResourceDiagnostic } from '@agent-device/host-kit/diagnostics'; import { acquireDurableCaptureRecoveryAuthorityBeforeDeadline, DurableCaptureRecoveryDeadlineError, @@ -28,12 +28,6 @@ export type DurableCaptureRecoverySummary = Readonly<{ export type DurableCaptureRecoveryOutcome = 'ignored' | 'recovered' | 'retained'; -export type DurableCaptureRecoveryDiagnostic = Readonly<{ - phase: string; - resourcePath: string; - data: Readonly>; -}>; - export type DurableCaptureRecoveryParams, C> = { definition: DurableCaptureRecordDefinition; sessionsDir: string; @@ -48,7 +42,7 @@ export type DurableCaptureRecoveryParams>; perRecordDeadlineMs?: number; - onDiagnostic?: (diagnostic: DurableCaptureRecoveryDiagnostic) => void; + onDiagnostic?: (diagnostic: ResourceDiagnostic) => void; }; export async function recoverDurableCaptureResourcesAfterDaemonLock< diff --git a/packages/host-kit/src/diagnostics.ts b/packages/host-kit/src/diagnostics.ts index 4a6e17e119..def5222700 100644 --- a/packages/host-kit/src/diagnostics.ts +++ b/packages/host-kit/src/diagnostics.ts @@ -6,6 +6,7 @@ export { flushDiagnosticsToSessionFile, getDiagnosticsMeta, registerDiagnosticSensitiveValue, + type ResourceDiagnostic, updateDiagnosticsScope, withDiagnosticsScope, withDiagnosticTimer, diff --git a/packages/host-kit/src/internal/diagnostics.ts b/packages/host-kit/src/internal/diagnostics.ts index ee1145fb25..94df8f4da5 100644 --- a/packages/host-kit/src/internal/diagnostics.ts +++ b/packages/host-kit/src/internal/diagnostics.ts @@ -123,6 +123,13 @@ export function countDiagnosticEventsByPhase(phases: readonly string[]): number return total; } +/** A warning about one on-disk resource, handed to a caller that records it once its log exists. */ +export type ResourceDiagnostic = Readonly<{ + phase: string; + resourcePath: string; + data: Readonly>; +}>; + export type DiagnosticEventInput = { level?: DiagnosticLevel; phase: string; diff --git a/packages/platform-apple/src/runner-operations-facade.ts b/packages/platform-apple/src/runner-operations-facade.ts index eb5d1f999f..7c826e11ee 100644 --- a/packages/platform-apple/src/runner-operations-facade.ts +++ b/packages/platform-apple/src/runner-operations-facade.ts @@ -30,7 +30,6 @@ export { writeRunnerCacheMetadataForArtifacts, } from './core/runner-client.ts'; export { queryAppleRunnerSelector } from './core/runner-selector-query.ts'; -export { restoreLegacyXctestDeviceSetRedirect } from './runner/runner-device-set.ts'; export async function cleanupRunnerLeasesForOwner( owner: Parameters<(typeof import('./core/runner-client.ts'))['cleanupRunnerLeasesForOwner']>[0], diff --git a/packages/platform-apple/src/runner-owner-facade.ts b/packages/platform-apple/src/runner-owner-facade.ts index 8052c794ec..9b683958d8 100644 --- a/packages/platform-apple/src/runner-owner-facade.ts +++ b/packages/platform-apple/src/runner-owner-facade.ts @@ -3,3 +3,13 @@ export { setRunnerLeaseOwnerStateDir, type RunnerDeviceClaimAuthorityProbe, } from './core/runner-owner-state.ts'; + +export async function restoreLegacyXctestDeviceSetRedirect( + ...args: Parameters< + (typeof import('./runner/legacy-xctest-device-set.ts'))['restoreLegacyXctestDeviceSetRedirect'] + > +): Promise { + const { restoreLegacyXctestDeviceSetRedirect: restore } = + await import('./runner/legacy-xctest-device-set.ts'); + restore(...args); +} diff --git a/packages/platform-apple/src/runner/__tests__/legacy-xctest-device-set.test.ts b/packages/platform-apple/src/runner/__tests__/legacy-xctest-device-set.test.ts new file mode 100644 index 0000000000..ae73a41738 --- /dev/null +++ b/packages/platform-apple/src/runner/__tests__/legacy-xctest-device-set.test.ts @@ -0,0 +1,164 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, test, vi } from 'vitest'; +import { mkdtempForTestSync } from './tmp-dir.ts'; +import { restoreLegacyXctestDeviceSetRedirect } from '../legacy-xctest-device-set.ts'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +type LegacyRedirect = { + xctestDeviceSetPath: string; + backupPath: string; + scopedSetPath: string; +}; + +function makeLegacyPaths(): LegacyRedirect { + const root = mkdtempForTestSync('agent-device-legacy-xctest-set-'); + const developer = path.join(root, 'Library', 'Developer'); + fs.mkdirSync(developer, { recursive: true }); + const scopedSetPath = path.join(root, 'tenant-set'); + fs.mkdirSync(path.join(scopedSetPath, 'SCOPED-UDID'), { recursive: true }); + return { + xctestDeviceSetPath: path.join(developer, 'XCTestDevices'), + backupPath: path.join(developer, 'XCTestDevices.agent-device-backup'), + scopedSetPath, + }; +} + +test('an older redirect is undone: the symlink goes and the host set comes back', () => { + const paths = makeLegacyPaths(); + fs.mkdirSync(path.join(paths.backupPath, 'HOST-UDID'), { recursive: true }); + fs.symlinkSync(paths.scopedSetPath, paths.xctestDeviceSetPath, 'dir'); + + const phases = restoreRecordingPhases(paths); + + assert.equal(fs.lstatSync(paths.xctestDeviceSetPath).isSymbolicLink(), false); + assert.ok(fs.existsSync(path.join(paths.xctestDeviceSetPath, 'HOST-UDID'))); + assert.equal(fs.existsSync(paths.backupPath), false); + // Unlinking never follows the link: the scoped set it pointed at keeps its devices. + assert.ok(fs.existsSync(path.join(paths.scopedSetPath, 'SCOPED-UDID'))); + assert.deepEqual(phases, [ + 'ios_runner_legacy_xctest_device_set_link_removed', + 'ios_runner_legacy_xctest_device_set_backup_restored', + ]); +}); + +test('a symlink an older redirect left without a backup is removed, whatever it points at', () => { + for (const target of ['scoped', 'external'] as const) { + const paths = makeLegacyPaths(); + const linkTarget = + target === 'scoped' + ? paths.scopedSetPath + : path.join(path.dirname(paths.scopedSetPath), 'external-volume'); + fs.mkdirSync(linkTarget, { recursive: true }); + fs.symlinkSync(linkTarget, paths.xctestDeviceSetPath, 'dir'); + const diagnostics: unknown[] = []; + + restoreLegacyXctestDeviceSetRedirect( + (diagnostic) => diagnostics.push(diagnostic), + paths.xctestDeviceSetPath, + ); + + assert.equal(fs.lstatSync(paths.xctestDeviceSetPath, { throwIfNoEntry: false }), undefined); + assert.ok(fs.existsSync(linkTarget), target); + assert.deepEqual(diagnostics, [ + { + phase: 'ios_runner_legacy_xctest_device_set_link_removed', + resourcePath: paths.xctestDeviceSetPath, + data: { linkTarget }, + }, + ]); + } +}); + +test('a backup never replaces a host set that is already in place', () => { + const paths = makeLegacyPaths(); + fs.mkdirSync(path.join(paths.xctestDeviceSetPath, 'CURRENT-UDID'), { recursive: true }); + fs.mkdirSync(path.join(paths.backupPath, 'OLD-UDID'), { recursive: true }); + + restoreRecordingPhases(paths); + + assert.ok(fs.existsSync(path.join(paths.xctestDeviceSetPath, 'CURRENT-UDID'))); + assert.ok(fs.existsSync(path.join(paths.backupPath, 'OLD-UDID'))); +}); + +test('a host with no leftovers is left untouched and reports nothing', () => { + const paths = makeLegacyPaths(); + + const phases = restoreRecordingPhases(paths); + + assert.equal(fs.existsSync(paths.xctestDeviceSetPath), false); + assert.equal(fs.existsSync(paths.backupPath), false); + assert.deepEqual(phases, []); +}); + +/** Runs the restore with the other daemon's whole restore landing just before this one's `call`. */ +function withOtherDaemonFinishingBefore( + call: 'unlinkSync' | 'renameSync', + paths: LegacyRedirect, +): string[] { + const original = fs[call] as (...args: unknown[]) => unknown; + vi.spyOn(fs, call).mockImplementationOnce(((...args: unknown[]) => { + restoreRecordingPhases(paths); + return original.apply(fs, args); + }) as never); + return restoreRecordingPhases(paths); +} + +test('a daemon whose unlink or rename the other daemon already made finishes the restore', () => { + for (const call of ['unlinkSync', 'renameSync'] as const) { + const paths = makeLegacyPaths(); + fs.mkdirSync(path.join(paths.backupPath, 'HOST-UDID'), { recursive: true }); + fs.symlinkSync(paths.scopedSetPath, paths.xctestDeviceSetPath, 'dir'); + + const phases = withOtherDaemonFinishingBefore(call, paths); + + assert.equal( + phases.includes('ios_runner_legacy_xctest_device_set_restore_failed'), + false, + call, + ); + assert.equal(fs.lstatSync(paths.xctestDeviceSetPath).isSymbolicLink(), false, call); + assert.ok(fs.existsSync(path.join(paths.xctestDeviceSetPath, 'HOST-UDID')), call); + assert.equal(fs.existsSync(paths.backupPath), false, call); + assert.ok(fs.existsSync(path.join(paths.scopedSetPath, 'SCOPED-UDID')), call); + vi.restoreAllMocks(); + } +}); + +test('an unlink that fails while the link is still there is reported, not thrown', () => { + const paths = makeLegacyPaths(); + fs.mkdirSync(paths.backupPath); + fs.symlinkSync(paths.scopedSetPath, paths.xctestDeviceSetPath, 'dir'); + vi.spyOn(fs, 'unlinkSync').mockImplementationOnce(() => { + throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }); + }); + const diagnostics: unknown[] = []; + + restoreLegacyXctestDeviceSetRedirect( + (diagnostic) => diagnostics.push(diagnostic), + paths.xctestDeviceSetPath, + ); + + assert.deepEqual(diagnostics, [ + { + phase: 'ios_runner_legacy_xctest_device_set_restore_failed', + resourcePath: paths.xctestDeviceSetPath, + data: { error: 'EACCES: permission denied' }, + }, + ]); + assert.equal(fs.lstatSync(paths.xctestDeviceSetPath).isSymbolicLink(), true); + assert.ok(fs.existsSync(paths.backupPath)); +}); + +function restoreRecordingPhases(paths: LegacyRedirect): string[] { + const phases: string[] = []; + restoreLegacyXctestDeviceSetRedirect( + (diagnostic) => phases.push(diagnostic.phase), + paths.xctestDeviceSetPath, + ); + return phases; +} diff --git a/packages/platform-apple/src/runner/__tests__/runner-device-set.test.ts b/packages/platform-apple/src/runner/__tests__/runner-device-set.test.ts index 8c8e8efabe..d06500e68e 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-device-set.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-device-set.test.ts @@ -1,18 +1,14 @@ import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; import { afterEach, beforeEach, test, vi } from 'vitest'; import { resetAllProcessMemosForTests } from '@agent-device/kernel/ttl-memo'; import { IOS_DEVICE, IOS_SIMULATOR, TVOS_SIMULATOR } from './device-fixtures.ts'; -import { mkdtempForTestSync } from './tmp-dir.ts'; -import { appleRunnerTestHost } from '../test-host.ts'; import { STUBBED_APPLE_TOOLCHAIN, stubAppleToolchainProbes } from './apple-toolchain-fixtures.ts'; import { isSameRunnerSimulator, - restoreLegacyXctestDeviceSetRedirect, runnerSimulatorSetFailureDetails, xcodebuildDestinationArgs, } from '../runner-device-set.ts'; +import { resolveExpectedRunnerCacheMetadata } from '../runner-cache-metadata.ts'; const toolchainProbe = stubAppleToolchainProbes(); beforeEach(resetAllProcessMemosForTests); @@ -54,6 +50,7 @@ test('the default set, a blank set path and a physical device leave the destinat test('a failure reports the scoped set and the selected Xcode only for a scoped-set simulator', () => { const scoped = { ...IOS_SIMULATOR, simulatorSetPath: '/tmp/tenant-a/simulators' }; + resolveExpectedRunnerCacheMetadata(scoped); assert.deepEqual(runnerSimulatorSetFailureDetails(scoped), { simulatorSetPath: '/tmp/tenant-a/simulators', @@ -62,13 +59,13 @@ test('a failure reports the scoped set and the selected Xcode only for a scoped- assert.deepEqual(runnerSimulatorSetFailureDetails(IOS_SIMULATOR), {}); }); -test('a failure whose Xcode cannot be read still names the scoped set, and no Xcode key', () => { - toolchainProbe.mockReturnValue({ exitCode: 1, stdout: '', stderr: 'xcode-select: error' }); - +test('a failure before any cache decision read the Xcode names the scoped set, never probing', () => { + toolchainProbe.mockClear(); assert.deepEqual( runnerSimulatorSetFailureDetails({ ...IOS_SIMULATOR, simulatorSetPath: '/tmp/tenant-a/sims' }), { simulatorSetPath: '/tmp/tenant-a/sims' }, ); + assert.equal(toolchainProbe.mock.calls.length, 0); }); test('one udid in two simulator sets names two simulators', () => { @@ -85,126 +82,3 @@ test('one udid in two simulator sets names two simulators', () => { false, ); }); - -type LegacyRedirect = { - xctestDeviceSetPath: string; - backupPath: string; - scopedSetPath: string; -}; - -function makeLegacyPaths(): LegacyRedirect { - const root = mkdtempForTestSync('agent-device-legacy-xctest-set-'); - const developer = path.join(root, 'Library', 'Developer'); - fs.mkdirSync(developer, { recursive: true }); - const scopedSetPath = path.join(root, 'tenant-set'); - fs.mkdirSync(path.join(scopedSetPath, 'SCOPED-UDID'), { recursive: true }); - return { - xctestDeviceSetPath: path.join(developer, 'XCTestDevices'), - backupPath: path.join(developer, 'XCTestDevices.agent-device-backup'), - scopedSetPath, - }; -} - -test('an older redirect is undone: the symlink goes and the host set comes back', () => { - const paths = makeLegacyPaths(); - fs.mkdirSync(path.join(paths.backupPath, 'HOST-UDID'), { recursive: true }); - fs.symlinkSync(paths.scopedSetPath, paths.xctestDeviceSetPath, 'dir'); - const phases = recordDiagnosticPhases(); - - restoreLegacyXctestDeviceSetRedirect(paths.xctestDeviceSetPath); - - assert.equal(fs.lstatSync(paths.xctestDeviceSetPath).isSymbolicLink(), false); - assert.ok(fs.existsSync(path.join(paths.xctestDeviceSetPath, 'HOST-UDID'))); - assert.equal(fs.existsSync(paths.backupPath), false); - // Unlinking never follows the link: the scoped set it pointed at keeps its devices. - assert.ok(fs.existsSync(path.join(paths.scopedSetPath, 'SCOPED-UDID'))); - assert.deepEqual(phases, [ - 'ios_runner_legacy_xctest_device_set_link_removed', - 'ios_runner_legacy_xctest_device_set_backup_restored', - ]); -}); - -test('a symlink an older redirect left without a backup is removed, whatever it points at', () => { - for (const target of ['scoped', 'external'] as const) { - const paths = makeLegacyPaths(); - const linkTarget = - target === 'scoped' - ? paths.scopedSetPath - : path.join(path.dirname(paths.scopedSetPath), 'external-volume'); - fs.mkdirSync(linkTarget, { recursive: true }); - fs.symlinkSync(linkTarget, paths.xctestDeviceSetPath, 'dir'); - - restoreLegacyXctestDeviceSetRedirect(paths.xctestDeviceSetPath); - - assert.equal(fs.lstatSync(paths.xctestDeviceSetPath, { throwIfNoEntry: false }), undefined); - assert.ok(fs.existsSync(linkTarget), target); - } -}); - -test('a backup never replaces a host set that is already in place', () => { - const paths = makeLegacyPaths(); - fs.mkdirSync(path.join(paths.xctestDeviceSetPath, 'CURRENT-UDID'), { recursive: true }); - fs.mkdirSync(path.join(paths.backupPath, 'OLD-UDID'), { recursive: true }); - - restoreLegacyXctestDeviceSetRedirect(paths.xctestDeviceSetPath); - - assert.ok(fs.existsSync(path.join(paths.xctestDeviceSetPath, 'CURRENT-UDID'))); - assert.ok(fs.existsSync(path.join(paths.backupPath, 'OLD-UDID'))); -}); - -test('a host with no leftovers is left untouched and reports nothing', () => { - const paths = makeLegacyPaths(); - const phases = recordDiagnosticPhases(); - - restoreLegacyXctestDeviceSetRedirect(paths.xctestDeviceSetPath); - - assert.equal(fs.existsSync(paths.xctestDeviceSetPath), false); - assert.equal(fs.existsSync(paths.backupPath), false); - assert.deepEqual(phases, []); -}); - -/** Runs the restore with the other daemon's whole restore landing just before this one's `call`. */ -function withOtherDaemonFinishingBefore( - call: 'unlinkSync' | 'renameSync', - paths: LegacyRedirect, -): void { - const original = fs[call] as (...args: unknown[]) => unknown; - vi.spyOn(fs, call).mockImplementationOnce(((...args: unknown[]) => { - restoreLegacyXctestDeviceSetRedirect(paths.xctestDeviceSetPath); - return original.apply(fs, args); - }) as never); - restoreLegacyXctestDeviceSetRedirect(paths.xctestDeviceSetPath); -} - -test('a daemon whose unlink or rename the other daemon already made finishes the restore', () => { - for (const call of ['unlinkSync', 'renameSync'] as const) { - const paths = makeLegacyPaths(); - fs.mkdirSync(path.join(paths.backupPath, 'HOST-UDID'), { recursive: true }); - fs.symlinkSync(paths.scopedSetPath, paths.xctestDeviceSetPath, 'dir'); - - withOtherDaemonFinishingBefore(call, paths); - - assert.equal(fs.lstatSync(paths.xctestDeviceSetPath).isSymbolicLink(), false, call); - assert.ok(fs.existsSync(path.join(paths.xctestDeviceSetPath, 'HOST-UDID')), call); - assert.equal(fs.existsSync(paths.backupPath), false, call); - assert.ok(fs.existsSync(path.join(paths.scopedSetPath, 'SCOPED-UDID')), call); - vi.restoreAllMocks(); - } -}); - -test('an unlink that fails while the link is still there is reported', () => { - const paths = makeLegacyPaths(); - fs.mkdirSync(paths.backupPath); - fs.symlinkSync(paths.scopedSetPath, paths.xctestDeviceSetPath, 'dir'); - vi.spyOn(fs, 'unlinkSync').mockImplementationOnce(() => { - throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }); - }); - - assert.throws(() => restoreLegacyXctestDeviceSetRedirect(paths.xctestDeviceSetPath), /EACCES/); -}); - -function recordDiagnosticPhases(): string[] { - const phases: string[] = []; - appleRunnerTestHost.update({ emitDiagnostic: (event) => phases.push(event.phase) }); - return phases; -} diff --git a/packages/platform-apple/src/runner/__tests__/runner-early-exit-diagnosis.test.ts b/packages/platform-apple/src/runner/__tests__/runner-early-exit-diagnosis.test.ts index 23af649d68..532f83b34a 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-early-exit-diagnosis.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-early-exit-diagnosis.test.ts @@ -7,6 +7,7 @@ import { resetAllProcessMemosForTests } from '@agent-device/kernel/ttl-memo'; import type { ExecBackgroundResult } from '@agent-device/host-kit/command'; import { buildRunnerEarlyExitError } from '../runner-startup-transport.ts'; import { readRunnerLogTail } from '../runner-io.ts'; +import { resolveExpectedRunnerCacheMetadata } from '../runner-cache-metadata.ts'; import { RunnerCommandAccounting, type RunnerSession } from '../runner-session-types.ts'; import { mkdtempForTestSync } from './tmp-dir.ts'; import { STUBBED_APPLE_TOOLCHAIN, stubAppleToolchainProbes } from './apple-toolchain-fixtures.ts'; @@ -176,8 +177,8 @@ test('a session that never probed the device publishes no disk-image claim (#268 const SET_WITHOUT_UDID = CAPTURED_SCOPED_SIMULATOR.setWithoutUdid; -// An external xctestrun: the session carries no build of its own, so the Xcode it names comes from -// the toolchain the host selects. +// An external xctestrun: the session carries no build of its own, so the Xcode it names is the one a +// runner cache decision in this process already read, if any. function simulatorSessionFailingWith(log: string, simulatorSetPath?: string): RunnerSession { return { ...sessionFailingWith(log), @@ -203,28 +204,26 @@ function simulatorSessionFailingWith(log: string, simulatorSetPath?: string): Ru } test('a scoped-set simulator whose destination is missing names its set and the Xcode', async () => { - const error = (await buildRunnerEarlyExitError({ - session: simulatorSessionFailingWith( - CAPTURED_LAUNCH_DESTINATION_NOT_FOUND_OUTPUT, - SET_WITHOUT_UDID, - ), - port: 8100, - })) as AppError; + const session = simulatorSessionFailingWith( + CAPTURED_LAUNCH_DESTINATION_NOT_FOUND_OUTPUT, + SET_WITHOUT_UDID, + ); + resolveExpectedRunnerCacheMetadata(session.device); + + const error = (await buildRunnerEarlyExitError({ session, port: 8100 })) as AppError; assert.equal(error.details?.reason, 'simulator_set_destination_not_found'); assert.match(String(error.details?.hint), /-DVTSimulatorSetLocation/); assert.equal(error.details?.simulatorSetPath, SET_WITHOUT_UDID); assert.equal(error.details?.xcodeVersion, STUBBED_APPLE_TOOLCHAIN.xcodeVersion); - assert.ok( - error.message.endsWith( - `simulator set ${SET_WITHOUT_UDID} with Xcode ${STUBBED_APPLE_TOOLCHAIN.xcodeVersion}`, - ), + assert.equal( + error.message, + `Runner did not accept connection (xcodebuild exited early): xcodebuild found no simulator ${CAPTURED_SCOPED_SIMULATOR.udid} in simulator set ${SET_WITHOUT_UDID} with Xcode ${STUBBED_APPLE_TOOLCHAIN.xcodeVersion}`, ); }); -test('a scoped-set destination error whose Xcode cannot be read still names the set', async () => { - toolchainProbe.mockReturnValue({ exitCode: 1, stdout: '', stderr: 'xcode-select: error' }); - +test('a scoped-set destination error before any cache decision read the Xcode still names the set', async () => { + toolchainProbe.mockClear(); const error = (await buildRunnerEarlyExitError({ session: simulatorSessionFailingWith( CAPTURED_LAUNCH_DESTINATION_NOT_FOUND_OUTPUT, @@ -236,9 +235,11 @@ test('a scoped-set destination error whose Xcode cannot be read still names the assert.equal(error.details?.reason, 'simulator_set_destination_not_found'); assert.equal(error.details?.simulatorSetPath, SET_WITHOUT_UDID); assert.equal('xcodeVersion' in (error.details ?? {}), false); - assert.ok( - error.message.endsWith(`simulator set ${SET_WITHOUT_UDID} with Xcode (version unreadable)`), + assert.equal( + error.message, + `Runner did not accept connection (xcodebuild exited early): xcodebuild found no simulator ${CAPTURED_SCOPED_SIMULATOR.udid} in simulator set ${SET_WITHOUT_UDID}`, ); + assert.equal(toolchainProbe.mock.calls.length, 0); }); test('a default-set simulator early exit keeps its boot-failure reason', async () => { diff --git a/packages/platform-apple/src/runner/__tests__/runner-startup-failure-fixtures.ts b/packages/platform-apple/src/runner/__tests__/runner-startup-failure-fixtures.ts index 484648a495..8c520eceba 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-startup-failure-fixtures.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-startup-failure-fixtures.ts @@ -83,17 +83,17 @@ const CAPTURED_DESTINATION_XCODE_VERSION = 'Xcode 26.2 / Build version 17C52'; /** The scoped-set simulator the destination captures targeted, and a set that does not hold it. */ export const CAPTURED_SCOPED_SIMULATOR = { udid: '9DD70D58-C485-4CE5-8743-5FED48468446', - setWithoutUdid: - '/Users/thymikee/Developer/agent-device/.claude/worktrees/_scratch/2963-simset-empty', + setWithoutUdid: '/Users//simulator-sets/empty', } as const; /** * The runner launch (`test-without-building`) output for {@link CAPTURED_SCOPED_SIMULATOR} with * `-DVTSimulatorSetLocation` naming the set that does not hold it, captured with the launch argv from - * `runner-process-launch.ts` against a cached xctestrun. Host destination lines are left out. + * `runner-process-launch.ts` against a cached xctestrun. Host destination lines are left out, and + * home, checkout and temp paths are placeholders. */ export const CAPTURED_LAUNCH_DESTINATION_NOT_FOUND_OUTPUT = - 'Command line invocation:\n /Applications/Xcode-26.2.0.app/Contents/Developer/usr/bin/xcodebuild test-without-building -only-testing AgentDeviceRunnerUITests/RunnerTests/testCommand -parallel-testing-enabled NO -test-timeouts-enabled NO -collect-test-diagnostics never -maximum-concurrent-test-simulator-destinations 1 -destination-timeout 20 -xctestrun /Users/thymikee/.agent-device/apple-runner/derived/ios-simulator/cache-327953f5ae7efca8/Build/Products/AgentDeviceRunner_AgentDeviceRunnerUITests_iphonesimulator26.2-arm64.xctestrun -derivedDataPath /Users/thymikee/Developer/agent-device/.claude/worktrees/_scratch/2963-capture-derived -destination "platform=iOS Simulator,id=9DD70D58-C485-4CE5-8743-5FED48468446" -DVTSimulatorSetLocation=/Users/thymikee/Developer/agent-device/.claude/worktrees/_scratch/2963-simset-empty\n\n2026-09-25 14:06:21.656 xcodebuild[56376:166317816] [MT] IDERunDestination: Supported platforms for the buildables in the current scheme is empty.\n2026-09-25 14:06:21.753 xcodebuild[56376:166317816] [MT] IDERunDestination: Supported platforms for the buildables in the current scheme is empty.\n2026-09-25 14:06:41.674 xcodebuild[56376:166317816] [MT] IDERunDestination: Supported platforms for the buildables in the current scheme is empty.\n2026-09-25 14:06:41.685 xcodebuild[56376:166317816] Writing error result bundle to /var/folders/65/fz9_2bsj6fzgct46vx226s8c0000gn/T/ResultBundle_2026-25-09_14-06-0041.xcresult\nxcodebuild: error: Unable to find a device matching the provided destination specifier:\n\t\t{ platform:iOS Simulator, id:9DD70D58-C485-4CE5-8743-5FED48468446 }\n\n\tThe requested device could not be found because no available devices matched the request.\n\n\tAvailable destinations for the "AgentDeviceRunner" scheme:\n\t\t{ platform:iOS, id:dvtdevice-DVTiPhonePlaceholder-iphoneos:placeholder, name:Any iOS Device }\n\t\t{ platform:iOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-iphonesimulator:placeholder, name:Any iOS Simulator Device }\n\t\t{ platform:macOS, name:Any Mac }\n\t\t{ platform:macOS, variant:Mac Catalyst, name:Any Mac }\n\t\t{ platform:tvOS, id:dvtdevice-DVTiOSDevicePlaceholder-appletvos:placeholder, name:Any tvOS Device }\n\t\t{ platform:tvOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-appletvsimulator:placeholder, name:Any tvOS Simulator Device }\n\t\t{ platform:visionOS, id:dvtdevice-DVTiOSDevicePlaceholder-xros:placeholder, name:Any visionOS Device }\n\t\t{ platform:visionOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-xrsimulator:placeholder, name:Any visionOS Simulator Device }\n\t\t{ platform:watchOS, id:dvtdevice-DVTiOSDevicePlaceholder-watchos:placeholder, name:Any watchOS Device }\n\t\t{ platform:watchOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-watchsimulator:placeholder, name:Any watchOS Simulator Device }\n'; + 'Command line invocation:\n /Applications/Xcode-26.2.0.app/Contents/Developer/usr/bin/xcodebuild test-without-building -only-testing AgentDeviceRunnerUITests/RunnerTests/testCommand -parallel-testing-enabled NO -test-timeouts-enabled NO -collect-test-diagnostics never -maximum-concurrent-test-simulator-destinations 1 -destination-timeout 20 -xctestrun /Users//.agent-device/apple-runner/derived/ios-simulator/cache-327953f5ae7efca8/Build/Products/AgentDeviceRunner_AgentDeviceRunnerUITests_iphonesimulator26.2-arm64.xctestrun -derivedDataPath /Users//runner-derived -destination "platform=iOS Simulator,id=9DD70D58-C485-4CE5-8743-5FED48468446" -DVTSimulatorSetLocation=/Users//simulator-sets/empty\n\n2026-09-25 14:06:21.656 xcodebuild[56376:166317816] [MT] IDERunDestination: Supported platforms for the buildables in the current scheme is empty.\n2026-09-25 14:06:21.753 xcodebuild[56376:166317816] [MT] IDERunDestination: Supported platforms for the buildables in the current scheme is empty.\n2026-09-25 14:06:41.674 xcodebuild[56376:166317816] [MT] IDERunDestination: Supported platforms for the buildables in the current scheme is empty.\n2026-09-25 14:06:41.685 xcodebuild[56376:166317816] Writing error result bundle to /var/folders//T/ResultBundle_2026-25-09_14-06-0041.xcresult\nxcodebuild: error: Unable to find a device matching the provided destination specifier:\n\t\t{ platform:iOS Simulator, id:9DD70D58-C485-4CE5-8743-5FED48468446 }\n\n\tThe requested device could not be found because no available devices matched the request.\n\n\tAvailable destinations for the "AgentDeviceRunner" scheme:\n\t\t{ platform:iOS, id:dvtdevice-DVTiPhonePlaceholder-iphoneos:placeholder, name:Any iOS Device }\n\t\t{ platform:iOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-iphonesimulator:placeholder, name:Any iOS Simulator Device }\n\t\t{ platform:macOS, name:Any Mac }\n\t\t{ platform:macOS, variant:Mac Catalyst, name:Any Mac }\n\t\t{ platform:tvOS, id:dvtdevice-DVTiOSDevicePlaceholder-appletvos:placeholder, name:Any tvOS Device }\n\t\t{ platform:tvOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-appletvsimulator:placeholder, name:Any tvOS Simulator Device }\n\t\t{ platform:visionOS, id:dvtdevice-DVTiOSDevicePlaceholder-xros:placeholder, name:Any visionOS Device }\n\t\t{ platform:visionOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-xrsimulator:placeholder, name:Any visionOS Simulator Device }\n\t\t{ platform:watchOS, id:dvtdevice-DVTiOSDevicePlaceholder-watchos:placeholder, name:Any watchOS Device }\n\t\t{ platform:watchOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-watchsimulator:placeholder, name:Any watchOS Simulator Device }\n'; export type RunnerStartupFailureFixture = Readonly<{ /** Stable name for a focused test or a review comment. */ @@ -244,28 +244,28 @@ export const RUNNER_STARTUP_FAILURE_FIXTURES: readonly RunnerStartupFailureFixtu reason: 'simulator_set_destination_not_found', site: 'build-for-testing', command: - '/Applications/Xcode-26.2.0.app/Contents/Developer/usr/bin/xcodebuild build-for-testing -project /Users/thymikee/Developer/agent-device/.claude/worktrees/impl-2935b/apple/runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj -scheme AgentDeviceRunner -parallel-testing-enabled NO -maximum-concurrent-test-simulator-destinations 1 -destination "platform=iOS Simulator,id=9DD70D58-C485-4CE5-8743-5FED48468446" -DVTSimulatorSetLocation=/Users/thymikee/Developer/agent-device/.claude/worktrees/_scratch/2963-simset-empty -derivedDataPath /Users/thymikee/Developer/agent-device/.claude/worktrees/_scratch/2963-capture-derived COMPILER_INDEX_STORE_ENABLE=NO ENABLE_CODE_COVERAGE=NO ONLY_ACTIVE_ARCH=YES ENABLE_PREVIEWS=NO ENABLE_DEBUG_DYLIB=NO -IDEPackageSupportDisableManifestSandbox=1 -IDEPackageSupportDisablePluginExecutionSandbox=1 ENABLE_USER_SCRIPT_SANDBOXING=NO "OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox" AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID=com.callstack.agentdevice.runner AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID=com.callstack.agentdevice.runner.uitests', + '/Applications/Xcode-26.2.0.app/Contents/Developer/usr/bin/xcodebuild build-for-testing -project /Users//agent-device/apple/runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj -scheme AgentDeviceRunner -parallel-testing-enabled NO -maximum-concurrent-test-simulator-destinations 1 -destination "platform=iOS Simulator,id=9DD70D58-C485-4CE5-8743-5FED48468446" -DVTSimulatorSetLocation=/Users//simulator-sets/empty -derivedDataPath /Users//runner-derived COMPILER_INDEX_STORE_ENABLE=NO ENABLE_CODE_COVERAGE=NO ONLY_ACTIVE_ARCH=YES ENABLE_PREVIEWS=NO ENABLE_DEBUG_DYLIB=NO -IDEPackageSupportDisableManifestSandbox=1 -IDEPackageSupportDisablePluginExecutionSandbox=1 ENABLE_USER_SCRIPT_SANDBOXING=NO "OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox" AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID=com.callstack.agentdevice.runner AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID=com.callstack.agentdevice.runner.uitests', xcodeVersion: CAPTURED_DESTINATION_XCODE_VERSION, provenance: 'captured', output: - 'Command line invocation:\n /Applications/Xcode-26.2.0.app/Contents/Developer/usr/bin/xcodebuild build-for-testing -project /Users/thymikee/Developer/agent-device/.claude/worktrees/impl-2935b/apple/runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj -scheme AgentDeviceRunner -parallel-testing-enabled NO -maximum-concurrent-test-simulator-destinations 1 -destination "platform=iOS Simulator,id=9DD70D58-C485-4CE5-8743-5FED48468446" -DVTSimulatorSetLocation=/Users/thymikee/Developer/agent-device/.claude/worktrees/_scratch/2963-simset-empty -derivedDataPath /Users/thymikee/Developer/agent-device/.claude/worktrees/_scratch/2963-capture-derived COMPILER_INDEX_STORE_ENABLE=NO ENABLE_CODE_COVERAGE=NO ONLY_ACTIVE_ARCH=YES ENABLE_PREVIEWS=NO ENABLE_DEBUG_DYLIB=NO -IDEPackageSupportDisableManifestSandbox=1 -IDEPackageSupportDisablePluginExecutionSandbox=1 ENABLE_USER_SCRIPT_SANDBOXING=NO "OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox" AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID=com.callstack.agentdevice.runner AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID=com.callstack.agentdevice.runner.uitests\n\nBuild settings from command line:\n AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID = com.callstack.agentdevice.runner\n AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID = com.callstack.agentdevice.runner.uitests\n COMPILER_INDEX_STORE_ENABLE = NO\n ENABLE_CODE_COVERAGE = NO\n ENABLE_DEBUG_DYLIB = NO\n ENABLE_PREVIEWS = NO\n ENABLE_USER_SCRIPT_SANDBOXING = NO\n ONLY_ACTIVE_ARCH = YES\n OTHER_SWIFT_FLAGS = $(inherited) -disable-sandbox\n\nResolve Package Graph\n\n\nResolved source packages:\n agent-device-snapshot-presentation: /Users/thymikee/Developer/agent-device/.claude/worktrees/impl-2935b/apple/snapshot-presentation @ local\n\n2026-09-25 14:05:36.652 xcodebuild[34716:166287301] Writing error result bundle to /var/folders/65/fz9_2bsj6fzgct46vx226s8c0000gn/T/ResultBundle_2026-25-09_14-05-0036.xcresult\nxcodebuild: error: Unable to find a device matching the provided destination specifier:\n\t\t{ platform:iOS Simulator, id:9DD70D58-C485-4CE5-8743-5FED48468446 }\n\n\tThe requested device could not be found because no available devices matched the request.\n\n\tAvailable destinations for the "AgentDeviceRunner" scheme:\n\t\t{ platform:iOS, id:dvtdevice-DVTiPhonePlaceholder-iphoneos:placeholder, name:Any iOS Device }\n\t\t{ platform:iOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-iphonesimulator:placeholder, name:Any iOS Simulator Device }\n\t\t{ platform:macOS, name:Any Mac }\n\t\t{ platform:tvOS, id:dvtdevice-DVTiOSDevicePlaceholder-appletvos:placeholder, name:Any tvOS Device }\n\t\t{ platform:tvOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-appletvsimulator:placeholder, name:Any tvOS Simulator Device }\n\t\t{ platform:visionOS, id:dvtdevice-DVTiOSDevicePlaceholder-xros:placeholder, name:Any visionOS Device }\n\t\t{ platform:visionOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-xrsimulator:placeholder, name:Any visionOS Simulator Device }\n', + 'Command line invocation:\n /Applications/Xcode-26.2.0.app/Contents/Developer/usr/bin/xcodebuild build-for-testing -project /Users//agent-device/apple/runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj -scheme AgentDeviceRunner -parallel-testing-enabled NO -maximum-concurrent-test-simulator-destinations 1 -destination "platform=iOS Simulator,id=9DD70D58-C485-4CE5-8743-5FED48468446" -DVTSimulatorSetLocation=/Users//simulator-sets/empty -derivedDataPath /Users//runner-derived COMPILER_INDEX_STORE_ENABLE=NO ENABLE_CODE_COVERAGE=NO ONLY_ACTIVE_ARCH=YES ENABLE_PREVIEWS=NO ENABLE_DEBUG_DYLIB=NO -IDEPackageSupportDisableManifestSandbox=1 -IDEPackageSupportDisablePluginExecutionSandbox=1 ENABLE_USER_SCRIPT_SANDBOXING=NO "OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox" AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID=com.callstack.agentdevice.runner AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID=com.callstack.agentdevice.runner.uitests\n\nBuild settings from command line:\n AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID = com.callstack.agentdevice.runner\n AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID = com.callstack.agentdevice.runner.uitests\n COMPILER_INDEX_STORE_ENABLE = NO\n ENABLE_CODE_COVERAGE = NO\n ENABLE_DEBUG_DYLIB = NO\n ENABLE_PREVIEWS = NO\n ENABLE_USER_SCRIPT_SANDBOXING = NO\n ONLY_ACTIVE_ARCH = YES\n OTHER_SWIFT_FLAGS = $(inherited) -disable-sandbox\n\nResolve Package Graph\n\n\nResolved source packages:\n agent-device-snapshot-presentation: /Users//agent-device/apple/snapshot-presentation @ local\n\n2026-09-25 14:05:36.652 xcodebuild[34716:166287301] Writing error result bundle to /var/folders//T/ResultBundle_2026-25-09_14-05-0036.xcresult\nxcodebuild: error: Unable to find a device matching the provided destination specifier:\n\t\t{ platform:iOS Simulator, id:9DD70D58-C485-4CE5-8743-5FED48468446 }\n\n\tThe requested device could not be found because no available devices matched the request.\n\n\tAvailable destinations for the "AgentDeviceRunner" scheme:\n\t\t{ platform:iOS, id:dvtdevice-DVTiPhonePlaceholder-iphoneos:placeholder, name:Any iOS Device }\n\t\t{ platform:iOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-iphonesimulator:placeholder, name:Any iOS Simulator Device }\n\t\t{ platform:macOS, name:Any Mac }\n\t\t{ platform:tvOS, id:dvtdevice-DVTiOSDevicePlaceholder-appletvos:placeholder, name:Any tvOS Device }\n\t\t{ platform:tvOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-appletvsimulator:placeholder, name:Any tvOS Simulator Device }\n\t\t{ platform:visionOS, id:dvtdevice-DVTiOSDevicePlaceholder-xros:placeholder, name:Any visionOS Device }\n\t\t{ platform:visionOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-xrsimulator:placeholder, name:Any visionOS Simulator Device }\n', device: { ...IOS_SIMULATOR, id: CAPTURED_SCOPED_SIMULATOR.udid, simulatorSetPath: CAPTURED_SCOPED_SIMULATOR.setWithoutUdid, }, - note: "Captured by running the runner build argv agent-device records in runner.log, with `-DVTSimulatorSetLocation` naming a set that does not hold the udid: what a simulator deleted from its scoped set, or an Xcode that stops honoring the key, leaves xcodebuild to report. Destination lines naming this host's Mac, paired iPhone and default-set simulators are left out; the rest is verbatim.", + note: "Captured by running the runner build argv agent-device records in runner.log, with `-DVTSimulatorSetLocation` naming a set that does not hold the udid: what a simulator deleted from its scoped set, or an Xcode that stops honoring the key, leaves xcodebuild to report. Destination lines naming this host's Mac, paired iPhone and default-set simulators are left out, and home, checkout and temp paths are placeholders; the rest is verbatim.", }, { id: 'default-set-destination-not-found', reason: 'build_failed_unclassified', site: 'build-for-testing', command: - '/Applications/Xcode-26.2.0.app/Contents/Developer/usr/bin/xcodebuild build-for-testing -project /Users/thymikee/Developer/agent-device/.claude/worktrees/impl-2935b/apple/runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj -scheme AgentDeviceRunner -parallel-testing-enabled NO -maximum-concurrent-test-simulator-destinations 1 -destination "platform=iOS Simulator,id=9DD70D58-C485-4CE5-8743-5FED48468446" -derivedDataPath /Users/thymikee/Developer/agent-device/.claude/worktrees/_scratch/2963-capture-derived COMPILER_INDEX_STORE_ENABLE=NO ENABLE_CODE_COVERAGE=NO ONLY_ACTIVE_ARCH=YES ENABLE_PREVIEWS=NO ENABLE_DEBUG_DYLIB=NO -IDEPackageSupportDisableManifestSandbox=1 -IDEPackageSupportDisablePluginExecutionSandbox=1 ENABLE_USER_SCRIPT_SANDBOXING=NO "OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox" AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID=com.callstack.agentdevice.runner AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID=com.callstack.agentdevice.runner.uitests', + '/Applications/Xcode-26.2.0.app/Contents/Developer/usr/bin/xcodebuild build-for-testing -project /Users//agent-device/apple/runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj -scheme AgentDeviceRunner -parallel-testing-enabled NO -maximum-concurrent-test-simulator-destinations 1 -destination "platform=iOS Simulator,id=9DD70D58-C485-4CE5-8743-5FED48468446" -derivedDataPath /Users//runner-derived COMPILER_INDEX_STORE_ENABLE=NO ENABLE_CODE_COVERAGE=NO ONLY_ACTIVE_ARCH=YES ENABLE_PREVIEWS=NO ENABLE_DEBUG_DYLIB=NO -IDEPackageSupportDisableManifestSandbox=1 -IDEPackageSupportDisablePluginExecutionSandbox=1 ENABLE_USER_SCRIPT_SANDBOXING=NO "OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox" AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID=com.callstack.agentdevice.runner AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID=com.callstack.agentdevice.runner.uitests', xcodeVersion: CAPTURED_DESTINATION_XCODE_VERSION, provenance: 'captured', output: - 'Command line invocation:\n /Applications/Xcode-26.2.0.app/Contents/Developer/usr/bin/xcodebuild build-for-testing -project /Users/thymikee/Developer/agent-device/.claude/worktrees/impl-2935b/apple/runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj -scheme AgentDeviceRunner -parallel-testing-enabled NO -maximum-concurrent-test-simulator-destinations 1 -destination "platform=iOS Simulator,id=9DD70D58-C485-4CE5-8743-5FED48468446" -derivedDataPath /Users/thymikee/Developer/agent-device/.claude/worktrees/_scratch/2963-capture-derived COMPILER_INDEX_STORE_ENABLE=NO ENABLE_CODE_COVERAGE=NO ONLY_ACTIVE_ARCH=YES ENABLE_PREVIEWS=NO ENABLE_DEBUG_DYLIB=NO -IDEPackageSupportDisableManifestSandbox=1 -IDEPackageSupportDisablePluginExecutionSandbox=1 ENABLE_USER_SCRIPT_SANDBOXING=NO "OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox" AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID=com.callstack.agentdevice.runner AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID=com.callstack.agentdevice.runner.uitests\n\nBuild settings from command line:\n AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID = com.callstack.agentdevice.runner\n AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID = com.callstack.agentdevice.runner.uitests\n COMPILER_INDEX_STORE_ENABLE = NO\n ENABLE_CODE_COVERAGE = NO\n ENABLE_DEBUG_DYLIB = NO\n ENABLE_PREVIEWS = NO\n ENABLE_USER_SCRIPT_SANDBOXING = NO\n ONLY_ACTIVE_ARCH = YES\n OTHER_SWIFT_FLAGS = $(inherited) -disable-sandbox\n\nResolve Package Graph\n\n\nResolved source packages:\n agent-device-snapshot-presentation: /Users/thymikee/Developer/agent-device/.claude/worktrees/impl-2935b/apple/snapshot-presentation @ local\n\n2026-09-25 14:04:35.750 xcodebuild[28777:166276731] Writing error result bundle to /var/folders/65/fz9_2bsj6fzgct46vx226s8c0000gn/T/ResultBundle_2026-25-09_14-04-0035.xcresult\nxcodebuild: error: Unable to find a device matching the provided destination specifier:\n\t\t{ platform:iOS Simulator, id:9DD70D58-C485-4CE5-8743-5FED48468446 }\n\n\tThe requested device could not be found because no available devices matched the request.\n\n\tAvailable destinations for the "AgentDeviceRunner" scheme:\n\t\t{ platform:iOS, id:dvtdevice-DVTiPhonePlaceholder-iphoneos:placeholder, name:Any iOS Device }\n\t\t{ platform:iOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-iphonesimulator:placeholder, name:Any iOS Simulator Device }\n\t\t{ platform:macOS, name:Any Mac }\n\t\t{ platform:tvOS, id:dvtdevice-DVTiOSDevicePlaceholder-appletvos:placeholder, name:Any tvOS Device }\n\t\t{ platform:tvOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-appletvsimulator:placeholder, name:Any tvOS Simulator Device }\n\t\t{ platform:visionOS, id:dvtdevice-DVTiOSDevicePlaceholder-xros:placeholder, name:Any visionOS Device }\n\t\t{ platform:visionOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-xrsimulator:placeholder, name:Any visionOS Simulator Device }\n', + 'Command line invocation:\n /Applications/Xcode-26.2.0.app/Contents/Developer/usr/bin/xcodebuild build-for-testing -project /Users//agent-device/apple/runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj -scheme AgentDeviceRunner -parallel-testing-enabled NO -maximum-concurrent-test-simulator-destinations 1 -destination "platform=iOS Simulator,id=9DD70D58-C485-4CE5-8743-5FED48468446" -derivedDataPath /Users//runner-derived COMPILER_INDEX_STORE_ENABLE=NO ENABLE_CODE_COVERAGE=NO ONLY_ACTIVE_ARCH=YES ENABLE_PREVIEWS=NO ENABLE_DEBUG_DYLIB=NO -IDEPackageSupportDisableManifestSandbox=1 -IDEPackageSupportDisablePluginExecutionSandbox=1 ENABLE_USER_SCRIPT_SANDBOXING=NO "OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox" AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID=com.callstack.agentdevice.runner AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID=com.callstack.agentdevice.runner.uitests\n\nBuild settings from command line:\n AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID = com.callstack.agentdevice.runner\n AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID = com.callstack.agentdevice.runner.uitests\n COMPILER_INDEX_STORE_ENABLE = NO\n ENABLE_CODE_COVERAGE = NO\n ENABLE_DEBUG_DYLIB = NO\n ENABLE_PREVIEWS = NO\n ENABLE_USER_SCRIPT_SANDBOXING = NO\n ONLY_ACTIVE_ARCH = YES\n OTHER_SWIFT_FLAGS = $(inherited) -disable-sandbox\n\nResolve Package Graph\n\n\nResolved source packages:\n agent-device-snapshot-presentation: /Users//agent-device/apple/snapshot-presentation @ local\n\n2026-09-25 14:04:35.750 xcodebuild[28777:166276731] Writing error result bundle to /var/folders//T/ResultBundle_2026-25-09_14-04-0035.xcresult\nxcodebuild: error: Unable to find a device matching the provided destination specifier:\n\t\t{ platform:iOS Simulator, id:9DD70D58-C485-4CE5-8743-5FED48468446 }\n\n\tThe requested device could not be found because no available devices matched the request.\n\n\tAvailable destinations for the "AgentDeviceRunner" scheme:\n\t\t{ platform:iOS, id:dvtdevice-DVTiPhonePlaceholder-iphoneos:placeholder, name:Any iOS Device }\n\t\t{ platform:iOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-iphonesimulator:placeholder, name:Any iOS Simulator Device }\n\t\t{ platform:macOS, name:Any Mac }\n\t\t{ platform:tvOS, id:dvtdevice-DVTiOSDevicePlaceholder-appletvos:placeholder, name:Any tvOS Device }\n\t\t{ platform:tvOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-appletvsimulator:placeholder, name:Any tvOS Simulator Device }\n\t\t{ platform:visionOS, id:dvtdevice-DVTiOSDevicePlaceholder-xros:placeholder, name:Any visionOS Device }\n\t\t{ platform:visionOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-xrsimulator:placeholder, name:Any visionOS Simulator Device }\n', device: { ...IOS_SIMULATOR, id: CAPTURED_SCOPED_SIMULATOR.udid }, note: "Captured with the same argv and no `-DVTSimulatorSetLocation`, so xcodebuild looked in the default set, which does not hold the udid. The text is the scoped row's, word for word: only the scoped set path on the device tells the two apart. Host destination lines are left out as above.", }, diff --git a/packages/platform-apple/src/runner/__tests__/runner-startup-failure-reasons.test.ts b/packages/platform-apple/src/runner/__tests__/runner-startup-failure-reasons.test.ts index 10c8b29c6c..d2aa865755 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-startup-failure-reasons.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-startup-failure-reasons.test.ts @@ -50,6 +50,11 @@ import { mkdtempForTestSync } from './tmp-dir.ts'; const CACHE_RECOVERY_HINT = /clean:xcuitest|apple-runner\/derived/; +/** The fixtures whose failure message names more than the failed build, by fixture id. */ +const MESSAGE_FOR_FIXTURE: Readonly> = { + 'scoped-set-destination-not-found': `xcodebuild build-for-testing failed: xcodebuild found no simulator ${CAPTURED_SCOPED_SIMULATOR.udid} in simulator set ${CAPTURED_SCOPED_SIMULATOR.setWithoutUdid} with Xcode ${STUBBED_APPLE_TOOLCHAIN.xcodeVersion}`, +}; + /** * The phrase each reason's advice has to contain. Kept as text rather than as syntax because two of * them are quotations from `core/devicectl.ts`, and a fifth escaping helper for a prose remedy with @@ -174,7 +179,10 @@ function assertFailureEnvelope( fixture: RunnerStartupFailureFixture, ): void { assert.equal(envelope.code, 'COMMAND_FAILED'); - assert.ok(envelope.message.startsWith('xcodebuild build-for-testing failed')); + assert.equal( + envelope.message, + MESSAGE_FOR_FIXTURE[fixture.id] ?? 'xcodebuild build-for-testing failed', + ); assert.equal(envelope.details?.reason, fixture.reason); assert.ok( String(envelope.hint).includes(HINT_FOR_REASON[fixture.reason]), @@ -239,16 +247,13 @@ test('every reason the classifier can name is produced by a rule row', () => { }); test('a scoped-set simulator xcodebuild cannot find names its set and the Xcode', async () => { - const { udid, setWithoutUdid } = CAPTURED_SCOPED_SIMULATOR; + const { setWithoutUdid } = CAPTURED_SCOPED_SIMULATOR; const envelope = await driveBuildFailure(buildFixtureById('scoped-set-destination-not-found')); assert.equal(envelope.details?.reason, 'simulator_set_destination_not_found'); assert.equal(envelope.details?.simulatorSetPath, setWithoutUdid); assert.equal(envelope.details?.xcodeVersion, STUBBED_APPLE_TOOLCHAIN.xcodeVersion); - assert.equal( - envelope.message, - `xcodebuild build-for-testing failed: xcodebuild found no simulator ${udid} in simulator set ${setWithoutUdid} with Xcode ${STUBBED_APPLE_TOOLCHAIN.xcodeVersion}`, - ); + assert.equal(envelope.message, MESSAGE_FOR_FIXTURE['scoped-set-destination-not-found']); const buildArgs = runCmdStreaming.mock.calls[0]?.[1] as string[]; assert.ok(buildArgs.includes(`-DVTSimulatorSetLocation=${setWithoutUdid}`)); }); diff --git a/packages/platform-apple/src/runner/legacy-xctest-device-set.ts b/packages/platform-apple/src/runner/legacy-xctest-device-set.ts new file mode 100644 index 0000000000..9b86280350 --- /dev/null +++ b/packages/platform-apple/src/runner/legacy-xctest-device-set.ts @@ -0,0 +1,74 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { ResourceDiagnostic } from '@agent-device/host-kit/diagnostics'; + +/** + * Puts the host's own `~/Library/Developer/XCTestDevices` back where an older agent-device left it + * redirected into a scoped simulator set: a symlink in its place, with the real directory renamed to + * `XCTestDevices.agent-device-backup` when one existed. Xcode's first-launch cleanup deletes every + * device in `XCTestDevices`, so a symlink left there deletes the set it points at. A symlinked + * `XCTestDevices` is unsupported: any symlink there is removed, as released versions did on every + * scoped runner start; unlinking deletes no data. Daemons starting together may both undo it: a step + * the other daemon already took is done, not a failure. Best effort: a failure is reported, not + * thrown. + */ +export function restoreLegacyXctestDeviceSetRedirect( + onDiagnostic: (diagnostic: ResourceDiagnostic) => void, + xctestDeviceSetPath: string = path.join(os.homedir(), 'Library', 'Developer', 'XCTestDevices'), +): void { + const backupPath = `${xctestDeviceSetPath}.agent-device-backup`; + try { + if (isSymlinkAt(xctestDeviceSetPath)) { + const linkTarget = readLinkTarget(xctestDeviceSetPath); + removeSymlinkUnlessGone(xctestDeviceSetPath); + onDiagnostic({ + phase: 'ios_runner_legacy_xctest_device_set_link_removed', + resourcePath: xctestDeviceSetPath, + data: { linkTarget }, + }); + } + if (fs.existsSync(backupPath) && !fs.existsSync(xctestDeviceSetPath)) { + restoreBackupUnlessRestored(backupPath, xctestDeviceSetPath); + onDiagnostic({ + phase: 'ios_runner_legacy_xctest_device_set_backup_restored', + resourcePath: xctestDeviceSetPath, + data: { backupPath }, + }); + } + } catch (error) { + onDiagnostic({ + phase: 'ios_runner_legacy_xctest_device_set_restore_failed', + resourcePath: xctestDeviceSetPath, + data: { error: error instanceof Error ? error.message : String(error) }, + }); + } +} + +function removeSymlinkUnlessGone(linkPath: string): void { + try { + fs.unlinkSync(linkPath); + } catch (error) { + if (isSymlinkAt(linkPath)) throw error; + } +} + +function restoreBackupUnlessRestored(backupPath: string, restoredPath: string): void { + try { + fs.renameSync(backupPath, restoredPath); + } catch (error) { + if (fs.existsSync(backupPath) && !fs.existsSync(restoredPath)) throw error; + } +} + +function isSymlinkAt(filePath: string): boolean { + return fs.lstatSync(filePath, { throwIfNoEntry: false })?.isSymbolicLink() ?? false; +} + +function readLinkTarget(linkPath: string): string | undefined { + try { + return fs.readlinkSync(linkPath); + } catch { + return undefined; + } +} diff --git a/packages/platform-apple/src/runner/runner-cache-metadata.ts b/packages/platform-apple/src/runner/runner-cache-metadata.ts index 475f40718a..8de16b11b3 100644 --- a/packages/platform-apple/src/runner/runner-cache-metadata.ts +++ b/packages/platform-apple/src/runner/runner-cache-metadata.ts @@ -39,8 +39,6 @@ const RUNNER_CACHE_METADATA_VALUE_MAX_LENGTH = 300; * probe, its warm retry, and the two probes still to run (#2422). */ const TOOLCHAIN_FINGERPRINT_BUDGET_MS = 45_000; -/** What naming the Xcode in a failure report may spend when the fingerprint is not memoized yet. */ -const XCODE_VERSION_REPORT_BUDGET_MS = 5_000; const TOOLCHAIN_PROBE_MAX_BUFFER = 128 * 1024; const TOOLCHAIN_PROBE_DETAIL_MAX_LENGTH = 200; const TOOLCHAIN_PROBE_HINT = @@ -304,19 +302,14 @@ function requireRunnerToolchainFingerprint( } /** - * The selected Xcode's version, for a failure report that names it. Reads the fingerprint the cache - * decision already memoized; a cold or unreadable toolchain answers undefined within - * {@link XCODE_VERSION_REPORT_BUDGET_MS} instead of delaying the failure it describes. + * The selected Xcode's version as this process's runner cache decision memoized it, for a failure + * report that names it; undefined when no decision has read the toolchain. Never probes: a report + * must not wait on the toolchain it describes. */ -export function readRunnerXcodeVersion(device: DeviceInfo): string | undefined { - try { - return requireRunnerToolchainFingerprint( - resolveRunnerSdkName(resolveRunnerPlatformName(device), device.kind), - createRunnerPhaseBudget(XCODE_VERSION_REPORT_BUDGET_MS, undefined), - ).xcodeVersion; - } catch { - return undefined; - } +export function memoizedRunnerXcodeVersion(device: DeviceInfo): string | undefined { + return toolchainFingerprintCache().get( + resolveRunnerSdkName(resolveRunnerPlatformName(device), device.kind), + )?.xcodeVersion; } function readRunnerToolchainFingerprint( diff --git a/packages/platform-apple/src/runner/runner-device-set.ts b/packages/platform-apple/src/runner/runner-device-set.ts index 48c30b6e17..36930f4030 100644 --- a/packages/platform-apple/src/runner/runner-device-set.ts +++ b/packages/platform-apple/src/runner/runner-device-set.ts @@ -1,9 +1,6 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { emitDiagnostic, simulatorAddressFor } from './host.ts'; -import { readRunnerXcodeVersion } from './runner-cache-metadata.ts'; +import { simulatorAddressFor } from './host.ts'; +import { memoizedRunnerXcodeVersion } from './runner-cache-metadata.ts'; /** The scoped simulator set that holds this runner's simulator, or undefined for the default set. */ export function runnerSimulatorSetPath(device: DeviceInfo): string | undefined { @@ -29,87 +26,30 @@ export function xcodebuildDestinationArgs(device: DeviceInfo, destination: strin : ['-destination', destination, `-DVTSimulatorSetLocation=${simulatorSetPath}`]; } -/** - * Puts the host's own `~/Library/Developer/XCTestDevices` back where an older agent-device left it - * redirected into a scoped simulator set: a symlink in its place, with the real directory renamed to - * `XCTestDevices.agent-device-backup` when one existed. Xcode's first-launch cleanup deletes every - * device in `XCTestDevices`, so a symlink left there deletes the set it points at. Any symlink there - * is removed, as released versions did on every scoped runner start; unlinking deletes no data. - * Daemons starting together may both undo it: a step the other daemon already took is done, not a - * failure. - */ -export function restoreLegacyXctestDeviceSetRedirect( - xctestDeviceSetPath: string = path.join(os.homedir(), 'Library', 'Developer', 'XCTestDevices'), -): void { - const backupPath = `${xctestDeviceSetPath}.agent-device-backup`; - if (isSymlinkAt(xctestDeviceSetPath)) { - const linkTarget = readLinkTarget(xctestDeviceSetPath); - removeSymlinkUnlessGone(xctestDeviceSetPath); - emitDiagnostic({ - level: 'warn', - phase: 'ios_runner_legacy_xctest_device_set_link_removed', - data: { xctestDeviceSetPath, linkTarget }, - }); - } - if (fs.existsSync(backupPath) && !fs.existsSync(xctestDeviceSetPath)) { - restoreBackupUnlessRestored(backupPath, xctestDeviceSetPath); - emitDiagnostic({ - level: 'warn', - phase: 'ios_runner_legacy_xctest_device_set_backup_restored', - data: { xctestDeviceSetPath, backupPath }, - }); - } -} - -function removeSymlinkUnlessGone(linkPath: string): void { - try { - fs.unlinkSync(linkPath); - } catch (error) { - if (isSymlinkAt(linkPath)) throw error; - } -} - -function restoreBackupUnlessRestored(backupPath: string, restoredPath: string): void { - try { - fs.renameSync(backupPath, restoredPath); - } catch (error) { - if (fs.existsSync(backupPath) && !fs.existsSync(restoredPath)) throw error; - } -} - -function isSymlinkAt(filePath: string): boolean { - return fs.lstatSync(filePath, { throwIfNoEntry: false })?.isSymbolicLink() ?? false; -} - -function readLinkTarget(linkPath: string): string | undefined { - try { - return fs.readlinkSync(linkPath); - } catch { - return undefined; - } -} - /** What a runner xcodebuild failure reports about the scoped set it resolved its destination in. */ type RunnerSimulatorSetFailureDetails = { simulatorSetPath?: string; xcodeVersion?: string }; /** * The scoped set a runner xcodebuild phase resolved its destination in, with the selected Xcode when - * the toolchain answers; empty for the default set. + * the runner cache decision already read it; empty for the default set. */ export function runnerSimulatorSetFailureDetails( device: DeviceInfo, ): RunnerSimulatorSetFailureDetails { const simulatorSetPath = runnerSimulatorSetPath(device); if (simulatorSetPath === undefined) return {}; - const xcodeVersion = readRunnerXcodeVersion(device); + const xcodeVersion = memoizedRunnerXcodeVersion(device); return xcodeVersion === undefined ? { simulatorSetPath } : { simulatorSetPath, xcodeVersion }; } -/** Names the simulator, the scoped set and the Xcode behind a `simulator_set_destination_not_found`. */ +/** + * Names the simulator and the scoped set behind a `simulator_set_destination_not_found`, and the Xcode + * when this process has read its version. + */ export function simulatorSetDestinationNotFoundMessage( message: string, device: DeviceInfo, details: RunnerSimulatorSetFailureDetails, ): string { - return `${message}: xcodebuild found no simulator ${device.id} in simulator set ${details.simulatorSetPath} with Xcode ${details.xcodeVersion ?? '(version unreadable)'}`; + return `${message}: xcodebuild found no simulator ${device.id} in simulator set ${details.simulatorSetPath}${details.xcodeVersion === undefined ? '' : ` with Xcode ${details.xcodeVersion}`}`; } diff --git a/src/daemon/__tests__/daemon-runtime-app-log.test.ts b/src/daemon/__tests__/daemon-runtime-app-log.test.ts index 546840e914..0057a59e90 100644 --- a/src/daemon/__tests__/daemon-runtime-app-log.test.ts +++ b/src/daemon/__tests__/daemon-runtime-app-log.test.ts @@ -2,14 +2,12 @@ import fs from 'node:fs'; import path from 'node:path'; import { expect, test, vi } from 'vitest'; import { localRuntimeOwner } from '@agent-device/contracts/platform-runtime'; +import type { ResourceDiagnostic } from '@agent-device/host-kit/diagnostics'; import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; import { createTestAppLogLiveHandle } from '../../__tests__/test-utils/app-log-live-handle.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; -import { - recoverAppLogResourcesAfterDaemonLock, - type AppLogRecoveryDiagnostic, -} from '../app-log-resource-recovery.ts'; +import { recoverAppLogResourcesAfterDaemonLock } from '../app-log-resource-recovery.ts'; import { appLogResourceStore } from '../app-log-resource-store.ts'; import { flushDaemonStartupDiagnostics, @@ -58,7 +56,7 @@ test('retained startup recovery evidence is flushed after daemon.log publication const resourcePath = path.join(sessionsDir, 'session', 'app-log.resource.json'); fs.mkdirSync(path.dirname(resourcePath), { recursive: true }); fs.writeFileSync(resourcePath, '{'); - const diagnostics: AppLogRecoveryDiagnostic[] = []; + const diagnostics: ResourceDiagnostic[] = []; await recoverAppLogResourcesAfterDaemonLock({ sessionsDir, diff --git a/src/daemon/app-log-resource-recovery.ts b/src/daemon/app-log-resource-recovery.ts index 755decfe99..aea6a5d548 100644 --- a/src/daemon/app-log-resource-recovery.ts +++ b/src/daemon/app-log-resource-recovery.ts @@ -11,9 +11,9 @@ import { } from '@agent-device/contracts/platform-runtime-operations'; import type { DurableCaptureRecoveryControl, - DurableCaptureRecoveryDiagnostic, DurableCaptureRecoverySummary, } from '@agent-device/capture-kit/durable-capture'; +import type { ResourceDiagnostic } from '@agent-device/host-kit/diagnostics'; import { appLogDurableResource } from './app-log-session-resource.ts'; import { acquireExactDurableCaptureRecoveryControl } from '@agent-device/capture-kit/durable-capture-runtime-recovery'; @@ -22,14 +22,13 @@ const appLogRecoveryUse = defineUse({ }); export type AppLogRecoverySummary = DurableCaptureRecoverySummary; -export type AppLogRecoveryDiagnostic = DurableCaptureRecoveryDiagnostic; export function recoverAppLogResourcesAfterDaemonLock(params: { sessionsDir: string; gateway: DeviceRuntimeGateway; scope: PlatformRequestScope; perRecordDeadlineMs?: number; - onDiagnostic?: (diagnostic: AppLogRecoveryDiagnostic) => void; + onDiagnostic?: (diagnostic: ResourceDiagnostic) => void; }): Promise { return appLogDurableResource.recoverAll({ sessionsDir: params.sessionsDir, @@ -43,7 +42,7 @@ export function recoverAppLogResourceAfterDaemonLock(params: { gateway: DeviceRuntimeGateway; scope: PlatformRequestScope; perRecordDeadlineMs?: number; - onDiagnostic?: (diagnostic: AppLogRecoveryDiagnostic) => void; + onDiagnostic?: (diagnostic: ResourceDiagnostic) => void; }) { return appLogDurableResource.recoverOne( { sessionsDir: params.sessionsDir, ...buildAppLogRecoveryParams(params) }, @@ -55,7 +54,7 @@ function buildAppLogRecoveryParams(params: { gateway: DeviceRuntimeGateway; scope: PlatformRequestScope; perRecordDeadlineMs?: number; - onDiagnostic?: (diagnostic: AppLogRecoveryDiagnostic) => void; + onDiagnostic?: (diagnostic: ResourceDiagnostic) => void; }) { return { scope: params.scope, diff --git a/src/daemon/platform-owner-lifecycle.ts b/src/daemon/platform-owner-lifecycle.ts index faf4356d9c..31b407cefc 100644 --- a/src/daemon/platform-owner-lifecycle.ts +++ b/src/daemon/platform-owner-lifecycle.ts @@ -1,4 +1,5 @@ import type { DeviceIdentity, DeviceInfo } from '@agent-device/kernel/device'; +import type { ResourceDiagnostic } from '@agent-device/host-kit/diagnostics'; import type { OwnedProcessRecordStore } from '@agent-device/host-kit/process'; /** Predicate answering whether this process currently holds an active claim on the device. */ @@ -21,11 +22,16 @@ export type LegacyAppLogMarkerRecoveryOutcome = Readonly<{ * this surface. No generic hook bag — every phase this daemon relies on is named here. */ export type PlatformOwnerLifecycle = Readonly<{ - /** Publishes the daemon-owned lease-owner state dir and claim-authority probe. */ + /** + * Startup, before servers open: publishes the daemon-owned lease-owner state dir and + * claim-authority probe, and puts back the host's own `XCTestDevices` where an older agent-device + * left it redirected into a scoped simulator set. + */ configureForDaemonLock( input: Readonly<{ stateDir: string; hasDeviceClaimAuthority: DeviceClaimAuthorityProbe; + onDiagnostic: (diagnostic: ResourceDiagnostic) => void; }>, ): Promise; /** Clears the configuration above: on a failed lock acquisition, and on shutdown. */ @@ -40,11 +46,6 @@ export type PlatformOwnerLifecycle = Readonly<{ ownedProcessRecords?: OwnedProcessRecordStore; }>, ): Promise; - /** - * Startup, after daemon.log publication: puts back the host's own `XCTestDevices` directory where - * an older agent-device left it redirected into a scoped simulator set. - */ - restoreLegacyXctestDeviceSetRedirect(): Promise; /** Shutdown: resets Android snapshot-helper runtime sessions. */ resetAndroidSnapshotHelper(): Promise; }>; diff --git a/src/daemon/server/daemon-runtime-xctest-device-set.test.ts b/src/daemon/server/daemon-runtime-xctest-device-set.test.ts index f3f99dc146..ebfe0091f1 100644 --- a/src/daemon/server/daemon-runtime-xctest-device-set.test.ts +++ b/src/daemon/server/daemon-runtime-xctest-device-set.test.ts @@ -1,75 +1,158 @@ import fs from 'node:fs'; import path from 'node:path'; import { afterEach, expect, test, vi } from 'vitest'; -import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { withMockedPlatform } from '../../__tests__/test-utils/host-execution.ts'; -const { mockRestoreLegacyXctestDeviceSetRedirect } = vi.hoisted(() => ({ - mockRestoreLegacyXctestDeviceSetRedirect: vi.fn(), +const legacyRedirect = vi.hoisted(() => ({ + xctestDeviceSetPath: '', + infoPublishedAtRestore: [] as boolean[], + infoPath: '', })); -vi.mock('../../platform-runtime-daemon-lifecycle.ts', async (importOriginal) => { - const actual = - await importOriginal(); +vi.mock('@agent-device/platform-apple/runner-owner', async (importOriginal) => { + const actual = await importOriginal(); return { - platformDaemonLifecycleOwners: { - ...actual.platformDaemonLifecycleOwners, - restoreLegacyXctestDeviceSetRedirect: mockRestoreLegacyXctestDeviceSetRedirect, + ...actual, + restoreLegacyXctestDeviceSetRedirect: async ( + onDiagnostic: Parameters[0], + ) => { + legacyRedirect.infoPublishedAtRestore.push(fs.existsSync(legacyRedirect.infoPath)); + await actual.restoreLegacyXctestDeviceSetRedirect( + onDiagnostic, + legacyRedirect.xctestDeviceSetPath, + ); }, }; }); -import { restoreLegacyXctestDeviceSetForDaemonStartup } from './daemon-runtime.ts'; +vi.mock('../../platform-runtime.ts', () => ({ + androidObservation: {}, + createRequestPlatformProviders: () => ({ + run: async (_context: unknown, task: () => Promise) => await task(), + }), + createPlatformRuntimeGateway: () => ({ + applicationLifecycle: { + recoverStartupResources: async () => {}, + detachForDaemonShutdown: async () => {}, + finalizeDaemonShutdown: async () => {}, + }, + inspectFacts: async () => { + throw new Error('unused'); + }, + bind: async () => { + throw new Error('unused'); + }, + shutdown: async () => {}, + }), + createPlatformDeviceInventoryGateways: () => ({}), +})); + +vi.mock('../../provider-device-runtimes.ts', () => ({ + DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS: [], + createDefaultProviderRuntimeComposition: async () => ({ runtimes: [], platformModules: [] }), +})); + +import { startDaemonRuntime } from './daemon-runtime.ts'; const roots: string[] = []; afterEach(() => { - mockRestoreLegacyXctestDeviceSetRedirect.mockReset(); + legacyRedirect.infoPublishedAtRestore.length = 0; for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); }); -function daemonLogPath(): string { - const root = mkdtempForTestSync('agent-device-daemon-xctest-device-set-'); - roots.push(root); - return path.join(root, 'daemon.log'); -} - -function loggedPhases(logPath: string): string[] { - if (!fs.existsSync(logPath)) return []; +function loggedEvents(logPath: string): Array<{ phase: string; data?: Record }> { return fs .readFileSync(logPath, 'utf8') .trim() .split('\n') - .map((line) => (JSON.parse(line) as { phase: string }).phase); + .map((line) => JSON.parse(line) as { phase: string; data?: Record }) + .filter((event) => event.phase.startsWith('ios_runner_legacy_xctest_device_set_')); } -test('what the restore puts back is recorded in daemon.log', async () => { - const logPath = daemonLogPath(); - mockRestoreLegacyXctestDeviceSetRedirect.mockImplementationOnce(async () => { - emitDiagnostic({ level: 'warn', phase: 'ios_runner_legacy_xctest_device_set_link_removed' }); - }); - - await restoreLegacyXctestDeviceSetForDaemonStartup(logPath); - - expect(loggedPhases(logPath)).toEqual(['ios_runner_legacy_xctest_device_set_link_removed']); -}); - -test('a restore that fails is recorded in daemon.log and does not fail the daemon startup', async () => { - const logPath = daemonLogPath(); - mockRestoreLegacyXctestDeviceSetRedirect.mockRejectedValueOnce( - Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }), +function startDarwinDaemon(stateDir: string) { + return withMockedPlatform('darwin', () => + startDaemonRuntime({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + }, + exit: () => {}, + registerProcessHandlers: false, + stderr: { write: () => {} }, + stdout: { write: () => {} }, + }), ); +} - await expect(restoreLegacyXctestDeviceSetForDaemonStartup(logPath)).resolves.toBeUndefined(); - - expect(loggedPhases(logPath)).toEqual(['ios_runner_legacy_xctest_device_set_restore_failed']); -}); - -test('a host with nothing to put back leaves daemon.log untouched', async () => { - const logPath = daemonLogPath(); - mockRestoreLegacyXctestDeviceSetRedirect.mockResolvedValueOnce(undefined); +function makeLegacyRedirect(): { stateDir: string; developer: string; scopedSetPath: string } { + const root = mkdtempForTestSync('agent-device-daemon-xctest-device-set-'); + roots.push(root); + const stateDir = path.join(root, 'state'); + const developer = path.join(root, 'Library', 'Developer'); + const scopedSetPath = path.join(root, 'tenant-set'); + fs.mkdirSync(path.join(scopedSetPath, 'SCOPED-UDID'), { recursive: true }); + fs.mkdirSync(developer, { recursive: true }); + legacyRedirect.xctestDeviceSetPath = path.join(developer, 'XCTestDevices'); + legacyRedirect.infoPath = path.join(stateDir, 'daemon.json'); + fs.symlinkSync(scopedSetPath, legacyRedirect.xctestDeviceSetPath, 'dir'); + return { stateDir, developer, scopedSetPath }; +} - await restoreLegacyXctestDeviceSetForDaemonStartup(logPath); +test('macOS daemon startup puts back a redirected XCTestDevices before it publishes readiness', async () => { + const { stateDir, developer, scopedSetPath } = makeLegacyRedirect(); + fs.mkdirSync(path.join(developer, 'XCTestDevices.agent-device-backup', 'HOST-UDID'), { + recursive: true, + }); - expect(fs.existsSync(logPath)).toBe(false); + const runtime = await startDarwinDaemon(stateDir); + try { + expect(runtime).not.toBeNull(); + expect(legacyRedirect.infoPublishedAtRestore).toEqual([false]); + expect(fs.lstatSync(legacyRedirect.xctestDeviceSetPath).isSymbolicLink()).toBe(false); + expect(fs.existsSync(path.join(legacyRedirect.xctestDeviceSetPath, 'HOST-UDID'))).toBe(true); + expect(fs.existsSync(path.join(scopedSetPath, 'SCOPED-UDID'))).toBe(true); + expect(loggedEvents(path.join(stateDir, 'daemon.log'))).toEqual([ + expect.objectContaining({ + phase: 'ios_runner_legacy_xctest_device_set_link_removed', + data: expect.objectContaining({ + resourcePath: legacyRedirect.xctestDeviceSetPath, + linkTarget: scopedSetPath, + }), + }), + expect.objectContaining({ phase: 'ios_runner_legacy_xctest_device_set_backup_restored' }), + ]); + } finally { + await runtime?.shutdown(); + } }); + +test.skipIf(process.getuid?.() === 0)( + 'a restore the host refuses is logged and the daemon still starts', + async () => { + const { stateDir, developer } = makeLegacyRedirect(); + fs.chmodSync(developer, 0o555); + + try { + const runtime = await startDarwinDaemon(stateDir); + try { + expect(runtime).not.toBeNull(); + expect(fs.existsSync(legacyRedirect.infoPath)).toBe(true); + expect(fs.lstatSync(legacyRedirect.xctestDeviceSetPath).isSymbolicLink()).toBe(true); + expect(loggedEvents(path.join(stateDir, 'daemon.log'))).toEqual([ + expect.objectContaining({ + phase: 'ios_runner_legacy_xctest_device_set_restore_failed', + data: expect.objectContaining({ resourcePath: legacyRedirect.xctestDeviceSetPath }), + }), + ]); + } finally { + await runtime?.shutdown(); + } + } finally { + fs.chmodSync(developer, 0o755); + } + }, +); diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index f9d9ba0a13..a944692889 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -53,6 +53,7 @@ import { emitDiagnostic, flushDiagnosticsToSessionFile, withDiagnosticsScope, + type ResourceDiagnostic, } from '@agent-device/host-kit/diagnostics'; import { createOwnedProcessRecordStore, @@ -83,10 +84,7 @@ import { prewarmPngWorker, terminatePngWorker } from '@agent-device/capture-kit/ import { platformResourceCleanup } from '../../platform-runtime-resource-cleanup.ts'; import { platformDaemonLifecycleOwners } from '../../platform-runtime-daemon-lifecycle.ts'; import { openWebSessionNames } from '../web-session-names.ts'; -import { - recoverAppLogResourcesAfterDaemonLock, - type AppLogRecoveryDiagnostic, -} from '../app-log-resource-recovery.ts'; +import { recoverAppLogResourcesAfterDaemonLock } from '../app-log-resource-recovery.ts'; import { createDaemonRecoveryPlatformScope } from '../platform-request-scope.ts'; import { createAppLogAdmissionLedger } from '../app-log-admission-ledger.ts'; @@ -220,7 +218,7 @@ export type DaemonRuntimeController = { export async function flushDaemonStartupDiagnostics( logPath: string, - diagnostics: readonly AppLogRecoveryDiagnostic[], + diagnostics: readonly ResourceDiagnostic[], ): Promise { if (diagnostics.length === 0) return; await withDiagnosticsScope( @@ -570,24 +568,25 @@ export async function startDaemonRuntime( let servers: DaemonServer[] = []; let socketPort: number | undefined; let httpPort: number | undefined; - const startupAppLogDiagnostics: AppLogRecoveryDiagnostic[] = []; + const startupDiagnostics: ResourceDiagnostic[] = []; try { await platformDaemonLifecycleOwners.configureForDaemonLock({ stateDir: baseDir, hasDeviceClaimAuthority: processOwnsActiveDeviceClaim, + onDiagnostic: (diagnostic) => startupDiagnostics.push(diagnostic), }); const legacyMarkerRecovery = await platformDaemonLifecycleOwners.recoverLegacyAppLogMarkers(sessionsDir); appLogAdmissionLedger.retainLegacyMarkers(legacyMarkerRecovery.retained); for (const markerPath of legacyMarkerRecovery.recovered) { - startupAppLogDiagnostics.push({ + startupDiagnostics.push({ phase: 'app_log_legacy_marker_recovered', resourcePath: markerPath, data: {}, }); } for (const retained of legacyMarkerRecovery.retained) { - startupAppLogDiagnostics.push({ + startupDiagnostics.push({ phase: 'app_log_legacy_marker_retained', resourcePath: retained.markerPath, data: { @@ -600,7 +599,7 @@ export async function startDaemonRuntime( sessionsDir, gateway: deviceRuntimeGateway, scope: createDaemonRecoveryPlatformScope(), - onDiagnostic: (diagnostic) => startupAppLogDiagnostics.push(diagnostic), + onDiagnostic: (diagnostic) => startupDiagnostics.push(diagnostic), }); await reapOwnedProcessRecordsAtStartup(ownedProcessRecords, { openWebSessionNames: openWebSessionNames(sessionStore), @@ -626,7 +625,7 @@ export async function startDaemonRuntime( socketPort = opened.socketPort; httpPort = opened.httpPort; publishDaemonInfo(socketPort, httpPort); - await flushDaemonStartupDiagnostics(logPath, startupAppLogDiagnostics); + await flushDaemonStartupDiagnostics(logPath, startupDiagnostics); // After publication: publishDaemonInfo truncates daemon.log, so anything // written before it is lost — including reconciliation diagnostics. await reconcileDeviceClaimsForDaemonStartup( @@ -634,7 +633,6 @@ export async function startDaemonRuntime( createOwnerScopedDeviceClaimReconciler(createDaemonRecoveryPlatformScope()), baseDir, ); - await restoreLegacyXctestDeviceSetForDaemonStartup(logPath); // Arms the initial idle-reap timer: a daemon that starts and never // receives a request must still be able to reap itself. idleReap.noteActivity(); @@ -786,28 +784,6 @@ async function reconcileDeviceClaimsForDaemonStartup( ); } -/** - * Best effort: the runner never reads `XCTestDevices`, so a restore that fails here is recorded in - * daemon.log and fails neither this daemon nor a runner start. - */ -export async function restoreLegacyXctestDeviceSetForDaemonStartup(logPath: string): Promise { - await withDiagnosticsScope( - { command: 'daemon', session: 'daemon', logPath, debug: false }, - async () => { - try { - await platformDaemonLifecycleOwners.restoreLegacyXctestDeviceSetRedirect(); - } catch (error) { - emitDiagnostic({ - level: 'warn', - phase: 'ios_runner_legacy_xctest_device_set_restore_failed', - data: { error: error instanceof Error ? error.message : String(error) }, - }); - } - flushDiagnosticsToSessionFile({ force: true }); - }, - ); -} - export async function cleanupWebBrowserOrphansForDaemonStartup(params: { stateDir: string; sessionStore: SessionStore; diff --git a/src/platform-runtime-daemon-lifecycle.ts b/src/platform-runtime-daemon-lifecycle.ts index be1b22bec0..8526e96620 100644 --- a/src/platform-runtime-daemon-lifecycle.ts +++ b/src/platform-runtime-daemon-lifecycle.ts @@ -19,6 +19,7 @@ export const platformDaemonLifecycleOwners: PlatformOwnerLifecycle = Object.free configureForDaemonLock: async (input) => { await configureAppleRunnerLeaseOwnerStateDir(input.stateDir); await configureAppleRunnerDeviceClaimAuthorityProbe(input.hasDeviceClaimAuthority); + await restoreLegacyXctestDeviceSetRedirectRuntime(input.onDiagnostic); }, clearDaemonLockConfiguration: async () => { await configureAppleRunnerLeaseOwnerStateDir(undefined); @@ -32,9 +33,6 @@ export const platformDaemonLifecycleOwners: PlatformOwnerLifecycle = Object.free cleanupManagedWebOrphans: async (params) => { await cleanupManagedWebRuntimeOrphans(params); }, - restoreLegacyXctestDeviceSetRedirect: async () => { - await restoreLegacyXctestDeviceSetRedirectRuntime(); - }, resetAndroidSnapshotHelper: async () => { await resetAndroidSnapshotHelperRuntime(); }, diff --git a/src/platform-runtime-resource-cleanup.ts b/src/platform-runtime-resource-cleanup.ts index e39c21b20d..689c02af6e 100644 --- a/src/platform-runtime-resource-cleanup.ts +++ b/src/platform-runtime-resource-cleanup.ts @@ -1,7 +1,7 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { isIosFamily } from '@agent-device/kernel/device'; import type { PlatformResourceCleanup } from './daemon/platform-resource-cleanup.ts'; -import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import { emitDiagnostic, type ResourceDiagnostic } from '@agent-device/host-kit/diagnostics'; import { type OwnedProcessRecordStore } from '@agent-device/host-kit/process'; import { loadAndroidMechanics } from './platform-runtime-android-mechanics.ts'; @@ -17,11 +17,13 @@ async function stopAndroidSnapshotHelperRuntimeForDevice(device: DeviceInfo): Pr } /** Only a macOS host has an `XCTestDevices` an older agent-device could have redirected. */ -export async function restoreLegacyXctestDeviceSetRedirectRuntime(): Promise { +export async function restoreLegacyXctestDeviceSetRedirectRuntime( + onDiagnostic: (diagnostic: ResourceDiagnostic) => void, +): Promise { if (process.platform !== 'darwin') return; const { restoreLegacyXctestDeviceSetRedirect } = - await import('@agent-device/platform-apple/runner/operations'); - restoreLegacyXctestDeviceSetRedirect(); + await import('@agent-device/platform-apple/runner-owner'); + await restoreLegacyXctestDeviceSetRedirect(onDiagnostic); } export async function cleanupManagedWebRuntimeOrphans(params: { diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index d46a5df559..2946e4626a 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -204,8 +204,8 @@ agent-device devices --platform android --android-device-allowlist emulator-5554 ``` - `--ios-simulator-device-set ` constrains simulator discovery and simulator command execution via `xcrun simctl --set ...`. -- The XCTest runner's `xcodebuild` phases resolve a scoped simulator in the same set through `-DVTSimulatorSetLocation=`; `~/Library/Developer/XCTestDevices` is never redirected. If the selected Xcode no longer resolves the simulator that way, the runner start fails with `details.reason: "simulator_set_destination_not_found"`, and the error names the set and the selected Xcode version. -- On macOS, daemon startup puts back a `~/Library/Developer/XCTestDevices` that an older agent-device left redirected into a scoped set: it removes any symlink at that path, restores `XCTestDevices.agent-device-backup` when it exists, and records each step in `daemon.log`. A failed restore is recorded there too and does not stop the daemon or a runner start. +- The XCTest runner's `xcodebuild` phases resolve a scoped simulator in the same set through `-DVTSimulatorSetLocation=`; `~/Library/Developer/XCTestDevices` is never redirected. If the selected Xcode no longer resolves the simulator that way, the runner start fails with `details.reason: "simulator_set_destination_not_found"`, and the error names the set, and the selected Xcode version when this daemon has already read it. +- On macOS, daemon startup puts back a `~/Library/Developer/XCTestDevices` that an older agent-device left redirected into a scoped set, before the daemon accepts requests: it removes any symlink at that path, restores `XCTestDevices.agent-device-backup` when it exists, and records each step in `daemon.log`. A symlinked `XCTestDevices` is not supported: daemon startup removes a symlink you made yourself too, for example one that moves `XCTestDevices` to another volume. Only the link is removed, never the directory it points to, and the `daemon.log` entry names that directory. A failed restore is recorded there too and does not stop the daemon or a runner start. - `--android-device-allowlist ` constrains Android discovery/selection to comma or space separated serials. - Scope is applied before selectors (`--device`, `--udid`, `--serial`), so out-of-scope selectors fail with `DEVICE_NOT_FOUND`. - With iOS simulator-set scope enabled, iOS physical devices are not enumerated.