Files
buzz/mobile/ios/RunnerTests/ProfileSetupRecoveryTests.swift
Matt Toohey fae3f0346c test(mobile): keep late callbacks and fixed sleeps from failing tests (#8678)
## Summary

Fixes three mobile flakes from the 21–28 Oct 2026 CI runs, in test code
only:

- In two iOS `RunnerTests` classes, late bridge callbacks no longer fail
whichever test is running when they fire.
- The waits on a busy test-host main thread now cap at 10 s.
- Two Flutter avatar tests poll for the ready frame instead of sleeping
a fixed 50 ms.

This is one of seven PRs split out of the `flakey-tests` branch
(siblings below).

### Related issue

None found.

### Flaky tests fixed

| Test | Failing runs | Cause | Change |
|---|---|---|---|
| `BuzzPushSnapshotEnrichmentTests` (RunnerTests) | 40362671989 (job
121712747802, main), 39681273328#1 (job 119504560518), 40658588384 (job
122715950541, PR #8640) | The 1 s waits ran out while the test host's
Flutter app held the main thread, and the test returned and deleted its
temp directory. The bridge callback's assertions then ran later and were
blamed on whichever test was running (`ProfileSetupRecoveryTests`,
`IdentityFilePluginTests`). | Callbacks only record the value and
fulfill. Every assertion, including the `isRestricted` checks, runs
after the wait, so a late callback writes into a dead local. The seven
waits cap at 10 s (`callbackTimeout`). |
| `ProfileSetupRecoveryTests`
`testStartedEditorWithoutReadyRestoresProfileAndAllowsRetry` |
40585805357 (job 122477274956, PR #8568) | Engine release and editor
dismissal must both happen within 2 s, on the main thread the host app
keeps busy. | Both predicate waits cap at 10 s. |
| Flutter `channel_detail_page_test.dart` "profile sheet shows the
poster before autoplay and restores it on tap" and
`settings_profile_header_test.dart` "shows the poster until the animated
avatar is ready" | 40368315739 (job 121732711851, main) | After the
image response is released, both tests slept a fixed 50 ms of real time
before `pumpAndSettle`. A slower real decode left no frame scheduled, so
the ready key was missing. | A new `WidgetHelpers.pumpUntilFound` pumps
in 10 ms real-time steps until the ready key appears, capped at about 5
s. |

Local evidence is from the original `flakey-tests` commit (53aaec97c),
Xcode 27.1 on the iOS 26.2 simulator:

- Enrichment, with `Self.complete` delayed 1.5 s and 4 s: 6 failures
before, 107/107 after. With a 12 s delay, every failure lands in its
owning test. A mutated error code is still caught.
- Profile setup, with the engine release held 4 s: 2 failures before,
4/4 after. A leaked engine still fails.
- Avatar tests: under load, 94/96 before and 96/96 after. With a 200 ms
image delay, 0/5 before and 5/5 after. A widget that never signals ready
still fails.

Not included:

- The planned `AppDelegate` / `Runner.xcscheme` change that keeps
Flutter out of the RunnerTests host. It did not work locally: restored
scene sessions and the `UIMainStoryboardFile` fallback, and a normal
launch came up black afterwards.
- `IdentityFilePluginTests`
`testSyntheticDocumentsReadInPlaceAndRejectInvalidOrOversize`
(40441255307#1). It is left unchanged and stays open.

### Verification

This branch was pushed without local re-verification, by the user's
choice: local test runs were overloading the machine and failing for
that reason. **Hosted CI is the check.** It was pushed with `git push
--no-verify`, because the pre-push hook runs the Flutter tests.

- Every file is byte-identical to its final version on `flakey-tests`
(67185df6a).
- Checked for this PR: `git diff --check`, and a conflict-free merge
with all six siblings onto `main`.
- #8613's flake-check lane will repeat the two changed Dart test files.

### Readiness

Test-only change. AGENTS.md's readiness steps (agent review, then human
confirmation) are still outstanding, so `buzz-review-completed` is not
set.

### Merge order

Independent of the other six. It merges cleanly onto `main`, and with
every sibling, in any order.

### Sibling PRs

Split from `flakey-tests` (one PR per area): #8676 (PostgreSQL lease +
lane), #8677 (relay E2E helper + Windows shell), #8679 (Tauri apt cache
+ Windows Rust cap), #8680 (desktop product races), #8681 (desktop E2E
state gates), #8682 (desktop E2E composer + row actions).

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

