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
59 changes: 40 additions & 19 deletions mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,12 @@ final class BuzzCommunicationNotificationTests: XCTestCase {
}
}

/// 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
Expand All @@ -268,28 +273,32 @@ final class BuzzPushSnapshotEnrichmentTests: XCTestCase {
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
XCTAssertEqual((value as? [String: Any])?["status"] as? String, "signal")
XCTAssertEqual((value as? [String: Any])?["ageUpper"] as? Int, 17)
minorValue = value
minor.fulfill()
}
await fulfillment(of: [minor], timeout: 1)
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
XCTAssertEqual((value as? FlutterError)?.code, "age_signal_unavailable")
failedValue = value
failed.fulfill()
}
await fulfillment(of: [failed], timeout: 1)
await fulfillment(of: [failed], timeout: callbackTimeout)
XCTAssertEqual((failedValue as? FlutterError)?.code, "age_signal_unavailable")
}

func testAllowedNotificationRestoreNeedsNoAppGroupStore() {
Expand All @@ -299,13 +308,15 @@ final class BuzzPushSnapshotEnrichmentTests: XCTestCase {
keychainAccessGroup: nil
)
let completed = expectation(description: "allowed without storage")
var received: Any?
XCTAssertTrue(bridge.handle(
FlutterMethodCall(methodName: "restoreAgeRestrictedNotifications", arguments: nil)
) { value in
XCTAssertNil(value)
received = value
completed.fulfill()
})
wait(for: [completed], timeout: 1)
wait(for: [completed], timeout: callbackTimeout)
XCTAssertNil(received)
}

func testRestrictionCleanupFailurePreservesSnapshotAndRestoreReleasesAuthority() throws {
Expand Down Expand Up @@ -335,25 +346,29 @@ final class BuzzPushSnapshotEnrichmentTests: XCTestCase {
)
)
let restricted = expectation(description: "restriction despite cleanup failure")
var purgeResult: Any?
XCTAssertTrue(bridge.handle(
FlutterMethodCall(methodName: "purgeAgeRestrictedNotifications", arguments: nil)
) { value in
XCTAssertEqual((value as? FlutterError)?.code, "age_restriction_purge_failed")
XCTAssertTrue(BuzzAgeRestrictionSession.isRestricted(containerURL: directory))
purgeResult = value
restricted.fulfill()
})
wait(for: [restricted], timeout: 1)
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
XCTAssertNil(value)
XCTAssertFalse(BuzzAgeRestrictionSession.isRestricted(containerURL: directory))
restoreResult = value
restored.fulfill()
})
wait(for: [restored], timeout: 1)
wait(for: [restored], timeout: callbackTimeout)
XCTAssertNil(restoreResult)
XCTAssertFalse(BuzzAgeRestrictionSession.isRestricted(containerURL: directory))
XCTAssertEqual(deletionCalls, 2)
XCTAssertEqual(try Data(contentsOf: snapshotURL), original)
}
Expand All @@ -365,24 +380,28 @@ final class BuzzPushSnapshotEnrichmentTests: XCTestCase {
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 {
XCTAssertNotNil($0)
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
XCTAssertEqual((value as? FlutterError)?.code, "interaction_cleanup_retry_failed")
XCTAssertFalse(BuzzAgeRestrictionSession.isRestricted(containerURL: directory))
received = value
completed.fulfill()
})
wait(for: [completed], timeout: 1)
wait(for: [completed], timeout: callbackTimeout)
XCTAssertEqual((received as? FlutterError)?.code, "interaction_cleanup_retry_failed")
XCTAssertFalse(BuzzAgeRestrictionSession.isRestricted(containerURL: directory))
}

func testStrictAgeGateWriteFailsWhenAppGroupStoreIsUnavailable() {
Expand All @@ -392,6 +411,7 @@ final class BuzzPushSnapshotEnrichmentTests: XCTestCase {
keychainAccessGroup: nil
)
let completed = expectation(description: "strict write rejected")
var received: Any?

XCTAssertTrue(
bridge.handle(
Expand All @@ -405,12 +425,13 @@ final class BuzzPushSnapshotEnrichmentTests: XCTestCase {
]
)
) { value in
XCTAssertEqual((value as? FlutterError)?.code, "snapshot_sync_unavailable")
received = value
completed.fulfill()
}
)

wait(for: [completed], timeout: 1)
wait(for: [completed], timeout: callbackTimeout)
XCTAssertEqual((received as? FlutterError)?.code, "snapshot_sync_unavailable")
}

func testMetadataAuthorityUsesNewestGrantForMatchingRelay() {
Expand Down
5 changes: 3 additions & 2 deletions mobile/ios/RunnerTests/ProfileSetupRecoveryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,9 @@ final class ProfileSetupRecoveryTests: XCTestCase {
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: 2)
await fulfillment(of: [released], timeout: 10)
}
XCTAssertEqual(watchdogs.count, 2)
skip.sendActions(for: .touchUpInside)
Expand All @@ -178,7 +179,7 @@ final class ProfileSetupRecoveryTests: XCTestCase {
XCTAssertTrue(notNow.isEnabled)
notNow.sendActions(for: .touchUpInside)
let dismissed = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in parent.presentedViewController == nil }, object: nil)
await fulfillment(of: [dismissed], timeout: 2)
await fulfillment(of: [dismissed], timeout: 10)
XCTAssertEqual(messenger.completionAction, "skip")
withExtendedLifetime(coordinator) {}
nav.dismiss(animated: false)
Expand Down
13 changes: 5 additions & 8 deletions mobile/test/features/channels/channel_detail_page_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import 'package:buzz/shared/widgets/skeleton.dart';
import 'package:shared_preferences/shared_preferences.dart';

