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>
This commit is contained in:
Wes
2026-10-14 19:57:47 -07:00
committed by GitHub
parent 7b11c6147d
commit b77ba86262
7 changed files with 149 additions and 19 deletions

View File

@@ -57,11 +57,31 @@ jobs:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
# Mobile lanes run in parallel; the required check reads the combined
# matrix result. `flutter test` shards split tests, not files, so every test
# shard still compiles the whole suite.
mobile:
name: Mobile
name: Mobile (${{ matrix.name }})
runs-on: ubuntu-latest
timeout-minutes: 30
if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.mobile)
strategy:
fail-fast: false
matrix:
include:
- name: Checks
lane: checks
- name: Tests 1/3
lane: test
shard: 0
- name: Tests 2/3
lane: test
shard: 1
- name: Tests 3/3
lane: test
shard: 2
- name: Android
lane: android
permissions:
contents: read
steps:
@@ -107,39 +127,79 @@ jobs:
path: ~/.pub-cache
key: pub-${{ runner.os }}-${{ hashFiles('mobile/pubspec.lock') }}
- name: Format check
if: matrix.lane == 'checks'
run: cd mobile && dart format --output=none --set-exit-if-changed .
- name: Analyze
if: matrix.lane == 'checks'
run: cd mobile && flutter analyze
- name: Test
run: cd mobile && flutter test --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example
if: matrix.lane == 'test'
run: cd mobile && flutter test --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example --total-shards=3 --shard-index=${{ matrix.shard }}
- name: Test mobile without push configuration
if: matrix.lane == 'checks'
run: cd mobile && flutter test test/shared/push/push_unconfigured_build_test.dart
- name: Test mobile recipe argument forwarding
if: matrix.lane == 'checks'
run: /bin/bash scripts/test-mobile-gateway-recipes.sh
- name: Restore Gradle cache
if: matrix.lane == 'android'
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('mobile/android/**/*.gradle*', 'mobile/android/gradle/wrapper/gradle-wrapper.properties', 'mobile/pubspec.lock') }}
restore-keys: gradle-${{ runner.os }}-
- name: Build Android debug APK
if: matrix.lane == 'android'
run: just mobile-build-android
- name: Save Gradle cache
if: matrix.lane == 'android' && github.event_name == 'push'
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
continue-on-error: true
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('mobile/android/**/*.gradle*', 'mobile/android/gradle/wrapper/gradle-wrapper.properties', 'mobile/pubspec.lock') }}
# The unsigned release build needs nothing from the native lane, so it runs
# beside it instead of after the simulator tests.
mobile-swift:
name: Mobile Swift
name: Mobile Swift (${{ matrix.name }})
runs-on: macos-latest
timeout-minutes: 45
if: inputs.lane == 'mobile-swift' && inputs.mobile
strategy:
fail-fast: false
matrix:
include:
- name: Native
lane: native
- name: Release
lane: release
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- name: Install Flutter dependencies
run: cd mobile && flutter pub get
- name: Build
if: matrix.lane == 'native'
run: swift build --package-path mobile/ios/BuzzPushKit
- name: Build release
if: matrix.lane == 'native'
run: swift build -c release --package-path mobile/ios/BuzzPushKit
- name: Test
if: matrix.lane == 'native'
run: swift test --package-path mobile/ios/BuzzPushKit
- name: Test native pagination geometry
if: matrix.lane == 'native'
run: /bin/bash scripts/test-mobile-pagination.sh
- name: Build complete iOS simulator app
if: matrix.lane == 'native'
run: cd mobile && flutter build ios --simulator --debug --no-pub
- name: Test native controls and push navigation bridge
if: matrix.lane == 'native'
run: |
device=$(xcrun simctl create "Pagination accessibility" "iPhone 16")
trap 'xcrun simctl shutdown "$device"; xcrun simctl delete "$device"' EXIT
@@ -153,6 +213,7 @@ jobs:
-only-testing:RunnerTests/BuzzPushNavigationBridgeTests \
CODE_SIGNING_ALLOWED=NO test
- name: Build complete unsigned iOS release
if: matrix.lane == 'release'
run: |
/bin/bash scripts/test-mobile-gateway-recipes.sh
cd mobile && flutter build ios --release --no-codesign --no-pub

View File

