Files
buzz/mobile/test/shared/push/push_presentation_recovery_test.dart
Wes b77ba86262 perf(mobile): cut test wall time and run mobile CI lanes in parallel (#8347)
## Problem

Mobile pushes and PRs are slow.

- **Pre-push takes ~133s on a mobile-only change.** About 115s of that
is `flutter test`, and the finish time is set by a few files that wait
on real clocks.
- `push_presentation_export_test` (75s) and
`push_presentation_recovery_test` (19s) sleep through the full 7.75s
retry backoff.
- `channel_messages_provider_test` (70s) sleeps through recount and
ownership retry backoff on real time.
- **The required Mobile CI job takes ~15–16 min.** It runs tests
(390–430s on a 4-vCPU runner, 2 test files at a time) and then the
Android debug APK build (~400s, no Gradle cache) back to back.
- **Mobile Swift takes 18–21 min.** The unsigned release build (~4.5
min) waits behind the simulator tests.

Pre-commit is ~3s and isn't part of the problem.

## Change

**Tests** (each one still asserts what it did before)
- **Push exports.** Production retries wait through
`debugPushPresentationRetryWait`, which defaults to `Future.delayed`.
The export and recovery tests skip the real waits and assert the exact
250ms → 4s backoff schedule instead. Export fixtures sign each event
once per file.
- **`channel_messages_provider_test`.** Every test runs on `fakeAsync`.
A file-local `test` wrapper keeps the 90 call sites unchanged. Timers
fire in their real order, and a test that waits on work outside fake
time fails instead of hanging. Raising the production retry bound
(`attempt < 2` → `3`) still fails the 5 exhausted-retry tests.
- **`signed qualified caller`.** This test mounts the page 48 times and
opens the profile sheet twice per mount. It now disables the sheet
animation; it asserts which pubkey and labels show, not motion.

**CI**
- **Mobile runs as five parallel matrix lanes:** checks (format,
analyze, unconfigured push test, recipe test), three `flutter test
--total-shards=3` shards, and the Android build. The required `Mobile`
gate still reads the combined result.
- **Android restores a Gradle cache,** which is saved only on pushes to
`main`.
- **Mobile Swift builds the unsigned release** in a parallel lane beside
the native build, tests and simulator checks.

## Validation

Measured locally on an 18-core Mac with warm caches:

| | Before | After |
|---|---|---|
| `flutter test` (full suite) | 115s | 74s |
| Summed per-test time | 411s | 307s |
| `push_presentation_export_test` | 75s | 38s |
| `channel_messages_provider_test` | 70s | 22s |
| Pre-push, mobile-only push | 133s | 95s |

- The CI lane timings come from this PR's own run.
- `actionlint` passes, and so do
`scripts/test-ci-required-context-isolation.sh` and
`scripts/ci-required-gates.test.mjs`.
- The pre-push hook (format, analyze, full `flutter test`) passed on
push.

## Not in this PR

- `channel_detail_page_test` (~55s) is now the local long pole.
Splitting it into shard files would let it run in parallel, but it is
one 16k-line library with `part` files, so that is a separate refactor.
- Caching Hermit and pub on macOS would cut ~100s of setup from each
Mobile Swift lane.

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

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:57:47 -06:00

717 lines
25 KiB
Dart

import 'dart:async';
import 'dart:isolate';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/shared/community/community.dart';
import 'package:buzz/shared/community/community_provider.dart';
import 'package:buzz/shared/profile/user_cache_provider.dart';
import 'package:buzz/shared/profile/profile_event_parser.dart';
import 'package:buzz/shared/push/push_presentation_cache.dart';
import 'package:buzz/shared/push/push_presentation_export_recovery.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:fake_async/fake_async.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
const _bridge = MethodChannel('buzz/push');
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
setUp(() {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
pushPresentationExportError.value = null;
});
tearDown(() {
debugDefaultTargetPlatformOverride = null;
pushPresentationExportError.value = null;
messenger.setMockMethodCallHandler(_bridge, null);
});
for (final profiles in [true, false]) {
final section = profiles ? 'profiles' : 'channels';
test(
'$section producer recovers its saturated detached snapshot',
() async {
final blocked = Completer<void>();
final entered = Completer<void>();
final delivered = Completer<Map<dynamic, dynamic>>();
messenger.setMockMethodCallHandler(_bridge, (call) async {
final args = call.arguments as Map<dynamic, dynamic>;
if (args['communityId'] == 'blocker' && !entered.isCompleted) {
entered.complete();
await blocked.future;
}
if (args['communityId'] == 'original' && !delivered.isCompleted) {
delivered.complete(args);
}
return null;
});
final filler = _signed(0);
final queued = [
cacheBuzzPushProfileEvents('blocker', [filler]),
];
await entered.future;
for (var i = 1; i < 8; i++) {
queued.add(cacheBuzzPushProfileEvents('queued-$i', [filler]));
}
final event = _signed(profiles ? 0 : 39000);
final membership = _signed(39002);
var communityID = 'original';
final container = _container(
_Session(event, membership),
communityID: () => communityID,
);
addTearDown(container.dispose);
Future<bool>? preload;
try {
await container.read(activeCommunityProvider.future);
if (profiles) {
final ready = Completer<void>();
final subscription = container.listen(userCacheProvider, (_, next) {
if (next.containsKey(event.pubkey) && !ready.isCompleted) {
ready.complete();
}
});
addTearDown(subscription.close);
preload = container.read(userCacheProvider.notifier).preload([
event.pubkey,
]);
await ready.future.timeout(const Duration(seconds: 5));
} else {
await container.read(channelsProvider.future);
}
expect(delivered.isCompleted, isFalse);
communityID = 'replacement';
container.invalidate(activeCommunityProvider);
await container.read(activeCommunityProvider.future);
} finally {
blocked.complete();
await Future.wait(queued);
if (preload != null) expect(await preload, isTrue);
}
final payload = await delivered.future.timeout(
const Duration(seconds: 5),
);
expect(payload['section'], section);
expect(payload['communityId'], 'original');
expect(payload[profiles ? 'events' : 'metadataEvents'], [
event.toJson(),
]);
if (!profiles) {
expect(payload['membershipEvents'], [membership.toJson()]);
}
expect(pushPresentationExportError.value, isNull);
},
);
test(
'$section producer preserves distinct input beyond eight arrivals',
() async {
final entered = Completer<void>();
final release = Completer<void>();
final payloads = <Map<dynamic, dynamic>>[];
final delivered = Completer<void>();
final expected = <String>{};
messenger.setMockMethodCallHandler(_bridge, (call) async {
final args = call.arguments as Map<dynamic, dynamic>;
if (args['communityId'] == 'blocker' && !entered.isCompleted) {
entered.complete();
await release.future;
}
if (args['communityId'] == 'original') {
payloads.add(args);
final ids = <String>{
for (final payload in payloads)
for (final event
in payload[profiles ? 'events' : 'metadataEvents'] as List)
event['id'] as String,
};
if (expected.length == 13 &&
ids.containsAll(expected) &&
!delivered.isCompleted) {
delivered.complete();
}
}
return null;
});
final queued = [
cacheBuzzPushProfileEvents('blocker', [_signed(0)]),
];
await entered.future;
for (var i = 1; i < 8; i++) {
queued.add(cacheBuzzPushProfileEvents('queued-$i', [_signed(0)]));
}
final first = _signed(profiles ? 0 : 39000);
expected.add(first.id);
final session = _Session(first, _signed(39002));
final container = _container(session);
addTearDown(container.dispose);
final loads = <Future<bool>>[];
List<String>? visibleBefore;
var subscriptionsBefore = 0;
try {
await container.read(activeCommunityProvider.future);
if (profiles) {
final ready = Completer<void>();
final subscription = container.listen(userCacheProvider, (_, next) {
if (next.containsKey(first.pubkey) && !ready.isCompleted) {
ready.complete();
}
});
addTearDown(subscription.close);
loads.add(
container.read(userCacheProvider.notifier).preload([
first.pubkey,
]),
);
await ready.future.timeout(const Duration(seconds: 5));
expect(await loads.single, isTrue);
} else {
await container.read(channelsProvider.future);
}
for (var i = 2; i <= 13; i++) {
final event = _signed(
profiles ? 0 : 39000,
key: i,
channel: 'channel-$i',
);
session.extra.add(event);
expected.add(event.id);
if (profiles) {
loads.add(
container.read(userCacheProvider.notifier).preload([
event.pubkey,
]),
);
} else {
session.extra.add(_signed(39002, channel: 'channel-$i'));
await container.read(channelsProvider.notifier).refresh();
}
}
// Include a newer valid event and an invalid newest candidate for an
// entity in a pending partial batch. Selection must keep the valid one.
final newer = _signed(
profiles ? 0 : 39000,
key: 2,
channel: 'channel-2',
createdAt: 200,
);
expected.remove(
_signed(profiles ? 0 : 39000, key: 2, channel: 'channel-2').id,
);
expected.add(newer.id);
session.extra.addAll([
newer,
NostrEvent(
id: 'invalid-newest',
pubkey: newer.pubkey,
createdAt: 300,
kind: newer.kind,
tags: newer.tags,
content: 'tampered',
sig: newer.sig,
),
]);
if (profiles) {
// Let the 50 ms fetch-coalescing timer fire if backpressure is
// removed. This is scheduling coverage, not a latency threshold.
await Future<void>.delayed(const Duration(milliseconds: 60));
expect(
session.profileFetches,
1,
reason: 'pending keys must remain upstream while export waits',
);
} else {
for (var turn = 0; turn < 20; turn++) {
await Future<void>.delayed(Duration.zero);
}
visibleBefore = container
.read(channelsProvider)
.requireValue
.map((channel) => channel.id)
.toList();
subscriptionsBefore = session.subscriptions;
}
} finally {
release.complete();
await Future.wait(queued);
}
await Future.wait(loads);
await delivered.future.timeout(const Duration(seconds: 5));
expect(pushPresentationExportError.value, isNull);
if (!profiles) {
expect(
container
.read(channelsProvider)
.requireValue
.map((channel) => channel.id),
visibleBefore,
);
expect(session.subscriptions, subscriptionsBefore);
expect(
session.directoryLoads,
0,
reason: 'notification recovery must not fetch the open directory',
);
expect(
payloads.last['membershipEvents'],
unorderedEquals([
session.membership.toJson(),
for (final event in session.extra)
if (event.kind == 39002) event.toJson(),
]),
);
}
},
);
test(
'$section producer handles detached worker submission failure',
() async {
final port = ReceivePort();
addTearDown(port.close);
final event = _Unsendable(_signed(profiles ? 0 : 39000), port);
final failure = Completer<void>();
void onError() {
if (pushPresentationExportError.value != null &&
!failure.isCompleted) {
failure.complete();
}
}
pushPresentationExportError.addListener(onError);
addTearDown(() => pushPresentationExportError.removeListener(onError));
var nativeCalls = 0;
messenger.setMockMethodCallHandler(_bridge, (_) async {
nativeCalls++;
return null;
});
final container = _container(
_Session(event, _signed(39002)),
bypassProfileWorker: true,
);
addTearDown(container.dispose);
await container.read(activeCommunityProvider.future);
if (profiles) {
await container.read(userCacheProvider.notifier).preload([
event.pubkey,
]);
} else {
await container.read(channelsProvider.future);
}
await failure.future.timeout(const Duration(seconds: 5));
expect(nativeCalls, 0);
final terminalError = pushPresentationExportError.value;
await cacheBuzzPushProfileEvents('unrelated-success', [_signed(0)]);
expect(nativeCalls, 1);
expect(pushPresentationExportError.value, terminalError);
},
);
}
for (final mode in ['reentrant', 'retired', 'failure']) {
test('channel dirty refetch handles $mode work', () async {
final nativeEntered = Completer<void>();
final releaseNative = Completer<void>();
final refetchEntered = Completer<void>();
final releaseRefetch = Completer<void>();
final complete = Completer<void>();
final payloads = <Map<dynamic, dynamic>>[];
messenger.setMockMethodCallHandler(_bridge, (call) async {
final args = call.arguments as Map<dynamic, dynamic>;
payloads.add(args);
if (payloads.length == 1) {
nativeEntered.complete();
await releaseNative.future;
} else if ((mode == 'reentrant' && payloads.length == 3) ||
(mode == 'retired' && args['communityId'] == 'replacement')) {
if (!complete.isCompleted) complete.complete();
}
return null;
});
void errorChanged() {
if (mode == 'failure' &&
pushPresentationExportError.value != null &&
!complete.isCompleted) {
complete.complete();
}
}
pushPresentationExportError.addListener(errorChanged);
addTearDown(
() => pushPresentationExportError.removeListener(errorChanged),
);
final session = _Session(_signed(39000), _signed(39002));
var community = 'original';
final container = _container(session, communityID: () => community);
addTearDown(container.dispose);
await container.read(activeCommunityProvider.future);
await container.read(channelsProvider.future);
await nativeEntered.future;
await container.read(channelsProvider.notifier).refresh();
session.beforeMetadata = () async {
refetchEntered.complete();
await releaseRefetch.future;
if (mode == 'failure') throw StateError('synthetic refetch failure');
};
releaseNative.complete();
await refetchEntered.future.timeout(const Duration(seconds: 5));
try {
if (mode == 'reentrant') {
session.extra.addAll([
_signed(39000, channel: 'later'),
_signed(39002, channel: 'later'),
]);
await container.read(channelsProvider.notifier).refresh();
} else if (mode == 'retired') {
community = 'replacement';
session.event = _signed(39000, channel: 'replacement-channel');
session.membership = _signed(39002, channel: 'replacement-channel');
container.invalidate(activeCommunityProvider);
await container.read(activeCommunityProvider.future);
container
.read(relayConfigProvider.notifier)
.update(baseUrl: 'https://replacement.invalid');
await container.read(channelsProvider.future);
await container.read(channelsProvider.notifier).refresh();
}
} finally {
releaseRefetch.complete();
}
await complete.future.timeout(const Duration(seconds: 5));
for (var turn = 0; turn < 20; turn++) {
await Future<void>.delayed(Duration.zero);
}
if (mode == 'failure') {
expect(session.metadataLoads, 3);
expect(session.directoryLoads, 0);
expect(payloads, hasLength(1));
expect(
pushPresentationExportError.value,
contains('synthetic refetch failure'),
);
} else if (mode == 'retired') {
expect(
payloads.where((payload) => payload['communityId'] == 'original'),
hasLength(1),
);
expect(payloads.last['metadataEvents'], [session.event.toJson()]);
expect(payloads.last['membershipEvents'], [
session.membership.toJson(),
]);
expect(pushPresentationExportError.value, isNull);
} else {
expect(session.metadataLoads, 5);
expect(session.directoryLoads, 0);
expect(payloads.last['metadataEvents'], hasLength(2));
expect(payloads.last['membershipEvents'], hasLength(2));
expect(pushPresentationExportError.value, isNull);
}
});
}
for (final failedOperation in ['initial export', 'dirty refetch']) {
test(
'pending channel input drains after exhausted $failedOperation',
() async {
final retryWaits = <Duration>[];
debugPushPresentationRetryWait = (delay) async => retryWaits.add(delay);
addTearDown(
() => debugPushPresentationRetryWait = Future<void>.delayed,
);
final blockerEntered = Completer<void>();
final releaseBlocker = Completer<void>();
final initialEntered = Completer<void>();
final releaseInitial = Completer<void>();
final refetchEntered = Completer<void>();
final releaseRefetch = Completer<void>();
final terminal = Completer<void>();
late _Session session;
List<int>? readsBeforeAdmissionRetries;
List<int>? readsAtTerminal;
final latestDelivered = Completer<Map<dynamic, dynamic>>();
final latest = _signed(39000, channel: 'latest');
final latestMembership = _signed(39002, channel: 'latest');
messenger.setMockMethodCallHandler(_bridge, (call) async {
final args = call.arguments as Map<dynamic, dynamic>;
if (args['communityId'] == 'blocker' && !blockerEntered.isCompleted) {
blockerEntered.complete();
await releaseBlocker.future;
}
if (args['communityId'] == 'original') {
if (failedOperation == 'dirty refetch' &&
!initialEntered.isCompleted) {
initialEntered.complete();
await releaseInitial.future;
}
if ((args['metadataEvents'] as List).any(
(event) => event['id'] == latest.id,
) &&
!latestDelivered.isCompleted) {
latestDelivered.complete(args);
}
}
return null;
});
void onFailure() {
if (pushPresentationExportError.value != null &&
!terminal.isCompleted) {
readsAtTerminal = session.snapshotReadCounts;
terminal.complete();
}
}
pushPresentationExportError.addListener(onFailure);
addTearDown(
() => pushPresentationExportError.removeListener(onFailure),
);
final queued = <Future<void>>[];
Future<void> saturate() async {
queued.add(cacheBuzzPushProfileEvents('blocker', [_signed(0)]));
await blockerEntered.future;
for (var i = 1; i < 8; i++) {
queued.add(cacheBuzzPushProfileEvents('queued-$i', [_signed(0)]));
}
}
session = _Session(_signed(39000), _signed(39002));
final container = _container(session);
addTearDown(container.dispose);
try {
if (failedOperation == 'initial export') await saturate();
await container.read(activeCommunityProvider.future);
await container.read(channelsProvider.future);
if (failedOperation == 'dirty refetch') {
await initialEntered.future;
await container.read(channelsProvider.notifier).refresh();
session.beforeMetadata = () async {
refetchEntered.complete();
await releaseRefetch.future;
};
releaseInitial.complete();
await refetchEntered.future.timeout(const Duration(seconds: 5));
await saturate();
}
session.extra.addAll([latest, latestMembership]);
await container.read(channelsProvider.notifier).refresh();
readsBeforeAdmissionRetries = session.snapshotReadCounts;
if (!releaseRefetch.isCompleted) releaseRefetch.complete();
await terminal.future.timeout(const Duration(seconds: 5));
expect(pushPresentationExportError.value, contains('queue is full'));
expect(retryWaits, [
for (final milliseconds in [250, 500, 1000, 2000, 4000])
Duration(milliseconds: milliseconds),
]);
if (failedOperation == 'dirty refetch') {
// Membership pages and metadata were fetched before the paused
// metadata response. Only the remaining member snapshot is read;
// all five admission retries must reuse those same raw events.
expect(
[
for (var i = 0; i < 3; i++)
readsAtTerminal![i] - readsBeforeAdmissionRetries[i],
],
[0, 0, 1],
);
}
// Capacity returns only after the predecessor has exhausted every retry.
releaseBlocker.complete();
await Future.wait(queued);
final payload = await latestDelivered.future.timeout(
const Duration(seconds: 5),
);
expect(payload['communityId'], 'original');
expect(payload['metadataEvents'], contains(equals(latest.toJson())));
expect(
payload['membershipEvents'],
contains(equals(latestMembership.toJson())),
);
expect(session.directoryLoads, 0);
expect(pushPresentationExportError.value, contains('queue is full'));
} finally {
if (!releaseBlocker.isCompleted) releaseBlocker.complete();
if (!releaseInitial.isCompleted) releaseInitial.complete();
if (!releaseRefetch.isCompleted) releaseRefetch.complete();
await Future.wait(queued);
}
},
);
}
test('bounded retries terminate and a later operation can succeed', () {
fakeAsync((clock) {
final recovery = PushPresentationExportRecovery();
var attempts = 0;
bool? result;
recovery
.export(() async {
attempts++;
throw PushPresentationExportQueueFull();
})
.then((value) => result = value);
clock.flushMicrotasks();
clock.elapse(const Duration(seconds: 8));
expect(result, isFalse);
expect(attempts, 6);
final terminal = pushPresentationExportError.value;
expect(terminal, isNotNull);
clock.elapse(const Duration(days: 1));
expect(attempts, 6);
bool? recovered;
recovery.export(() async {}).then((value) => recovered = value);
clock.flushMicrotasks();
expect(recovered, isTrue);
expect(pushPresentationExportError.value, terminal);
});
});
}
ProviderContainer _container(
_Session session, {
String Function()? communityID,
bool bypassProfileWorker = false,
}) => ProviderContainer(
retry: (_, _) => null,
overrides: [
if (bypassProfileWorker)
profileEventBatchParserProvider.overrideWithValue(
(events) async => events.map(parseProfileEvent).toList(),
),
relaySessionProvider.overrideWith(() => session),
appLifecycleProvider.overrideWith(_Lifecycle.new),
myPubkeyProvider.overrideWith((ref) => 'me'),
activeCommunityProvider.overrideWith(
(ref) async => Community(
id: communityID?.call() ?? 'original',
name: 'Synthetic',
relayUrl: 'https://example.invalid',
addedAt: DateTime(2026),
),
),
],
);
class _Session extends RelaySessionNotifier {
_Session(this.event, this.membership);
NostrEvent event;
NostrEvent membership;
final extra = <NostrEvent>[];
int profileFetches = 0;
int subscriptions = 0;
int directoryLoads = 0;
int metadataLoads = 0;
int membershipPages = 0;
int memberSnapshotLoads = 0;
List<int> get snapshotReadCounts => [
membershipPages,
metadataLoads,
memberSnapshotLoads,
];
Future<void> Function()? beforeMetadata;
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
if (filter.kinds.contains(0)) profileFetches++;
if (filter.kinds.contains(39002) && filter.tags['#d'] != null) {
memberSnapshotLoads++;
}
if (filter.kinds.contains(39000)) {
metadataLoads++;
final hook = beforeMetadata;
beforeMetadata = null;
if (hook != null) await hook();
}
return [
for (final candidate in [event, membership, ...extra])
if (filter.kinds.contains(candidate.kind) &&
(filter.authors == null ||
filter.authors!.contains(candidate.pubkey)))
candidate,
];
}
@override
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) async {
if (filters.any(
(filter) => filter.kinds.contains(39002) && filter.tags['#p'] != null,
)) {
membershipPages++;
}
if (filters.any(
(filter) =>
filter.kinds.contains(39000) &&
filter.tags['#d'] == null &&
filter.extensions['before_id'] == null,
)) {
directoryLoads++;
}
return [
for (final filter in filters)
...await fetchHistory(filter, timeout: timeout),
];
}
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String)? onClosed,
}) async {
subscriptions++;
return () {};
}
}
class _Lifecycle extends AppLifecycleNotifier {
@override
AppLifecycleState build() => AppLifecycleState.resumed;
}
NostrEvent _signed(
int kind, {
int key = 1,
String channel = 'channel',
int createdAt = 100,
}) => NostrEvent.fromJson(
nostr.Event.from(
kind: kind,
createdAt: createdAt,
content: kind == 0 ? '{"name":"Synthetic"}' : '',
tags: kind == 0
? []
: [
['d', channel],
['name', 'Synthetic'],
if (kind == 39002) ['p', 'me'],
],
secretKey: key.toRadixString(16).padLeft(64, '0'),
).toMap(),
);
class _Unsendable extends NostrEvent {
final ReceivePort port;
_Unsendable(NostrEvent event, this.port)
: super(
id: event.id,
pubkey: event.pubkey,
createdAt: event.createdAt,
kind: event.kind,
tags: event.tags,
content: event.content,
sig: event.sig,
);
}