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
30 changes: 0 additions & 30 deletions packages/platform-apple/src/runner/__tests__/runner-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,6 @@ vi.mock('../runner-macos-products.ts', async () => {

import type { DeviceInfo } from '@agent-device/kernel/device';
import { RUNNER_COMMAND_TRAITS, isReadOnlyRunnerCommand } from '../runner-command-traits.ts';
import {
isRetryableRunnerError,
shouldRetryRunnerConnectError,
} from '../runner-error-classification.ts';
import { withRunnerCommandId, type RunnerCommand } from '../runner-contract.ts';
import {
resolveRunnerBuildDestination,
Expand Down Expand Up @@ -412,32 +408,6 @@ test('assertSafeDerivedCleanup allows cleaning override path under project .tmp'
});
});

test('shouldRetryRunnerConnectError does not retry xcodebuild early-exit errors', () => {
const err = new AppError(
'COMMAND_FAILED',
'Runner did not accept connection (xcodebuild exited early)',
);
assert.equal(shouldRetryRunnerConnectError(err), false);
});

test('shouldRetryRunnerConnectError retries transient connect errors', () => {
const err = new AppError('COMMAND_FAILED', 'Runner endpoint probe failed');
assert.equal(shouldRetryRunnerConnectError(err), true);
});

test('isRetryableRunnerError does not retry xcodebuild early-exit errors', () => {
const err = new AppError(
'COMMAND_FAILED',
'Runner did not accept connection (xcodebuild exited early)',
);
assert.equal(isRetryableRunnerError(err), false);
});

test('isRetryableRunnerError does not retry busy-connecting errors', () => {
const err = new AppError('COMMAND_FAILED', 'Device is busy (Connecting to iPhone)');
assert.equal(isRetryableRunnerError(err), false);
});

