From 1722826c34fe02f806e91a2ced8abd406d10d1e9 Mon Sep 17 00:00:00 2001 From: Roscoe Rubin-Rottenberg Date: Fri, 24 Jul 2026 00:31:04 -0400 Subject: [PATCH] test: organize iOS integration checks --- .github/workflows/native-checks.yml | 8 +- .../authentication_navigation_test.dart | 33 +++ integration_test/registration_test.dart | 34 +++ .../authentication_test_app.dart} | 203 ++++++++---------- test/README.md | 12 +- 5 files changed, 173 insertions(+), 117 deletions(-) create mode 100644 integration_test/authentication_navigation_test.dart create mode 100644 integration_test/registration_test.dart rename integration_test/{app_smoke_test.dart => support/authentication_test_app.dart} (71%) diff --git a/.github/workflows/native-checks.yml b/.github/workflows/native-checks.yml index 2473c074..fd785c29 100644 --- a/.github/workflows/native-checks.yml +++ b/.github/workflows/native-checks.yml @@ -55,7 +55,7 @@ jobs: run: flutter build apk --debug ios: - runs-on: macos-15 + runs-on: blacksmith-6vcpu-macos-15 steps: - name: Checkout Repository @@ -77,11 +77,13 @@ jobs: - name: Compile iOS simulator app run: flutter build ios --simulator --debug - - name: Run iOS integration smoke test + - name: Run iOS integration tests timeout-minutes: 20 run: | DEVICE_ID=$(xcrun simctl list devices available | awk -F '[()]' '/iPhone/ { print $2; exit }') test -n "$DEVICE_ID" xcrun simctl boot "$DEVICE_ID" || true xcrun simctl bootstatus "$DEVICE_ID" -b - flutter test integration_test/app_smoke_test.dart -d "$DEVICE_ID" --reporter=expanded + for TEST_FILE in integration_test/*_test.dart; do + flutter test "$TEST_FILE" -d "$DEVICE_ID" --reporter=expanded + done diff --git a/integration_test/authentication_navigation_test.dart b/integration_test/authentication_navigation_test.dart new file mode 100644 index 00000000..ce0357bf --- /dev/null +++ b/integration_test/authentication_navigation_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:spark/src/features/auth/ui/pages/login_page.dart'; +import 'package:spark/src/features/auth/ui/pages/register_page.dart'; + +import 'support/authentication_test_app.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('user can move between registration and login', (tester) async { + final testApp = AuthenticationTestApp(); + addTearDown(testApp.dispose); + + await testApp.launch(); + await pumpUntilVisible(tester, find.byType(RegisterPage)); + + expect(find.byType(RegisterPage), findsOneWidget); + expect(find.text('Get Started'), findsOneWidget); + expect(testApp.downloadInitializations, 1); + expect(testApp.pushInitializations, 1); + + await tester.tap(find.byKey(RegisterPage.haveAccountButtonKey)); + await pumpUntilVisible(tester, find.byType(LoginPage)); + + expect(find.byType(LoginPage), findsOneWidget); + + await tester.tap(find.byKey(LoginPage.backButtonKey)); + await pumpUntilVisible(tester, find.byType(RegisterPage)); + + expect(find.byType(RegisterPage), findsOneWidget); + }); +} diff --git a/integration_test/registration_test.dart b/integration_test/registration_test.dart new file mode 100644 index 00000000..243871f5 --- /dev/null +++ b/integration_test/registration_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:spark/src/features/auth/ui/pages/register_page.dart'; + +import 'support/authentication_test_app.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('rejected OAuth callback restores the registration action', ( + tester, + ) async { + final testApp = AuthenticationTestApp(); + addTearDown(testApp.dispose); + + await testApp.launch(); + await pumpUntilVisible(tester, find.byType(RegisterPage)); + + final getStartedButton = find.byKey(RegisterPage.getStartedButtonKey); + await tester.ensureVisible(getStartedButton); + await tester.tap(getStartedButton); + await tester.pumpAndSettle(); + + expect(testApp.authRepository.completedCallbacks, [ + 'sprk://oauth-callback?code=isolated-code', + ]); + expect(testApp.oauthLauncher.requestedUrls, [ + 'https://auth.example/register', + ]); + expect(testApp.oauthLauncher.callbackSchemes, ['sprk']); + expect(find.text('Callback rejected by test server'), findsOneWidget); + expect(getStartedButton, findsOneWidget); + }); +} diff --git a/integration_test/app_smoke_test.dart b/integration_test/support/authentication_test_app.dart similarity index 71% rename from integration_test/app_smoke_test.dart rename to integration_test/support/authentication_test_app.dart index 990b7f1f..c1d210a7 100644 --- a/integration_test/app_smoke_test.dart +++ b/integration_test/support/authentication_test_app.dart @@ -1,40 +1,39 @@ import 'dart:convert'; +import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:integration_test/integration_test.dart'; import 'package:poptart/poptart.dart'; import 'package:spark/main.dart' as app; import 'package:spark/src/core/auth/data/models/login_result.dart'; import 'package:spark/src/core/auth/data/repositories/auth_repository.dart'; -import 'package:spark/src/core/design_system/components/atoms/buttons/app_button.dart'; import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; import 'package:spark/src/core/notifications/push_notification_service.dart'; import 'package:spark/src/core/storage/cache/download_manager_interface.dart'; import 'package:spark/src/core/storage/storage.dart'; import 'package:spark/src/features/auth/providers/auth_providers.dart'; import 'package:spark/src/features/auth/providers/oauth_browser_launcher.dart'; -import 'package:spark/src/features/auth/ui/pages/login_page.dart'; -import 'package:spark/src/features/auth/ui/pages/register_page.dart'; -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); +class AuthenticationTestApp { + AuthenticationTestApp() + : authRepository = FakeAuthRepository(), + oauthLauncher = FakeOAuthBrowserLauncher(); + + final FakeAuthRepository authRepository; + final FakeOAuthBrowserLauncher oauthLauncher; + ProviderContainer? _providerContainer; - testWidgets('isolated launch supports auth routing and OAuth callback', ( - tester, - ) async { - final authRepository = _FakeAuthRepository(); - final oauthLauncher = _FakeOAuthBrowserLauncher(); + int downloadInitializations = 0; + int pushInitializations = 0; + + Future launch() async { final providerContainer = ProviderContainer( overrides: [ authRepositoryProvider.overrideWithValue(authRepository), oauthBrowserLauncherProvider.overrideWithValue(oauthLauncher), ], ); - addTearDown(providerContainer.dispose); - var downloadInitializations = 0; - var pushInitializations = 0; + _providerContainer = providerContainer; await app.runSparkApp( preferencesStorage: _InMemoryStorage(), @@ -49,34 +48,79 @@ void main() { return _initializeFakePushNotifications(); }, ); - await _pumpUntilVisible(tester, find.byType(RegisterPage)); - - expect(find.byType(RegisterPage), findsOneWidget); - expect(find.text('Get Started'), findsOneWidget); - - await tester.tap(find.byKey(RegisterPage.haveAccountButtonKey)); - await _pumpUntilVisible(tester, find.byType(LoginPage)); - - expect(find.byType(LoginPage), findsOneWidget); - - await tester.tap(find.byKey(LoginPage.backButtonKey)); - await _pumpUntilVisible(tester, find.byType(RegisterPage)); - await tester.pumpAndSettle(); - - final getStartedButton = find.byKey(RegisterPage.getStartedButtonKey); - await tester.ensureVisible(getStartedButton); - await tester.tap(getStartedButton); - await tester.pumpAndSettle(); - - expect(authRepository.completedCallbacks, [ - 'sprk://oauth-callback?code=isolated-code', - ]); - expect(oauthLauncher.requestedUrls, ['https://auth.example/register']); - expect(downloadInitializations, 1); - expect(pushInitializations, 1); - expect(find.text('Callback rejected by test server'), findsOneWidget); - expect(find.widgetWithText(AppButton, 'Get Started'), findsOneWidget); - }); + } + + void dispose() { + _providerContainer?.dispose(); + _providerContainer = null; + } +} + +Future pumpUntilVisible( + WidgetTester tester, + Finder finder, { + Duration timeout = const Duration(seconds: 30), +}) async { + final stopwatch = Stopwatch()..start(); + + while (finder.evaluate().isEmpty && stopwatch.elapsed < timeout) { + await tester.pump(const Duration(milliseconds: 100)); + } + + stopwatch.stop(); + expect( + finder, + findsWidgets, + reason: 'Expected $finder to appear within $timeout.', + ); +} + +class FakeOAuthBrowserLauncher implements OAuthBrowserLauncher { + final List requestedUrls = []; + final List callbackSchemes = []; + + @override + Future authenticate({ + required String url, + required String callbackUrlScheme, + }) async { + requestedUrls.add(url); + callbackSchemes.add(callbackUrlScheme); + return 'sprk://oauth-callback?code=isolated-code'; + } +} + +class FakeAuthRepository implements AuthRepository { + final List completedCallbacks = []; + + @override + Future get initializationComplete => Future.value(); + + @override + bool get isAuthenticated => false; + + @override + String? get did => null; + + @override + String? get handle => null; + + @override + PoptartClient? get atproto => null; + + @override + Future initiateOAuthWithoutLoginHint() async { + return 'https://auth.example/register'; + } + + @override + Future completeOAuth(String callbackUrl) async { + completedCallbacks.add(callbackUrl); + return LoginResult.failed('Callback rejected by test server'); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } Future _initializeFakePushNotifications() async { @@ -104,13 +148,14 @@ class _FakeDownloadManager implements DownloadManagerInterface { class _FakePushMessagingClient implements PushMessagingClient { @override - Stream get onMessage => const Stream.empty(); + Stream get onMessage => const Stream.empty(); @override - Stream get onMessageOpenedApp => const Stream.empty(); + Stream get onMessageOpenedApp => + const Stream.empty(); @override - Stream get onTokenRefresh => const Stream.empty(); + Stream get onTokenRefresh => const Stream.empty(); @override Future getInitialMessage() async => null; @@ -127,25 +172,6 @@ class _FakePushMessagingClient implements PushMessagingClient { PushAuthorizationStatus.denied; } -Future _pumpUntilVisible( - WidgetTester tester, - Finder finder, { - Duration timeout = const Duration(seconds: 30), -}) async { - final stopwatch = Stopwatch()..start(); - - while (finder.evaluate().isEmpty && stopwatch.elapsed < timeout) { - await tester.pump(const Duration(milliseconds: 100)); - } - - stopwatch.stop(); - expect( - finder, - findsWidgets, - reason: 'Expected $finder to appear within $timeout.', - ); -} - class _InMemoryStorage implements LocalStorageInterface { final Map _values = {}; @@ -217,50 +243,3 @@ class _InMemoryStorage implements LocalStorageInterface { Future setStringList(String key, List value) async => _values[key] = List.of(value); } - -class _FakeOAuthBrowserLauncher implements OAuthBrowserLauncher { - final List requestedUrls = []; - - @override - Future authenticate({ - required String url, - required String callbackUrlScheme, - }) async { - requestedUrls.add(url); - expect(callbackUrlScheme, 'sprk'); - return 'sprk://oauth-callback?code=isolated-code'; - } -} - -class _FakeAuthRepository implements AuthRepository { - final List completedCallbacks = []; - - @override - Future get initializationComplete => Future.value(); - - @override - bool get isAuthenticated => false; - - @override - String? get did => null; - - @override - String? get handle => null; - - @override - PoptartClient? get atproto => null; - - @override - Future initiateOAuthWithoutLoginHint() async { - return 'https://auth.example/register'; - } - - @override - Future completeOAuth(String callbackUrl) async { - completedCallbacks.add(callbackUrl); - return LoginResult.failed('Callback rejected by test server'); - } - - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} diff --git a/test/README.md b/test/README.md index 35e30649..b626b72d 100644 --- a/test/README.md +++ b/test/README.md @@ -55,5 +55,13 @@ flutter test --coverage dart run tool/coverage_summary.dart coverage/lcov.info --enforce-critical ``` -The app integration smoke test lives under `integration_test/` and must keep its -storage and external-service boundaries isolated from real user data. +Integration tests live under `integration_test/`, with one file per major user +journey and shared fakes under `integration_test/support/`. Run a journey on a +device with: + +```sh +flutter test integration_test/registration_test.dart -d +``` + +Every journey must keep storage and external-service boundaries isolated from +real user data. -- 2.51.2