From c996571b872b592bcf8a031f2df4ad05f13e9fb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 3 Jun 2026 17:16:09 -0700 Subject: [PATCH 1/7] feat: cache ios runner artifacts during prepare --- .github/actions/setup-apple-replay/action.yml | 6 +- .github/workflows/ios.yml | 3 +- .github/workflows/replays-nightly.yml | 3 +- scripts/write-xcuitest-cache-metadata.mjs | 70 ++++- src/daemon/handlers/__tests__/session.test.ts | 31 ++- src/daemon/handlers/session.ts | 26 +- .../ios/__tests__/runner-client.test.ts | 53 ++++ .../__tests__/runner-command-retry.test.ts | 153 ++++++++++- .../ios/__tests__/runner-session.test.ts | 15 +- .../ios/__tests__/runner-xctestrun.test.ts | 70 ++++- src/platforms/ios/runner-client.ts | 190 ++++++++++++++ src/platforms/ios/runner-session-types.ts | 2 + src/platforms/ios/runner-session.ts | 12 +- src/platforms/ios/runner-xctestrun.ts | 240 ++++++++++++++++-- 14 files changed, 806 insertions(+), 68 deletions(-) diff --git a/.github/actions/setup-apple-replay/action.yml b/.github/actions/setup-apple-replay/action.yml index d200bc0ba..c82373032 100644 --- a/.github/actions/setup-apple-replay/action.yml +++ b/.github/actions/setup-apple-replay/action.yml @@ -27,6 +27,10 @@ inputs: description: "Optional AGENT_DEVICE_IOS_CLEAN_DERIVED value" required: false default: "" + build-on-miss: + description: "Whether this setup action should build replay artifacts on cache miss" + required: false + default: "true" outputs: agent-home-dir: @@ -68,7 +72,7 @@ runs: shell: bash - name: Build replay artifacts - if: steps.restore-prebuilt.outputs.cache-hit != 'true' + if: inputs.build-on-miss == 'true' && steps.restore-prebuilt.outputs.cache-hit != 'true' run: ${{ inputs.build-command }} shell: bash env: diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index daad3f9bc..287565782 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -47,6 +47,7 @@ jobs: xcuitest-platform: ios xcuitest-destination: generic/platform=iOS Simulator clean-derived: "1" + build-on-miss: "false" - name: Boot iOS test simulator uses: ./.github/actions/boot-ios-test-simulator @@ -57,7 +58,7 @@ jobs: - name: Prepare iOS runner run: | pnpm clean:daemon - node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout 240000 + node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout 300000 --json - name: Run iOS simulator smoke replay run: | diff --git a/.github/workflows/replays-nightly.yml b/.github/workflows/replays-nightly.yml index fe2c89f0d..663388587 100644 --- a/.github/workflows/replays-nightly.yml +++ b/.github/workflows/replays-nightly.yml @@ -70,6 +70,7 @@ jobs: xcuitest-platform: ios xcuitest-destination: generic/platform=iOS Simulator clean-derived: "1" + build-on-miss: "false" - name: Boot iOS test simulator uses: ./.github/actions/boot-ios-test-simulator @@ -80,7 +81,7 @@ jobs: - name: Prepare iOS runner run: | pnpm clean:daemon - node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout 240000 + node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout 300000 --json - name: Run iOS simulator replay suite run: node --experimental-strip-types src/bin.ts test test/integration/replays/ios/simulator --retries 2 --report-junit test/artifacts/replays-ios-simulator.junit.xml diff --git a/scripts/write-xcuitest-cache-metadata.mjs b/scripts/write-xcuitest-cache-metadata.mjs index c0bfe8c79..98e994891 100644 --- a/scripts/write-xcuitest-cache-metadata.mjs +++ b/scripts/write-xcuitest-cache-metadata.mjs @@ -131,6 +131,57 @@ function resolveBuildDestinationFamily() { return `generic/platform=${platformName}`; } +function resolveRunnerSdkName() { + const platformName = resolvePlatformName(); + if (platformName === 'macOS') return 'macosx'; + if (platformName === 'tvOS') { + return resolveDeviceKind() === 'simulator' ? 'appletvsimulator' : 'appletvos'; + } + return resolveDeviceKind() === 'simulator' ? 'iphonesimulator' : 'iphoneos'; +} + +function runAppleToolFingerprintCommand(command, args) { + try { + return execFileSync(command, args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5000, + maxBuffer: 128 * 1024, + }).trim() || 'unknown'; + } catch { + return 'unknown'; + } +} + +function parseXcodeVersionOutput(output) { + return { + version: output.match(/^Xcode\s+(.+)$/m)?.[1]?.trim() || 'unknown', + buildVersion: output.match(/^Build version\s+(.+)$/m)?.[1]?.trim() || 'unknown', + }; +} + +function resolveRunnerToolchainFingerprint() { + const xcode = parseXcodeVersionOutput( + runAppleToolFingerprintCommand('xcodebuild', ['-version']), + ); + const sdkName = resolveRunnerSdkName(); + return { + xcodeVersion: xcode.version, + xcodeBuildVersion: xcode.buildVersion, + sdkName, + sdkVersion: runAppleToolFingerprintCommand('xcrun', [ + '--sdk', + sdkName, + '--show-sdk-version', + ]), + sdkBuildVersion: runAppleToolFingerprintCommand('xcrun', [ + '--sdk', + sdkName, + '--show-sdk-build-version', + ]), + }; +} + function resolveSigningBuildSettings() { if (platform !== 'macos') { return []; @@ -149,6 +200,7 @@ const metadata = { schemaVersion: 1, packageVersion: readPackageVersion(), runnerSourceFingerprint: computeRunnerSourceFingerprint(), + ...resolveRunnerToolchainFingerprint(), platformName: resolvePlatformName(), deviceKind: resolveDeviceKind(), target: resolveTarget(), @@ -175,14 +227,16 @@ function resolveRunnerCacheArtifacts() { const productPaths = resolveExistingXctestrunProductPaths(xctestrunPath); if (!productPaths || productPaths.length === 0) return null; const xctestrunMtimeMs = readFileMtimeMs(xctestrunPath); - if (xctestrunMtimeMs === null) return null; + const xctestrunSize = readFileSize(xctestrunPath); + if (xctestrunMtimeMs === null || xctestrunSize === null) return null; const productArtifacts = []; for (const productPath of productPaths) { const mtimeMs = readFileMtimeMs(productPath); - if (mtimeMs === null) return null; - productArtifacts.push({ path: productPath, mtimeMs }); + const size = readFileSize(productPath); + if (mtimeMs === null || size === null) return null; + productArtifacts.push({ path: productPath, mtimeMs, size }); } - return { xctestrunPath, xctestrunMtimeMs, productPaths: productArtifacts }; + return { xctestrunPath, xctestrunMtimeMs, xctestrunSize, productPaths: productArtifacts }; } function findXctestrun(root) { @@ -378,6 +432,14 @@ function readFileMtimeMs(filePath) { } } +function readFileSize(filePath) { + try { + return fs.statSync(filePath).size; + } catch { + return null; + } +} + function isRecord(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index a4ed5a97f..b8b542e7b 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -17,8 +17,12 @@ vi.mock('../../../platforms/ios/runner-client.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + prepareIosRunner: vi.fn(async () => ({ + runner: { currentUptimeMs: 42 }, + connectMs: 3, + healthCheckMs: 3, + })), prewarmIosRunnerSession: vi.fn(), - runIosRunnerCommand: vi.fn(async () => ({ currentUptimeMs: 42 })), stopIosRunnerSession: vi.fn(async () => {}), }; }); @@ -96,8 +100,8 @@ import { dispatchCommand, resolveTargetDevice } from '../../../core/dispatch.ts' import { ensureDeviceReady } from '../../device-ready.ts'; import { applyRuntimeHintsToApp, clearRuntimeHintsFromApp } from '../../runtime-hints.ts'; import { + prepareIosRunner, prewarmIosRunnerSession, - runIosRunnerCommand, stopIosRunnerSession, } from '../../../platforms/ios/runner-client.ts'; import { runMacOsAlertAction } from '../../../platforms/ios/macos-helper.ts'; @@ -120,7 +124,7 @@ const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintsToApp); const mockClearRuntimeHints = vi.mocked(clearRuntimeHintsFromApp); const mockPrewarmIosRunnerSession = vi.mocked(prewarmIosRunnerSession); -const mockRunIosRunnerCommand = vi.mocked(runIosRunnerCommand); +const mockPrepareIosRunner = vi.mocked(prepareIosRunner); const mockStopIosRunner = vi.mocked(stopIosRunnerSession); const mockDismissMacOsAlert = vi.mocked(runMacOsAlertAction); const mockSettleSimulator = vi.mocked(settleIosSimulator); @@ -151,8 +155,12 @@ beforeEach(() => { mockClearRuntimeHints.mockReset(); mockClearRuntimeHints.mockResolvedValue(undefined); mockPrewarmIosRunnerSession.mockReset(); - mockRunIosRunnerCommand.mockReset(); - mockRunIosRunnerCommand.mockResolvedValue({ currentUptimeMs: 42 }); + mockPrepareIosRunner.mockReset(); + mockPrepareIosRunner.mockResolvedValue({ + runner: { currentUptimeMs: 42 }, + connectMs: 3, + healthCheckMs: 3, + }); mockStopIosRunner.mockReset(); mockStopIosRunner.mockResolvedValue(undefined); mockDismissMacOsAlert.mockReset(); @@ -2130,12 +2138,13 @@ test('prepare ios-runner starts the XCTest runner on an explicit iOS selector', expect(mockEnsureDeviceReady).toHaveBeenCalledWith( expect.objectContaining({ platform: 'ios', id: 'sim-1' }), ); - expect(mockRunIosRunnerCommand).toHaveBeenCalledTimes(1); - expect(mockRunIosRunnerCommand).toHaveBeenCalledWith( + expect(mockPrepareIosRunner).toHaveBeenCalledTimes(1); + expect(mockPrepareIosRunner).toHaveBeenCalledWith( expect.objectContaining({ platform: 'ios', id: 'sim-1' }), - { command: 'uptime' }, expect.objectContaining({ cleanStaleBundles: true, + buildTimeoutMs: 240000, + healthTimeoutMs: 90000, logPath: expect.stringMatching(/daemon\.log$/), requestId: 'prepare-request', startupTimeoutMs: 240000, @@ -2147,6 +2156,8 @@ test('prepare ios-runner starts the XCTest runner on an explicit iOS selector', deviceId: 'sim-1', deviceName: 'iPhone 17 Pro', kind: 'simulator', + connectMs: 3, + healthCheckMs: 3, runner: { currentUptimeMs: 42 }, message: 'Prepared iOS runner: iPhone 17 Pro', }); @@ -2183,7 +2194,7 @@ test('prepare ios-runner rejects non-iOS devices', async () => { expect(response.error.code).toBe('UNSUPPORTED_OPERATION'); expect(response.error.message).toBe('prepare ios-runner is only supported on iOS'); } - expect(mockRunIosRunnerCommand).not.toHaveBeenCalled(); + expect(mockPrepareIosRunner).not.toHaveBeenCalled(); }); test('prepare requires the ios-runner subcommand', async () => { @@ -2210,7 +2221,7 @@ test('prepare requires the ios-runner subcommand', async () => { expect(response.error.message).toBe('prepare requires a subcommand: ios-runner'); } expect(mockResolveTargetDevice).not.toHaveBeenCalled(); - expect(mockRunIosRunnerCommand).not.toHaveBeenCalled(); + expect(mockPrepareIosRunner).not.toHaveBeenCalled(); }); test('open web URL on iOS device session without active app falls back to Safari', async () => { diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 601f34ca8..d5ef59911 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -7,7 +7,10 @@ import { } from '../../command-catalog.ts'; import { resolvePayloadInput } from '../../utils/payload-input.ts'; import type { AndroidAdbExecutor } from '../../platforms/android/adb-executor.ts'; -import { runIosRunnerCommand } from '../../platforms/ios/runner-client.ts'; +import { + prepareIosRunner, + type PrepareIosRunnerResult, +} from '../../platforms/ios/runner-client.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import { normalizePlatformSelector } from '../../utils/device.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; @@ -42,6 +45,8 @@ const STATE_COMMANDS = DAEMON_COMMAND_GROUPS.state; const OBSERVABILITY_COMMANDS = DAEMON_COMMAND_GROUPS.observability; const REPLAY_COMMANDS = DAEMON_COMMAND_GROUPS.replay; const PREPARE_IOS_RUNNER_MIN_STARTUP_TIMEOUT_MS = 45_000; +const PREPARE_IOS_RUNNER_DEFAULT_BUILD_TIMEOUT_MS = 5 * 60_000; +const PREPARE_IOS_RUNNER_HEALTH_TIMEOUT_MS = 90_000; export const SESSION_COMMAND_HANDLERS = { ...Object.fromEntries([...INVENTORY_COMMANDS].map((command) => [command, true] as const)), @@ -90,9 +95,8 @@ async function handlePrepareCommand(params: { } const startedAtMs = Date.now(); - const result = await runIosRunnerCommand( + const result = await prepareIosRunner( device, - { command: 'uptime' }, buildPrepareIosRunnerOptions(req, session, logPath), ); const durationMs = Math.max(0, Date.now() - startedAtMs); @@ -106,7 +110,8 @@ function buildPrepareIosRunnerOptions( req: DaemonRequest, session: SessionState | undefined, logPath: string, -): Parameters[2] { +): Parameters[1] { + const buildTimeoutMs = readPrepareIosRunnerBuildTimeoutMs(req); return { verbose: req.flags?.verbose, logPath, @@ -114,6 +119,8 @@ function buildPrepareIosRunnerOptions( cleanStaleBundles: true, startupTimeoutMs: resolvePrepareIosRunnerStartupTimeoutMs(req.flags?.timeoutMs), requestId: req.meta?.requestId, + buildTimeoutMs, + healthTimeoutMs: Math.min(buildTimeoutMs, PREPARE_IOS_RUNNER_HEALTH_TIMEOUT_MS), }; } @@ -124,11 +131,18 @@ function resolvePrepareIosRunnerStartupTimeoutMs(timeoutMs: unknown): number | u return Math.max(PREPARE_IOS_RUNNER_MIN_STARTUP_TIMEOUT_MS, Math.floor(timeoutMs)); } +function readPrepareIosRunnerBuildTimeoutMs(req: DaemonRequest): number { + const value = req.flags?.timeoutMs; + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? value + : PREPARE_IOS_RUNNER_DEFAULT_BUILD_TIMEOUT_MS; +} + function prepareIosRunnerResponseData( action: string, device: DeviceInfo, durationMs: number, - runner: Awaited>, + result: PrepareIosRunnerResult, ): Record { return { action, @@ -137,7 +151,7 @@ function prepareIosRunnerResponseData( deviceName: device.name, kind: device.kind, durationMs, - runner, + ...result, message: `Prepared iOS runner: ${device.name}`, }; } diff --git a/src/platforms/ios/__tests__/runner-client.test.ts b/src/platforms/ios/__tests__/runner-client.test.ts index c26126ef6..aa29935af 100644 --- a/src/platforms/ios/__tests__/runner-client.test.ts +++ b/src/platforms/ios/__tests__/runner-client.test.ts @@ -48,7 +48,9 @@ import { } from '../runner-client.ts'; import { acquireRunnerXctestrunCacheLock, + ensureXctestrunArtifact, ensureXctestrun, + markRunnerXctestrunArtifactBadForRun, resolveExpectedRunnerCacheMetadata, resolveRunnerDerivedPath, resolveRunnerCacheMetadataPath, @@ -1132,6 +1134,57 @@ test('ensureXctestrun rebuilds cached runner when metadata package version misma assert.equal(rebuiltMetadata.artifacts?.xctestrunPath, rebuiltXctestrunPath); }); +test('ensureXctestrunArtifact stress-recovers after a bad restored artifact', async () => { + const projectRoot = repoRoot; + const tmpDir = await makeProjectTmpDir(); + const derivedPath = path.join(tmpDir, 'custom-derived'); + const productPath = path.join(derivedPath, 'Runner.app'); + const cachedXctestrunPath = path.join(derivedPath, 'cached.xctestrun'); + await fs.promises.mkdir(productPath, { recursive: true }); + writeXctestrunFixture(cachedXctestrunPath, { + projectRoot, + productRelativePaths: ['Runner.app'], + }); + writeRunnerCacheMetadataWithArtifacts({ + derivedPath, + device: macOsDevice, + xctestrunPath: cachedXctestrunPath, + productPaths: [productPath], + }); + withRunnerDerivedPathEnv(derivedPath); + + const hit = await ensureXctestrunArtifact(macOsDevice, {}); + + assert.equal(hit.xctestrunPath, cachedXctestrunPath); + assert.equal(hit.cache, 'exact'); + assert.equal(hit.artifact, 'valid'); + assert.equal(hit.buildMs, 0); + assert.equal(mockRunCmdStreaming.mock.calls.length, 0); + + await markRunnerXctestrunArtifactBadForRun(hit, 'stress health failed'); + assert.equal(fs.existsSync(cachedXctestrunPath), false); + + const rebuiltXctestrunPath = path.join(derivedPath, 'rebuilt', 'rebuilt.xctestrun'); + mockRunCmdStreaming.mockImplementationOnce(async () => { + await fs.promises.mkdir(path.join(derivedPath, 'rebuilt', 'Runner.app'), { recursive: true }); + writeXctestrunFixture(rebuiltXctestrunPath, { + projectRoot, + productRelativePaths: ['Runner.app'], + }); + }); + + const rebuilt = await ensureXctestrunArtifact(macOsDevice, { + buildTimeoutMs: 300_000, + }); + + assert.equal(rebuilt.xctestrunPath, rebuiltXctestrunPath); + assert.equal(rebuilt.cache, 'miss'); + assert.equal(rebuilt.artifact, 'rebuilt'); + assert.equal(rebuilt.reason, 'missing_xctestrun'); + assert.equal(mockRunCmdStreaming.mock.calls.length, 1); + assert.equal(mockRunCmdStreaming.mock.calls[0]?.[2]?.timeoutMs, 300_000); +}); + test('ensureXctestrun rethrows unexpected cached macOS runner repair errors', async () => { const { derivedPath, existingXctestrunPath } = await makeCachedRunnerXctestrun(); diff --git a/src/platforms/ios/__tests__/runner-command-retry.test.ts b/src/platforms/ios/__tests__/runner-command-retry.test.ts index 6d0c08874..26d3a8f58 100644 --- a/src/platforms/ios/__tests__/runner-command-retry.test.ts +++ b/src/platforms/ios/__tests__/runner-command-retry.test.ts @@ -9,12 +9,14 @@ const { mockExecuteRunnerCommandWithSession, mockEmitDiagnostic, mockInvalidateRunnerSession, + mockMarkRunnerXctestrunArtifactBadForRun, mockStopRunnerSession, } = vi.hoisted(() => ({ mockEnsureRunnerSession: vi.fn(), mockExecuteRunnerCommandWithSession: vi.fn(), mockEmitDiagnostic: vi.fn(), mockInvalidateRunnerSession: vi.fn(), + mockMarkRunnerXctestrunArtifactBadForRun: vi.fn(), mockStopRunnerSession: vi.fn(), })); @@ -40,10 +42,145 @@ vi.mock('../runner-session.ts', async () => { }; }); -import { runIosRunnerCommand } from '../runner-client.ts'; +vi.mock('../runner-xctestrun.ts', async () => { + const actual = + await vi.importActual('../runner-xctestrun.ts'); + return { + ...actual, + markRunnerXctestrunArtifactBadForRun: mockMarkRunnerXctestrunArtifactBadForRun, + }; +}); + +import { prepareIosRunner, runIosRunnerCommand } from '../runner-client.ts'; +import type { RunnerXctestrunArtifact } from '../runner-xctestrun.ts'; beforeEach(() => { vi.resetAllMocks(); + mockMarkRunnerXctestrunArtifactBadForRun.mockResolvedValue(undefined); +}); + +test('prepareIosRunner marks a bad restored artifact and rebuilds once after health failure', async () => { + const restoredArtifact = makeRunnerArtifact({ + xctestrunPath: '/tmp/restored.xctestrun', + cache: 'exact', + artifact: 'valid', + }); + const rebuiltArtifact = makeRunnerArtifact({ + xctestrunPath: '/tmp/rebuilt.xctestrun', + cache: 'miss', + artifact: 'rebuilt', + buildMs: 123, + }); + const restoredSession = makeRunnerSession({ + port: 8100, + xctestrunPath: restoredArtifact.xctestrunPath, + xctestrunArtifact: restoredArtifact, + }); + const rebuiltSession = makeRunnerSession({ + port: 8101, + xctestrunPath: rebuiltArtifact.xctestrunPath, + xctestrunArtifact: rebuiltArtifact, + }); + + mockEnsureRunnerSession + .mockResolvedValueOnce(restoredSession) + .mockResolvedValueOnce(rebuiltSession); + mockExecuteRunnerCommandWithSession + .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection')) + .mockResolvedValueOnce({ uptimeMs: 42 }); + + const result = await prepareIosRunner(IOS_SIMULATOR, { + healthTimeoutMs: 90_000, + buildTimeoutMs: 300_000, + }); + + assert.deepEqual(result, { + runner: { uptimeMs: 42 }, + cache: 'miss', + artifact: 'rebuilt', + buildMs: 123, + connectMs: result.connectMs, + healthCheckMs: result.healthCheckMs, + xctestrunPath: '/tmp/rebuilt.xctestrun', + failureReason: 'Runner did not accept connection', + }); + assert.equal(result.connectMs >= 0, true); + assert.equal(result.healthCheckMs >= 0, true); + assert.deepEqual(mockInvalidateRunnerSession.mock.calls[0], [ + restoredSession, + 'prepare_cached_runner_health_failed', + ]); + assert.deepEqual(mockMarkRunnerXctestrunArtifactBadForRun.mock.calls[0], [ + restoredArtifact, + 'Runner did not accept connection', + ]); + assert.deepEqual(mockEnsureRunnerSession.mock.calls[1]?.[1], { + healthTimeoutMs: 90_000, + buildTimeoutMs: 300_000, + cleanStaleBundles: true, + forceRunnerXctestrunRebuild: true, + }); + assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2); + assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[0]?.[2].command, 'uptime'); + assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[0]?.[4], 90_000); + assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[1]?.[1], rebuiltSession); + assert.ok( + mockEmitDiagnostic.mock.calls.some( + ([event]) => event.phase === 'ios_runner_prepare_bad_cache_recovered', + ), + ); +}); + +test('prepareIosRunner invalidates rebuilt sessions when bad-cache recovery health fails', async () => { + const restoredArtifact = makeRunnerArtifact({ + xctestrunPath: '/tmp/restored.xctestrun', + cache: 'restore-key', + artifact: 'valid', + }); + const rebuiltArtifact = makeRunnerArtifact({ + xctestrunPath: '/tmp/rebuilt.xctestrun', + cache: 'miss', + artifact: 'rebuilt', + }); + const restoredSession = makeRunnerSession({ + port: 8100, + xctestrunPath: restoredArtifact.xctestrunPath, + xctestrunArtifact: restoredArtifact, + }); + const rebuiltSession = makeRunnerSession({ + port: 8101, + xctestrunPath: rebuiltArtifact.xctestrunPath, + xctestrunArtifact: rebuiltArtifact, + }); + + mockEnsureRunnerSession + .mockResolvedValueOnce(restoredSession) + .mockResolvedValueOnce(rebuiltSession); + mockExecuteRunnerCommandWithSession + .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner endpoint probe failed')) + .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner health timed out')); + + await assert.rejects( + () => prepareIosRunner(IOS_SIMULATOR, { healthTimeoutMs: 90_000 }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.message, 'artifact restored but runner did not connect'); + assert.equal(error.details?.restoredFailureReason, 'Runner endpoint probe failed'); + assert.equal(error.details?.xctestrunPath, '/tmp/rebuilt.xctestrun'); + assert.equal(error.details?.artifact, 'rebuilt'); + assert.equal(error.details?.cache, 'miss'); + return true; + }, + ); + + assert.deepEqual(mockInvalidateRunnerSession.mock.calls, [ + [restoredSession, 'prepare_cached_runner_health_failed'], + [rebuiltSession, 'prepare_rebuilt_runner_health_failed'], + ]); + assert.deepEqual(mockMarkRunnerXctestrunArtifactBadForRun.mock.calls[0], [ + restoredArtifact, + 'Runner endpoint probe failed', + ]); }); test('mutating commands restart stale ready sessions when the preflight probe never reaches the runner', async () => { @@ -604,6 +741,20 @@ function makeRunnerSession(overrides: Partial = {}): RunnerSessio } as RunnerSession; } +function makeRunnerArtifact( + overrides: Partial = {}, +): RunnerXctestrunArtifact { + return { + xctestrunPath: '/tmp/runner.xctestrun', + derived: '/tmp/derived', + cache: 'exact', + artifact: 'valid', + buildMs: 0, + xctestrunPathSource: 'manifest', + ...overrides, + }; +} + async function captureDiagnostics(callback: () => Promise): Promise { await callback(); return JSON.stringify(mockEmitDiagnostic.mock.calls.map(([event]) => event)); diff --git a/src/platforms/ios/__tests__/runner-session.test.ts b/src/platforms/ios/__tests__/runner-session.test.ts index d2845fa19..0b0068464 100644 --- a/src/platforms/ios/__tests__/runner-session.test.ts +++ b/src/platforms/ios/__tests__/runner-session.test.ts @@ -12,7 +12,7 @@ import type { RunnerSession } from '../runner-session-types.ts'; const { mockAcquireXcodebuildSimulatorSetRedirect, mockCleanupTempFile, - mockEnsureXctestrun, + mockEnsureXctestrunArtifact, mockGetFreePort, mockIsProcessAlive, mockIsProcessGroupAlive, @@ -26,7 +26,7 @@ const { } = vi.hoisted(() => ({ mockAcquireXcodebuildSimulatorSetRedirect: vi.fn(), mockCleanupTempFile: vi.fn(), - mockEnsureXctestrun: vi.fn(), + mockEnsureXctestrunArtifact: vi.fn(), mockGetFreePort: vi.fn(), mockIsProcessAlive: vi.fn(), mockIsProcessGroupAlive: vi.fn(), @@ -86,7 +86,7 @@ vi.mock('../runner-xctestrun.ts', async () => { return { ...actual, acquireXcodebuildSimulatorSetRedirect: mockAcquireXcodebuildSimulatorSetRedirect, - ensureXctestrun: mockEnsureXctestrun, + ensureXctestrunArtifact: mockEnsureXctestrunArtifact, prepareXctestrunWithEnv: mockPrepareXctestrunWithEnv, }; }); @@ -103,7 +103,14 @@ import { beforeEach(() => { vi.resetAllMocks(); mockRunXcrun.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); - mockEnsureXctestrun.mockResolvedValue('/tmp/base-runner.xctestrun'); + mockEnsureXctestrunArtifact.mockResolvedValue({ + xctestrunPath: '/tmp/base-runner.xctestrun', + derived: '/tmp/derived', + cache: 'miss', + artifact: 'rebuilt', + buildMs: 12, + xctestrunPathSource: 'build', + }); mockGetFreePort.mockResolvedValue(8123); mockPrepareXctestrunWithEnv.mockResolvedValue({ xctestrunPath: '/tmp/session-runner.xctestrun', diff --git a/src/platforms/ios/__tests__/runner-xctestrun.test.ts b/src/platforms/ios/__tests__/runner-xctestrun.test.ts index a5481db96..33912c23d 100644 --- a/src/platforms/ios/__tests__/runner-xctestrun.test.ts +++ b/src/platforms/ios/__tests__/runner-xctestrun.test.ts @@ -8,6 +8,7 @@ import path from 'node:path'; import type { DeviceInfo } from '../../../utils/device.ts'; import { withCommandExecutorOverride } from '../../../utils/exec.ts'; import { + __resetRunnerToolchainFingerprintCacheForTests, acquireXcodebuildSimulatorSetRedirect, findXctestrun, prepareXctestrunWithEnv, @@ -198,23 +199,70 @@ test('scoreXctestrunCandidate penalizes macos and env xctestrun files for simula test('setup metadata script matches expected iOS simulator cache metadata', async () => { await withTempDir('runner-cache-metadata-', async (root) => { - execFileSync( - process.execPath, - ['scripts/write-xcuitest-cache-metadata.mjs', 'ios', root, 'generic/platform=iOS Simulator'], - { cwd: process.cwd(), stdio: ['ignore', 'ignore', 'inherit'] }, + const binDir = path.join(root, 'bin'); + fs.mkdirSync(binDir); + writeExecutable( + path.join(binDir, 'xcodebuild'), + ['#!/bin/sh', 'printf "Xcode 26.2\\nBuild version 17C52\\n"'].join('\n'), ); - - const actual = JSON.parse( - fs.readFileSync(path.join(root, '.agent-device-runner-cache.json'), 'utf8'), + writeExecutable( + path.join(binDir, 'xcrun'), + [ + '#!/bin/sh', + 'case "$*" in', + ' *"--show-sdk-version"*) printf "26.2\\n" ;;', + ' *"--show-sdk-build-version"*) printf "23C53\\n" ;;', + ' *) exit 1 ;;', + 'esac', + ].join('\n'), ); - const { artifacts: _actualArtifacts, ...actualComparable } = actual; - const { artifacts: _expectedArtifacts, ...expectedComparable } = - resolveExpectedRunnerCacheMetadata(iosSimulator); + const previousPath = process.env.PATH; + process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ''}`; + __resetRunnerToolchainFingerprintCacheForTests(); + + try { + execFileSync( + process.execPath, + [ + 'scripts/write-xcuitest-cache-metadata.mjs', + 'ios', + root, + 'generic/platform=iOS Simulator', + ], + { + cwd: process.cwd(), + env: { ...process.env, PATH: process.env.PATH }, + stdio: ['ignore', 'ignore', 'inherit'], + }, + ); - assert.deepEqual(actualComparable, expectedComparable); + const actual = JSON.parse( + fs.readFileSync(path.join(root, '.agent-device-runner-cache.json'), 'utf8'), + ); + const { artifacts: _actualArtifacts, ...actualComparable } = actual; + const { artifacts: _expectedArtifacts, ...expectedComparable } = + resolveExpectedRunnerCacheMetadata(iosSimulator); + + assert.deepEqual(actualComparable, expectedComparable); + } finally { + __resetRunnerToolchainFingerprintCacheForTests(); + restoreEnvVar('PATH', previousPath); + } }); }); +function writeExecutable(filePath: string, contents: string): void { + fs.writeFileSync(filePath, `${contents}\n`, { mode: 0o755 }); +} + +function restoreEnvVar(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + return; + } + process.env[name] = value; +} + test('prepareXctestrunWithEnv avoids XCTest screen recordings for nested and legacy targets', async () => { await withTempDir('runner-xctestrun-policy-', async (root) => { const xctestrunPath = path.join(root, 'AgentDeviceRunner.xctestrun'); diff --git a/src/platforms/ios/runner-client.ts b/src/platforms/ios/runner-client.ts index 59619d827..8da5de04c 100644 --- a/src/platforms/ios/runner-client.ts +++ b/src/platforms/ios/runner-client.ts @@ -28,6 +28,10 @@ import { resolveAppleRunnerProvider, type AppleRunnerCommandOptions, } from './runner-provider.ts'; +import { + markRunnerXctestrunArtifactBadForRun, + type RunnerXctestrunArtifact, +} from './runner-xctestrun.ts'; export { isRetryableRunnerError, resolveRunnerEarlyExitHint, @@ -59,6 +63,21 @@ type RunnerReadinessPreflightRecoveryDetails = { readinessPreflightSkippedAgeMs?: number; }; +export type PrepareIosRunnerOptions = RunnerSessionOptions & { + healthTimeoutMs: number; +}; + +export type PrepareIosRunnerResult = { + runner: Record; + cache?: RunnerXctestrunArtifact['cache']; + artifact?: RunnerXctestrunArtifact['artifact']; + buildMs?: number; + connectMs: number; + healthCheckMs: number; + xctestrunPath?: string; + failureReason?: string; +}; + const RUNNER_STATUS_RECOVERY_TIMEOUT_MS = 3_000; // --- Runner command execution --- @@ -125,6 +144,79 @@ export function prewarmIosRunnerSession( return prewarm; } +export async function prepareIosRunner( + device: DeviceInfo, + options: PrepareIosRunnerOptions, +): Promise { + validateRunnerDevice(device); + assertRunnerRequestActive(options.requestId); + const signal = getRequestSignal(options.requestId); + const command = withRunnerCommandId({ command: 'uptime' }); + if (hasScopedAppleRunnerProvider(device, { requestId: options.requestId })) { + const provider = resolveAppleRunnerProvider( + device, + createLocalAppleRunnerProvider(executeRunnerCommand), + undefined, + { requestId: options.requestId }, + ); + const healthStartedAt = Date.now(); + const runner = await provider.runCommand(device, command, options); + return { + runner, + connectMs: 0, + healthCheckMs: Math.max(0, Date.now() - healthStartedAt), + }; + } + let session: RunnerSession | undefined; + try { + const connectStartedAt = Date.now(); + session = await ensureRunnerSession(device, options); + const connectMs = Date.now() - connectStartedAt; + return await runPrepareHealthCheck(device, session, command, options, signal, connectMs); + } catch (err) { + const appErr = err instanceof AppError ? err : new AppError('COMMAND_FAILED', String(err)); + if (!session || !shouldRecoverBadCachedRunnerArtifact(appErr, session)) { + throw err; + } + const reason = appErr.message || 'runner_health_failed'; + await invalidateRunnerSession(session, 'prepare_cached_runner_health_failed'); + await markRunnerXctestrunArtifactBadForRun(session.xctestrunArtifact, reason); + const connectStartedAt = Date.now(); + const rebuiltSession = await ensureRunnerSession(device, { + ...options, + cleanStaleBundles: true, + forceRunnerXctestrunRebuild: true, + }); + const connectMs = Date.now() - connectStartedAt; + try { + const recovered = await runPrepareHealthCheck( + device, + rebuiltSession, + command, + options, + signal, + connectMs, + reason, + ); + emitDiagnostic({ + level: 'info', + phase: 'ios_runner_prepare_bad_cache_recovered', + data: { + command: command.command, + commandId: command.commandId, + sessionId: rebuiltSession.sessionId, + xctestrunPath: rebuiltSession.xctestrunArtifact?.xctestrunPath, + reason, + }, + }); + return recovered; + } catch (retryErr) { + await invalidateRunnerSession(rebuiltSession, 'prepare_rebuilt_runner_health_failed'); + throw wrapPrepareHealthFailure(retryErr, rebuiltSession, reason); + } + } +} + // fallow-ignore-next-line complexity async function executeRunnerCommand( device: DeviceInfo, @@ -251,6 +343,104 @@ async function executeRunnerCommand( } } +async function runPrepareHealthCheck( + device: DeviceInfo, + session: RunnerSession, + command: RunnerCommand, + options: PrepareIosRunnerOptions, + signal: AbortSignal | undefined, + connectMs: number, + failureReason?: string, +): Promise { + const healthStartedAt = Date.now(); + const runner = await executeRunnerCommandWithSession( + device, + session, + command, + options.logPath, + options.healthTimeoutMs, + signal, + ); + return buildPrepareIosRunnerResult( + runner, + session, + connectMs, + Date.now() - healthStartedAt, + failureReason, + ); +} + +function shouldRecoverBadCachedRunnerArtifact( + error: AppError, + session: RunnerSession, +): session is RunnerSession & { + xctestrunArtifact: NonNullable; +} { + const artifact = session.xctestrunArtifact; + if (!artifact || artifact.cache === 'miss') return false; + return ( + isRetryableRunnerError(error) || + shouldRetryRunnerConnectError(error) || + isPrepareHealthTimeout(error) + ); +} + +function isPrepareHealthTimeout(error: AppError): boolean { + const message = error.message.toLowerCase(); + return ( + message.includes('timeout') || message.includes('timed out') || message.includes('deadline') + ); +} + +function wrapPrepareHealthFailure( + error: unknown, + session: RunnerSession, + restoredFailureReason: string, +): AppError { + const appErr = error instanceof AppError ? error : new AppError('COMMAND_FAILED', String(error)); + return new AppError( + appErr.code, + 'artifact restored but runner did not connect', + { + ...(appErr.details ?? {}), + restoredFailureReason, + xctestrunPath: session.xctestrunArtifact?.xctestrunPath, + artifact: session.xctestrunArtifact?.artifact, + cache: session.xctestrunArtifact?.cache, + reason: appErr.message, + }, + appErr, + ); +} + +function buildPrepareIosRunnerResult( + runner: Record, + session: RunnerSession, + connectMs: number, + healthCheckMs: number, + failureReason: string | undefined, +): PrepareIosRunnerResult { + const artifact = session.xctestrunArtifact; + if (!artifact) { + return { + runner, + connectMs: Math.max(0, connectMs), + healthCheckMs: Math.max(0, healthCheckMs), + ...(failureReason ? { failureReason } : {}), + }; + } + return { + runner, + cache: artifact.cache, + artifact: artifact.artifact, + buildMs: artifact.buildMs, + connectMs: Math.max(0, connectMs), + healthCheckMs: Math.max(0, healthCheckMs), + xctestrunPath: artifact.xctestrunPath, + ...(failureReason ? { failureReason } : {}), + }; +} + async function handleRunnerTransportErrorAfterCommandSend( device: DeviceInfo, session: RunnerSession, diff --git a/src/platforms/ios/runner-session-types.ts b/src/platforms/ios/runner-session-types.ts index 073e3c070..4a268455b 100644 --- a/src/platforms/ios/runner-session-types.ts +++ b/src/platforms/ios/runner-session-types.ts @@ -1,5 +1,6 @@ import type { ExecResult, ExecBackgroundResult } from '../../utils/exec.ts'; import type { DeviceInfo } from '../../utils/device.ts'; +import type { RunnerXctestrunArtifact } from './runner-xctestrun.ts'; export type RunnerSession = { sessionId: string; @@ -7,6 +8,7 @@ export type RunnerSession = { deviceId: string; port: number; xctestrunPath: string; + xctestrunArtifact?: RunnerXctestrunArtifact; jsonPath: string; testPromise: Promise; child: ExecBackgroundResult['child']; diff --git a/src/platforms/ios/runner-session.ts b/src/platforms/ios/runner-session.ts index cd232f7cd..0ceaa954c 100644 --- a/src/platforms/ios/runner-session.ts +++ b/src/platforms/ios/runner-session.ts @@ -18,7 +18,7 @@ import { } from './runner-transport.ts'; import { acquireXcodebuildSimulatorSetRedirect, - ensureXctestrun, + ensureXctestrunArtifact, IOS_RUNNER_CONTAINER_BUNDLE_IDS, prepareXctestrunWithEnv, resolveRunnerDestination, @@ -41,6 +41,8 @@ export type RunnerSessionOptions = { cleanStaleBundles?: boolean; startupTimeoutMs?: number; requestId?: string; + buildTimeoutMs?: number; + forceRunnerXctestrunRebuild?: boolean; }; const runnerSessions = new Map(); @@ -111,11 +113,12 @@ export async function ensureRunnerSession( phase: 'ios_runner_startup_cleanup_stale_bundles_skipped', }); } - const xctestrun = await measureRunnerStartupStep( + const xctestrunArtifact = await measureRunnerStartupStep( startupTimings, 'ensure_xctestrun', - async () => await ensureXctestrun(device, options), + async () => await ensureXctestrunArtifact(device, options), ); + startupTimings.build_xctestrun = xctestrunArtifact.buildMs; const port = await measureRunnerStartupStep( startupTimings, 'allocate_port', @@ -126,7 +129,7 @@ export async function ensureRunnerSession( 'prepare_xctestrun_env', async () => await prepareXctestrunWithEnv( - xctestrun, + xctestrunArtifact.xctestrunPath, { AGENT_DEVICE_RUNNER_PORT: String(port) }, `session-${device.id}-${port}`, ), @@ -188,6 +191,7 @@ export async function ensureRunnerSession( deviceId: device.id, port, xctestrunPath, + xctestrunArtifact, jsonPath, testPromise, child, diff --git a/src/platforms/ios/runner-xctestrun.ts b/src/platforms/ios/runner-xctestrun.ts index 9e40a7572..755793371 100644 --- a/src/platforms/ios/runner-xctestrun.ts +++ b/src/platforms/ios/runner-xctestrun.ts @@ -4,7 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import { AppError } from '../../utils/errors.ts'; import { sleep } from '../../utils/timeouts.ts'; -import { runCmdStreaming, type ExecBackgroundResult } from '../../utils/exec.ts'; +import { runCmdStreaming, runCmdSync, type ExecBackgroundResult } from '../../utils/exec.ts'; import { resolveIosSimulatorDeviceSetPath } from '../../utils/device-isolation.ts'; import { isProcessAlive, readProcessStartTime } from '../../utils/process-identity.ts'; import { isEnvTruthy } from '../../utils/retry.ts'; @@ -41,6 +41,8 @@ const RUNNER_XCTESTRUN_CAPTURE_OPTIONS = { } as const; const runnerXctestrunBuildLocks = new Map>(); +const badRunnerArtifactsForRun = new Set(); +const appleToolFingerprintCache = new Map(); export const runnerPrepProcesses = new Set(); type EnvMap = Record; @@ -87,6 +89,11 @@ export type RunnerXctestrunCacheMetadata = { schemaVersion: number; packageVersion: string; runnerSourceFingerprint: string; + xcodeVersion: string; + xcodeBuildVersion: string; + sdkName: string; + sdkVersion: string; + sdkBuildVersion: string; platformName: string; deviceKind: DeviceInfo['kind']; target: NonNullable; @@ -97,6 +104,16 @@ export type RunnerXctestrunCacheMetadata = { artifacts?: RunnerXctestrunCacheArtifacts; }; +export type RunnerXctestrunArtifact = { + xctestrunPath: string; + derived: string; + cache: 'exact' | 'restore-key' | 'miss'; + artifact: 'valid' | 'rebuilt'; + buildMs: number; + xctestrunPathSource: 'manifest' | 'scan' | 'build'; + reason?: string; +}; + type RunnerXctestrunCacheArtifacts = { xctestrunPath: string; xctestrunMtimeMs: number; @@ -500,8 +517,21 @@ function isLiveXcodebuildSimulatorSetLockOwner(owner: XcodebuildSimulatorSetLock export async function ensureXctestrun( device: DeviceInfo, - options: { verbose?: boolean; logPath?: string; traceLogPath?: string }, + options: { verbose?: boolean; logPath?: string; traceLogPath?: string; buildTimeoutMs?: number }, ): Promise { + return (await ensureXctestrunArtifact(device, options)).xctestrunPath; +} + +export async function ensureXctestrunArtifact( + device: DeviceInfo, + options: { + verbose?: boolean; + logPath?: string; + traceLogPath?: string; + buildTimeoutMs?: number; + forceRunnerXctestrunRebuild?: boolean; + }, +): Promise { const projectRoot = findProjectRoot(); const expectedCacheMetadata = resolveExpectedRunnerCacheMetadata(device, projectRoot); const derived = resolveRunnerDerivedPath(device, expectedCacheMetadata); @@ -514,6 +544,7 @@ export async function ensureXctestrun( projectRoot, expectedCacheMetadata, derived, + forceRebuild: options.forceRunnerXctestrunRebuild === true, }); } finally { await releaseCacheLock(); @@ -523,17 +554,14 @@ export async function ensureXctestrun( async function ensureXctestrunUnderCacheLock(params: { device: DeviceInfo; - options: { verbose?: boolean; logPath?: string; traceLogPath?: string }; + options: { verbose?: boolean; logPath?: string; traceLogPath?: string; buildTimeoutMs?: number }; projectRoot: string; expectedCacheMetadata: RunnerXctestrunCacheMetadata; derived: string; -}): Promise { + forceRebuild: boolean; +}): Promise { const { device, options, projectRoot, expectedCacheMetadata, derived } = params; - if (shouldCleanDerived()) { - emitRunnerXctestrunDecision('clean', 'forced_clean', { derived }); - assertSafeDerivedCleanup(derived); - cleanRunnerDerivedArtifacts(derived); - } + cleanRunnerDerivedBeforeEvaluation(derived, params.forceRebuild); const existing = await evaluateExistingXctestrun({ derived, projectRoot, @@ -542,25 +570,85 @@ async function ensureXctestrunUnderCacheLock(params: { xctestrunReferencesProjectRoot, resolveExistingXctestrunProductPaths, }); + const cache = + existing.reason === 'reuse_ready' ? 'exact' : existing.xctestrunPath ? 'restore-key' : 'miss'; if (existing.reason !== 'reuse_ready') { emitRunnerXctestrunDecision('rebuild', existing.reason, { derived, xctestrunPath: existing.xctestrunPath, }); } - if (existing.reason === 'reuse_ready') { - const reusableXctestrun = await tryReuseExistingXctestrun( - device, - derived, - expectedCacheMetadata, - existing, - ); - if (reusableXctestrun) return reusableXctestrun; - } + const reusable = await resolveReusableXctestrunArtifact({ + device, + derived, + expectedCacheMetadata, + existing, + cache, + }); + if (reusable) return reusable; if (existing.xctestrunPath) { assertSafeDerivedCleanup(derived); cleanRunnerDerivedArtifacts(derived); } + return await buildXctestrunArtifact({ + device, + options, + projectRoot, + expectedCacheMetadata, + derived, + cache, + reason: existing.reason, + }); +} + +function cleanRunnerDerivedBeforeEvaluation(derived: string, forceRebuild: boolean): void { + if (!shouldCleanDerived() && !forceRebuild && !badRunnerArtifactsForRun.has(derived)) { + return; + } + emitRunnerXctestrunDecision('clean', forceRebuild ? 'forced_rebuild' : 'forced_clean', { + derived, + }); + assertSafeDerivedCleanup(derived); + cleanRunnerDerivedArtifacts(derived); + badRunnerArtifactsForRun.delete(derived); +} + +async function resolveReusableXctestrunArtifact(params: { + device: DeviceInfo; + derived: string; + expectedCacheMetadata: RunnerXctestrunCacheMetadata; + existing: ExistingXctestrunState; + cache: RunnerXctestrunArtifact['cache']; +}): Promise { + const { device, derived, expectedCacheMetadata, existing, cache } = params; + if (existing.reason !== 'reuse_ready') return null; + const reusableXctestrun = await tryReuseExistingXctestrun( + device, + derived, + expectedCacheMetadata, + existing, + ); + if (!reusableXctestrun) return null; + return { + xctestrunPath: reusableXctestrun, + derived, + cache, + artifact: 'valid', + buildMs: 0, + xctestrunPathSource: existing.source, + }; +} + +async function buildXctestrunArtifact(params: { + device: DeviceInfo; + options: { verbose?: boolean; logPath?: string; traceLogPath?: string; buildTimeoutMs?: number }; + projectRoot: string; + expectedCacheMetadata: RunnerXctestrunCacheMetadata; + derived: string; + cache: RunnerXctestrunArtifact['cache']; + reason: ExistingXctestrunState['reason']; +}): Promise { + const { device, options, projectRoot, expectedCacheMetadata, derived, cache, reason } = params; const projectPath = path.join( projectRoot, 'ios-runner', @@ -572,7 +660,9 @@ async function ensureXctestrunUnderCacheLock(params: { throw new AppError('COMMAND_FAILED', 'iOS runner project not found', { projectPath }); } + const buildStartedAt = Date.now(); await buildRunnerXctestrun(device, projectPath, derived, options); + const buildMs = Math.max(0, Date.now() - buildStartedAt); const built = findXctestrun(derived, device); if (!built) { @@ -593,7 +683,15 @@ async function ensureXctestrunUnderCacheLock(params: { derived, xctestrunPath: built, }); - return built; + return { + xctestrunPath: built, + derived, + cache, + artifact: 'rebuilt', + buildMs, + xctestrunPathSource: 'build', + reason, + }; } async function tryReuseExistingXctestrun( @@ -666,6 +764,10 @@ const RUNNER_ROOT_TRANSIENT_ENTRY_NAMES = new Set([ 'info.plist', ]); +export function __resetRunnerToolchainFingerprintCacheForTests(): void { + appleToolFingerprintCache.clear(); +} + export function shouldDeleteRunnerDerivedRootEntry(entryName: string): boolean { return RUNNER_ROOT_TRANSIENT_ENTRY_NAMES.has(entryName); } @@ -678,11 +780,13 @@ export function resolveExpectedRunnerCacheMetadata( device: DeviceInfo, projectRoot: string = findProjectRoot(), ): RunnerXctestrunCacheMetadata { + const platformName = resolveRunnerPlatformName(device); return { schemaVersion: RUNNER_CACHE_SCHEMA_VERSION, packageVersion: readVersion(), runnerSourceFingerprint: computeRunnerSourceFingerprint(projectRoot), - platformName: resolveRunnerPlatformName(device), + ...resolveRunnerToolchainFingerprint(platformName, device.kind), + platformName, deviceKind: device.kind, target: device.target ?? 'mobile', buildDestinationFamily: resolveRunnerBuildDestinationFamily(device), @@ -696,6 +800,67 @@ export function resolveExpectedRunnerCacheMetadata( }; } +function resolveRunnerToolchainFingerprint( + platformName: 'iOS' | 'tvOS' | 'macOS', + deviceKind: DeviceInfo['kind'], +): { + xcodeVersion: string; + xcodeBuildVersion: string; + sdkName: string; + sdkVersion: string; + sdkBuildVersion: string; +} { + const xcode = parseXcodeVersionOutput(runAppleToolFingerprintCommand('xcodebuild', ['-version'])); + const sdkName = resolveRunnerSdkName(platformName, deviceKind); + return { + xcodeVersion: xcode.version, + xcodeBuildVersion: xcode.buildVersion, + sdkName, + sdkVersion: runAppleToolFingerprintCommand('xcrun', ['--sdk', sdkName, '--show-sdk-version']), + sdkBuildVersion: runAppleToolFingerprintCommand('xcrun', [ + '--sdk', + sdkName, + '--show-sdk-build-version', + ]), + }; +} + +function resolveRunnerSdkName( + platformName: 'iOS' | 'tvOS' | 'macOS', + deviceKind: DeviceInfo['kind'], +): string { + if (platformName === 'macOS') return 'macosx'; + if (platformName === 'tvOS') { + return deviceKind === 'simulator' ? 'appletvsimulator' : 'appletvos'; + } + return deviceKind === 'simulator' ? 'iphonesimulator' : 'iphoneos'; +} + +function runAppleToolFingerprintCommand(cmd: string, args: string[]): string { + const cacheKey = JSON.stringify([cmd, args]); + const cached = appleToolFingerprintCache.get(cacheKey); + if (cached !== undefined) return cached; + try { + const result = runCmdSync(cmd, args, { + allowFailure: true, + timeoutMs: 5_000, + maxBuffer: 128 * 1024, + }); + const value = result.exitCode === 0 ? result.stdout.trim() || 'unknown' : 'unknown'; + appleToolFingerprintCache.set(cacheKey, value); + return value; + } catch { + appleToolFingerprintCache.set(cacheKey, 'unknown'); + return 'unknown'; + } +} + +function parseXcodeVersionOutput(output: string): { version: string; buildVersion: string } { + const version = output.match(/^Xcode\s+(.+)$/m)?.[1]?.trim() || 'unknown'; + const buildVersion = output.match(/^Build version\s+(.+)$/m)?.[1]?.trim() || 'unknown'; + return { version, buildVersion }; +} + export function writeRunnerCacheMetadata( derived: string, metadata: RunnerXctestrunCacheMetadata, @@ -707,6 +872,25 @@ export function writeRunnerCacheMetadata( ); } +export async function markRunnerXctestrunArtifactBadForRun( + artifact: Pick, + reason: string, +): Promise { + badRunnerArtifactsForRun.add(artifact.derived); + const releaseCacheLock = await acquireRunnerXctestrunCacheLock(artifact.derived); + try { + emitRunnerXctestrunDecision('clean', 'bad_artifact', { + derived: artifact.derived, + xctestrunPath: artifact.xctestrunPath, + reason, + }); + assertSafeDerivedCleanup(artifact.derived); + cleanRunnerDerivedArtifacts(artifact.derived); + } finally { + await releaseCacheLock(); + } +} + function readRunnerCacheMetadata(derived: string): RunnerXctestrunCacheMetadata | null { try { const raw: unknown = JSON.parse( @@ -1224,7 +1408,7 @@ async function buildRunnerXctestrun( device: DeviceInfo, projectPath: string, derived: string, - options: { verbose?: boolean; logPath?: string; traceLogPath?: string }, + options: { verbose?: boolean; logPath?: string; traceLogPath?: string; buildTimeoutMs?: number }, ): Promise { const runnerBundleBuildSettings = resolveRunnerBundleBuildSettings(process.env); const signingBuildSettings = resolveRunnerSigningBuildSettings( @@ -1259,6 +1443,7 @@ async function buildRunnerXctestrun( ], { detached: true, + timeoutMs: options.buildTimeoutMs, onSpawn: (child) => { runnerPrepProcesses.add(child); child.on('close', () => { @@ -1459,6 +1644,7 @@ type ExistingXctestrunState = reason: 'reuse_ready'; xctestrunPath: string; productPaths: string[]; + source: 'manifest' | 'scan'; } | { reason: @@ -1468,6 +1654,7 @@ type ExistingXctestrunState = | 'cache_metadata_mismatch'; xctestrunPath: string; productPaths: string[]; + source: 'manifest' | 'scan'; }; // fallow-ignore-next-line complexity @@ -1488,22 +1675,23 @@ async function evaluateExistingXctestrun(options: { return { reason: 'missing_xctestrun', xctestrunPath: null }; } const hasValidatedManifest = manifest?.xctestrunPath === xctestrunPath; + const source = hasValidatedManifest ? 'manifest' : 'scan'; const productPaths = hasValidatedManifest ? manifest.productPaths : await options.resolveExistingXctestrunProductPaths(xctestrunPath); if (!productPaths) { - return { reason: 'missing_products', xctestrunPath, productPaths: [] }; + return { reason: 'missing_products', xctestrunPath, productPaths: [], source }; } if ( !options.xctestrunReferencesProjectRoot(xctestrunPath, options.projectRoot) && !hasValidatedManifest ) { - return { reason: 'project_root_mismatch', xctestrunPath, productPaths }; + return { reason: 'project_root_mismatch', xctestrunPath, productPaths, source }; } if (!cacheMetadata.ok) { - return { reason: cacheMetadata.reason, xctestrunPath, productPaths }; + return { reason: cacheMetadata.reason, xctestrunPath, productPaths, source }; } - return { reason: 'reuse_ready', xctestrunPath, productPaths }; + return { reason: 'reuse_ready', xctestrunPath, productPaths, source }; } function emitRunnerXctestrunDecision( @@ -1517,6 +1705,8 @@ function emitRunnerXctestrunDecision( | 'cache_metadata_mismatch' | 'repair_failed' | 'reuse_ready' + | 'forced_rebuild' + | 'bad_artifact' | 'built_new', data: Record, ): void { From 80cd41a3a6045719422fecc72b59494f5b4457ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 4 Jun 2026 08:46:35 -0700 Subject: [PATCH 2/7] refactor: deepen ios runner lifecycle --- src/platforms/ios/runner-client.ts | 749 +------------------ src/platforms/ios/runner-command-recovery.ts | 436 +++++++++++ src/platforms/ios/runner-lifecycle.ts | 342 +++++++++ 3 files changed, 785 insertions(+), 742 deletions(-) create mode 100644 src/platforms/ios/runner-command-recovery.ts create mode 100644 src/platforms/ios/runner-lifecycle.ts diff --git a/src/platforms/ios/runner-client.ts b/src/platforms/ios/runner-client.ts index 8da5de04c..f5e41a5fe 100644 --- a/src/platforms/ios/runner-client.ts +++ b/src/platforms/ios/runner-client.ts @@ -1,24 +1,15 @@ -import { AppError, toAppErrorCode } from '../../utils/errors.ts'; import { withRetry } from '../../utils/retry.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; -import { getRequestSignal } from '../../daemon/request-cancel.ts'; -import { RUNNER_COMMAND_TIMEOUT_MS } from './runner-transport.ts'; import { type RunnerSessionOptions, - type RunnerSession, ensureRunnerSession, - invalidateRunnerSession, - stopIosRunnerSession, validateRunnerDevice, - executeRunnerCommandWithSession, - readRunnerStartupTimeoutMs, } from './runner-session.ts'; import { assertRunnerRequestActive, isReadOnlyRunnerCommand, isRetryableRunnerError, - shouldRetryRunnerConnectError, withRunnerCommandId, type RunnerCommand, } from './runner-contract.ts'; @@ -29,9 +20,11 @@ import { type AppleRunnerCommandOptions, } from './runner-provider.ts'; import { - markRunnerXctestrunArtifactBadForRun, - type RunnerXctestrunArtifact, -} from './runner-xctestrun.ts'; + executeRunnerCommand, + prepareLocalIosRunner, + type PrepareIosRunnerOptions, + type PrepareIosRunnerResult, +} from './runner-lifecycle.ts'; export { isRetryableRunnerError, resolveRunnerEarlyExitHint, @@ -39,46 +32,7 @@ export { shouldRetryRunnerConnectError, type RunnerCommand, } from './runner-contract.ts'; - -type LifecycleResponsePayload = { - ok?: unknown; - data?: unknown; -}; - -type RunnerTransportRecovery = - | { type: 'recovered'; data: Record; reason: string; lifecycleState?: string } - | { type: 'skipInvalidation'; error: AppError; reason: string; lifecycleState?: string } - | { type: 'retainInvalidation'; error?: AppError; reason: string; lifecycleState?: string }; - -type RunnerTransportRecoveryContext = { - command: RunnerCommand; - session: RunnerSession; - transportError: AppError; - invalidationReason: string; -}; - -type RunnerReadinessPreflightRecoveryDetails = { - readinessPreflightSkipped?: boolean; - readinessPreflightSkipReason?: string; - readinessPreflightSkippedAgeMs?: number; -}; - -export type PrepareIosRunnerOptions = RunnerSessionOptions & { - healthTimeoutMs: number; -}; - -export type PrepareIosRunnerResult = { - runner: Record; - cache?: RunnerXctestrunArtifact['cache']; - artifact?: RunnerXctestrunArtifact['artifact']; - buildMs?: number; - connectMs: number; - healthCheckMs: number; - xctestrunPath?: string; - failureReason?: string; -}; - -const RUNNER_STATUS_RECOVERY_TIMEOUT_MS = 3_000; +export type { PrepareIosRunnerOptions, PrepareIosRunnerResult } from './runner-lifecycle.ts'; // --- Runner command execution --- @@ -150,7 +104,6 @@ export async function prepareIosRunner( ): Promise { validateRunnerDevice(device); assertRunnerRequestActive(options.requestId); - const signal = getRequestSignal(options.requestId); const command = withRunnerCommandId({ command: 'uptime' }); if (hasScopedAppleRunnerProvider(device, { requestId: options.requestId })) { const provider = resolveAppleRunnerProvider( @@ -167,695 +120,7 @@ export async function prepareIosRunner( healthCheckMs: Math.max(0, Date.now() - healthStartedAt), }; } - let session: RunnerSession | undefined; - try { - const connectStartedAt = Date.now(); - session = await ensureRunnerSession(device, options); - const connectMs = Date.now() - connectStartedAt; - return await runPrepareHealthCheck(device, session, command, options, signal, connectMs); - } catch (err) { - const appErr = err instanceof AppError ? err : new AppError('COMMAND_FAILED', String(err)); - if (!session || !shouldRecoverBadCachedRunnerArtifact(appErr, session)) { - throw err; - } - const reason = appErr.message || 'runner_health_failed'; - await invalidateRunnerSession(session, 'prepare_cached_runner_health_failed'); - await markRunnerXctestrunArtifactBadForRun(session.xctestrunArtifact, reason); - const connectStartedAt = Date.now(); - const rebuiltSession = await ensureRunnerSession(device, { - ...options, - cleanStaleBundles: true, - forceRunnerXctestrunRebuild: true, - }); - const connectMs = Date.now() - connectStartedAt; - try { - const recovered = await runPrepareHealthCheck( - device, - rebuiltSession, - command, - options, - signal, - connectMs, - reason, - ); - emitDiagnostic({ - level: 'info', - phase: 'ios_runner_prepare_bad_cache_recovered', - data: { - command: command.command, - commandId: command.commandId, - sessionId: rebuiltSession.sessionId, - xctestrunPath: rebuiltSession.xctestrunArtifact?.xctestrunPath, - reason, - }, - }); - return recovered; - } catch (retryErr) { - await invalidateRunnerSession(rebuiltSession, 'prepare_rebuilt_runner_health_failed'); - throw wrapPrepareHealthFailure(retryErr, rebuiltSession, reason); - } - } -} - -// fallow-ignore-next-line complexity -async function executeRunnerCommand( - device: DeviceInfo, - command: RunnerCommand, - options: AppleRunnerCommandOptions, -): Promise> { - assertRunnerRequestActive(options.requestId); - const signal = getRequestSignal(options.requestId); - let session: RunnerSession | undefined; - try { - session = await ensureRunnerSession(device, options); - const timeoutMs = session.ready - ? RUNNER_COMMAND_TIMEOUT_MS - : readRunnerStartupTimeoutMs(session); - return await executeRunnerCommandWithSession( - device, - session, - command, - options.logPath, - timeoutMs, - signal, - ); - } catch (err) { - const appErr = err instanceof AppError ? err : new AppError('COMMAND_FAILED', String(err)); - if ( - appErr.code === 'COMMAND_FAILED' && - typeof appErr.message === 'string' && - appErr.message.includes('Runner did not accept connection') && - shouldRetryRunnerConnectError(appErr) && - session - ) { - assertRunnerRequestActive(options.requestId); - await invalidateRunnerSession(session, 'runner_connect_failed_before_command_send'); - session = await ensureRunnerSession(device, { ...options, cleanStaleBundles: true }); - try { - return await executeRunnerCommandWithSession( - device, - session, - command, - options.logPath, - readRunnerStartupTimeoutMs(session), - signal, - ); - } catch (retryErr) { - const retryAppErr = - retryErr instanceof AppError - ? retryErr - : new AppError('COMMAND_FAILED', String(retryErr)); - if (isRetryableRunnerError(retryAppErr)) { - return await handleRunnerTransportErrorAfterCommandSend( - device, - session, - command, - retryAppErr, - options, - signal, - 'transport_error_after_retry_command_send', - ); - } - throw retryErr; - } - } - if (session && shouldRestartAfterReadinessPreflightError(appErr)) { - assertRunnerRequestActive(options.requestId); - await invalidateRunnerSession( - session, - 'runner_readiness_preflight_failed_before_command_send', - ); - session = await ensureRunnerSession(device, { ...options, cleanStaleBundles: true }); - try { - const recovered = await executeRunnerCommandWithSession( - device, - session, - command, - options.logPath, - readRunnerStartupTimeoutMs(session), - signal, - ); - emitDiagnostic({ - level: 'debug', - phase: 'ios_runner_readiness_preflight_recovered', - data: { - command: command.command, - commandId: command.commandId, - recovery: 'session_restarted', - sessionId: session.sessionId, - }, - }); - return recovered; - } catch (retryErr) { - const retryAppErr = - retryErr instanceof AppError - ? retryErr - : new AppError('COMMAND_FAILED', String(retryErr)); - if (isRetryableRunnerError(retryAppErr)) { - return await handleRunnerTransportErrorAfterCommandSend( - device, - session, - command, - retryAppErr, - options, - signal, - 'transport_error_after_retry_command_send', - ); - } - throw retryErr; - } - } - if (!session && appErr.message.includes('Runner did not accept connection')) { - await stopIosRunnerSession(device.id); - } - if (session && isRetryableRunnerError(appErr)) { - return await handleRunnerTransportErrorAfterCommandSend( - device, - session, - command, - appErr, - options, - signal, - 'transport_error_after_command_send', - ); - } - throw err; - } -} - -async function runPrepareHealthCheck( - device: DeviceInfo, - session: RunnerSession, - command: RunnerCommand, - options: PrepareIosRunnerOptions, - signal: AbortSignal | undefined, - connectMs: number, - failureReason?: string, -): Promise { - const healthStartedAt = Date.now(); - const runner = await executeRunnerCommandWithSession( - device, - session, - command, - options.logPath, - options.healthTimeoutMs, - signal, - ); - return buildPrepareIosRunnerResult( - runner, - session, - connectMs, - Date.now() - healthStartedAt, - failureReason, - ); -} - -function shouldRecoverBadCachedRunnerArtifact( - error: AppError, - session: RunnerSession, -): session is RunnerSession & { - xctestrunArtifact: NonNullable; -} { - const artifact = session.xctestrunArtifact; - if (!artifact || artifact.cache === 'miss') return false; - return ( - isRetryableRunnerError(error) || - shouldRetryRunnerConnectError(error) || - isPrepareHealthTimeout(error) - ); -} - -function isPrepareHealthTimeout(error: AppError): boolean { - const message = error.message.toLowerCase(); - return ( - message.includes('timeout') || message.includes('timed out') || message.includes('deadline') - ); -} - -function wrapPrepareHealthFailure( - error: unknown, - session: RunnerSession, - restoredFailureReason: string, -): AppError { - const appErr = error instanceof AppError ? error : new AppError('COMMAND_FAILED', String(error)); - return new AppError( - appErr.code, - 'artifact restored but runner did not connect', - { - ...(appErr.details ?? {}), - restoredFailureReason, - xctestrunPath: session.xctestrunArtifact?.xctestrunPath, - artifact: session.xctestrunArtifact?.artifact, - cache: session.xctestrunArtifact?.cache, - reason: appErr.message, - }, - appErr, - ); -} - -function buildPrepareIosRunnerResult( - runner: Record, - session: RunnerSession, - connectMs: number, - healthCheckMs: number, - failureReason: string | undefined, -): PrepareIosRunnerResult { - const artifact = session.xctestrunArtifact; - if (!artifact) { - return { - runner, - connectMs: Math.max(0, connectMs), - healthCheckMs: Math.max(0, healthCheckMs), - ...(failureReason ? { failureReason } : {}), - }; - } - return { - runner, - cache: artifact.cache, - artifact: artifact.artifact, - buildMs: artifact.buildMs, - connectMs: Math.max(0, connectMs), - healthCheckMs: Math.max(0, healthCheckMs), - xctestrunPath: artifact.xctestrunPath, - ...(failureReason ? { failureReason } : {}), - }; -} - -async function handleRunnerTransportErrorAfterCommandSend( - device: DeviceInfo, - session: RunnerSession, - command: RunnerCommand, - transportError: AppError, - options: AppleRunnerCommandOptions, - signal: AbortSignal | undefined, - invalidationReason: string, -): Promise> { - const recovery = await tryRecoverRunnerCommandAfterTransportError( - device, - session, - command, - transportError, - options, - signal, - ); - return await applyRunnerTransportRecovery(recovery, { - command, - session, - transportError, - invalidationReason, - }); -} - -async function applyRunnerTransportRecovery( - recovery: RunnerTransportRecovery | undefined, - context: RunnerTransportRecoveryContext, -): Promise> { - if (!recovery) return await retainRunnerInvalidation(context, 'status_recovery_unavailable'); - if (recovery.type === 'recovered') return recoverRunnerResponse(recovery, context); - if (recovery.type === 'skipInvalidation') throw skipRunnerInvalidation(recovery, context); - return await retainRunnerInvalidation( - context, - recovery.reason, - recovery.lifecycleState, - recovery.error, - ); -} - -function recoverRunnerResponse( - recovery: Extract, - context: RunnerTransportRecoveryContext, -): Record { - emitRunnerInvalidationDecision({ - command: context.command, - session: context.session, - transportError: context.transportError, - decision: 'skipped', - reason: recovery.reason, - lifecycleState: recovery.lifecycleState, - }); - return recovery.data; -} - -function skipRunnerInvalidation( - recovery: Extract, - context: RunnerTransportRecoveryContext, -): AppError { - emitRunnerInvalidationDecision({ - command: context.command, - session: context.session, - transportError: context.transportError, - decision: 'skipped', - reason: recovery.reason, - lifecycleState: recovery.lifecycleState, - }); - return recovery.error; -} - -async function retainRunnerInvalidation( - context: RunnerTransportRecoveryContext, - reason: string, - lifecycleState?: string, - error?: AppError, -): Promise { - emitRunnerInvalidationDecision({ - command: context.command, - session: context.session, - transportError: context.transportError, - decision: 'retained', - reason, - lifecycleState, - }); - await invalidateRunnerSession(context.session, context.invalidationReason); - throw error ?? context.transportError; -} - -async function tryRecoverRunnerCommandAfterTransportError( - device: DeviceInfo, - session: RunnerSession, - command: RunnerCommand, - transportError: AppError, - options: AppleRunnerCommandOptions, - signal?: AbortSignal, -): Promise { - if (command.command === 'status' || !command.commandId?.trim()) return undefined; - const readinessPreflight = readReadinessPreflightRecoveryDetails(transportError); - let status: Record; - try { - status = await executeRunnerCommandWithSession( - device, - session, - { command: 'status', statusCommandId: command.commandId }, - options.logPath, - RUNNER_STATUS_RECOVERY_TIMEOUT_MS, - signal, - ); - } catch (error) { - emitDiagnostic({ - level: 'debug', - phase: 'ios_runner_command_status_recovery_failed', - data: { - command: command.command, - commandId: command.commandId, - error: error instanceof Error ? error.message : String(error), - ...readinessPreflight, - }, - }); - return { type: 'retainInvalidation', reason: 'status_probe_failed' }; - } - - const lifecycleState = typeof status.lifecycleState === 'string' ? status.lifecycleState : ''; - emitDiagnostic({ - level: 'debug', - phase: 'ios_runner_command_status_recovery', - data: { - command: command.command, - commandId: command.commandId, - lifecycleState, - ...readinessPreflight, - }, - }); - return handleRunnerCommandStatusRecovery( - status, - lifecycleState, - command, - transportError, - options, - ); -} - -function handleRunnerCommandStatusRecovery( - status: Record, - lifecycleState: string, - command: RunnerCommand, - transportError: AppError, - options: AppleRunnerCommandOptions, -): RunnerTransportRecovery | undefined { - if (lifecycleState === 'completed') { - return handleCompletedRunnerStatus(status, command, transportError, options); - } - - if (lifecycleState === 'failed') { - return { - type: 'skipInvalidation', - reason: 'runner_reported_failure', - lifecycleState, - error: runnerStatusFailureError(status, command, transportError, options), - }; - } - - if (lifecycleState === 'accepted' || lifecycleState === 'started') { - return { - type: 'skipInvalidation', - reason: 'command_still_in_flight', - lifecycleState, - error: runnerStatusInFlightError(lifecycleState, command, transportError, options), - }; - } - - return { - type: 'retainInvalidation', - reason: lifecycleState ? 'unknown_lifecycle_state' : 'missing_lifecycle_state', - lifecycleState, - error: new AppError( - 'COMMAND_FAILED', - `Runner command "${command.command}" lost its transport response and lifecycle status was ${lifecycleState ? `"${lifecycleState}"` : 'missing'}, so agent-device invalidated the runner session instead of replaying the command.`, - { - command: command.command, - commandId: command.commandId, - lifecycleState, - recovery: 'lifecycle_state_not_recoverable', - hint: unknownLifecycleStateHint(command.command), - logPath: options.logPath, - transportError: transportError.message, - }, - transportError, - ), - }; -} - -function handleCompletedRunnerStatus( - status: Record, - command: RunnerCommand, - transportError: AppError, - options: AppleRunnerCommandOptions, -): RunnerTransportRecovery { - const recovered = parseLifecycleResponseJson(status.lifecycleResponseJson); - if (recovered) { - return { - type: 'recovered', - data: recovered, - reason: 'completed_with_retained_response', - lifecycleState: 'completed', - }; - } - if (isReadOnlyRunnerCommand(command.command)) { - return { - type: 'skipInvalidation', - error: transportError, - reason: 'read_only_completed_without_retained_response', - lifecycleState: 'completed', - }; - } - const readinessPreflight = readReadinessPreflightRecoveryDetails(transportError); - return { - type: 'skipInvalidation', - reason: 'completed_without_retained_response', - lifecycleState: 'completed', - error: new AppError( - 'COMMAND_FAILED', - `Runner command "${command.command}" completed after the transport response was lost, but no recoverable response was retained.`, - { - command: command.command, - commandId: command.commandId, - lifecycleState: 'completed', - recovery: 'completed_without_retained_response', - ...readinessPreflight, - hint: completedWithoutRetainedResponseHint(command.command, readinessPreflight), - logPath: options.logPath, - transportError: transportError.message, - }, - transportError, - ), - }; -} - -function runnerStatusFailureError( - status: Record, - command: RunnerCommand, - transportError: AppError, - options: AppleRunnerCommandOptions, -): AppError { - const errorCode = - typeof status.lifecycleErrorCode === 'string' ? status.lifecycleErrorCode : undefined; - const errorMessage = - typeof status.lifecycleErrorMessage === 'string' - ? status.lifecycleErrorMessage - : 'Runner command failed'; - const hint = - typeof status.lifecycleErrorHint === 'string' ? status.lifecycleErrorHint : undefined; - const readinessPreflight = readReadinessPreflightRecoveryDetails(transportError); - return new AppError( - toAppErrorCode(errorCode), - errorMessage, - { - command: command.command, - commandId: command.commandId, - lifecycleState: 'failed', - recovery: 'runner_reported_failure', - ...readinessPreflight, - hint: hint ?? runnerReportedFailureHint(command.command, readinessPreflight), - logPath: options.logPath, - transportError: transportError.message, - }, - transportError, - ); -} - -function runnerStatusInFlightError( - lifecycleState: string, - command: RunnerCommand, - transportError: AppError, - options: AppleRunnerCommandOptions, -): AppError { - if (isReadOnlyRunnerCommand(command.command)) { - return transportError; - } - const readinessPreflight = readReadinessPreflightRecoveryDetails(transportError); - return new AppError( - 'COMMAND_FAILED', - `Runner command "${command.command}" is still ${lifecycleState} after the transport response was lost.`, - { - command: command.command, - commandId: command.commandId, - lifecycleState, - recovery: 'command_still_in_flight', - ...readinessPreflight, - hint: inFlightAfterLostResponseHint(command.command, lifecycleState, readinessPreflight), - logPath: options.logPath, - transportError: transportError.message, - }, - transportError, - ); -} - -function parseLifecycleResponseJson(value: unknown): Record | undefined { - if (typeof value !== 'string' || value.trim().length === 0) return undefined; - const parsed = parseLifecycleResponsePayload(value); - if (!parsed.ok) return undefined; - if (parsed.data && typeof parsed.data === 'object' && !Array.isArray(parsed.data)) { - return parsed.data as Record; - } - return {}; -} - -function parseLifecycleResponsePayload(value: string): LifecycleResponsePayload { - try { - const raw: unknown = JSON.parse(value); - if (raw && typeof raw === 'object') return raw as LifecycleResponsePayload; - } catch {} - return {}; -} - -function completedWithoutRetainedResponseHint( - command: string, - readinessPreflight: RunnerReadinessPreflightRecoveryDetails = {}, -): string { - return `${lostResponseReadinessContext(readinessPreflight)}The runner is still reachable and reports "${command}" already completed, so agent-device kept the session open and will not replay it. Run snapshot -i to inspect the current UI, then continue from that observed state.`; -} - -function runnerReportedFailureHint( - command: string, - readinessPreflight: RunnerReadinessPreflightRecoveryDetails = {}, -): string { - return `${lostResponseReadinessContext(readinessPreflight)}The runner is still reachable and reports "${command}" failed after the transport response was lost, so agent-device kept the session open and did not replay it. Run snapshot -i to inspect the current UI and retry with a selector visible in that snapshot.`; -} - -function inFlightAfterLostResponseHint( - command: string, - lifecycleState: string, - readinessPreflight: RunnerReadinessPreflightRecoveryDetails = {}, -): string { - return `${lostResponseReadinessContext(readinessPreflight)}The runner is still reachable and reports "${command}" is ${lifecycleState}, so agent-device kept the session open and will not replay it. Wait briefly, run snapshot -i to inspect the current UI, then continue from that observed state.`; -} - -function lostResponseReadinessContext( - readinessPreflight: RunnerReadinessPreflightRecoveryDetails, -): string { - if (readinessPreflight.readinessPreflightSkipped !== true) return ''; - return 'This hot command skipped the uptime preflight because the runner had just responded; status recovery confirmed the runner still observed it. '; -} - -function unknownLifecycleStateHint(command: string): string { - return `The runner did not confirm that "${command}" reached a safe terminal state, so agent-device kept the conservative invalidation path. Run snapshot -i before retrying if the UI may have changed.`; -} - -function emitRunnerInvalidationDecision(params: { - command: RunnerCommand; - session: RunnerSession; - transportError: AppError; - decision: 'skipped' | 'retained'; - reason: string; - lifecycleState?: string; -}): void { - const { command, session, transportError, decision, reason, lifecycleState } = params; - emitDiagnostic({ - level: decision === 'retained' ? 'warn' : 'debug', - phase: 'ios_runner_command_invalidation_decision', - data: { - command: command.command, - commandId: command.commandId, - decision, - reason, - lifecycleState, - runnerReachable: lifecycleState !== undefined, - sessionId: session.sessionId, - transportError: transportError.message, - }, - }); -} - -function isRunnerReadinessPreflightError(error: AppError): boolean { - return error.details?.runnerReadinessPreflightFailed === true; -} - -function shouldRestartAfterReadinessPreflightError(error: AppError): boolean { - return ( - isRunnerReadinessPreflightError(error) && - (isRetryableRunnerError(error) || isRunnerReadinessPreflightTimeout(error)) - ); -} - -function isRunnerReadinessPreflightTimeout(error: AppError): boolean { - const message = error.message.toLowerCase(); - return message.includes('timeout') || message.includes('timed out'); -} - -function readBooleanDetail(error: AppError, key: string): boolean | undefined { - const value = error.details?.[key]; - return typeof value === 'boolean' ? value : undefined; -} - -function readStringDetail(error: AppError, key: string): string | undefined { - const value = error.details?.[key]; - return typeof value === 'string' ? value : undefined; -} - -function readNumberDetail(error: AppError, key: string): number | undefined { - const value = error.details?.[key]; - return typeof value === 'number' ? value : undefined; -} - -function readReadinessPreflightRecoveryDetails( - error: AppError, -): RunnerReadinessPreflightRecoveryDetails { - const details: RunnerReadinessPreflightRecoveryDetails = {}; - const skipped = readBooleanDetail(error, 'runnerReadinessPreflightSkipped'); - if (skipped !== undefined) details.readinessPreflightSkipped = skipped; - const reason = readStringDetail(error, 'runnerReadinessPreflightSkipReason'); - if (reason !== undefined) details.readinessPreflightSkipReason = reason; - const ageMs = readNumberDetail(error, 'runnerReadinessPreflightSkippedAgeMs'); - if (ageMs !== undefined) details.readinessPreflightSkippedAgeMs = ageMs; - return details; + return await prepareLocalIosRunner(device, options); } export { diff --git a/src/platforms/ios/runner-command-recovery.ts b/src/platforms/ios/runner-command-recovery.ts new file mode 100644 index 000000000..e93645991 --- /dev/null +++ b/src/platforms/ios/runner-command-recovery.ts @@ -0,0 +1,436 @@ +import { AppError, toAppErrorCode } from '../../utils/errors.ts'; +import type { DeviceInfo } from '../../utils/device.ts'; +import { emitDiagnostic } from '../../utils/diagnostics.ts'; +import { isReadOnlyRunnerCommand, type RunnerCommand } from './runner-contract.ts'; +import type { AppleRunnerCommandOptions } from './runner-provider.ts'; +import { executeRunnerCommandWithSession, type RunnerSession } from './runner-session.ts'; + +type LifecycleResponsePayload = { + ok?: unknown; + data?: unknown; +}; + +type RunnerTransportRecovery = + | { type: 'recovered'; data: Record; reason: string; lifecycleState?: string } + | { type: 'skipInvalidation'; error: AppError; reason: string; lifecycleState?: string } + | { type: 'retainInvalidation'; error?: AppError; reason: string; lifecycleState?: string }; + +type RunnerTransportRecoveryContext = { + command: RunnerCommand; + session: RunnerSession; + transportError: AppError; + invalidationReason: string; + invalidateSession: (session: RunnerSession, reason: string) => Promise; +}; + +type RunnerReadinessPreflightRecoveryDetails = { + readinessPreflightSkipped?: boolean; + readinessPreflightSkipReason?: string; + readinessPreflightSkippedAgeMs?: number; +}; + +const RUNNER_STATUS_RECOVERY_TIMEOUT_MS = 3_000; + +export async function handleRunnerTransportErrorAfterCommandSend(params: { + device: DeviceInfo; + session: RunnerSession; + command: RunnerCommand; + transportError: AppError; + options: AppleRunnerCommandOptions; + signal: AbortSignal | undefined; + invalidationReason: string; + invalidateSession: (session: RunnerSession, reason: string) => Promise; +}): Promise> { + const { device, session, command, transportError, options, signal, invalidationReason } = params; + const recovery = await tryRecoverRunnerCommandAfterTransportError( + device, + session, + command, + transportError, + options, + signal, + ); + return await applyRunnerTransportRecovery(recovery, { + command, + session, + transportError, + invalidationReason, + invalidateSession: params.invalidateSession, + }); +} + +async function applyRunnerTransportRecovery( + recovery: RunnerTransportRecovery | undefined, + context: RunnerTransportRecoveryContext, +): Promise> { + if (!recovery) return await retainRunnerInvalidation(context, 'status_recovery_unavailable'); + if (recovery.type === 'recovered') return recoverRunnerResponse(recovery, context); + if (recovery.type === 'skipInvalidation') throw skipRunnerInvalidation(recovery, context); + return await retainRunnerInvalidation( + context, + recovery.reason, + recovery.lifecycleState, + recovery.error, + ); +} + +function recoverRunnerResponse( + recovery: Extract, + context: RunnerTransportRecoveryContext, +): Record { + emitRunnerInvalidationDecision({ + command: context.command, + session: context.session, + transportError: context.transportError, + decision: 'skipped', + reason: recovery.reason, + lifecycleState: recovery.lifecycleState, + }); + return recovery.data; +} + +function skipRunnerInvalidation( + recovery: Extract, + context: RunnerTransportRecoveryContext, +): AppError { + emitRunnerInvalidationDecision({ + command: context.command, + session: context.session, + transportError: context.transportError, + decision: 'skipped', + reason: recovery.reason, + lifecycleState: recovery.lifecycleState, + }); + return recovery.error; +} + +async function retainRunnerInvalidation( + context: RunnerTransportRecoveryContext, + reason: string, + lifecycleState?: string, + error?: AppError, +): Promise { + emitRunnerInvalidationDecision({ + command: context.command, + session: context.session, + transportError: context.transportError, + decision: 'retained', + reason, + lifecycleState, + }); + await context.invalidateSession(context.session, context.invalidationReason); + throw error ?? context.transportError; +} + +async function tryRecoverRunnerCommandAfterTransportError( + device: DeviceInfo, + session: RunnerSession, + command: RunnerCommand, + transportError: AppError, + options: AppleRunnerCommandOptions, + signal?: AbortSignal, +): Promise { + if (command.command === 'status' || !command.commandId?.trim()) return undefined; + const readinessPreflight = readReadinessPreflightRecoveryDetails(transportError); + let status: Record; + try { + status = await executeRunnerCommandWithSession( + device, + session, + { command: 'status', statusCommandId: command.commandId }, + options.logPath, + RUNNER_STATUS_RECOVERY_TIMEOUT_MS, + signal, + ); + } catch (error) { + emitDiagnostic({ + level: 'debug', + phase: 'ios_runner_command_status_recovery_failed', + data: { + command: command.command, + commandId: command.commandId, + error: error instanceof Error ? error.message : String(error), + ...readinessPreflight, + }, + }); + return { type: 'retainInvalidation', reason: 'status_probe_failed' }; + } + + const lifecycleState = typeof status.lifecycleState === 'string' ? status.lifecycleState : ''; + emitDiagnostic({ + level: 'debug', + phase: 'ios_runner_command_status_recovery', + data: { + command: command.command, + commandId: command.commandId, + lifecycleState, + ...readinessPreflight, + }, + }); + return handleRunnerCommandStatusRecovery( + status, + lifecycleState, + command, + transportError, + options, + ); +} + +function handleRunnerCommandStatusRecovery( + status: Record, + lifecycleState: string, + command: RunnerCommand, + transportError: AppError, + options: AppleRunnerCommandOptions, +): RunnerTransportRecovery | undefined { + if (lifecycleState === 'completed') { + return handleCompletedRunnerStatus(status, command, transportError, options); + } + + if (lifecycleState === 'failed') { + return { + type: 'skipInvalidation', + reason: 'runner_reported_failure', + lifecycleState, + error: runnerStatusFailureError(status, command, transportError, options), + }; + } + + if (lifecycleState === 'accepted' || lifecycleState === 'started') { + return { + type: 'skipInvalidation', + reason: 'command_still_in_flight', + lifecycleState, + error: runnerStatusInFlightError(lifecycleState, command, transportError, options), + }; + } + + return { + type: 'retainInvalidation', + reason: lifecycleState ? 'unknown_lifecycle_state' : 'missing_lifecycle_state', + lifecycleState, + error: new AppError( + 'COMMAND_FAILED', + `Runner command "${command.command}" lost its transport response and lifecycle status was ${lifecycleState ? `"${lifecycleState}"` : 'missing'}, so agent-device invalidated the runner session instead of replaying the command.`, + { + command: command.command, + commandId: command.commandId, + lifecycleState, + recovery: 'lifecycle_state_not_recoverable', + hint: unknownLifecycleStateHint(command.command), + logPath: options.logPath, + transportError: transportError.message, + }, + transportError, + ), + }; +} + +function handleCompletedRunnerStatus( + status: Record, + command: RunnerCommand, + transportError: AppError, + options: AppleRunnerCommandOptions, +): RunnerTransportRecovery { + const recovered = parseLifecycleResponseJson(status.lifecycleResponseJson); + if (recovered) { + return { + type: 'recovered', + data: recovered, + reason: 'completed_with_retained_response', + lifecycleState: 'completed', + }; + } + if (isReadOnlyRunnerCommand(command.command)) { + return { + type: 'skipInvalidation', + error: transportError, + reason: 'read_only_completed_without_retained_response', + lifecycleState: 'completed', + }; + } + const readinessPreflight = readReadinessPreflightRecoveryDetails(transportError); + return { + type: 'skipInvalidation', + reason: 'completed_without_retained_response', + lifecycleState: 'completed', + error: new AppError( + 'COMMAND_FAILED', + `Runner command "${command.command}" completed after the transport response was lost, but no recoverable response was retained.`, + { + command: command.command, + commandId: command.commandId, + lifecycleState: 'completed', + recovery: 'completed_without_retained_response', + ...readinessPreflight, + hint: completedWithoutRetainedResponseHint(command.command, readinessPreflight), + logPath: options.logPath, + transportError: transportError.message, + }, + transportError, + ), + }; +} + +function runnerStatusFailureError( + status: Record, + command: RunnerCommand, + transportError: AppError, + options: AppleRunnerCommandOptions, +): AppError { + const errorCode = + typeof status.lifecycleErrorCode === 'string' ? status.lifecycleErrorCode : undefined; + const errorMessage = + typeof status.lifecycleErrorMessage === 'string' + ? status.lifecycleErrorMessage + : 'Runner command failed'; + const hint = + typeof status.lifecycleErrorHint === 'string' ? status.lifecycleErrorHint : undefined; + const readinessPreflight = readReadinessPreflightRecoveryDetails(transportError); + return new AppError( + toAppErrorCode(errorCode), + errorMessage, + { + command: command.command, + commandId: command.commandId, + lifecycleState: 'failed', + recovery: 'runner_reported_failure', + ...readinessPreflight, + hint: hint ?? runnerReportedFailureHint(command.command, readinessPreflight), + logPath: options.logPath, + transportError: transportError.message, + }, + transportError, + ); +} + +function runnerStatusInFlightError( + lifecycleState: string, + command: RunnerCommand, + transportError: AppError, + options: AppleRunnerCommandOptions, +): AppError { + if (isReadOnlyRunnerCommand(command.command)) { + return transportError; + } + const readinessPreflight = readReadinessPreflightRecoveryDetails(transportError); + return new AppError( + 'COMMAND_FAILED', + `Runner command "${command.command}" is still ${lifecycleState} after the transport response was lost.`, + { + command: command.command, + commandId: command.commandId, + lifecycleState, + recovery: 'command_still_in_flight', + ...readinessPreflight, + hint: inFlightAfterLostResponseHint(command.command, lifecycleState, readinessPreflight), + logPath: options.logPath, + transportError: transportError.message, + }, + transportError, + ); +} + +function parseLifecycleResponseJson(value: unknown): Record | undefined { + if (typeof value !== 'string' || value.trim().length === 0) return undefined; + const parsed = parseLifecycleResponsePayload(value); + if (!parsed.ok) return undefined; + if (parsed.data && typeof parsed.data === 'object' && !Array.isArray(parsed.data)) { + return parsed.data as Record; + } + return {}; +} + +function parseLifecycleResponsePayload(value: string): LifecycleResponsePayload { + try { + const raw: unknown = JSON.parse(value); + if (raw && typeof raw === 'object') return raw as LifecycleResponsePayload; + } catch {} + return {}; +} + +function completedWithoutRetainedResponseHint( + command: string, + readinessPreflight: RunnerReadinessPreflightRecoveryDetails = {}, +): string { + return `${lostResponseReadinessContext(readinessPreflight)}The runner is still reachable and reports "${command}" already completed, so agent-device kept the session open and will not replay it. Run snapshot -i to inspect the current UI, then continue from that observed state.`; +} + +function runnerReportedFailureHint( + command: string, + readinessPreflight: RunnerReadinessPreflightRecoveryDetails = {}, +): string { + return `${lostResponseReadinessContext(readinessPreflight)}The runner is still reachable and reports "${command}" failed after the transport response was lost, so agent-device kept the session open and did not replay it. Run snapshot -i to inspect the current UI and retry with a selector visible in that snapshot.`; +} + +function inFlightAfterLostResponseHint( + command: string, + lifecycleState: string, + readinessPreflight: RunnerReadinessPreflightRecoveryDetails = {}, +): string { + return `${lostResponseReadinessContext(readinessPreflight)}The runner is still reachable and reports "${command}" is ${lifecycleState}, so agent-device kept the session open and will not replay it. Wait briefly, run snapshot -i to inspect the current UI, then continue from that observed state.`; +} + +function lostResponseReadinessContext( + readinessPreflight: RunnerReadinessPreflightRecoveryDetails, +): string { + if (readinessPreflight.readinessPreflightSkipped !== true) return ''; + return 'This hot command skipped the uptime preflight because the runner had just responded; status recovery confirmed the runner still observed it. '; +} + +function unknownLifecycleStateHint(command: string): string { + return `The runner did not confirm that "${command}" reached a safe terminal state, so agent-device kept the conservative invalidation path. Run snapshot -i before retrying if the UI may have changed.`; +} + +function emitRunnerInvalidationDecision(params: { + command: RunnerCommand; + session: RunnerSession; + transportError: AppError; + decision: 'skipped' | 'retained'; + reason: string; + lifecycleState?: string; +}): void { + const { command, session, transportError, decision, reason, lifecycleState } = params; + emitDiagnostic({ + level: decision === 'retained' ? 'warn' : 'debug', + phase: 'ios_runner_command_invalidation_decision', + data: { + command: command.command, + commandId: command.commandId, + decision, + reason, + lifecycleState, + runnerReachable: lifecycleState !== undefined, + sessionId: session.sessionId, + transportError: transportError.message, + }, + }); +} + +function readBooleanDetail(error: AppError, key: string): boolean | undefined { + const value = error.details?.[key]; + return typeof value === 'boolean' ? value : undefined; +} + +function readStringDetail(error: AppError, key: string): string | undefined { + const value = error.details?.[key]; + return typeof value === 'string' ? value : undefined; +} + +function readNumberDetail(error: AppError, key: string): number | undefined { + const value = error.details?.[key]; + return typeof value === 'number' ? value : undefined; +} + +function readReadinessPreflightRecoveryDetails( + error: AppError, +): RunnerReadinessPreflightRecoveryDetails { + const details: RunnerReadinessPreflightRecoveryDetails = {}; + const skipped = readBooleanDetail(error, 'runnerReadinessPreflightSkipped'); + if (skipped !== undefined) details.readinessPreflightSkipped = skipped; + const reason = readStringDetail(error, 'runnerReadinessPreflightSkipReason'); + if (reason !== undefined) details.readinessPreflightSkipReason = reason; + const ageMs = readNumberDetail(error, 'runnerReadinessPreflightSkippedAgeMs'); + if (ageMs !== undefined) details.readinessPreflightSkippedAgeMs = ageMs; + return details; +} diff --git a/src/platforms/ios/runner-lifecycle.ts b/src/platforms/ios/runner-lifecycle.ts new file mode 100644 index 000000000..f8459a93b --- /dev/null +++ b/src/platforms/ios/runner-lifecycle.ts @@ -0,0 +1,342 @@ +import { AppError } from '../../utils/errors.ts'; +import type { DeviceInfo } from '../../utils/device.ts'; +import { emitDiagnostic } from '../../utils/diagnostics.ts'; +import { getRequestSignal } from '../../daemon/request-cancel.ts'; +import { RUNNER_COMMAND_TIMEOUT_MS, RUNNER_STARTUP_TIMEOUT_MS } from './runner-transport.ts'; +import { + type RunnerSessionOptions, + type RunnerSession, + ensureRunnerSession, + invalidateRunnerSession, + stopIosRunnerSession, + executeRunnerCommandWithSession, + readRunnerStartupTimeoutMs, +} from './runner-session.ts'; +import { + assertRunnerRequestActive, + isRetryableRunnerError, + shouldRetryRunnerConnectError, + withRunnerCommandId, + type RunnerCommand, +} from './runner-contract.ts'; +import type { AppleRunnerCommandOptions } from './runner-provider.ts'; +import { + markRunnerXctestrunArtifactBadForRun, + type RunnerXctestrunArtifact, +} from './runner-xctestrun.ts'; +import { handleRunnerTransportErrorAfterCommandSend } from './runner-command-recovery.ts'; + +export type PrepareIosRunnerOptions = RunnerSessionOptions & { + healthTimeoutMs: number; +}; + +export type PrepareIosRunnerResult = { + runner: Record; + cache?: RunnerXctestrunArtifact['cache']; + artifact?: RunnerXctestrunArtifact['artifact']; + buildMs?: number; + connectMs: number; + healthCheckMs: number; + xctestrunPath?: string; + failureReason?: string; +}; + +export async function prepareLocalIosRunner( + device: DeviceInfo, + options: PrepareIosRunnerOptions, +): Promise { + assertRunnerRequestActive(options.requestId); + const signal = getRequestSignal(options.requestId); + const command = withRunnerCommandId({ command: 'uptime' }); + let session: RunnerSession | undefined; + try { + const connectStartedAt = Date.now(); + session = await ensureRunnerSession(device, options); + const connectMs = Date.now() - connectStartedAt; + return await runPrepareHealthCheck(device, session, command, options, signal, connectMs); + } catch (err) { + const appErr = err instanceof AppError ? err : new AppError('COMMAND_FAILED', String(err)); + if (!session || !shouldRecoverBadCachedRunnerArtifact(appErr, session)) { + throw err; + } + const reason = appErr.message || 'runner_health_failed'; + await invalidateRunnerSession(session, 'prepare_cached_runner_health_failed'); + await markRunnerXctestrunArtifactBadForRun(session.xctestrunArtifact, reason); + const connectStartedAt = Date.now(); + const rebuiltSession = await ensureRunnerSession(device, { + ...options, + cleanStaleBundles: true, + forceRunnerXctestrunRebuild: true, + }); + const connectMs = Date.now() - connectStartedAt; + try { + const recovered = await runPrepareHealthCheck( + device, + rebuiltSession, + command, + options, + signal, + connectMs, + reason, + ); + emitDiagnostic({ + level: 'info', + phase: 'ios_runner_prepare_bad_cache_recovered', + data: { + command: command.command, + commandId: command.commandId, + sessionId: rebuiltSession.sessionId, + xctestrunPath: rebuiltSession.xctestrunArtifact?.xctestrunPath, + reason, + }, + }); + return recovered; + } catch (retryErr) { + await invalidateRunnerSession(rebuiltSession, 'prepare_rebuilt_runner_health_failed'); + throw wrapPrepareHealthFailure(retryErr, rebuiltSession, reason); + } + } +} + +// fallow-ignore-next-line complexity +export async function executeRunnerCommand( + device: DeviceInfo, + command: RunnerCommand, + options: AppleRunnerCommandOptions, +): Promise> { + assertRunnerRequestActive(options.requestId); + const signal = getRequestSignal(options.requestId); + let session: RunnerSession | undefined; + try { + session = await ensureRunnerSession(device, options); + const timeoutMs = session.ready + ? RUNNER_COMMAND_TIMEOUT_MS + : readRunnerStartupTimeoutMs(session); + return await executeRunnerCommandWithSession( + device, + session, + command, + options.logPath, + timeoutMs, + signal, + ); + } catch (err) { + const appErr = err instanceof AppError ? err : new AppError('COMMAND_FAILED', String(err)); + if ( + appErr.code === 'COMMAND_FAILED' && + typeof appErr.message === 'string' && + appErr.message.includes('Runner did not accept connection') && + shouldRetryRunnerConnectError(appErr) && + session + ) { + assertRunnerRequestActive(options.requestId); + await invalidateRunnerSession(session, 'runner_connect_failed_before_command_send'); + session = await ensureRunnerSession(device, { ...options, cleanStaleBundles: true }); + try { + return await executeRunnerCommandWithSession( + device, + session, + command, + options.logPath, + RUNNER_STARTUP_TIMEOUT_MS, + signal, + ); + } catch (retryErr) { + const retryAppErr = + retryErr instanceof AppError + ? retryErr + : new AppError('COMMAND_FAILED', String(retryErr)); + if (isRetryableRunnerError(retryAppErr)) { + return await handleRunnerTransportErrorAfterCommandSend({ + device, + session, + command, + transportError: retryAppErr, + options, + signal, + invalidationReason: 'transport_error_after_retry_command_send', + invalidateSession: invalidateRunnerSession, + }); + } + throw retryErr; + } + } + if (session && shouldRestartAfterReadinessPreflightError(appErr)) { + assertRunnerRequestActive(options.requestId); + await invalidateRunnerSession( + session, + 'runner_readiness_preflight_failed_before_command_send', + ); + session = await ensureRunnerSession(device, { ...options, cleanStaleBundles: true }); + try { + const recovered = await executeRunnerCommandWithSession( + device, + session, + command, + options.logPath, + RUNNER_STARTUP_TIMEOUT_MS, + signal, + ); + emitDiagnostic({ + level: 'debug', + phase: 'ios_runner_readiness_preflight_recovered', + data: { + command: command.command, + commandId: command.commandId, + recovery: 'session_restarted', + sessionId: session.sessionId, + }, + }); + return recovered; + } catch (retryErr) { + const retryAppErr = + retryErr instanceof AppError + ? retryErr + : new AppError('COMMAND_FAILED', String(retryErr)); + if (isRetryableRunnerError(retryAppErr)) { + return await handleRunnerTransportErrorAfterCommandSend({ + device, + session, + command, + transportError: retryAppErr, + options, + signal, + invalidationReason: 'transport_error_after_retry_command_send', + invalidateSession: invalidateRunnerSession, + }); + } + throw retryErr; + } + } + if (!session && appErr.message.includes('Runner did not accept connection')) { + await stopIosRunnerSession(device.id); + } + if (session && isRetryableRunnerError(appErr)) { + return await handleRunnerTransportErrorAfterCommandSend({ + device, + session, + command, + transportError: appErr, + options, + signal, + invalidationReason: 'transport_error_after_command_send', + invalidateSession: invalidateRunnerSession, + }); + } + throw err; + } +} + +async function runPrepareHealthCheck( + device: DeviceInfo, + session: RunnerSession, + command: RunnerCommand, + options: PrepareIosRunnerOptions, + signal: AbortSignal | undefined, + connectMs: number, + failureReason?: string, +): Promise { + const healthStartedAt = Date.now(); + const runner = await executeRunnerCommandWithSession( + device, + session, + command, + options.logPath, + options.healthTimeoutMs, + signal, + ); + return buildPrepareIosRunnerResult( + runner, + session, + connectMs, + Date.now() - healthStartedAt, + failureReason, + ); +} + +function shouldRecoverBadCachedRunnerArtifact( + error: AppError, + session: RunnerSession, +): session is RunnerSession & { + xctestrunArtifact: NonNullable; +} { + const artifact = session.xctestrunArtifact; + if (!artifact || artifact.cache === 'miss') return false; + return ( + isRetryableRunnerError(error) || + shouldRetryRunnerConnectError(error) || + isPrepareHealthTimeout(error) + ); +} + +function isPrepareHealthTimeout(error: AppError): boolean { + const message = error.message.toLowerCase(); + return ( + message.includes('timeout') || message.includes('timed out') || message.includes('deadline') + ); +} + +function wrapPrepareHealthFailure( + error: unknown, + session: RunnerSession, + restoredFailureReason: string, +): AppError { + const appErr = error instanceof AppError ? error : new AppError('COMMAND_FAILED', String(error)); + return new AppError( + appErr.code, + 'artifact restored but runner did not connect', + { + ...(appErr.details ?? {}), + restoredFailureReason, + xctestrunPath: session.xctestrunArtifact?.xctestrunPath, + artifact: session.xctestrunArtifact?.artifact, + cache: session.xctestrunArtifact?.cache, + reason: appErr.message, + }, + appErr, + ); +} + +function buildPrepareIosRunnerResult( + runner: Record, + session: RunnerSession, + connectMs: number, + healthCheckMs: number, + failureReason: string | undefined, +): PrepareIosRunnerResult { + const artifact = session.xctestrunArtifact; + if (!artifact) { + return { + runner, + connectMs: Math.max(0, connectMs), + healthCheckMs: Math.max(0, healthCheckMs), + ...(failureReason ? { failureReason } : {}), + }; + } + return { + runner, + cache: artifact.cache, + artifact: artifact.artifact, + buildMs: artifact.buildMs, + connectMs: Math.max(0, connectMs), + healthCheckMs: Math.max(0, healthCheckMs), + xctestrunPath: artifact.xctestrunPath, + ...(failureReason ? { failureReason } : {}), + }; +} + +function isRunnerReadinessPreflightError(error: AppError): boolean { + return error.details?.runnerReadinessPreflightFailed === true; +} + +function shouldRestartAfterReadinessPreflightError(error: AppError): boolean { + return ( + isRunnerReadinessPreflightError(error) && + (isRetryableRunnerError(error) || isRunnerReadinessPreflightTimeout(error)) + ); +} + +function isRunnerReadinessPreflightTimeout(error: AppError): boolean { + const message = error.message.toLowerCase(); + return message.includes('timeout') || message.includes('timed out'); +} From aa97d844c23b9a5fe779e5b7f44dd4370b23544d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 4 Jun 2026 09:54:26 -0700 Subject: [PATCH 3/7] refactor: generalize apple runner prepare --- src/daemon-client.ts | 6 +- src/daemon/handlers/__tests__/session.test.ts | 55 +- src/daemon/handlers/session.ts | 11 +- .../ios/__tests__/runner-client.test.ts | 7 + .../__tests__/runner-command-retry.test.ts | 16 +- src/platforms/ios/runner-lifecycle.ts | 200 +++-- src/platforms/ios/runner-xctestrun.ts | 140 ++-- src/utils/__tests__/args.test.ts | 6 +- src/utils/cli-command-overrides.ts | 6 +- src/utils/cli-help.ts | 2 +- .../provider-scenarios/macos-desktop.test.ts | 767 +++++++++--------- 11 files changed, 696 insertions(+), 520 deletions(-) diff --git a/src/daemon-client.ts b/src/daemon-client.ts index 0f410c2de..0120e4594 100644 --- a/src/daemon-client.ts +++ b/src/daemon-client.ts @@ -1159,11 +1159,11 @@ function resolveRequestTimeoutHint(params: { if (!resetDaemon) { const iosPrepareHint = command === PUBLIC_COMMANDS.snapshot - ? ' If this was the first iOS snapshot on the device, run agent-device prepare ios-runner --platform ios before snapshot/test so runner startup is handled explicitly.' + ? ' If this was the first Apple-platform snapshot on the device, run agent-device prepare ios-runner with the same --platform before snapshot/test so runner startup is handled explicitly.' : ''; - return `Retry with --debug and check daemon diagnostics logs. The timed-out ${command ?? 'request'} request was canceled and iOS runner work was aborted when detected; the daemon was kept alive so the session can still be closed or inspected.${iosPrepareHint}`; + return `Retry with --debug and check daemon diagnostics logs. The timed-out ${command ?? 'request'} request was canceled and Apple runner work was aborted when detected; the daemon was kept alive so the session can still be closed or inspected.${iosPrepareHint}`; } - return 'Retry with --debug and check daemon diagnostics logs. Timed-out iOS runner xcodebuild processes were terminated when detected.'; + return 'Retry with --debug and check daemon diagnostics logs. Timed-out Apple runner xcodebuild processes were terminated when detected.'; } function handleTransportError( diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index b8b542e7b..87fbc872a 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -2159,12 +2159,59 @@ test('prepare ios-runner starts the XCTest runner on an explicit iOS selector', connectMs: 3, healthCheckMs: 3, runner: { currentUptimeMs: 42 }, - message: 'Prepared iOS runner: iPhone 17 Pro', + message: 'Prepared Apple runner: iPhone 17 Pro', }); expect(sessionStore.get(sessionName)).toBeUndefined(); }); -test('prepare ios-runner rejects non-iOS devices', async () => { +test('prepare ios-runner starts the XCTest runner on an explicit macOS selector', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'prepare-macos-runner'; + mockResolveTargetDevice.mockResolvedValue({ + platform: 'macos', + id: 'host-macos-local', + name: 'Host Mac', + kind: 'device', + target: 'desktop', + booted: true, + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'prepare', + positionals: ['ios-runner'], + flags: { platform: 'macos', timeoutMs: 240000 }, + meta: { requestId: 'prepare-macos-request' }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(mockPrepareIosRunner).toHaveBeenCalledWith( + expect.objectContaining({ platform: 'macos', id: 'host-macos-local' }), + expect.objectContaining({ + buildTimeoutMs: 240000, + healthTimeoutMs: 90000, + requestId: 'prepare-macos-request', + }), + ); + expect((response as any).data).toMatchObject({ + action: 'ios-runner', + platform: 'macos', + deviceId: 'host-macos-local', + deviceName: 'Host Mac', + kind: 'device', + message: 'Prepared Apple runner: Host Mac', + }); +}); + +test('prepare ios-runner rejects non-Apple runner devices', async () => { const sessionStore = makeSessionStore(); mockResolveTargetDevice.mockResolvedValue({ platform: 'android', @@ -2192,7 +2239,9 @@ test('prepare ios-runner rejects non-iOS devices', async () => { expect(response?.ok).toBe(false); if (response && !response.ok) { expect(response.error.code).toBe('UNSUPPORTED_OPERATION'); - expect(response.error.message).toBe('prepare ios-runner is only supported on iOS'); + expect(response.error.message).toBe( + 'prepare ios-runner is only supported on Apple runner platforms', + ); } expect(mockPrepareIosRunner).not.toHaveBeenCalled(); }); diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index d5ef59911..6feb29bc4 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -12,7 +12,7 @@ import { type PrepareIosRunnerResult, } from '../../platforms/ios/runner-client.ts'; import type { DeviceInfo } from '../../utils/device.ts'; -import { normalizePlatformSelector } from '../../utils/device.ts'; +import { isApplePlatform, normalizePlatformSelector } from '../../utils/device.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { contextFromFlags } from '../context.ts'; @@ -90,8 +90,11 @@ async function handlePrepareCommand(params: { flags, ensureReady: true, }); - if (device.platform !== 'ios') { - return errorResponse('UNSUPPORTED_OPERATION', 'prepare ios-runner is only supported on iOS'); + if (!isApplePlatform(device.platform)) { + return errorResponse( + 'UNSUPPORTED_OPERATION', + 'prepare ios-runner is only supported on Apple runner platforms', + ); } const startedAtMs = Date.now(); @@ -152,7 +155,7 @@ function prepareIosRunnerResponseData( kind: device.kind, durationMs, ...result, - message: `Prepared iOS runner: ${device.name}`, + message: `Prepared Apple runner: ${device.name}`, }; } diff --git a/src/platforms/ios/__tests__/runner-client.test.ts b/src/platforms/ios/__tests__/runner-client.test.ts index aa29935af..d671bfc65 100644 --- a/src/platforms/ios/__tests__/runner-client.test.ts +++ b/src/platforms/ios/__tests__/runner-client.test.ts @@ -754,6 +754,12 @@ test('resolveRunnerDerivedPath keys default cache by runner metadata', () => { target: 'tv', buildDestinationFamily: 'appletvsimulator', }); + const macPath = resolveRunnerDerivedPath(macOsDevice, { + ...metadata, + platformName: 'macOS', + target: 'desktop', + buildDestinationFamily: 'macos', + }); const staleVersionPath = resolveRunnerDerivedPath(iosSimulator, { ...metadata, packageVersion: '0.0.0-stale', @@ -761,6 +767,7 @@ test('resolveRunnerDerivedPath keys default cache by runner metadata', () => { assert.match(iosPath, /\/ios-runner\/derived\/ios-simulator\/cache-[a-f0-9]{16}$/); assert.match(tvPath, /\/ios-runner\/derived\/tvos-simulator\/cache-[a-f0-9]{16}$/); + assert.match(macPath, /\/ios-runner\/derived\/macos\/cache-[a-f0-9]{16}$/); assert.notEqual(iosPath, staleVersionPath); }); diff --git a/src/platforms/ios/__tests__/runner-command-retry.test.ts b/src/platforms/ios/__tests__/runner-command-retry.test.ts index 26d3a8f58..4ee43a839 100644 --- a/src/platforms/ios/__tests__/runner-command-retry.test.ts +++ b/src/platforms/ios/__tests__/runner-command-retry.test.ts @@ -10,14 +10,12 @@ const { mockEmitDiagnostic, mockInvalidateRunnerSession, mockMarkRunnerXctestrunArtifactBadForRun, - mockStopRunnerSession, } = vi.hoisted(() => ({ mockEnsureRunnerSession: vi.fn(), mockExecuteRunnerCommandWithSession: vi.fn(), mockEmitDiagnostic: vi.fn(), mockInvalidateRunnerSession: vi.fn(), mockMarkRunnerXctestrunArtifactBadForRun: vi.fn(), - mockStopRunnerSession: vi.fn(), })); vi.mock('../../../utils/diagnostics.ts', async () => { @@ -38,7 +36,6 @@ vi.mock('../runner-session.ts', async () => { ensureRunnerSession: mockEnsureRunnerSession, executeRunnerCommandWithSession: mockExecuteRunnerCommandWithSession, invalidateRunnerSession: mockInvalidateRunnerSession, - stopRunnerSession: mockStopRunnerSession, }; }); @@ -129,6 +126,16 @@ test('prepareIosRunner marks a bad restored artifact and rebuilds once after hea ([event]) => event.phase === 'ios_runner_prepare_bad_cache_recovered', ), ); + assert.ok( + mockEmitDiagnostic.mock.calls.some( + ([event]) => + event.phase === 'apple_runner_prepare' && + event.data?.cache === 'miss' && + event.data?.artifact === 'rebuilt' && + event.data?.xctestrunPath === '/tmp/rebuilt.xctestrun' && + event.data?.failureReason === 'Runner did not accept connection', + ), + ); }); test('prepareIosRunner invalidates rebuilt sessions when bad-cache recovery health fails', async () => { @@ -201,7 +208,6 @@ test('mutating commands restart stale ready sessions when the preflight probe ne staleSession, 'runner_connect_failed_before_command_send', ]); - assert.equal(mockStopRunnerSession.mock.calls.length, 0); assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2); assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[0]?.[2].command, 'tap'); assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[1]?.[1], freshSession); @@ -225,7 +231,6 @@ test('mutating commands retry startup sessions with stale bundle cleanup', async startupSession, 'runner_connect_failed_before_command_send', ]); - assert.equal(mockStopRunnerSession.mock.calls.length, 0); assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2); assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[1]?.[1], freshSession); }); @@ -320,7 +325,6 @@ test('mutating commands do not restart or replay after command send failure', as session, 'transport_error_after_command_send', ]); - assert.equal(mockStopRunnerSession.mock.calls.length, 0); assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2); assertDiagnosticDecision({ decision: 'retained', diff --git a/src/platforms/ios/runner-lifecycle.ts b/src/platforms/ios/runner-lifecycle.ts index f8459a93b..51e0b72ef 100644 --- a/src/platforms/ios/runner-lifecycle.ts +++ b/src/platforms/ios/runner-lifecycle.ts @@ -8,7 +8,6 @@ import { type RunnerSession, ensureRunnerSession, invalidateRunnerSession, - stopIosRunnerSession, executeRunnerCommandWithSession, readRunnerStartupTimeoutMs, } from './runner-session.ts'; @@ -53,7 +52,10 @@ export async function prepareLocalIosRunner( const connectStartedAt = Date.now(); session = await ensureRunnerSession(device, options); const connectMs = Date.now() - connectStartedAt; - return await runPrepareHealthCheck(device, session, command, options, signal, connectMs); + return recordPrepareResult( + device, + await runPrepareHealthCheck(device, session, command, options, signal, connectMs), + ); } catch (err) { const appErr = err instanceof AppError ? err : new AppError('COMMAND_FAILED', String(err)); if (!session || !shouldRecoverBadCachedRunnerArtifact(appErr, session)) { @@ -90,10 +92,20 @@ export async function prepareLocalIosRunner( reason, }, }); - return recovered; + return recordPrepareResult(device, recovered); } catch (retryErr) { await invalidateRunnerSession(rebuiltSession, 'prepare_rebuilt_runner_health_failed'); - throw wrapPrepareHealthFailure(retryErr, rebuiltSession, reason); + const wrapped = wrapPrepareHealthFailure(retryErr, rebuiltSession, reason); + emitPrepareDiagnostic(device, { + cache: rebuiltSession.xctestrunArtifact?.cache, + artifact: rebuiltSession.xctestrunArtifact?.artifact, + buildMs: rebuiltSession.xctestrunArtifact?.buildMs, + connectMs, + healthCheckMs: 0, + xctestrunPath: rebuiltSession.xctestrunArtifact?.xctestrunPath, + failureReason: wrapped.message, + }); + throw wrapped; } } } @@ -130,86 +142,26 @@ export async function executeRunnerCommand( session ) { assertRunnerRequestActive(options.requestId); - await invalidateRunnerSession(session, 'runner_connect_failed_before_command_send'); - session = await ensureRunnerSession(device, { ...options, cleanStaleBundles: true }); - try { - return await executeRunnerCommandWithSession( - device, - session, - command, - options.logPath, - RUNNER_STARTUP_TIMEOUT_MS, - signal, - ); - } catch (retryErr) { - const retryAppErr = - retryErr instanceof AppError - ? retryErr - : new AppError('COMMAND_FAILED', String(retryErr)); - if (isRetryableRunnerError(retryAppErr)) { - return await handleRunnerTransportErrorAfterCommandSend({ - device, - session, - command, - transportError: retryAppErr, - options, - signal, - invalidationReason: 'transport_error_after_retry_command_send', - invalidateSession: invalidateRunnerSession, - }); - } - throw retryErr; - } + return await restartSessionAndRunCommand({ + device, + session, + command, + options, + signal, + restartReason: 'runner_connect_failed_before_command_send', + }); } if (session && shouldRestartAfterReadinessPreflightError(appErr)) { assertRunnerRequestActive(options.requestId); - await invalidateRunnerSession( + return await restartSessionAndRunCommand({ + device, session, - 'runner_readiness_preflight_failed_before_command_send', - ); - session = await ensureRunnerSession(device, { ...options, cleanStaleBundles: true }); - try { - const recovered = await executeRunnerCommandWithSession( - device, - session, - command, - options.logPath, - RUNNER_STARTUP_TIMEOUT_MS, - signal, - ); - emitDiagnostic({ - level: 'debug', - phase: 'ios_runner_readiness_preflight_recovered', - data: { - command: command.command, - commandId: command.commandId, - recovery: 'session_restarted', - sessionId: session.sessionId, - }, - }); - return recovered; - } catch (retryErr) { - const retryAppErr = - retryErr instanceof AppError - ? retryErr - : new AppError('COMMAND_FAILED', String(retryErr)); - if (isRetryableRunnerError(retryAppErr)) { - return await handleRunnerTransportErrorAfterCommandSend({ - device, - session, - command, - transportError: retryAppErr, - options, - signal, - invalidationReason: 'transport_error_after_retry_command_send', - invalidateSession: invalidateRunnerSession, - }); - } - throw retryErr; - } - } - if (!session && appErr.message.includes('Runner did not accept connection')) { - await stopIosRunnerSession(device.id); + command, + options, + signal, + restartReason: 'runner_readiness_preflight_failed_before_command_send', + recoveredDiagnosticPhase: 'ios_runner_readiness_preflight_recovered', + }); } if (session && isRetryableRunnerError(appErr)) { return await handleRunnerTransportErrorAfterCommandSend({ @@ -227,6 +179,64 @@ export async function executeRunnerCommand( } } +async function restartSessionAndRunCommand(params: { + device: DeviceInfo; + session: RunnerSession; + command: RunnerCommand; + options: AppleRunnerCommandOptions; + signal: AbortSignal | undefined; + restartReason: + | 'runner_connect_failed_before_command_send' + | 'runner_readiness_preflight_failed_before_command_send'; + recoveredDiagnosticPhase?: string; +}): Promise> { + const { device, command, options, signal, restartReason } = params; + await invalidateRunnerSession(params.session, restartReason); + const restartedSession = await ensureRunnerSession(device, { + ...options, + cleanStaleBundles: true, + }); + try { + const recovered = await executeRunnerCommandWithSession( + device, + restartedSession, + command, + options.logPath, + RUNNER_STARTUP_TIMEOUT_MS, + signal, + ); + if (params.recoveredDiagnosticPhase) { + emitDiagnostic({ + level: 'debug', + phase: params.recoveredDiagnosticPhase, + data: { + command: command.command, + commandId: command.commandId, + recovery: 'session_restarted', + sessionId: restartedSession.sessionId, + }, + }); + } + return recovered; + } catch (retryErr) { + const retryAppErr = + retryErr instanceof AppError ? retryErr : new AppError('COMMAND_FAILED', String(retryErr)); + if (isRetryableRunnerError(retryAppErr)) { + return await handleRunnerTransportErrorAfterCommandSend({ + device, + session: restartedSession, + command, + transportError: retryAppErr, + options, + signal, + invalidationReason: 'transport_error_after_retry_command_send', + invalidateSession: invalidateRunnerSession, + }); + } + throw retryErr; + } +} + async function runPrepareHealthCheck( device: DeviceInfo, session: RunnerSession, @@ -325,6 +335,36 @@ function buildPrepareIosRunnerResult( }; } +function recordPrepareResult( + device: DeviceInfo, + result: PrepareIosRunnerResult, +): PrepareIosRunnerResult { + emitPrepareDiagnostic(device, result); + return result; +} + +function emitPrepareDiagnostic( + device: DeviceInfo, + result: Omit, +): void { + emitDiagnostic({ + level: result.failureReason ? 'warn' : 'info', + phase: 'apple_runner_prepare', + data: { + platform: device.platform, + target: device.target, + deviceId: device.id, + cache: result.cache, + artifact: result.artifact, + buildMs: result.buildMs, + connectMs: result.connectMs, + healthCheckMs: result.healthCheckMs, + xctestrunPath: result.xctestrunPath, + failureReason: result.failureReason, + }, + }); +} + function isRunnerReadinessPreflightError(error: AppError): boolean { return error.details?.runnerReadinessPreflightFailed === true; } diff --git a/src/platforms/ios/runner-xctestrun.ts b/src/platforms/ios/runner-xctestrun.ts index 755793371..d8c4dadbc 100644 --- a/src/platforms/ios/runner-xctestrun.ts +++ b/src/platforms/ios/runner-xctestrun.ts @@ -40,6 +40,76 @@ const RUNNER_XCTESTRUN_CAPTURE_OPTIONS = { UserAttachmentLifetime: 'keepNever', } as const; +type RunnerApplePlatformName = 'iOS' | 'tvOS' | 'macOS'; +type RunnerPlatformProfile = { + sdkName: Record<'simulator' | 'device', string>; + derivedBaseName: Record<'simulator' | 'device', string>; + xctestrunHints: Record<'simulator' | 'device', { preferred: string[]; disallowed: string[] }>; +}; + +const RUNNER_PLATFORM_PROFILES: Record = { + iOS: { + sdkName: { + simulator: 'iphonesimulator', + device: 'iphoneos', + }, + derivedBaseName: { + simulator: 'ios-simulator', + device: 'ios-device', + }, + xctestrunHints: { + simulator: { + preferred: ['iphonesimulator'], + disallowed: ['iphoneos', 'appletvos', 'appletvsimulator', 'macos'], + }, + device: { + preferred: ['iphoneos'], + disallowed: ['iphonesimulator', 'appletvos', 'appletvsimulator', 'macos'], + }, + }, + }, + tvOS: { + sdkName: { + simulator: 'appletvsimulator', + device: 'appletvos', + }, + derivedBaseName: { + simulator: 'tvos-simulator', + device: 'tvos-device', + }, + xctestrunHints: { + simulator: { + preferred: ['appletvsimulator'], + disallowed: ['appletvos', 'iphoneos', 'iphonesimulator', 'macos'], + }, + device: { + preferred: ['appletvos'], + disallowed: ['appletvsimulator', 'iphoneos', 'iphonesimulator', 'macos'], + }, + }, + }, + macOS: { + sdkName: { + simulator: 'macosx', + device: 'macosx', + }, + derivedBaseName: { + simulator: 'macos', + device: 'macos', + }, + xctestrunHints: { + simulator: { + preferred: ['macos'], + disallowed: ['iphoneos', 'iphonesimulator', 'appletvos', 'appletvsimulator'], + }, + device: { + preferred: ['macos'], + disallowed: ['iphoneos', 'iphonesimulator', 'appletvos', 'appletvsimulator'], + }, + }, + }, +}; + const runnerXctestrunBuildLocks = new Map>(); const badRunnerArtifactsForRun = new Set(); const appleToolFingerprintCache = new Map(); @@ -826,14 +896,10 @@ function resolveRunnerToolchainFingerprint( } function resolveRunnerSdkName( - platformName: 'iOS' | 'tvOS' | 'macOS', + platformName: RunnerApplePlatformName, deviceKind: DeviceInfo['kind'], ): string { - if (platformName === 'macOS') return 'macosx'; - if (platformName === 'tvOS') { - return deviceKind === 'simulator' ? 'appletvsimulator' : 'appletvos'; - } - return deviceKind === 'simulator' ? 'iphonesimulator' : 'iphoneos'; + return RUNNER_PLATFORM_PROFILES[platformName].sdkName[runnerPlatformDeviceKind(deviceKind)]; } function runAppleToolFingerprintCommand(cmd: string, args: string[]): string { @@ -1215,37 +1281,7 @@ function resolveRunnerXctestrunHints(device: DeviceInfo): { preferred: string[]; disallowed: string[]; } { - if (device.platform === 'macos') { - return { - preferred: ['macos'], - disallowed: ['iphoneos', 'iphonesimulator', 'appletvos', 'appletvsimulator'], - }; - } - - if (device.target === 'tv') { - if (device.kind === 'simulator') { - return { - preferred: ['appletvsimulator'], - disallowed: ['appletvos', 'iphoneos', 'iphonesimulator', 'macos'], - }; - } - return { - preferred: ['appletvos'], - disallowed: ['appletvsimulator', 'iphoneos', 'iphonesimulator', 'macos'], - }; - } - - if (device.kind === 'simulator') { - return { - preferred: ['iphonesimulator'], - disallowed: ['iphoneos', 'appletvos', 'appletvsimulator', 'macos'], - }; - } - - return { - preferred: ['iphoneos'], - disallowed: ['iphonesimulator', 'appletvos', 'appletvsimulator', 'macos'], - }; + return resolveRunnerPlatformProfile(device).xctestrunHints[runnerPlatformDeviceKind(device.kind)]; } export function xctestrunReferencesProjectRoot( @@ -1486,20 +1522,12 @@ export function resolveRunnerDerivedPath( } function resolveRunnerDerivedBasePath(device: DeviceInfo): string { - if (device.platform === 'macos') { - return path.join(RUNNER_DERIVED_ROOT, 'derived', 'macos'); - } - if (device.target === 'tv') { - return path.join( - RUNNER_DERIVED_ROOT, - 'derived', - device.kind === 'simulator' ? 'tvos-simulator' : 'tvos-device', - ); - } - if (device.kind === 'simulator') { - return path.join(RUNNER_DERIVED_ROOT, 'derived', 'ios-simulator'); - } - return path.join(RUNNER_DERIVED_ROOT, 'derived', 'ios-device'); + const profile = resolveRunnerPlatformProfile(device); + return path.join( + RUNNER_DERIVED_ROOT, + 'derived', + profile.derivedBaseName[runnerPlatformDeviceKind(device.kind)], + ); } export function resolveRunnerDestination(device: DeviceInfo): string { @@ -1535,7 +1563,7 @@ function resolveRunnerBuildDestinationFamily(device: DeviceInfo): string { return `generic/platform=${platformName}`; } -function resolveRunnerPlatformName(device: DeviceInfo): 'iOS' | 'tvOS' | 'macOS' { +function resolveRunnerPlatformName(device: DeviceInfo): RunnerApplePlatformName { if (device.platform !== 'ios' && device.platform !== 'macos') { throw new AppError( 'UNSUPPORTED_PLATFORM', @@ -1548,6 +1576,14 @@ function resolveRunnerPlatformName(device: DeviceInfo): 'iOS' | 'tvOS' | 'macOS' return resolveApplePlatformName(device.target); } +function resolveRunnerPlatformProfile(device: DeviceInfo): RunnerPlatformProfile { + return RUNNER_PLATFORM_PROFILES[resolveRunnerPlatformName(device)]; +} + +function runnerPlatformDeviceKind(deviceKind: DeviceInfo['kind']): 'simulator' | 'device' { + return deviceKind === 'simulator' ? 'simulator' : 'device'; +} + function resolveMacRunnerArch(): 'arm64' | 'x86_64' { return process.arch === 'arm64' ? 'arm64' : 'x86_64'; } diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index 0ab267143..c74da8e02 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -905,7 +905,7 @@ test('usage includes concise top-level commands', () => { usageText, /install-from-source \| install-from-source --github-actions-artifact/, ); - assert.match(usageText, /prepare ios-runner --platform ios/); + assert.match(usageText, /prepare ios-runner --platform ios\|macos/); assert.match(usageText, /metro prepare --public-base-url /); assert.match(usageText, /batch --steps \| --steps-file /); assert.match(usageText, /network dump/); @@ -1039,7 +1039,7 @@ test('usageForCommand includes Maestro test suite flag', () => { test('usageForCommand documents prepare ios-runner', () => { const help = usageForCommand('prepare'); if (help === null) throw new Error('Expected prepare help text'); - assert.match(help, /Usage:\s+agent-device prepare ios-runner --platform ios/); + assert.match(help, /Usage:\s+agent-device prepare ios-runner --platform ios\|macos/); assert.match(help, /Prepare platform helper infrastructure/); assert.match(help, /--timeout /); assert.match(help, /XCTest runner/); @@ -1461,7 +1461,7 @@ test('usage includes swipe and press series options', () => { test('usage renders concise commands inline with descriptions', () => { const help = usage(); assert.match(help, /Commands:[\s\S]*\n boot\s{2,}Boot target device\/simulator/); - assert.match(help, / prepare ios-runner --platform ios\s{2,}Prepare platform helpers/); + assert.match(help, / prepare ios-runner --platform ios\|macos\s{2,}Prepare platform helpers/); assert.match( help, / metro prepare --public-base-url \| --proxy-base-url ; metro reload\s{2,}Prepare Metro or reload apps/, diff --git a/src/utils/cli-command-overrides.ts b/src/utils/cli-command-overrides.ts index a191b08a9..220973472 100644 --- a/src/utils/cli-command-overrides.ts +++ b/src/utils/cli-command-overrides.ts @@ -66,10 +66,10 @@ const CLI_COMMAND_OVERRIDES = { allowedFlags: ['headless'], }, prepare: { - usageOverride: 'prepare ios-runner --platform ios [--timeout ]', - listUsageOverride: 'prepare ios-runner --platform ios', + usageOverride: 'prepare ios-runner --platform ios|macos [--timeout ]', + listUsageOverride: 'prepare ios-runner --platform ios|macos', helpDescription: - 'Prepare platform helper infrastructure. ios-runner builds/reuses and starts the XCTest runner so later iOS snapshots and interactions do not pay first-use startup cost.', + 'Prepare platform helper infrastructure. ios-runner builds/reuses and starts the XCTest runner so later Apple snapshots and interactions do not pay first-use startup cost.', summary: 'Prepare platform helpers', positionalArgs: ['ios-runner'], allowedFlags: ['timeoutMs'], diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index 13a12df7e..b0fac6d59 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -122,7 +122,7 @@ Bootstrap: agent-device prepare ios-runner --platform ios --timeout 240000 If app id is unknown, plan devices, apps, then open . Discovery is not enough when the task asks to open/start the app. Install arguments are app/package id then artifact path. If the task says install, use install; use reinstall only when explicitly requested. Fresh runtime state is open --relaunch after install. - In iOS CI, run prepare ios-runner after boot/install and before replay/test. prepare ios-runner builds/reuses the XCTest runner and proves it can answer a lightweight command before the first snapshot pays that setup cost. + In Apple CI, run prepare ios-runner after boot/install and before replay/test. prepare ios-runner builds/reuses the XCTest runner and proves it can answer a lightweight command before the first snapshot pays that setup cost. Do not open artifact paths or invent package ids. If apps lookup misses the target and no URL/artifact is provided, ask or stop. Snapshots and refs: diff --git a/test/integration/provider-scenarios/macos-desktop.test.ts b/test/integration/provider-scenarios/macos-desktop.test.ts index 45ae33c8d..2f626b95f 100644 --- a/test/integration/provider-scenarios/macos-desktop.test.ts +++ b/test/integration/provider-scenarios/macos-desktop.test.ts @@ -2,400 +2,437 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import { test } from 'vitest'; import { assertFlatToolCall, assertPngFile } from './assertions.ts'; +import { PROVIDER_SCENARIO_MACOS } from './fixtures.ts'; import { createProviderScenarioTempPath, withProviderScenarioResource } from './harness.ts'; import { createMacOsDesktopWorld } from './macos-world.ts'; +import { createAppleRunnerProviderFromTranscript } from './providers.ts'; import { runProviderScenario } from './scenario.ts'; +import { createProviderTranscript } from './transcript.ts'; test('Provider-backed integration macOS desktop flow uses semantic host and helper providers', async () => { - await withProviderScenarioResource(createMacOsDesktopWorld, async ({ daemon, appleTool }) => { - const screenshotPath = createProviderScenarioTempPath( - 'agent-device-provider-scenario-macos', - 'png', - ); - try { - await runProviderScenario(daemon, [ - { - name: 'open settings app', - command: 'open', - positionals: ['settings'], - flags: { platform: 'macos' }, - }, - { - name: 'list user apps by default', - command: 'apps', - assert: (apps) => { - assert.deepEqual(apps.json?.result?.data?.apps, ['Demo (com.example.demo)']); + const runnerTranscript = createProviderTranscript([ + { + command: 'macos.runner.uptime', + deviceId: PROVIDER_SCENARIO_MACOS.id, + platform: 'macos', + request: { command: 'uptime' }, + result: { uptimeMs: 84 }, + }, + ]); + const appleRunnerProvider = createAppleRunnerProviderFromTranscript( + runnerTranscript, + 'macos.runner', + ); + await withProviderScenarioResource( + async () => await createMacOsDesktopWorld({ appleRunnerProvider }), + async ({ daemon, appleTool }) => { + const screenshotPath = createProviderScenarioTempPath( + 'agent-device-provider-scenario-macos', + 'png', + ); + try { + await runProviderScenario(daemon, [ + { + name: 'open settings app', + command: 'open', + positionals: ['settings'], + flags: { platform: 'macos' }, + }, + { + name: 'prepare macOS runner', + command: 'prepare', + positionals: ['ios-runner'], + flags: { platform: 'macos' }, + expectData: { + action: 'ios-runner', + platform: 'macos', + deviceId: PROVIDER_SCENARIO_MACOS.id, + runner: { uptimeMs: 84 }, + }, + }, + { + name: 'list user apps by default', + command: 'apps', + assert: (apps) => { + assert.deepEqual(apps.json?.result?.data?.apps, ['Demo (com.example.demo)']); + }, }, - }, - { - name: 'list all apps with flag', - command: 'apps', - flags: { appsFilter: 'all' }, - assert: (apps) => { - assert.deepEqual(apps.json?.result?.data?.apps, [ - 'Demo (com.example.demo)', - 'System Settings (com.apple.systempreferences)', - ]); + { + name: 'list all apps with flag', + command: 'apps', + flags: { appsFilter: 'all' }, + assert: (apps) => { + assert.deepEqual(apps.json?.result?.data?.apps, [ + 'Demo (com.example.demo)', + 'System Settings (com.apple.systempreferences)', + ]); + }, }, - }, - { - name: 'read app session state', - command: 'appstate', - expectData: { - platform: 'macos', - appName: 'settings', - appBundleId: 'com.apple.systempreferences', - source: 'session', - surface: 'app', + { + name: 'read app session state', + command: 'appstate', + expectData: { + platform: 'macos', + appName: 'settings', + appBundleId: 'com.apple.systempreferences', + source: 'session', + surface: 'app', + }, }, - }, - { - name: 'read logs path', - command: 'logs', - expectData: { active: false, backend: 'macos' }, - assert: (logsPath) => { - assert.equal(typeof logsPath.json?.result?.data?.path, 'string'); + { + name: 'read logs path', + command: 'logs', + expectData: { active: false, backend: 'macos' }, + assert: (logsPath) => { + assert.equal(typeof logsPath.json?.result?.data?.path, 'string'); + }, }, - }, - { - name: 'write clipboard', - command: 'clipboard', - positionals: ['write', 'desktop otp 123456'], - expectData: { textLength: 18 }, - }, - { - name: 'read clipboard', - command: 'clipboard', - positionals: ['read'], - expectData: { text: 'desktop otp 123456' }, - }, - { - name: 'set dark appearance', - command: 'settings', - positionals: ['appearance', 'dark'], - expectData: { setting: 'appearance', state: 'dark' }, - }, - { - name: 'grant accessibility permission through helper', - command: 'settings', - positionals: ['permission', 'grant', 'accessibility'], - expectData: { - action: 'grant', - target: 'accessibility', - granted: true, - requested: true, - openedSettings: false, + { + name: 'write clipboard', + command: 'clipboard', + positionals: ['write', 'desktop otp 123456'], + expectData: { textLength: 18 }, }, - }, - { - name: 'reset screen recording permission through helper', - command: 'settings', - positionals: ['permission', 'reset', 'screen-recording'], - expectData: { - action: 'reset', - target: 'screen-recording', - granted: false, - requested: true, - openedSettings: false, + { + name: 'read clipboard', + command: 'clipboard', + positionals: ['read'], + expectData: { text: 'desktop otp 123456' }, }, - }, - { - name: 'switch to frontmost desktop surface', - command: 'open', - flags: { - platform: 'macos', - surface: 'frontmost-app', + { + name: 'set dark appearance', + command: 'settings', + positionals: ['appearance', 'dark'], + expectData: { setting: 'appearance', state: 'dark' }, }, - expectData: { - surface: 'frontmost-app', - appBundleId: 'com.apple.systempreferences', + { + name: 'grant accessibility permission through helper', + command: 'settings', + positionals: ['permission', 'grant', 'accessibility'], + expectData: { + action: 'grant', + target: 'accessibility', + granted: true, + requested: true, + openedSettings: false, + }, }, - }, - { - name: 'read frontmost automation alert through helper', - command: 'alert', - positionals: ['get'], - expectData: { - title: 'System Events Wants to Control System Settings', - role: 'AXSheet', - action: 'get', - bundleId: 'com.apple.systempreferences', + { + name: 'reset screen recording permission through helper', + command: 'settings', + positionals: ['permission', 'reset', 'screen-recording'], + expectData: { + action: 'reset', + target: 'screen-recording', + granted: false, + requested: true, + openedSettings: false, + }, }, - }, - { - name: 'accept frontmost automation alert through helper', - command: 'alert', - positionals: ['accept'], - expectData: { - action: 'accept', - bundleId: 'com.apple.systempreferences', + { + name: 'switch to frontmost desktop surface', + command: 'open', + flags: { + platform: 'macos', + surface: 'frontmost-app', + }, + expectData: { + surface: 'frontmost-app', + appBundleId: 'com.apple.systempreferences', + }, }, - }, - { - name: 'dismiss frontmost automation alert through helper', - command: 'alert', - positionals: ['dismiss'], - expectData: { - action: 'dismiss', - bundleId: 'com.apple.systempreferences', + { + name: 'read frontmost automation alert through helper', + command: 'alert', + positionals: ['get'], + expectData: { + title: 'System Events Wants to Control System Settings', + role: 'AXSheet', + action: 'get', + bundleId: 'com.apple.systempreferences', + }, }, - }, - { - name: 'capture frontmost snapshot', - command: 'snapshot', - flags: { snapshotInteractiveOnly: true }, - assert: (snapshot) => { - const general = snapshot.json?.result?.data?.nodes?.find( - (node: { label?: string }) => node.label === 'General', - ); - assert.equal(general?.ref, 'e2', JSON.stringify(snapshot.json)); - assert.equal(daemon.session()?.snapshot?.backend, 'macos-helper'); - assert.equal(daemon.session()?.snapshot?.nodes[0]?.surface, 'frontmost-app'); + { + name: 'accept frontmost automation alert through helper', + command: 'alert', + positionals: ['accept'], + expectData: { + action: 'accept', + bundleId: 'com.apple.systempreferences', + }, }, - }, - { - name: 'read snapshot ref text through helper', - command: 'get', - positionals: ['text', '@e2'], - expectData: { text: 'System Settings General pane' }, - }, - { - name: 'press snapshot ref', - command: 'press', - positionals: ['@e2'], - expectData: { x: 116, y: 80 }, - }, - { - name: 'switch to desktop surface', - command: 'open', - flags: { - platform: 'macos', - surface: 'desktop', + { + name: 'dismiss frontmost automation alert through helper', + command: 'alert', + positionals: ['dismiss'], + expectData: { + action: 'dismiss', + bundleId: 'com.apple.systempreferences', + }, }, - expectData: { - surface: 'desktop', - appBundleId: undefined, + { + name: 'capture frontmost snapshot', + command: 'snapshot', + flags: { snapshotInteractiveOnly: true }, + assert: (snapshot) => { + const general = snapshot.json?.result?.data?.nodes?.find( + (node: { label?: string }) => node.label === 'General', + ); + assert.equal(general?.ref, 'e2', JSON.stringify(snapshot.json)); + assert.equal(daemon.session()?.snapshot?.backend, 'macos-helper'); + assert.equal(daemon.session()?.snapshot?.nodes[0]?.surface, 'frontmost-app'); + }, }, - }, - { - name: 'read desktop surface state', - command: 'appstate', - expectData: { - platform: 'macos', - appName: 'desktop', - appBundleId: undefined, - source: 'session', - surface: 'desktop', + { + name: 'read snapshot ref text through helper', + command: 'get', + positionals: ['text', '@e2'], + expectData: { text: 'System Settings General pane' }, }, - }, - { - name: 'capture fullscreen desktop screenshot with max-size', - command: 'screenshot', - flags: { - out: screenshotPath, - screenshotFullscreen: true, - screenshotMaxSize: 1, + { + name: 'press snapshot ref', + command: 'press', + positionals: ['@e2'], + expectData: { x: 116, y: 80 }, }, - expectData: { path: screenshotPath }, - assert: () => { - assertPngFile(screenshotPath); + { + name: 'switch to desktop surface', + command: 'open', + flags: { + platform: 'macos', + surface: 'desktop', + }, + expectData: { + surface: 'desktop', + appBundleId: undefined, + }, }, - }, - { - name: 'capture desktop surface snapshot', - command: 'snapshot', - assert: (snapshot) => { - assert.deepEqual( - snapshot.json?.result?.data?.nodes?.map((node: { label?: string }) => node.label), - ['Desktop', 'Notes', 'Notes', 'Pinned'], - ); - assert.equal(daemon.session()?.snapshot?.backend, 'macos-helper'); - assert.equal(daemon.session()?.snapshot?.nodes[0]?.surface, 'desktop'); + { + name: 'read desktop surface state', + command: 'appstate', + expectData: { + platform: 'macos', + appName: 'desktop', + appBundleId: undefined, + source: 'session', + surface: 'desktop', + }, }, - }, - { - name: 'wait for desktop surface text through helper snapshot polling', - command: 'wait', - positionals: ['text', 'Notes', '100'], - expectData: { text: 'Notes' }, - }, - { - name: 'scope desktop snapshot after helper capture', - command: 'snapshot', - flags: { snapshotScope: 'Notes', snapshotDepth: 0 }, - assert: (snapshot) => { - const nodes = snapshot.json?.result?.data?.nodes ?? []; - assert.equal(nodes.length, 1, JSON.stringify(snapshot.json)); - assert.equal(nodes[0]?.label, 'Notes', JSON.stringify(snapshot.json)); - assert.equal(nodes[0]?.depth, 0, JSON.stringify(snapshot.json)); - assert.equal(nodes[0]?.parentIndex, undefined, JSON.stringify(snapshot.json)); - assert.equal(daemon.session()?.snapshot?.backend, 'macos-helper'); + { + name: 'capture fullscreen desktop screenshot with max-size', + command: 'screenshot', + flags: { + out: screenshotPath, + screenshotFullscreen: true, + screenshotMaxSize: 1, + }, + expectData: { path: screenshotPath }, + assert: () => { + assertPngFile(screenshotPath); + }, }, - }, - { - name: 'switch to menubar surface', - command: 'open', - flags: { - platform: 'macos', - surface: 'menubar', + { + name: 'capture desktop surface snapshot', + command: 'snapshot', + assert: (snapshot) => { + assert.deepEqual( + snapshot.json?.result?.data?.nodes?.map((node: { label?: string }) => node.label), + ['Desktop', 'Notes', 'Notes', 'Pinned'], + ); + assert.equal(daemon.session()?.snapshot?.backend, 'macos-helper'); + assert.equal(daemon.session()?.snapshot?.nodes[0]?.surface, 'desktop'); + }, }, - expectData: { - surface: 'menubar', - appBundleId: undefined, + { + name: 'wait for desktop surface text through helper snapshot polling', + command: 'wait', + positionals: ['text', 'Notes', '100'], + expectData: { text: 'Notes' }, }, - }, - { - name: 'capture menubar surface snapshot', - command: 'snapshot', - assert: (snapshot) => { - assert.deepEqual( - snapshot.json?.result?.data?.nodes?.map((node: { label?: string }) => node.label), - ['Menu Bar', 'File'], - ); - assert.equal(daemon.session()?.snapshot?.backend, 'macos-helper'); - assert.equal(daemon.session()?.snapshot?.nodes[0]?.surface, 'menubar'); + { + name: 'scope desktop snapshot after helper capture', + command: 'snapshot', + flags: { snapshotScope: 'Notes', snapshotDepth: 0 }, + assert: (snapshot) => { + const nodes = snapshot.json?.result?.data?.nodes ?? []; + assert.equal(nodes.length, 1, JSON.stringify(snapshot.json)); + assert.equal(nodes[0]?.label, 'Notes', JSON.stringify(snapshot.json)); + assert.equal(nodes[0]?.depth, 0, JSON.stringify(snapshot.json)); + assert.equal(nodes[0]?.parentIndex, undefined, JSON.stringify(snapshot.json)); + assert.equal(daemon.session()?.snapshot?.backend, 'macos-helper'); + }, }, - }, - { - name: 'click menubar coordinates through helper-backed press path', - command: 'click', - positionals: ['100', '200'], - expectData: { x: 100, y: 200 }, - }, - { - name: 'switch to Demo menubar app surface', - command: 'open', - positionals: ['Demo'], - flags: { - platform: 'macos', - surface: 'menubar', + { + name: 'switch to menubar surface', + command: 'open', + flags: { + platform: 'macos', + surface: 'menubar', + }, + expectData: { + surface: 'menubar', + appBundleId: undefined, + }, }, - expectData: { - surface: 'menubar', - appName: 'Demo', - appBundleId: 'com.example.demo', + { + name: 'capture menubar surface snapshot', + command: 'snapshot', + assert: (snapshot) => { + assert.deepEqual( + snapshot.json?.result?.data?.nodes?.map((node: { label?: string }) => node.label), + ['Menu Bar', 'File'], + ); + assert.equal(daemon.session()?.snapshot?.backend, 'macos-helper'); + assert.equal(daemon.session()?.snapshot?.nodes[0]?.surface, 'menubar'); + }, }, - }, - { - name: 'capture targeted menubar surface snapshot', - command: 'snapshot', - assert: (snapshot) => { - assert.deepEqual( - snapshot.json?.result?.data?.nodes?.map((node: { label?: string }) => node.label), - ['Menu Bar', 'Demo'], - ); - assert.equal(daemon.session()?.snapshot?.backend, 'macos-helper'); - assert.equal(daemon.session()?.snapshot?.nodes[1]?.bundleId, 'com.example.demo'); + { + name: 'click menubar coordinates through helper-backed press path', + command: 'click', + positionals: ['100', '200'], + expectData: { x: 100, y: 200 }, }, - }, - ]); + { + name: 'switch to Demo menubar app surface', + command: 'open', + positionals: ['Demo'], + flags: { + platform: 'macos', + surface: 'menubar', + }, + expectData: { + surface: 'menubar', + appName: 'Demo', + appBundleId: 'com.example.demo', + }, + }, + { + name: 'capture targeted menubar surface snapshot', + command: 'snapshot', + assert: (snapshot) => { + assert.deepEqual( + snapshot.json?.result?.data?.nodes?.map((node: { label?: string }) => node.label), + ['Menu Bar', 'Demo'], + ); + assert.equal(daemon.session()?.snapshot?.backend, 'macos-helper'); + assert.equal(daemon.session()?.snapshot?.nodes[1]?.bundleId, 'com.example.demo'); + }, + }, + ]); - assertFlatToolCall(appleTool.calls, [ - 'macos-host', - 'openBundle', - 'com.apple.systempreferences', - ]); - assertFlatToolCall(appleTool.calls, ['macos-host', 'listApps', 'all']); - assertFlatToolCall(appleTool.calls, ['macos-host', 'writeClipboard', 'desktop otp 123456']); - assertFlatToolCall(appleTool.calls, ['macos-host', 'readClipboard']); - assertFlatToolCall(appleTool.calls, ['macos-host', 'setDarkMode', 'true']); - assertFlatToolCall(appleTool.calls, ['macos-helper', 'permission', 'grant', 'accessibility']); - assertFlatToolCall(appleTool.calls, [ - 'macos-helper', - 'permission', - 'reset', - 'screen-recording', - ]); - assertFlatToolCall(appleTool.calls, ['macos-helper', 'app', 'frontmost']); - assertFlatToolCall(appleTool.calls, [ - 'macos-helper', - 'alert', - 'get', - '--surface', - 'frontmost-app', - ]); - assertFlatToolCall(appleTool.calls, [ - 'macos-helper', - 'alert', - 'accept', - '--surface', - 'frontmost-app', - ]); - assertFlatToolCall(appleTool.calls, [ - 'macos-helper', - 'alert', - 'dismiss', - '--surface', - 'frontmost-app', - ]); - assertFlatToolCall(appleTool.calls, [ - 'macos-helper', - 'snapshot', - '--surface', - 'frontmost-app', - ]); - assertFlatToolCall(appleTool.calls, ['macos-helper', 'snapshot', '--surface', 'desktop']); - assert.ok( - appleTool.calls.filter( - (call) => call.join('\0') === 'macos-helper\0snapshot\0--surface\0desktop', - ).length >= 2, - 'Expected desktop snapshot to be used by both snapshot and wait workflows', - ); - assertFlatToolCall(appleTool.calls, ['macos-helper', 'snapshot', '--surface', 'menubar']); - assertFlatToolCall(appleTool.calls, [ - 'macos-helper', - 'screenshot', - '--out', - screenshotPath, - '--surface', - 'desktop', - '--fullscreen', - ]); - assertFlatToolCall(appleTool.calls, [ - 'macos-helper', - 'read', - '--x', - '116', - '--y', - '80', - '--bundle-id', - 'com.apple.systempreferences', - '--surface', - 'frontmost-app', - ]); - assertFlatToolCall(appleTool.calls, [ - 'macos-helper', - 'press', - '--x', - '116', - '--y', - '80', - '--bundle-id', - 'com.apple.systempreferences', - '--surface', - 'frontmost-app', - ]); - assertFlatToolCall(appleTool.calls, [ - 'macos-helper', - 'press', - '--x', - '100', - '--y', - '200', - '--surface', - 'menubar', - ]); - assertFlatToolCall(appleTool.calls, ['macos-host', 'openBundle', 'com.example.demo']); - assertFlatToolCall(appleTool.calls, [ - 'macos-helper', - 'snapshot', - '--surface', - 'menubar', - '--bundle-id', - 'com.example.demo', - ]); - } finally { - fs.rmSync(screenshotPath, { force: true }); - } - }); + assertFlatToolCall(appleTool.calls, [ + 'macos-host', + 'openBundle', + 'com.apple.systempreferences', + ]); + assertFlatToolCall(appleTool.calls, ['macos-host', 'listApps', 'all']); + assertFlatToolCall(appleTool.calls, ['macos-host', 'writeClipboard', 'desktop otp 123456']); + assertFlatToolCall(appleTool.calls, ['macos-host', 'readClipboard']); + assertFlatToolCall(appleTool.calls, ['macos-host', 'setDarkMode', 'true']); + assertFlatToolCall(appleTool.calls, [ + 'macos-helper', + 'permission', + 'grant', + 'accessibility', + ]); + assertFlatToolCall(appleTool.calls, [ + 'macos-helper', + 'permission', + 'reset', + 'screen-recording', + ]); + assertFlatToolCall(appleTool.calls, ['macos-helper', 'app', 'frontmost']); + assertFlatToolCall(appleTool.calls, [ + 'macos-helper', + 'alert', + 'get', + '--surface', + 'frontmost-app', + ]); + assertFlatToolCall(appleTool.calls, [ + 'macos-helper', + 'alert', + 'accept', + '--surface', + 'frontmost-app', + ]); + assertFlatToolCall(appleTool.calls, [ + 'macos-helper', + 'alert', + 'dismiss', + '--surface', + 'frontmost-app', + ]); + assertFlatToolCall(appleTool.calls, [ + 'macos-helper', + 'snapshot', + '--surface', + 'frontmost-app', + ]); + assertFlatToolCall(appleTool.calls, ['macos-helper', 'snapshot', '--surface', 'desktop']); + assert.ok( + appleTool.calls.filter( + (call) => call.join('\0') === 'macos-helper\0snapshot\0--surface\0desktop', + ).length >= 2, + 'Expected desktop snapshot to be used by both snapshot and wait workflows', + ); + assertFlatToolCall(appleTool.calls, ['macos-helper', 'snapshot', '--surface', 'menubar']); + assertFlatToolCall(appleTool.calls, [ + 'macos-helper', + 'screenshot', + '--out', + screenshotPath, + '--surface', + 'desktop', + '--fullscreen', + ]); + assertFlatToolCall(appleTool.calls, [ + 'macos-helper', + 'read', + '--x', + '116', + '--y', + '80', + '--bundle-id', + 'com.apple.systempreferences', + '--surface', + 'frontmost-app', + ]); + assertFlatToolCall(appleTool.calls, [ + 'macos-helper', + 'press', + '--x', + '116', + '--y', + '80', + '--bundle-id', + 'com.apple.systempreferences', + '--surface', + 'frontmost-app', + ]); + assertFlatToolCall(appleTool.calls, [ + 'macos-helper', + 'press', + '--x', + '100', + '--y', + '200', + '--surface', + 'menubar', + ]); + assertFlatToolCall(appleTool.calls, ['macos-host', 'openBundle', 'com.example.demo']); + assertFlatToolCall(appleTool.calls, [ + 'macos-helper', + 'snapshot', + '--surface', + 'menubar', + '--bundle-id', + 'com.example.demo', + ]); + } finally { + fs.rmSync(screenshotPath, { force: true }); + } + runnerTranscript.assertComplete(); + }, + ); }); From 0893d71565f3a62c59d36e67f938efbcf4785dfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 4 Jun 2026 10:48:07 -0700 Subject: [PATCH 4/7] refactor: simplify apple runner lifecycle --- src/platforms/ios/apple-runner-platform.ts | 149 ++++++++++++++++ src/platforms/ios/runner-client.ts | 59 ++++--- src/platforms/ios/runner-lifecycle.ts | 28 +-- src/platforms/ios/runner-provider.ts | 45 ++++- src/platforms/ios/runner-xctestrun.ts | 163 ++---------------- .../provider-scenarios/macos-desktop.test.ts | 45 +++++ 6 files changed, 294 insertions(+), 195 deletions(-) create mode 100644 src/platforms/ios/apple-runner-platform.ts diff --git a/src/platforms/ios/apple-runner-platform.ts b/src/platforms/ios/apple-runner-platform.ts new file mode 100644 index 000000000..88f2a7c57 --- /dev/null +++ b/src/platforms/ios/apple-runner-platform.ts @@ -0,0 +1,149 @@ +import { AppError } from '../../utils/errors.ts'; +import { resolveApplePlatformName, type DeviceInfo } from '../../utils/device.ts'; + +export type RunnerApplePlatformName = 'iOS' | 'tvOS' | 'macOS'; + +type RunnerPlatformDeviceKind = 'simulator' | 'device'; + +type RunnerPlatformProfile = { + sdkName: Record; + derivedBaseName: Record; + xctestrunHints: Record; +}; + +const RUNNER_PLATFORM_PROFILES: Record = { + iOS: { + sdkName: { + simulator: 'iphonesimulator', + device: 'iphoneos', + }, + derivedBaseName: { + simulator: 'ios-simulator', + device: 'ios-device', + }, + xctestrunHints: { + simulator: { + preferred: ['iphonesimulator'], + disallowed: ['iphoneos', 'appletvos', 'appletvsimulator', 'macos'], + }, + device: { + preferred: ['iphoneos'], + disallowed: ['iphonesimulator', 'appletvos', 'appletvsimulator', 'macos'], + }, + }, + }, + tvOS: { + sdkName: { + simulator: 'appletvsimulator', + device: 'appletvos', + }, + derivedBaseName: { + simulator: 'tvos-simulator', + device: 'tvos-device', + }, + xctestrunHints: { + simulator: { + preferred: ['appletvsimulator'], + disallowed: ['appletvos', 'iphoneos', 'iphonesimulator', 'macos'], + }, + device: { + preferred: ['appletvos'], + disallowed: ['appletvsimulator', 'iphoneos', 'iphonesimulator', 'macos'], + }, + }, + }, + macOS: { + sdkName: { + simulator: 'macosx', + device: 'macosx', + }, + derivedBaseName: { + simulator: 'macos', + device: 'macos', + }, + xctestrunHints: { + simulator: { + preferred: ['macos'], + disallowed: ['iphoneos', 'iphonesimulator', 'appletvos', 'appletvsimulator'], + }, + device: { + preferred: ['macos'], + disallowed: ['iphoneos', 'iphonesimulator', 'appletvos', 'appletvsimulator'], + }, + }, + }, +}; + +export function resolveRunnerPlatformName(device: DeviceInfo): RunnerApplePlatformName { + if (device.platform !== 'ios' && device.platform !== 'macos') { + throw new AppError( + 'UNSUPPORTED_PLATFORM', + `Unsupported platform for Apple runner: ${device.platform}`, + ); + } + if (device.platform === 'macos') { + return 'macOS'; + } + return resolveApplePlatformName(device.target); +} + +export function resolveRunnerSdkName( + platformName: RunnerApplePlatformName, + deviceKind: DeviceInfo['kind'], +): string { + return RUNNER_PLATFORM_PROFILES[platformName].sdkName[runnerPlatformDeviceKind(deviceKind)]; +} + +export function resolveRunnerDerivedBaseName(device: DeviceInfo): string { + const profile = RUNNER_PLATFORM_PROFILES[resolveRunnerPlatformName(device)]; + return profile.derivedBaseName[runnerPlatformDeviceKind(device.kind)]; +} + +export function resolveRunnerXctestrunHints(device: DeviceInfo): { + preferred: string[]; + disallowed: string[]; +} { + const profile = RUNNER_PLATFORM_PROFILES[resolveRunnerPlatformName(device)]; + return profile.xctestrunHints[runnerPlatformDeviceKind(device.kind)]; +} + +export function resolveRunnerDestination(device: DeviceInfo): string { + const platformName = resolveRunnerPlatformName(device); + if (platformName === 'macOS') { + return `platform=macOS,arch=${resolveMacRunnerArch()}`; + } + if (device.kind === 'simulator') { + return `platform=${platformName} Simulator,id=${device.id}`; + } + return `platform=${platformName},id=${device.id}`; +} + +export function resolveRunnerBuildDestination(device: DeviceInfo): string { + const platformName = resolveRunnerPlatformName(device); + if (platformName === 'macOS') { + return `platform=macOS,arch=${resolveMacRunnerArch()}`; + } + if (device.kind === 'simulator') { + return `platform=${platformName} Simulator,id=${device.id}`; + } + return `generic/platform=${platformName}`; +} + +export function resolveRunnerBuildDestinationFamily(device: DeviceInfo): string { + const platformName = resolveRunnerPlatformName(device); + if (platformName === 'macOS') { + return `platform=macOS,arch=${resolveMacRunnerArch()}`; + } + if (device.kind === 'simulator') { + return `generic/platform=${platformName} Simulator`; + } + return `generic/platform=${platformName}`; +} + +function runnerPlatformDeviceKind(deviceKind: DeviceInfo['kind']): RunnerPlatformDeviceKind { + return deviceKind === 'simulator' ? 'simulator' : 'device'; +} + +function resolveMacRunnerArch(): 'arm64' | 'x86_64' { + return process.arch === 'arm64' ? 'arm64' : 'x86_64'; +} diff --git a/src/platforms/ios/runner-client.ts b/src/platforms/ios/runner-client.ts index f5e41a5fe..50c566c3f 100644 --- a/src/platforms/ios/runner-client.ts +++ b/src/platforms/ios/runner-client.ts @@ -15,9 +15,9 @@ import { } from './runner-contract.ts'; import { createLocalAppleRunnerProvider, - hasScopedAppleRunnerProvider, resolveAppleRunnerProvider, type AppleRunnerCommandOptions, + type AppleRunnerProvider, } from './runner-provider.ts'; import { executeRunnerCommand, @@ -44,12 +44,7 @@ export async function runIosRunnerCommand( validateRunnerDevice(device); assertRunnerRequestActive(options.requestId); const runnerCommand = withRunnerCommandId(command); - const provider = resolveAppleRunnerProvider( - device, - createLocalAppleRunnerProvider(executeRunnerCommand), - undefined, - { requestId: options.requestId }, - ); + const provider = resolveAppleRunnerRuntime(device, options); if (isReadOnlyRunnerCommand(runnerCommand.command)) { return withRetry( () => { @@ -74,15 +69,17 @@ export function prewarmIosRunnerSession( if (device.platform !== 'ios') { return undefined; } - if (hasScopedAppleRunnerProvider(device, { requestId: options.requestId })) { + const provider = resolveAppleRunnerRuntime(device, options); + if (!provider.prewarm) { emitDiagnostic({ level: 'debug', - phase: 'ios_runner_session_prewarm_skipped_scoped_provider', + phase: 'ios_runner_session_prewarm_unavailable', data: { deviceId: device.id }, }); return undefined; } - const prewarm = ensureRunnerSession(device, options) + const prewarm = provider + .prewarm(device, options) .then(() => {}) .catch((error: unknown) => { emitDiagnostic({ @@ -105,24 +102,36 @@ export async function prepareIosRunner( validateRunnerDevice(device); assertRunnerRequestActive(options.requestId); const command = withRunnerCommandId({ command: 'uptime' }); - if (hasScopedAppleRunnerProvider(device, { requestId: options.requestId })) { - const provider = resolveAppleRunnerProvider( - device, - createLocalAppleRunnerProvider(executeRunnerCommand), - undefined, - { requestId: options.requestId }, - ); - const healthStartedAt = Date.now(); - const runner = await provider.runCommand(device, command, options); - return { - runner, - connectMs: 0, - healthCheckMs: Math.max(0, Date.now() - healthStartedAt), - }; + const provider = resolveAppleRunnerRuntime(device, options); + if (provider.prepare) { + return await provider.prepare(device, options); } - return await prepareLocalIosRunner(device, options); + + const healthStartedAt = Date.now(); + const runner = await provider.runCommand(device, command, options); + return { + runner, + connectMs: 0, + healthCheckMs: Math.max(0, Date.now() - healthStartedAt), + }; +} + +function resolveAppleRunnerRuntime( + device: DeviceInfo, + options: { requestId?: string }, +): AppleRunnerProvider { + return resolveAppleRunnerProvider(device, LOCAL_APPLE_RUNNER_RUNTIME, undefined, { + requestId: options.requestId, + }); } +const LOCAL_APPLE_RUNNER_RUNTIME = createLocalAppleRunnerProvider(executeRunnerCommand, { + prepare: prepareLocalIosRunner, + prewarm: async (device, options) => { + await ensureRunnerSession(device, options); + }, +}); + export { resolveRunnerDestination, resolveRunnerBuildDestination, diff --git a/src/platforms/ios/runner-lifecycle.ts b/src/platforms/ios/runner-lifecycle.ts index 51e0b72ef..aed3c9378 100644 --- a/src/platforms/ios/runner-lifecycle.ts +++ b/src/platforms/ios/runner-lifecycle.ts @@ -4,7 +4,6 @@ import { emitDiagnostic } from '../../utils/diagnostics.ts'; import { getRequestSignal } from '../../daemon/request-cancel.ts'; import { RUNNER_COMMAND_TIMEOUT_MS, RUNNER_STARTUP_TIMEOUT_MS } from './runner-transport.ts'; import { - type RunnerSessionOptions, type RunnerSession, ensureRunnerSession, invalidateRunnerSession, @@ -18,27 +17,16 @@ import { withRunnerCommandId, type RunnerCommand, } from './runner-contract.ts'; -import type { AppleRunnerCommandOptions } from './runner-provider.ts'; -import { - markRunnerXctestrunArtifactBadForRun, - type RunnerXctestrunArtifact, -} from './runner-xctestrun.ts'; +import type { + AppleRunnerCommandOptions, + AppleRunnerPrepareOptions, + AppleRunnerPrepareResult, +} from './runner-provider.ts'; +import { markRunnerXctestrunArtifactBadForRun } from './runner-xctestrun.ts'; import { handleRunnerTransportErrorAfterCommandSend } from './runner-command-recovery.ts'; -export type PrepareIosRunnerOptions = RunnerSessionOptions & { - healthTimeoutMs: number; -}; - -export type PrepareIosRunnerResult = { - runner: Record; - cache?: RunnerXctestrunArtifact['cache']; - artifact?: RunnerXctestrunArtifact['artifact']; - buildMs?: number; - connectMs: number; - healthCheckMs: number; - xctestrunPath?: string; - failureReason?: string; -}; +export type PrepareIosRunnerOptions = AppleRunnerPrepareOptions; +export type PrepareIosRunnerResult = AppleRunnerPrepareResult; export async function prepareLocalIosRunner( device: DeviceInfo, diff --git a/src/platforms/ios/runner-provider.ts b/src/platforms/ios/runner-provider.ts index 8c23b250b..189441ea2 100644 --- a/src/platforms/ios/runner-provider.ts +++ b/src/platforms/ios/runner-provider.ts @@ -11,18 +11,60 @@ export type AppleRunnerCommandOptions = { requestId?: string; }; +export type AppleRunnerLifecycleOptions = AppleRunnerCommandOptions & { + cleanStaleBundles?: boolean; + buildTimeoutMs?: number; + forceRunnerXctestrunRebuild?: boolean; +}; + +export type AppleRunnerPrewarmOptions = AppleRunnerLifecycleOptions; + +export type AppleRunnerPrepareOptions = AppleRunnerLifecycleOptions & { + healthTimeoutMs: number; +}; + +export type AppleRunnerPrepareResult = { + runner: Record; + cache?: 'exact' | 'restore-key' | 'miss'; + artifact?: 'valid' | 'rebuilt'; + buildMs?: number; + connectMs: number; + healthCheckMs: number; + xctestrunPath?: string; + failureReason?: string; +}; + export type AppleRunnerCommandExecutor = ( device: DeviceInfo, command: RunnerCommand, options: AppleRunnerCommandOptions, ) => Promise>; +export type AppleRunnerPrepareExecutor = ( + device: DeviceInfo, + options: AppleRunnerPrepareOptions, +) => Promise; + +export type AppleRunnerPrewarmExecutor = ( + device: DeviceInfo, + options: AppleRunnerPrewarmOptions, +) => Promise; + export type AppleRunnerProvider = { /** * Executes a runner protocol command for an already resolved Apple target. * Scoped providers may adapt this call to a request-local transport. */ runCommand: AppleRunnerCommandExecutor; + /** + * Proves a runner can answer a cheap command after any required local setup. + * Command-only providers may omit this and let callers fall back to uptime. + */ + prepare?: AppleRunnerPrepareExecutor; + /** + * Starts runner setup opportunistically. This must remain best-effort. + */ + prewarm?: AppleRunnerPrewarmExecutor; }; export type AppleRunnerProviderScopeOptions = { @@ -40,8 +82,9 @@ const appleRunnerProviderScope = new AsyncLocalStorage export function createLocalAppleRunnerProvider( runCommand: AppleRunnerCommandExecutor, + lifecycle: Pick = {}, ): AppleRunnerProvider { - return { runCommand }; + return { runCommand, ...lifecycle }; } export function resolveAppleRunnerProvider( diff --git a/src/platforms/ios/runner-xctestrun.ts b/src/platforms/ios/runner-xctestrun.ts index d8c4dadbc..d2775af50 100644 --- a/src/platforms/ios/runner-xctestrun.ts +++ b/src/platforms/ios/runner-xctestrun.ts @@ -8,7 +8,7 @@ import { runCmdStreaming, runCmdSync, type ExecBackgroundResult } from '../../ut import { resolveIosSimulatorDeviceSetPath } from '../../utils/device-isolation.ts'; import { isProcessAlive, readProcessStartTime } from '../../utils/process-identity.ts'; import { isEnvTruthy } from '../../utils/retry.ts'; -import { resolveApplePlatformName, type DeviceInfo } from '../../utils/device.ts'; +import type { DeviceInfo } from '../../utils/device.ts'; import { withKeyedLock } from '../../utils/keyed-lock.ts'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; import { findProjectRoot, readVersion } from '../../utils/version.ts'; @@ -21,6 +21,18 @@ import { } from './runner-macos-products.ts'; import { resolveExistingXctestrunProductPaths } from './runner-xctestrun-products.ts'; import { applyXctestRunnerAppIcon } from './runner-icon.ts'; +import { + resolveRunnerBuildDestination, + resolveRunnerBuildDestinationFamily, + resolveRunnerDerivedBaseName, + resolveRunnerPlatformName, + resolveRunnerSdkName, + resolveRunnerXctestrunHints, +} from './apple-runner-platform.ts'; +export { + resolveRunnerBuildDestination, + resolveRunnerDestination, +} from './apple-runner-platform.ts'; const DEFAULT_IOS_RUNNER_APP_BUNDLE_ID = 'com.callstack.agentdevice.runner'; const XCTEST_DEVICE_SET_BASE_NAME = 'XCTestDevices'; @@ -40,76 +52,6 @@ const RUNNER_XCTESTRUN_CAPTURE_OPTIONS = { UserAttachmentLifetime: 'keepNever', } as const; -type RunnerApplePlatformName = 'iOS' | 'tvOS' | 'macOS'; -type RunnerPlatformProfile = { - sdkName: Record<'simulator' | 'device', string>; - derivedBaseName: Record<'simulator' | 'device', string>; - xctestrunHints: Record<'simulator' | 'device', { preferred: string[]; disallowed: string[] }>; -}; - -const RUNNER_PLATFORM_PROFILES: Record = { - iOS: { - sdkName: { - simulator: 'iphonesimulator', - device: 'iphoneos', - }, - derivedBaseName: { - simulator: 'ios-simulator', - device: 'ios-device', - }, - xctestrunHints: { - simulator: { - preferred: ['iphonesimulator'], - disallowed: ['iphoneos', 'appletvos', 'appletvsimulator', 'macos'], - }, - device: { - preferred: ['iphoneos'], - disallowed: ['iphonesimulator', 'appletvos', 'appletvsimulator', 'macos'], - }, - }, - }, - tvOS: { - sdkName: { - simulator: 'appletvsimulator', - device: 'appletvos', - }, - derivedBaseName: { - simulator: 'tvos-simulator', - device: 'tvos-device', - }, - xctestrunHints: { - simulator: { - preferred: ['appletvsimulator'], - disallowed: ['appletvos', 'iphoneos', 'iphonesimulator', 'macos'], - }, - device: { - preferred: ['appletvos'], - disallowed: ['appletvsimulator', 'iphoneos', 'iphonesimulator', 'macos'], - }, - }, - }, - macOS: { - sdkName: { - simulator: 'macosx', - device: 'macosx', - }, - derivedBaseName: { - simulator: 'macos', - device: 'macos', - }, - xctestrunHints: { - simulator: { - preferred: ['macos'], - disallowed: ['iphoneos', 'iphonesimulator', 'appletvos', 'appletvsimulator'], - }, - device: { - preferred: ['macos'], - disallowed: ['iphoneos', 'iphonesimulator', 'appletvos', 'appletvsimulator'], - }, - }, - }, -}; - const runnerXctestrunBuildLocks = new Map>(); const badRunnerArtifactsForRun = new Set(); const appleToolFingerprintCache = new Map(); @@ -895,13 +837,6 @@ function resolveRunnerToolchainFingerprint( }; } -function resolveRunnerSdkName( - platformName: RunnerApplePlatformName, - deviceKind: DeviceInfo['kind'], -): string { - return RUNNER_PLATFORM_PROFILES[platformName].sdkName[runnerPlatformDeviceKind(deviceKind)]; -} - function runAppleToolFingerprintCommand(cmd: string, args: string[]): string { const cacheKey = JSON.stringify([cmd, args]); const cached = appleToolFingerprintCache.get(cacheKey); @@ -1277,13 +1212,6 @@ export function scoreXctestrunCandidate(candidatePath: string, device: DeviceInf return score; } -function resolveRunnerXctestrunHints(device: DeviceInfo): { - preferred: string[]; - disallowed: string[]; -} { - return resolveRunnerPlatformProfile(device).xctestrunHints[runnerPlatformDeviceKind(device.kind)]; -} - export function xctestrunReferencesProjectRoot( xctestrunPath: string, projectRoot: string, @@ -1522,70 +1450,7 @@ export function resolveRunnerDerivedPath( } function resolveRunnerDerivedBasePath(device: DeviceInfo): string { - const profile = resolveRunnerPlatformProfile(device); - return path.join( - RUNNER_DERIVED_ROOT, - 'derived', - profile.derivedBaseName[runnerPlatformDeviceKind(device.kind)], - ); -} - -export function resolveRunnerDestination(device: DeviceInfo): string { - const platformName = resolveRunnerPlatformName(device); - if (platformName === 'macOS') { - return `platform=macOS,arch=${resolveMacRunnerArch()}`; - } - if (device.kind === 'simulator') { - return `platform=${platformName} Simulator,id=${device.id}`; - } - return `platform=${platformName},id=${device.id}`; -} - -export function resolveRunnerBuildDestination(device: DeviceInfo): string { - const platformName = resolveRunnerPlatformName(device); - if (platformName === 'macOS') { - return `platform=macOS,arch=${resolveMacRunnerArch()}`; - } - if (device.kind === 'simulator') { - return `platform=${platformName} Simulator,id=${device.id}`; - } - return `generic/platform=${platformName}`; -} - -function resolveRunnerBuildDestinationFamily(device: DeviceInfo): string { - const platformName = resolveRunnerPlatformName(device); - if (platformName === 'macOS') { - return `platform=macOS,arch=${resolveMacRunnerArch()}`; - } - if (device.kind === 'simulator') { - return `generic/platform=${platformName} Simulator`; - } - return `generic/platform=${platformName}`; -} - -function resolveRunnerPlatformName(device: DeviceInfo): RunnerApplePlatformName { - if (device.platform !== 'ios' && device.platform !== 'macos') { - throw new AppError( - 'UNSUPPORTED_PLATFORM', - `Unsupported platform for iOS runner: ${device.platform}`, - ); - } - if (device.platform === 'macos') { - return 'macOS'; - } - return resolveApplePlatformName(device.target); -} - -function resolveRunnerPlatformProfile(device: DeviceInfo): RunnerPlatformProfile { - return RUNNER_PLATFORM_PROFILES[resolveRunnerPlatformName(device)]; -} - -function runnerPlatformDeviceKind(deviceKind: DeviceInfo['kind']): 'simulator' | 'device' { - return deviceKind === 'simulator' ? 'simulator' : 'device'; -} - -function resolveMacRunnerArch(): 'arm64' | 'x86_64' { - return process.arch === 'arm64' ? 'arm64' : 'x86_64'; + return path.join(RUNNER_DERIVED_ROOT, 'derived', resolveRunnerDerivedBaseName(device)); } export function resolveRunnerMaxConcurrentDestinationsFlag(device: DeviceInfo): string { diff --git a/test/integration/provider-scenarios/macos-desktop.test.ts b/test/integration/provider-scenarios/macos-desktop.test.ts index 2f626b95f..4537d7fdf 100644 --- a/test/integration/provider-scenarios/macos-desktop.test.ts +++ b/test/integration/provider-scenarios/macos-desktop.test.ts @@ -8,6 +8,51 @@ import { createMacOsDesktopWorld } from './macos-world.ts'; import { createAppleRunnerProviderFromTranscript } from './providers.ts'; import { runProviderScenario } from './scenario.ts'; import { createProviderTranscript } from './transcript.ts'; +import type { + AppleRunnerPrepareResult, + AppleRunnerProvider, +} from '../../../src/platforms/ios/runner-provider.ts'; + +test('Provider-backed integration prepare uses the Apple runner lifecycle provider', async () => { + const lifecycleCalls: string[] = []; + const appleRunnerProvider: AppleRunnerProvider = { + runCommand: async () => { + throw new Error('prepare should not be reduced to a raw runner command'); + }, + prepare: async (device): Promise => { + lifecycleCalls.push(`prepare:${device.platform}:${device.target ?? 'unknown'}`); + return { + runner: { uptimeMs: 123 }, + connectMs: 7, + healthCheckMs: 11, + }; + }, + }; + + await withProviderScenarioResource( + async () => await createMacOsDesktopWorld({ appleRunnerProvider }), + async ({ daemon }) => { + await runProviderScenario(daemon, [ + { + name: 'prepare macOS runner', + command: 'prepare', + positionals: ['ios-runner'], + flags: { platform: 'macos' }, + expectData: { + action: 'ios-runner', + platform: 'macos', + deviceId: PROVIDER_SCENARIO_MACOS.id, + runner: { uptimeMs: 123 }, + connectMs: 7, + healthCheckMs: 11, + }, + }, + ]); + }, + ); + + assert.deepEqual(lifecycleCalls, ['prepare:macos:desktop']); +}); test('Provider-backed integration macOS desktop flow uses semantic host and helper providers', async () => { const runnerTranscript = createProviderTranscript([ From 08aef44695cfb975551d36f32089c8521c607671 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 4 Jun 2026 14:49:14 -0700 Subject: [PATCH 5/7] fix: clarify runner prepare recovery diagnostics --- .github/workflows/ios.yml | 1 - .github/workflows/replays-nightly.yml | 1 - scripts/write-xcuitest-cache-metadata.mjs | 2 +- .../__tests__/runner-command-retry.test.ts | 7 +++-- src/platforms/ios/runner-lifecycle.ts | 17 +++++++----- src/platforms/ios/runner-provider.ts | 1 + src/platforms/ios/runner-xctestrun.ts | 26 ++++++++++++++++--- 7 files changed, 40 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 287565782..162100079 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -46,7 +46,6 @@ jobs: build-command: sh ./scripts/build-xcuitest-apple.sh xcuitest-platform: ios xcuitest-destination: generic/platform=iOS Simulator - clean-derived: "1" build-on-miss: "false" - name: Boot iOS test simulator diff --git a/.github/workflows/replays-nightly.yml b/.github/workflows/replays-nightly.yml index 663388587..ee410f5e7 100644 --- a/.github/workflows/replays-nightly.yml +++ b/.github/workflows/replays-nightly.yml @@ -69,7 +69,6 @@ jobs: build-command: sh ./scripts/build-xcuitest-apple.sh xcuitest-platform: ios xcuitest-destination: generic/platform=iOS Simulator - clean-derived: "1" build-on-miss: "false" - name: Boot iOS test simulator diff --git a/scripts/write-xcuitest-cache-metadata.mjs b/scripts/write-xcuitest-cache-metadata.mjs index 98e994891..c2f6da80c 100644 --- a/scripts/write-xcuitest-cache-metadata.mjs +++ b/scripts/write-xcuitest-cache-metadata.mjs @@ -197,7 +197,7 @@ function resolveSigningBuildSettings() { const appBundleId = resolveRunnerAppBundleId(); const testBundleId = resolveRunnerTestBundleId(); const metadata = { - schemaVersion: 1, + schemaVersion: 2, packageVersion: readPackageVersion(), runnerSourceFingerprint: computeRunnerSourceFingerprint(), ...resolveRunnerToolchainFingerprint(), diff --git a/src/platforms/ios/__tests__/runner-command-retry.test.ts b/src/platforms/ios/__tests__/runner-command-retry.test.ts index 4ee43a839..51403a0ea 100644 --- a/src/platforms/ios/__tests__/runner-command-retry.test.ts +++ b/src/platforms/ios/__tests__/runner-command-retry.test.ts @@ -99,8 +99,9 @@ test('prepareIosRunner marks a bad restored artifact and rebuilds once after hea connectMs: result.connectMs, healthCheckMs: result.healthCheckMs, xctestrunPath: '/tmp/rebuilt.xctestrun', - failureReason: 'Runner did not accept connection', + recoveryReason: 'Runner did not accept connection', }); + assert.equal(result.failureReason, undefined); assert.equal(result.connectMs >= 0, true); assert.equal(result.healthCheckMs >= 0, true); assert.deepEqual(mockInvalidateRunnerSession.mock.calls[0], [ @@ -133,7 +134,9 @@ test('prepareIosRunner marks a bad restored artifact and rebuilds once after hea event.data?.cache === 'miss' && event.data?.artifact === 'rebuilt' && event.data?.xctestrunPath === '/tmp/rebuilt.xctestrun' && - event.data?.failureReason === 'Runner did not accept connection', + event.data?.recoveryReason === 'Runner did not accept connection' && + event.data?.failureReason === undefined && + event.level === 'info', ), ); }); diff --git a/src/platforms/ios/runner-lifecycle.ts b/src/platforms/ios/runner-lifecycle.ts index aed3c9378..8e55ed4b0 100644 --- a/src/platforms/ios/runner-lifecycle.ts +++ b/src/platforms/ios/runner-lifecycle.ts @@ -67,7 +67,7 @@ export async function prepareLocalIosRunner( options, signal, connectMs, - reason, + { recoveryReason: reason }, ); emitDiagnostic({ level: 'info', @@ -232,7 +232,7 @@ async function runPrepareHealthCheck( options: PrepareIosRunnerOptions, signal: AbortSignal | undefined, connectMs: number, - failureReason?: string, + reason?: { recoveryReason?: string; failureReason?: string }, ): Promise { const healthStartedAt = Date.now(); const runner = await executeRunnerCommandWithSession( @@ -248,7 +248,7 @@ async function runPrepareHealthCheck( session, connectMs, Date.now() - healthStartedAt, - failureReason, + reason, ); } @@ -300,15 +300,19 @@ function buildPrepareIosRunnerResult( session: RunnerSession, connectMs: number, healthCheckMs: number, - failureReason: string | undefined, + reason: { recoveryReason?: string; failureReason?: string } | undefined, ): PrepareIosRunnerResult { const artifact = session.xctestrunArtifact; + const reasonFields = { + ...(reason?.recoveryReason ? { recoveryReason: reason.recoveryReason } : {}), + ...(reason?.failureReason ? { failureReason: reason.failureReason } : {}), + }; if (!artifact) { return { runner, connectMs: Math.max(0, connectMs), healthCheckMs: Math.max(0, healthCheckMs), - ...(failureReason ? { failureReason } : {}), + ...reasonFields, }; } return { @@ -319,7 +323,7 @@ function buildPrepareIosRunnerResult( connectMs: Math.max(0, connectMs), healthCheckMs: Math.max(0, healthCheckMs), xctestrunPath: artifact.xctestrunPath, - ...(failureReason ? { failureReason } : {}), + ...reasonFields, }; } @@ -348,6 +352,7 @@ function emitPrepareDiagnostic( connectMs: result.connectMs, healthCheckMs: result.healthCheckMs, xctestrunPath: result.xctestrunPath, + recoveryReason: result.recoveryReason, failureReason: result.failureReason, }, }); diff --git a/src/platforms/ios/runner-provider.ts b/src/platforms/ios/runner-provider.ts index 189441ea2..ee9950fcc 100644 --- a/src/platforms/ios/runner-provider.ts +++ b/src/platforms/ios/runner-provider.ts @@ -31,6 +31,7 @@ export type AppleRunnerPrepareResult = { connectMs: number; healthCheckMs: number; xctestrunPath?: string; + recoveryReason?: string; failureReason?: string; }; diff --git a/src/platforms/ios/runner-xctestrun.ts b/src/platforms/ios/runner-xctestrun.ts index d2775af50..562fc090c 100644 --- a/src/platforms/ios/runner-xctestrun.ts +++ b/src/platforms/ios/runner-xctestrun.ts @@ -41,7 +41,7 @@ const XCTEST_DEVICE_SET_LEGACY_BACKUP_PREFIX = '.agent-device-xctestdevices-back const RUNNER_DERIVED_ROOT = path.join(os.homedir(), '.agent-device', 'ios-runner'); const RUNNER_CACHE_METADATA_FILE = '.agent-device-runner-cache.json'; -const RUNNER_CACHE_SCHEMA_VERSION = 1; +const RUNNER_CACHE_SCHEMA_VERSION = 2; const XCTEST_DEVICE_SET_LOCK_TIMEOUT_MS = 30_000; const XCTEST_DEVICE_SET_LOCK_POLL_MS = 100; const XCTEST_DEVICE_SET_LOCK_OWNER_GRACE_MS = 5_000; @@ -917,8 +917,8 @@ function evaluateRunnerCacheMetadata( return { ok: false, reason: 'cache_metadata_missing' }; } if ( - JSON.stringify(comparableRunnerCacheMetadata(actual)) !== - JSON.stringify(comparableRunnerCacheMetadata(expected)) + stableJsonStringify(comparableRunnerCacheMetadata(actual)) !== + stableJsonStringify(comparableRunnerCacheMetadata(expected)) ) { return { ok: false, reason: 'cache_metadata_mismatch' }; } @@ -935,11 +935,29 @@ function comparableRunnerCacheMetadata( function resolveRunnerDerivedCacheKey(metadata: RunnerXctestrunCacheMetadata): string { const hash = crypto .createHash('sha256') - .update(JSON.stringify(comparableRunnerCacheMetadata(metadata))) + .update(stableJsonStringify(comparableRunnerCacheMetadata(metadata))) .digest('hex'); return `cache-${hash.slice(0, 16)}`; } +function stableJsonStringify(value: unknown): string { + return JSON.stringify(sortJsonKeys(value)); +} + +function sortJsonKeys(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => sortJsonKeys(item)); + } + if (!value || typeof value !== 'object') { + return value; + } + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, sortJsonKeys(item)]), + ); +} + function withRunnerCacheArtifacts( metadata: RunnerXctestrunCacheMetadata, xctestrunPath: string, From 580762247f94718606b0b41d706b7d796d04d3d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 4 Jun 2026 14:51:33 -0700 Subject: [PATCH 6/7] refactor: trim apple runner provider surface --- .../ios/__tests__/runner-provider.test.ts | 19 ------------------- src/platforms/ios/runner-provider.ts | 7 ------- 2 files changed, 26 deletions(-) diff --git a/src/platforms/ios/__tests__/runner-provider.test.ts b/src/platforms/ios/__tests__/runner-provider.test.ts index 87e79eb8f..428b3a2f3 100644 --- a/src/platforms/ios/__tests__/runner-provider.test.ts +++ b/src/platforms/ios/__tests__/runner-provider.test.ts @@ -2,7 +2,6 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { IOS_SIMULATOR } from '../../../__tests__/test-utils/index.ts'; import { - hasScopedAppleRunnerProvider, resolveAppleRunnerProvider, withAppleRunnerProvider, type AppleRunnerProvider, @@ -47,24 +46,6 @@ test('scoped Apple runner provider requires matching request id when scoped by r assert.deepEqual(calls, ['scoped', 'fallback', 'fallback']); }); -test('scoped Apple runner provider detection follows request scoping', async () => { - const scoped = runnerProvider('scoped', []); - - await withAppleRunnerProvider( - scoped, - { deviceId: IOS_SIMULATOR.id, requestId: 'req-1' }, - async () => { - assert.equal(hasScopedAppleRunnerProvider(IOS_SIMULATOR, { requestId: 'req-1' }), true); - assert.equal(hasScopedAppleRunnerProvider(IOS_SIMULATOR), false); - assert.equal(hasScopedAppleRunnerProvider(IOS_SIMULATOR, { requestId: 'req-2' }), false); - assert.equal( - hasScopedAppleRunnerProvider({ ...IOS_SIMULATOR, id: 'other-sim' }, { requestId: 'req-1' }), - false, - ); - }, - ); -}); - function runnerProvider(source: string, calls: string[]): AppleRunnerProvider { return { runCommand: async () => { diff --git a/src/platforms/ios/runner-provider.ts b/src/platforms/ios/runner-provider.ts index ee9950fcc..b06c55e55 100644 --- a/src/platforms/ios/runner-provider.ts +++ b/src/platforms/ios/runner-provider.ts @@ -101,13 +101,6 @@ export function resolveAppleRunnerProvider( : normalizeAppleRunnerProvider(fallback); } -export function hasScopedAppleRunnerProvider( - device: DeviceInfo, - options: { requestId?: string } = {}, -): boolean { - return resolveScopedAppleRunnerProvider(device, options) !== undefined; -} - function resolveScopedAppleRunnerProvider( device: DeviceInfo, options: { requestId?: string } = {}, From e4df13da3c4d257d3dcd273898bc152501057e02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 4 Jun 2026 15:24:53 -0700 Subject: [PATCH 7/7] test: simplify runner recovery diagnostics assertion --- .../__tests__/runner-command-retry.test.ts | 160 ++++++++++-------- 1 file changed, 89 insertions(+), 71 deletions(-) diff --git a/src/platforms/ios/__tests__/runner-command-retry.test.ts b/src/platforms/ios/__tests__/runner-command-retry.test.ts index 51403a0ea..62ae12041 100644 --- a/src/platforms/ios/__tests__/runner-command-retry.test.ts +++ b/src/platforms/ios/__tests__/runner-command-retry.test.ts @@ -57,31 +57,11 @@ beforeEach(() => { }); test('prepareIosRunner marks a bad restored artifact and rebuilds once after health failure', async () => { - const restoredArtifact = makeRunnerArtifact({ - xctestrunPath: '/tmp/restored.xctestrun', - cache: 'exact', - artifact: 'valid', - }); - const rebuiltArtifact = makeRunnerArtifact({ - xctestrunPath: '/tmp/rebuilt.xctestrun', - cache: 'miss', - artifact: 'rebuilt', - buildMs: 123, - }); - const restoredSession = makeRunnerSession({ - port: 8100, - xctestrunPath: restoredArtifact.xctestrunPath, - xctestrunArtifact: restoredArtifact, - }); - const rebuiltSession = makeRunnerSession({ - port: 8101, - xctestrunPath: rebuiltArtifact.xctestrunPath, - xctestrunArtifact: rebuiltArtifact, - }); + const fixtures = makeBadCacheRecoveryFixtures(); mockEnsureRunnerSession - .mockResolvedValueOnce(restoredSession) - .mockResolvedValueOnce(rebuiltSession); + .mockResolvedValueOnce(fixtures.restoredSession) + .mockResolvedValueOnce(fixtures.rebuiltSession); mockExecuteRunnerCommandWithSession .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection')) .mockResolvedValueOnce({ uptimeMs: 42 }); @@ -91,54 +71,9 @@ test('prepareIosRunner marks a bad restored artifact and rebuilds once after hea buildTimeoutMs: 300_000, }); - assert.deepEqual(result, { - runner: { uptimeMs: 42 }, - cache: 'miss', - artifact: 'rebuilt', - buildMs: 123, - connectMs: result.connectMs, - healthCheckMs: result.healthCheckMs, - xctestrunPath: '/tmp/rebuilt.xctestrun', - recoveryReason: 'Runner did not accept connection', - }); - assert.equal(result.failureReason, undefined); - assert.equal(result.connectMs >= 0, true); - assert.equal(result.healthCheckMs >= 0, true); - assert.deepEqual(mockInvalidateRunnerSession.mock.calls[0], [ - restoredSession, - 'prepare_cached_runner_health_failed', - ]); - assert.deepEqual(mockMarkRunnerXctestrunArtifactBadForRun.mock.calls[0], [ - restoredArtifact, - 'Runner did not accept connection', - ]); - assert.deepEqual(mockEnsureRunnerSession.mock.calls[1]?.[1], { - healthTimeoutMs: 90_000, - buildTimeoutMs: 300_000, - cleanStaleBundles: true, - forceRunnerXctestrunRebuild: true, - }); - assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2); - assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[0]?.[2].command, 'uptime'); - assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[0]?.[4], 90_000); - assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[1]?.[1], rebuiltSession); - assert.ok( - mockEmitDiagnostic.mock.calls.some( - ([event]) => event.phase === 'ios_runner_prepare_bad_cache_recovered', - ), - ); - assert.ok( - mockEmitDiagnostic.mock.calls.some( - ([event]) => - event.phase === 'apple_runner_prepare' && - event.data?.cache === 'miss' && - event.data?.artifact === 'rebuilt' && - event.data?.xctestrunPath === '/tmp/rebuilt.xctestrun' && - event.data?.recoveryReason === 'Runner did not accept connection' && - event.data?.failureReason === undefined && - event.level === 'info', - ), - ); + assertRecoveredPrepareResult(result); + assertBadCacheRecoverySideEffects(fixtures); + assertRecoveredPrepareDiagnostics(); }); test('prepareIosRunner invalidates rebuilt sessions when bad-cache recovery health fails', async () => { @@ -715,6 +650,89 @@ test('mutating commands invalidate the retry session without replaying again', a }); }); +function makeBadCacheRecoveryFixtures() { + const restoredArtifact = makeRunnerArtifact({ + xctestrunPath: '/tmp/restored.xctestrun', + cache: 'exact', + artifact: 'valid', + }); + const rebuiltArtifact = makeRunnerArtifact({ + xctestrunPath: '/tmp/rebuilt.xctestrun', + cache: 'miss', + artifact: 'rebuilt', + buildMs: 123, + }); + const restoredSession = makeRunnerSession({ + port: 8100, + xctestrunPath: restoredArtifact.xctestrunPath, + xctestrunArtifact: restoredArtifact, + }); + const rebuiltSession = makeRunnerSession({ + port: 8101, + xctestrunPath: rebuiltArtifact.xctestrunPath, + xctestrunArtifact: rebuiltArtifact, + }); + + return { restoredArtifact, restoredSession, rebuiltSession }; +} + +function assertRecoveredPrepareResult(result: Awaited>): void { + assert.deepEqual(result, { + runner: { uptimeMs: 42 }, + cache: 'miss', + artifact: 'rebuilt', + buildMs: 123, + connectMs: result.connectMs, + healthCheckMs: result.healthCheckMs, + xctestrunPath: '/tmp/rebuilt.xctestrun', + recoveryReason: 'Runner did not accept connection', + }); + assert.equal(result.failureReason, undefined); + assert.equal(result.connectMs >= 0, true); + assert.equal(result.healthCheckMs >= 0, true); +} + +function assertBadCacheRecoverySideEffects( + fixtures: ReturnType, +): void { + assert.deepEqual(mockInvalidateRunnerSession.mock.calls[0], [ + fixtures.restoredSession, + 'prepare_cached_runner_health_failed', + ]); + assert.deepEqual(mockMarkRunnerXctestrunArtifactBadForRun.mock.calls[0], [ + fixtures.restoredArtifact, + 'Runner did not accept connection', + ]); + assert.deepEqual(mockEnsureRunnerSession.mock.calls[1]?.[1], { + healthTimeoutMs: 90_000, + buildTimeoutMs: 300_000, + cleanStaleBundles: true, + forceRunnerXctestrunRebuild: true, + }); + assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2); + assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[0]?.[2].command, 'uptime'); + assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[0]?.[4], 90_000); + assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[1]?.[1], fixtures.rebuiltSession); +} + +function assertRecoveredPrepareDiagnostics(): void { + assert.ok( + mockEmitDiagnostic.mock.calls.some( + ([event]) => event.phase === 'ios_runner_prepare_bad_cache_recovered', + ), + ); + const prepareDiagnostic = mockEmitDiagnostic.mock.calls.find( + ([event]) => event.phase === 'apple_runner_prepare', + )?.[0]; + assert.ok(prepareDiagnostic); + assert.equal(prepareDiagnostic.level, 'info'); + assert.equal(prepareDiagnostic.data?.cache, 'miss'); + assert.equal(prepareDiagnostic.data?.artifact, 'rebuilt'); + assert.equal(prepareDiagnostic.data?.xctestrunPath, '/tmp/rebuilt.xctestrun'); + assert.equal(prepareDiagnostic.data?.recoveryReason, 'Runner did not accept connection'); + assert.equal(prepareDiagnostic.data?.failureReason, undefined); +} + function assertDiagnosticDecision(expected: { decision: 'skipped' | 'retained'; reason: string;