mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
67
.github/workflows/_ci-clients.yml
vendored
67
.github/workflows/_ci-clients.yml
vendored
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user