Files
buzz/mobile/test/shared/push/push_presentation_export_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

629 lines
20 KiB
Dart

import 'dart:async';
import 'dart:isolate';
import 'package:buzz/shared/push/push_presentation_cache.dart';
import 'package:buzz/shared/push/push_presentation_export_recovery.dart';
import 'package:buzz/shared/relay/nostr_models.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nostr/nostr.dart' as nostr;
const _channel = MethodChannel('buzz/push');
const _secret =
'0000000000000000000000000000000000000000000000000000000000000001';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final calls = <MethodCall>[];
final retryWaits = <Duration>[];
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
setUp(() {
calls.clear();
retryWaits.clear();
debugPushPresentationRetryWait = (delay) async => retryWaits.add(delay);
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
pushPresentationCacheError.value = null;
pushPresentationExportError.value = null;
messenger.setMockMethodCallHandler(_channel, (call) async {
calls.add(call);
return null;
});
});
tearDown(() {
debugPushPresentationRetryWait = Future<void>.delayed;
messenger.setMockMethodCallHandler(_channel, null);
debugDefaultTargetPlatformOverride = null;
pushPresentationCacheError.value = null;
pushPresentationExportError.value = null;
});
for (final profiles in [true, false]) {
for (final failedChunk in [0, 1]) {
test(
'native chunk transient failure retries ${profiles ? "profiles" : "channels"} chunk $failedChunk in FIFO slot',
() async {
final limit = profiles ? 256 : 512;
final observations = ReceivePort();
addTearDown(observations.close);
var workerReads = 0;
final caller = Isolate.current.debugName;
observations.listen((name) {
if (name != caller) workerReads++;
});
final events = [
for (var i = 0; i <= limit; i++)
_signed(
profiles ? 0 : 39000,
100,
channelID: 'channel-$i',
secretKey: profiles
? (i + 1).toRadixString(16).padLeft(64, '0')
: _secret,
),
];
events[0] = _ObservedVerificationEvent(
events[0],
observations.sendPort,
);
final memberships = profiles
? <NostrEvent>[]
: [
for (var i = 0; i <= limit; i++)
_signed(39002, 100, channelID: 'channel-$i'),
];
var failed = false;
final successfulChunks = <Map>[];
messenger.setMockMethodCallHandler(_channel, (call) async {
calls.add(call);
final args = call.arguments as Map;
if (args['communityId'] == 'following-community') return null;
final chunk =
(args[profiles ? 'events' : 'metadataEvents'] as List).length ==
limit
? 0
: 1;
if (chunk == failedChunk && !failed) {
failed = true;
throw PlatformException(
code: profiles
? 'profile_cache_failed'
: 'channel_cache_failed',
);
}
successfulChunks.add(args);
return null;
});
final exported = PushPresentationExportRecovery().export(
() => profiles
? cacheBuzzPushProfileEvents('original-community', events)
: cacheBuzzPushChannelEvents(
'original-community',
events,
memberships,
),
);
final following = cacheBuzzPushProfileEvents('following-community', [
_signed(0, 200),
]);
expect(await exported, isTrue);
await following;
expect(calls.map((call) => call.arguments['communityId']), [
'original-community',
'original-community',
'original-community',
'following-community',
]);
expect(
calls[failedChunk + 1].arguments,
equals(calls[failedChunk].arguments),
reason: 'retry must reuse the exact failed verified chunk',
);
expect(successfulChunks, hasLength(2));
expect([
for (final chunk in successfulChunks)
...chunk[profiles ? 'events' : 'metadataEvents'] as List,
], unorderedEquals(events.map((event) => event.toJson())));
if (!profiles) {
expect([
for (final chunk in successfulChunks)
...chunk['membershipEvents'] as List,
], unorderedEquals(memberships.map((event) => event.toJson())));
}
await Future<void>.delayed(Duration.zero);
expect(
workerReads,
1,
reason: 'native retries must not repeat signature verification',
);
expect(pushPresentationExportError.value, isNull);
},
);
}
}
for (final profiles in [true, false]) {
test(
'permanent first ${profiles ? "profile" : "channel"} chunk failure exhausts retries and stops export',
() async {
final limit = profiles ? 256 : 512;
final events = [
for (var i = 0; i <= limit; i++)
_signed(
profiles ? 0 : 39000,
100,
channelID: 'channel-$i',
secretKey: profiles
? (i + 1).toRadixString(16).padLeft(64, '0')
: _secret,
),
];
final memberships = profiles
? <NostrEvent>[]
: [
for (var i = 0; i <= limit; i++)
_signed(39002, 100, channelID: 'channel-$i'),
];
messenger.setMockMethodCallHandler(_channel, (call) async {
calls.add(call);
if (call.arguments['communityId'] == 'failed-community') {
throw PlatformException(
code: profiles ? 'profile_cache_failed' : 'channel_cache_failed',
);
}
return null;
});
final recovery = PushPresentationExportRecovery();
final succeeded = await recovery.export(
() => profiles
? cacheBuzzPushProfileEvents('failed-community', events)
: cacheBuzzPushChannelEvents(
'failed-community',
events,
memberships,
),
);
expect(succeeded, isFalse);
expect(
calls,
hasLength(6),
reason: 'initial attempt plus five retries, with no later chunk',
);
expect(retryWaits, _retryBackoff);
expect(
pushPresentationCacheError.value,
contains(profiles ? 'profile_cache_failed' : 'channel_cache_failed'),
);
final terminal = pushPresentationExportError.value;
expect(
terminal,
contains(profiles ? 'profile_cache_failed' : 'channel_cache_failed'),
);
await cacheBuzzPushProfileEvents('following-community', [
_signed(0, 200),
]);
expect(calls, hasLength(7));
for (final retry in calls.take(6)) {
expect(retry.arguments, equals(calls.first.arguments));
}
expect(calls.last.arguments['communityId'], 'following-community');
expect(pushPresentationExportError.value, terminal);
},
);
}
test('native retry budget is shared across all chunks', () async {
final events = [
for (var i = 1; i <= 257; i++)
_signed(0, 100, secretKey: i.toRadixString(16).padLeft(64, '0')),
];
messenger.setMockMethodCallHandler(_channel, (call) async {
calls.add(call);
final rows = call.arguments['events'] as List;
if (calls.length == 1 || rows.length == 1) {
throw PlatformException(code: 'profile_cache_failed');
}
return null;
});
expect(
await PushPresentationExportRecovery().export(
() => cacheBuzzPushProfileEvents('community', events),
),
isFalse,
);
expect(calls, hasLength(7));
expect(retryWaits, _retryBackoff);
expect(calls.map((call) => (call.arguments['events'] as List).length), [
256,
256,
1,
1,
1,
1,
1,
]);
expect(pushPresentationExportError.value, contains('profile_cache_failed'));
});
test(
'native argument errors are terminal without persistence retries',
() async {
messenger.setMockMethodCallHandler(_channel, (call) async {
calls.add(call);
throw PlatformException(code: 'invalid_arguments');
});
expect(
await PushPresentationExportRecovery().export(
() => cacheBuzzPushProfileEvents('community', [_signed(0, 100)]),
),
isFalse,
);
expect(calls, hasLength(1));
expect(pushPresentationExportError.value, contains('invalid_arguments'));
},
);
test('unavailable native bridge remains an intentional no-op', () async {
messenger.setMockMethodCallHandler(_channel, (call) async {
calls.add(call);
throw MissingPluginException('synthetic non-Runner embedding');
});
expect(
await PushPresentationExportRecovery().export(
() => cacheBuzzPushProfileEvents('community', [_signed(0, 100)]),
),
isTrue,
);
expect(calls, hasLength(1));
expect(pushPresentationCacheError.value, isNull);
expect(pushPresentationExportError.value, isNull);
});
for (final profiles in [true, false]) {
test(
'large ${profiles ? "profile" : "channel"} exports fit native bounds',
() async {
final limit = profiles ? 256 : 512;
final events = [
for (var i = 0; i <= limit; i++)
_signed(
profiles ? 0 : 39000,
100,
channelID: 'channel-$i',
secretKey: profiles
? (i + 1).toRadixString(16).padLeft(64, '0')
: _secret,
),
];
// Deliberately reverse roster order: each chunk must pair by channel ID.
final memberships = profiles
? <NostrEvent>[]
: [
for (var i = limit; i >= 0; i--)
_signed(39002, 100, channelID: 'channel-$i'),
];
final received = <dynamic>[];
final receivedMemberships = <dynamic>[];
messenger.setMockMethodCallHandler(_channel, (call) async {
final args = call.arguments as Map;
if (args['communityId'] == 'following-community') {
calls.add(call);
return null;
}
final metadata = args[profiles ? 'events' : 'metadataEvents'] as List;
final rosters = profiles
? <dynamic>[]
: args['membershipEvents'] as List;
if (metadata.length > limit || rosters.length > limit) {
throw PlatformException(code: 'invalid_arguments');
}
expect(args['communityId'], 'bounded-community');
if (!profiles) {
String id(dynamic event) =>
(event['tags'] as List).firstWhere((tag) => tag[0] == 'd')[1]
as String;
expect(rosters.map(id).toSet(), metadata.map(id).toSet());
}
received.addAll(metadata);
receivedMemberships.addAll(rosters);
calls.add(call);
return null;
});
final largeExport = profiles
? cacheBuzzPushProfileEvents('bounded-community', events)
: cacheBuzzPushChannelEvents(
'bounded-community',
events,
memberships,
);
final followingExport = cacheBuzzPushProfileEvents(
'following-community',
[_signed(0, 200)],
);
await Future.wait([largeExport, followingExport]);
expect(pushPresentationCacheError.value, isNull);
expect(calls.map((call) => call.arguments['communityId']), [
'bounded-community',
'bounded-community',
'following-community',
]);
expect(
received,
unorderedEquals(events.map((event) => event.toJson())),
);
expect(
receivedMemberships,
unorderedEquals(memberships.map((event) => event.toJson())),
);
},
);
}
for (final kind in [0, 39000]) {
test(
'kind $kind verifies off the calling isolate before native export',
() async {
final verification = Completer<void>();
final observations = ReceivePort();
addTearDown(observations.close);
final callerIsolateName = Isolate.current.debugName;
observations.listen((name) {
if (name != callerIsolateName && !verification.isCompleted) {
verification.complete();
}
});
final event = _ObservedVerificationEvent(
_signed(kind, 100),
observations.sendPort,
);
var timerRan = false;
Timer.run(() => timerRan = true);
if (kind == 0) {
await cacheBuzzPushProfileEvents('community', [event]);
} else {
await cacheBuzzPushChannelEvents('community', [event], []);
}
// The signal originates from reading the real signature in the worker.
// Restoring synchronous verification cannot produce it. This is a
// liveness timeout, not a device-dependent elapsed-time performance gate.
await verification.future.timeout(const Duration(seconds: 5));
expect(timerRan, isTrue);
expect(calls.single.method, 'syncPushSnapshot');
expect(calls.single.arguments['communityId'], 'community');
final key = kind == 0 ? 'events' : 'metadataEvents';
expect(calls.single.arguments[key], [event.toJson()]);
},
);
}
test(
'profile export retains older valid candidates and signed payloads',
() async {
final older = _signed(0, 100);
final newestValid = _signed(0, 200);
await cacheBuzzPushProfileEvents('profiles-community', [
older,
_tampered(_signed(0, 300)),
newestValid,
_signed(39000, 400),
]);
expect(calls.single.arguments, {
'section': 'profiles',
'communityId': 'profiles-community',
'events': [newestValid.toJson()],
});
},
);
test(
'channel export preserves valid fallback and membership scope pairing',
() async {
final metadata = _signed(39000, 200);
final membership = _signed(39002, 200);
await cacheBuzzPushChannelEvents(
'channels-community',
[
_signed(39000, 100),
_tampered(_signed(39000, 300)),
metadata,
_signed(39000, 400, channelID: 'unpaired'),
],
[_tampered(_signed(39002, 300)), membership, _signed(39002, 100)],
);
expect(calls.single.arguments, {
'section': 'channels',
'communityId': 'channels-community',
'metadataEvents': [metadata.toJson()],
'membershipEvents': [membership.toJson()],
});
},
);
test(
'native handoff and completion retain FIFO across sections and communities',
() async {
final firstEntered = Completer<void>();
final releaseFirst = Completer<void>();
var firstReleased = false;
var overlappingHandoff = false;
messenger.setMockMethodCallHandler(_channel, (call) async {
calls.add(call);
if (calls.length == 1) {
firstEntered.complete();
await releaseFirst.future;
firstReleased = true;
} else {
overlappingHandoff |= !firstReleased;
}
return null;
});
final completed = <String>[];
final first = cacheBuzzPushProfileEvents('first', [
_signed(0, 100),
]).then((_) => completed.add('first'));
await firstEntered.future;
final second = cacheBuzzPushChannelEvents('second', [
_signed(39000, 100),
], []).then((_) => completed.add('second'));
final third = cacheBuzzPushProfileEvents('third', [
_signed(0, 200),
]).then((_) => completed.add('third'));
try {
await Future<void>.delayed(Duration.zero);
expect(completed, isEmpty);
expect(calls.length, 1);
} finally {
releaseFirst.complete();
await Future.wait([first, second, third]);
}
expect(overlappingHandoff, isFalse);
expect(completed, ['first', 'second', 'third']);
expect(calls.map((call) => call.arguments['communityId']), [
'first',
'second',
'third',
]);
},
);
test(
'saturation rejects explicitly and admitted exports drain before recovery',
() async {
final entered = Completer<void>();
final release = Completer<void>();
messenger.setMockMethodCallHandler(_channel, (call) async {
calls.add(call);
if (calls.length == 1) {
entered.complete();
await release.future;
}
return null;
});
final event = _signed(0, 100);
final admitted = [
cacheBuzzPushProfileEvents('0', [event]),
];
await entered.future;
try {
for (var i = 1; i < 8; i++) {
admitted.add(cacheBuzzPushProfileEvents('$i', [event]));
}
await expectLater(
cacheBuzzPushProfileEvents('rejected', [
event,
]).timeout(const Duration(seconds: 5)),
throwsStateError,
);
expect(calls.length, 1);
} finally {
release.complete();
await Future.wait(admitted);
}
expect(calls.map((call) => call.arguments['communityId']), [
for (var i = 0; i < 8; i++) '$i',
]);
await cacheBuzzPushProfileEvents('recovered', [event]);
expect(calls.last.arguments['communityId'], 'recovered');
expect(calls.length, 9);
},
);
test(
'worker submission failure propagates and releases the next export',
() async {
final unsendable = ReceivePort();
addTearDown(unsendable.close);
final failed = cacheBuzzPushProfileEvents('failed', [
_UnsendableEvent(_signed(0, 100), unsendable),
]);
final failure = expectLater(failed, throwsArgumentError);
final next = cacheBuzzPushProfileEvents('next', [_signed(0, 200)]);
await failure;
await next.timeout(const Duration(seconds: 5));
expect(calls.length, 1);
expect(calls.single.arguments['communityId'], 'next');
},
);
}
const _retryBackoff = [
Duration(milliseconds: 250),
Duration(milliseconds: 500),
Duration(seconds: 1),
Duration(seconds: 2),
Duration(seconds: 4),
];
// Signing dominates fixture cost, and tests reuse the same events.
final _signedEvents = <(int, int, String, String), NostrEvent>{};
NostrEvent _signed(
int kind,
int createdAt, {
String channelID = 'channel',
String secretKey = _secret,
}) => _signedEvents.putIfAbsent(
(kind, createdAt, channelID, secretKey),
() => NostrEvent.fromJson(
nostr.Event.from(
kind: kind,
createdAt: createdAt,
content: kind == 0 ? '{"name":"Synthetic profile"}' : '',
tags: kind == 0
? []
: [
['d', channelID],
],
secretKey: secretKey,
).toMap(),
),
);
NostrEvent _tampered(NostrEvent event) => NostrEvent(
id: event.id,
pubkey: event.pubkey,
createdAt: event.createdAt,
kind: event.kind,
tags: event.tags,
content: 'Tampered',
sig: event.sig,
);
class _ObservedVerificationEvent extends NostrEvent {
final SendPort observations;
_ObservedVerificationEvent(NostrEvent event, this.observations)
: super(
id: event.id,
pubkey: event.pubkey,
createdAt: event.createdAt,
kind: event.kind,
tags: event.tags,
content: event.content,
sig: event.sig,
);
@override
String get sig {
observations.send(Isolate.current.debugName);
return super.sig;
}
}
class _UnsendableEvent extends NostrEvent {
final ReceivePort port;
_UnsendableEvent(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,
);
}