Skip to content

Commit 66119e2

Browse files
authored
perf: adapt ios runner uptime preflight (#662)
1 parent 7f035f3 commit 66119e2

4 files changed

Lines changed: 363 additions & 27 deletions

File tree

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

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,28 @@ test('mutating commands restart stale sessions when readiness preflight times ou
143143
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[1]?.[1], freshSession);
144144
});
145145

146+
test('mutating commands emit readiness recovery diagnostics after failed preflight restart succeeds', async () => {
147+
const staleSession = makeRunnerSession({ port: 8100, ready: true });
148+
const freshSession = makeRunnerSession({ port: 8101, ready: false });
149+
150+
mockEnsureRunnerSession.mockResolvedValueOnce(staleSession).mockResolvedValueOnce(freshSession);
151+
mockExecuteRunnerCommandWithSession
152+
.mockRejectedValueOnce(
153+
new AppError('COMMAND_FAILED', 'fetch failed', {
154+
runnerReadinessPreflightFailed: true,
155+
}),
156+
)
157+
.mockResolvedValueOnce({ message: 'tapped' });
158+
159+
const diagnostics = await captureDiagnostics(async () => {
160+
const result = await runIosRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 });
161+
assert.deepEqual(result, { message: 'tapped' });
162+
});
163+
164+
assert.match(diagnostics, /ios_runner_readiness_preflight_recovered/);
165+
assert.match(diagnostics, /"recovery":"session_restarted"/);
166+
});
167+
146168
test('mutating commands do not restart or replay after command send failure', async () => {
147169
const session = makeRunnerSession({ port: 8100, ready: true });
148170

@@ -197,6 +219,35 @@ test('mutating commands recover cached responses before invalidating after comma
197219
assert.equal(statusCommand.statusCommandId, sentCommand.commandId);
198220
});
199221

222+
test('mutating commands run status recovery after transport failure when readiness preflight was skipped', async () => {
223+
const session = makeRunnerSession({ port: 8100, ready: true });
224+
225+
mockEnsureRunnerSession.mockResolvedValueOnce(session);
226+
mockExecuteRunnerCommandWithSession
227+
.mockRejectedValueOnce(
228+
new AppError('COMMAND_FAILED', 'fetch failed', {
229+
runnerReadinessPreflightSkipped: true,
230+
runnerReadinessPreflightSkipReason: 'recent_successful_response',
231+
}),
232+
)
233+
.mockResolvedValueOnce({
234+
lifecycleState: 'completed',
235+
lifecycleResponseJson: JSON.stringify({ ok: true, data: { message: 'tapped' } }),
236+
});
237+
238+
const diagnostics = await captureDiagnostics(async () => {
239+
const result = await runIosRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 });
240+
assert.deepEqual(result, { message: 'tapped' });
241+
});
242+
243+
assert.equal(mockInvalidateRunnerSession.mock.calls.length, 0);
244+
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2);
245+
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[1]?.[2].command, 'status');
246+
assert.match(diagnostics, /ios_runner_command_status_recovery/);
247+
assert.match(diagnostics, /"readinessPreflightSkipped":true/);
248+
assert.match(diagnostics, /"readinessPreflightSkipReason":"recent_successful_response"/);
249+
});
250+
200251
test('mutating commands keep invalidating when status cannot find the command', async () => {
201252
const session = makeRunnerSession({ port: 8100, ready: true });
202253

@@ -348,6 +399,36 @@ test('mutating commands report recovery guidance when completed status has no re
348399
});
349400
});
350401

402+
test('mutating commands include skipped readiness context in lost-response guidance', async () => {
403+
const session = makeRunnerSession({ port: 8100, ready: true });
404+
405+
mockEnsureRunnerSession.mockResolvedValueOnce(session);
406+
mockExecuteRunnerCommandWithSession
407+
.mockRejectedValueOnce(
408+
new AppError('COMMAND_FAILED', 'fetch failed', {
409+
runnerReadinessPreflightSkipped: true,
410+
runnerReadinessPreflightSkipReason: 'recent_successful_response',
411+
runnerReadinessPreflightSkippedAgeMs: 4,
412+
}),
413+
)
414+
.mockResolvedValueOnce({ lifecycleState: 'completed' });
415+
416+
await assert.rejects(
417+
() => runIosRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }),
418+
(error: unknown) => {
419+
assert.ok(error instanceof AppError);
420+
assert.equal(error.details?.recovery, 'completed_without_retained_response');
421+
assert.equal(error.details?.readinessPreflightSkipped, true);
422+
assert.equal(error.details?.readinessPreflightSkipReason, 'recent_successful_response');
423+
assert.equal(error.details?.readinessPreflightSkippedAgeMs, 4);
424+
assert.match(String(error.details?.hint), /skipped the uptime preflight/);
425+
assert.match(String(error.details?.hint), /status recovery confirmed/);
426+
assert.match(String(error.details?.hint), /snapshot -i/);
427+
return true;
428+
},
429+
);
430+
});
431+
351432
test('mutating commands preserve runner failure details from status recovery', async () => {
352433
const session = makeRunnerSession({ port: 8100, ready: true });
353434

@@ -502,3 +583,8 @@ function makeRunnerSession(overrides: Partial<RunnerSession> = {}): RunnerSessio
502583
...overrides,
503584
} as RunnerSession;
504585
}
586+
587+
async function captureDiagnostics(callback: () => Promise<void>): Promise<string> {
588+
await callback();
589+
return JSON.stringify(mockEmitDiagnostic.mock.calls.map(([event]) => event));
590+
}

