mirror of
https://github.com/block/buzz.git
synced 2026-11-10 11:21:05 +00:00
## 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>
178 lines
7.7 KiB
Swift
178 lines
7.7 KiB
Swift
import Flutter
|
|
import UIKit
|
|
import XCTest
|
|
@testable import Buzz
|
|
|
|
/// 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() async throws -> (UIWindow, UIViewController) {
|
|
let parent = UIViewController()
|
|
return (try await keyWindow(showing: parent), parent)
|
|
}
|
|
|
|
@MainActor
|
|
private func picker(in parent: UIViewController) async throws -> UIDocumentPickerViewController {
|
|
let presented = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in
|
|
parent.presentedViewController is UIDocumentPickerViewController
|
|
}, object: nil)
|
|
await fulfillment(of: [presented], timeout: 5)
|
|
return try XCTUnwrap(parent.presentedViewController as? UIDocumentPickerViewController)
|
|
}
|
|
|
|
@MainActor
|
|
private func settle(_ controller: UIViewController) async {
|
|
if let transition = controller.transitionCoordinator {
|
|
await withCheckedContinuation { continuation in
|
|
transition.animate(alongsideTransition: nil) { _ in continuation.resume() }
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func dismiss(_ parent: UIViewController) async {
|
|
await withCheckedContinuation { continuation in
|
|
parent.dismiss(animated: false) { continuation.resume() }
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func testPickerResolvesPresenterAtRequestAndCancelsExactlyOnce() async throws {
|
|
let messenger = IdentityFileTestMessenger()
|
|
// A previous process died with a share open. Startup must remove only its
|
|
// export directory, while preserving unrelated temporary documents.
|
|
let manager = FileManager.default
|
|
// Matches path_provider_foundation 2.6.0 getTemporaryPath().
|
|
let exportRoot = try XCTUnwrap(manager.urls(for: .cachesDirectory, in: .userDomainMask).first)
|
|
// Exercise the production method-channel writer, not a lookalike fixture.
|
|
let exporter = IdentityFilePlugin(messenger: messenger)
|
|
var exportedPath: String?
|
|
messenger.invoke("prepareExport", arguments: ["text": "synthetic-export-not-a-key\n"]) {
|
|
exportedPath = $0 as? String
|
|
}
|
|
let exported = URL(fileURLWithPath: try XCTUnwrap(exportedPath))
|
|
let interrupted = exported.deletingLastPathComponent()
|
|
XCTAssertEqual(interrupted.deletingLastPathComponent().standardizedFileURL, exportRoot.standardizedFileURL)
|
|
XCTAssertEqual(try String(contentsOf: exported, encoding: .utf8), "synthetic-export-not-a-key\n")
|
|
#if !targetEnvironment(simulator)
|
|
// Simulator does not implement iOS Data Protection attributes.
|
|
let attributes = try manager.attributesOfItem(atPath: exported.path)
|
|
XCTAssertEqual(attributes[.protectionKey] as? FileProtectionType, .complete)
|
|
#endif
|
|
withExtendedLifetime(exporter) {}
|
|
let unrelated = exportRoot.appendingPathComponent("unrelated-\(UUID().uuidString)")
|
|
try Data("keep".utf8).write(to: unrelated)
|
|
defer {
|
|
try? manager.removeItem(at: interrupted)
|
|
try? manager.removeItem(at: unrelated)
|
|
}
|
|
// Initialize before this window exists: registrar startup timing must not
|
|
// permanently prevent later user-triggered presentation.
|
|
let plugin = IdentityFilePlugin(messenger: messenger)
|
|
XCTAssertFalse(manager.fileExists(atPath: interrupted.path))
|
|
XCTAssertTrue(manager.fileExists(atPath: unrelated.path))
|
|
let (window, parent) = try await makeWindow()
|
|
defer { window.isHidden = true }
|
|
var responses = 0
|
|
messenger.invokeImport { value in
|
|
responses += 1
|
|
XCTAssertNil(value)
|
|
}
|
|
let picker = try await picker(in: parent)
|
|
await settle(picker)
|
|
XCTAssertFalse(picker.allowsMultipleSelection)
|
|
XCTAssertEqual(responses, 0)
|
|
var busy: FlutterError?
|
|
messenger.invokeImport { busy = $0 as? FlutterError }
|
|
XCTAssertEqual(busy?.code, "import_busy")
|
|
XCTAssertNil(busy?.details)
|
|
let attachment = XCTAttachment(image: UIGraphicsImageRenderer(bounds: window.bounds).image { _ in
|
|
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
|
|
})
|
|
attachment.name = "Identity document picker presented"
|
|
attachment.lifetime = .keepAlways
|
|
add(attachment)
|
|
plugin.documentPickerWasCancelled(picker)
|
|
plugin.documentPickerWasCancelled(picker)
|
|
XCTAssertEqual(responses, 1)
|
|
await dismiss(parent)
|
|
messenger.invokeImport { value in
|
|
responses += 1
|
|
XCTAssertNil(value)
|
|
}
|
|
let next = try await self.picker(in: parent)
|
|
await settle(next)
|
|
// Late callbacks from the previous picker must not finish the new request.
|
|
plugin.documentPickerWasCancelled(picker)
|
|
XCTAssertEqual(responses, 1)
|
|
plugin.documentPicker(picker, didPickDocumentsAt: [])
|
|
XCTAssertEqual(responses, 1)
|
|
plugin.documentPickerWasCancelled(next)
|
|
XCTAssertEqual(responses, 2)
|
|
await dismiss(parent)
|
|
}
|
|
|
|
@MainActor
|
|
func testSyntheticDocumentsReadInPlaceAndRejectInvalidOrOversize() async throws {
|
|
let messenger = IdentityFileTestMessenger()
|
|
let plugin = IdentityFilePlugin(messenger: messenger)
|
|
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)
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let cases: [(Data, String?)] = [
|
|
(Data("synthetic-nsec-file-not-an-identity\n".utf8), "synthetic-nsec-file-not-an-identity\n"),
|
|
(Data(repeating: 97, count: 1024), String(repeating: "a", count: 1024)),
|
|
(Data(repeating: 97, count: 1025), nil),
|
|
(Data([0xc3, 0x28]), nil),
|
|
(Data(), nil),
|
|
]
|
|
for (index, entry) in cases.enumerated() {
|
|
let url = directory.appendingPathComponent("fixture-\(index).nsec")
|
|
try entry.0.write(to: url)
|
|
let completed = expectation(description: "file result \(index)")
|
|
messenger.invokeImport { value in
|
|
if let expected = entry.1 { XCTAssertEqual(value as? String, expected) }
|
|
else {
|
|
let error = value as? FlutterError
|
|
XCTAssertEqual(error?.code, "import_failed")
|
|
XCTAssertNil(error?.message)
|
|
XCTAssertNil(error?.details)
|
|
}
|
|
completed.fulfill()
|
|
}
|
|
let picker = try await picker(in: parent)
|
|
await settle(picker)
|
|
// A synthetic delegate URL, not a claim that Files/iCloud selected it.
|
|
plugin.documentPicker(picker, didPickDocumentsAt: [url])
|
|
await fulfillment(of: [completed], timeout: 5)
|
|
XCTAssertEqual(try Data(contentsOf: url), entry.0, "Original document must remain untouched")
|
|
await dismiss(parent)
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class IdentityFileTestMessenger: NSObject, FlutterBinaryMessenger {
|
|
private var handler: FlutterBinaryMessageHandler?
|
|
func send(onChannel channel: String, message: Data?) {}
|
|
func send(onChannel channel: String, message: Data?, binaryReply callback: FlutterBinaryReply?) { callback?(nil) }
|
|
func setMessageHandlerOnChannel(_ channel: String, binaryMessageHandler handler: FlutterBinaryMessageHandler?) -> FlutterBinaryMessengerConnection {
|
|
self.handler = handler
|
|
return 1
|
|
}
|
|
func cleanUpConnection(_ connection: FlutterBinaryMessengerConnection) { handler = nil }
|
|
func invokeImport(reply: @escaping (Any?) -> Void) {
|
|
invoke("import", arguments: nil, reply: reply)
|
|
}
|
|
func invoke(_ method: String, arguments: Any?, reply: @escaping (Any?) -> Void) {
|
|
let codec = FlutterStandardMethodCodec.sharedInstance()
|
|
handler?(codec.encode(FlutterMethodCall(methodName: method, arguments: arguments))) { data in
|
|
reply(data.flatMap { codec.decodeEnvelope($0) })
|
|
}
|
|
}
|
|
}
|