test('xctestrunReferencesProjectRoot rejects stale worktree artifacts', async () => {
const tmpDir = await makeTmpDir();
const xctestrunPath = path.join(tmpDir, 'AgentDeviceRunner.xctestrun');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { beforeEach, test, vi } from 'vitest';
import assert from 'node:assert/strict';
import { IOS_SIMULATOR } from './device-fixtures.ts';
import { createTestRequestCancellation } from './runner-session-fixtures.ts';
import { createTestRequestCancellation, runnerConnectFailure } from './runner-session-fixtures.ts';
import { AppError } from '@agent-device/kernel/errors';
import { Deadline } from '../host.ts';
import { appleRunnerTestHost } from '../test-host.ts';
Expand Down Expand Up @@ -71,7 +71,7 @@ test('prepareIosRunner marks a bad restored artifact and rebuilds once after hea
.mockResolvedValueOnce(fixtures.restoredSession)
.mockResolvedValueOnce(fixtures.rebuiltSession);
mockExecuteRunnerCommandWithSession
.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection'))
.mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused'))
.mockResolvedValueOnce({ uptimeMs: 42 });

const result = await prepareIosRunner(IOS_SIMULATOR, {
Expand Down Expand Up @@ -110,7 +110,7 @@ test('prepareIosRunner invalidates rebuilt sessions when bad-cache recovery heal
.mockResolvedValueOnce(restoredSession)
.mockResolvedValueOnce(rebuiltSession);
mockExecuteRunnerCommandWithSession
.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner endpoint probe failed'))
.mockRejectedValueOnce(runnerConnectFailure('runner_endpoint_probe_exhausted'))
.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner health timed out'));

await assert.rejects(
Expand Down Expand Up @@ -144,7 +144,7 @@ test('prepareIosRunner retries a fresh launch session when the health check cann
.mockResolvedValueOnce(stuckSession)
.mockResolvedValueOnce(relaunchedSession);
mockExecuteRunnerCommandWithSession
.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection'))
.mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused'))
.mockResolvedValueOnce({ uptimeMs: 42 });

const result = await prepareIosRunner(IOS_SIMULATOR, {
Expand Down Expand Up @@ -232,8 +232,8 @@ test('prepareIosRunner does not force a rebuild when the relaunched fresh sessio
.mockResolvedValueOnce(stuckSession)
.mockResolvedValueOnce(relaunchedSession);
mockExecuteRunnerCommandWithSession
.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection'))
.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection'));
.mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused'))
.mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused'));

await assert.rejects(
() =>
Expand Down Expand Up @@ -261,7 +261,7 @@ test('prepareIosRunner does not relaunch after non-retryable runner startup fail

mockEnsureRunnerSession.mockResolvedValueOnce(failedSession);
mockExecuteRunnerCommandWithSession.mockRejectedValueOnce(
new AppError('COMMAND_FAILED', 'xcodebuild exited early'),
runnerConnectFailure('xcodebuild_exited_early'),
);

await assert.rejects(
Expand All @@ -281,7 +281,7 @@ test('prepareIosRunner does not relaunch after request cancellation', async () =
mockEnsureRunnerSession.mockResolvedValueOnce(stuckSession);
mockExecuteRunnerCommandWithSession.mockImplementationOnce(() => {
markRequestCanceled(requestId);
throw new AppError('COMMAND_FAILED', 'Runner did not accept connection');
throw runnerConnectFailure('runner_connect_refused');
});

try {
Expand All @@ -303,7 +303,7 @@ test('mutating commands restart stale ready sessions when the preflight probe ne

mockEnsureRunnerSession.mockResolvedValueOnce(staleSession).mockResolvedValueOnce(freshSession);
mockExecuteRunnerCommandWithSession
.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection'))
.mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused'))
.mockResolvedValueOnce({ message: 'tapped' });

const result = await runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 });
Expand All @@ -326,7 +326,7 @@ test('mutating commands retry startup sessions with stale bundle cleanup', async

mockEnsureRunnerSession.mockResolvedValueOnce(startupSession).mockResolvedValueOnce(freshSession);
mockExecuteRunnerCommandWithSession
.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection'))
.mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused'))
.mockResolvedValueOnce({ message: 'tapped' });

const result = await runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 });
Expand Down Expand Up @@ -811,7 +811,7 @@ test('mutating commands invalidate the retry session without replaying again', a

mockEnsureRunnerSession.mockResolvedValueOnce(staleSession).mockResolvedValueOnce(freshSession);
mockExecuteRunnerCommandWithSession
.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection'))
.mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused'))
.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed'))
.mockResolvedValueOnce({ lifecycleState: 'notAccepted' });

Expand Down Expand Up @@ -1153,7 +1153,7 @@ test('a failed replacement boot does not consume the request recycle budget', as
const requestId = 'req-recycle-transient-boot-failure';
mockEnsureRunnerSession
.mockResolvedValueOnce(makeRunnerSession({ port: 8100, state: 'ready' }))
.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection'))
.mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused'))
.mockResolvedValueOnce(makeRunnerSession({ port: 8101, state: 'ready' }));
mockExecuteRunnerCommandWithSession
.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed'))
Expand Down Expand Up @@ -1204,7 +1204,7 @@ test('a later command in the same request cannot pay for a second recycle boot',

mockEnsureRunnerSession.mockResolvedValueOnce(staleSession).mockResolvedValueOnce(freshSession);
mockExecuteRunnerCommandWithSession
.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection'))
.mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused'))
.mockResolvedValueOnce({ message: 'tapped' });

// First command consumes the request's only recycle via restart-and-replay.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
shouldRestartRunnerBeforeCommandSend,
shouldRetryRunnerConnectError,
} from '../runner-error-classification.ts';
import { runnerConnectFailure } from './runner-session-fixtures.ts';

function commandFailed(message: string, details?: Record<string, unknown>): AppError {
return new AppError('COMMAND_FAILED', message, details);
Expand All @@ -27,20 +28,27 @@ test('every rule carries a unique reason', () => {
// --- retryable axis (isRetryableRunnerError) ---

test('transport-shaped failures are retryable', () => {
for (const message of [
'Runner did not accept connection on port 8100',
'fetch failed',
'connect ECONNREFUSED 127.0.0.1:8100',
'socket hang up',
]) {
assert.equal(
isRetryableRunnerError(
runnerConnectFailure(
'runner_connect_refused',
'Runner did not accept connection on port 8100',
),
),
true,
);
for (const message of ['fetch failed', 'connect ECONNREFUSED 127.0.0.1:8100', 'socket hang up']) {
assert.equal(isRetryableRunnerError(commandFailed(message)), true, message);
}
});

test('boot-shaped failures are not retryable', () => {
assert.equal(
isRetryableRunnerError(
commandFailed('Runner did not accept connection (xcodebuild exited early)'),
runnerConnectFailure(
'xcodebuild_exited_early',
'Runner did not accept connection (xcodebuild exited early)',
),
),
false,
);
Expand All @@ -51,7 +59,9 @@ test('boot-shaped failures are not retryable', () => {
});

test('an explicitly retriable flag wins over any message denial', () => {
const flagged = commandFailed('xcodebuild exited early', { retriable: true });
const flagged = runnerConnectFailure('xcodebuild_exited_early', 'xcodebuild exited early', {
retriable: true,
});
assert.equal(isRetryableRunnerError(flagged), true);
});

Expand All @@ -64,15 +74,22 @@ test('retryable requires an AppError with COMMAND_FAILED', () => {

test('connect loop keeps waiting by default, including for unknown errors', () => {
assert.equal(
shouldRetryRunnerConnectError(commandFailed('Runner did not accept connection')),
shouldRetryRunnerConnectError(
runnerConnectFailure('runner_connect_refused', 'Runner did not accept connection'),
),
true,
);
assert.equal(shouldRetryRunnerConnectError(new Error('anything')), true);
assert.equal(shouldRetryRunnerConnectError(new AppError('INVALID_ARGS', 'nope')), true);
});

test('connect loop stops for terminal verdicts', () => {
assert.equal(shouldRetryRunnerConnectError(commandFailed('xcodebuild exited early')), false);
assert.equal(
shouldRetryRunnerConnectError(
runnerConnectFailure('xcodebuild_exited_early', 'xcodebuild exited early'),
),
false,
);
const unattached = new AppError('DEVICE_NOT_FOUND', 'device not attached', {
usbmuxDeviceAttached: false,
});
Expand Down Expand Up @@ -140,27 +157,37 @@ test('a deadline on its own earns no recovery verdict', () => {

test('only a runner that never accepted a connection indicts the cached artifact', () => {
assert.equal(
shouldRebuildCachedRunnerArtifact(commandFailed('Runner endpoint probe failed')),
shouldRebuildCachedRunnerArtifact(
runnerConnectFailure('runner_endpoint_probe_exhausted', 'Runner endpoint probe failed'),
),
true,
);
assert.equal(
shouldRebuildCachedRunnerArtifact(commandFailed('Runner did not accept connection')),
shouldRebuildCachedRunnerArtifact(
runnerConnectFailure('runner_connect_refused', 'Runner did not accept connection'),
),
true,
);
assert.equal(
shouldRebuildCachedRunnerArtifact(
commandFailed('Runner did not accept connection (simctl spawn)', { port: 8100 }),
runnerConnectFailure(
'runner_connect_refused',
'Runner did not accept connection (simctl spawn)',
{
port: 8100,
},
),
),
true,
);
// Wiping derived data cannot fix a boot that refuses to compile, and its message
// otherwise reads as a refused connection.
// Wiping derived data cannot fix a boot that refuses to compile.
assert.equal(
shouldRebuildCachedRunnerArtifact(
commandFailed('Runner did not accept connection (xcodebuild exited early)', {
port: 8100,
logPath: '/tmp/runner.log',
}),
runnerConnectFailure(
'xcodebuild_exited_early',
'Runner did not accept connection (xcodebuild exited early)',
{ port: 8100, logPath: '/tmp/runner.log' },
),
),
false,
);
Expand Down Expand Up @@ -198,23 +225,67 @@ test('ordinary errors are never session-fatal', () => {

// --- restart-before-send axis (shouldRestartRunnerBeforeCommandSend) ---

test('a refused connection before send restarts the session, case-insensitively', () => {
test('a refused connection before send restarts the session', () => {
assert.equal(
shouldRestartRunnerBeforeCommandSend(commandFailed('Runner did not accept connection')),
true,
);
assert.equal(
shouldRestartRunnerBeforeCommandSend(commandFailed('runner did not accept connection')),
shouldRestartRunnerBeforeCommandSend(
runnerConnectFailure('runner_connect_refused', 'Runner did not accept connection'),
),
true,
);
});

test('a terminal connect verdict refuses the restart even when the message matches', () => {
const both = commandFailed('xcodebuild exited early: runner did not accept connection');
assert.equal(shouldRestartRunnerBeforeCommandSend(both), false);
test('an early exit or a foreign transport failure earns no restart before send', () => {
const earlyExit = runnerConnectFailure(
'xcodebuild_exited_early',
'xcodebuild exited early: runner did not accept connection',
);
assert.equal(shouldRestartRunnerBeforeCommandSend(earlyExit), false);
assert.equal(shouldRestartRunnerBeforeCommandSend(commandFailed('socket hang up')), false);
});

// --- typed connect-failure reasons (agent-device's own connect path) ---

test('xcodebuild_exited_early is decided by the typed reason, not the message', () => {
for (const message of ['Runner did not accept connection (xcodebuild exited early)', 'boom']) {
const error = runnerConnectFailure('xcodebuild_exited_early', message);
assert.equal(isRetryableRunnerError(error), false, message);
assert.equal(shouldRetryRunnerConnectError(error), false, message);
assert.equal(shouldRebuildCachedRunnerArtifact(error), false, message);
assert.equal(shouldRestartRunnerBeforeCommandSend(error), false, message);
}
// The same words without the reason earn no terminal verdict.
const untyped = commandFailed('Runner did not accept connection (xcodebuild exited early)');
assert.equal(shouldRetryRunnerConnectError(untyped), true);
});

test('runner_connect_refused is decided by the typed reason, not the message', () => {
for (const message of ['Runner did not accept connection', 'boom']) {
const error = runnerConnectFailure('runner_connect_refused', message);
assert.equal(isRetryableRunnerError(error), true, message);
assert.equal(shouldRetryRunnerConnectError(error), true, message);
assert.equal(shouldRebuildCachedRunnerArtifact(error), true, message);
assert.equal(shouldRestartRunnerBeforeCommandSend(error), true, message);
}
const untyped = commandFailed('Runner did not accept connection');
assert.equal(isRetryableRunnerError(untyped), false);
assert.equal(shouldRebuildCachedRunnerArtifact(untyped), false);
assert.equal(shouldRestartRunnerBeforeCommandSend(untyped), false);
});

test('runner_endpoint_probe_exhausted is decided by the typed reason, not the message', () => {
for (const message of ['Runner endpoint probe failed', 'boom']) {
const error = runnerConnectFailure('runner_endpoint_probe_exhausted', message);
assert.equal(shouldRebuildCachedRunnerArtifact(error), true, message);
assert.equal(isRetryableRunnerError(error), false, message);
assert.equal(shouldRestartRunnerBeforeCommandSend(error), false, message);
assert.equal(shouldRetryRunnerConnectError(error), true, message);
}
assert.equal(
shouldRebuildCachedRunnerArtifact(commandFailed('Runner endpoint probe failed')),
false,
);
});

// The literals are what the Swift runner encodes, so they are the contract and not the constant
// names: a rename on one side has to fail here rather than silently split the pair (#2728).
test('a refused screen capture keeps the runner reason and stays off the wire code', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import {
import { appleRunnerTestHost } from '../test-host.ts';
import type { RunnerXctestrunArtifact } from '../runner-xctestrun.ts';
import { IOS_SIMULATOR } from './device-fixtures.ts';
import { createTestRequestCancellation, makeRunnerSession } from './runner-session-fixtures.ts';
import {
createTestRequestCancellation,
makeRunnerSession,
runnerConnectFailure,
} from './runner-session-fixtures.ts';

const {
mockEnsureRunnerSession,
Expand Down Expand Up @@ -216,7 +220,10 @@ test('a boot that exited early does not wipe a restored runner artifact', async

mockEnsureRunnerSession.mockResolvedValueOnce(restoredSession);
mockExecuteRunnerCommandWithSession.mockRejectedValueOnce(
new AppError('COMMAND_FAILED', 'Runner did not accept connection (xcodebuild exited early)'),
runnerConnectFailure(
'xcodebuild_exited_early',
'Runner did not accept connection (xcodebuild exited early)',
),
);

await assert.rejects(
Expand Down
Loading
Loading