---------

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Jitter <2b2e6415e748c35180847a37aadae06a11e30dddc76b784e7fd9354c4eb42e7a@buzz.block.builderlab.xyz>
2026-10-30 11:58:40 +11:00

268 lines
14 KiB
Swift

import Flutter
import UIKit
import XCTest
@testable import Buzz
final class ProfileSetupRecoveryTests: XCTestCase {
@MainActor
func testSelectedEditorIconsContrastInBothAppearances() throws {
for dark in [false, true] {
for icon in ["emoji", "palette"] {
let control = NavigationGlassButtonPlatformView(
frame: CGRect(x: 0, y: 0, width: 64, height: 64), viewIdentifier: 911,
arguments: ["icon": icon, "controlSize": 64, "selected": true,
"brightness": dark ? "dark" : "light", "foregroundColor": dark ? 0xFFFFFFFF : 0xFF000000],
messenger: ProfileSetupTestMessenger())
let button = try XCTUnwrap(descendants(control.view()).compactMap { $0 as? UIButton }.first)
let image = try XCTUnwrap(button.configuration?.image)
XCTAssertEqual(image.renderingMode, .alwaysOriginal)
let rendered = UIGraphicsImageRenderer(size: image.size).image { _ in
image.draw(at: .zero)
}
let cgImage = try XCTUnwrap(rendered.cgImage)
let width = cgImage.width, height = cgImage.height
var pixels = [UInt8](repeating: 0, count: width * height * 4)
try pixels.withUnsafeMutableBytes { bytes in
let context = try XCTUnwrap(CGContext(data: bytes.baseAddress, width: width, height: height,
bitsPerComponent: 8, bytesPerRow: width * 4, space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue))
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: CGFloat(width), height: CGFloat(height)))
}
let opaque = stride(from: 0, to: pixels.count, by: 4).filter { pixels[$0 + 3] > 250 }
XCTAssertFalse(opaque.isEmpty)
XCTAssertTrue(opaque.allSatisfy { pixels[$0] == (dark ? 0 : pixels[$0 + 3]) })
let attachment = XCTAttachment(image: rendered)
attachment.name = "Selected \(icon) dark=\(dark)"
attachment.lifetime = .keepAlways
add(attachment)
}
}
}
@MainActor
func testCameraShutterHasAnOpaqueCenter() throws {
let control = NavigationGlassButtonPlatformView(
frame: CGRect(x: 0, y: 0, width: 100, height: 100), viewIdentifier: 910,
arguments: ["icon": "shutter", "controlSize": 100, "accessibilityLabel": "Take photo"],
messenger: ProfileSetupTestMessenger())
let button = try XCTUnwrap(descendants(control.view()).compactMap { $0 as? UIButton }.first)
let image = try XCTUnwrap(button.configuration?.image?.cgImage)
var pixel = [UInt8](repeating: 0, count: 4)
let context = try XCTUnwrap(CGContext(data: &pixel, width: 1, height: 1, bitsPerComponent: 8,
bytesPerRow: 4, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue))
context.translateBy(x: -CGFloat(image.width / 2), y: -CGFloat(image.height / 2))
context.draw(image, in: CGRect(x: 0, y: 0, width: CGFloat(image.width), height: CGFloat(image.height)))
XCTAssertEqual(pixel[3], 255, "The shutter must be filled, not an outlined circle")
let attachment = XCTAttachment(image: UIImage(cgImage: image))
attachment.lifetime = .keepAlways
add(attachment)
}
@MainActor
func testStartedEditorWithoutReadyRestoresProfileAndAllowsRetry() async throws {
let messenger = ProfileSetupTestMessenger()
let parent = UIViewController()
let window = UIWindow(frame: UIScreen.main.bounds)
window.windowScene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
window.rootViewController = parent
window.makeKeyAndVisible()
defer { window.isHidden = true }
var watchdogs: [() -> Void] = []
weak var engine: FlutterEngine?
let coordinator = NativeProfileSetupCoordinator(messenger: messenger,
scheduleEditorWatchdog: { watchdogs.append($0) },
startEditorEngine: {
engine = $0
// Start the normal app entry point: it never sends avatar-editor ready.
return $0.run()
}, configureEditorEngine: { _ in })
messenger.invoke("present", arguments: ["session": 81, "dark": true, "primaryArgb": 0xFFFFFFFF, "onPrimaryArgb": 0xFF000000])
let nav = try XCTUnwrap(parent.presentedViewController as? UINavigationController)
if let transition = nav.transitionCoordinator {
await withCheckedContinuation { continuation in
transition.animate(alongsideTransition: nil) { _ in continuation.resume() }
}
}
let profile = try XCTUnwrap(nav.viewControllers.first)
let avatar = try XCTUnwrap(descendants(profile.view).compactMap { $0 as? UIButton }
.first { $0.accessibilityLabel == "Add profile photo" })
XCTAssertEqual(nav.modalPresentationStyle, .overFullScreen)
XCTAssertNil(profile.navigationItem.rightBarButtonItem)
let name = try XCTUnwrap(descendants(profile.view).compactMap { $0 as? UITextField }.first)
let submit = try XCTUnwrap(descendants(profile.view).compactMap { $0 as? UIButton }
.first { $0.configuration?.title == "Continue" })
let skip = try XCTUnwrap(descendants(profile.view).compactMap { $0 as? UIButton }
.first { $0.configuration?.title == "Skip" })
let foreground = try XCTUnwrap(submit.configuration?.baseForegroundColor)
var red: CGFloat = 1, green: CGFloat = 1, blue: CGFloat = 1, alpha: CGFloat = 0
XCTAssertTrue(foreground.getRed(&red, green: &green, blue: &blue, alpha: &alpha))
XCTAssertEqual(red, 0); XCTAssertEqual(green, 0); XCTAssertEqual(blue, 0); XCTAssertEqual(alpha, 1)
XCTAssertTrue(skip.isHidden)
XCTAssertTrue(avatar.superview!.isHidden)
XCTAssertFalse(submit.isEnabled)
name.text = "Kenny"
name.sendActions(for: .editingChanged)
submit.sendActions(for: .touchUpInside)
XCTAssertNotNil(engine, "Prepare the editor before the avatar is tapped")
XCTAssertNil(profile.navigationItem.rightBarButtonItem)
XCTAssertEqual(submit.alpha, 1)
XCTAssertTrue(name.isHidden)
XCTAssertFalse(skip.isHidden)
XCTAssertFalse(submit.isEnabled, "Continue requires an avatar saved from the editor")
XCTAssertFalse(avatar.superview!.isHidden)
profile.view.layoutIfNeeded()
XCTAssertEqual(avatar.bounds.width, 220, accuracy: 0.5)
XCTAssertEqual(avatar.transform.a, 0.85, accuracy: 0.001)
XCTAssertTrue(descendants(profile.view).compactMap { $0 as? UILabel }
.contains { $0.text == "Choose your avatar" && $0.accessibilityTraits.contains(.header) })
XCTAssertTrue(avatar.point(inside: CGPoint(x: 4, y: 110), with: nil))
XCTAssertTrue(avatar.point(inside: CGPoint(x: 110, y: 110), with: nil))
let pan = UIPanGestureRecognizer()
let disabledPan = UIPanGestureRecognizer()
disabledPan.isEnabled = false
nav.view.addGestureRecognizer(pan)
nav.view.addGestureRecognizer(disabledPan)
nav.interactivePopGestureRecognizer?.isEnabled = true
for _ in 0..<2 {
autoreleasepool {
avatar.sendActions(for: .touchUpInside)
XCTAssertNotNil(engine)
XCTAssertEqual(profile.navigationItem.rightBarButtonItem?.title, "Done")
XCTAssertFalse(profile.navigationItem.rightBarButtonItem?.isEnabled ?? true)
XCTAssertTrue(profile.navigationItem.leftBarButtonItem?.isEnabled ?? false)
let editor = profile.children.compactMap { $0 as? FlutterViewController }.first
XCTAssertEqual(editor?.isViewOpaque, false, "Native chrome must remain visible through the exiting editor")
XCTAssertEqual(editor?.view.backgroundColor, UIColor.clear)
for dark in [false, true] {
messenger.invoke("appearance", arguments: ["session": 81, "dark": dark,
"pageBackgroundArgb": dark ? 0xFF000000 : 0xFFFFFFFF])
XCTAssertEqual(editor?.isViewOpaque, false)
XCTAssertEqual(editor?.view.backgroundColor, UIColor.clear)
XCTAssertEqual(nav.overrideUserInterfaceStyle, dark ? .dark : .light)
}
XCTAssertFalse(pan.isEnabled)
XCTAssertFalse(nav.interactivePopGestureRecognizer?.isEnabled ?? true)
XCTAssertTrue(nav.isModalInPresentation)
XCTAssertFalse(nav.isNavigationBarHidden)
if watchdogs.count > 1 {
// An old attempt's watchdog cannot tear down the successful retry.
watchdogs[0]()
XCTAssertTrue(profile.children.contains { $0 is FlutterViewController })
XCTAssertNotNil(engine)
}
watchdogs.last?()
XCTAssertTrue(nav.topViewController === profile)
XCTAssertFalse(nav.isNavigationBarHidden)
XCTAssertEqual(nav.modalPresentationStyle, .overFullScreen)
XCTAssertTrue(pan.isEnabled)
XCTAssertFalse(disabledPan.isEnabled)
XCTAssertTrue(nav.interactivePopGestureRecognizer?.isEnabled ?? false)
XCTAssertFalse(nav.isModalInPresentation)
XCTAssertTrue(avatar.isEnabled)
XCTAssertFalse(avatar.isHidden)
}
// UIKit releases popped controllers at the end of its autorelease turn.
// The cap absorbs a busy main thread; a leaked engine never becomes nil.
let released = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in engine == nil }, object: nil)
await fulfillment(of: [released], timeout: 10)
}
XCTAssertEqual(watchdogs.count, 2)
skip.sendActions(for: .touchUpInside)
XCTAssertEqual(messenger.savedArguments?["name"] as? String, "Kenny")
XCTAssertEqual(messenger.savedArguments?["skipAvatar"] as? Bool, true)
XCTAssertTrue(skip.isEnabled, "Failed name-only save must remain retryable through Skip")
XCTAssertFalse(submit.isEnabled)
let notNow = try XCTUnwrap(descendants(profile.view).compactMap { $0 as? UIButton }
.first { $0.configuration?.title == "Not now" })
XCTAssertFalse(notNow.isHidden)
XCTAssertTrue(notNow.isEnabled)
notNow.sendActions(for: .touchUpInside)
let dismissed = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in parent.presentedViewController == nil }, object: nil)
await fulfillment(of: [dismissed], timeout: 10)
XCTAssertEqual(messenger.completionAction, "skip")
withExtendedLifetime(coordinator) {}
nav.dismiss(animated: false)
}
@MainActor
func testSelectedEmojiShowsEditablePreview() async throws {
let messenger = ProfileSetupTestMessenger()
let parent = UIViewController()
let window = UIWindow(frame: UIScreen.main.bounds)
window.windowScene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
window.rootViewController = parent
window.makeKeyAndVisible()
defer { window.isHidden = true }
let coordinator = NativeProfileSetupCoordinator(messenger: messenger, startEditorEngine: { _ in false }, configureEditorEngine: { _ in })
messenger.invoke("present", arguments: ["session": 82, "name": "Kenny", "emoji": "😊", "emojiColor": 0xFFFFCC00, "primaryArgb": 0xFF000000, "onPrimaryArgb": 0xFFFFFFFF])
let nav = try XCTUnwrap(parent.presentedViewController as? UINavigationController)
if let transition = nav.transitionCoordinator {
await withCheckedContinuation { continuation in
transition.animate(alongsideTransition: nil) { _ in continuation.resume() }
}
}
let profile = try XCTUnwrap(nav.viewControllers.first)
let avatar = try XCTUnwrap(descendants(profile.view).compactMap { $0 as? UIButton }
.first { $0.accessibilityLabel == "Edit profile photo" })
let submit = try XCTUnwrap(descendants(profile.view).compactMap { $0 as? UIButton }
.first { $0.configuration?.title == "Continue" })
submit.sendActions(for: .touchUpInside)
XCTAssertTrue(submit.isEnabled)
for dark in [true, false] {
let background: UInt32 = dark ? 0xFF000000 : 0xFFFFFFFF
messenger.invoke("appearance", arguments: ["session": 82, "dark": dark, "pageBackgroundArgb": background,
"primaryArgb": dark ? 0xFFFFFFFF : 0xFF000000, "onPrimaryArgb": background])
XCTAssertTrue(parent.presentedViewController === nav)
XCTAssertEqual(nav.overrideUserInterfaceStyle, dark ? .dark : .light)
XCTAssertTrue(submit.isEnabled)
XCTAssertEqual(avatar.accessibilityLabel, "Edit profile photo")
let expected = dark ? UIColor.black : UIColor.white
var actualWhite: CGFloat = -1, alpha: CGFloat = 0
XCTAssertTrue(profile.view.backgroundColor!.getWhite(&actualWhite, alpha: &alpha))
var expectedWhite: CGFloat = -1
expected.getWhite(&expectedWhite, alpha: &alpha)
XCTAssertEqual(actualWhite, expectedWhite, accuracy: 0.001)
}
let image = try XCTUnwrap(avatar.image(for: .normal))
XCTAssertEqual(image.size, CGSize(width: 220, height: 220))
let attachment = XCTAttachment(image: image)
attachment.name = "Selected emoji with edit badge"
attachment.lifetime = .keepAlways
add(attachment)
withExtendedLifetime(coordinator) {}
nav.dismiss(animated: false)
}
@MainActor
private func descendants(_ view: UIView) -> [UIView] {
view.subviews.flatMap { [$0] + descendants($0) }
}
}
private final class ProfileSetupTestMessenger: NSObject, FlutterBinaryMessenger {
private var handler: FlutterBinaryMessageHandler?
var savedArguments: [String: Any]?
var completionAction: String?
func send(onChannel channel: String, message: Data?) {}
func send(onChannel channel: String, message: Data?, binaryReply callback: FlutterBinaryReply?) {
let codec = FlutterStandardMethodCodec.sharedInstance()
if let message {
let call = codec.decodeMethodCall(message)
if call.method == "save" { savedArguments = call.arguments as? [String: Any] }
}
callback?(codec.encodeSuccessEnvelope(["saved": false]))
}
func setMessageHandlerOnChannel(_ channel: String, binaryMessageHandler handler: FlutterBinaryMessageHandler?) -> FlutterBinaryMessengerConnection {
self.handler = handler
return 1
}
func cleanUpConnection(_ connection: FlutterBinaryMessengerConnection) { handler = nil }
func invoke(_ method: String, arguments: [String: Any]) {
let codec = FlutterStandardMethodCodec.sharedInstance()
handler?(codec.encode(FlutterMethodCall(methodName: method, arguments: arguments))) { data in
if let data { self.completionAction = try? codec.decodeEnvelope(data) as? String }
}
}
}