Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -51,28 +51,6 @@ extension RunnerTests {
let usedNonHittableFallback: Bool
}

// MARK: - Navigation Gestures

func tapInAppBackControl(app: XCUIApplication) -> Bool {
#if os(macOS)
if let back = macOSNavigationBackElement(app: app) {
tapElementCenter(app: app, element: back)
return true
}
return false
#elseif os(tvOS)
_ = pressTvRemote(.menu)
return true
#else
let buttons = app.navigationBars.buttons.allElementsBoundByIndex
if let back = buttons.first(where: { $0.isHittable }) {
back.tap()
return true
}
return false
#endif
}

func performBackGesture(app: XCUIApplication) {
if pressTvRemote(.menu) {
return
Expand Down Expand Up @@ -1375,27 +1353,6 @@ extension RunnerTests {
}
#endif

private func tapElementCenter(app: XCUIApplication, element: XCUIElement) {
let frame = element.frame
if !frame.isEmpty {
_ = tapAt(app: app, x: frame.midX, y: frame.midY)
return
}
#if !os(tvOS)
element.tap()
#endif
}

private func macOSNavigationBackElement(app: XCUIApplication) -> XCUIElement? {
let predicate = NSPredicate(
format: "identifier == %@ OR label == %@",
"go back",
"Back"
)
let element = app.descendants(matching: .any).matching(predicate).firstMatch
return element.exists ? element : nil
}

#if AGENT_DEVICE_RUNNER_UNIT_TESTS
// Identity in portrait/unknown, 90° per landscape, 180° upside-down.
func testNativeSynthesizedPointRotatesByInterfaceOrientation() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import XCTest

extension RunnerTests {
static let navigationBackKeywords = ["back", "close", "cancel"]
static let navigationFallbackVerificationDelay: TimeInterval = 0.25

func tapInAppBackControl(app: XCUIApplication) -> Bool {
#if os(macOS)
if let back = macOSNavigationBackElement(app: app) {
tapElementCenter(app: app, element: back)
return true
}
return false
#elseif os(tvOS)
_ = pressTvRemote(.menu)
return true
#else
let buttons = app.navigationBars.buttons.allElementsBoundByIndex
if let back = buttons.first(where: { $0.isHittable }) {
back.tap()
return true
}
if isSnapshotXCTestChannelPenalized(bundleId: currentBundleId) {
NSLog("AGENT_DEVICE_RUNNER_IN_APP_BACK_SKIPPED_XCTEST_ENUMERATION bundle=%@", currentBundleId ?? "")
} else if let back = topNavigationBackElement(app: app) {
tapElementCenter(app: app, element: back)
return true
}
return tapTopLeadingNavigationFallback(app: app)
#endif
}

private func tapElementCenter(app: XCUIApplication, element: XCUIElement) {
let frame = element.frame
if !frame.isEmpty {
_ = tapAt(app: app, x: frame.midX, y: frame.midY)
return
}
#if !os(tvOS)
element.tap()
#endif
}

private func topNavigationBackElement(app: XCUIApplication) -> XCUIElement? {
#if os(iOS)
let frame = onScreenWindowFrame(app: app)
let candidates = app.buttons.matching(Self.navigationBackPredicate()).allElementsBoundByIndex.compactMap {
element -> (XCUIElement, Int)? in
guard element.exists, element.isHittable else { return nil }
guard Self.isTopNavigationControlFrame(element.frame, in: frame) else { return nil }
guard let rank = Self.navigationBackControlRank(
label: element.label,
identifier: element.identifier
) else {
return nil
}
return (element, rank)
}
return candidates.sorted { lhs, rhs in
if lhs.1 != rhs.1 { return lhs.1 < rhs.1 }
let leftFrame = lhs.0.frame
let rightFrame = rhs.0.frame
if leftFrame.minY != rightFrame.minY { return leftFrame.minY < rightFrame.minY }
return leftFrame.minX < rightFrame.minX
}.first?.0
#else
return nil
#endif
}

static func navigationBackPredicate() -> NSPredicate {
let clauses = navigationBackKeywords.flatMap { _ in
["label CONTAINS[c] %@", "identifier CONTAINS[c] %@"]
}.joined(separator: " OR ")
let arguments = navigationBackKeywords.flatMap { [$0, $0] }
return NSPredicate(format: clauses, argumentArray: arguments)
}

static func navigationBackControlRank(label: String, identifier: String) -> Int? {
let text = "\(label) \(identifier)".lowercased()
return navigationBackKeywords.firstIndex { text.contains($0) }
}

static func isTopNavigationControlFrame(_ candidate: CGRect, in window: CGRect) -> Bool {
guard
candidate.width.isFinite,
candidate.height.isFinite,
window.width.isFinite,
window.height.isFinite,
candidate.width > 0,
candidate.height > 0,
window.width > 0,
window.height > 0
else {
return false
}
// Accept the compact navigation/search header band without matching deep content controls.
let maxY = window.minY + min(max(window.height * 0.22, 96), 180)
return candidate.midY >= window.minY && candidate.midY <= maxY
}

static func topLeadingNavigationFallbackPoint(in frame: CGRect) -> CGPoint? {
guard frame.width.isFinite, frame.height.isFinite, frame.width > 0, frame.height > 0 else {
return nil
}
// Aim at the standard leading navigation slot, bounded for compact and tablet widths.
let xOffset = min(max(frame.width * 0.08, 28), 44)
// Sit below the status/dynamic-island region and inside common custom RN search headers.
let yOffset = min(max(frame.height * 0.155, 56), 132)
return CGPoint(x: frame.minX + xOffset, y: frame.minY + yOffset)
}

private func tapTopLeadingNavigationFallback(app: XCUIApplication) -> Bool {
#if os(iOS)
let frame = onScreenWindowFrame(app: app)
guard let point = Self.topLeadingNavigationFallbackPoint(in: frame) else {
return false
}
let before = captureNavigationFallbackVisualState()
let context = synthesizedCoordinateContext(
policy: synthesizedGesturePolicy(.coordinateTap)
)?.withReferenceFrame(frame)
let synthesized = performGesture(app, idleTimeout: false) {
synthesizedTapAt(app: app, x: point.x, y: point.y, context: context)
}
if case .performed = synthesized.outcome {
return didNavigationFallbackChangeVisualState(before: before)
}
let fallback = performGesture(app) {
tapAt(app: app, x: point.x, y: point.y)
}
if case .performed = fallback.outcome {
return didNavigationFallbackChangeVisualState(before: before)
}
#endif
return false
}

private func captureNavigationFallbackVisualState() -> Data? {
#if os(iOS)
runnerPngData(for: XCUIScreen.main.screenshot().image)
#else
return nil
#endif
}

private func didNavigationFallbackChangeVisualState(before: Data?) -> Bool {
sleepFor(Self.navigationFallbackVerificationDelay)
let after = captureNavigationFallbackVisualState()
let changed = Self.didNavigationFallbackChangeVisualState(before: before, after: after)
if !changed {
NSLog("AGENT_DEVICE_RUNNER_IN_APP_BACK_FALLBACK_NO_STATE_CHANGE")
}
return changed
}

static func didNavigationFallbackChangeVisualState(before: Data?, after: Data?) -> Bool {
guard let before, let after else { return false }
return before != after
}

private func macOSNavigationBackElement(app: XCUIApplication) -> XCUIElement? {
let predicate = NSPredicate(
format: "identifier == %@ OR label == %@",
"go back",
"Back"
)
let element = app.descendants(matching: .any).matching(predicate).firstMatch
return element.exists ? element : nil
}

#if AGENT_DEVICE_RUNNER_UNIT_TESTS
func testTopLeadingNavigationFallbackPointTargetsHeaderControlBand() throws {
let point = try XCTUnwrap(
Self.topLeadingNavigationFallbackPoint(
in: CGRect(x: 0, y: 0, width: 430, height: 932)
)
)

XCTAssertEqual(point.x, 34.4, accuracy: 0.01)
XCTAssertEqual(point.y, 132, accuracy: 0.01)
}

func testTopLeadingNavigationFallbackPointRejectsInvalidFrame() {
XCTAssertNil(Self.topLeadingNavigationFallbackPoint(in: .infinite))
XCTAssertNil(Self.topLeadingNavigationFallbackPoint(in: .zero))
}

func testNavigationBackControlRankPrefersBackThenCloseThenCancel() {
XCTAssertEqual(Self.navigationBackControlRank(label: "Back", identifier: ""), 0)
XCTAssertEqual(Self.navigationBackControlRank(label: "Close", identifier: ""), 1)
XCTAssertEqual(Self.navigationBackControlRank(label: "Cancel search", identifier: ""), 2)
XCTAssertNil(Self.navigationBackControlRank(label: "Search for more feeds", identifier: ""))
}

func testNavigationBackPredicateUsesTheSharedKeywordTable() {
let predicate = Self.navigationBackPredicate()

XCTAssertTrue(predicate.evaluate(with: ["label": "Back", "identifier": ""]))
XCTAssertTrue(predicate.evaluate(with: ["label": "", "identifier": "close-button"]))
XCTAssertFalse(predicate.evaluate(with: ["label": "Search for more feeds", "identifier": ""]))
}

func testTopNavigationControlFrameAcceptsOnlyHeaderBand() {
let window = CGRect(x: 0, y: 0, width: 430, height: 932)

XCTAssertTrue(
Self.isTopNavigationControlFrame(
CGRect(x: 340, y: 84, width: 72, height: 44),
in: window
)
)
XCTAssertFalse(
Self.isTopNavigationControlFrame(
CGRect(x: 20, y: 760, width: 72, height: 44),
in: window
)
)
XCTAssertFalse(Self.isTopNavigationControlFrame(.infinite, in: window))
}

func testNavigationFallbackRequiresObservedVisualChange() {
XCTAssertTrue(
Self.didNavigationFallbackChangeVisualState(
before: Data([1, 2, 3]),
after: Data([1, 2, 4])
)
)
XCTAssertFalse(
Self.didNavigationFallbackChangeVisualState(
before: Data([1, 2, 3]),
after: Data([1, 2, 3])
)
)
XCTAssertFalse(Self.didNavigationFallbackChangeVisualState(before: nil, after: Data([1])))
XCTAssertFalse(Self.didNavigationFallbackChangeVisualState(before: Data([1]), after: nil))
}
#endif
}
Original file line number Diff line number Diff line change
Expand Up @@ -571,7 +571,8 @@ extension RunnerTests {
func makeSnapshotTraversalContext(
app: XCUIApplication,
options: SnapshotOptions,
captureDeadline: Date = .distantFuture
captureDeadline: Date = .distantFuture,
treeCaptureSliceBudgetOverride: TimeInterval? = nil
) throws -> SnapshotTraversalContext? {
let (viewport, queryRoot) = try runMainThreadWork(
command: nil,
Expand All @@ -584,7 +585,8 @@ extension RunnerTests {
)
}

let slice = min(treeCaptureSliceBudget, max(0.5, captureDeadline.timeIntervalSinceNow))
let treeSliceBudget = treeCaptureSliceBudgetOverride ?? treeCaptureSliceBudget
let slice = min(treeSliceBudget, max(0.5, captureDeadline.timeIntervalSinceNow))
guard let rootSnapshot = try captureSnapshotRootBounded(queryRoot, sliceSeconds: slice) else {
return nil
}
Expand All @@ -600,7 +602,7 @@ extension RunnerTests {
)
}

static let treeCaptureTimeoutCode = "IOS_TREE_CAPTURE_TIMEOUT"
static let xCTestSnapshotTimeoutCode = "IOS_TREE_CAPTURE_TIMEOUT"

func hasAbandonedTreeCapture() -> Bool {
treeCaptureLock.lock()
Expand All @@ -611,7 +613,8 @@ extension RunnerTests {
/// Runs the blocking tree-snapshot XPC on the main thread bounded by `sliceSeconds`. On
/// timeout the XPC keeps running on main (it cannot be cancelled); the capture is marked
/// abandoned so plans avoid XCTest-backed tiers until it drains, the tree backend is penalized
/// for this bundle, and the plan moves on to the private AX backend (#1105/#1122).
/// for this bundle, and the plan moves to the platform's independent recovery tier when one
/// exists (#1105/#1122).
private func captureSnapshotRootBounded(
_ element: XCUIElement,
sliceSeconds: TimeInterval
Expand All @@ -628,7 +631,7 @@ extension RunnerTests {
self.abandonedTreeCaptureCount += 1
self.treeCaptureLock.unlock()
NSLog("AGENT_DEVICE_RUNNER_TREE_CAPTURE_SLICE_TIMEOUT slice=%.1f", sliceSeconds)
self.penalizeSnapshotTreeBackend(
self.penalizeSnapshotXCTestChannel(
bundleId: self.currentBundleId,
reason: "tree_capture_slice_timeout"
)
Expand All @@ -647,17 +650,17 @@ extension RunnerTests {
private func treeCaptureTimeoutError(sliceSeconds: TimeInterval) -> () -> Error {
{
SnapshotCaptureFailure(
code: Self.treeCaptureTimeoutCode,
code: Self.xCTestSnapshotTimeoutCode,
message: "the XCTest tree capture exceeded its \(Int(sliceSeconds))s time slice",
hint: "The capture plan recovers through the private AX backend on this screen."
hint: "The capture plan will avoid or tightly bound XCTest-backed snapshot tiers on this screen."
)
}
}

func snapshotMainThreadTimeoutError(_ operation: String) -> () -> Error {
{
SnapshotCaptureFailure(
code: Self.treeCaptureTimeoutCode,
code: Self.xCTestSnapshotTimeoutCode,
message: "timed out while \(operation) on the XCTest main thread",
hint: "The capture plan will skip XCTest-backed snapshot tiers while the previous main-thread work drains."
)
Expand Down
Loading
Loading