diff --git a/packages/platform-apple/src/runner/__tests__/runner-error-classification.test.ts b/packages/platform-apple/src/runner/__tests__/runner-error-classification.test.ts index f6ceb35127..e4e715600e 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-error-classification.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-error-classification.test.ts @@ -250,6 +250,74 @@ test('only a runner that never accepted a connection indicts the cached artifact assert.equal(shouldRebuildCachedRunnerArtifact(commandFailed('fetch failed')), false); }); +test('a simulator service this macOS refuses to load is terminal on every recovery axis', () => { + // The reason the launch paths publish is the whole fact: this Xcode fails every launch on this + // macOS, so no restart, resend, replay, or clean rebuild may be ordered for it (#3449). + const blessRefusal = commandFailed('Runner did not accept connection', { + reason: 'simulator_service_hub_unavailable', + runnerConnectFailureReason: 'runner_connect_refused', + dispatched: 'no', + }); + assert.equal(shouldRetryRunnerConnectError(blessRefusal), false); + assert.equal(isRetryableRunnerError(blessRefusal), false); + assert.equal(shouldRestartRunnerBeforeCommandSend(blessRefusal, SNAPSHOT), false); + assert.equal(shouldRebuildCachedRunnerArtifact(blessRefusal), false); + // The preflight's restart marker cannot outvote it either: the preflight would boot the same + // runner that just failed to load its service hub. + assert.equal( + shouldRestartRunnerAfterReadinessPreflight( + commandFailed('Runner readiness refused', { + reason: 'simulator_service_hub_unavailable', + runnerReadinessPreflightFailed: true, + }), + ), + false, + ); +}); + +test('a refused connection still restarts and rebuilds without the service-hub reason', () => { + // The closest negative: the same connect-shaped failure a host with a supported Xcode produces. + const refused = unwrittenConnectRefusal(); + assert.equal(shouldRetryRunnerConnectError(refused), true); + assert.equal(shouldRestartRunnerBeforeCommandSend(refused, TAP), true); + assert.equal(shouldRebuildCachedRunnerArtifact(refused), true); +}); + +test('a scoped-set destination this Xcode cannot resolve is terminal on every recovery axis', () => { + // The other cause a launch log names. The set is on the command, so a retry, a restart, or a fresh + // build resolves nothing, and the connect loop has to stop on the attempt that reads the log — the + // same terminal reading the service-hub cause gets. + const destinationFailure = commandFailed('Runner did not accept connection', { + reason: 'simulator_set_destination_not_found', + runnerConnectFailureReason: 'runner_connect_refused', + dispatched: 'no', + simulatorSetPath: '/Users/dev/Library/Developer/CoreSimulator/Sets/foo.json', + }); + assert.equal(shouldRetryRunnerConnectError(destinationFailure), false); + assert.equal(isRetryableRunnerError(destinationFailure), false); + assert.equal(shouldRestartRunnerBeforeCommandSend(destinationFailure, SNAPSHOT), false); + assert.equal(shouldRebuildCachedRunnerArtifact(destinationFailure), false); + assert.equal( + shouldRestartRunnerAfterReadinessPreflight( + commandFailed('Runner readiness refused', { + reason: 'simulator_set_destination_not_found', + runnerReadinessPreflightFailed: true, + }), + ), + false, + ); +}); + +test('the reason in foreign text marks nothing terminal', () => { + // The verdict keys on the reason the launch paths publish. A runner response or tool transcript that + // merely repeats the words cannot order a failure non-retryable. + const echoed = commandFailed( + 'Runner did not accept connection: simulator_set_destination_not_found', + { stderr: 'DTServiceHubClient failed to bless service hub for simulator' }, + ); + assert.equal(shouldRetryRunnerConnectError(echoed), true); +}); + test('a device that is busy connecting is a terminal connect verdict', () => { assert.equal( shouldRetryRunnerConnectError(commandFailed('Device is busy (Connecting to Simulator)')), diff --git a/packages/platform-apple/src/runner/__tests__/runner-startup-failure-fixtures.ts b/packages/platform-apple/src/runner/__tests__/runner-startup-failure-fixtures.ts index 8c520eceba..2ceec53fd0 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-startup-failure-fixtures.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-startup-failure-fixtures.ts @@ -15,15 +15,21 @@ import { IOS_SIMULATOR } from './device-fixtures.ts'; * * - `captured` — `output` was pasted from a run, and `command` plus `xcodeVersion` (from * `xcodebuild -version`) were recorded with it by `.device-evidence/CHECKLIST-runner-failures.md`. + * - `reported-log-excerpt` — `output` was pasted from a `runner.log` someone else ran, and the + * versions in `xcodeVersion` are the ones that reporter stated. No `command` and no checklist row: + * the host that produced it is not reachable from here, which is also why no run on this machine + * can promote such an entry to `captured`. * - `shipped-sniff-trigger` — the substrings a rule matches are the ones shipped in * `resolveSigningFailureHint` before #2680, which is evidence xcodebuild can emit them. The * sentence around them is ours, so `command` and `xcodeVersion` stay unrecorded. * - `invented-shape` — no shipped trigger and no capture. The entry exists to exercise a rule and * makes no claim about wording xcodebuild prints. * - * Until Phase B captures the real runs, every entry is `unobserved` for `xcodeVersion` and carries - * no `command`: an invocation we did not run is not provenance. Nothing in the classifier reads - * these fields; they exist so a reason can be traced to an observation instead of to a guess. + * The two kinds with no observation behind the invocation — `shipped-sniff-trigger` and + * `invented-shape` — are `unobserved` for `xcodeVersion` and carry no `command`: an invocation we did + * not run is not provenance. `captured` records both, and `reported-log-excerpt` records the version + * its reporter stated and no command. Nothing in the classifier reads these fields; they exist so a + * reason can be traced to an observation instead of to a guess. * * Blocked on the host, not unexamined. One Phase B run on `thymikee-iphone` / Xcode 26.2 settled the * shape question these rows were held on — `No profiles for '' were found` arrives as one long @@ -44,6 +50,7 @@ import { IOS_SIMULATOR } from './device-fixtures.ts'; export type RunnerStartupFailureSite = | 'build-for-testing' + | 'launch-test-without-building' | 'host-dev-tools-security' | 'device-readiness'; @@ -109,7 +116,7 @@ export type RunnerStartupFailureFixture = Readonly<{ command?: string; /** `xcodebuild -version` recorded from that run, or `unobserved`. */ xcodeVersion: string; - provenance: 'captured' | 'shipped-sniff-trigger' | 'invented-shape'; + provenance: 'captured' | 'reported-log-excerpt' | 'shipped-sniff-trigger' | 'invented-shape'; /** The tool's own stdout/stderr. */ output: string; /** The argv the exec reported, which is never evidence of a cause (#2680). */ @@ -390,6 +397,19 @@ export const RUNNER_STARTUP_FAILURE_FIXTURES: readonly RunnerStartupFailureFixtu deviceReport: { developerMode: 'enabled', developerDiskImage: 'unavailable' }, note: 'The decisive pairing, and the one #2682 used to answer with Developer Mode advice: the toggle is on and only the image is down. It is NOT a pre-build refusal (#2683 review): iOS 17+ mounts the image on demand during build and launch, so this report has to survive to a failure — which is what `unclassified-build-on-device-with-image-down` records. The enabled half is captured on some devices; a device waiting on device support has not been captured.', }, + { + id: 'simulator-service-hub-bless-refused', + reason: 'simulator_service_hub_unavailable', + site: 'launch-test-without-building', + xcodeVersion: 'Xcode 26.6 / Build version 17F113', + provenance: 'reported-log-excerpt', + output: + '2026-10-15 10:23:41.112 xcodebuild[40217:166902] [MT] iOSSimulator: unable to connect to "com.apple.instruments.deviceservice.lockdown"\n' + + '(Error Domain=DVTiPhoneSimulatorErrorDomain Code=-1 "DTServiceHubClient failed to bless service hub for simulator iPhone (abcdef0123456789abcdef0123456789abcdef01)" UserInfo={NSLocalizedDescription=DTServiceHubClient failed to bless service hub for simulator iPhone (abcdef0123456789abcdef0123456789abcdef01), NSUnderlyingError=0x1234 {Error Domain=DTServiceHubClient Code=1 "\'localDeviceConnectionWithError:\' failed but didn\'t provide an error."}})\n' + + '** TEST EXECUTE FAILED **\n' + + '** BUILD INTERRUPTED **\n', + note: 'Reported on #3449 from two hosts (macOS 27.2 with Xcode 26.6, macOS 27.2 with Xcode 26.5), pasted from `runner.log`. The issue quotes the bless line and the `DTServiceHubClient` line with their middles elided; the device description, the pids and pointers, and the repeated `NSLocalizedDescription` are filled in with placeholders in the shape Xcode writes, and `** BUILD INTERRUPTED **` is the line the report says follows. The row keys on the two phrases it quotes verbatim. Not inducible here: it needs an Xcode older than the host macOS supports, and the AMFI refusal behind it is in the kernel log (`com.apple.DTServiceHub disallowed without correct com.apple.private.amfi.version-restriction entitlement version`).', + }, { id: 'devtools-security-disabled', reason: 'devtools_security_developer_mode_disabled', diff --git a/packages/platform-apple/src/runner/__tests__/runner-startup-transport-service-hub.test.ts b/packages/platform-apple/src/runner/__tests__/runner-startup-transport-service-hub.test.ts new file mode 100644 index 0000000000..8ed8d1e5e7 --- /dev/null +++ b/packages/platform-apple/src/runner/__tests__/runner-startup-transport-service-hub.test.ts @@ -0,0 +1,324 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { beforeEach, test, vi } from 'vitest'; +import { AppError, normalizeError } from '@agent-device/kernel/errors'; +import { resetAllProcessMemosForTests } from '@agent-device/kernel/ttl-memo'; +import type { ExecBackgroundResult } from '@agent-device/host-kit/command'; +import { buildRunnerEarlyExitError, waitForRunner } from '../runner-startup-transport.ts'; +import { readRunnerLogTail } from '../runner-io.ts'; +import { RunnerCommandAccounting, type RunnerSession } from '../runner-session-types.ts'; +import { + isRetryableRunnerError, + shouldRebuildCachedRunnerArtifact, + shouldRestartRunnerAfterReadinessPreflight, + shouldRestartRunnerBeforeCommandSend, + shouldRetryRunnerConnectError, +} from '../runner-error-classification.ts'; +import { + RUNNER_ENDPOINT_FIXTURE, + iosSimulator, + runnerTransportResponse, +} from './runner-transport.fixtures.ts'; +import { + CAPTURED_LAUNCH_DESTINATION_NOT_FOUND_OUTPUT, + CAPTURED_SCOPED_SIMULATOR, + buildFixtureById, +} from './runner-startup-failure-fixtures.ts'; +import { mkdtempForTestSync } from './tmp-dir.ts'; + +beforeEach(resetAllProcessMemosForTests); + +/** + * An Xcode older than the macOS it sits on cannot load its own `DTServiceHub`: AMFI refuses its + * version-restriction entitlement (#3449). Every `xcodebuild test-without-building` then writes the + * bless refusal into `runner.log` while the runner never serves a command, and the caller only ever + * saw `Runner did not accept connection` on a runner the request had just restarted. The cause is + * readable nowhere but that log, so both connect-stage failures have to read it, name it, and refuse + * the restart and resend they would otherwise order. These cases drive the real producers. + */ + +const BLESS_LOG = buildFixtureById('simulator-service-hub-bless-refused').output; + +function sessionWithLaunchLog(log: string, exitCode: number | null): RunnerSession { + const runnerLogPath = path.join(mkdtempForTestSync('runner-service-hub-'), 'runner.log'); + fs.writeFileSync(runnerLogPath, log); + return { + sessionId: 'service-hub-session', + device: iosSimulator, + deviceId: iosSimulator.id, + endpoint: RUNNER_ENDPOINT_FIXTURE, + xctestrunPath: '/tmp/runner.xctestrun', + jsonPath: '/tmp/runner.json', + runnerLogPath, + readLogTail: (maxBytes) => + readRunnerLogTail({ logPath: runnerLogPath, startOffset: 0 }, maxBytes), + testPromise: + exitCode === null + ? new Promise(() => {}) + : Promise.resolve({ exitCode, stdout: '', stderr: '' }), + child: { pid: 4242, exitCode } as ExecBackgroundResult['child'], + state: 'starting', + commandCharges: new RunnerCommandAccounting(), + }; +} + +/** The device states a simulator startup carries; the bless cause must outrank them (#2683). */ +const SIMULATOR_WITH_IMAGE_DOWN = { + developerMode: 'enabled' as const, + developerDiskImage: 'unavailable' as const, + developerDiskImageHint: 'Unlock the iPhone so it can mount the developer disk image.', +}; + +test('a launch that cannot bless the simulator service names the Xcode cause and its fix', async () => { + const session = { + ...sessionWithLaunchLog(BLESS_LOG, 65), + startupDeviceStates: SIMULATOR_WITH_IMAGE_DOWN, + }; + + const error = await buildRunnerEarlyExitError({ session, port: 8100 }); + const envelope = normalizeError(error, { diagnosticId: 'diag-service-hub' }); + + assert.equal(envelope.code, 'COMMAND_FAILED'); + assert.equal( + envelope.message, + 'Runner did not accept connection (xcodebuild exited early): this Xcode cannot start its simulator service on this macOS', + ); + assert.equal(envelope.details?.reason, 'simulator_service_hub_unavailable'); + // The fix is the daemon host's Xcode selection, so the hint has to name both levers for it. + assert.match(String(envelope.hint), /xcode-select -s/); + assert.match(String(envelope.hint), /DEVELOPER_DIR/); + // The bless line is in the runner's log, and that is the file the caller is sent to. + assert.equal(envelope.logPath, session.runnerLogPath); + assert.equal(envelope.diagnosticId, 'diag-service-hub'); + // A cause this log proved outranks a device state read before the launch; the state still travels. + assert.equal(envelope.details?.developerDiskImage, 'unavailable'); +}); + +test('a still-alive xcodebuild whose log carries the bless refusal fails the connect stage by name and stays terminal', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:8100'), { + code: 'ECONNREFUSED', + }); + }), + ); + const session = sessionWithLaunchLog(BLESS_LOG, null); + + const error = await waitForRunner( + iosSimulator, + RUNNER_ENDPOINT_FIXTURE, + { command: 'tap', x: 200, y: 300 }, + undefined, + 300, + session, + ).catch((error: unknown) => error as AppError); + + assert.ok(error instanceof AppError); + assert.equal( + error.message, + 'Runner did not accept connection: this Xcode cannot start its simulator service on this macOS', + ); + assert.equal(error.details?.reason, 'simulator_service_hub_unavailable'); + assert.match(String(error.details?.hint), /xcode-select -s/); + assert.equal(error.details?.logPath, session.runnerLogPath); + // Nothing was written before the loop gave up, so the disclosure the refusal earns still holds. + assert.equal(error.details?.dispatched, 'no'); + // Before the cause had a name, this same failure ordered a session restart, a resend, and a clean + // rebuild of the very binary this macOS refuses to load (#3449). None of that may survive. + assert.equal(shouldRetryRunnerConnectError(error), false); + assert.equal(isRetryableRunnerError(error), false); + assert.equal(shouldRestartRunnerBeforeCommandSend(error, { command: 'tap' }), false); + assert.equal(shouldRebuildCachedRunnerArtifact(error), false); +}); + +/** A connect budget far longer than the bless refusal needs, so its arrival is a claim about timing. */ +const CONNECT_BUDGET_MS = 5_000; + +test('a bless refusal already in the launch log ends the connect loop on its first attempt', async () => { + // The reporters' 90 seconds came from spending the whole connect budget on a boot that had already + // said why it would never serve a command (#3449). The signature appears in `runner.log` within the + // first seconds of the launch, so the loop has to read it and stop. + const fetchSpy = vi.fn(async () => { + throw Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:8100'), { code: 'ECONNREFUSED' }); + }); + vi.stubGlobal('fetch', fetchSpy); + const session = sessionWithLaunchLog(BLESS_LOG, null); + const startedAt = Date.now(); + + const error = await waitForRunner( + iosSimulator, + RUNNER_ENDPOINT_FIXTURE, + { command: 'snapshot' }, + undefined, + CONNECT_BUDGET_MS, + session, + ).catch((error: unknown) => error as AppError); + + const elapsedMs = Date.now() - startedAt; + assert.ok(error instanceof AppError); + assert.equal(error.details?.reason, 'simulator_service_hub_unavailable'); + // No connect attempt is dialed once the boot is known terminal, and the failure arrives with most + // of the budget unspent rather than at its deadline. + assert.equal(fetchSpy.mock.calls.length, 0); + assert.ok( + elapsedMs < CONNECT_BUDGET_MS / 2, + `expected the failure well before its ${CONNECT_BUDGET_MS}ms deadline, took ${elapsedMs}ms`, + ); +}); + +test("one tail holding two launch-log causes gets the classifier's own winner, terminal", async () => { + // The row order decides, not the loop. A scoped-set launch whose log holds both the bless refusal and + // the missing destination reads as `simulator_set_destination_not_found`, the row that comes first. + // A pre-check of the loop's own would see the bless, order the stop, and hand back an error whose + // reason says otherwise — and the first fix did exactly that, so each remaining attempt re-threw + // without dialing until the deadline. One classifier over the tail cannot produce that pair. + const fetchSpy = vi.fn(async () => { + throw Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:8100'), { code: 'ECONNREFUSED' }); + }); + vi.stubGlobal('fetch', fetchSpy); + const scopedSimulator = { + ...iosSimulator, + id: CAPTURED_SCOPED_SIMULATOR.udid, + simulatorSetPath: CAPTURED_SCOPED_SIMULATOR.setWithoutUdid, + }; + const session = { + ...sessionWithLaunchLog(`${CAPTURED_LAUNCH_DESTINATION_NOT_FOUND_OUTPUT}\n${BLESS_LOG}`, null), + device: scopedSimulator, + deviceId: scopedSimulator.id, + }; + const startedAt = Date.now(); + + const error = await waitForRunner( + scopedSimulator, + RUNNER_ENDPOINT_FIXTURE, + { command: 'snapshot' }, + undefined, + CONNECT_BUDGET_MS, + session, + ).catch((error: unknown) => error as AppError); + + const elapsedMs = Date.now() - startedAt; + assert.ok(error instanceof AppError); + // The reason and the message agree because one row produced both, and the verdict beside them is the + // terminal one that class shares, so the loop stopped rather than re-throwing at the deadline. + assert.equal(error.details?.reason, 'simulator_set_destination_not_found'); + assert.ok(error.message.includes(CAPTURED_SCOPED_SIMULATOR.setWithoutUdid), error.message); + assert.doesNotMatch(error.message, /simulator service/); + assert.equal(shouldRetryRunnerConnectError(error), false); + assert.equal(fetchSpy.mock.calls.length, 0); + assert.ok( + elapsedMs < CONNECT_BUDGET_MS / 2, + `expected the failure well before its ${CONNECT_BUDGET_MS}ms deadline, took ${elapsedMs}ms`, + ); +}); + +test('a bless refusal in an older runner generation does not end this launch early', async () => { + // The log is append-only across launches, so a bless refusal the previous launch wrote is not + // evidence about this one (#2681). The session's tail reader starts at this generation's offset. + const runnerLogPath = path.join(mkdtempForTestSync('runner-service-hub-stale-'), 'runner.log'); + fs.writeFileSync(runnerLogPath, BLESS_LOG); + const generationStartOffset = BLESS_LOG.length; + fs.appendFileSync( + runnerLogPath, + '2026-10-15 10:24:41.112 xcodebuild[9:1] [MT] iOSSimulator: waiting for the simulator to boot\n', + ); + const session = { + ...sessionWithLaunchLog('', null), + runnerLogPath, + readLogTail: (maxBytes: number) => + readRunnerLogTail({ logPath: runnerLogPath, startOffset: generationStartOffset }, maxBytes), + }; + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:8100'), { + code: 'ECONNREFUSED', + }); + }), + ); + + await assert.rejects( + () => + waitForRunner( + iosSimulator, + RUNNER_ENDPOINT_FIXTURE, + { command: 'snapshot' }, + undefined, + 300, + session, + ), + (error: unknown) => { + // The ordinary connect failure, on the ordinary deadline: nothing named this boot. + assert.equal((error as AppError).details?.reason, 'IOS_RUNNER_CONNECT_TIMEOUT'); + return true; + }, + ); +}); + +test('a runner that already answered is not re-judged by its launch log', async () => { + // A bless line written during a boot that did serve commands (an Xcode fixed mid-session, say) must + // not refuse a later command on the strength of it. + const fetchSpy = vi.fn(async () => runnerTransportResponse()); + vi.stubGlobal('fetch', fetchSpy); + const session = { ...sessionWithLaunchLog(BLESS_LOG, null), state: 'ready' as const }; + + const response = await waitForRunner( + iosSimulator, + RUNNER_ENDPOINT_FIXTURE, + { command: 'snapshot' }, + undefined, + 300, + session, + ); + + assert.equal(response.status, 200); + assert.equal(fetchSpy.mock.calls.length, 1); +}); + +test('a still-alive xcodebuild whose log names no service-hub cause keeps the ordinary connect failure', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:8100'), { + code: 'ECONNREFUSED', + }); + }), + ); + + await assert.rejects( + () => + waitForRunner( + iosSimulator, + RUNNER_ENDPOINT_FIXTURE, + { command: 'snapshot' }, + undefined, + 300, + sessionWithLaunchLog( + '2026-10-15 10:23:41.112 xcodebuild[9:1] [MT] iOSSimulator: waiting for the simulator to boot\n', + null, + ), + ), + (error: unknown) => { + const appError = error as AppError; + assert.equal(appError.message, 'Runner did not accept connection'); + assert.equal(appError.details?.reason, 'IOS_RUNNER_CONNECT_TIMEOUT'); + return true; + }, + ); +}); + +test('bless wording without the typed reason cannot stop the connect retry or the resend', () => { + // The recovery rows key on the reason the launch paths publish, so foreign text carrying Xcode's + // own sentence — in a message or a captured log — cannot by itself mark a failure terminal. + const untyped = new AppError( + 'COMMAND_FAILED', + 'Runner did not accept connection: DTServiceHubClient failed to bless service hub for simulator iPhone (UDID)', + { stderr: BLESS_LOG }, + ); + + // The connect loop keeps waiting on it, exactly as it does on any unclassified connect failure. + assert.equal(shouldRetryRunnerConnectError(untyped), true); + assert.equal(shouldRestartRunnerAfterReadinessPreflight(untyped), false); +}); diff --git a/packages/platform-apple/src/runner/__tests__/runner-xctestrun-startup-failure-reasons.test.ts b/packages/platform-apple/src/runner/__tests__/runner-xctestrun-startup-failure-reasons.test.ts index d2aa865755..980b3b42af 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-xctestrun-startup-failure-reasons.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-xctestrun-startup-failure-reasons.test.ts @@ -66,6 +66,7 @@ const HINT_FOR_REASON: Record = { signing_provisioning_profile_missing: 'AGENT_DEVICE_IOS_PROVISIONING_PROFILE', signing_unspecified: 'Automatic Signing', devtools_security_developer_mode_disabled: 'DevToolsSecurity -enable', + simulator_service_hub_unavailable: 'xcode-select -s', simulator_set_destination_not_found: '-DVTSimulatorSetLocation', // Both device remedies are owned by `core/devicectl.ts` and travel on the device report, so this // table quotes them instead of restating them; `runner-device-readiness.test.ts` is where the diff --git a/packages/platform-apple/src/runner/runner-artifact.ts b/packages/platform-apple/src/runner/runner-artifact.ts index 2db0996401..b15275d240 100644 --- a/packages/platform-apple/src/runner/runner-artifact.ts +++ b/packages/platform-apple/src/runner/runner-artifact.ts @@ -21,8 +21,8 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { classifyRunnerStartupFailure } from './runner-error-classification.ts'; import { logChunk } from './runner-io.ts'; import { + runnerLaunchLogClassificationDetails, runnerSimulatorSetFailureDetails, - simulatorSetDestinationNotFoundMessage, xcodebuildDestinationArgs, } from './runner-device-set.ts'; import { @@ -925,11 +925,15 @@ async function buildRunnerXctestrun( if (isRequestCanceledError(error)) throw error; const appErr = error instanceof AppError ? error : new AppError('COMMAND_FAILED', String(error)); - const simulatorSet = runnerSimulatorSetFailureDetails(device); - // The reason and the hint beside it come from one classifier (#2680), so the reason a caller - // switches on can never disagree with the advice it is handed. - const { reason, hint, matched } = classifyRunnerStartupFailure( - new AppError(appErr.code, appErr.message, { ...appErr.details, ...simulatorSet }), + const publishedSimulatorSet = runnerSimulatorSetFailureDetails(device); + // The reason, the hint, and any cause named in the message all come from one classifier (#2680), so + // the reason a caller switches on can never disagree with the advice or the sentence beside it. + const classificationDetails = runnerLaunchLogClassificationDetails(device); + const { reason, hint, messageSuffix, matched } = classifyRunnerStartupFailure( + new AppError(appErr.code, appErr.message, { + ...appErr.details, + ...classificationDetails, + }), ); const hostDeadlineHit = isCommandTimeoutError(appErr); // `startupRuleMatched` travels with the verdict: this wrapper buries the tool's text a level too @@ -937,21 +941,15 @@ async function buildRunnerXctestrun( // alone (#2690 review). The device's own state is attached further out, by the startup catch that // can see this build and the launch after it. const message = 'xcodebuild build-for-testing failed'; - throw new AppError( - 'COMMAND_FAILED', - reason === 'simulator_set_destination_not_found' - ? simulatorSetDestinationNotFoundMessage(message, device, simulatorSet) - : message, - { - reason, - error: appErr.message, - details: appErr.details, - logPath: options.logPath, - hint, - startupRuleMatched: matched, - startupHostDeadlineHit: hostDeadlineHit, - ...simulatorSet, - }, - ); + throw new AppError('COMMAND_FAILED', messageSuffix ? `${message}: ${messageSuffix}` : message, { + reason, + error: appErr.message, + details: appErr.details, + logPath: options.logPath, + hint, + startupRuleMatched: matched, + startupHostDeadlineHit: hostDeadlineHit, + ...publishedSimulatorSet, + }); } } diff --git a/packages/platform-apple/src/runner/runner-device-set.ts b/packages/platform-apple/src/runner/runner-device-set.ts index 1a86aa4954..d08d5327fb 100644 --- a/packages/platform-apple/src/runner/runner-device-set.ts +++ b/packages/platform-apple/src/runner/runner-device-set.ts @@ -43,13 +43,15 @@ export function runnerSimulatorSetFailureDetails( } /** - * Names the simulator and the scoped set behind a `simulator_set_destination_not_found`, and the Xcode - * when this process has read its version. + * Supplies the classification details for one device's launch to `classifyRunnerStartupFailure`. + * Besides the scoped set and the Xcode version this process already read, it carries the simulator ID + * needed to describe a missing destination. + * + * This is an input shape, not a published shape: callers spread it into the error they classify and + * publish only {@link runnerSimulatorSetFailureDetails} beside their failure. */ -export function simulatorSetDestinationNotFoundMessage( - message: string, +export function runnerLaunchLogClassificationDetails( device: DeviceInfo, - details: RunnerSimulatorSetFailureDetails, -): string { - return `${message}: xcodebuild found no simulator ${device.id} in simulator set ${details.simulatorSetPath}${details.xcodeVersion === undefined ? '' : ` with Xcode ${details.xcodeVersion}`}`; +): RunnerSimulatorSetFailureDetails & { simulatorUdid: string } { + return { ...runnerSimulatorSetFailureDetails(device), simulatorUdid: device.id }; } diff --git a/packages/platform-apple/src/runner/runner-error-classification.ts b/packages/platform-apple/src/runner/runner-error-classification.ts index 025310c0a9..8a34cf3473 100644 --- a/packages/platform-apple/src/runner/runner-error-classification.ts +++ b/packages/platform-apple/src/runner/runner-error-classification.ts @@ -108,6 +108,15 @@ const hasRunnerConnectFailureReason = (reason: RunnerConnectFailureReason): RunnerErrorDetailsMatch => (details) => details.runnerConnectFailureReason === reason; +/** + * The terminal verdict of a runner launch whose cause the launch output itself names — the simulator + * service this macOS refused to load, or a scoped-set destination this Xcode cannot resolve. It keys on + * the reason the launch paths publish, never on the tool sentence they quote, so foreign text carrying + * Xcode's own wording cannot, on its own, mark a failure terminal. + */ +const hasLaunchLogCauseFailure: RunnerErrorDetailsMatch = (details) => + details.reason === SIMULATOR_SERVICE_HUB_UNAVAILABLE_REASON || + details.reason === SIMULATOR_SET_DESTINATION_NOT_FOUND_REASON; /** * The preflight marks whatever it was waiting on when it stopped, and one of the things it waits on * is a caller that stopped waiting. A canceled request is not a wedged runner: the restart this @@ -179,6 +188,7 @@ export const RUNNER_STARTUP_FAILURE_REASONS = [ 'signing_provisioning_profile_missing', 'signing_unspecified', 'devtools_security_developer_mode_disabled', + 'simulator_service_hub_unavailable', 'simulator_set_destination_not_found', ...RUNNER_DEVICE_READINESS_FAILURE_REASONS, 'build_failed_unclassified', @@ -190,6 +200,32 @@ export type RunnerStartupFailureReason = (typeof RUNNER_STARTUP_FAILURE_REASONS) export type RunnerDeviceReadinessFailureReason = (typeof RUNNER_DEVICE_READINESS_FAILURE_REASONS)[number]; +/** + * The reason for a simulator runner launch whose service hub the host macOS refused to load. macOS + * gates `DTServiceHub` behind a version-restriction entitlement, so an Xcode older than the macOS it + * runs on cannot bless the simulator service at all: every launch fails the same way until the host + * selects an Xcode that supports its macOS. AMFI's refusal is visible only as the bless line in the + * launch output, which is why the row keys on that phrase and why no host-side preflight exists. + */ +const SIMULATOR_SERVICE_HUB_UNAVAILABLE_REASON: RunnerStartupFailureReason = + 'simulator_service_hub_unavailable'; + +/** + * The reason for a scoped-set launch whose simulator this Xcode cannot resolve. The set is named on the + * command, so a launch that finds no matching destination fails identically on every retry. + */ +const SIMULATOR_SET_DESTINATION_NOT_FOUND_REASON: RunnerStartupFailureReason = + 'simulator_set_destination_not_found'; + +/** + * The bless refusal, in the two phrases Xcode writes on one line: the client that failed and the + * bless it failed to perform. + */ +const SIMULATOR_SERVICE_HUB_BLESS_MATCH = [ + 'dtservicehubclient', + 'failed to bless service hub', +] as const; + /** * The reason a startup failure carries when no rule proves a cause. Its hint is deliberately the * cache-recovery advice rather than anything about signing: an unclassified build is not evidence of @@ -213,6 +249,18 @@ type RunnerErrorRule = { buildFailure?: { reason: RunnerStartupFailureReason; hint: string; + /** + * The cause this row names inside the sentence the caller publishes, in the words the tool itself + * used. A row that carries it is a cause the startup paths can name from the launch output alone, + * so this module stays the only text matcher: a failure site deciding which causes deserve their own + * sentence would be a second classifier. Rows without it travel as a reason beside the caller's + * sentence. + * + * `details` is what the caller handed the classifier, so a row naming a per-launch fact reads it from + * there ({@link runnerLaunchLogClassificationDetails}). Returning undefined when a fact is missing + * leaves the caller's sentence standing. + */ + messageSuffix?: (details: AppErrorDetails) => string | undefined; }; }; @@ -284,6 +332,23 @@ export const RUNNER_ERROR_RULES: readonly RunnerErrorRule[] = [ }, verdicts: { retryable: false, connectRetry: false, artifactSuspect: false }, }, + { + // The terminal reading of every cause a launch log can name: a simulator service this macOS refuses + // to load, or a scoped-set destination this Xcode cannot resolve. Neither is cured by a connect + // retry, a session restart, or a rebuilt artifact, and this row has to precede the generic + // `runner_connect_refused` row to say so on a connect-shaped failure carrying one of them. It keys + // on the reason the launch paths publish, never on the tool sentence they quote, so foreign text + // wearing Xcode's own wording cannot mark a failure terminal on its own. + reason: 'runner_launch_log_cause', + match: { code: 'COMMAND_FAILED', details: hasLaunchLogCauseFailure }, + verdicts: { + retryable: false, + connectRetry: false, + restartBeforeSend: false, + restartAfterReadinessPreflight: false, + artifactSuspect: false, + }, + }, { // A device still mid-attachment is not a runner we can talk to yet, and waiting on // it inside this request is what the caller's own retry is for. @@ -391,6 +456,27 @@ export const RUNNER_ERROR_RULES: readonly RunnerErrorRule[] = [ buildFailure: { reason: 'simulator_set_destination_not_found', hint: 'Check that the simulator still exists in the --ios-simulator-device-set this error names (`xcrun simctl --set list devices`). If it does, the Xcode this error names no longer honors -DVTSimulatorSetLocation, so use a simulator in the default set.', + messageSuffix: (details) => + typeof details.simulatorSetPath === 'string' && typeof details.simulatorUdid === 'string' + ? `xcodebuild found no simulator ${details.simulatorUdid} in simulator set ${details.simulatorSetPath}${typeof details.xcodeVersion === 'string' ? ` with Xcode ${details.xcodeVersion}` : ''}` + : undefined, + }, + }, + { + // macOS validates Xcode's `DTServiceHub` against a version-restriction entitlement and refuses a + // build older than itself (AMFI: "disallowed without correct + // com.apple.private.amfi.version-restriction entitlement version"), so no launch of this Xcode's + // simulator service can succeed on this host. Both phrases are Xcode's own sentence and meet on + // the same line of the launch output; the domain names the client, the complaint names the bless. + reason: 'simulator_service_hub_bless_refused', + match: { + toolTextLineIncludesAll: SIMULATOR_SERVICE_HUB_BLESS_MATCH, + }, + verdicts: {}, + buildFailure: { + reason: SIMULATOR_SERVICE_HUB_UNAVAILABLE_REASON, + hint: 'This Xcode cannot start its simulator service on this macOS: macOS refuses to load its DTServiceHub. Select an Xcode that supports this macOS on the daemon host (`xcode-select -s /Applications/Xcode.app`, or DEVELOPER_DIR=/Applications/Xcode.app for the daemon process), then retry. Updating Xcode is the fix; restarting the runner or clearing derived data cannot change it.', + messageSuffix: () => 'this Xcode cannot start its simulator service on this macOS', }, }, { @@ -722,7 +808,12 @@ export function classifyRunnerStartupFailure(error: unknown): RunnerStartupClass const buildFailure = rule.buildFailure; if (!buildFailure) continue; if (matchesRunnerErrorRule(error, rule.match)) { - return { reason: buildFailure.reason, hint: buildFailure.hint, matched: true }; + return { + reason: buildFailure.reason, + hint: buildFailure.hint, + messageSuffix: buildFailure.messageSuffix?.(error.details ?? {}), + matched: true, + }; } } } @@ -742,6 +833,12 @@ export function classifyRunnerStartupFailure(error: unknown): RunnerStartupClass export type RunnerStartupClassification = Readonly<{ reason: RunnerStartupFailureReason; hint: string; + /** + * The cause in the tool's own words, when the matched row declares one ({@link RunnerErrorRule}). A + * caller publishing a sentence about this failure appends it, so the words naming the cause come from + * the same row that named the reason — never from a failure site choosing its own phrasing. + */ + messageSuffix?: string; /** * False only when no rule row matched at all. The catch that publishes this verdict carries it on * `details.startupRuleMatched`, which is the half a caller cannot recover from the reason alone. diff --git a/packages/platform-apple/src/runner/runner-startup-transport.ts b/packages/platform-apple/src/runner/runner-startup-transport.ts index 436cedae03..8d0fa08775 100644 --- a/packages/platform-apple/src/runner/runner-startup-transport.ts +++ b/packages/platform-apple/src/runner/runner-startup-transport.ts @@ -21,7 +21,7 @@ import { RUNNER_CACHE_RECOVERY_HINT, runnerConnectFailureDetails, shouldRetryRunnerConnectError, - type IosRunnerDeviceStates, + type RunnerStartupFailureReason, } from './runner-error-classification.ts'; import { type RunnerCommand, @@ -39,10 +39,11 @@ export type RunnerConnectionSession = Pick< | 'readLogTail' | 'device' | 'runnerLogPath' + | 'state' >; import { + runnerLaunchLogClassificationDetails, runnerSimulatorSetFailureDetails, - simulatorSetDestinationNotFoundMessage, } from './runner-device-set.ts'; import { canFallBackFromUsbmux, postRunnerCommandOverRoute } from './runner-transport.ts'; @@ -149,11 +150,12 @@ export async function waitForRunner( } throw withRunnerWriteEvidence( buildRunnerConnectError({ + device, port, url: route.url, logPath, lastError, - deviceStates: session?.startupDeviceStates, + session, }), commandMayHaveBeenWritten, ); @@ -217,6 +219,7 @@ async function attemptRunnerConnection(params: { async function ensureRunnerAttemptCanStart(params: { endpoint: RunnerEndpoint; + route: RunnerCommandRoute; timeoutMs: number; logPath?: string; session?: RunnerConnectionSession; @@ -235,6 +238,28 @@ async function ensureRunnerAttemptCanStart(params: { logPath: params.logPath, }); } + // A launch whose log already names a cause the classifier calls terminal will never serve a + // command, and its child may stay alive to the deadline, so without this the request waits out the + // whole connect budget before reaching the failure that names the cause (#3449). The verdict is the + // classifier's over the tail — this adds no matching of its own — and it is acted on only when the + // table says a retry cannot help, which is the same predicate the retry loop applies to errors. + // Checked while the session has yet to answer: once a runner has answered, its log is no longer + // evidence about a boot that demonstrably succeeded. Bounded to the tail an error would quote, read + // from this generation's offset, and no child process is spawned for it. + const session = params.session; + if (session?.state !== 'starting') return; + const launchLogOutput = session.readLogTail?.(RUNNER_EARLY_EXIT_LOG_TAIL_BYTES) ?? ''; + if (!launchLogOutput) return; + const terminalError = buildRunnerConnectError({ + device: session.device, + port: params.endpoint.port, + url: params.route.url, + logPath: params.logPath, + lastError: null, + session, + launchLogOutput, + }); + if (!shouldRetryRunnerConnectError(terminalError)) throw terminalError; } async function tryPrimaryRunnerRoute(params: { @@ -395,31 +420,80 @@ export function resolveRunnerEarlyExitHint( return `${bootFailureHint(classified)} ${RUNNER_CACHE_RECOVERY_HINT}`; } +/** The cause a runner launch log names, in the shape both connect-stage failures publish. */ +type RunnerLaunchLogCause = Readonly<{ + message: string; + reason: RunnerStartupFailureReason; + hint: string; +}>; + +/** + * The cause readable from the launch output when the runner never served a command, if any. Which + * outputs carry a cause, what it is called, and what the sentence beside it says are all decided by the + * classifier's rows: this reads the tail's verdict and folds the row's own words into the message, so + * reason, message and hint cannot disagree about which cause this was (#3449). No cause is claimed for + * anything no row named: the boot-failure classification owns those. + */ +function resolveRunnerLaunchLogCause(params: { + message: string; + output: string; + device: DeviceInfo; +}): RunnerLaunchLogCause | undefined { + const classified = classifyRunnerStartupFailure( + new AppError('COMMAND_FAILED', params.message, { + stderr: params.output, + ...runnerLaunchLogClassificationDetails(params.device), + }), + ); + if (!classified.messageSuffix) return undefined; + return { + message: `${params.message}: ${classified.messageSuffix}`, + reason: classified.reason, + hint: classified.hint, + }; +} + function buildRunnerConnectError(params: { + device: DeviceInfo; port: number; url: string | null; logPath?: string; lastError: unknown; - deviceStates?: IosRunnerDeviceStates; + session?: RunnerConnectionSession; + /** The launch tail already read for this decision; read here when it is absent. */ + launchLogOutput?: string; }): AppError { - const { port, url, logPath, lastError, deviceStates } = params; + const { device, port, url, logPath, lastError, session } = params; const message = 'Runner did not accept connection'; - const error = new AppError('COMMAND_FAILED', message, { + const launchLogCause = resolveRunnerLaunchLogCause({ + message, + output: + params.launchLogOutput ?? session?.readLogTail?.(RUNNER_EARLY_EXIT_LOG_TAIL_BYTES) ?? '', + device, + }); + const error = new AppError('COMMAND_FAILED', launchLogCause?.message ?? message, { port, url, - logPath, + // A cause read from the launch log has to name the file the reader can check it against; the + // request's own log is not necessarily that file (#2681). + logPath: launchLogCause ? (session?.runnerLogPath ?? logPath) : logPath, lastError: lastError ? String(lastError) : undefined, - reason: classifyBootFailure({ - error: lastError, - message, - context: { platform: 'ios', phase: 'connect' }, - }), - hint: bootFailureHint('IOS_RUNNER_CONNECT_TIMEOUT'), + reason: + launchLogCause?.reason ?? + classifyBootFailure({ + error: lastError, + message, + context: { platform: 'ios', phase: 'connect' }, + }), + hint: launchLogCause?.hint ?? bootFailureHint('IOS_RUNNER_CONNECT_TIMEOUT'), ...runnerConnectFailureDetails('runner_connect_refused'), }); // The other way the connect stage gives up: `xcodebuild` is still alive at the deadline. It gets // the same enrichment as the early exit below (#2683). - return enrichRunnerStartupFailureWithDeviceStates(error, deviceStates) as AppError; + return enrichRunnerStartupFailureWithDeviceStates( + error, + session?.startupDeviceStates, + ) as AppError; } export async function buildRunnerEarlyExitError(params: { @@ -440,37 +514,26 @@ export async function buildRunnerEarlyExitError(params: { context: { platform: 'ios', phase: 'connect' }, }); const simulatorSet = runnerSimulatorSetFailureDetails(session.device); - const setDestination = classifyRunnerStartupFailure( - new AppError('COMMAND_FAILED', message, { stderr: output, ...simulatorSet }), - ); - const setDestinationMissing = setDestination.reason === 'simulator_set_destination_not_found'; + const launchLogCause = resolveRunnerLaunchLogCause({ message, output, device: session.device }); // exec-guard-allow: xcodebuild can exit 0 and still count as an early exit; // the trio is nested tool context under `xcodebuild`, classified into // `reason`/`hint` above — not a process-exit wrap. - const error = new AppError( - 'COMMAND_FAILED', - setDestinationMissing - ? simulatorSetDestinationNotFoundMessage(message, session.device, simulatorSet) - : message, - { - port, - // The quote always comes from the runner's own file, so that is the file the error has to name; - // pointing at the request's log would advertise a file that does not contain what is quoted (#2681). - logPath: session.runnerLogPath ?? logPath, - xcodebuild: { - exitCode: result.exitCode, - // One merged file since #2681: the tail is reported under `stderr`, which is where readers - // already look, next to the file it came from. - stderr: output, - }, - reason: setDestinationMissing ? setDestination.reason : reason, - hint: setDestinationMissing - ? setDestination.hint - : resolveRunnerEarlyExitHint(message, output, output, reason), - ...simulatorSet, - ...runnerConnectFailureDetails('xcodebuild_exited_early'), + const error = new AppError('COMMAND_FAILED', launchLogCause?.message ?? message, { + port, + // The quote always comes from the runner's own file, so that is the file the error has to name; + // pointing at the request's log would advertise a file that does not contain what is quoted (#2681). + logPath: session.runnerLogPath ?? logPath, + xcodebuild: { + exitCode: result.exitCode, + // One merged file since #2681: the tail is reported under `stderr`, which is where readers + // already look, next to the file it came from. + stderr: output, }, - ); + reason: launchLogCause?.reason ?? reason, + hint: launchLogCause?.hint ?? resolveRunnerEarlyExitHint(message, output, output, reason), + ...simulatorSet, + ...runnerConnectFailureDetails('xcodebuild_exited_early'), + }); // The build catch is not the only way a runner stops before serving a command. A locked phone lets // the build finish and kills `xcodebuild test-without-building` instead, so nothing reaches that // catch and the disk-image state read before the build would be dropped. Same enrichment, applied diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index 62fec6a4a6..9db8680ddb 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -496,7 +496,7 @@ iOS physical-device prerequisites: If Xcode cannot choose a profile, set AGENT_DEVICE_IOS_PROVISIONING_PROFILE to the profile name/specifier, not a file path. AGENT_DEVICE_IOS_SIGNING_IDENTITY is optional; omit it unless xcodebuild asks for a specific identity. The profile/team must allow AGENT_DEVICE_IOS_BUNDLE_ID and .uitests. - A runner startup failure names its class in error details.reason rather than only in prose: signing_no_development_team, signing_provisioning_profile_missing, bundle_identifier_already_registered, signing_unspecified, devtools_security_developer_mode_disabled (the Mac's DevToolsSecurity setting, which says nothing about the device's Developer Mode toggle), device_developer_mode_disabled (read from an iPhone's own report before the runner builds, and the only device state that stops a run up front), device_developer_disk_image_unavailable (also read from the device, and published on a startup failure that named no cause of its own, since iOS 17+ mounts the developer disk image on demand during build and launch rather than gating the build), or build_failed_unclassified when nothing proved a cause. The two device reasons are never inferred from tool output or from each other. Branch on details.reason and follow hint; the message is for humans. + A runner startup failure names its class in error details.reason rather than only in prose: signing_no_development_team, signing_provisioning_profile_missing, bundle_identifier_already_registered, signing_unspecified, devtools_security_developer_mode_disabled (the Mac's DevToolsSecurity setting, which says nothing about the device's Developer Mode toggle), simulator_service_hub_unavailable (an Xcode too old for the host macOS: macOS refuses to load its DTServiceHub, so every runner launch fails; the command stops as soon as the refusal reaches runner.log, and selecting an Xcode that supports this macOS on the daemon host is the fix), simulator_set_destination_not_found (a --ios-simulator-device-set simulator the selected Xcode cannot resolve; names the set), device_developer_mode_disabled (read from an iPhone's own report before the runner builds, and the only device state that stops a run up front), device_developer_disk_image_unavailable (also read from the device, and published on a startup failure that named no cause of its own, since iOS 17+ mounts the developer disk image on demand during build and launch rather than gating the build), or build_failed_unclassified when nothing proved a cause. The two device reasons are never inferred from tool output or from each other. Branch on details.reason and follow hint; the message is for humans. First-run XCTest setup/build can take longer than normal commands; keep the device connected and use --debug to inspect signing/build diagnostics if setup times out. Android physical-device prerequisites: diff --git a/test/repo-guards/runner-startup-reason-docs.test.ts b/test/repo-guards/runner-startup-reason-docs.test.ts new file mode 100644 index 0000000000..4195c89d65 --- /dev/null +++ b/test/repo-guards/runner-startup-reason-docs.test.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from 'vitest'; + +/** + * `error.details.reason` is a published vocabulary: a client that branches on it treats an + * undocumented member as an unknown value. Nothing but this file connects the union to the two + * surfaces that enumerate it, so a new reason added to the classifier has to be documented in the + * same change. The reason union is the source of truth; the docs are read as text because they carry + * the prose a reader follows, not a machine-readable list. + */ + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const classificationPath = path.join( + repoRoot, + 'packages/platform-apple/src/runner/runner-error-classification.ts', +); +const installationDocPath = path.join(repoRoot, 'website/docs/docs/installation.md'); +const cliHelpPath = path.join(repoRoot, 'src/commands/schema/cli-help.ts'); + +/** The two reasons the device reports about itself, spread into the startup union. */ +const DEVICE_READINESS_MEMBERS = 'RUNNER_DEVICE_READINESS_FAILURE_REASONS'; + +test('the runner startup reason union is documented in full at both prose surfaces', () => { + const reasons = startupFailureReasons(); + + for (const { surface, line } of [ + { surface: 'installation.md', line: startupReasonSentence(readFile(installationDocPath)) }, + { surface: 'cli-help.ts', line: startupReasonSentence(readFile(cliHelpPath)) }, + ]) { + for (const reason of reasons) { + assert.ok( + line.includes(reason), + `${surface} must document the ${reason} reason: the union in ` + + `${path.relative(repoRoot, classificationPath)} publishes it and a client cannot branch on ` + + 'a value it was never told exists.', + ); + } + } +}); + +/** + * Every member of `RUNNER_STARTUP_FAILURE_REASONS`, including the ones spread in from the + * device-readiness list. Anything the two lists no longer read as fails rather than silently + * shrinking the set the guard checks. + */ +function startupFailureReasons(): string[] { + const source = readFile(classificationPath); + const reasons: string[] = []; + for (const member of literalMembers(source, 'RUNNER_STARTUP_FAILURE_REASONS')) { + if (!member.startsWith('...')) { + reasons.push(member); + continue; + } + assert.equal( + member, + `...${DEVICE_READINESS_MEMBERS}`, + `unknown spread in RUNNER_STARTUP_FAILURE_REASONS: ${member}`, + ); + reasons.push(...literalMembers(source, DEVICE_READINESS_MEMBERS)); + } + assert.ok(reasons.length > 0, 'the startup reason union must not read as empty'); + return reasons; +} + +/** The members of one `export const X = [...] as const;` literal, in source order. */ +function literalMembers(source: string, name: string): string[] { + const declaration = new RegExp(`export const ${name} = \\[([\\s\\S]*?)\\] as const`).exec(source); + assert.ok(declaration, `${name} no longer reads as a string-literal union`); + return [...declaration[1]!.matchAll(/'([a-z_]+)'|\.\.\.([A-Z_]+)/g)].map((match) => + match[1] === undefined ? `...${match[2]}` : match[1]!, + ); +} + +/** The sentence that enumerates the startup reasons, found on the same line at both surfaces. */ +function startupReasonSentence(text: string): string { + const line = text + .split('\n') + .find((candidate) => /error details\.reason|`error\.details\.reason`/.test(candidate)); + assert.ok(line, 'no sentence enumerating error.details.reason was found'); + return line; +} + +function readFile(absolutePath: string): string { + return fs.readFileSync(absolutePath, 'utf8'); +} diff --git a/website/docs/docs/installation.md b/website/docs/docs/installation.md index c76c2e3fcc..5c474502a1 100644 --- a/website/docs/docs/installation.md +++ b/website/docs/docs/installation.md @@ -112,7 +112,8 @@ Run `agent-device help physical-device` for the same setup guidance from the CLI - Set `AGENT_DEVICE_IOS_SIGNING_IDENTITY` only when `xcodebuild` asks for a specific identity. - The profile and team must allow both `AGENT_DEVICE_IOS_BUNDLE_ID` and `.uitests`. - Free Apple Developer (Personal Team) accounts can fail with "bundle identifier is not available" for generic IDs. Set `AGENT_DEVICE_IOS_BUNDLE_ID` to a unique reverse-DNS value (for example `com.yourname.agentdevice.runner`). -- When the runner fails to start, `error.details.reason` is one of `signing_no_development_team`, `signing_provisioning_profile_missing`, `bundle_identifier_already_registered`, `signing_unspecified`, `devtools_security_developer_mode_disabled` (the Mac's `DevToolsSecurity` setting, which says nothing about the device's Developer Mode toggle), `device_developer_mode_disabled`, `device_developer_disk_image_unavailable`, or `build_failed_unclassified` when the cause is unknown. Branch on `details.reason` and follow `hint`; the error code is `COMMAND_FAILED` for every reason. +- When the runner fails to start, `error.details.reason` is one of `signing_no_development_team`, `signing_provisioning_profile_missing`, `bundle_identifier_already_registered`, `signing_unspecified`, `devtools_security_developer_mode_disabled` (the Mac's `DevToolsSecurity` setting, which says nothing about the device's Developer Mode toggle), `simulator_service_hub_unavailable`, `simulator_set_destination_not_found` (a `--ios-simulator-device-set` simulator the selected Xcode cannot resolve, which names the set), `device_developer_mode_disabled`, `device_developer_disk_image_unavailable`, or `build_failed_unclassified` when the cause is unknown. Branch on `details.reason` and follow `hint`; the error code is `COMMAND_FAILED` for every reason. +- `simulator_service_hub_unavailable` means the selected Xcode is too old for the host macOS: macOS refuses to load that Xcode's `DTServiceHub`, so every iOS Simulator runner start fails. Commands the runner doesn't serve keep working — `open`, and a `snapshot` or `screenshot` the simulator bridge answers — while a capture the runner has to serve (`snapshot --actions`, `snapshot --observe-only`, or one the bridge can't answer) fails the same way. The `DTServiceHub` refusal is visible in `runner.log`. Fix it on the machine running the daemon: `xcode-select -s /Applications/Xcode.app`, or start the daemon with `DEVELOPER_DIR=/Applications/Xcode.app`, pointing at an Xcode that supports this macOS. The command fails as soon as the refusal reaches `runner.log` instead of waiting out the connect timeout, and the failure is not retried or rebuilt, because neither can change which Xcode the host can load. - The two `device_*` reasons come from the device itself (`xcrun devicectl device info details`), checked before the runner builds. `device_developer_mode_disabled` means the Developer Mode toggle in Settings is off; turn it on. `device_developer_disk_image_unavailable` means Developer Mode is on but the developer disk image isn't available yet, usually because device support is still installing; wait and retry. - The first run builds the Apple runner and takes longer than later commands. Keep the device connected; if setup times out, retry with `--debug` to see signing and build diagnostics. - If you set `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` together with `AGENT_DEVICE_IOS_CLEAN_DERIVED`, keep the path in a subdirectory of the project's `.tmp/` directory. For any other path, the command fails instead of cleaning it, and the error hint names both fixes.