From 865dc7e2fc3a36e5a7d740ecd49e6fa5d524ef59 Mon Sep 17 00:00:00 2001 From: Thiago Brezinski Date: Sun, 6 Sep 2026 14:30:16 +0100 Subject: [PATCH 1/5] fix(ios): warm the synthesized-input digitizer before the first gesture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XCTest attaches its HID digitizer lazily, on the first synthesized event a runner process posts. On a cold or loaded simulator that attach can lag several seconds behind the synthesizeWithError call that triggers it. When the first synthesized gesture of a process is timed (a drag with an activation hold, a paced pan), the touch-down then lands seconds into a window whose later samples were scheduled relative to the intended touch-down, so the app reconstructs a malformed gesture even though synthesizeWithError reported success. Once attached, the digitizer stays attached for the runner process, so every later gesture — including after a target relaunch — lands on schedule. Force the one-time attach with a throwaway synthesized contact before the first real gesture. The gesture's own timings are unchanged; the warm-up only moves the unavoidable one-time attach cost off the first user gesture, and is a no-op on a warm host. Runs once per runner process, before the first `gesture` command. Co-Authored-By: Claude Fable 5.1 --- .../RunnerTests+CommandExecution.swift | 4 ++ .../RunnerTests+SynthesizedInputWarmup.swift | 63 +++++++++++++++++++ .../RunnerTests.swift | 7 +++ ...nerTests+SynthesizedInputWarmupTests.swift | 23 +++++++ 4 files changed, 97 insertions(+) create mode 100644 apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInputWarmup.swift create mode 100644 apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInputWarmupTests.swift diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index bd647eeba..7f53c6535 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -2196,6 +2196,10 @@ extension RunnerTests { error: ErrorPayload(code: "INVALID_ARGS", message: validationError) ) } + // Attach the synthesized-input HID digitizer once per process before the first timed gesture, + // so this gesture's touch-down is not delayed by the one-time attach latency (see + // RunnerTests+SynthesizedInputWarmup). + ensureSynthesizedInputWarmed(app: activeApp) switch plannedGestureExecution(for: plan) { case .fastSwipe: // Validation above guarantees a non-empty, single-pointer path for this execution kind. diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInputWarmup.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInputWarmup.swift new file mode 100644 index 000000000..428b72866 --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInputWarmup.swift @@ -0,0 +1,63 @@ +import XCTest + +// One-time warm-up of the private XCTest synthesized-input pipeline. +// +// XCTest attaches its HID digitizer to the system lazily, on the first synthesized event a runner +// process posts. On a warm host that attach is immediate; on a cold or loaded simulator it can lag +// several seconds behind the synthesizeWithError call that triggered it. When the first synthesized +// gesture of a process is a *timed* one (a drag with an activation hold, a paced pan), the touch-down +// then lands seconds into a window whose later samples were scheduled relative to the intended +// touch-down, so the gesture the app reconstructs is malformed even though synthesizeWithError +// reported success. Once the digitizer is attached, every later synthesized gesture in the process — +// including after a target relaunch — lands on schedule. +// +// Absorbing that first-attach latency with a throwaway synthesized contact, before the first real +// gesture runs, keeps the real gesture's timing intact. This is not tolerance widening: the gesture's +// own timings are unchanged; the warm-up only moves the unavoidable one-time attach cost off the +// first user gesture. It is a no-op on a warm host (the attach it forces has already happened). + +extension RunnerTests { + /// Whether the synthesized-input pipeline still needs its one-time warm-up. Pure so the + /// once-per-process contract is testable without a live pipeline. + func shouldWarmSynthesizedInput(alreadyWarmed: Bool) -> Bool { + !alreadyWarmed + } + + /// The throwaway warm-up contact point: the top-center of the touch reference frame, in the + /// status-bar band. The digitizer attaches on the first synthesis regardless of where the contact + /// lands, so the point is chosen to be inert — the status-bar band does not forward touches to app + /// content — rather than meaningful. Returns nil when the frame is unusable. + func synthesizedInputWarmupPoint(referenceFrame: CGRect) -> CGPoint? { + guard referenceFrame.width > 0, referenceFrame.height > 0 else { return nil } + return CGPoint(x: referenceFrame.midX, y: referenceFrame.minY + 1) + } + + /// Best-effort: force the HID digitizer attach once per runner process, before the first real + /// synthesized gesture. Never fails the caller — a warm-up that cannot resolve a frame or that the + /// synthesizer refuses leaves the flag set (a real gesture would hit the same condition) so the + /// warm-up is not retried on every gesture. + func ensureSynthesizedInputWarmed(app: XCUIApplication) { +#if os(iOS) + guard shouldWarmSynthesizedInput(alreadyWarmed: didWarmSynthesizedInput) else { return } + didWarmSynthesizedInput = true + let frame = resolvedTouchReferenceFrame(app: app, appFrame: app.frame) + guard let point = synthesizedInputWarmupPoint(referenceFrame: frame) else { + NSLog("AGENT_DEVICE_RUNNER_SYNTHESIZED_INPUT_WARMUP outcome=skipped reason=no_reference_frame") + return + } + let startedAt = ProcessInfo.processInfo.systemUptime + let outcome = synthesizedTapAt(app: app, x: Double(point.x), y: Double(point.y)) + let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000 + switch outcome { + case .performed: + NSLog("AGENT_DEVICE_RUNNER_SYNTHESIZED_INPUT_WARMUP outcome=performed elapsedMs=%.0f", elapsedMs) + case .unsupported(let message, _): + NSLog( + "AGENT_DEVICE_RUNNER_SYNTHESIZED_INPUT_WARMUP outcome=unsupported elapsedMs=%.0f detail=%@", + elapsedMs, + message + ) + } +#endif + } +} diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift index 5c7f41849..ac5a684c0 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift @@ -48,6 +48,13 @@ final class RunnerTests: XCTestCase { var currentApp: XCUIApplication? var currentBundleId: String? var currentAppProcessIdentifier: Int? + // Set once, the first time this runner process synthesizes a touch gesture. The private + // XCTest event pipeline attaches its HID digitizer lazily on that first synthesis, and on a + // cold simulator that attach can lag seconds behind the call that triggered it. Because the + // attach is a runner-process/host-level connection rather than app state, it survives target + // relaunches, so warming it once per process is enough. Deliberately not cleared by + // invalidateCachedTarget. + var didWarmSynthesizedInput = false // iOS does not reliably expose hasKeyboardFocus for a bare type request, especially when // hardware-keyboard input hides the software keyboard. A successful tap on a concrete text // input is a scoped witness for the immediately-following bare type; lifecycle and non-text diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInputWarmupTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInputWarmupTests.swift new file mode 100644 index 000000000..37fcbdc00 --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInputWarmupTests.swift @@ -0,0 +1,23 @@ +import XCTest + +extension RunnerTests { +#if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS) + func testSynthesizedInputWarmupRunsOnceThenNotAgain() { + XCTAssertTrue(shouldWarmSynthesizedInput(alreadyWarmed: false), "cold process must warm") + XCTAssertFalse(shouldWarmSynthesizedInput(alreadyWarmed: true), "an already-warmed process must not warm again") + } + + func testSynthesizedInputWarmupPointSitsInTheStatusBarBandOfTheFrame() throws { + let frame = CGRect(x: 10, y: 20, width: 300, height: 600) + let point = try XCTUnwrap(synthesizedInputWarmupPoint(referenceFrame: frame)) + XCTAssertEqual(point.x, frame.midX, accuracy: 0.001, "warm-up contact is horizontally centered") + XCTAssertEqual(point.y, frame.minY + 1, accuracy: 0.001, "warm-up contact sits in the top band, off app content") + XCTAssertTrue(frame.contains(point), "warm-up contact stays inside the reference frame") + } + + func testSynthesizedInputWarmupPointIsUnavailableForAnEmptyFrame() { + XCTAssertNil(synthesizedInputWarmupPoint(referenceFrame: .zero)) + XCTAssertNil(synthesizedInputWarmupPoint(referenceFrame: CGRect(x: 0, y: 0, width: 0, height: 100))) + } +#endif +} From 449e2184bb3496eaecc8ce35a57048068ebe8dce Mon Sep 17 00:00:00 2001 From: Thiago Brezinski Date: Sun, 6 Sep 2026 20:52:35 +0100 Subject: [PATCH 2/5] fix(ios): prepare synthesized input without delivering contacts --- .../RunnerSynthesizedGesture.m | 63 +++++++++++---- .../RunnerTests+CommandExecution.swift | 1 - .../RunnerTests+SynthesizedInputWarmup.swift | 63 --------------- .../RunnerTests.swift | 7 -- ...nerTests+SynthesizedInputWarmupTests.swift | 77 +++++++++++++++---- 5 files changed, 112 insertions(+), 99 deletions(-) delete mode 100644 apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInputWarmup.swift diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m index dbbf4ed5b..f58f91414 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m @@ -2,6 +2,7 @@ #import "RunnerXCTestEventBridge.h" #import +#import #import static NSString *const RunnerGestureSynthesisSurface = @"event"; @@ -309,6 +310,21 @@ + (NSInteger)interfaceOrientationForApplication:(id)application { return nil; } +static id RunnerAllocateGestureRecord( + const RunnerGestureEventBridge *bridge, + NSString *name, + NSInteger orientation, + NSInteger processID +) { + id record = ((RunnerMsgSendInitRecord)objc_msgSend)( + [bridge->core.recordClass alloc], bridge->initRecordSelector, name, orientation + ); + if (record != nil) { + ((RunnerMsgSendSetInteger)objc_msgSend)(record, bridge->core.setTargetProcessIDSelector, processID); + } + return record; +} + static NSString * _Nullable RunnerCreateEventRecord( id application, NSString *recordName, @@ -326,22 +342,39 @@ + (NSInteger)interfaceOrientationForApplication:(id)application { return @"private XCTest event synthesis unavailable: could not resolve target process ID"; } - id eventRecord = ((RunnerMsgSendInitRecord)objc_msgSend)( - [bridge->core.recordClass alloc], - bridge->initRecordSelector, - recordName, - interfaceOrientation - ); - if (eventRecord == nil) { - return @"private XCTest event synthesis failed: could not create event record"; +#if TARGET_OS_IOS + // Prepare at the shared record boundary, before any route constructs timed + // pointer paths. An empty record carries no contact (including no status-bar + // tap), and reuses the caller's resolved orientation/PID without AX or images. + static BOOL didPrepare = NO; + @synchronized ([RunnerSynthesizedGesture class]) { + if (!didPrepare) { + NSTimeInterval startedAt = NSProcessInfo.processInfo.systemUptime; + NSString *preparationError = nil; + @try { + id preparation = RunnerAllocateGestureRecord( + bridge, @"agent-device-input-preparation", interfaceOrientation, targetProcessID + ); + preparationError = preparation == nil + ? @"could not create preparation record" + : RunnerSynthesizeEventRecord(bridge, preparation); + didPrepare = preparationError == nil; + } @catch (NSException *exception) { + preparationError = RunnerFormatXCTestException(exception, @"input preparation failed"); + } + NSLog( + @"AGENT_DEVICE_RUNNER_SYNTHESIZED_INPUT_WARMUP outcome=%@ elapsedMs=%.0f detail=%@", + didPrepare ? @"performed" : @"unsupported", + (NSProcessInfo.processInfo.systemUptime - startedAt) * 1000, + preparationError ?: @"" + ); + } } - ((RunnerMsgSendSetInteger)objc_msgSend)( - eventRecord, - bridge->core.setTargetProcessIDSelector, - targetProcessID - ); - *record = eventRecord; - return nil; +#endif + // Failed preparation is best-effort and remains eligible on the next request. + // The requested record is always fresh and retains its original timing. + *record = RunnerAllocateGestureRecord(bridge, recordName, interfaceOrientation, targetProcessID); + return *record == nil ? @"private XCTest event synthesis failed: could not create event record" : nil; } static NSString * _Nullable RunnerSynthesizeEventRecord( diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index 7f53c6535..528d43060 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -2199,7 +2199,6 @@ extension RunnerTests { // Attach the synthesized-input HID digitizer once per process before the first timed gesture, // so this gesture's touch-down is not delayed by the one-time attach latency (see // RunnerTests+SynthesizedInputWarmup). - ensureSynthesizedInputWarmed(app: activeApp) switch plannedGestureExecution(for: plan) { case .fastSwipe: // Validation above guarantees a non-empty, single-pointer path for this execution kind. diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInputWarmup.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInputWarmup.swift deleted file mode 100644 index 428b72866..000000000 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInputWarmup.swift +++ /dev/null @@ -1,63 +0,0 @@ -import XCTest - -// One-time warm-up of the private XCTest synthesized-input pipeline. -// -// XCTest attaches its HID digitizer to the system lazily, on the first synthesized event a runner -// process posts. On a warm host that attach is immediate; on a cold or loaded simulator it can lag -// several seconds behind the synthesizeWithError call that triggered it. When the first synthesized -// gesture of a process is a *timed* one (a drag with an activation hold, a paced pan), the touch-down -// then lands seconds into a window whose later samples were scheduled relative to the intended -// touch-down, so the gesture the app reconstructs is malformed even though synthesizeWithError -// reported success. Once the digitizer is attached, every later synthesized gesture in the process — -// including after a target relaunch — lands on schedule. -// -// Absorbing that first-attach latency with a throwaway synthesized contact, before the first real -// gesture runs, keeps the real gesture's timing intact. This is not tolerance widening: the gesture's -// own timings are unchanged; the warm-up only moves the unavoidable one-time attach cost off the -// first user gesture. It is a no-op on a warm host (the attach it forces has already happened). - -extension RunnerTests { - /// Whether the synthesized-input pipeline still needs its one-time warm-up. Pure so the - /// once-per-process contract is testable without a live pipeline. - func shouldWarmSynthesizedInput(alreadyWarmed: Bool) -> Bool { - !alreadyWarmed - } - - /// The throwaway warm-up contact point: the top-center of the touch reference frame, in the - /// status-bar band. The digitizer attaches on the first synthesis regardless of where the contact - /// lands, so the point is chosen to be inert — the status-bar band does not forward touches to app - /// content — rather than meaningful. Returns nil when the frame is unusable. - func synthesizedInputWarmupPoint(referenceFrame: CGRect) -> CGPoint? { - guard referenceFrame.width > 0, referenceFrame.height > 0 else { return nil } - return CGPoint(x: referenceFrame.midX, y: referenceFrame.minY + 1) - } - - /// Best-effort: force the HID digitizer attach once per runner process, before the first real - /// synthesized gesture. Never fails the caller — a warm-up that cannot resolve a frame or that the - /// synthesizer refuses leaves the flag set (a real gesture would hit the same condition) so the - /// warm-up is not retried on every gesture. - func ensureSynthesizedInputWarmed(app: XCUIApplication) { -#if os(iOS) - guard shouldWarmSynthesizedInput(alreadyWarmed: didWarmSynthesizedInput) else { return } - didWarmSynthesizedInput = true - let frame = resolvedTouchReferenceFrame(app: app, appFrame: app.frame) - guard let point = synthesizedInputWarmupPoint(referenceFrame: frame) else { - NSLog("AGENT_DEVICE_RUNNER_SYNTHESIZED_INPUT_WARMUP outcome=skipped reason=no_reference_frame") - return - } - let startedAt = ProcessInfo.processInfo.systemUptime - let outcome = synthesizedTapAt(app: app, x: Double(point.x), y: Double(point.y)) - let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000 - switch outcome { - case .performed: - NSLog("AGENT_DEVICE_RUNNER_SYNTHESIZED_INPUT_WARMUP outcome=performed elapsedMs=%.0f", elapsedMs) - case .unsupported(let message, _): - NSLog( - "AGENT_DEVICE_RUNNER_SYNTHESIZED_INPUT_WARMUP outcome=unsupported elapsedMs=%.0f detail=%@", - elapsedMs, - message - ) - } -#endif - } -} diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift index ac5a684c0..5c7f41849 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift @@ -48,13 +48,6 @@ final class RunnerTests: XCTestCase { var currentApp: XCUIApplication? var currentBundleId: String? var currentAppProcessIdentifier: Int? - // Set once, the first time this runner process synthesizes a touch gesture. The private - // XCTest event pipeline attaches its HID digitizer lazily on that first synthesis, and on a - // cold simulator that attach can lag seconds behind the call that triggered it. Because the - // attach is a runner-process/host-level connection rather than app state, it survives target - // relaunches, so warming it once per process is enough. Deliberately not cleared by - // invalidateCachedTarget. - var didWarmSynthesizedInput = false // iOS does not reliably expose hasKeyboardFocus for a bare type request, especially when // hardware-keyboard input hides the software keyboard. A successful tap on a concrete text // input is a scoped witness for the immediately-following bare type; lifecycle and non-text diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInputWarmupTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInputWarmupTests.swift index 37fcbdc00..8d2fa6525 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInputWarmupTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedInputWarmupTests.swift @@ -1,23 +1,74 @@ import XCTest -extension RunnerTests { #if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS) - func testSynthesizedInputWarmupRunsOnceThenNotAgain() { - XCTAssertTrue(shouldWarmSynthesizedInput(alreadyWarmed: false), "cold process must warm") - XCTAssertFalse(shouldWarmSynthesizedInput(alreadyWarmed: true), "an already-warmed process must not warm again") +import ObjectiveC.runtime + +private enum SynthesizedInputSpy { + static var paths: [ObjectIdentifier: Int] = [:] + static var submittedPathCounts: [Int] = [] + static var rejectNext = true +} + +private final class SynthesizedInputTarget: NSObject { + @objc var processID: Int { 42 } + @objc var interfaceOrientation: Int { 1 } +} + +private final class SynthesizedInputRecordSpy: NSObject { + @objc(addPointerEventPath:) + func addPointerEventPath(_ path: AnyObject) { + SynthesizedInputSpy.paths[ObjectIdentifier(self), default: 0] += 1 } - func testSynthesizedInputWarmupPointSitsInTheStatusBarBandOfTheFrame() throws { - let frame = CGRect(x: 10, y: 20, width: 300, height: 600) - let point = try XCTUnwrap(synthesizedInputWarmupPoint(referenceFrame: frame)) - XCTAssertEqual(point.x, frame.midX, accuracy: 0.001, "warm-up contact is horizontally centered") - XCTAssertEqual(point.y, frame.minY + 1, accuracy: 0.001, "warm-up contact sits in the top band, off app content") - XCTAssertTrue(frame.contains(point), "warm-up contact stays inside the reference frame") + @objc(synthesizeWithError:) + func synthesizeWithError(_ error: UnsafeMutablePointer?) -> Bool { + SynthesizedInputSpy.submittedPathCounts.append( + SynthesizedInputSpy.paths.removeValue(forKey: ObjectIdentifier(self)) ?? 0 + ) + if SynthesizedInputSpy.rejectNext { + SynthesizedInputSpy.rejectNext = false + error?.pointee = NSError(domain: "WarmupTest", code: 1) + return false + } + return true } +} +#endif - func testSynthesizedInputWarmupPointIsUnavailableForAnEmptyFrame() { - XCTAssertNil(synthesizedInputWarmupPoint(referenceFrame: .zero)) - XCTAssertNil(synthesizedInputWarmupPoint(referenceFrame: CGRect(x: 0, y: 0, width: 0, height: 100))) +extension RunnerTests { +#if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS) + func testSynthesizedInputPreparationDoesNotDeliverContactsAndOrdersMixedRoutes() throws { + let recordClass = try XCTUnwrap(NSClassFromString("XCSynthesizedEventRecord")) + var originals: [(Method, IMP)] = [] + for name in ["addPointerEventPath:", "synthesizeWithError:"] { + let selector = NSSelectorFromString(name) + let method = try XCTUnwrap(class_getInstanceMethod(recordClass, selector)) + let spy = try XCTUnwrap(class_getInstanceMethod(SynthesizedInputRecordSpy.self, selector)) + originals.append((method, method_getImplementation(method))) + method_setImplementation(method, method_getImplementation(spy)) + } + defer { + for (method, implementation) in originals { method_setImplementation(method, implementation) } + SynthesizedInputSpy.paths = [:] + SynthesizedInputSpy.submittedPathCounts = [] + SynthesizedInputSpy.rejectNext = true + } + let target = SynthesizedInputTarget() + // Exercise the actual bridge entry points used by gesture/sequence, scroll, + // synthesized drag and coordinate tap. No real event reaches the simulator. + let samples: [[[String: NSNumber]]] = [[ + ["x": 10, "y": 10, "offsetMs": 0], + ["x": 20, "y": 20, "offsetMs": 100], + ]] + // A failed preparation must leave the real request available and permit the + // next route to prepare again. Only a successful empty synthesis consumes it. + XCTAssertNil(RunnerSynthesizedGesture.synthesizeGesture(withApplication: target, pointerSamples: samples)) + XCTAssertNil(RunnerSynthesizedGesture.synthesizeControlledScroll(withApplication: target, x: 10, y: 10, x2: 20, y2: 20, durationMs: 100)) + XCTAssertNil(RunnerSynthesizedGesture.synthesizeContinuousDrag(withApplication: target, x: 10, y: 10, x2: 20, y2: 20, durationMs: 100)) + XCTAssertNil(RunnerSynthesizedGesture.synthesizeTap(withApplication: target, x: 10, y: 10)) + XCTAssertNil(RunnerSynthesizedGesture.synthesizeSwipe(withApplication: target, x: 10, y: 10, x2: 20, y2: 20, durationMs: 100)) + XCTAssertNil(RunnerSynthesizedGesture.synthesizeGesture(withApplication: target, pointerSamples: samples)) + XCTAssertEqual(SynthesizedInputSpy.submittedPathCounts, [0, 1, 0, 1, 1, 1, 1, 1]) } #endif } From de73f95f92df6acae3d585c37e221b0783fd3d56 Mon Sep 17 00:00:00 2001 From: Thiago Brezinski Date: Sun, 6 Sep 2026 20:52:35 +0100 Subject: [PATCH 3/5] chore(gates): isolate process-scoped input preparation regression --- .github/workflows/ios.yml | 6 ++++++ .github/workflows/xctest-nightly.yml | 1 + 2 files changed, 7 insertions(+) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 921403489..2f63a7620 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -149,6 +149,12 @@ jobs: run: | XCTESTRUN_PATH="$(find "$AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH/Build/Products" -maxdepth 1 -name '*.xctestrun' -print -quit)" test -n "$XCTESTRUN_PATH" + # The preparation flag is process-scoped; isolate this ordering test + # from other tests that may already have synthesized input. + xcodebuild test-without-building \ + -xctestrun "$XCTESTRUN_PATH" \ + -destination "platform=iOS Simulator,id=${{ steps.ios-simulator.outputs.simulator-udid }}" \ + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testSynthesizedInputPreparationDoesNotDeliverContactsAndOrdersMixedRoutes xcodebuild test-without-building \ -xctestrun "$XCTESTRUN_PATH" \ -destination "platform=iOS Simulator,id=${{ steps.ios-simulator.outputs.simulator-udid }}" \ diff --git a/.github/workflows/xctest-nightly.yml b/.github/workflows/xctest-nightly.yml index 91ba7a564..3af8bc92b 100644 --- a/.github/workflows/xctest-nightly.yml +++ b/.github/workflows/xctest-nightly.yml @@ -143,6 +143,7 @@ jobs: -xctestrun "$XCTESTRUN_PATH" \ -destination "platform=iOS Simulator,id=${{ steps.ios-simulator.outputs.simulator-udid }}" \ -skip-testing:AgentDeviceRunnerUITests/RunnerTests/testCommand \ + -skip-testing:AgentDeviceRunnerUITests/RunnerTests/testSynthesizedInputPreparationDoesNotDeliverContactsAndOrdersMixedRoutes \ -resultBundlePath "$RESULT_BUNDLE_PATH" # Best-effort and never the job's verdict on its own; the step below is what asserts. From 21e67439b69fed4103f526ba821d99c5d93a24b3 Mon Sep 17 00:00:00 2001 From: Thiago Brezinski Date: Sun, 6 Sep 2026 21:02:26 +0100 Subject: [PATCH 4/5] docs(ios): remove the retired handler warm-up reference --- .../RunnerTests+CommandExecution.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index 528d43060..bd647eeba 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -2196,9 +2196,6 @@ extension RunnerTests { error: ErrorPayload(code: "INVALID_ARGS", message: validationError) ) } - // Attach the synthesized-input HID digitizer once per process before the first timed gesture, - // so this gesture's touch-down is not delayed by the one-time attach latency (see - // RunnerTests+SynthesizedInputWarmup). switch plannedGestureExecution(for: plan) { case .fastSwipe: // Validation above guarantees a non-empty, single-pointer path for this execution kind. From 03fec1c0a5cfe621bf146f0f8c5b7b3ee045324e Mon Sep 17 00:00:00 2001 From: Thiago Brezinski Date: Sun, 6 Sep 2026 21:34:15 +0100 Subject: [PATCH 5/5] test(ios): run isolated preparation regression in nightly --- .github/workflows/xctest-nightly.yml | 33 +++++++++++++--- scripts/__tests__/xctest-selection.test.ts | 45 +++++++++++++++++++++- scripts/check-xctest-selection.ts | 32 ++++++++++----- 3 files changed, 93 insertions(+), 17 deletions(-) diff --git a/.github/workflows/xctest-nightly.yml b/.github/workflows/xctest-nightly.yml index 3af8bc92b..93da5e7b6 100644 --- a/.github/workflows/xctest-nightly.yml +++ b/.github/workflows/xctest-nightly.yml @@ -2,9 +2,9 @@ name: XCTest Nightly # The full iOS runner XCTest suite (#1781 A7). The PR lane (ios.yml) names a hand-written # subset of the target's methods in an `-only-testing:` list; everything outside that list ran -# nowhere at all. This lane drops the filter and runs the test plan whole, skipping only -# `testCommand` — which is the runner's server entry point rather than a test (see the step -# below). +# nowhere at all. This lane runs the test plan whole, except `testCommand` (the runner's +# server entry point). The process-scoped input-preparation regression runs in isolation; +# its result is merged with the remaining suite before checking the executed count. # # No count is quoted here on purpose. `pnpm check:xctest-selection` prints the live split # (declared / PR-selected / skipped / nightly-only) and is the only place those numbers are @@ -117,8 +117,21 @@ jobs: runtime-version: ${{ env.IOS_RUNTIME_VERSION }} preferred-device-name: iPhone 17 Pro - # Same command as ios.yml's targeted step minus every `-only-testing:` flag, so the - # xctestrun's own test plan decides what runs — with one exception. + # The preparation regression needs a fresh process before any other synthesized + # input. Run it separately, then merge both bundles for the source-derived count. + - name: Run isolated synthesized-input preparation regression + run: | + set -euo pipefail + XCTESTRUN_PATH="$(find "$AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH/Build/Products" -maxdepth 1 -name '*.xctestrun' -print -quit)" + test -n "$XCTESTRUN_PATH" + mkdir -p "$(dirname "$RESULT_BUNDLE_PATH")" + xcodebuild test-without-building \ + -xctestrun "$XCTESTRUN_PATH" \ + -destination "platform=iOS Simulator,id=${{ steps.ios-simulator.outputs.simulator-udid }}" \ + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testSynthesizedInputPreparationDoesNotDeliverContactsAndOrdersMixedRoutes \ + -resultBundlePath "${RESULT_BUNDLE_PATH%.xcresult}-isolated.xcresult" + + # The remaining suite follows the xctestrun plan, excluding the isolated test. # # `RunnerTests/testCommand` is not a test. It is the runner's server entry point: it # opens an NWListener and blocks in `XCTWaiter.wait(timeout: 24 * 60 * 60)` until a @@ -144,7 +157,15 @@ jobs: -destination "platform=iOS Simulator,id=${{ steps.ios-simulator.outputs.simulator-udid }}" \ -skip-testing:AgentDeviceRunnerUITests/RunnerTests/testCommand \ -skip-testing:AgentDeviceRunnerUITests/RunnerTests/testSynthesizedInputPreparationDoesNotDeliverContactsAndOrdersMixedRoutes \ - -resultBundlePath "$RESULT_BUNDLE_PATH" + -resultBundlePath "${RESULT_BUNDLE_PATH%.xcresult}-remaining.xcresult" + + - name: Merge isolated and remaining XCTest results + if: always() + run: | + set -euo pipefail + xcrun xcresulttool merge --output-path "$RESULT_BUNDLE_PATH" \ + "${RESULT_BUNDLE_PATH%.xcresult}-isolated.xcresult" \ + "${RESULT_BUNDLE_PATH%.xcresult}-remaining.xcresult" # Best-effort and never the job's verdict on its own; the step below is what asserts. # `--compact` first because a red night's summary is the large one, and the job summary diff --git a/scripts/__tests__/xctest-selection.test.ts b/scripts/__tests__/xctest-selection.test.ts index 177b5726e..87bfc356a 100644 --- a/scripts/__tests__/xctest-selection.test.ts +++ b/scripts/__tests__/xctest-selection.test.ts @@ -84,13 +84,20 @@ describe('the real tree', () => { test('the whole-bundle lanes skip the runner server entry point, which is not a test', () => { // The whole reason -skip-testing: exists in this repo. `testCommand` opens an // NWListener and waits 24 hours; an unfiltered run would hang the lane to its timeout. - const skipped = loadReport(repoRoot).flagged.filter((entry) => entry.flag === 'skip-testing'); + const report = loadReport(repoRoot); + const skipped = report.flagged.filter((entry) => entry.flag === 'skip-testing'); for (const entry of LANES.filter((entry) => entry.selection === 'whole')) { expect( skipped.filter((flag) => flag.workflow === entry.workflow).map((flag) => flag.identifier), ).toContain(ENTRY_POINT); } - for (const entry of skipped) expect(entry.identifier).toBe(ENTRY_POINT); + for (const entry of skipped) { + if (entry.identifier === ENTRY_POINT) continue; + // A real test may be excluded from the shared process only if another invocation + // on the same lane still reaches it. Removing the isolated run must fail this. + const current = LANES.find((lane) => lane.workflow === entry.workflow)!; + expect(report.reach[current.id].has(entry.identifier)).toBe(true); + } }); test('the simulator-only tests are exactly the ones the macOS build compiles out', () => { @@ -296,6 +303,40 @@ describe('a planted guard', () => { }); }); +describe('isolated invocations', () => { + const isolated = `${TARGET}/RunnerTests/testIsolated`; + const swift = source(`${ENTRY_SOURCE}extension RunnerTests {\n func testIsolated() {}\n}\n`); + const shared = `xcodebuild test-without-building -skip-testing:${ENTRY_POINT} -skip-testing:${isolated}`; + + test('a separate selected invocation restores a test skipped by the shared process', () => { + const report = buildReport(TARGET, swift, [ + ...laneWorkflows().filter((entry) => entry.workflow !== NIGHTLY_WORKFLOW_FILE), + { + workflow: NIGHTLY_WORKFLOW_FILE, + text: `xcodebuild test-without-building -only-testing:${isolated}\n${shared}`, + }, + ]); + expect([...report.reach.nightly]).toEqual([isolated]); + expect(report.entryPointReachedBy).toEqual([]); + }); + + test('removing the isolated invocation leaves that test unreachable on nightly', () => { + const report = buildReport(TARGET, swift, [ + ...laneWorkflows().filter((entry) => entry.workflow !== NIGHTLY_WORKFLOW_FILE), + { workflow: NIGHTLY_WORKFLOW_FILE, text: shared }, + ]); + expect(report.reach.nightly.has(isolated)).toBe(false); + }); + + test('a skip still wins over an only flag in the same invocation', () => { + const report = buildReport(TARGET, swift, [ + ...laneWorkflows().filter((entry) => entry.workflow !== NIGHTLY_WORKFLOW_FILE), + { workflow: NIGHTLY_WORKFLOW_FILE, text: `${shared} -only-testing:${isolated}` }, + ]); + expect(report.reach.nightly.has(isolated)).toBe(false); + }); +}); + describe('the workflow scan', () => { test('reads both flags on the multi-line xcodebuild invocation', () => { expect( diff --git a/scripts/check-xctest-selection.ts b/scripts/check-xctest-selection.ts index 6b8c36bbd..acade2e18 100644 --- a/scripts/check-xctest-selection.ts +++ b/scripts/check-xctest-selection.ts @@ -188,19 +188,26 @@ function identifiers( ); } -/** What one lane reaches: its platform's compiled set, narrowed by its flags. */ +/** Union the invocations: a skip in one process must not erase an isolated run. */ function laneReach( entry: Lane, declaredTests: readonly DeclaredTest[], - flagged: readonly FlaggedTest[], + workflowText: string, ): Set { - const only = identifiers(flagged, entry.workflow, 'only-testing'); - const skipped = identifiers(flagged, entry.workflow, 'skip-testing'); + const parts = workflowText.split(/^\s*(?:run:\s*)?xcodebuild\s+test(?:-without-building)?\b/m); + const invocations = parts.length > 1 ? parts.slice(1) : parts; + const compiled = declaredTests + .filter((test) => test.platforms.includes(entry.platform)) + .map((test) => test.identifier); return new Set( - declaredTests - .filter((test) => test.platforms.includes(entry.platform)) - .map((test) => test.identifier) - .filter((id) => (entry.selection === 'whole' || only.has(id)) && !skipped.has(id)), + invocations.flatMap((text) => { + const flagged = parseFlaggedTests(entry.workflow, text); + const only = identifiers(flagged, entry.workflow, 'only-testing'); + const skipped = identifiers(flagged, entry.workflow, 'skip-testing'); + // An explicit list narrows even a whole-bundle lane's isolated invocation. + const whole = entry.selection === 'whole' && only.size === 0; + return compiled.filter((id) => (whole || only.has(id)) && !skipped.has(id)); + }), ); } @@ -220,7 +227,14 @@ export function buildReport( // and cannot speak for anything else a workflow might select. const owned = flagged.filter((entry) => entry.identifier.startsWith(`${target}/`)); const reach = Object.fromEntries( - LANES.map((entry) => [entry.id, laneReach(entry, declaredTests, flagged)]), + LANES.map((entry) => [ + entry.id, + laneReach( + entry, + declaredTests, + workflows.find((workflow) => workflow.workflow === entry.workflow)?.text ?? '', + ), + ]), ) as Record>; const entryPoint = `${target}/${ENTRY_POINT_METHOD}`; const reachedAnywhere = new Set(LANES.flatMap((entry) => [...reach[entry.id]]));