src/platforms/ios/__tests__/runner-session.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import assert from 'node:assert/strict';
22
import { EventEmitter } from 'node:events';
3+
import fs from 'node:fs';
4+
import os from 'node:os';
5+
import path from 'node:path';
36
import { beforeEach, test, vi } from 'vitest';
47
import { IOS_SIMULATOR } from '../../../__tests__/test-utils/index.ts';
58
import { AppError } from '../../../utils/errors.ts';
9+
import { flushDiagnosticsToSessionFile, withDiagnosticsScope } from '../../../utils/diagnostics.ts';
610
import type { RunnerSession } from '../runner-session-types.ts';
711

812
const {
@@ -196,6 +200,25 @@ test('runner session probes readiness before mutating commands', async () => {
196200
});
197201
});
198202

203+
test('runner session emits reason diagnostics when readiness preflight is used', async () => {
204+
const session = makeRunnerSession({ ready: false });
205+
mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 }));
206+
mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true }));
207+
208+
const diagnostics = await captureDiagnostics(async () => {
209+
await executeRunnerCommandWithSession(
210+
IOS_SIMULATOR,
211+
session,
212+
{ command: 'tap', x: 120, y: 240, appBundleId: 'com.example.demo' },
213+
'/tmp/runner.log',
214+
30_000,
215+
);
216+
});
217+
218+
assert.match(diagnostics, /"reason":"startup"/);
219+
assert.match(diagnostics, /ios_runner_readiness_preflight/);
220+
});
221+
199222
test('runner session skips readiness preflight for tap commands after a recent successful response', async () => {
200223
const session = makeRunnerSession({ ready: true, lastSuccessfulRunnerResponseAtMs: Date.now() });
201224
mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true }));
@@ -213,6 +236,74 @@ test('runner session skips readiness preflight for tap commands after a recent s
213236
assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1);
214237
});
215238

239+
test('runner session emits explicit diagnostics when readiness preflight is skipped', async () => {
240+
const session = makeRunnerSession({ ready: true, lastSuccessfulRunnerResponseAtMs: Date.now() });
241+
mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true }));
242+
243+
const diagnostics = await captureDiagnostics(async () => {
244+
await executeRunnerCommandWithSession(
245+
IOS_SIMULATOR,
246+
session,
247+
{ command: 'tap', x: 120, y: 240, appBundleId: 'com.example.demo' },
248+
'/tmp/runner.log',
249+
30_000,
250+
);
251+
});
252+
253+
assert.match(diagnostics, /ios_runner_readiness_preflight_skipped/);
254+
assert.match(diagnostics, /"reason":"recent_successful_response"/);
255+
assert.doesNotMatch(diagnostics, /ios_runner_readiness_preflight_used/);
256+
});
257+
258+
test('runner session marks transport failures after skipped readiness preflight', async () => {
259+
const session = makeRunnerSession({ ready: true, lastSuccessfulRunnerResponseAtMs: Date.now() });
260+
mockSendRunnerCommandOnce.mockRejectedValueOnce(new Error('fetch failed'));
261+
262+
await assert.rejects(
263+
() =>
264+
executeRunnerCommandWithSession(
265+
IOS_SIMULATOR,
266+
session,
267+
{ command: 'tap', x: 120, y: 240, appBundleId: 'com.example.demo' },
268+
'/tmp/runner.log',
269+
30_000,
270+
),
271+
(error: unknown) => {
272+
assert.ok(error instanceof AppError);
273+
assert.equal(error.details?.runnerReadinessPreflightSkipped, true);
274+
assert.equal(error.details?.runnerReadinessPreflightSkipReason, 'recent_successful_response');
275+
return true;
276+
},
277+
);
278+
});
279+
280+
test('runner session does not mark runner response failures as skipped preflight transport failures', async () => {
281+
const session = makeRunnerSession({ ready: true, lastSuccessfulRunnerResponseAtMs: Date.now() });
282+
mockSendRunnerCommandOnce.mockResolvedValueOnce(
283+
runnerError({
284+
code: 'COMMAND_FAILED',
285+
message: 'Runner failed after receiving command',
286+
}),
287+
);
288+
289+
await assert.rejects(
290+
() =>
291+
executeRunnerCommandWithSession(
292+
IOS_SIMULATOR,
293+
session,
294+
{ command: 'tap', x: 120, y: 240, appBundleId: 'com.example.demo' },
295+
'/tmp/runner.log',
296+
30_000,
297+
),
298+
(error: unknown) => {
299+
assert.ok(error instanceof AppError);
300+
assert.equal(error.message, 'Runner failed after receiving command');
301+
assert.equal(error.details?.runnerReadinessPreflightSkipped, undefined);
302+
return true;
303+
},
304+
);
305+
});
306+
216307
test('runner session skips readiness preflight for selector taps after a recent successful response', async () => {
217308
const session = makeRunnerSession({ ready: true, lastSuccessfulRunnerResponseAtMs: Date.now() });
218309
mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true }));
@@ -489,6 +580,24 @@ function runnerError(error: { code: string; message: string }): Response {
489580
return new Response(JSON.stringify({ ok: false, error }));
490581
}
491582

583+
async function captureDiagnostics(callback: () => Promise<void>): Promise<string> {
584+
const previousHome = process.env.HOME;
585+
process.env.HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-runner-diag-'));
586+
try {
587+
return await withDiagnosticsScope(
588+
{ session: 'runner-session-test', requestId: 'request-1', command: 'tap' },
589+
async () => {
590+
await callback();
591+
const diagnosticsPath = flushDiagnosticsToSessionFile({ force: true });
592+
assert.ok(diagnosticsPath);
593+
return fs.readFileSync(diagnosticsPath, 'utf8');
594+
},
595+
);
596+
} finally {
597+
process.env.HOME = previousHome;
598+
}
599+
}
600+
492601
function assertRunnerCommand(
493602
actual: unknown,
494603
expected: Record<string, unknown>,

0 commit comments

Comments
 (0)