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