Skip to content

Commit 094c290

Browse files
authored
perf: speed up iOS replay runner (#557)
* perf: speed up iOS replay runner * fix: harden ios replay fast paths * fix: address ci validation failures * refactor: trim unused ios replay surface
1 parent 41508d2 commit 094c290

70 files changed

Lines changed: 5111 additions & 281 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.fallowrc.json

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424
],
2525
"ignorePatterns": [
2626
"examples/test-app/**",
27-
"ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests.xctestplan"
27+
"ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests.xctestplan",
28+
"scripts/write-xcuitest-cache-metadata.mjs"
2829
],
2930
"ignoreDependencies": ["@microsoft/api-extractor", "@theme"],
3031
"ignoreExports": [
@@ -64,14 +65,6 @@
6465
{
6566
"file": "src/platforms/ios/index.ts",
6667
"exports": ["listSimulatorApps", "uninstallIosApp"]
67-
},
68-
{
69-
"file": "src/platforms/ios/runner-client.ts",
70-
"exports": [
71-
"buildRunnerConnectError",
72-
"buildRunnerEarlyExitError",
73-
"resolveSigningFailureHint"
74-
]
7568
}
7669
],
7770
"usedClassMembers": ["name", "listActiveLeases", "delete", "values", "elapsedMs", "isExpired"],

.github/workflows/ios.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ jobs:
2121
env:
2222
IOS_RUNTIME_VERSION: "26.2"
2323
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived
24+
AGENT_DEVICE_IOS_ALLOW_OVERRIDE_DERIVED_CLEAN: "1"
2425
AGENT_DEVICE_IOS_SIMCTL_LIST_TIMEOUT_MS: "60000"
2526
AGENT_DEVICE_IOS_BOOT_TIMEOUT_MS: "180000"
2627
AGENT_DEVICE_IOS_APP_LAUNCH_TIMEOUT_MS: "60000"

.github/workflows/replays-nightly.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ jobs:
5252
env:
5353
IOS_RUNTIME_VERSION: "26.2"
5454
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived
55+
AGENT_DEVICE_IOS_ALLOW_OVERRIDE_DERIVED_CLEAN: "1"
5556
AGENT_DEVICE_IOS_SIMCTL_LIST_TIMEOUT_MS: "60000"
5657
AGENT_DEVICE_IOS_BOOT_TIMEOUT_MS: "180000"
5758
AGENT_DEVICE_IOS_APP_LAUNCH_TIMEOUT_MS: "60000"

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

