Skip to content

Commit 7d7b467

Browse files
authored
fix: retry iOS runner prepare launch (#692)
1 parent 36012a9 commit 7d7b467

6 files changed

Lines changed: 335 additions & 60 deletions

File tree

src/__tests__/cli-help.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,15 @@ test('appstate --help prints command help and skips daemon dispatch', async () =
4040
assert.match(result.stdout, /Global flags:/);
4141
});
4242

43+
test('prepare help documents iOS runner CI setup', async () => {
44+
const result = await runCliCapture(['help', 'prepare']);
45+
assert.equal(result.code, 0);
46+
assert.equal(result.calls.length, 0);
47+
assert.match(result.stdout, /prepare ios-runner --platform ios\|macos/);
48+
assert.match(result.stdout, /health-checks the XCTest runner/);
49+
assert.match(result.stdout, /after boot\/install and before replay\/test/);
50+
});
51+
4352
test('connect help documents cloud auth environment origins', async () => {
4453
const result = await runCliCapture(['help', 'connect']);
4554
assert.equal(result.code, 0);
@@ -91,6 +100,8 @@ test('help workflow preserves known device workaround guidance', async () => {
91100
assert.match(result.stdout, /agent-device clipboard write "some text"/);
92101
assert.match(result.stdout, /provider-native text injection when available/);
93102
assert.match(result.stdout, /Do not switch to raw adb, clipboard, or paste as an agent fallback/);
103+
assert.match(result.stdout, /exact key that includes the agent-device package and Xcode version/);
104+
assert.match(result.stdout, /Avoid broad restore-key fallbacks/);
94105
});
95106

96107
test('help unknown command prints error plus global usage and skips daemon dispatch', async () => {

src/platforms/ios/__tests__/runner-command-retry.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { beforeEach, test, vi } from 'vitest';
22
import assert from 'node:assert/strict';
33
import { IOS_SIMULATOR } from '../../../__tests__/test-utils/index.ts';
4+
import { clearRequestCanceled, markRequestCanceled } from '../../../daemon/request-cancel.ts';
45
import { AppError } from '../../../utils/errors.ts';
56
import type { RunnerSession } from '../runner-session-types.ts';
67

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

132+
test('prepareIosRunner retries a fresh launch session when the health check cannot connect', async () => {
133+
const stuckSession = makeRunnerSession({ port: 8100 });
134+
const relaunchedSession = makeRunnerSession({ port: 8101 });
135+
136+
mockEnsureRunnerSession.mockResolvedValueOnce(stuckSession).mockResolvedValueOnce(relaunchedSession);
137+
mockExecuteRunnerCommandWithSession
138+
.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection'))
139+
.mockResolvedValueOnce({ uptimeMs: 42 });
140+
141+
const result = await prepareIosRunner(IOS_SIMULATOR, {
142+
healthTimeoutMs: 90_000,
143+
buildTimeoutMs: 300_000,
144+
});
145+
146+
assert.deepEqual(result.runner, { uptimeMs: 42 });
147+
assert.equal(result.recoveryReason, 'Runner did not accept connection');
148+
assert.equal(mockEnsureRunnerSession.mock.calls[0]?.[1]?.cleanStaleBundles, undefined);
149+
assert.deepEqual(mockInvalidateRunnerSession.mock.calls, [
150+
[stuckSession, 'prepare_runner_health_retry'],
151+
]);
152+
assert.equal(mockEnsureRunnerSession.mock.calls.length, 2);
153+
assert.equal(mockEnsureRunnerSession.mock.calls[1]?.[1]?.cleanStaleBundles, true);
154+
assert.equal(mockEnsureRunnerSession.mock.calls[1]?.[1]?.forceRunnerXctestrunRebuild, undefined);
155+
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[1]?.[1], relaunchedSession);
156+
assert.deepEqual(
157+
mockEmitDiagnostic.mock.calls.find(
158+
([event]) => event.phase === 'ios_runner_prepare_health_retry',
159+
)?.[0].data,
160+
{
161+
command: 'uptime',
162+
commandId: mockExecuteRunnerCommandWithSession.mock.calls[0]?.[2].commandId,
163+
sessionId: stuckSession.sessionId,
164+
attempt: 1,
165+
maxAttempts: 2,
166+
reason: 'Runner did not accept connection',
167+
},
168+
);
169+
});
170+
171+
test('prepareIosRunner does not force a rebuild when the relaunched fresh session still cannot connect', async () => {
172+
const missArtifact = makeRunnerArtifact({
173+
xctestrunPath: '/tmp/miss.xctestrun',
174+
cache: 'miss',
175+
artifact: 'valid',
176+
});
177+
const exactArtifact = makeRunnerArtifact({
178+
xctestrunPath: '/tmp/exact.xctestrun',
179+
cache: 'exact',
180+
artifact: 'valid',
181+
});
182+
const stuckSession = makeRunnerSession({
183+
port: 8100,
184+
xctestrunPath: missArtifact.xctestrunPath,
185+
xctestrunArtifact: missArtifact,
186+
});
187+
const relaunchedSession = makeRunnerSession({
188+
port: 8101,
189+
xctestrunPath: exactArtifact.xctestrunPath,
190+
xctestrunArtifact: exactArtifact,
191+
});
192+
193+
mockEnsureRunnerSession.mockResolvedValueOnce(stuckSession).mockResolvedValueOnce(relaunchedSession);
194+
mockExecuteRunnerCommandWithSession
195+
.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection'))
196+
.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection'));
197+
198+
await assert.rejects(
199+
() =>
200+
prepareIosRunner(IOS_SIMULATOR, {
201+
healthTimeoutMs: 90_000,
202+
forceRunnerXctestrunRebuild: false,
203+
}),
204+
/Runner did not accept connection/,
205+
);
206+
207+
assert.deepEqual(mockInvalidateRunnerSession.mock.calls, [
208+
[stuckSession, 'prepare_runner_health_retry'],
209+
[relaunchedSession, 'prepare_runner_health_failed'],
210+
]);
211+
assert.equal(mockMarkRunnerXctestrunArtifactBadForRun.mock.calls.length, 0);
212+
assert.equal(mockEnsureRunnerSession.mock.calls.length, 2);
213+
assert.equal(mockEnsureRunnerSession.mock.calls[0]?.[1]?.cleanStaleBundles, undefined);
214+
assert.equal(mockEnsureRunnerSession.mock.calls[0]?.[1]?.forceRunnerXctestrunRebuild, false);
215+
assert.equal(mockEnsureRunnerSession.mock.calls[1]?.[1]?.cleanStaleBundles, true);
216+
assert.equal(mockEnsureRunnerSession.mock.calls[1]?.[1]?.forceRunnerXctestrunRebuild, false);
217+
});
218+
219+
test('prepareIosRunner does not relaunch after non-retryable runner startup failures', async () => {
220+
const failedSession = makeRunnerSession({ port: 8100 });
221+
222+
mockEnsureRunnerSession.mockResolvedValueOnce(failedSession);
223+
mockExecuteRunnerCommandWithSession.mockRejectedValueOnce(
224+
new AppError('COMMAND_FAILED', 'xcodebuild exited early'),
225+
);
226+
227+
await assert.rejects(
228+
() => prepareIosRunner(IOS_SIMULATOR, { healthTimeoutMs: 90_000 }),
229+
/xcodebuild exited early/,
230+
);
231+
232+
assert.equal(mockEnsureRunnerSession.mock.calls.length, 1);
233+
assert.equal(mockInvalidateRunnerSession.mock.calls.length, 0);
234+
assert.equal(mockMarkRunnerXctestrunArtifactBadForRun.mock.calls.length, 0);
235+
});
236+
237+
test('prepareIosRunner does not relaunch after request cancellation', async () => {
238+
const requestId = 'prepare-canceled-before-retry';
239+
const stuckSession = makeRunnerSession({ port: 8100 });
240+
241+
mockEnsureRunnerSession.mockResolvedValueOnce(stuckSession);
242+
mockExecuteRunnerCommandWithSession.mockImplementationOnce(() => {
243+
markRequestCanceled(requestId);
244+
throw new AppError('COMMAND_FAILED', 'Runner did not accept connection');
245+
});
246+
247+
try {
248+
await assert.rejects(
249+
() => prepareIosRunner(IOS_SIMULATOR, { healthTimeoutMs: 90_000, requestId }),
250+
/request canceled/,
251+
);
252+
} finally {
253+
clearRequestCanceled(requestId);
254+
}
255+
256+
assert.equal(mockEnsureRunnerSession.mock.calls.length, 1);
257+
assert.equal(mockInvalidateRunnerSession.mock.calls.length, 0);
258+
});
259+
131260
test('mutating commands restart stale ready sessions when the preflight probe never reaches the runner', async () => {
132261
const staleSession = makeRunnerSession({ port: 8100, ready: true });
133262
const freshSession = makeRunnerSession({ port: 8101, ready: false });

0 commit comments

Comments
 (0)