test(ios): wait for a key, foreground-active window before presenting in RunnerTests (#9275)

## Summary
Split out of the `flakey-monday` flake-fix series. Two iOS RunnerTests
acted right after `makeKeyAndVisible()` without waiting for the window
to become key in a foreground-active scene, which is the state the code
under test resolves its presenter from. A shared test helper now waits
for that state before the tests present anything.

Commits (cherry-picked from `flakey-monday`, original messages kept):
- `test(mobile): wait for a key window before presenting in iOS tests`
- `test(mobile): wait for a foreground-active scene in the key-window
helper`

**These Swift changes were NOT compiled or run locally.** The fixes were
written on a Linux workstation without Xcode or an iOS simulator, and
this split branch was not built either. iOS CI on this PR is the first
place they compile and run.

### Root cause and fix per test


**`RunnerTests.testRejectedCommunityEntryPresentationCompletesAndAllowsRetry`**
(`mobile/ios/RunnerTests/RunnerTests.swift`): timed out after 2 s
waiting for "Temporary native modal appeared".

**`IdentityFilePluginTests.testPickerResolvesPresenterAtRequestAndCancelsExactlyOnce`**
(`mobile/ios/RunnerTests/IdentityFilePluginTests.swift`): waited 5 s for
`parent.presentedViewController is UIDocumentPickerViewController`,
which never became true.
- Root cause (inferred from the code paths and CI symptoms; not
reproduced): `NativeCommunityEntryCoordinator` and
`IdentityFilePlugin.pick()` both resolve their presenter as the root of
the `isKeyWindow` window in a `.foregroundActive` scene.
`makeKeyAndVisible()` can return before key status moves to the new
window, so `pick()` presented on a different window's controller and the
expectation on `parent` could never be met. The community-entry test
also attached its window to the first connected scene rather than the
foreground-active one, swapped `rootViewController` and presented
immediately; UIKit silently drops a presentation from a controller whose
view is not yet in a window.
- Fix: a shared `XCTestCase` helper in `RunnerTests.swift`, internal so
`IdentityFilePluginTests` can use it without changing the Xcode project.
`keyWindow(showing:)` attaches a window to the `.foregroundActive`
scene, makes it key, and returns once a predicate expectation sees
`window.isKeyWindow` and the root's view in that window.
`keyWindowReady(_:showing:)` is that predicate; the community-entry test
waits on it again after swapping the root. The follow-up commit replaces
the `XCTUnwrap` of the foreground-active scene with the same
predicate-expectation wait (5 s budget), since the first test to run
could otherwise fail while its scene is still activating. The
community-entry test became `async`; its fixed 2 s presentation wait
became 5 s, the budget the picker test already uses. The readiness waits
are the fix, not the longer timeout. Assertions are unchanged.

### Related issue
N/A (flake fixes; none found).

### Testing
Human testing is still required. This PR does not carry
`buzz-review-completed`; the author has not yet run RunnerTests
themselves on this branch.

#### Verification
Not run locally at any point: Swift tests need Xcode and an iOS
simulator, which the authoring workstation lacked, and nothing was built
on this split branch either (the workstation was overloaded, so no
tests, builds or linters were executed after cherry-picking). The review
pass on `flakey-monday` confirmed the `keyWindowReady` shape compiles;
the follow-up follows that same shape. iOS CI on this PR is the
verification for both the fix and the split branch.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: mtoohey <mtoohey@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Matt Toohey
2026-11-10 08:39:12 +11:00
committed by GitHub
parent 15c2ca1b01
commit ea548da808
2 changed files with 54 additions and 18 deletions

View File

@@ -6,17 +6,12 @@ import XCTest
/// Actual UIKit picker presentation and method-channel dispatch, followed by
/// synthetic document-provider delegate callbacks. Never persists an identity.
final class IdentityFilePluginTests: XCTestCase {
/// `pick()` presents on the key window's root, so import is invoked only
/// once this window is key; otherwise the picker can land on another window.
@MainActor
private func makeWindow() throws -> (UIWindow, UIViewController) {
let scene = try XCTUnwrap(UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first { $0.activationState == .foregroundActive })
private func makeWindow() async throws -> (UIWindow, UIViewController) {
let parent = UIViewController()
let window = UIWindow(windowScene: scene)
window.rootViewController = parent
window.makeKeyAndVisible()
parent.loadViewIfNeeded()
return (window, parent)
return (try await keyWindow(showing: parent), parent)
}
@MainActor
@@ -79,7 +74,7 @@ final class IdentityFilePluginTests: XCTestCase {
let plugin = IdentityFilePlugin(messenger: messenger)
XCTAssertFalse(manager.fileExists(atPath: interrupted.path))
XCTAssertTrue(manager.fileExists(atPath: unrelated.path))
let (window, parent) = try makeWindow()
let (window, parent) = try await makeWindow()
defer { window.isHidden = true }
var responses = 0
messenger.invokeImport { value in
@@ -124,7 +119,7 @@ final class IdentityFilePluginTests: XCTestCase {
func testSyntheticDocumentsReadInPlaceAndRejectInvalidOrOversize() async throws {
let messenger = IdentityFileTestMessenger()
let plugin = IdentityFilePlugin(messenger: messenger)
let (window, parent) = try makeWindow()
let (window, parent) = try await makeWindow()
defer { window.isHidden = true }
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)

View File

@@ -9,13 +9,10 @@ import XCTest
class RunnerTests: XCTestCase {
@MainActor
func testRejectedCommunityEntryPresentationCompletesAndAllowsRetry() throws {
func testRejectedCommunityEntryPresentationCompletesAndAllowsRetry() async throws {
let messenger = NavigationTestMessenger()
let presenter = RejectedCommunityEntryPresenter()
let window = UIWindow(frame: UIScreen.main.bounds)
window.windowScene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
window.rootViewController = presenter
window.makeKeyAndVisible()
let window = try await keyWindow(showing: presenter)
defer { window.isHidden = true }
let coordinator = NativeCommunityEntryCoordinator(messenger: messenger)
for session in [1, 2] {
@@ -26,11 +23,13 @@ class RunnerTests: XCTestCase {
}
let root = UIViewController()
window.rootViewController = root
window.makeKeyAndVisible()
// Present only once the new root's view is in the key window: UIKit
// drops a presentation from a controller that is not in a window yet.
await fulfillment(of: [keyWindowReady(window, showing: root)], timeout: 5)
let temporary = UIViewController()
let presented = expectation(description: "Temporary native modal appeared")
root.present(temporary, animated: false) { presented.fulfill() }
wait(for: [presented], timeout: 2)
await fulfillment(of: [presented], timeout: 5)
XCTAssertTrue(root.presentedViewController === temporary)
XCTAssertNotNil(temporary.view.window)
var reply: Any?
@@ -2126,3 +2125,45 @@ private final class RejectedCommunityEntryPresenter: UIViewController {
attempts += 1
}
}
// MARK: - Key window readiness
extension XCTestCase {
/// A window of the foreground-active scene showing `root`, returned once it
/// is the key window and `root`'s view is in it.
///
/// Native presenters resolve the key window of the foreground-active scene,
/// and UIKit silently drops a presentation from a controller whose view is
/// not in a window. `makeKeyAndVisible()` can return before key status moves
/// to the new window, so acting right after it raced UIKit.
@MainActor
func keyWindow(showing root: UIViewController) async throws -> UIWindow {
// The scene can still be activating when the first test runs.
let activeScene = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in
self.foregroundActiveScene() != nil
}, object: nil)
await fulfillment(of: [activeScene], timeout: 5)
let scene = try XCTUnwrap(foregroundActiveScene())
let window = UIWindow(windowScene: scene)
window.rootViewController = root
window.makeKeyAndVisible()
await fulfillment(of: [keyWindowReady(window, showing: root)], timeout: 5)
return window
}
/// The scene production code presents in: the foreground-active one.
@MainActor
func foregroundActiveScene() -> UIWindowScene? {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first { $0.activationState == .foregroundActive }
}
/// Fulfilled once `window` is the key window and shows `root`'s view.
@MainActor
func keyWindowReady(_ window: UIWindow, showing root: UIViewController) -> XCTestExpectation {
XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in
window.isKeyWindow && root.viewIfLoaded?.window === window
}, object: nil)
}
}