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
11 changes: 11 additions & 0 deletions src/__tests__/cli-help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ test('appstate --help prints command help and skips daemon dispatch', async () =
assert.match(result.stdout, /Global flags:/);
});

test('prepare help documents iOS runner CI setup', async () => {
const result = await runCliCapture(['help', 'prepare']);
assert.equal(result.code, 0);
assert.equal(result.calls.length, 0);
assert.match(result.stdout, /prepare ios-runner --platform ios\|macos/);
assert.match(result.stdout, /health-checks the XCTest runner/);
assert.match(result.stdout, /after boot\/install and before replay\/test/);
});

test('connect help documents cloud auth environment origins', async () => {
const result = await runCliCapture(['help', 'connect']);
assert.equal(result.code, 0);
Expand Down Expand Up @@ -91,6 +100,8 @@ test('help workflow preserves known device workaround guidance', async () => {
assert.match(result.stdout, /agent-device clipboard write "some text"/);
assert.match(result.stdout, /provider-native text injection when available/);
assert.match(result.stdout, /Do not switch to raw adb, clipboard, or paste as an agent fallback/);
assert.match(result.stdout, /exact key that includes the agent-device package and Xcode version/);
assert.match(result.stdout, /Avoid broad restore-key fallbacks/);
});

test('help unknown command prints error plus global usage and skips daemon dispatch', async () => {
Expand Down
129 changes: 129 additions & 0 deletions src/platforms/ios/__tests__/runner-command-retry.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { beforeEach, test, vi } from 'vitest';
import assert from 'node:assert/strict';
import { IOS_SIMULATOR } from '../../../__tests__/test-utils/index.ts';
import { clearRequestCanceled, markRequestCanceled } from '../../../daemon/request-cancel.ts';
import { AppError } from '../../../utils/errors.ts';
import type { RunnerSession } from '../runner-session-types.ts';

Expand Down Expand Up @@ -128,6 +129,134 @@ test('prepareIosRunner invalidates rebuilt sessions when bad-cache recovery heal
]);
});

test('prepareIosRunner retries a fresh launch session when the health check cannot connect', async () => {
const stuckSession = makeRunnerSession({ port: 8100 });
const relaunchedSession = makeRunnerSession({ port: 8101 });

mockEnsureRunnerSession.mockResolvedValueOnce(stuckSession).mockResolvedValueOnce(relaunchedSession);
mockExecuteRunnerCommandWithSession
.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection'))
.mockResolvedValueOnce({ uptimeMs: 42 });

const result = await prepareIosRunner(IOS_SIMULATOR, {
healthTimeoutMs: 90_000,
buildTimeoutMs: 300_000,
});

assert.deepEqual(result.runner, { uptimeMs: 42 });
assert.equal(result.recoveryReason, 'Runner did not accept connection');
assert.equal(mockEnsureRunnerSession.mock.calls[0]?.[1]?.cleanStaleBundles, undefined);
assert.deepEqual(mockInvalidateRunnerSession.mock.calls, [
[stuckSession, 'prepare_runner_health_retry'],
]);
assert.equal(mockEnsureRunnerSession.mock.calls.length, 2);
assert.equal(mockEnsureRunnerSession.mock.calls[1]?.[1]?.cleanStaleBundles, true);
assert.equal(mockEnsureRunnerSession.mock.calls[1]?.[1]?.forceRunnerXctestrunRebuild, undefined);
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[1]?.[1], relaunchedSession);
assert.deepEqual(
mockEmitDiagnostic.mock.calls.find(
([event]) => event.phase === 'ios_runner_prepare_health_retry',
)?.[0].data,
{
command: 'uptime',
commandId: mockExecuteRunnerCommandWithSession.mock.calls[0]?.[2].commandId,
sessionId: stuckSession.sessionId,
attempt: 1,
maxAttempts: 2,
reason: 'Runner did not accept connection',
},
);
});

test('prepareIosRunner does not force a rebuild when the relaunched fresh session still cannot connect', async () => {
const missArtifact = makeRunnerArtifact({
xctestrunPath: '/tmp/miss.xctestrun',
cache: 'miss',
artifact: 'valid',
});
const exactArtifact = makeRunnerArtifact({
xctestrunPath: '/tmp/exact.xctestrun',
cache: 'exact',
artifact: 'valid',
});
const stuckSession = makeRunnerSession({
port: 8100,
xctestrunPath: missArtifact.xctestrunPath,
xctestrunArtifact: missArtifact,
});
const relaunchedSession = makeRunnerSession({
port: 8101,
xctestrunPath: exactArtifact.xctestrunPath,
xctestrunArtifact: exactArtifact,
});

mockEnsureRunnerSession.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'));

await assert.rejects(
() =>
prepareIosRunner(IOS_SIMULATOR, {
healthTimeoutMs: 90_000,
forceRunnerXctestrunRebuild: false,
}),
/Runner did not accept connection/,
);

assert.deepEqual(mockInvalidateRunnerSession.mock.calls, [
[stuckSession, 'prepare_runner_health_retry'],
[relaunchedSession, 'prepare_runner_health_failed'],
]);
assert.equal(mockMarkRunnerXctestrunArtifactBadForRun.mock.calls.length, 0);
assert.equal(mockEnsureRunnerSession.mock.calls.length, 2);
assert.equal(mockEnsureRunnerSession.mock.calls[0]?.[1]?.cleanStaleBundles, undefined);
assert.equal(mockEnsureRunnerSession.mock.calls[0]?.[1]?.forceRunnerXctestrunRebuild, false);
assert.equal(mockEnsureRunnerSession.mock.calls[1]?.[1]?.cleanStaleBundles, true);
assert.equal(mockEnsureRunnerSession.mock.calls[1]?.[1]?.forceRunnerXctestrunRebuild, false);
});

test('prepareIosRunner does not relaunch after non-retryable runner startup failures', async () => {
const failedSession = makeRunnerSession({ port: 8100 });

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

await assert.rejects(
() => prepareIosRunner(IOS_SIMULATOR, { healthTimeoutMs: 90_000 }),
/xcodebuild exited early/,
);

assert.equal(mockEnsureRunnerSession.mock.calls.length, 1);
assert.equal(mockInvalidateRunnerSession.mock.calls.length, 0);
assert.equal(mockMarkRunnerXctestrunArtifactBadForRun.mock.calls.length, 0);
});

test('prepareIosRunner does not relaunch after request cancellation', async () => {
const requestId = 'prepare-canceled-before-retry';
const stuckSession = makeRunnerSession({ port: 8100 });

mockEnsureRunnerSession.mockResolvedValueOnce(stuckSession);
mockExecuteRunnerCommandWithSession.mockImplementationOnce(() => {
markRequestCanceled(requestId);
throw new AppError('COMMAND_FAILED', 'Runner did not accept connection');
});

try {
await assert.rejects(
() => prepareIosRunner(IOS_SIMULATOR, { healthTimeoutMs: 90_000, requestId }),
/request canceled/,
);
} finally {
clearRequestCanceled(requestId);
}

assert.equal(mockEnsureRunnerSession.mock.calls.length, 1);
assert.equal(mockInvalidateRunnerSession.mock.calls.length, 0);
});

test('mutating commands restart stale ready sessions when the preflight probe never reaches the runner', async () => {
const staleSession = makeRunnerSession({ port: 8100, ready: true });
const freshSession = makeRunnerSession({ port: 8101, ready: false });
Expand Down
Loading
Loading