Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion src/platforms/__tests__/boot-diagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
import {
bootFailureHint,
classifyBootFailure,
isInfrastructureBootFailureReason,
} from '../boot-diagnostics.ts';
import { AppError } from '@agent-device/kernel/errors';

test('classifyBootFailure maps timeout errors', () => {
Expand Down Expand Up @@ -57,3 +61,54 @@ test('connect phase does not classify non-timeout errors as connect timeout', ()
});
assert.equal(reason, 'BOOT_COMMAND_FAILED');
});

test('classifies a runner install blocked by provisioning, not as a connect timeout', () => {
// Real xcodebuild output from an iPhone that was not in the signing account.
// The installer prose around it is localized by macOS, so only the CoreDevice
// error code and the English framework strings can be matched.
const stderr = [
'AgentDeviceRunnerUITests-Runner encountered an error (Failed to install or launch the test runner.',
'(Underlying Error: Nie można zainstalować „AgentDeviceRunnerUITests-Runner”.',
'Failed to install embedded profile for com.callstack.agentdevice.runner.uitests.xctrunner :',
'0xe8008012 (This provisioning profile cannot be installed on this device.)))',
'** TEST EXECUTE FAILED **',
].join('\n');

const reason = classifyBootFailure({
message: 'Runner did not accept connection (xcodebuild exited early)',
stderr,
context: { platform: 'ios', phase: 'connect' },
});

assert.equal(reason, 'IOS_RUNNER_DEVICE_NOT_PROVISIONED');
assert.match(bootFailureHint(reason), /provisioning profile does not cover it/);
assert.match(bootFailureHint(reason), /Register the device/);
});

test('a provisioning failure is not treated as retryable infrastructure', () => {
// Retrying cannot register a device with a signing team.
assert.equal(isInfrastructureBootFailureReason('IOS_RUNNER_DEVICE_NOT_PROVISIONED'), false);
});

test.each([
'Provisioning profile "Agent Device" has expired.',
'Failed to install embedded profile: signing certificate is not valid.',
])('does not mistake an unrelated signing failure for an unregistered device: %s', (stderr) => {
const reason = classifyBootFailure({
message: 'Runner did not accept connection (xcodebuild exited early)',
stderr,
context: { platform: 'ios', phase: 'connect' },
});

assert.equal(reason, 'BOOT_COMMAND_FAILED');
});

