From 33d1e21bc9992fc7cb1c0cfe494033721f68ef1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCttler?= Date: Sat, 18 Apr 2026 13:25:16 +0200 Subject: [PATCH] perf: cut integration-ui test time from 250s to 28s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix HOME override that caused FVM to re-download 220MB Flutter SDK on every run; use XDG_DATA_HOME instead to isolate app data without touching HOME - Switch DB path from getApplicationDocumentsDirectory() to getApplicationSupportDirectory() so XDG_DATA_HOME isolation works and stale accounts don't leak between test runs - Replace fixed pump(5s/3s) waits with pumpUntil() polling at 200ms so tests stop waiting as soon as the UI is ready (23s of dead wait → 8s) - Add timing instrumentation (ts() in shell, _log()/Stopwatch in Dart) - Fix CI integration-ui job: was mixing subosito flutter with fvm flutter; now uses fvm consistently with actions/cache for ~/.fvm, ~/.pub-cache, and build/linux Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 49 +++++++++++++++++++------- integration_test/app_e2e_test.dart | 53 ++++++++++++++++++++++------- lib/data/db/database.dart | 2 +- stalwart-dev/integration_ui_test.sh | 20 +++++++---- 4 files changed, 93 insertions(+), 31 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ae9f64..223e77b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,17 +47,25 @@ jobs: - uses: DeterminateSystems/magic-nix-cache-action@v8 - - uses: subosito/flutter-action@v2 + - name: Cache FVM Flutter SDK + uses: actions/cache@v4 with: - flutter-version: "3.41.6" - channel: stable - cache: true + path: ~/.fvm + key: fvm-${{ hashFiles('.fvm/fvm_config.json') }} + + - name: Cache pub packages + uses: actions/cache@v4 + with: + path: ~/.pub-cache + key: pub-${{ hashFiles('pubspec.lock') }} + restore-keys: pub- - name: Run integration tests run: | nix develop --command bash -c " - flutter pub get && - flutter pub run build_runner build --delete-conflicting-outputs && + fvm install --skip-pub-get && + fvm flutter pub get && + fvm flutter pub run build_runner build --delete-conflicting-outputs && stalwart-dev/test.sh " @@ -80,17 +88,34 @@ jobs: libgtk-3-dev pkg-config cmake ninja-build clang \ libsecret-1-dev - - uses: subosito/flutter-action@v2 + - name: Cache FVM Flutter SDK + uses: actions/cache@v4 with: - flutter-version: "3.41.6" - channel: stable - cache: true + path: ~/.fvm + key: fvm-${{ hashFiles('.fvm/fvm_config.json') }} + + - name: Cache pub packages + uses: actions/cache@v4 + with: + path: ~/.pub-cache + key: pub-${{ hashFiles('pubspec.lock') }} + restore-keys: pub- + + - name: Cache Linux debug build + uses: actions/cache@v4 + with: + path: | + build/linux + .dart_tool/flutter_build + key: linux-debug-${{ hashFiles('pubspec.lock', 'lib/**/*.dart', 'integration_test/**/*.dart') }} + restore-keys: linux-debug- - name: Run UI integration tests run: | nix develop --command bash -c " - flutter pub get && - flutter pub run build_runner build --delete-conflicting-outputs && + fvm install --skip-pub-get && + fvm flutter pub get && + fvm flutter pub run build_runner build --delete-conflicting-outputs && stalwart-dev/integration_ui_test.sh " diff --git a/integration_test/app_e2e_test.dart b/integration_test/app_e2e_test.dart index 33be382..4909516 100644 --- a/integration_test/app_e2e_test.dart +++ b/integration_test/app_e2e_test.dart @@ -36,6 +36,29 @@ class _InMemorySecureStorage implements SecureStorage { Future delete({required String key}) async => _store.remove(key); } +final _sw = Stopwatch()..start(); +void _log(String label) => + debugPrint('[${_sw.elapsedMilliseconds}ms] $label'); + +/// Pumps the widget tree at [interval] until [finder] matches at least one +/// widget, or [timeout] elapses (which throws). Replaces fixed `pump(N)` +/// waits — stops as soon as the UI is ready rather than burning the full budget. +Future pumpUntil( + WidgetTester tester, + Finder finder, { + Duration timeout = const Duration(seconds: 15), + Duration interval = const Duration(milliseconds: 200), +}) async { + final deadline = tester.binding.clock.now().add(timeout); + while (!tester.any(finder)) { + if (tester.binding.clock.now().isAfter(deadline)) { + throw Exception('pumpUntil timed out waiting for $finder'); + } + await tester.pump(interval); + } + await tester.pumpAndSettle(); +} + void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -65,10 +88,12 @@ void main() { addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); + _log('app start'); app.main(overrides: [ secureStorageProvider.overrideWithValue(_InMemorySecureStorage()), ]); await tester.pumpAndSettle(); + _log('app settled'); // ── Add account ──────────────────────────────────────────────────────── expect(find.text('No accounts yet.'), findsOneWidget); @@ -118,10 +143,10 @@ void main() { expect(find.text(userEmail), findsOneWidget); // ── Navigate to mailboxes ────────────────────────────────────────────── + _log('navigate to mailboxes'); await tester.tap(find.text('Alice')); - // Give the background sync time to populate mailboxes from IMAP. - await tester.pump(const Duration(seconds: 5)); - await tester.pumpAndSettle(); + await pumpUntil(tester, find.text('INBOX')); + _log('mailboxes settled'); expect(find.text('INBOX'), findsOneWidget); @@ -143,10 +168,11 @@ void main() { await tester.ensureVisible(bodyField); await tester.enterText(bodyField, 'Hello from integration test!'); + _log('send email'); await tester.tap(find.byIcon(Icons.send)); - // Wait for SMTP send + IMAP APPEND to complete. - await tester.pump(const Duration(seconds: 5)); - await tester.pumpAndSettle(); + // Wait for ComposeScreen to pop back to EmailListScreen after send. + await pumpUntil(tester, find.byIcon(Icons.edit)); + _log('send done'); // ComposeScreen pops back to EmailListScreen (INBOX) after send. @@ -159,9 +185,10 @@ void main() { await tester.pumpAndSettle(); // Sync Sent folder to fetch the appended message. + _log('sync Sent'); await tester.tap(find.byIcon(Icons.sync)); - await tester.pump(const Duration(seconds: 5)); - await tester.pumpAndSettle(); + await pumpUntil(tester, find.text(subject)); + _log('sync Sent done'); expect(find.text(subject), findsOneWidget); @@ -173,9 +200,10 @@ void main() { await tester.pumpAndSettle(); // Sync INBOX — Stalwart delivers to self near-instantly. + _log('sync INBOX'); await tester.tap(find.byIcon(Icons.sync)); - await tester.pump(const Duration(seconds: 5)); - await tester.pumpAndSettle(); + await pumpUntil(tester, find.text(subject)); + _log('sync INBOX done'); expect(find.text(subject), findsOneWidget); @@ -184,10 +212,11 @@ void main() { await tester.pumpAndSettle(); // Search by the 'E2E-' prefix — should match the message we just sent. + _log('search'); await tester.enterText(find.byType(TextField), 'E2E-'); await tester.testTextInput.receiveAction(TextInputAction.search); - await tester.pump(const Duration(seconds: 3)); - await tester.pumpAndSettle(); + await pumpUntil(tester, find.text(subject)); + _log('search done'); expect(find.text(subject), findsOneWidget); }, diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart index 5836e75..0a64644 100644 --- a/lib/data/db/database.dart +++ b/lib/data/db/database.dart @@ -85,7 +85,7 @@ class AppDatabase extends _$AppDatabase { LazyDatabase _openConnection() { return LazyDatabase(() async { - final dir = await getApplicationDocumentsDirectory(); + final dir = await getApplicationSupportDirectory(); final file = File(p.join(dir.path, 'sharedinbox.db')); return NativeDatabase.createInBackground(file); }); diff --git a/stalwart-dev/integration_ui_test.sh b/stalwart-dev/integration_ui_test.sh index 28e923b..0e52c34 100755 --- a/stalwart-dev/integration_ui_test.sh +++ b/stalwart-dev/integration_ui_test.sh @@ -6,6 +6,10 @@ # Run inside nix develop: stalwart-dev/integration_ui_test.sh set -Eeuo pipefail +# Timing helper: prints elapsed seconds since script start with a label. +_SCRIPT_START=$(date +%s%3N) +ts() { echo "[$(( $(date +%s%3N) - _SCRIPT_START ))ms] $*"; } + export STALWART_USER_B="${STALWART_USER_B:-alice@localhost}" export STALWART_PASS_B="${STALWART_PASS_B:-secret}" export STALWART_USER_C="${STALWART_USER_C:-bob@localhost}" @@ -14,7 +18,7 @@ export STALWART_RANDOM_PORTS=1 STALWART_TMPDIR="$(mktemp -d /tmp/stalwart-dev-XXXXXX)" export STALWART_TMPDIR -# Isolate the app database: fresh HOME → fresh path_provider directory. +# Isolate path_provider app data from the developer's real data directory. TEST_HOME="$(mktemp -d /tmp/sharedinbox-test-home-XXXXXX)" cleanup() { @@ -37,6 +41,8 @@ command -v xvfb-run >/dev/null || { exit 1 } +ts "script start" + # Pre-seed spam-filter version so Stalwart does not fetch it on first boot. mkdir -p "$STALWART_TMPDIR" sqlite3 "${STALWART_TMPDIR}/data.sqlite" \ @@ -46,6 +52,7 @@ sqlite3 "${STALWART_TMPDIR}/data.sqlite" \ LOGFILE="${STALWART_TMPDIR}/stalwart.log" rm -f "$LOGFILE" +ts "stalwart start" "$(dirname "$0")/start" >"$LOGFILE" 2>&1 & STALWART_PID=$! @@ -71,21 +78,22 @@ curl -s --max-time 1 -o /dev/null "${STALWART_URL}/.well-known/jmap" || { cat "$LOGFILE"; echo "Stalwart did not become ready"; exit 1 } -echo "Stalwart ready — IMAP=:${STALWART_IMAP_PORT:-?} SMTP=:${STALWART_SMTP_PORT:-?}" +ts "stalwart ready — IMAP=:${STALWART_IMAP_PORT:-?} SMTP=:${STALWART_SMTP_PORT:-?}" ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" export STALWART_IMAP_HOST="127.0.0.1" export STALWART_SMTP_HOST="127.0.0.1" -export HOME="$TEST_HOME" +# Isolate app data (path_provider uses XDG_DATA_HOME on Linux) without +# overriding HOME — keeping the real HOME lets FVM reuse its cached SDK. +export XDG_DATA_HOME="$TEST_HOME" -START=$(date +%s) +ts "flutter test start" # xvfb-run provides a virtual framebuffer so the Flutter Linux runner has a # display without requiring a real desktop session. No D-Bus or keyring daemon # is needed because the integration tests inject an in-memory SecureStorage. xvfb-run --auto-servernum fvm flutter test integration_test/ -d linux -END=$(date +%s) -echo "ui-integration: $((END - START))s" +ts "flutter test done"