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 @@ -422,6 +422,8 @@ - (void)viewDidLoad {
// UIApplicationSceneManifest in Info.plist names this class; a rename must update the manifest.
@interface AgentDeviceRunnerSceneDelegate : UIResponder <UIWindowSceneDelegate>
@property(nonatomic, strong) UIWindow *window;
// Held for the app's lifetime: a window nobody retained would be gone before the test queried it.
@property(nonatomic, strong) UIWindow *secondWindow;
@end

@implementation AgentDeviceRunnerSceneDelegate
Expand All @@ -442,6 +444,16 @@ - (void)scene:(UIScene *)scene
if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-dismiss-popup-window"]) {
self.window.accessibilityIdentifier = @"Dismiss popup";
}
// A second window the app keeps visible, so `app.windows` reports the main window and this one.
// Window order is what the resolver walks, and a candidate behind the answer must never be read.
if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-second-window"]) {
UIWindow *second = [[UIWindow alloc] initWithWindowScene:(UIWindowScene *)scene];
second.frame = ((UIWindowScene *)scene).coordinateSpace.bounds;
second.accessibilityIdentifier = @"agent-device-second-window";
second.rootViewController = [UIViewController new];
self.secondWindow = second;
[second makeKeyAndVisible];
}
#endif
[self.window makeKeyAndVisible];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -224,25 +224,23 @@ extension RunnerTests {
/// fold, a sheet, or a rotation moves every consumer to the same window in the same pass.
/// Synthesized gestures route their display ID through the returned window so the record's
/// display can never name a different window than the one the reference frame was measured on.
///
/// Stops at the first qualifying window. Reading a window that will be discarded is not free: a
/// transient candidate that disappears between its `exists` answer and its `frame` fetch makes
/// XCTest record an issue, and an issue `record(_:)` does not mute ends the runner session
/// (ADR 0004, ADR 0005, #2995).
///
/// The read that remains is not atomic: a window vanishing while it is the one being resolved still
/// records, and the chosen window is read again on its way to a display ID
/// (`RunnerResolveWindowDisplayID`).
func resolveRunnerWindow(app: XCUIApplication) -> (window: XCUIElement?, frame: CGRect) {
let windows = app.windows.allElementsBoundByIndex
var frames: [CGRect?] = []
for window in windows {
frames.append(window.exists ? window.frame : nil)
}
if let index = Self.firstUsableWindowIndex(frames: frames) {
return (windows[index], frames[index] ?? .zero)
}
return (nil, app.frame)
}

static func firstUsableWindowIndex(frames: [CGRect?]) -> Int? {
for (index, frame) in frames.enumerated() {
if let frame, !frame.isEmpty {
return index
for window in app.windows.allElementsBoundByIndex where window.exists {
let frame = window.frame
if !frame.isEmpty {
return (window, frame)
}
}
return nil
return (nil, app.frame)
}

func onScreenWindowFrame(app: XCUIApplication) -> CGRect {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,137 @@ extension RunnerTests {
}
}
#endif

#if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS)
import ObjectiveC.runtime

/// Answers `-[XCUIElement frame]` from a plan while counting the reads, so a test decides which
/// candidates the runtime refuses and the only variable left is how many windows the resolver asked
/// about. Installed for the duration of one test, which serial XCTest execution tolerates; the same
/// `method_setImplementation` technique the snapshot-plan lane uses for
/// `-[XCUIElement snapshotWithError:]`.
private enum RunnerWindowFrameReads {
static let usableFrame = CGRect(x: 0, y: 0, width: 402, height: 874)
private static let lock = NSLock()
private static var count = 0
private static var answers: [CGRect] = []

/// Answers the Nth read from `answers`, repeating the last one beyond its end, so every window the
/// app reports has a reply waiting and the read count is the resolver's choice alone. A `.zero`
/// entry is what an unmeasured window answers — `(0,0 0x0)` without raising (ADR 0025).
static func begin(answering answers: [CGRect]) {
lock.lock()
count = 0
RunnerWindowFrameReads.answers = answers
lock.unlock()
}

static func recorded() -> Int {
lock.lock()
defer { lock.unlock() }
return count
}

static func answerNextRead() -> CGRect {
lock.lock()
let index = count
count += 1
let answer = index < answers.count ? answers[index] : (answers.last ?? .zero)
lock.unlock()
return answer
}
}

private final class RunnerWindowFrameProbe: NSObject {
@objc var frame: CGRect {
RunnerWindowFrameReads.answerNextRead()
}
}

extension RunnerTests {
/// #2995: resolving a window must read the window it books and nothing behind it. Reads are
/// counted rather than matched by identity because which window `XCUIApplication.windows` reports
/// first is XCTest's to choose; the property is that the first answer is the last read.
@MainActor
func testRunnerWindowResolutionStopsReadingAtTheWindowItChose() throws {
let outcome = try resolveRunnerWindowAgainstTwoWindows(
answering: [RunnerWindowFrameReads.usableFrame]
)
XCTAssertNotNil(outcome.resolved.window, "an app with two usable windows resolves one of them")
XCTAssertEqual(outcome.reads, 1, "a window behind the chosen one was read")
}

/// The closest negative, so the single read above cannot pass because the resolver never looked at
/// a second window at all: when the first candidate reports no frame, the second one must be asked.
@MainActor
func testRunnerWindowResolutionKeepsLookingPastAWindowWithNoFrame() throws {
let outcome = try resolveRunnerWindowAgainstTwoWindows(
answering: [.zero, RunnerWindowFrameReads.usableFrame]
)
XCTAssertNotNil(
outcome.resolved.window,
"the second window qualifies, so the resolver owes a window rather than app.frame"
)
XCTAssertEqual(outcome.reads, 2, "a refused first window costs the second window its read")
}

/// The other half of stopping early: when no candidate qualifies, the walk must still have asked
/// every one of them, and it must refuse a window rather than book a zero rectangle to normalize
/// against.
@MainActor
func testRunnerWindowResolutionRefusesAWindowWhenEveryCandidateMeasuresZero() throws {
let outcome = try resolveRunnerWindowAgainstTwoWindows(answering: [.zero])
XCTAssertNil(outcome.resolved.window, "no candidate qualifies, so no window is booked against")
XCTAssertEqual(
outcome.reads,
3,
"the walk gives up only after asking both windows, and the fallback's own app.frame read is the third"
)
// `XCUIApplication` declares no `frame` of its own, so the third read is the same swizzled
// `XCUIElement` getter and the fallback frame is the probe's answer. A real app frame here would
// mean the fallback reads geometry the probe never saw.
XCTAssertEqual(
outcome.resolved.frame,
.zero,
"the app.frame fallback goes through the same read as a window's"
)
}

/// Launches the two-window fixture, installs the frame probe answering `frames` in read order
/// (repeating its last answer), and resolves once.
@MainActor
private func resolveRunnerWindowAgainstTwoWindows(
answering frames: [CGRect]
) throws -> (resolved: (window: XCUIElement?, frame: CGRect), reads: Int) {
app.launchArguments = ["--agent-device-second-window"]
app.launch()
defer {
invalidateCachedTarget(reason: "unit_test_cleanup")
app.terminate()
}
XCTAssertTrue(app.waitForExistence(timeout: appExistenceTimeout))
XCTAssertEqual(
app.windows.allElementsBoundByIndex.filter(\.exists).count,
2,
"the fixture must leave two live windows, or no candidate is left behind the answer"
)

guard
let frameMethod = class_getInstanceMethod(XCUIElement.self, #selector(getter: XCUIElement.frame)),
let probeMethod = class_getInstanceMethod(
RunnerWindowFrameProbe.self,
#selector(getter: RunnerWindowFrameProbe.frame)
)
else {
XCTFail("unable to install the window frame probe")
return ((nil, .zero), 0)
}
RunnerWindowFrameReads.begin(answering: frames)
let originalImplementation = method_getImplementation(frameMethod)
method_setImplementation(frameMethod, method_getImplementation(probeMethod))
defer { method_setImplementation(frameMethod, originalImplementation) }

return (resolveRunnerWindow(app: app), RunnerWindowFrameReads.recorded())
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -110,14 +110,6 @@ extension RunnerTests {
XCTAssertEqual(displayID, 99)
}

func testFirstUsableWindowSkipsAbsentAndEmptyWindows() {
let lit = CGRect(x: 0, y: 0, width: 402, height: 874)
XCTAssertEqual(RunnerTests.firstUsableWindowIndex(frames: [lit]), 0)
XCTAssertEqual(RunnerTests.firstUsableWindowIndex(frames: [nil, .zero, lit]), 2)
XCTAssertNil(RunnerTests.firstUsableWindowIndex(frames: [nil, .zero, nil]))
XCTAssertNil(RunnerTests.firstUsableWindowIndex(frames: []))
}

func testResolvedScreenHandsBackTheScreenTheResolvedWindowReported() {
let window = DisplayWindowFixture(frame: CGRect(x: 0, y: 0, width: 951, height: 669), displayID: 3)
var screen: AnyObject?
Expand Down
Loading