Lines changed: 128 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -140,11 +140,16 @@ extension RunnerTests {
140140
if let bundleId = requestedBundleId, targetNeedsActivation(activeApp) {
141141
activeApp = activateTarget(bundleId: bundleId, reason: "stale_target")
142142
} else if requestedBundleId == nil, targetNeedsActivation(activeApp) {
143-
app.activate()
143+
ensureRunnerHostAppActive(reason: "missing_app_bundle")
144144
activeApp = app
145145
}
146146

147-
if !activeApp.waitForExistence(timeout: appExistenceTimeout) {
147+
let skipExistenceWait = canUseFastForegroundAppGuard(
148+
activeApp: activeApp,
149+
requestedBundleId: requestedBundleId,
150+
command: command.command
151+
)
152+
if !skipExistenceWait && !activeApp.waitForExistence(timeout: appExistenceTimeout) {
148153
if let bundleId = requestedBundleId {
149154
activeApp = activateTarget(bundleId: bundleId, reason: "missing_after_wait")
150155
guard activeApp.waitForExistence(timeout: appExistenceTimeout) else {
@@ -159,10 +164,15 @@ extension RunnerTests {
159164
if let bundleId = requestedBundleId, activeApp.state != .runningForeground {
160165
activeApp = activateTarget(bundleId: bundleId, reason: "interaction_foreground_guard")
161166
} else if requestedBundleId == nil, activeApp.state != .runningForeground {
162-
app.activate()
167+
ensureRunnerHostAppActive(reason: "interaction_missing_app_bundle")
163168
activeApp = app
164169
}
165-
if !activeApp.waitForExistence(timeout: 2) {
170+
let skipInteractionExistenceWait = canUseFastForegroundAppGuard(
171+
activeApp: activeApp,
172+
requestedBundleId: requestedBundleId,
173+
command: command.command
174+
)
175+
if !skipInteractionExistenceWait && !activeApp.waitForExistence(timeout: 2) {
166176
if let bundleId = requestedBundleId {
167177
return Response(ok: false, error: ErrorPayload(message: "app '\(bundleId)' is not available"))
168178
}
@@ -241,6 +251,40 @@ extension RunnerTests {
241251
data: DataPayload(currentUptimeMs: currentUptimeMs())
242252
)
243253
case .tap:
254+
if let selectorKey = command.selectorKey, let selectorValue = command.selectorValue {
255+
let match = findElement(app: activeApp, selectorKey: selectorKey, selectorValue: selectorValue)
256+
if match.isAmbiguous {
257+
return Response(ok: false, error: ErrorPayload(code: "AMBIGUOUS_MATCH", message: "selector matched multiple elements"))
258+
}
259+
if let element = match.element {
260+
let frame = element.frame
261+
let touchFrame = frame.isEmpty
262+
? nil
263+
: resolvedTouchVisualizationFrame(app: activeApp, x: frame.midX, y: frame.midY)
264+
var outcome = RunnerInteractionOutcome.performed
265+
let timing = measureGesture {
266+
withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
267+
outcome = activateElement(app: activeApp, element: element, action: "tap by selector")
268+
}
269+
}
270+
if let response = unsupportedResponse(for: outcome) {
271+
return response
272+
}
273+
return Response(
274+
ok: true,
275+
data: DataPayload(
276+
message: "tapped",
277+
gestureStartUptimeMs: timing.gestureStartUptimeMs,
278+
gestureEndUptimeMs: timing.gestureEndUptimeMs,
279+
x: touchFrame?.x,
280+
y: touchFrame?.y,
281+
referenceWidth: touchFrame?.referenceWidth,
282+
referenceHeight: touchFrame?.referenceHeight
283+
)
284+
)
285+
}
286+
return Response(ok: false, error: ErrorPayload(code: "ELEMENT_NOT_FOUND", message: "element not found"))
287+
}
244288
if let text = command.text {
245289
if let element = findElement(app: activeApp, text: text) {
246290
var outcome = RunnerInteractionOutcome.performed
@@ -412,11 +456,25 @@ extension RunnerTests {
412456
return Response(ok: false, error: ErrorPayload(message: "drag requires x, y, x2, and y2"))
413457
}
414458
let holdDuration = min(max((command.durationMs ?? 60) / 1000.0, 0.016), 10.0)
415-
let dragFrame = resolvedDragVisualizationFrame(app: activeApp, x: x, y: y, x2: x2, y2: y2)
459+
let dragPoints = keyboardAvoidingDragPoints(app: activeApp, x: x, y: y, x2: x2, y2: y2)
460+
let dragFrame = resolvedDragVisualizationFrame(
461+
app: activeApp,
462+
x: dragPoints.x,
463+
y: dragPoints.y,
464+
x2: dragPoints.x2,
465+
y2: dragPoints.y2
466+
)
416467
var outcome = RunnerInteractionOutcome.performed
417468
let timing = measureGesture {
418469
withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
419-
outcome = dragAt(app: activeApp, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration)
470+
outcome = dragAt(
471+
app: activeApp,
472+
x: dragPoints.x,
473+
y: dragPoints.y,
474+
x2: dragPoints.x2,
475+
y2: dragPoints.y2,
476+
holdDuration: holdDuration
477+
)
420478
}
421479
}
422480
if let response = unsupportedResponse(for: outcome) {
@@ -447,6 +505,7 @@ extension RunnerTests {
447505
return Response(ok: false, error: ErrorPayload(message: "dragSeries pattern must be one-way or ping-pong"))
448506
}
449507
let holdDuration = min(max((command.durationMs ?? 60) / 1000.0, 0.016), 10.0)
508+
let dragPoints = keyboardAvoidingDragPoints(app: activeApp, x: x, y: y, x2: x2, y2: y2)
450509
var outcome = RunnerInteractionOutcome.performed
451510
let timing = measureGesture {
452511
withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
@@ -456,9 +515,23 @@ extension RunnerTests {
456515
}
457516
let reverse = pattern == "ping-pong" && (idx % 2 == 1)
458517
if reverse {
459-
outcome = dragAt(app: activeApp, x: x2, y: y2, x2: x, y2: y, holdDuration: holdDuration)
518+
outcome = dragAt(
519+
app: activeApp,
520+
x: dragPoints.x2,
521+
y: dragPoints.y2,
522+
x2: dragPoints.x,
523+
y2: dragPoints.y,
524+
holdDuration: holdDuration
525+
)
460526
} else {
461-
outcome = dragAt(app: activeApp, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration)
527+
outcome = dragAt(
528+
app: activeApp,
529+
x: dragPoints.x,
530+
y: dragPoints.y,
531+
x2: dragPoints.x2,
532+
y2: dragPoints.y2,
533+
holdDuration: holdDuration
534+
)
462535
}
463536
}
464537
}
@@ -487,45 +560,11 @@ extension RunnerTests {
487560
}
488561
return Response(ok: true, data: DataPayload(message: "remote pressed"))
489562
case .type:
490-
guard let text = command.text else {
491-
return Response(ok: false, error: ErrorPayload(message: "type requires text"))
492-
}
493-
let delaySeconds = Double(max(command.delayMs ?? 0, 0)) / 1000.0
494-
let target: XCUIElement?
495-
if let x = command.x, let y = command.y {
496-
target = textInputAt(app: activeApp, x: x, y: y) ?? focusedTextInput(app: activeApp)
497-
} else {
498-
target = focusedTextInput(app: activeApp)
499-
}
500-
func typeIntoTarget(_ value: String) {
501-
if let focused = target {
502-
focused.typeText(value)
503-
} else {
504-
activeApp.typeText(value)
505-
}
506-
}
507-
if command.clearFirst == true {
508-
guard let focused = target else {
509-
let message =
510-
(command.x != nil && command.y != nil)
511-
? "no text input found at the provided coordinates to clear"
512-
: "no focused text input to clear"
513-
return Response(ok: false, error: ErrorPayload(message: message))
514-
}
515-
clearTextInput(focused)
516-
}
517-
if delaySeconds > 0 && text.count > 1 {
518-
let chunks = Array(text)
519-
for (index, character) in chunks.enumerated() {
520-
typeIntoTarget(String(character))
521-
if index + 1 < chunks.count {
522-
Thread.sleep(forTimeInterval: delaySeconds)
523-
}
524-
}
525-
} else {
526-
typeIntoTarget(text)
563+
var response: Response?
564+
withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
565+
response = executeTypeCommand(activeApp: activeApp, command: command)
527566
}
528-
return Response(ok: true, data: DataPayload(message: "typed"))
567+
return response ?? Response(ok: false, error: ErrorPayload(message: "type produced no response"))
529568
case .interactionFrame:
530569
let frame = resolvedTouchReferenceFrame(app: activeApp, appFrame: activeApp.frame)
531570
return Response(
@@ -573,6 +612,11 @@ extension RunnerTests {
573612
}
574613
let found = findElement(app: activeApp, text: text) != nil
575614
return Response(ok: true, data: DataPayload(found: found))
615+
case .querySelector:
616+
guard let selectorKey = command.selectorKey, let selectorValue = command.selectorValue else {
617+
return Response(ok: false, error: ErrorPayload(message: "querySelector requires selectorKey and selectorValue"))
618+
}
619+
return queryElement(app: activeApp, selectorKey: selectorKey, selectorValue: selectorValue)
576620
case .readText:
577621
guard let x = command.x, let y = command.y else {
578622
return Response(ok: false, error: ErrorPayload(message: "readText requires x and y"))
@@ -604,7 +648,7 @@ extension RunnerTests {
604648
targetApp.activate()
605649
activeApp = targetApp
606650
// Brief wait for the app transition animation to complete
607-
Thread.sleep(forTimeInterval: 0.5)
651+
sleepFor(0.5)
608652
}
609653
if command.fullscreen == true {
610654
screenshot = XCUIScreen.main.screenshot()
@@ -734,4 +778,41 @@ extension RunnerTests {
734778
)
735779
}
736780
}
781+
782+
private func executeTypeCommand(activeApp: XCUIApplication, command: Command) -> Response {
783+
guard let text = command.text else {
784+
return Response(ok: false, error: ErrorPayload(message: "type requires text"))
785+
}
786+
let delaySeconds = Double(max(command.delayMs ?? 0, 0)) / 1000.0
787+
let textEntryMode = resolveTextEntryMode(command)
788+
let target = focusTextInputForTextEntry(app: activeApp, x: command.x, y: command.y)
789+
if textEntryMode == .replacement {
790+
guard target.element != nil else {
791+
let message =
792+
(command.x != nil && command.y != nil)
793+
? "no text input found at the provided coordinates to clear"
794+
: "no focused text input to clear"
795+
return Response(ok: false, error: ErrorPayload(message: message))
796+
}
797+
}
798+
let textResult = typeTextReliably(
799+
app: activeApp,
800+
target: target,
801+
text: text,
802+
delaySeconds: delaySeconds,
803+
repairMode: textEntryMode
804+
)
805+
if textResult.verified == false {
806+
let expected = textResult.expectedText ?? ""
807+
let observed = textResult.observedText ?? ""
808+
return Response(
809+
ok: false,
810+
error: ErrorPayload(
811+
code: "TEXT_ENTRY_MISMATCH",
812+
message: "text entry verification failed: expected \"\(expected)\", observed \"\(observed)\""
813+
)
814+
)
815+
}
816+
return Response(ok: true, data: DataPayload(message: textResult.repaired ? "typed after repair" : "typed"))
817+
}
737818
}

0 commit comments

Comments
 (0)