import '../../helpers/thread_ids.dart';
import '../../helpers/widget_helpers.dart';

part 'thread_reply_refresh_cases.dart';
part 'thread_title_capsule_cases.dart';
Expand Down Expand Up @@ -10119,17 +10120,13 @@ void main() {
);

animationResponse.complete(http.Response.bytes(_transparentPng, 200));
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
final animationReady = find.byKey(
const ValueKey('progressive-animated-avatar-animation-ready'),
);
await WidgetHelpers.pumpUntilFound(tester, animationReady);
await tester.pumpAndSettle();

expect(
find.byKey(
const ValueKey('progressive-animated-avatar-animation-ready'),
),
findsOneWidget,
);
expect(animationReady, findsOneWidget);
expect(
find.byKey(const ValueKey('progressive-animated-avatar-poster')),
findsNothing,
Expand Down
10 changes: 4 additions & 6 deletions mobile/test/features/profile/settings_profile_header_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -128,15 +128,13 @@ void main() {
containsAll([posterUrl, animationUrl]),
);
animationResponse.complete(http.Response.bytes(_transparentPng, 200));
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
final animationReady = find.byKey(
const ValueKey('progressive-animated-avatar-animation-ready'),
);
await WidgetHelpers.pumpUntilFound(tester, animationReady);
await tester.pumpAndSettle();

expect(
find.byKey(const ValueKey('progressive-animated-avatar-animation-ready')),
findsOneWidget,
);
expect(animationReady, findsOneWidget);
expect(
find.byKey(const ValueKey('progressive-animated-avatar-poster')),
findsNothing,
Expand Down
13 changes: 13 additions & 0 deletions mobile/test/helpers/widget_helpers.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hooks_riverpod/misc.dart';
import 'package:buzz/shared/theme/theme.dart';
Expand All @@ -24,4 +25,16 @@ class WidgetHelpers {
),
);
}

/// Pumps until [finder] matches, letting real async work such as an image
/// decode run between frames. Gives up after about 5 s so the caller's
/// `expect` still reports a real regression.
static Future<void> pumpUntilFound(WidgetTester tester, Finder finder) async {
for (var i = 0; i < 500 && finder.evaluate().isEmpty; i++) {
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 10)),
);
await tester.pump();
}
}
}
6 changes: 5 additions & 1 deletion scripts/flake-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1115,6 +1115,9 @@ function repeatNode(files, iterations) {

// Per-test statuses of a `dart test` JSON report (one event per line), keyed
// `<file> › <name>`. Hidden entries are the runner's own file-loading steps.
// A test declared through a helper (every `testWidgets` is) has the helper's
// `package:` URL as its `url`, so its file comes from its suite, else from
// `root_url`.
export function parseDartReport(text, mobileRoot = `${root}mobile/`) {
const started = new Map();
const suites = new Map();
Expand All @@ -1131,8 +1134,9 @@ export function parseDartReport(text, mobileRoot = `${root}mobile/`) {
if (event.type !== "testDone" || event.hidden) continue;
const test = started.get(event.testID);
if (!test) continue;
const url = test.root_url ?? test.url;
const source = suites.get(test.suiteID) ??
(test.url?.startsWith("file:") ? fileURLToPath(test.url) : test.url ?? "unknown");
(url?.startsWith("file:") ? fileURLToPath(url) : url ?? "unknown");
const file = source.replace(mobileRoot, "");
statuses.set(
`${file} › ${test.name}`,
Expand Down
17 changes: 17 additions & 0 deletions scripts/flake-check.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -953,6 +953,15 @@ test("reports give per-test statuses per iteration", () => {
type: "testStart",
test: { id: 4, name: "later", url: "file:///m/test/b_test.dart" },
}),
event({
type: "testStart",
test: {
id: 5,
name: "widget renders",
url: "package:flutter_test/src/widget_tester.dart",
root_url: "file:///m/test/b_test.dart",
},
}),
"not json",
event({
type: "testDone",
Expand All @@ -975,6 +984,13 @@ test("reports give per-test statuses per iteration", () => {
hidden: false,
skipped: true,
}),
event({
type: "testDone",
testID: 5,
result: "success",
hidden: false,
skipped: false,
}),
event({ type: "done", success: false }),
].join("\n"),
"/m/",
Expand All @@ -983,6 +999,7 @@ test("reports give per-test statuses per iteration", () => {
["test/a_test.dart › group renders", "passed"],
["test/a_test.dart › group fails", "failed"],
["test/b_test.dart › later", "skipped"],
["test/b_test.dart › widget renders", "passed"],
]),
);
// Playwright lists a --repeat-each journey once per repetition, in order.
Expand Down
Loading