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/app_test.dart b/app/integration_test/app_test.dart index 2dea3769d..992778bac 100644 --- a/app/integration_test/app_test.dart +++ b/app/integration_test/app_test.dart @@ -10,36 +10,36 @@ 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(); - }); - - Future pumpSharezoneApp(WidgetTester tester) async { + // 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, AppDependencies dependencies) async { await tester.pumpWidget( Sharezone( beitrittsversuche: dependencies.beitrittsversuche, @@ -51,95 +51,95 @@ void main() { ); } - group('Authentication', () { - testWidgets('User should be able to sign in', (tester) async { - await pumpSharezoneApp(tester); - await tester.pumpAndSettle(const Duration(seconds: 1)); + patrolTest('sharezone e2e test', + nativeAutomation: true, + nativeAutomatorConfig: const NativeAutomatorConfig( + packageName: 'de.codingbrain.sharezone', + bundleId: 'de.codingbrain.sharezone.app', + ), ($) async { + final dependencies = await initializeDependencies(flavor: Flavor.prod); - // 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(); - } + const user1 = _UserCredentials( + email: String.fromEnvironment('USER_1_EMAIL'), + password: String.fromEnvironment('USER_1_PASSWORD'), + // email: 'e2e-test1@sharezone.net', + // password: '^R#jH9np', + ); - 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. - }); + await $.pumpWidgetAndSettle(Sharezone( + beitrittsversuche: dependencies.beitrittsversuche, + blocDependencies: dependencies.blocDependencies, + dynamicLinkBloc: dependencies.dynamicLinkBloc, + flavor: Flavor.dev, + isIntegrationTest: true, + )); + + // 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 $(K.goToLoginButton).tap(); + } + + await $(K.emailTextField).enterText(user1.email); + await $(K.passwordTextField).enterText(user1.password); + await $(K.loginButton).tap(); + + // 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 $(K.dashboardAppBarTitle).waitUntilExists(); + expect($(K.dashboardAppBarTitle), 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 $(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 $('Meine Klasse:').waitUntilVisible(); + // await $('Meine Klasse:').waitUntilExists(); + + // We assume that the user is in at least 5 groups with the following + // group names. + expect($('10A'), findsOneWidget); + expect($('Deutsch LK'), findsOneWidget); + expect($('Englisch LK'), findsOneWidget); + expect($('Französisch LK'), findsOneWidget); + expect($('Latein LK'), findsOneWidget); + expect($('Spanisch LK'), findsOneWidget); + + log("Test: User should be able to load timetable"); + await $(K.timetableNavigationItem).tap(); + + // Ensure that the timetable is loaded. We assume that the timetable is + // loaded when we found one of the courses. + // await $('Deutsch LK').waitUntilVisible(); + 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($('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)); + + log("Test: User should be able to load information sheets"); + $(K.blackboardNavigationItem).tap(); + + // We a searching for an information sheet that is already created. + const informationSheetTitel = 'German Course Trip to Berlin'; + // TODO: Is this line still needed? + // await $(informationSheetTitel).waitUntilVisible(); + await $(informationSheetTitel).waitUntilExists(); + expect($(informationSheetTitel), findsOneWidget); }); } diff --git a/app/integration_test/test_bundle.dart b/app/integration_test/test_bundle.dart new file mode 100644 index 000000000..e718a730c --- /dev/null +++ b/app/integration_test/test_bundle.dart @@ -0,0 +1,83 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND AND DO NOT COMMIT TO VERSION CONTROL +// ignore_for_file: type=lint, invalid_use_of_internal_member + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:patrol/patrol.dart'; +import 'package:patrol/src/native/contracts/contracts.dart'; +import 'package:test_api/src/backend/invoker.dart'; + +// START: GENERATED TEST IMPORTS +import 'app_test.dart' as __app_test; +// END: GENERATED TEST IMPORTS + +Future main() async { + // This is the entrypoint of the bundled Dart test. + // + // Its responsibilies are: + // * Running a special Dart test that runs before all the other tests and + // explores the hierarchy of groups and tests. + // * Hosting a PatrolAppService, which the native side of Patrol uses to get + // the Dart tests, and to request execution of a specific Dart test. + // + // When running on Android, the Android Test Orchestrator, before running the + // tests, makes an initial run to gather the tests that it will later run. The + // native side of Patrol (specifically: PatrolJUnitRunner class) is hooked + // into the Android Test Orchestrator lifecycle and knows when that initial + // run happens. When it does, PatrolJUnitRunner makes an RPC call to + // PatrolAppService and asks it for Dart tests. + // + // When running on iOS, the native side of Patrol (specifically: the + // PATROL_INTEGRATION_TEST_IOS_RUNNER macro) makes an initial run to gather + // the tests that it will later run (same as the Android). During that initial + // run, it makes an RPC call to PatrolAppSevice and asks it for Dart tests. + // + // Once the native runner has the list of Dart tests, it dynamically creates + // native test cases from them. On Android, this is done using the + // Parametrized JUnit runner. On iOS, new test case methods are swizzled into + // the RunnerUITests class, taking advantage of the very dynamic nature of + // Objective-C runtime. + // + // Execution of these dynamically created native test cases is then fully + // managed by the underlying native test framework (JUnit on Android, XCTest + // on iOS). The native test cases do only one thing - request execution of the + // Dart test (out of which they had been created) and wait for it to complete. + // The result of running the Dart test is the result of the native test case. + + final nativeAutomator = NativeAutomator(config: NativeAutomatorConfig()); + await nativeAutomator.initialize(); + final binding = PatrolBinding.ensureInitialized(); + final testExplorationCompleter = Completer(); + + // A special test to expore the hierarchy of groups and tests. This is a hack + // around https://github.com/dart-lang/test/issues/1998. + // + // This test must be the first to run. If not, the native side likely won't + // receive any tests, and everything will fall apart. + test('patrol_test_explorer', () { + // Maybe somewhat counterintuitively, this callback runs *after* the calls + // to group() below. + final topLevelGroup = Invoker.current!.liveTest.groups.first; + final dartTestGroup = createDartTestGroup(topLevelGroup); + testExplorationCompleter.complete(dartTestGroup); + print('patrol_test_explorer: obtained Dart-side test hierarchy:'); + printGroupStructure(dartTestGroup); + }); + + // START: GENERATED TEST GROUPS + group('.app_test', __app_test.main); + // END: GENERATED TEST GROUPS + + final dartTestGroup = await testExplorationCompleter.future; + final appService = PatrolAppService(topLevelDartTestGroup: dartTestGroup); + binding.patrolAppService = appService; + await runAppService(appService); + + // Until now, the native test runner was waiting for us, the Dart side, to + // come alive. Now that we did, let's tell it that we're ready to be asked + // about Dart tests. + await nativeAutomator.markPatrolAppServiceReady(); + + await appService.testExecutionCompleted; +} 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..ce20026e2 --- /dev/null +++ b/app/lib/keys.dart @@ -0,0 +1,12 @@ +import 'package:flutter/foundation.dart'; + +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/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.yaml b/app/pubspec.yaml index 695f6eb0a..68ae9b331 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -182,6 +182,13 @@ dev_dependencies: 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 + flutter: uses-material-design: true