Files
buzz/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.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

572 lines
20 KiB
Swift

import BuzzPushKit
import Flutter
import Intents
import UserNotifications
import XCTest
@testable import Buzz
final class BuzzCommunicationNotificationTests: XCTestCase {
func testIntentUsesVerifiedSenderAvatarAndGroupName() throws {
let resolution = communicationResolution(
displayName: "Alice",
groupName: "General",
avatarPNG: Data([0x89, 0x50, 0x4E, 0x47])
)
let descriptor = try XCTUnwrap(
BuzzCommunicationNotificationDescriptor(resolution: resolution)
)
let intent = BuzzCommunicationNotificationPresenter.makeIntent(descriptor)
XCTAssertEqual(intent.sender?.displayName, "Alice")
XCTAssertEqual(intent.sender?.customIdentifier, descriptor.senderIdentifier)
XCTAssertNotNil(intent.sender?.image)
XCTAssertNotNil(intent.image(forParameterNamed: \.speakableGroupName))
XCTAssertEqual(intent.content, "Hello Buzz")
XCTAssertEqual(intent.speakableGroupName?.spokenPhrase, "General")
XCTAssertEqual(intent.conversationIdentifier, resolution.conversationIdentifier)
XCTAssertNil(intent.recipients)
XCTAssertEqual(descriptor.recipientCount, 1)
XCTAssertEqual(
(intent.donationMetadata as? INSendMessageIntentDonationMetadata)?.recipientCount,
1
)
}
func testDirectMessageRetainsSenderAvatarWithoutGroupMetadata() throws {
let resolution = communicationResolution(
displayName: "Alice",
groupName: nil,
avatarPNG: Data([0x89, 0x50, 0x4E, 0x47])
)
let descriptor = try XCTUnwrap(
BuzzCommunicationNotificationDescriptor(resolution: resolution)
)
let intent = BuzzCommunicationNotificationPresenter.makeIntent(descriptor)
XCTAssertEqual(intent.sender?.displayName, "Alice")
XCTAssertNotNil(intent.sender?.image)
XCTAssertNil(intent.image(forParameterNamed: \.speakableGroupName))
XCTAssertNil(intent.speakableGroupName)
XCTAssertNil(intent.donationMetadata)
}
func testMissingVerifiedRecipientCountUsesOrdinaryPresentation() {
XCTAssertNil(
BuzzCommunicationNotificationDescriptor(
resolution: communicationResolution(recipientCount: nil)
)
)
}
func testPresentationFallsBackWhenDonationFails() {
let ordinary = UNMutableNotificationContent()
ordinary.title = "Alice"
ordinary.body = "Hello Buzz"
var updateCalled = false
let presenter = BuzzCommunicationNotificationPresenter(
donate: { _, completion in
completion(NSError(domain: "test", code: 1))
},
deleteAllInteractions: { completion in completion(nil) },
updateContent: { content, _ in
updateCalled = true
return content
}
)
let completed = expectation(description: "ordinary fallback returned")
presenter.present(
ordinaryContent: ordinary,
resolution: communicationResolution()
) { content in
XCTAssertEqual(content.title, "Alice")
XCTAssertEqual(content.body, "Hello Buzz")
completed.fulfill()
}
wait(for: [completed], timeout: 1)
XCTAssertFalse(updateCalled)
}
func testPresentationDonatesBeforeSpecializing() {
let ordinary = UNMutableNotificationContent()
ordinary.title = "Alice"
var order: [String] = []
let presenter = BuzzCommunicationNotificationPresenter(
donate: { _, completion in
order.append("donate")
completion(nil)
},
deleteAllInteractions: { completion in completion(nil) },
updateContent: { _, _ in
order.append("update")
let specialized = UNMutableNotificationContent()
specialized.title = "specialized"
return specialized
}
)
let completed = expectation(description: "specialized content returned")
presenter.present(
ordinaryContent: ordinary,
resolution: communicationResolution()
) { content in
XCTAssertEqual(content.title, "specialized")
completed.fulfill()
}
wait(for: [completed], timeout: 1)
XCTAssertEqual(order, ["donate", "update"])
}
func testPresentationFallsBackWhenSpecializationFails() {
let ordinary = UNMutableNotificationContent()
ordinary.title = "Alice"
ordinary.body = "Hello Buzz"
let presenter = BuzzCommunicationNotificationPresenter(
donate: { _, completion in completion(nil) },
deleteAllInteractions: { completion in completion(nil) },
updateContent: { _, _ in throw NSError(domain: "test", code: 2) }
)
let completed = expectation(description: "ordinary fallback returned")
presenter.present(
ordinaryContent: ordinary,
resolution: communicationResolution()
) { content in
XCTAssertEqual(content.title, "Alice")
XCTAssertEqual(content.body, "Hello Buzz")
completed.fulfill()
}
wait(for: [completed], timeout: 1)
}
func testFenceChangeDeletesDonatedInteractionBeforeCompleting() {
let ordinary = UNMutableNotificationContent()
ordinary.title = "Alice"
var allowed = true
var order: [String] = []
let presenter = BuzzCommunicationNotificationPresenter(
donate: { _, completion in
order.append("donate")
allowed = false
completion(nil)
},
deleteAllInteractions: { completion in
order.append("delete")
completion(nil)
},
updateContent: { content, _ in
order.append("update")
return content
}
)
let completed = expectation(description: "restricted donation deleted")
presenter.present(
ordinaryContent: ordinary,
resolution: communicationResolution(),
isStillAllowed: { allowed }
) { content in
order.append("complete")
XCTAssertEqual(content.title, "Alice")
completed.fulfill()
}
wait(for: [completed], timeout: 1)
XCTAssertEqual(order, ["donate", "delete", "complete"])
}
func testBlockedFenceDoesNotDonate() {
let ordinary = UNMutableNotificationContent()
ordinary.title = "Alice"
var donateCalled = false
let presenter = BuzzCommunicationNotificationPresenter(
donate: { _, completion in
donateCalled = true
completion(nil)
},
deleteAllInteractions: { completion in completion(nil) },
updateContent: { content, _ in content }
)
let completed = expectation(description: "blocked donation skipped")
presenter.present(
ordinaryContent: ordinary,
resolution: communicationResolution(),
isStillAllowed: { false }
) { content in
XCTAssertEqual(content.title, "Alice")
completed.fulfill()
}
wait(for: [completed], timeout: 1)
XCTAssertFalse(donateCalled)
}
private func communicationResolution(
displayName: String = "Alice",
groupName: String? = "General",
avatarPNG: Data? = nil,
recipientCount: Int? = 1
) -> BuzzPushResolution {
let communityID = "community-id"
let channelID = "channel/general:v5"
return BuzzPushResolution(
title: displayName,
body: "Hello Buzz",
subtitle: "Community",
threadIdentifier: BuzzPushPresentationIdentity.conversation(
communityID: communityID,
channelID: channelID
),
navigationTarget: BuzzPushNavigationTarget(
eventID: "message-id",
communityID: communityID,
channelID: channelID
),
senderPubkey: String(repeating: "a", count: 64),
senderAvatarPNG: avatarPNG,
conversationIdentifier: BuzzPushPresentationIdentity.conversation(
communityID: communityID,
channelID: channelID
),
conversationDisplayName: groupName,
conversationRecipientCount: recipientCount
)
}
}
/// Callbacks only record what they receive and assertions run after the wait,
/// so a callback that lands after its test returned cannot fail a later test.
/// The timeout is a cap; a passing test returns as soon as its callback runs.
final class BuzzPushSnapshotEnrichmentTests: XCTestCase {
private let callbackTimeout: TimeInterval = 10
func testAgeSignalRequestDoesNotDependOnNotificationStorage() {
let delegate = MissingAppGroupDelegate()
var completed = false
delegate.handleAgeSignalMethodCall(
FlutterMethodCall(methodName: "requestAgeSignal", arguments: nil),
viewController: nil
) { value in
if #available(iOS 26.0, *) {
XCTAssertEqual((value as? FlutterError)?.code, "age_signal_unavailable")
} else {
XCTAssertEqual((value as? [String: Any])?["status"] as? String, "noSignal")
}
completed = true
}
XCTAssertTrue(completed)
}
@MainActor
func testNativeAgeRequestDeliversKnownMinorAndFailureThroughFlutterCallback() async {
guard #available(iOS 26.0, *) else { return }
let delegate = MissingAppGroupDelegate()
delegate.requestPlatformAgeSignal = { _ in
BuzzAgeSignalPayload.sharing(exclusiveUpperBound: 18, lowerBound: 13)
}
let minor = expectation(description: "native minor response")
var minorValue: Any?
delegate.handleAgeSignalMethodCall(
FlutterMethodCall(methodName: "requestAgeSignal", arguments: nil),
viewController: UIViewController()
) { value in
minorValue = value
minor.fulfill()
}
await fulfillment(of: [minor], timeout: callbackTimeout)
XCTAssertEqual((minorValue as? [String: Any])?["status"] as? String, "signal")
XCTAssertEqual((minorValue as? [String: Any])?["ageUpper"] as? Int, 17)
delegate.requestPlatformAgeSignal = { _ in
throw NSError(domain: "InjectedAgeFailure", code: 1)
}
let failed = expectation(description: "native error response")
var failedValue: Any?
delegate.handleAgeSignalMethodCall(
FlutterMethodCall(methodName: "requestAgeSignal", arguments: nil),
viewController: UIViewController()
) { value in
failedValue = value
failed.fulfill()
}
await fulfillment(of: [failed], timeout: callbackTimeout)
XCTAssertEqual((failedValue as? FlutterError)?.code, "age_signal_unavailable")
}
func testAllowedNotificationRestoreNeedsNoAppGroupStore() {
let bridge = BuzzPushSnapshotBridge(
appGroupIdentifier: nil,
endpointGrantStore: BuzzPushEndpointGrantKeychainStore(accessGroup: nil),
keychainAccessGroup: nil
)
let completed = expectation(description: "allowed without storage")
var received: Any?
XCTAssertTrue(bridge.handle(
FlutterMethodCall(methodName: "restoreAgeRestrictedNotifications", arguments: nil)
) { value in
received = value
completed.fulfill()
})
wait(for: [completed], timeout: callbackTimeout)
XCTAssertNil(received)
}
func testRestrictionCleanupFailurePreservesSnapshotAndRestoreReleasesAuthority() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let store = BuzzPushPresentationCacheStore(containerURL: directory)
try store.replaceCommunities([
PushLeaseCommunity(id: "retained", name: "Retained", relayUrl: "https://relay.example",
pubkey: nil, policies: [])
])
let snapshotURL = directory.appendingPathComponent(BuzzPushPresentationCacheStore.fileName)
let original = try Data(contentsOf: snapshotURL)
var deletionCalls = 0
let bridge = BuzzPushSnapshotBridge(
appGroupIdentifier: nil,
endpointGrantStore: BuzzPushEndpointGrantKeychainStore(accessGroup: nil),
keychainAccessGroup: nil,
containerURL: { directory },
interactionDeletionDeadline: BuzzInteractionDeletionDeadline(
timeout: 5,
deleteAllInteractions: { completion in
deletionCalls += 1
completion(deletionCalls == 1 ? NSError(domain: "InjectedCleanupFailure", code: 1) : nil)
},
scheduleTimeout: { _, _ in }
)
)
let restricted = expectation(description: "restriction despite cleanup failure")
var purgeResult: Any?
XCTAssertTrue(bridge.handle(
FlutterMethodCall(methodName: "purgeAgeRestrictedNotifications", arguments: nil)
) { value in
purgeResult = value
restricted.fulfill()
})
wait(for: [restricted], timeout: callbackTimeout)
XCTAssertEqual((purgeResult as? FlutterError)?.code, "age_restriction_purge_failed")
XCTAssertTrue(BuzzAgeRestrictionSession.isRestricted(containerURL: directory))
XCTAssertEqual(try Data(contentsOf: snapshotURL), original)
let restored = expectation(description: "allowed without restoring snapshot")
var restoreResult: Any?
XCTAssertTrue(bridge.handle(
FlutterMethodCall(methodName: "restoreAgeRestrictedNotifications", arguments: nil)
) { value in
restoreResult = value
restored.fulfill()
})
wait(for: [restored], timeout: callbackTimeout)
XCTAssertNil(restoreResult)
XCTAssertFalse(BuzzAgeRestrictionSession.isRestricted(containerURL: directory))
XCTAssertEqual(deletionCalls, 2)
XCTAssertEqual(try Data(contentsOf: snapshotURL), original)
}
func testNewBridgeRetriesRecordedCleanupWithoutRestrictingAccess() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let failure = NSError(domain: "InjectedCleanupFailure", code: 1)
let deletion = BuzzInteractionDeletionDeadline(timeout: 5,
deleteAllInteractions: { $0(failure) }, scheduleTimeout: { _, _ in })
var seedError: Error?
BuzzInteractionCleanupRetry(containerURL: directory, deletion: deletion).request {
seedError = $0
}
XCTAssertNotNil(seedError)
let bridge = BuzzPushSnapshotBridge(
appGroupIdentifier: nil,
endpointGrantStore: BuzzPushEndpointGrantKeychainStore(accessGroup: nil),
keychainAccessGroup: nil, containerURL: { directory },
interactionDeletionDeadline: deletion
)
let completed = expectation(description: "retry failure stays allowed")
var received: Any?
XCTAssertTrue(bridge.handle(
FlutterMethodCall(methodName: "restoreAgeRestrictedNotifications", arguments: nil)
) { value in
received = value
completed.fulfill()
})
wait(for: [completed], timeout: callbackTimeout)
XCTAssertEqual((received as? FlutterError)?.code, "interaction_cleanup_retry_failed")
XCTAssertFalse(BuzzAgeRestrictionSession.isRestricted(containerURL: directory))
}
func testStrictAgeGateWriteFailsWhenAppGroupStoreIsUnavailable() {
let bridge = BuzzPushSnapshotBridge(
appGroupIdentifier: nil,
endpointGrantStore: BuzzPushEndpointGrantKeychainStore(accessGroup: nil),
keychainAccessGroup: nil
)
let completed = expectation(description: "strict write rejected")
var received: Any?
XCTAssertTrue(
bridge.handle(
FlutterMethodCall(
methodName: "syncAgeGatePushSnapshot",
arguments: [
"section": "communities",
"communities": [[String: Any]](),
"signingKeys": [String: String](),
"settleFence": false,
]
)
) { value in
received = value
completed.fulfill()
}
)
wait(for: [completed], timeout: callbackTimeout)
XCTAssertEqual((received as? FlutterError)?.code, "snapshot_sync_unavailable")
}
func testMetadataAuthorityUsesNewestGrantForMatchingRelay() {
let correctRelay = grant(
generation: 2,
metadataPubkey: String(repeating: "a", count: 64)
)
let wrongRelay = grant(
relayOrigin: "https://other.example",
generation: 99,
metadataPubkey: String(repeating: "b", count: 64)
)
XCTAssertEqual(
BuzzPushSnapshotBridge.relayMetadataPubkey(
relayURL: "wss://relay.example/",
grants: [wrongRelay, correctRelay, grant(
generation: 1, metadataPubkey: String(repeating: "d", count: 64)
)]
),
correctRelay.relayMetadataPubkey
)
}
private func grant(
relayOrigin: String = "https://relay.example",
generation: Int64,
metadataPubkey: String
) -> BuzzPushEndpointGrantRecord {
BuzzPushEndpointGrantRecord(
gatewayOrigin: "https://push.example",
relayOrigin: relayOrigin,
relayPubkey: String(repeating: "c", count: 64),
relayMetadataPubkey: metadataPubkey,
appAttestKeyId: Data(repeating: 0xAA, count: 32).base64EncodedString(),
installationId: "installation",
endpointGrant: "opaque-grant",
endpointHash: String(repeating: "d", count: 64),
endpointEpoch: 1,
generation: generation,
expiresAt: 1_900_000_000
)
}
}
final class BuzzPushNotificationResponseTests: XCTestCase {
func testValidDefaultActionRoutesAndCompletesExactlyOnce() {
let target = BuzzPushNavigationTarget(
eventID: "message-id",
communityID: "community-id",
channelID: "opaque-channel-id"
)
var routedTargets: [BuzzPushNavigationTarget] = []
var forwarded = 0
var completions = 0
BuzzPushNotificationResponseCoordinator.handle(
actionIdentifier: UNNotificationDefaultActionIdentifier,
userInfo: [BuzzPushNavigationTarget.userInfoKey: target.userInfoValue],
onTarget: { routedTargets.append($0) },
forwardToFlutter: { pluginCompletion in
forwarded += 1
pluginCompletion()
pluginCompletion()
},
completion: { completions += 1 }
)
XCTAssertEqual(routedTargets, [target])
XCTAssertEqual(forwarded, 1)
XCTAssertEqual(completions, 1)
}
func testMalformedTargetFallsBackToOneCompletion() {
var routedTargets: [BuzzPushNavigationTarget] = []
var completions = 0
BuzzPushNotificationResponseCoordinator.handle(
actionIdentifier: UNNotificationDefaultActionIdentifier,
userInfo: [BuzzPushNavigationTarget.userInfoKey: ["event_id": ""]],
onTarget: { routedTargets.append($0) },
forwardToFlutter: { _ in },
completion: { completions += 1 }
)
XCTAssertTrue(routedTargets.isEmpty)
XCTAssertEqual(completions, 1)
}
func testNonDefaultActionIgnoresLateDuplicatePluginCompletion() throws {
var routedTargets: [BuzzPushNavigationTarget] = []
var pluginCompletion: (() -> Void)?
var completions = 0
BuzzPushNotificationResponseCoordinator.handle(
actionIdentifier: "buzz.reply",
userInfo: [:],
onTarget: { routedTargets.append($0) },
forwardToFlutter: { pluginCompletion = $0 },
completion: { completions += 1 }
)
let capturedCompletion = try XCTUnwrap(pluginCompletion)
capturedCompletion()
capturedCompletion()
XCTAssertTrue(routedTargets.isEmpty)
XCTAssertEqual(completions, 1)
}
}
private final class MissingAppGroupDelegate: AppDelegate {
override var appGroupIdentifier: String? { nil }
}
final class BuzzPushNavigationBridgeTests: XCTestCase {
func testWarmAndColdNavigationCarryTheSameThreadArguments() throws {
let target = BuzzPushNavigationTarget(
eventID: String(repeating: "a", count: 64), communityID: "community",
channelID: "channel", threadRootID: String(repeating: "b", count: 64)
)
let expected = ["eventId": target.eventID, "communityId": "community",
"channelId": "channel", "threadRootId": String(repeating: "b", count: 64)]
XCTAssertEqual(target.flutterArguments, expected)
let buffer = BuzzPushNavigationBuffer()
buffer.record(target)
XCTAssertEqual(try XCTUnwrap(buffer.take()).flutterArguments, expected)
XCTAssertNil(buffer.take())
}
func testChannelNavigationOmitsOptionalThreadArgument() {
let target = BuzzPushNavigationTarget(
eventID: "event", communityID: "community", channelID: "channel"
)
XCTAssertEqual(target.flutterArguments,
["eventId": "event", "communityId": "community", "channelId": "channel"])
}
}