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;