From c45162e482357304ff7224d4f0e290b8fa27bb99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 18 Oct 2026 19:38:32 +0200 Subject: [PATCH 1/2] test(ios): make dropped-select-all coverage independent of shortcut recovery --- ...unnerTests+SynthesizedTextEntryTests.swift | 42 +++++++------------ 1 file changed, 16 insertions(+), 26 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedTextEntryTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedTextEntryTests.swift index 2c67a43489..e954ec30f5 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedTextEntryTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedTextEntryTests.swift @@ -194,47 +194,37 @@ extension RunnerTests { XCTAssertEqual(String(describing: textField.value ?? ""), "") } - /// Drops the select-all of the first `remaining` replacing posts, the way a React Native field in - /// a freshly launched app does, so each of those passes deletes only the last character. - final class SelectAllDroppingSynthesizer: TextEntrySynthesizing { - var remaining: Int - - init(dropping count: Int) { - remaining = count - } - + /// Drops every select-all while preserving real synthesized delete and text delivery. + struct SelectAllDroppingSynthesizer: TextEntrySynthesizing { func enterText( app: XCUIApplication, text: String, replacingExistingText: Bool ) -> SynthesizedTextEntryAction { - let dropsSelectAll = replacingExistingText && remaining > 0 - if dropsSelectAll { remaining -= 1 } - return PrivateXCTestTextEntrySynthesizer().enterText( + PrivateXCTestTextEntrySynthesizer().enterText( app: app, text: text, - replacingExistingText: replacingExistingText && !dropsSelectAll + replacingExistingText: false ) } } - /// Two fixed clear passes left "Clie" of "Client" in 3 of 5 fills on a freshly launched React - /// Native app that dropped its select-alls, and the fill typed after it. The clear now repeats - /// until the field reads empty, and types nothing over text it could not remove. + /// A later Command-A can also be dropped by XCTest/iOS, so neither case relies on it succeeding. + /// Three characters require more than the old two clear passes; five exceed the four-pass budget. @MainActor func testSynthesizedReplacementClearsPastDroppedSelectAlls() throws { - let field = try focusSynthesizedReplacementField() - defer { tearDownSynthesizedReplacementField() } - let frame = field.frame - let found = try requireTextInputAtPoint(app: app, x: frame.midX, y: frame.midY) - let cases: [(dropped: Int, failure: TextEntryFailure?)] = [ - (2, nil), - (SynthesizedTextPlan.Step.maxClearPassCount, .clearNotObserved), + let cases: [(seed: String, failure: TextEntryFailure?)] = [ + ("old", nil), + ("stale", .clearNotObserved), ] for testCase in cases { - let label = "\(testCase.dropped) select-alls dropped" - let seeded = try replaceSynthesizedFieldText(field, text: "stale", commandId: "seed-\(testCase.dropped)") + let field = try focusSynthesizedReplacementField() + defer { tearDownSynthesizedReplacementField() } + let frame = field.frame + let found = try requireTextInputAtPoint(app: app, x: frame.midX, y: frame.midY) + let label = "\(testCase.seed.count) characters, all select-alls dropped" + let seeded = try replaceSynthesizedFieldText(field, text: testCase.seed, commandId: "seed-\(testCase.seed.count)") XCTAssertTrue(seeded.ok, String(describing: seeded.error)) let result = typeTextReliably( app: app, @@ -248,7 +238,7 @@ extension RunnerTests { delaySeconds: 0, repairMode: .replacement, xCTestChannelPenalized: true, - synthesizer: SelectAllDroppingSynthesizer(dropping: testCase.dropped) + synthesizer: SelectAllDroppingSynthesizer() ) let value = String(describing: field.value ?? "") From 090e27420ad5521a14f657c80cd09e33ad089145 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 19 Oct 2026 07:58:23 +0200 Subject: [PATCH 2/2] test(ios): isolate capture-budget regression from live prerequisites --- ...RunnerTests+SnapshotAcquisitionTests.swift | 9 +- ...ts+SnapshotCapturePlanOccupancyTests.swift | 109 ++++++++++-------- ...unnerTests+SynthesizedTextEntryTests.swift | 4 +- 3 files changed, 65 insertions(+), 57 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotAcquisitionTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotAcquisitionTests.swift index a4db44a49c..2331644477 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotAcquisitionTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotAcquisitionTests.swift @@ -2,17 +2,19 @@ import XCTest import AgentDeviceSnapshotPresentation -/// An element's attributes as XCTest hands them to a query predicate. +/// Snapshot attributes shared by query-predicate and capture-plan fixtures. @MainActor -private final class ElementAttributesFixture: NSObject, XCUIElementSnapshot { +final class ElementAttributesFixture: NSObject, XCUIElementSnapshot { let elementType: XCUIElement.ElementType let frame: CGRect let label: String + let children: [XCUIElementSnapshot] - init(_ elementType: XCUIElement.ElementType, _ frame: CGRect, label: String = "") { + init(_ elementType: XCUIElement.ElementType, _ frame: CGRect, label: String = "", children: [XCUIElementSnapshot] = []) { self.elementType = elementType self.frame = frame self.label = label + self.children = children } var identifier: String { "" } @@ -26,7 +28,6 @@ private final class ElementAttributesFixture: NSObject, XCUIElementSnapshot { #if !os(macOS) var hasFocus: Bool { false } #endif - var children: [XCUIElementSnapshot] { [] } var dictionaryRepresentation: [XCUIElement.AttributeName: Any] { [:] } } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanOccupancyTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanOccupancyTests.swift index f181db2ae8..55f3c0416f 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanOccupancyTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanOccupancyTests.swift @@ -70,37 +70,35 @@ private final class RunnerSlowSweepQueryStub: NSObject { } } -/// Per-run state for `RunnerSlowRootSnapshotStub`: the swizzled IMP cannot capture the original -/// implementation or a test-local flag. +/// The root-read delay exhausts the collapsed-tab query's processing slice. All other facts are +/// fixed, so XCTest's viewport, orientation, keyboard, and large-tree XPCs cannot consume the slice. private enum RunnerSlowRootSnapshotGate { - /// Longer than half the plan deadline the test passes, so whatever slice is left after the root - /// read is shorter than the read itself, while the real read still has most of `delay` to finish. static let delay: TimeInterval = 3 - static var original: IMP? static var delayed = false + static var root: XCUIElementSnapshot? } -/// Stands in for `-[XCUIElement snapshotWithError:]` so the capture's root read, the plan's first -/// tree snapshot, takes at least `RunnerSlowRootSnapshotGate.delay` and then reads the real tree. private final class RunnerSlowRootSnapshotStub: NSObject { @objc(snapshotWithError:) func snapshot() throws -> XCUIElementSnapshot { - if !RunnerSlowRootSnapshotGate.delayed { - RunnerSlowRootSnapshotGate.delayed = true - Thread.sleep(forTimeInterval: RunnerSlowRootSnapshotGate.delay) + RunnerSlowRootSnapshotGate.delayed = true + Thread.sleep(forTimeInterval: RunnerSlowRootSnapshotGate.delay) + guard let root = RunnerSlowRootSnapshotGate.root else { + throw NSError(domain: "AgentDeviceRunner.tests", code: 2) } - typealias Read = @convention(c) ( - AnyObject, Selector, AutoreleasingUnsafeMutablePointer? - ) -> XCUIElementSnapshot? - let read = unsafeBitCast(RunnerSlowRootSnapshotGate.original, to: Read.self) - var error: NSError? - guard let snapshot = read(self, #selector(XCUIElement.snapshot), &error) else { - throw error ?? NSError(domain: "AgentDeviceRunner.tests", code: 2) - } - return snapshot + return root } } +/// XCTest getters used by the plan's prerequisites and query-sweep fallback. The method swaps are +/// process-wide and restored after this serial XCTest case, as the blocking snapshot stub is above. +private final class RunnerSnapshotFactsStub: NSObject { + @objc var frame: CGRect { CGRect(x: 0, y: 0, width: 390, height: 844) } + @objc var exists: Bool { false } + @objc var allElementsBoundByIndex: [XCUIElement] { [] } + @objc class func interfaceOrientation(forApplication application: AnyObject) -> Int { 1 } +} + extension RunnerTests { /// The Bluesky feed shape: the tree XPC grinds past its slice. The plan must recover through /// private AX without queueing the query sweep behind the abandoned XPC, and a fresh process's @@ -476,48 +474,57 @@ extension RunnerTests { XCTAssertEqual(quality.reasonCode, "budget") } - /// A childless tab bar makes the tree tier run the collapsed tab query, and a slow root read leaves too - /// little of the processing slice for it to start. The plan must treat that tree as a tier that - /// ran out of time and move on, not accept the truncated tree as a healthy capture. + /// A childless tab bar needs a query whose estimated cost exceeds the remaining processing slice. + /// The plan must reject that incomplete tree even though its visible buttons otherwise look healthy. func testPlanMovesPastATreeTierWhoseCollapsedTabQueryCannotStart() throws { - guard - let snapshotMethod = class_getInstanceMethod( - XCUIApplication.self, - #selector(XCUIElement.snapshot) - ), - let stubMethod = class_getInstanceMethod( - RunnerSlowRootSnapshotStub.self, - #selector(RunnerSlowRootSnapshotStub.snapshot) - ) - else { - XCTFail("unable to install the slow root snapshot stub") - return + let swaps: [(Method?, Method?)] = [ + (class_getInstanceMethod(XCUIApplication.self, #selector(XCUIElement.snapshot)), + class_getInstanceMethod(RunnerSlowRootSnapshotStub.self, #selector(RunnerSlowRootSnapshotStub.snapshot))), + (class_getInstanceMethod(XCUIElement.self, #selector(getter: XCUIElement.frame)), + class_getInstanceMethod(RunnerSnapshotFactsStub.self, #selector(getter: RunnerSnapshotFactsStub.frame))), + (class_getInstanceMethod(XCUIElement.self, #selector(getter: XCUIElement.exists)), + class_getInstanceMethod(RunnerSnapshotFactsStub.self, #selector(getter: RunnerSnapshotFactsStub.exists))), + (class_getInstanceMethod(XCUIElementQuery.self, #selector(getter: XCUIElementQuery.allElementsBoundByIndex)), + class_getInstanceMethod(RunnerSnapshotFactsStub.self, #selector(getter: RunnerSnapshotFactsStub.allElementsBoundByIndex))), + (class_getClassMethod(RunnerSynthesizedGesture.self, #selector(RunnerSynthesizedGesture.interfaceOrientation(forApplication:))), + class_getClassMethod(RunnerSnapshotFactsStub.self, #selector(RunnerSnapshotFactsStub.interfaceOrientation(forApplication:)))), + ] + var originals: [(Method, IMP)] = [] + let previousPrivateAX = privateAXAcquisitionOverrideForTesting + defer { + for (method, original) in originals.reversed() { method_setImplementation(method, original) } + RunnerSlowRootSnapshotGate.root = nil + privateAXAcquisitionOverrideForTesting = previousPrivateAX + clearSnapshotXCTestChannelPenalty(reason: "test-cleanup") + MainActor.assumeIsolated { invalidateCachedTarget(reason: "unit_test_cleanup") } + } + for (method, stub) in swaps { + let method = try XCTUnwrap(method) + let stub = try XCTUnwrap(stub) + originals.append((method, method_setImplementation(method, method_getImplementation(stub)))) } - app.launchArguments = ["--agent-device-crowded-screen", "--agent-device-collapsed-tab-bar"] - app.launch() - XCTAssertTrue(app.staticTexts["agent-device-crowded-row-499"].waitForExistence(timeout: appExistenceTimeout)) let captureTarget = MainActor.assumeIsolated { + let windowFrame = CGRect(x: 0, y: 0, width: 390, height: 844) + let buttons: [XCUIElementSnapshot] = (0..<13).map { index in + ElementAttributesFixture( + .button, + CGRect(x: 20, y: 20 + index * 45, width: 100, height: 30), + label: "Row \(index)" + ) + } + let tabBar = ElementAttributesFixture(.tabBar, CGRect(x: 0, y: 790, width: 390, height: 50)) + RunnerSlowRootSnapshotGate.root = ElementAttributesFixture( + .application, windowFrame, label: "Fixture", + children: [ElementAttributesFixture(.window, windowFrame, children: buttons + [tabBar])] + ) mainOwned.app = app mainOwned.bundleId = "com.callstack.agentdevice.runner.collapsed-tab-query-test" return takeSnapshotCaptureTarget(app: app) } + privateAXAcquisitionOverrideForTesting = { nil } snapshotXCTestPenaltyWarmupExemption.isPending = false clearSnapshotXCTestChannelPenalty(reason: "test-setup") RunnerSlowRootSnapshotGate.delayed = false - RunnerSlowRootSnapshotGate.original = method_setImplementation( - snapshotMethod, - method_getImplementation(stubMethod) - ) - defer { - if let original = RunnerSlowRootSnapshotGate.original { - method_setImplementation(snapshotMethod, original) - } - clearSnapshotXCTestChannelPenalty(reason: "test-cleanup") - MainActor.assumeIsolated { - invalidateCachedTarget(reason: "unit_test_cleanup") - } - app.terminate() - } let payload = try runDeferredPlanOffMain( target: captureTarget, diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedTextEntryTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedTextEntryTests.swift index e954ec30f5..f9ebaf62b0 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedTextEntryTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedTextEntryTests.swift @@ -210,12 +210,12 @@ extension RunnerTests { } /// A later Command-A can also be dropped by XCTest/iOS, so neither case relies on it succeeding. - /// Three characters require more than the old two clear passes; five exceed the four-pass budget. + /// Three characters require more than the old two clear passes; refusal exceeds the clear budget. @MainActor func testSynthesizedReplacementClearsPastDroppedSelectAlls() throws { let cases: [(seed: String, failure: TextEntryFailure?)] = [ ("old", nil), - ("stale", .clearNotObserved), + (String(repeating: "x", count: SynthesizedTextPlan.Step.maxClearPassCount + 1), .clearNotObserved), ] for testCase in cases {