@@ -28,6 +28,11 @@ class PushPresentationExportQueueFull extends StateError {
/// The latest best-effort App Group presentation-cache failure.
final pushPresentationCacheError = ValueNotifier<String?>(null);
/// Waits out one export retry delay for the native writer and
/// [PushPresentationExportRecovery]. Tests replace it to skip real time.
Future<void> Function(Duration delay) debugPushPresentationRetryWait =
Future<void>.delayed;
/// Revalidates a relay event before it crosses into the native cache writer.
bool isVerifiedPushPresentationEvent(NostrEvent event) {
try {
@@ -294,7 +299,7 @@ Future<void> _invokeVerifiedChunk(
if (error.code != retryableCode) rethrow;
final delay = budget.takeDelay();
if (delay == null) rethrow;
await Future<void>.delayed(delay);
await debugPushPresentationRetryWait(delay);
}
}
}

View File

@@ -27,7 +27,9 @@ class PushPresentationExportRecovery {
}
for (final milliseconds in [250, 500, 1000, 2000, 4000]) {
await Future<void>.delayed(Duration(milliseconds: milliseconds));
await debugPushPresentationRetryWait(
Duration(milliseconds: milliseconds),
);
try {
await operation();
return true;

View File

@@ -613,6 +613,8 @@ void main() {
},
threadReplies: const {'qualified': []},
initialThreadRootId: thread ? 'qualified' : null,
// 48 mounts each open the sheet twice; skip its motion.
disableAnimations: true,
),
);
await tester.pumpAndSettle();

View File

@@ -2,7 +2,9 @@ import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart' hide test;
import 'package:flutter_test/flutter_test.dart' as flutter_test show test;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/features/channels/channel_messages_provider.dart';
import 'package:buzz/features/channels/pending_local_messages_provider.dart';
@@ -12,6 +14,35 @@ import 'package:buzz/shared/relay/relay.dart';
import '../../helpers/thread_ids.dart';
/// Every test in this file runs on virtual time, so retry backoff and refresh
/// timers fire in their real order without waiting on the wall clock. Work
/// outside the fake zone, such as an isolate, fails the test instead of hanging.
void test(String description, Future<void> Function() body) {
flutter_test.test(description, () {
fakeAsync((clock) {
var done = false;
Object? error;
StackTrace? stack;
body().then(
(_) => done = true,
onError: (Object e, StackTrace s) {
error = e;
stack = s;
done = true;
},
);
clock.flushMicrotasks();
while (!done) {
if (clock.pendingTimers.isEmpty) {
throw StateError('Test is waiting on work outside fake time');
}
clock.elapse(const Duration(milliseconds: 1));
}
if (error != null) Error.throwWithStackTrace(error!, stack!);
});
});
}
void main() {
test('live window without deep links does not rescan flattened ids', () async {
var historyIdReads = 0;

View File

@@ -16,11 +16,14 @@ const _secret =
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;
@@ -30,6 +33,7 @@ void main() {
});
});
tearDown(() {
debugPushPresentationRetryWait = Future<void>.delayed;
messenger.setMockMethodCallHandler(_channel, null);
debugDefaultTargetPlatformOverride = null;
pushPresentationCacheError.value = null;
@@ -187,6 +191,7 @@ void main() {
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'),
@@ -229,6 +234,7 @@ void main() {
isFalse,
);
expect(calls, hasLength(7));
expect(retryWaits, _retryBackoff);
expect(calls.map((call) => (call.arguments['events'] as List).length), [
256,
256,
@@ -542,23 +548,37 @@ void main() {
);
}
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,
}) => NostrEvent.fromJson(
nostr.Event.from(
kind: kind,
createdAt: createdAt,
content: kind == 0 ? '{"name":"Synthetic profile"}' : '',
tags: kind == 0
? []
: [
['d', channelID],
],
secretKey: secretKey,
).toMap(),
}) => _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(

View File

@@ -420,6 +420,11 @@ void main() {
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>();
@@ -497,8 +502,12 @@ void main() {
await container.read(channelsProvider.notifier).refresh();
readsBeforeAdmissionRetries = session.snapshotReadCounts;
if (!releaseRefetch.isCompleted) releaseRefetch.complete();
await terminal.future.timeout(const Duration(seconds: 12));
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;