test('still classifies a genuine runner connect timeout as such', () => {
assert.equal(
classifyBootFailure({
message: 'Runner did not accept connection',
context: { platform: 'ios', phase: 'connect' },
}),
'IOS_RUNNER_CONNECT_TIMEOUT',
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import type { AppError } from '@agent-device/kernel/errors';
import type { ExecBackgroundResult } from '../../../../utils/exec.ts';
import { buildRunnerEarlyExitError } from '../runner/runner-contract.ts';
import type { RunnerSession } from '../runner/runner-session-types.ts';

// Verbatim xcodebuild output from an iPhone that was not in the signing account.
// macOS localizes the installer prose, so the machine-readable anchors are the
// CoreDevice error code and the English framework strings around it.
const PROVISIONING_FAILURE_STDERR = [
'AgentDeviceRunnerUITests-Runner encountered an error (Failed to install or launch the test runner.',
'(Underlying Error: Nie można zainstalować „AgentDeviceRunnerUITests-Runner”.',
'Failed to install embedded profile for com.callstack.agentdevice.runner.uitests.xctrunner :',
'0xe8008012 (This provisioning profile cannot be installed on this device.))))',
'** TEST EXECUTE FAILED **',
].join('\n');

function sessionFailingWith(stdout: string, stderr: string): RunnerSession {
return {
sessionId: 'early-exit-session',
device: { platform: 'apple', id: 'device-1', name: 'iPhone', kind: 'device', booted: true },
deviceId: 'device-1',
port: 8100,
xctestrunPath: '/tmp/runner.xctestrun',
jsonPath: '/tmp/runner.json',
testPromise: Promise.resolve({ exitCode: 1, stdout, stderr }),
child: { pid: 4242, exitCode: 1 } as ExecBackgroundResult['child'],
ready: false,
};
}

test('the early-exit error a user actually receives names the provisioning cause', async () => {
// Regression: the reason was classified correctly while the hint was built
// separately and always returned connect-timeout guidance, so the shipped
// error still told people to retry a runner that can never install.
const error = (await buildRunnerEarlyExitError({
session: sessionFailingWith('', PROVISIONING_FAILURE_STDERR),
port: 8100,
})) as AppError;

assert.equal(error.details?.reason, 'IOS_RUNNER_DEVICE_NOT_PROVISIONED');
const hint = String(error.details?.hint);
assert.match(hint, /provisioning profile does not cover it/);
assert.match(hint, /Register the device/);
assert.doesNotMatch(hint, /Retry runner startup/);
// Clearing derived data cannot register a device, so that advice is withheld.
assert.doesNotMatch(hint, /clean:xcuitest/);
});

test('an ordinary early exit still gets connect-timeout and cache-recovery guidance', async () => {
const error = (await buildRunnerEarlyExitError({
session: sessionFailingWith('', 'xcodebuild: error: Timed out waiting for the test runner'),
port: 8100,
})) as AppError;

assert.equal(error.details?.reason, 'IOS_RUNNER_CONNECT_TIMEOUT');
assert.match(String(error.details?.hint), /Retry runner startup/);
assert.match(String(error.details?.hint), /clean:xcuitest/);
});

test('a busy connecting device keeps its own targeted hint', async () => {
const error = (await buildRunnerEarlyExitError({
session: sessionFailingWith('', 'The device is busy: connecting to device'),
port: 8100,
})) as AppError;

assert.match(String(error.details?.hint), /still connecting/);
});
16 changes: 13 additions & 3 deletions src/platforms/apple/core/runner/runner-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import {
getRequestSignal,
isRequestCanceled,
} from '../../../../request/cancel.ts';
import { bootFailureHint, classifyBootFailure } from '../../../boot-diagnostics.ts';
import {
bootFailureHint,
classifyBootFailure,
type BootFailureReason,
} from '../../../boot-diagnostics.ts';
import type { RunnerSession } from './runner-session-types.ts';

const RUNNER_CACHE_RECOVERY_HINT =
Expand Down Expand Up @@ -170,12 +174,18 @@ export function resolveRunnerEarlyExitHint(
message: string,
stdout: string,
stderr: string,
reason?: BootFailureReason,
): string {
const haystack = `${message}\n${stdout}\n${stderr}`.toLowerCase();
if (haystack.includes('device is busy') && haystack.includes('connecting')) {
return 'Target iOS device is still connecting. Keep it unlocked, wait for device trust/connection to settle, then retry.';
}
return `${bootFailureHint('IOS_RUNNER_CONNECT_TIMEOUT')} ${RUNNER_CACHE_RECOVERY_HINT}`;
const classified = reason ?? 'IOS_RUNNER_CONNECT_TIMEOUT';
// Clearing cached build products cannot put a device into a provisioning
// profile, so that recovery advice is withheld where it would only add noise
// to an already actionable instruction.
if (classified === 'IOS_RUNNER_DEVICE_NOT_PROVISIONED') return bootFailureHint(classified);
return `${bootFailureHint(classified)} ${RUNNER_CACHE_RECOVERY_HINT}`;
}

export function buildRunnerConnectError(params: {
Expand Down Expand Up @@ -226,7 +236,7 @@ export async function buildRunnerEarlyExitError(params: {
stderr: result.stderr,
},
reason,
hint: resolveRunnerEarlyExitHint(message, result.stdout, result.stderr),
hint: resolveRunnerEarlyExitHint(message, result.stdout, result.stderr, reason),
});
}

Expand Down
5 changes: 4 additions & 1 deletion src/platforms/apple/core/runner/runner-recycle-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,10 @@ export function buildRunnerRecycleBudgetExhaustedError(
command: command.command,
commandId: command.commandId,
recovery: 'runner_recycle_budget_exhausted',
hint: 'The current screen is overwhelming the iOS accessibility capture (usually heavy or animating content). The app session is preserved: run `screenshot` for visual truth and interact with coordinate commands, or navigate to another screen and retry. Re-running the same command immediately will likely wedge again.',
// This path only knows that a restart was already spent, never why the
// runner failed. Naming the heavy-screen case as the cause sent people
// to change screens when the runner had in fact failed to install.
hint: 'Check the runner log for the underlying failure before retrying — a provisioning or code-signing error there means the runner cannot install on this device, and no retry will help. If the runner is healthy, the current screen is likely too heavy or animating for accessibility capture: the app session is preserved, so run `screenshot` for visual truth and interact by coordinates, or navigate to another screen.',
logPath: options.logPath,
},
);
Expand Down
16 changes: 16 additions & 0 deletions src/platforms/boot-diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { asAppError } from '@agent-device/kernel/errors';
export type BootFailureReason =
| 'IOS_BOOT_TIMEOUT'
| 'IOS_RUNNER_CONNECT_TIMEOUT'
| 'IOS_RUNNER_DEVICE_NOT_PROVISIONED'
| 'IOS_TOOL_MISSING'
| 'ANDROID_BOOT_TIMEOUT'
| 'ADB_TRANSPORT_UNAVAILABLE'
Expand Down Expand Up @@ -71,6 +72,19 @@ export function classifyBootFailure(input: {
.join('\n')
.toLowerCase();

// Matched before the connect-timeout branch: a runner that could not be
// installed also never accepts a connection. The stable CoreDevice code is
// specific to a profile that does not cover the target device; generic
// profile text also appears for unrelated signing failures.
if (platform === 'ios' && haystack.includes('0xe8008012')) {
return 'IOS_RUNNER_DEVICE_NOT_PROVISIONED';
}
if (
platform === 'ios' &&
(haystack.includes('provisioning profile') || haystack.includes('embedded profile'))
) {
return 'BOOT_COMMAND_FAILED';
}
if (
platform === 'ios' &&
(haystack.includes('runner did not accept connection') ||
Expand Down Expand Up @@ -129,6 +143,8 @@ export function bootFailureHint(reason: BootFailureReason): string {
return 'Retry simulator boot and inspect simctl bootstatus logs; in CI reduce parallel jobs or use a larger runner.';
case 'IOS_RUNNER_CONNECT_TIMEOUT':
return 'Retry runner startup, inspect xcodebuild logs, and verify simulator responsiveness before command execution.';
case 'IOS_RUNNER_DEVICE_NOT_PROVISIONED':
return 'The XCTest runner cannot be installed on this device: its provisioning profile does not cover it. Register the device with the signing team (Xcode > Settings > Accounts, or add its UDID to the provisioning profile) and retry. Retrying without that will keep failing.';
case 'ANDROID_BOOT_TIMEOUT':
return 'Retry emulator startup and verify sys.boot_completed reaches 1; consider increasing startup budget in CI.';
case 'ADB_TRANSPORT_UNAVAILABLE':
Expand Down
Loading