Skip to content

Commit dfd5c71

Browse files
authored
perf: speed up hot iOS taps (#572)
* perf: skip fresh iOS tap preflight * perf: use app root for iOS coordinate taps * perf: simplify iOS coordinate tap payloads * fix: tighten hot iOS tap safety window * chore: trace skipped iOS tap preflights
1 parent 6617d52 commit dfd5c71

4 files changed

Lines changed: 140 additions & 19 deletions

File tree

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1359,12 +1359,17 @@ extension RunnerTests {
13591359

13601360
#if !os(tvOS)
13611361
private func interactionCoordinate(app: XCUIApplication, x: Double, y: Double) -> XCUICoordinate {
1362+
#if os(iOS)
1363+
let origin = app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
1364+
return origin.withOffset(CGVector(dx: x, dy: y))
1365+
#else
13621366
let root = interactionRoot(app: app)
13631367
let origin = root.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
13641368
let rootFrame = root.frame
13651369
let offsetX = x - Double(rootFrame.origin.x)
13661370
let offsetY = y - Double(rootFrame.origin.y)
13671371
return origin.withOffset(CGVector(dx: offsetX, dy: offsetY))
1372+
#endif
13681373
}
13691374
#endif
13701375

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

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,48 @@ test('runner session probes readiness before mutating commands', async () => {
161161
});
162162
});
163163

164-
test('runner session probes readiness before mutating commands even when marked ready', async () => {
164+
test('runner session skips readiness preflight for tap commands after a recent successful response', async () => {
165+
const session = makeRunnerSession({ ready: true, lastSuccessfulRunnerResponseAtMs: Date.now() });
166+
mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true }));
167+
168+
const result = await executeRunnerCommandWithSession(
169+
IOS_SIMULATOR,
170+
session,
171+
{ command: 'tap', x: 120, y: 240, appBundleId: 'com.example.demo' },
172+
'/tmp/runner.log',
173+
30_000,
174+
);
175+
176+
assert.deepEqual(result, { tapped: true });
177+
assert.equal(mockWaitForRunner.mock.calls.length, 0);
178+
assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1);
179+
});
180+
181+
test('runner session skips readiness preflight for tapSeries after a recent successful response', async () => {
182+
const session = makeRunnerSession({ ready: true, lastSuccessfulRunnerResponseAtMs: Date.now() });
183+
mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true }));
184+
185+
const result = await executeRunnerCommandWithSession(
186+
IOS_SIMULATOR,
187+
session,
188+
{
189+
command: 'tapSeries',
190+
x: 120,
191+
y: 240,
192+
count: 2,
193+
intervalMs: 80,
194+
appBundleId: 'com.example.demo',
195+
},
196+
'/tmp/runner.log',
197+
30_000,
198+
);
199+
200+
assert.deepEqual(result, { tapped: true });
201+
assert.equal(mockWaitForRunner.mock.calls.length, 0);
202+
assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1);
203+
});
204+
205+
test('runner session keeps readiness preflight for tap commands when ready but never proven fresh', async () => {
165206
const session = makeRunnerSession({ ready: true });
166207
mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 }));
167208
mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true }));
@@ -180,6 +221,47 @@ test('runner session probes readiness before mutating commands even when marked
180221
assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1);
181222
});
182223

