From 210a8b6d6797b71c77456cb9fb627105d4743e68 Mon Sep 17 00:00:00 2001 From: Jonas Sander <29028262+Jonas-Sander@users.noreply.github.com> Date: Mon, 6 Nov 2023 17:50:55 +0100 Subject: [PATCH] Use `package:patrol` for Android e2e tests. (#1137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use [`package:patrol`](https://pub.dev/packages/patrol) for integration/e2e tests for Android. The biggest benefit is that each test can run from a completely clean state, which is not possible with the `integration_test` package. Because of this we shouldn't have the issues with e.g. the auth state being kept for each test (or logging out and back in which breaks stuff). For other platforms I kept our old tests (renamed to `integration_test/integration_test_old.dart`): * Web should theoretically work as long as no native interaction is used, but I couldn't set it up practically. There is an [open issue for web ](https://github.com/leancodepl/patrol/issues/733) in their repo. * iOS should work, but I couldn't get it running. * macOS is never mentioned, so I don't know if it's possible to run tests for mac. From https://patrol.leancode.co/: > Patrol lets you [access native features of the platform](https://patrol.leancode.co/native/overview) that the Flutter app is running on. Finally, you can interact with permission dialogs, notifications, WebViews, change device settings, toggle Wi-Fi, and much more – and you can code this very easily in plain Dart. > > Patrol also provides a [new custom finder system](https://patrol.leancode.co/finders/overview) that extends Flutter's default finders, making them shorter and easier to understand. Patrol's custom finders, coupled with [Hot Restart](https://patrol.leancode.co/cli-commands/develop), make writing integration tests dramatically faster, easier and more fun! Command to running the tests: ``` patrol build android \ --flavor prod \ --dart-define USER_1_EMAIL=example@sharezone.net \ --dart-define USER_1_PASSWORD=foobar \ -t integration_test/app_test.dart ``` --- .../workflows/integration_tests_app_ci.yml | 47 ++-- app/android/app/build.gradle | 13 +- .../sharezone/MainActivityTest.java | 31 ++- app/integration_test/.gitignore | 1 + app/integration_test/README.md | 17 +- app/integration_test/app_test.dart | 203 ++++++------------ .../integration_test_old.dart | 203 ++++++++++++++++++ app/lib/auth/login_page.dart | 7 +- app/lib/dashboard/dashboard_page.dart | 3 +- app/lib/keys.dart | 21 ++ app/lib/main/run_app.dart | 20 +- .../navigation/models/navigation_item.dart | 2 + .../scaffold/app_bar_configuration.dart | 3 +- app/lib/onboarding/mobile_welcome_page.dart | 3 +- .../sign_up/pages/choose_type_of_user.dart | 3 +- app/lib/onboarding/sign_up/sign_up_page.dart | 1 + app/pubspec.lock | 16 ++ app/pubspec.yaml | 8 + 18 files changed, 409 insertions(+), 193 deletions(-) create mode 100644 app/integration_test/.gitignore create mode 100644 app/integration_test/integration_test_old.dart create mode 100644 app/lib/keys.dart diff --git a/.github/workflows/integration_tests_app_ci.yml b/.github/workflows/integration_tests_app_ci.yml index 7ea6a8333..71c528220 100644 --- a/.github/workflows/integration_tests_app_ci.yml +++ b/.github/workflows/integration_tests_app_ci.yml @@ -79,7 +79,7 @@ jobs: # (~5% of the time) build takes longer and then is a long timeout needed. defaults: run: - working-directory: app/android + working-directory: app timeout-minutes: 90 steps: - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 @@ -99,18 +99,10 @@ jobs: flutter-version: ${{ env.FLUTTER_VERSION }} channel: ${{ env.FLUTTER_CHANNEL }} - - name: Build Instrumentation Test - env: - # In gradlew, we can't just pass --dart-define as we do with "flutter - # test". We need to convert the key and the value into base64, like - # "USER_1_EMAIL=user@example.com" becomes to - # "VVNFUl8xX0VNQUlMPXVzZXJAZXhhbXBsZS5jb20=". - # - # The secrets already contain the base64 encoded values. We don't - # encode them in the workflow file, because we could easily leak the - # base64 encoded version in logs. - USER_1_EMAIL_BASE64: ${{ secrets.INTEGRATION_TEST_USER_1_EMAIL_DART_DEFINE_BASE64 }} - USER_1_PASSWORD_BASE64: ${{ secrets.INTEGRATION_TEST_USER_1_PASSWORD_DART_DEFINE_BASE64 }} + - name: Install patrol cli + run: flutter pub global activate patrol_cli ^2.2.1 + + - name: Run Flutter build run: | # Flutter build is required to generate files in android/ to build the # gradle project. @@ -119,14 +111,17 @@ jobs: # Firebase Test Lab with the dev flavor. We always got "No tests # found.". flutter build apk \ - --target=lib/main_prod.dart \ - --flavor prod \ - --config-only + --target=lib/main_prod.dart \ + --flavor prod \ + --config-only - ./gradlew app:assembleProdDebugAndroidTest - ./gradlew app:assembleProdDebug \ - -Ptarget=integration_test/app_test.dart \ - -Pdart-defines="$USER_1_EMAIL_BASE64,$USER_1_PASSWORD_BASE64" + - name: Build Instrumentation Test + run: | + patrol build android \ + --flavor prod \ + --dart-define USER_1_EMAIL=${{ secrets.INTEGRATION_TEST_USER_1_EMAIL }} \ + --dart-define USER_1_PASSWORD=${{ secrets.INTEGRATION_TEST_USER_1_PASSWORD }} \ + -t integration_test/app_test.dart - name: Setup credentials env: @@ -153,10 +148,12 @@ jobs: run: | gcloud firebase test android run \ --type instrumentation \ - --app ../build/app/outputs/apk/prod/debug/app-prod-debug.apk \ - --test ../build/app/outputs/apk/androidTest/prod/debug/app-prod-debug-androidTest.apk \ + --app build/app/outputs/apk/prod/debug/app-prod-debug.apk \ + --test build/app/outputs/apk/androidTest/prod/debug/app-prod-debug-androidTest.apk \ --device model=Pixel2,version=30,locale=en,orientation=portrait \ - --timeout 3m + --timeout 10m \ + --use-orchestrator \ + --environment-variables clearPackageData=true # It can easily happen that a dependency changed but the .lock file is not # updated. Or other cases where files are changed during a build. @@ -229,7 +226,7 @@ jobs: # https://github.com/flutter/flutter/issues/88690 fvm flutter drive \ --driver=test_driver/integration_test.dart \ - --target=integration_test/app_test.dart \ + --target=integration_test/integration_test_old.dart \ --flavor prod \ --dart-define=USER_1_EMAIL=$USER_1_EMAIL \ --dart-define=USER_1_PASSWORD=$USER_1_PASSWORD \ @@ -274,7 +271,7 @@ jobs: chromedriver --port=4444 & fvm flutter drive \ --driver=test_driver/integration_test.dart \ - --target=integration_test/app_test.dart \ + --target=integration_test/integration_test_old.dart \ --flavor dev \ --dart-define=USER_1_EMAIL=$USER_1_EMAIL \ --dart-define=USER_1_PASSWORD=$USER_1_PASSWORD \ diff --git a/app/android/app/build.gradle b/app/android/app/build.gradle index c9e7ced12..b117dd8fc 100644 --- a/app/android/app/build.gradle +++ b/app/android/app/build.gradle @@ -56,6 +56,12 @@ android { disable 'InvalidPackage' } + // From https://patrol.leancode.co/getting-started#create-a-simple-integration-test + testOptions { + execution "ANDROIDX_TEST_ORCHESTRATOR" + } + + defaultConfig { applicationId "de.codingbrain.sharezone" minSdkVersion 21 @@ -63,8 +69,9 @@ android { versionCode flutterVersionCode.toInteger() versionName flutterVersionName multiDexEnabled true - // From https://pub.dev/packages/integration_test - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + // From https://patrol.leancode.co/getting-started#create-a-simple-integration-test + testInstrumentationRunner "pl.leancode.patrol.PatrolJUnitRunner" + testInstrumentationRunnerArguments clearPackageData: "true" } signingConfigs { @@ -114,6 +121,8 @@ dependencies { testImplementation 'junit:junit:4.12' androidTestImplementation 'androidx.test:runner:1.2.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + // From https://patrol.leancode.co/getting-started#create-a-simple-integration-test + androidTestUtil "androidx.test:orchestrator:1.4.2" } apply plugin: 'com.google.firebase.firebase-perf' diff --git a/app/android/app/src/androidTest/java/de/codingbrain/sharezone/MainActivityTest.java b/app/android/app/src/androidTest/java/de/codingbrain/sharezone/MainActivityTest.java index d9f0ae8d1..bc07d2e4c 100644 --- a/app/android/app/src/androidTest/java/de/codingbrain/sharezone/MainActivityTest.java +++ b/app/android/app/src/androidTest/java/de/codingbrain/sharezone/MainActivityTest.java @@ -10,13 +10,32 @@ package de.codingbrain.sharezone; -import androidx.test.rule.ActivityTestRule; -import dev.flutter.plugins.integration_test.FlutterTestRunner; -import org.junit.Rule; +import androidx.test.platform.app.InstrumentationRegistry; +import org.junit.Test; import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; +import pl.leancode.patrol.PatrolJUnitRunner; -@RunWith(FlutterTestRunner.class) +@RunWith(Parameterized.class) public class MainActivityTest { - @Rule - public ActivityTestRule rule = new ActivityTestRule<>(MainActivity.class, true, false); + @Parameters(name = "{0}") + public static Object[] testCases() { + PatrolJUnitRunner instrumentation = (PatrolJUnitRunner) InstrumentationRegistry.getInstrumentation(); + instrumentation.setUp(MainActivity.class); + instrumentation.waitForPatrolAppService(); + return instrumentation.listDartTests(); + } + + public MainActivityTest(String dartTestName) { + this.dartTestName = dartTestName; + } + + private final String dartTestName; + + @Test + public void runDartTest() { + PatrolJUnitRunner instrumentation = (PatrolJUnitRunner) InstrumentationRegistry.getInstrumentation(); + instrumentation.runDartTest(dartTestName); + } } diff --git a/app/integration_test/.gitignore b/app/integration_test/.gitignore new file mode 100644 index 000000000..ed095c2d6 --- /dev/null +++ b/app/integration_test/.gitignore @@ -0,0 +1 @@ +test_bundle.dart \ No newline at end of file diff --git a/app/integration_test/README.md b/app/integration_test/README.md index 92f62bd6f..c7a8f50d6 100644 --- a/app/integration_test/README.md +++ b/app/integration_test/README.md @@ -2,7 +2,10 @@ Unit tests and widget tests are handy for testing individual classes, functions, or widgets. However, they generally don’t test how individual pieces work together as a whole, or capture the performance of an application running on a real device. These tasks are performed with integration tests. -Integration tests are written using the [integration_test](https://github.com/flutter/flutter/tree/master/packages/integration_test) package, provided by the SDK. +Integration tests for Android are written using the [patrol ](https://pub.dev/packages/patrol) package. + +Integration tests for all other platforms are written using the [integration_test](https://github.com/flutter/flutter/tree/master/packages/integration_test) package, provided by the SDK. + ## How to run integration tests @@ -31,7 +34,17 @@ following data: ### Mobile -You can run the integration tests using the `flutter test` command: +You can run the Android integration tests using the [`patrol_cli`](https://pub.dev/packages/patrol_cli) command: + +```sh +patrol test \ + --flavor prod \ + --dart-define USER_1_EMAIL="EMAIL" \ + --dart-define USER_1_PASSWORD="PASSWORD" \ + -t integration_test/app_test.dart +``` + +You can run the integration tests for the other platforms using the [`patrol_cli`](https://pub.dev/packages/patrol_cli) command: ```sh fvm flutter test \ diff --git a/app/integration_test/app_test.dart b/app/integration_test/app_test.dart index 2dea3769d..e18b94252 100644 --- a/app/integration_test/app_test.dart +++ b/app/integration_test/app_test.dart @@ -6,139 +6,96 @@ // // SPDX-License-Identifier: EUPL-1.2 -import 'dart:developer'; - -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; +import 'package:patrol/patrol.dart'; +import 'package:sharezone/keys.dart'; import 'package:sharezone/main/run_app.dart'; import 'package:sharezone/main/sharezone.dart'; import 'package:sharezone/util/flavor.dart'; import 'package:sharezone_utils/platform.dart'; void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - late AppDependencies dependencies; - late _UserCredentials user1; - - setUpAll(() async { - dependencies = await initializeDependencies(flavor: Flavor.prod); - }); - - setUp(() async { - // Credentials are passed via environment variables. See "README.md" how to - // pass the them correctly. - user1 = const _UserCredentials( - email: String.fromEnvironment('USER_1_EMAIL'), - password: String.fromEnvironment('USER_1_PASSWORD'), - ); - - // We should ensure that the user is logged out before running a test, to - // have fresh start. - await dependencies.blocDependencies.auth.signOut(); - }); + const config = PatrolTesterConfig( + existsTimeout: Duration(seconds: 30), + visibleTimeout: Duration(seconds: 30), + settleTimeout: Duration(seconds: 30), + ); + group('e2e tests', () { + late AppDependencies dependencies; + late _UserCredentials credentials; + + setUp(() async { + isIntegrationTest = true; + dependencies = await initializeDependencies(flavor: Flavor.prod); + + credentials = const _UserCredentials( + email: String.fromEnvironment('USER_1_EMAIL'), + password: String.fromEnvironment('USER_1_PASSWORD'), + ); + }); - Future pumpSharezoneApp(WidgetTester tester) async { - await tester.pumpWidget( - Sharezone( + Future login(PatrolIntegrationTester $) async { + await $.pumpWidgetAndSettle(Sharezone( beitrittsversuche: dependencies.beitrittsversuche, blocDependencies: dependencies.blocDependencies, dynamicLinkBloc: dependencies.dynamicLinkBloc, flavor: Flavor.dev, isIntegrationTest: true, - ), - ); - } - - group('Authentication', () { - testWidgets('User should be able to sign in', (tester) async { - await pumpSharezoneApp(tester); - await tester.pumpAndSettle(const Duration(seconds: 1)); + )); // On web and desktop we don't show the welcome page, therefore we don't // need to navigate to the login page. if (!PlatformCheck.isDesktopOrWeb) { - await tester.tap(find.byKey(const Key('go-to-login-button-E2E'))); - await tester.pumpAndSettle(); + await $(K.goToLoginButton).tap(); } - await tester.enterText( - find.byKey(const Key('email-text-field-E2E')), - user1.email, - ); - await tester.enterText( - find.byKey(const Key('password-text-field-E2E')), - user1.password, - ); - - await tester.tap(find.byKey(const Key('login-button-E2E'))); - await tester.pumpAndSettle(); - - // Ensure that the user document is loaded. Otherwise, the user might see - // for short a moment the page to select the type of user which could fail - // the test. - await tester - .pumpUntil(find.byKey(const Key('dashboard-appbar-title-E2E'))); + await $(K.emailTextField).enterText(credentials.email); + await $(K.passwordTextField).enterText(credentials.password); + await $(K.loginButton).tap(); + } - expect( - find.byKey(const Key('dashboard-appbar-title-E2E')), - findsOneWidget, - ); - - // At the moment, we can't log out properly / use the navigation when - // signing in again. This blocks to write more integration tests. As a - // workaround, we put all integration test into one test. - // - // We can remove this workaround, when the following issue are resolved: - // * https://github.com/SharezoneApp/sharezone-app/issues/497 - // * https://github.com/SharezoneApp/sharezone-app/issues/117 - - log("Test: User should be able to load groups"); - await tester.tap(find.byKey(const Key('nav-item-group-E2E'))); - await tester.pumpAndSettle(); + patrolTest('can log in', nativeAutomation: true, config: config, ($) async { + await login($); + }); + patrolTest('User should be able to load groups', + nativeAutomation: true, config: config, ($) async { + await login($); + await $(K.groupsNavigationItem).tap(); - // Ensure that the group list is loaded. When the school class is loaded, - // we assume that the courses list is loaded as well. - await tester.pumpUntil(find.text('Meine Klasse:')); + await $('Meine Klasse:').waitUntilExists(); // We assume that the user is in at least 5 groups with the following // group names. - expect(find.text('10A'), findsOneWidget); - expect(find.text('Deutsch LK'), findsOneWidget); - expect(find.text('Englisch LK'), findsOneWidget); - expect(find.text('Französisch LK'), findsOneWidget); - expect(find.text('Latein LK'), findsOneWidget); - expect(find.text('Spanisch LK'), findsOneWidget); - - log("Test: User should be able to load timetable"); - await tester.tap(find.byKey(const Key('nav-item-timetable-E2E'))); - await tester.pumpAndSettle(); - - // Ensure that the timetable is loaded. We assume that the timetable is - // loaded when we found one of the courses. - await tester.pumpUntil(find.text('Deutsch LK')); + expect($('10A'), findsOneWidget); + expect($('Deutsch LK'), findsOneWidget); + expect($('Englisch LK'), findsOneWidget); + expect($('Französisch LK'), findsOneWidget); + expect($('Latein LK'), findsOneWidget); + expect($('Spanisch LK'), findsOneWidget); + }); + patrolTest('User should be able to load timetable', + nativeAutomation: true, config: config, ($) async { + await login($); + await $(K.timetableNavigationItem).tap(); + await $('Deutsch LK').waitUntilExists(); // We assume that we can load the timetable when we found x-times the name // of the course (the name of the course is included a lesson). - expect(find.text('Deutsch LK'), findsNWidgets(6)); - expect(find.text('Englisch LK'), findsNWidgets(2)); - expect(find.text('Französisch LK'), findsNWidgets(4)); - expect(find.text('Latein LK'), findsNWidgets(4)); - expect(find.text('Spanisch LK'), findsNWidgets(4)); + expect($('Deutsch LK'), findsNWidgets(6)); + expect($('Englisch LK'), findsNWidgets(2)); + expect($('Französisch LK'), findsNWidgets(4)); + expect($('Latein LK'), findsNWidgets(4)); + expect($('Spanisch LK'), findsNWidgets(4)); + }); + patrolTest('User should be able to load information sheets', + nativeAutomation: true, config: config, ($) async { + await login($); + await $(K.blackboardNavigationItem).tap(); - log("Test: User should be able to load information sheets"); - await tester.tap(find.byKey(const Key('nav-item-blackboard-E2E'))); - await tester.pumpAndSettle(); + await $('German Course Trip to Berlin').waitUntilExists(); // We a searching for an information sheet that is already created. - const informationSheetTitel = 'German Course Trip to Berlin'; - await tester.pumpUntil(find.text(informationSheetTitel)); - expect(find.text(informationSheetTitel), findsOneWidget); - - // We don't check the text of the information sheet for now because the - // `find.text()` can't find text `MarkdownBody` which it a bit more - // complex. + expect($('German Course Trip to Berlin'), findsOneWidget); }); }); } @@ -156,43 +113,3 @@ class _UserCredentials { /// The password of the user. final String password; } - -extension on WidgetTester { - /// Waits for a widget identified by [finder] to be present in the widget - /// tree. - /// - /// This function can be useful when there is no load indicator (if there - /// were, `await tester.pumpAndSettle()` would wait until the load animation - /// was finished) and the widget tree is not yet ready to be tested. - /// - /// The function repeatedly calls `tester.pump()` and then delays for - /// a short duration, checking at each iteration if the widget identified by - /// [finder] is present in the widget tree. This continues until the widget is - /// found or the [timeout] duration has passed, whichever occurs first. - /// - /// Throws an [Exception] if the [timeout] duration passes without the widget - /// being found in the widget tree. - /// - /// Workaround for https://github.com/flutter/flutter/issues/88765. - /// - /// ## Example - /// - /// ```dart - /// await tester.pumpUntil(find.text('Hello, World')); - /// ``` - Future pumpUntil( - Finder finder, { - Duration timeout = const Duration(seconds: 70), - }) async { - final end = binding.clock.now().add(timeout); - - do { - if (binding.clock.now().isAfter(end)) { - throw Exception('Timed out waiting for $finder'); - } - - await pump(); - await Future.delayed(const Duration(milliseconds: 200)); - } while (finder.evaluate().isEmpty); - } -} diff --git a/app/integration_test/integration_test_old.dart b/app/integration_test/integration_test_old.dart new file mode 100644 index 000000000..16a825c44 --- /dev/null +++ b/app/integration_test/integration_test_old.dart @@ -0,0 +1,203 @@ +// Copyright (c) 2022 Sharezone UG (haftungsbeschränkt) +// Licensed under the EUPL-1.2-or-later. +// +// You may obtain a copy of the Licence at: +// https://joinup.ec.europa.eu/software/page/eupl +// +// SPDX-License-Identifier: EUPL-1.2 + +import 'dart:developer'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:sharezone/main/run_app.dart'; +import 'package:sharezone/main/sharezone.dart'; +import 'package:sharezone/util/flavor.dart'; +import 'package:sharezone_utils/platform.dart'; + +/// Old integration tests that we keep for testing non-Android platforms. +/// +/// We usually use `package:patrol` for our integration tests, but it we +/// couldn't set it up for iOS and web so far. Therefore, we use the old tests +/// as a fallback to at least have some integration tests for these platforms. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + late AppDependencies dependencies; + late _UserCredentials user1; + + setUpAll(() async { + dependencies = await initializeDependencies(flavor: Flavor.prod); + }); + + setUp(() async { + // Credentials are passed via environment variables. See "README.md" how to + // pass the them correctly. + user1 = const _UserCredentials( + email: String.fromEnvironment('USER_1_EMAIL'), + password: String.fromEnvironment('USER_1_PASSWORD'), + ); + + // We should ensure that the user is logged out before running a test, to + // have fresh start. + await dependencies.blocDependencies.auth.signOut(); + }); + + Future pumpSharezoneApp(WidgetTester tester) async { + await tester.pumpWidget( + Sharezone( + beitrittsversuche: dependencies.beitrittsversuche, + blocDependencies: dependencies.blocDependencies, + dynamicLinkBloc: dependencies.dynamicLinkBloc, + flavor: Flavor.dev, + isIntegrationTest: true, + ), + ); + } + + group('Authentication', () { + testWidgets('User should be able to sign in', (tester) async { + await pumpSharezoneApp(tester); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + // On web and desktop we don't show the welcome page, therefore we don't + // need to navigate to the login page. + if (!PlatformCheck.isDesktopOrWeb) { + await tester.tap(find.byKey(const Key('go-to-login-button-E2E'))); + await tester.pumpAndSettle(); + } + + await tester.enterText( + find.byKey(const Key('email-text-field-E2E')), + user1.email, + ); + await tester.enterText( + find.byKey(const Key('password-text-field-E2E')), + user1.password, + ); + + await tester.tap(find.byKey(const Key('login-button-E2E'))); + await tester.pumpAndSettle(); + + // Ensure that the user document is loaded. Otherwise, the user might see + // for short a moment the page to select the type of user which could fail + // the test. + await tester + .pumpUntil(find.byKey(const Key('dashboard-appbar-title-E2E'))); + + expect( + find.byKey(const Key('dashboard-appbar-title-E2E')), + findsOneWidget, + ); + + // At the moment, we can't log out properly / use the navigation when + // signing in again. This blocks to write more integration tests. As a + // workaround, we put all integration test into one test. + // + // We can remove this workaround, when the following issue are resolved: + // * https://github.com/SharezoneApp/sharezone-app/issues/497 + // * https://github.com/SharezoneApp/sharezone-app/issues/117 + + log("Test: User should be able to load groups"); + await tester.tap(find.byKey(const Key('nav-item-group-E2E'))); + await tester.pumpAndSettle(); + + // Ensure that the group list is loaded. When the school class is loaded, + // we assume that the courses list is loaded as well. + await tester.pumpUntil(find.text('Meine Klasse:')); + + // We assume that the user is in at least 5 groups with the following + // group names. + expect(find.text('10A'), findsOneWidget); + expect(find.text('Deutsch LK'), findsOneWidget); + expect(find.text('Englisch LK'), findsOneWidget); + expect(find.text('Französisch LK'), findsOneWidget); + expect(find.text('Latein LK'), findsOneWidget); + expect(find.text('Spanisch LK'), findsOneWidget); + + log("Test: User should be able to load timetable"); + await tester.tap(find.byKey(const Key('nav-item-timetable-E2E'))); + await tester.pumpAndSettle(); + + // Ensure that the timetable is loaded. We assume that the timetable is + // loaded when we found one of the courses. + await tester.pumpUntil(find.text('Deutsch LK')); + + // We assume that we can load the timetable when we found x-times the name + // of the course (the name of the course is included a lesson). + expect(find.text('Deutsch LK'), findsNWidgets(6)); + expect(find.text('Englisch LK'), findsNWidgets(2)); + expect(find.text('Französisch LK'), findsNWidgets(4)); + expect(find.text('Latein LK'), findsNWidgets(4)); + expect(find.text('Spanisch LK'), findsNWidgets(4)); + + log("Test: User should be able to load information sheets"); + await tester.tap(find.byKey(const Key('nav-item-blackboard-E2E'))); + await tester.pumpAndSettle(); + + // We a searching for an information sheet that is already created. + const informationSheetTitel = 'German Course Trip to Berlin'; + await tester.pumpUntil(find.text(informationSheetTitel)); + expect(find.text(informationSheetTitel), findsOneWidget); + + // We don't check the text of the information sheet for now because the + // `find.text()` can't find text `MarkdownBody` which it a bit more + // complex. + }); + }); +} + +/// The credentials for user used in the integration tests. +class _UserCredentials { + const _UserCredentials({ + required this.email, + required this.password, + }); + + /// The email address of the user. + final String email; + + /// The password of the user. + final String password; +} + +extension on WidgetTester { + /// Waits for a widget identified by [finder] to be present in the widget + /// tree. + /// + /// This function can be useful when there is no load indicator (if there + /// were, `await tester.pumpAndSettle()` would wait until the load animation + /// was finished) and the widget tree is not yet ready to be tested. + /// + /// The function repeatedly calls `tester.pump()` and then delays for + /// a short duration, checking at each iteration if the widget identified by + /// [finder] is present in the widget tree. This continues until the widget is + /// found or the [timeout] duration has passed, whichever occurs first. + /// + /// Throws an [Exception] if the [timeout] duration passes without the widget + /// being found in the widget tree. + /// + /// Workaround for https://github.com/flutter/flutter/issues/88765. + /// + /// ## Example + /// + /// ```dart + /// await tester.pumpUntil(find.text('Hello, World')); + /// ``` + Future pumpUntil( + Finder finder, { + Duration timeout = const Duration(seconds: 70), + }) async { + final end = binding.clock.now().add(timeout); + + do { + if (binding.clock.now().isAfter(end)) { + throw Exception('Timed out waiting for $finder'); + } + + await pump(); + await Future.delayed(const Duration(milliseconds: 200)); + } while (finder.evaluate().isEmpty); + } +} diff --git a/app/lib/auth/login_page.dart b/app/lib/auth/login_page.dart index 2ce94bf6c..85bd5f30c 100644 --- a/app/lib/auth/login_page.dart +++ b/app/lib/auth/login_page.dart @@ -17,6 +17,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:sharezone/download_app_tip/widgets/download_app_tip_card.dart'; import 'package:sharezone/groups/src/widgets/contact_support.dart'; +import 'package:sharezone/keys.dart'; import 'package:sharezone/onboarding/sign_up/sign_up_page.dart'; import 'package:sharezone/util/flavor.dart'; import 'package:sharezone_common/api_errors.dart'; @@ -140,7 +141,7 @@ class _LoginPageState extends State { : ContinueRoundButton( tooltip: 'Einloggen', onTap: () => handleLoginSubmit(context), - key: const ValueKey('login-button-E2E'), + key: K.loginButton, ), ), ], @@ -393,7 +394,7 @@ class EmailLoginField extends StatelessWidget { stream: emailStream, builder: (context, snapshot) { return TextField( - key: const ValueKey('email-text-field-E2E'), + key: K.emailTextField, focusNode: emailFocusNode, onChanged: (email) => onChanged(email.trim()), onEditingComplete: () => @@ -447,7 +448,7 @@ class _PasswordFieldState extends State { label: 'Passwortfeld', enabled: true, child: TextField( - key: const ValueKey('password-text-field-E2E'), + key: K.passwordTextField, focusNode: widget.focusNode, onChanged: widget.onChanged, onEditingComplete: widget.onEditingComplete, diff --git a/app/lib/dashboard/dashboard_page.dart b/app/lib/dashboard/dashboard_page.dart index 3a148df60..2dea8cab5 100644 --- a/app/lib/dashboard/dashboard_page.dart +++ b/app/lib/dashboard/dashboard_page.dart @@ -19,6 +19,7 @@ import 'package:holidays/holidays.dart' hide State; import 'package:shared_preferences/shared_preferences.dart'; import 'package:sharezone/blackboard/blackboard_page.dart'; import 'package:sharezone/blackboard/blackboard_view.dart'; +import 'package:sharezone/keys.dart'; import 'package:sharezone/main/application_bloc.dart'; import 'package:sharezone/holidays/holiday_bloc.dart'; import 'package:sharezone/dashboard/analytics/dashboard_analytics.dart'; @@ -166,7 +167,7 @@ class _AppBarTitle extends StatelessWidget { NavigationItem.overview.getName(), style: Theme.of(context).textTheme.titleLarge!.copyWith(color: Colors.white), - key: const ValueKey('dashboard-appbar-title-E2E'), + key: K.dashboardAppBarTitle, ); } } diff --git a/app/lib/keys.dart b/app/lib/keys.dart new file mode 100644 index 000000000..56fe80e0c --- /dev/null +++ b/app/lib/keys.dart @@ -0,0 +1,21 @@ +// Copyright (c) 2023 Sharezone UG (haftungsbeschränkt) +// Licensed under the EUPL-1.2-or-later. +// +// You may obtain a copy of the Licence at: +// https://joinup.ec.europa.eu/software/page/eupl +// +// SPDX-License-Identifier: EUPL-1.2 + +import 'package:flutter/foundation.dart'; + +/// Widget keys, used for integration tests. +class K { + static const goToLoginButton = Key('go-to-login-button-E2E'); + static const emailTextField = Key('email-text-field-E2E'); + static const passwordTextField = Key('password-text-field-E2E'); + static const loginButton = Key('login-button-E2E'); + static const dashboardAppBarTitle = Key('dashboard-appbar-title-E2E'); + static const groupsNavigationItem = Key('nav-item-groups-E2E'); + static const timetableNavigationItem = Key('nav-item-timetable-E2E'); + static const blackboardNavigationItem = Key('nav-item-blackboard-E2E'); +} diff --git a/app/lib/main/run_app.dart b/app/lib/main/run_app.dart index 0a1bf407b..1edaae544 100644 --- a/app/lib/main/run_app.dart +++ b/app/lib/main/run_app.dart @@ -109,14 +109,18 @@ Future initializeDependencies({ functions: firebaseFunctions, ); - // From: - // https://firebase.google.com/docs/crashlytics/get-started?platform=flutter#configure-crash-handlers - FlutterError.onError = - pluginInitializations.crashAnalytics.recordFlutterError; - PlatformDispatcher.instance.onError = (error, stack) { - pluginInitializations.crashAnalytics.recordError(error, stack); - return true; - }; + // `package:patrol` (e2e/integration tests) breaks when overriding onError, so + // we disable the override if we are running these tests. + if (!isIntegrationTest) { + // From: + // https://firebase.google.com/docs/crashlytics/get-started?platform=flutter#configure-crash-handlers + FlutterError.onError = + pluginInitializations.crashAnalytics.recordFlutterError; + PlatformDispatcher.instance.onError = (error, stack) { + pluginInitializations.crashAnalytics.recordError(error, stack); + return true; + }; + } final dynamicLinkBloc = runDynamicLinkBloc(pluginInitializations); diff --git a/app/lib/navigation/models/navigation_item.dart b/app/lib/navigation/models/navigation_item.dart index 33742b4e5..6079f8de5 100644 --- a/app/lib/navigation/models/navigation_item.dart +++ b/app/lib/navigation/models/navigation_item.dart @@ -16,6 +16,7 @@ import 'package:sharezone/feedback/feedback_box_page.dart'; import 'package:sharezone/filesharing/file_sharing_page.dart'; import 'package:sharezone/groups/src/pages/course/group_page.dart'; import 'package:sharezone/homework/parent/homework_page.dart'; +import 'package:sharezone/keys.dart'; import 'package:sharezone/settings/settings_page.dart'; import 'package:sharezone/sharezone_plus/page/sharezone_plus_page.dart'; import 'package:sharezone/timetable/timetable_page/timetable_page.dart'; @@ -88,6 +89,7 @@ extension NavigationItemExtension on NavigationItem { cupertinoIcon: SFSymbols.checkmark_square_fill)); case NavigationItem.group: return Icon( + key: K.groupsNavigationItem, themeIconData(Icons.group, cupertinoIcon: SFSymbols.person_2_fill)); case NavigationItem.timetable: return Icon( diff --git a/app/lib/navigation/scaffold/app_bar_configuration.dart b/app/lib/navigation/scaffold/app_bar_configuration.dart index 61386f3e6..1bcb7d8c6 100644 --- a/app/lib/navigation/scaffold/app_bar_configuration.dart +++ b/app/lib/navigation/scaffold/app_bar_configuration.dart @@ -7,6 +7,7 @@ // SPDX-License-Identifier: EUPL-1.2 import 'package:flutter/material.dart'; +import 'package:sharezone/keys.dart'; import 'package:sharezone/navigation/models/navigation_item.dart'; class AppBarConfiguration { @@ -58,7 +59,7 @@ class _AppBarTitle extends StatelessWidget { NavigationItem.overview.getName(), style: Theme.of(context).textTheme.titleLarge?.copyWith(color: Colors.white), - key: const ValueKey('dashboard-appbar-title-E2E'), + key: K.dashboardAppBarTitle, ); } } diff --git a/app/lib/onboarding/mobile_welcome_page.dart b/app/lib/onboarding/mobile_welcome_page.dart index 4f32f15c8..f9ec14311 100644 --- a/app/lib/onboarding/mobile_welcome_page.dart +++ b/app/lib/onboarding/mobile_welcome_page.dart @@ -9,6 +9,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import 'package:sharezone/auth/login_page.dart'; +import 'package:sharezone/keys.dart'; import 'package:sharezone/onboarding/sign_up/sign_up_page.dart'; import 'package:sharezone_widgets/sharezone_widgets.dart'; @@ -186,7 +187,7 @@ class _AlreadyHaveAnAccountButton extends StatelessWidget { @override Widget build(BuildContext context) { return _BaseButton( - key: const Key('go-to-login-button-E2E'), + key: K.goToLoginButton, text: const Column( children: [ Text( diff --git a/app/lib/onboarding/sign_up/pages/choose_type_of_user.dart b/app/lib/onboarding/sign_up/pages/choose_type_of_user.dart index b916b8fe8..34d5b7805 100644 --- a/app/lib/onboarding/sign_up/pages/choose_type_of_user.dart +++ b/app/lib/onboarding/sign_up/pages/choose_type_of_user.dart @@ -42,7 +42,8 @@ class ChooseTypeOfUser extends StatelessWidget { if (withLogin) ...[ const Divider(height: 46), const _LoginButton( - key: ValueKey('go-to-login-button-E2E')), + key: K.goToLoginButton, + ), ] ], ), diff --git a/app/lib/onboarding/sign_up/sign_up_page.dart b/app/lib/onboarding/sign_up/sign_up_page.dart index fd12bcb6a..12b980c6c 100644 --- a/app/lib/onboarding/sign_up/sign_up_page.dart +++ b/app/lib/onboarding/sign_up/sign_up_page.dart @@ -11,6 +11,7 @@ import 'package:flare_flutter/flare_actor.dart'; import 'package:flutter/material.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import 'package:sharezone/auth/login_page.dart'; +import 'package:sharezone/keys.dart'; import 'package:sharezone/onboarding/bloc/registration_bloc.dart'; import 'package:sharezone/onboarding/group_onboarding/widgets/bottom_bar_button.dart'; import 'package:sharezone/privacy_policy/privacy_policy_page.dart'; diff --git a/app/pubspec.lock b/app/pubspec.lock index fd73b7ec7..6c5944b07 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -1555,6 +1555,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.1" + patrol: + dependency: "direct dev" + description: + name: patrol + sha256: "53e3e6380f96c41e898578d033cc521ad06f97a0f0cf46f44c19023094566a22" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + patrol_finders: + dependency: transitive + description: + name: patrol_finders + sha256: "9de6b2c873843db6811c3def4a2d9f023968b1428a9fdc9127d5f089015df569" + url: "https://pub.dev" + source: hosted + version: "1.0.0" pdfx: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index e14e80274..eeae73300 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -181,6 +181,14 @@ dev_dependencies: bloc_test: ^9.1.4 bloc_presentation_test: ^1.0.0 path: ^1.8.3 + patrol: ^2.3.1 + +patrol: + app_name: Sharezone App + android: + package_name: de.codingbrain.sharezone + ios: + bundle_id: de.codingbrain.sharezone.app flutter: uses-material-design: true