From b77ba862627c7362a4b863a0a077677d811a7558 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 14 Oct 2026 19:57:47 -0700 Subject: [PATCH] perf(mobile): cut test wall time and run mobile CI lanes in parallel (#8347) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 --- .github/workflows/_ci-clients.yml | 67 ++++++++++++++++++- .../shared/push/push_presentation_cache.dart | 7 +- .../push_presentation_export_recovery.dart | 4 +- .../channels/channel_detail_page_test.dart | 2 + .../channel_messages_provider_test.dart | 33 ++++++++- .../push/push_presentation_export_test.dart | 44 ++++++++---- .../push/push_presentation_recovery_test.dart | 11 ++- 7 files changed, 149 insertions(+), 19 deletions(-) diff --git a/.github/workflows/_ci-clients.yml b/.github/workflows/_ci-clients.yml index 7b8969c336..25b377f846 100644 --- a/.github/workflows/_ci-clients.yml +++ b/.github/workflows/_ci-clients.yml @@ -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 diff --git a/mobile/lib/shared/push/push_presentation_cache.dart b/mobile/lib/shared/push/push_presentation_cache.dart index 99af19d864..324752e56e 100644 --- a/mobile/lib/shared/push/push_presentation_cache.dart +++ b/mobile/lib/shared/push/push_presentation_cache.dart @@ -28,6 +28,11 @@ class PushPresentationExportQueueFull extends StateError { /// The latest best-effort App Group presentation-cache failure. final pushPresentationCacheError = ValueNotifier(null); +/// Waits out one export retry delay for the native writer and +/// [PushPresentationExportRecovery]. Tests replace it to skip real time. +Future Function(Duration delay) debugPushPresentationRetryWait = + Future.delayed; + /// Revalidates a relay event before it crosses into the native cache writer. bool isVerifiedPushPresentationEvent(NostrEvent event) { try { @@ -294,7 +299,7 @@ Future _invokeVerifiedChunk( if (error.code != retryableCode) rethrow; final delay = budget.takeDelay(); if (delay == null) rethrow; - await Future.delayed(delay); + await debugPushPresentationRetryWait(delay); } } } diff --git a/mobile/lib/shared/push/push_presentation_export_recovery.dart b/mobile/lib/shared/push/push_presentation_export_recovery.dart index 764697c39c..29d742099c 100644 --- a/mobile/lib/shared/push/push_presentation_export_recovery.dart +++ b/mobile/lib/shared/push/push_presentation_export_recovery.dart @@ -27,7 +27,9 @@ class PushPresentationExportRecovery { } for (final milliseconds in [250, 500, 1000, 2000, 4000]) { - await Future.delayed(Duration(milliseconds: milliseconds)); + await debugPushPresentationRetryWait( + Duration(milliseconds: milliseconds), + ); try { await operation(); return true; diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index da0a715ff8..9d005d9954 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -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(); diff --git a/mobile/test/features/channels/channel_messages_provider_test.dart b/mobile/test/features/channels/channel_messages_provider_test.dart index 4d6ef0f362..d9c37cea04 100644 --- a/mobile/test/features/channels/channel_messages_provider_test.dart +++ b/mobile/test/features/channels/channel_messages_provider_test.dart @@ -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 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; diff --git a/mobile/test/shared/push/push_presentation_export_test.dart b/mobile/test/shared/push/push_presentation_export_test.dart index 3d4baf63af..b0edf4d08d 100644 --- a/mobile/test/shared/push/push_presentation_export_test.dart +++ b/mobile/test/shared/push/push_presentation_export_test.dart @@ -16,11 +16,14 @@ const _secret = void main() { TestWidgetsFlutterBinding.ensureInitialized(); final calls = []; + final retryWaits = []; 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.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( diff --git a/mobile/test/shared/push/push_presentation_recovery_test.dart b/mobile/test/shared/push/push_presentation_recovery_test.dart index 9d58cebe36..8158756403 100644 --- a/mobile/test/shared/push/push_presentation_recovery_test.dart +++ b/mobile/test/shared/push/push_presentation_recovery_test.dart @@ -420,6 +420,11 @@ void main() { test( 'pending channel input drains after exhausted $failedOperation', () async { + final retryWaits = []; + debugPushPresentationRetryWait = (delay) async => retryWaits.add(delay); + addTearDown( + () => debugPushPresentationRetryWait = Future.delayed, + ); final blockerEntered = Completer(); final releaseBlocker = Completer(); final initialEntered = Completer(); @@ -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;