224+
test('runner session keeps readiness preflight for tap commands when marked ready but stale', async () => {
225+
const session = makeRunnerSession({
226+
ready: true,
227+
lastSuccessfulRunnerResponseAtMs: Date.now() - 11_000,
228+
});
229+
mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 }));
230+
mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true }));
231+
232+
const result = await executeRunnerCommandWithSession(
233+
IOS_SIMULATOR,
234+
session,
235+
{ command: 'tap', x: 120, y: 240, appBundleId: 'com.example.demo' },
236+
'/tmp/runner.log',
237+
30_000,
238+
);
239+
240+
assert.deepEqual(result, { tapped: true });
241+
assert.equal(mockWaitForRunner.mock.calls.length, 1);
242+
assert.deepEqual(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' });
243+
assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1);
244+
});
245+
246+
test('runner session keeps readiness preflight for non-tap mutating commands when marked ready', async () => {
247+
const session = makeRunnerSession({ ready: true, lastSuccessfulRunnerResponseAtMs: Date.now() });
248+
mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 }));
249+
mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ pressed: true }));
250+
251+
const result = await executeRunnerCommandWithSession(
252+
IOS_SIMULATOR,
253+
session,
254+
{ command: 'longPress', x: 120, y: 240, appBundleId: 'com.example.demo' },
255+
'/tmp/runner.log',
256+
30_000,
257+
);
258+
259+
assert.deepEqual(result, { pressed: true });
260+
assert.equal(mockWaitForRunner.mock.calls.length, 1);
261+
assert.deepEqual(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' });
262+
assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1);
263+
});
264+
183265
test('runner session preserves structured runner failures', async () => {
184266
const session = makeRunnerSession({ ready: true });
185267
mockWaitForRunner.mockResolvedValueOnce(

src/platforms/ios/runner-session-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type RunnerSession = {
1111
testPromise: Promise<ExecResult>;
1212
child: ExecBackgroundResult['child'];
1313
ready: boolean;
14+
lastSuccessfulRunnerResponseAtMs?: number;
1415
startupTimings?: Record<string, number>;
1516
startupTimingsReported?: boolean;
1617
simulatorSetRedirect?: { release: () => Promise<void> };

src/platforms/ios/runner-session.ts

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ const runnerSessionLocks = new Map<string, Promise<unknown>>();
4343
const RUNNER_STOP_WAIT_TIMEOUT_MS = 10_000;
4444
const RUNNER_INVALIDATE_WAIT_TIMEOUT_MS = 1_000;
4545
const RUNNER_READY_PREFLIGHT_TIMEOUT_MS = 5_000;
46+
// Hot tap loops can skip one uptime preflight only while the runner has just responded.
47+
// Failed mutating taps still invalidate the session instead of being replayed.
48+
const RUNNER_TAP_PREFLIGHT_SKIP_FRESHNESS_MS = 10_000;
4649
const RUNNER_SHUTDOWN_TIMEOUT_MS = 15_000;
4750

4851
function withRunnerSessionLock<T>(deviceId: string, task: () => Promise<T>): Promise<T> {
@@ -441,24 +444,40 @@ export async function executeRunnerCommandWithSession(
441444
}
442445

443446
const deadline = Deadline.fromTimeoutMs(timeoutMs);
444-
const readinessTimeoutMs = session.ready
445-
? Math.min(RUNNER_READY_PREFLIGHT_TIMEOUT_MS, deadline.remainingMs())
446-
: Math.min(RUNNER_STARTUP_TIMEOUT_MS, deadline.remainingMs());
447-
const readinessResponse = await withDiagnosticTimer(
448-
'ios_runner_readiness_preflight',
449-
async () =>
450-
await waitForRunner(
451-
device,
452-
session.port,
453-
{ command: 'uptime' },
454-
logPath,
455-
readinessTimeoutMs,
456-
session,
457-
signal,
458-
),
459-
{ command: command.command, sessionReady: session.ready, timeoutMs: readinessTimeoutMs },
460-
);
461-
await parseRunnerResponse(readinessResponse, session, logPath);
447+
const shouldPreflight = shouldPreflightMutatingRunnerCommand(session, command);
448+
if (shouldPreflight) {
449+
const readinessTimeoutMs = session.ready
450+
? Math.min(RUNNER_READY_PREFLIGHT_TIMEOUT_MS, deadline.remainingMs())
451+
: Math.min(RUNNER_STARTUP_TIMEOUT_MS, deadline.remainingMs());
452+
const readinessResponse = await withDiagnosticTimer(
453+
'ios_runner_readiness_preflight',
454+
async () =>
455+
await waitForRunner(
456+
device,
457+
session.port,
458+
{ command: 'uptime' },
459+
logPath,
460+
readinessTimeoutMs,
461+
session,
462+
signal,
463+
),
464+
{ command: command.command, sessionReady: session.ready, timeoutMs: readinessTimeoutMs },
465+
);
466+
await parseRunnerResponse(readinessResponse, session, logPath);
467+
} else {
468+
emitDiagnostic({
469+
level: 'debug',
470+
phase: 'ios_runner_readiness_preflight_skipped',
471+
data: {
472+
command: command.command,
473+
lastSuccessfulRunnerResponseAgeMs:
474+
session.lastSuccessfulRunnerResponseAtMs === undefined
475+
? undefined
476+
: Date.now() - session.lastSuccessfulRunnerResponseAtMs,
477+
sessionReady: session.ready,
478+
},
479+
});
480+
}
462481
const remainingMs = deadline.remainingMs();
463482
if (remainingMs <= 0) {
464483
throw new AppError('COMMAND_FAILED', 'Runner command deadline exceeded', { timeoutMs });
@@ -508,12 +527,26 @@ export async function parseRunnerResponse(
508527
});
509528
}
510529
session.ready = true;
530+
session.lastSuccessfulRunnerResponseAtMs = Date.now();
511531
if (json.data && typeof json.data === 'object' && !Array.isArray(json.data)) {
512532
return json.data as Record<string, unknown>;
513533
}
514534
return {};
515535
}
516536

537+
function shouldPreflightMutatingRunnerCommand(
538+
session: RunnerSession,
539+
command: RunnerCommand,
540+
): boolean {
541+
if (!session.ready) return true;
542+
if (command.command !== 'tap' && command.command !== 'tapSeries') return true;
543+
const lastSuccessAt = session.lastSuccessfulRunnerResponseAtMs;
544+
return (
545+
lastSuccessAt === undefined ||
546+
Date.now() - lastSuccessAt > RUNNER_TAP_PREFLIGHT_SKIP_FRESHNESS_MS
547+
);
548+
}
549+
517550
async function measureRunnerStartupStep<T>(
518551
timings: Record<string, number>,
519552
phase: string,

0 commit comments

Comments
 (0)