diff --git a/packages/platform-apple/src/runner/__tests__/runner-toolchain-probe.test.ts b/packages/platform-apple/src/runner/__tests__/runner-toolchain-probe.test.ts index fb3fd5d3c6..7df14c9580 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-toolchain-probe.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-toolchain-probe.test.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import { isRequestCanceledError } from '@agent-device/kernel/errors'; import { resetAllProcessMemosForTests } from '@agent-device/kernel/ttl-memo'; import type { ExecOptions, ExecResult } from '@agent-device/host-kit/command'; +import type { DiagnosticEventInput } from '@agent-device/host-kit/diagnostics'; import { IOS_SIMULATOR } from './device-fixtures.ts'; import { appleRunnerTestHost } from '../test-host.ts'; import { @@ -106,6 +107,103 @@ function blockResolutionForWholeTimeout(clock: { nowMs: number }): void { }); } +test('an exhausted fingerprint retains the failed probe and retry in diagnostics', () => { + const clock = installToolchainProbeClock(); + vi.spyOn(Date, 'now').mockImplementation(() => clock.nowMs); + const events: DiagnosticEventInput[] = []; + appleRunnerTestHost.update({ emitDiagnostic: (event) => events.push(event) }); + runCmdSync.mockImplementation((command: string, args: string[], options: ExecOptions) => { + if (command === 'xcode-select') return selectedDeveloperDirResult(XCODE_A); + throw blockForWholeTimeout(clock, command, args, options); + }); + + expect(readFingerprint).toThrow('The Apple runner budget ran out before this step began'); + + expect( + events + .filter(({ data }) => data?.cmd) + .map(({ phase, data }) => [phase, data?.event, data?.cmd, data?.timeoutMs]), + ).toEqual([ + ['apple_toolchain_probe', 'started', 'xcodebuild', 30_000], + ['apple_toolchain_probe', 'failed', 'xcodebuild', 30_000], + ['apple_toolchain_probe', 'started', 'xcodebuild', 15_000], + ['apple_toolchain_probe', 'failed', 'xcodebuild', 15_000], + ]); + for (const event of events.filter(({ data }) => data?.event === 'failed')) { + expect(event).toMatchObject({ + durationMs: event.data?.timeoutMs, + data: { + args: ['-version'], + error: { code: 'COMMAND_FAILED', details: { timeoutMs: event.data?.timeoutMs } }, + }, + }); + } + expect(clock.nowMs).toBe(45_000); +}); + +test.each([ + { + label: 'nonzero exit', + output: { exitCode: 70, stdout: '', stderr: 'SDK unavailable' }, + reason: 'nonzero_exit', + detail: 'exit 70: SDK unavailable', + }, + { + label: 'empty output', + output: { exitCode: 0, stdout: ' \n', stderr: '' }, + reason: 'empty_output', + detail: 'no output', + }, + { + label: 'unparsable output', + output: { exitCode: 0, stdout: 'unexpected version format', stderr: '' }, + reason: 'unparsable_output', + detail: 'unrecognized output: unexpected version format', + }, + { + label: 'exec failure', + output: new Error('version probe failed'), + reason: 'probe_error', + detail: 'version probe failed', + }, +])( + 'the trace records the classified $label before a later probe exhausts the budget', + ({ output, reason, detail }) => { + const clock = installToolchainProbeClock(); + const events: DiagnosticEventInput[] = []; + appleRunnerTestHost.update({ emitDiagnostic: (event) => events.push(event) }); + runCmdSync.mockImplementation((command: string, args: string[], options: ExecOptions) => { + if (command === 'xcode-select') return selectedDeveloperDirResult(XCODE_A); + if (command === 'xcodebuild') { + if (output instanceof Error) throw output; + return output; + } + throw blockForWholeTimeout(clock, command, args, options); + }); + + expect(readFingerprint).toThrow('The Apple runner budget ran out before this step began'); + expect( + events.filter( + ({ data }) => data?.event === 'rejected' && data?.probe === 'xcodebuild -version', + ), + ).toEqual([ + { + level: 'error', + phase: 'apple_toolchain_probe', + data: { event: 'rejected', probe: 'xcodebuild -version', reason, detail }, + }, + ]); + }, +); + +test('the trace does not reject a readable toolchain fingerprint', () => { + const events: DiagnosticEventInput[] = []; + appleRunnerTestHost.update({ emitDiagnostic: (event) => events.push(event) }); + + expect(readFingerprint().xcodeBuildVersion).toBe(STUBBED_APPLE_TOOLCHAIN.xcodeBuildVersion); + expect(events.filter(({ data }) => data?.event === 'rejected')).toEqual([]); +}); + describe('the developer dir a toolchain fingerprint answers for', () => { test('a default selection that moved re-reads instead of answering the Xcode it replaced', () => { scriptSelectableXcodes(); diff --git a/packages/platform-apple/src/runner/runner-toolchain-probe.ts b/packages/platform-apple/src/runner/runner-toolchain-probe.ts index 1f10433211..0eb4cc9cf5 100644 --- a/packages/platform-apple/src/runner/runner-toolchain-probe.ts +++ b/packages/platform-apple/src/runner/runner-toolchain-probe.ts @@ -2,6 +2,7 @@ import { AppError, createRequestCanceledError, isRequestCanceledError, + normalizeError, } from '@agent-device/kernel/errors'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { TtlMemo } from '@agent-device/kernel/ttl-memo'; @@ -9,6 +10,7 @@ import { commandDeveloperDir, createTtlMemo, Deadline, + emitDiagnostic, isCommandTimeoutError, runCmdSync, } from './host.ts'; @@ -439,11 +441,35 @@ function attemptToolchainProbe( clock.throwIfCanceled(); const timeoutMs = clock.attemptTimeoutMs(); if (timeoutMs <= 0) throw runnerPhaseBudgetExhaustedError('apple_toolchain_probe'); - return runCmdSync(cmd, args, { - allowFailure: true, - timeoutMs, - maxBuffer: TOOLCHAIN_PROBE_MAX_BUFFER, - }); + const startedAtMs = Date.now(); + const data = { cmd, args, timeoutMs }; + emitDiagnostic({ phase: 'apple_toolchain_probe', data: { ...data, event: 'started' } }); + try { + const result = runCmdSync(cmd, args, { + allowFailure: true, + timeoutMs, + maxBuffer: TOOLCHAIN_PROBE_MAX_BUFFER, + }); + emitDiagnostic({ + phase: 'apple_toolchain_probe', + durationMs: Date.now() - startedAtMs, + data: { + ...data, + event: 'completed', + exitCode: result.exitCode, + stderr: result.stderr.slice(0, TOOLCHAIN_PROBE_DETAIL_MAX_LENGTH), + }, + }); + return result; + } catch (error) { + emitDiagnostic({ + level: 'error', + phase: 'apple_toolchain_probe', + durationMs: Date.now() - startedAtMs, + data: { ...data, event: 'failed', error: normalizeError(error) }, + }); + throw error; + } } function parseXcodeVersionOutput( @@ -473,5 +499,11 @@ function probeFailure( detail.length > TOOLCHAIN_PROBE_DETAIL_MAX_LENGTH ? `${detail.slice(0, TOOLCHAIN_PROBE_DETAIL_MAX_LENGTH)}…` : detail; - return { ok: false, failure: { probe, reason, detail: bounded } }; + const failure = { probe, reason, detail: bounded }; + emitDiagnostic({ + level: 'error', + phase: 'apple_toolchain_probe', + data: { event: 'rejected', ...failure }, + }); + return { ok: false, failure }; } diff --git a/scripts/write-xcuitest-cache-metadata.ts b/scripts/write-xcuitest-cache-metadata.ts index b0c4caa70a..b2385a11e0 100644 --- a/scripts/write-xcuitest-cache-metadata.ts +++ b/scripts/write-xcuitest-cache-metadata.ts @@ -3,6 +3,8 @@ import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { withCommandHost } from '@agent-device/host-kit/command'; import { captureHostEnvironment } from '@agent-device/host-kit/process'; +import { withDiagnosticsScope } from '@agent-device/host-kit/diagnostics'; +import { normalizeError } from '@agent-device/kernel/errors'; import type { RunnerXctestrunCacheMetadata } from '@agent-device/platform-apple/runner/operations'; import { findRunnerXctestrun, @@ -80,10 +82,14 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) setAppleRunnerHostEnvironment(host); await withCommandHost( { host, requestEnv: undefined }, - async () => await writeXcuitestCacheMetadata(process.argv.slice(2), host.cwd), + async () => + await withDiagnosticsScope( + { debug: true }, + async () => await writeXcuitestCacheMetadata(process.argv.slice(2), host.cwd), + ), ); } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); + console.error(JSON.stringify(normalizeError(error))); process.exit(1); } }