From 25437ba92e36874aa2dca28263e6f731b2368ad8 Mon Sep 17 00:00:00 2001 From: DatDang Date: Fri, 11 Oct 2024 11:58:09 +0700 Subject: [PATCH] TF-2953 Set up Patrol tests --- .gitignore | 1 + android/app/build.gradle | 13 +++- .../android/tmail/MainActivityTest.java | 33 ++++++++++ docs/adr/0052-patrol-integration-test.md | 36 +++++++++++ integration_test/base/base_scenario.dart | 9 +++ integration_test/base/core_robot.dart | 15 +++++ integration_test/base/test_base.dart | 31 ++++++++++ integration_test/robots/composer_robot.dart | 62 +++++++++++++++++++ integration_test/robots/login_robot.dart | 43 +++++++++++++ integration_test/robots/thread_robot.dart | 18 ++++++ .../login_with_basic_auth_scenario.dart | 39 ++++++++++++ .../scenarios/send_email_scenario.dart | 41 ++++++++++++ .../tests/compose/send_email_test.dart | 24 +++++++ .../login/login_with_basic_auth_test.dart | 18 ++++++ .../utils/scenario_utils_mixin.dart | 9 +++ lib/main.dart | 32 +++++----- pubspec.lock | 22 ++++++- pubspec.yaml | 9 +++ 18 files changed, 437 insertions(+), 18 deletions(-) create mode 100644 android/app/src/androidTest/java/com/linagora/android/tmail/MainActivityTest.java create mode 100644 docs/adr/0052-patrol-integration-test.md create mode 100644 integration_test/base/base_scenario.dart create mode 100644 integration_test/base/core_robot.dart create mode 100644 integration_test/base/test_base.dart create mode 100644 integration_test/robots/composer_robot.dart create mode 100644 integration_test/robots/login_robot.dart create mode 100644 integration_test/robots/thread_robot.dart create mode 100644 integration_test/scenarios/login_with_basic_auth_scenario.dart create mode 100644 integration_test/scenarios/send_email_scenario.dart create mode 100644 integration_test/tests/compose/send_email_test.dart create mode 100644 integration_test/tests/login/login_with_basic_auth_test.dart create mode 100644 integration_test/utils/scenario_utils_mixin.dart diff --git a/.gitignore b/.gitignore index 77a53b29b..f5d16ab74 100644 --- a/.gitignore +++ b/.gitignore @@ -120,3 +120,4 @@ app.*.symbols *.g.dart messages_*.dart *.mocks.dart +integration_test/test_bundle.dart \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index a9c72ee42..2ac5b63a0 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -23,7 +23,7 @@ if (flutterVersionName == null) { def flutterMinSdkVersion = localProperties.getProperty('flutter.minSdkVersion') if (flutterMinSdkVersion == null) { - flutterMinSdkVersion = '19' + flutterMinSdkVersion = '21' } apply plugin: 'com.android.application' @@ -54,6 +54,8 @@ android { manifestPlaceholders = [ 'appAuthRedirectScheme': 'teammail.mobile' ] + testInstrumentationRunner "pl.leancode.patrol.PatrolJUnitRunner" + testInstrumentationRunnerArguments clearPackageData: "true" } compileOptions { @@ -77,7 +79,15 @@ android { // Signing with the debug keys for now, so `flutter run --release` works. signingConfig signingConfigs.release } + debug { + minifyEnabled false + } } + + testOptions { + execution "ANDROIDX_TEST_ORCHESTRATOR" + } + } flutter { @@ -90,4 +100,5 @@ dependencies { implementation 'androidx.work:work-runtime-ktx:2.7.0' implementation 'com.android.support:multidex:1.0.3' implementation 'androidx.window:window:1.0.0' + androidTestUtil "androidx.test:orchestrator:1.5.0" } diff --git a/android/app/src/androidTest/java/com/linagora/android/tmail/MainActivityTest.java b/android/app/src/androidTest/java/com/linagora/android/tmail/MainActivityTest.java new file mode 100644 index 000000000..678f6825a --- /dev/null +++ b/android/app/src/androidTest/java/com/linagora/android/tmail/MainActivityTest.java @@ -0,0 +1,33 @@ +package com.linagora.android.tmail; + +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(Parameterized.class) +public class MainActivityTest { + @Parameters(name = "{0}") + public static Object[] testCases() { + PatrolJUnitRunner instrumentation = (PatrolJUnitRunner) InstrumentationRegistry.getInstrumentation(); + // replace "MainActivity.class" with "io.flutter.embedding.android.FlutterActivity.class" + // if your AndroidManifest is using: android:name="io.flutter.embedding.android.FlutterActivity" + 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/docs/adr/0052-patrol-integration-test.md b/docs/adr/0052-patrol-integration-test.md new file mode 100644 index 000000000..72dc927ae --- /dev/null +++ b/docs/adr/0052-patrol-integration-test.md @@ -0,0 +1,36 @@ +# 52. Patrol integration test + +Date: 2024-04-10 + +## Status + +Accepted + +## Context + +- A need for integration testing for Twake Mail mobile arised. +- The testing tool must be able to handle native UI and webview. + +## Decision + +- Patrol was chosen to write and test Twake Mail. + +## Consequences + +- Developers are now able to integration test Twake Mail +- Set up: + - Run `dart pub global activate patrol_cli` to enable Patrol CLI + - Install ngrok and jq + - Open docker and Android emulator/connect Android device + - Remember to use `dart-define` neccessary for each test command + - Test individual test locally by edit `scripts/patrol-local-integration-test-with-docker.sh` + - Replace `patrol test -v` with `patrol test -v -t path/to/test/file` + - Run the `scripts/patrol-local-integration-test-with-docker.sh` + - Test every tests locally by running `scripts/patrol-local-integration-test-with-docker.sh` script + - Read more about Patrol in [Patrol homepage](https://patrol.leancode.co/) + +## Limitations + +- Backend docker container is initiated before Patrol tests run, and close after all tests have run. This lead to no data isolation between tests +- Patrol gives no way of accessing Docker from system, due to when it runs, it bundle all tests into a single apk, and apk cannot access the system's terminal +- Tried https://github.com/testcontainers/testcontainers-java but also failed, with the same reason as Patrol. diff --git a/integration_test/base/base_scenario.dart b/integration_test/base/base_scenario.dart new file mode 100644 index 000000000..a9445902e --- /dev/null +++ b/integration_test/base/base_scenario.dart @@ -0,0 +1,9 @@ +import 'package:patrol/patrol.dart'; + +abstract class BaseScenario { + final PatrolIntegrationTester $; + + const BaseScenario(this.$); + + Future execute(); +} \ No newline at end of file diff --git a/integration_test/base/core_robot.dart b/integration_test/base/core_robot.dart new file mode 100644 index 000000000..a116b2553 --- /dev/null +++ b/integration_test/base/core_robot.dart @@ -0,0 +1,15 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:patrol/patrol.dart'; + +abstract class CoreRobot { + final PatrolIntegrationTester $; + + CoreRobot(this.$); + + Future ensureViewVisible(PatrolFinder patrolFinder) async { + await $.waitUntilVisible(patrolFinder); + expect(patrolFinder, findsWidgets); + } + + dynamic ignoreException() => $.tester.takeException(); +} \ No newline at end of file diff --git a/integration_test/base/test_base.dart b/integration_test/base/test_base.dart new file mode 100644 index 000000000..3318e6814 --- /dev/null +++ b/integration_test/base/test_base.dart @@ -0,0 +1,31 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patrol/patrol.dart'; +import 'package:tmail_ui_user/main.dart' as app; + +class TestBase { + void runPatrolTest({ + required String description, + required Function(PatrolIntegrationTester $) test, + }) { + patrolTest( + description, + config: const PatrolTesterConfig( + settlePolicy: SettlePolicy.trySettle, + visibleTimeout: Duration(minutes: 1)), + nativeAutomatorConfig: const NativeAutomatorConfig( + findTimeout: Duration(seconds: 10), + ), + framePolicy: LiveTestWidgetsFlutterBindingFramePolicy.benchmarkLive, + ($) async { + await app.runTmail(); + // https://github.com/leancodepl/patrol/issues/1602#issuecomment-1665317814 + final originalOnError = FlutterError.onError!; + FlutterError.onError = (FlutterErrorDetails details) { + originalOnError(details); + }; + + await test($); + }); + } +} \ No newline at end of file diff --git a/integration_test/robots/composer_robot.dart b/integration_test/robots/composer_robot.dart new file mode 100644 index 000000000..e8cd076a7 --- /dev/null +++ b/integration_test/robots/composer_robot.dart @@ -0,0 +1,62 @@ +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:core/presentation/views/button/tmail_button_widget.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:model/email/prefix_email_address.dart'; +import 'package:rich_text_composer/rich_text_composer.dart'; +import 'package:tmail_ui_user/features/composer/presentation/composer_controller.dart'; +import 'package:tmail_ui_user/features/composer/presentation/composer_view.dart'; +import 'package:tmail_ui_user/features/composer/presentation/view/mobile/mobile_editor_view.dart'; +import 'package:tmail_ui_user/features/composer/presentation/widgets/mobile/app_bar_composer_widget.dart'; +import 'package:tmail_ui_user/features/composer/presentation/widgets/recipient_composer_widget.dart'; +import 'package:tmail_ui_user/features/composer/presentation/widgets/recipient_suggestion_item_widget.dart'; +import 'package:tmail_ui_user/features/composer/presentation/widgets/subject_composer_widget.dart'; + +import '../base/core_robot.dart'; + +class ComposerRobot extends CoreRobot { + ComposerRobot(super.$); + + Future addRecipient(String email) async { + await $(RecipientComposerWidget) + .which((widget) => widget.prefix == PrefixEmailAddress.to) + .enterText(email); + await $(RecipientSuggestionItemWidget) + .which((widget) => widget.emailAddress.email?.contains(email) ?? false) + .tap(); + } + + Future addSubject(String subject) async { + await $(SubjectComposerWidget).enterText(subject); + } + + Future addContent(String content) async { + ComposerController? composerController; + await $(ComposerView) + .which((widget) { + composerController = widget.controller; + return true; + }) + .$(MobileEditorView).$(HtmlEditor).$(InAppWebView).tap(); + + await composerController?.htmlEditorApi?.requestFocusLastChild(); + + await composerController!.htmlEditorApi!.insertHtml('$content

'); + } + + Future sendEmail() async { + await $(AppBarComposerWidget) + .$(TMailButtonWidget) + .which((widget) => widget.icon == ImagePaths().icSendMobile) + .tap(); + } + + Future expectSendEmailSuccessToast() async { + expect($('Message has been sent successfully'), findsOneWidget); + } + + Future grantContactPermission() async { + if (await $.native.isPermissionDialogVisible(timeout: const Duration(seconds: 5))) { + await $.native.grantPermissionWhenInUse(); + } + } +} \ No newline at end of file diff --git a/integration_test/robots/login_robot.dart b/integration_test/robots/login_robot.dart new file mode 100644 index 000000000..b079396fe --- /dev/null +++ b/integration_test/robots/login_robot.dart @@ -0,0 +1,43 @@ +import 'package:core/presentation/views/text/type_ahead_form_field_builder.dart'; +import 'package:flutter/material.dart'; +import 'package:tmail_ui_user/features/login/domain/model/recent_login_username.dart'; +import 'package:tmail_ui_user/features/login/presentation/login_view.dart'; +import 'package:tmail_ui_user/features/login/presentation/widgets/login_text_input_builder.dart'; + +import '../base/core_robot.dart'; + +class LoginRobot extends CoreRobot { + LoginRobot(super.$); + + Future expectLoginViewVisible() => ensureViewVisible($(LoginView)); + + Future enterEmail(String email) async { + final finder = $(LoginView).$(TextField); + await finder.enterText(email); + await $('Next').tap(); + } + + Future enterHostUrl(String url) async { + final finder = $(LoginView).$(TextField); + await finder.enterText(url); + await $('Next').tap(); + } + + Future enterBasicAuthEmail(String email) async { + await $(LoginView) + .$(TypeAheadFormFieldBuilder) + .$(TextField) + .enterText(email); + } + + Future enterBasicAuthPassword(String password) async { + await $(LoginView) + .$(LoginTextInputBuilder) + .$(TextField) + .enterText(password); + } + + Future loginBasicAuth() async { + await $(Container).$(ElevatedButton).tap(); + } +} \ No newline at end of file diff --git a/integration_test/robots/thread_robot.dart b/integration_test/robots/thread_robot.dart new file mode 100644 index 000000000..d2dd8ee3c --- /dev/null +++ b/integration_test/robots/thread_robot.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; +import 'package:tmail_ui_user/features/base/widget/compose_floating_button.dart'; +import 'package:tmail_ui_user/features/composer/presentation/composer_view.dart'; +import 'package:tmail_ui_user/features/thread/presentation/thread_view.dart'; + +import '../base/core_robot.dart'; + +class ThreadRobot extends CoreRobot { + ThreadRobot(super.$); + + Future expectThreadViewVisible() => ensureViewVisible($(ThreadView)); + + Future openComposer() async { + await $(ComposeFloatingButton).$(InkWell).tap(); + } + + Future expectComposerViewVisible() => ensureViewVisible($(ComposerView)); +} \ No newline at end of file diff --git a/integration_test/scenarios/login_with_basic_auth_scenario.dart b/integration_test/scenarios/login_with_basic_auth_scenario.dart new file mode 100644 index 000000000..f83ebd081 --- /dev/null +++ b/integration_test/scenarios/login_with_basic_auth_scenario.dart @@ -0,0 +1,39 @@ +import '../base/base_scenario.dart'; +import '../robots/login_robot.dart'; +import '../robots/thread_robot.dart'; +import '../utils/scenario_utils_mixin.dart'; + +class LoginWithBasicAuthScenario extends BaseScenario with ScenarioUtilsMixin { + const LoginWithBasicAuthScenario( + super.$, + { + required this.username, + required this.hostUrl, + required this.email, + required this.password, + } + ); + + final String username; + final String hostUrl; + final String email; + final String password; + + @override + Future execute() async { + final loginRobot = LoginRobot($); + final threadRobot = ThreadRobot($); + + await loginRobot.expectLoginViewVisible(); + await loginRobot.enterEmail(username); + await loginRobot.enterHostUrl(hostUrl); + + await loginRobot.enterBasicAuthEmail(email); + await loginRobot.enterBasicAuthPassword(password); + await loginRobot.loginBasicAuth(); + + await grantNotificationPermission($.native); + + await threadRobot.expectThreadViewVisible(); + } +} \ No newline at end of file diff --git a/integration_test/scenarios/send_email_scenario.dart b/integration_test/scenarios/send_email_scenario.dart new file mode 100644 index 000000000..8d9184fa8 --- /dev/null +++ b/integration_test/scenarios/send_email_scenario.dart @@ -0,0 +1,41 @@ +import '../base/base_scenario.dart'; +import '../robots/composer_robot.dart'; +import '../robots/thread_robot.dart'; +import 'login_with_basic_auth_scenario.dart'; + +class SendEmailScenario extends BaseScenario { + const SendEmailScenario( + super.$, + { + required this.loginWithBasicAuthScenario, + required this.additionalRecipient, + required this.subject, + required this.content + } + ); + + final LoginWithBasicAuthScenario loginWithBasicAuthScenario; + final String additionalRecipient; + final String subject; + final String content; + + @override + Future execute() async { + final threadRobot = ThreadRobot($); + final composerRobot = ComposerRobot($); + + await loginWithBasicAuthScenario.execute(); + + await threadRobot.openComposer(); + await threadRobot.expectComposerViewVisible(); + + await composerRobot.grantContactPermission(); + + await composerRobot.addRecipient(loginWithBasicAuthScenario.email); + await composerRobot.addRecipient(additionalRecipient); + await composerRobot.addSubject(subject); + await composerRobot.addContent(content); + await composerRobot.sendEmail(); + await composerRobot.expectSendEmailSuccessToast(); + } +} \ No newline at end of file diff --git a/integration_test/tests/compose/send_email_test.dart b/integration_test/tests/compose/send_email_test.dart new file mode 100644 index 000000000..7ff763c7b --- /dev/null +++ b/integration_test/tests/compose/send_email_test.dart @@ -0,0 +1,24 @@ +import '../../base/test_base.dart'; +import '../../scenarios/login_with_basic_auth_scenario.dart'; +import '../../scenarios/send_email_scenario.dart'; + +void main() { + TestBase().runPatrolTest( + description: 'Should see success toast when send email successfully', + test: ($) async { + final loginWithBasicAuthScenario = LoginWithBasicAuthScenario($, + username: const String.fromEnvironment('USERNAME'), + hostUrl: const String.fromEnvironment('BASIC_AUTH_URL'), + email: const String.fromEnvironment('BASIC_AUTH_EMAIL'), + password: const String.fromEnvironment('PASSWORD'), + ); + final sendEmailScenario = SendEmailScenario($, + loginWithBasicAuthScenario: loginWithBasicAuthScenario, + additionalRecipient: const String.fromEnvironment('ADDITIONAL_MAIL_RECIPIENT'), + subject: 'Test subject', + content: 'Test content'); + + await sendEmailScenario.execute(); + }, + ); +} \ No newline at end of file diff --git a/integration_test/tests/login/login_with_basic_auth_test.dart b/integration_test/tests/login/login_with_basic_auth_test.dart new file mode 100644 index 000000000..12ae045de --- /dev/null +++ b/integration_test/tests/login/login_with_basic_auth_test.dart @@ -0,0 +1,18 @@ +import '../../base/test_base.dart'; +import '../../scenarios/login_with_basic_auth_scenario.dart'; + +void main() { + TestBase().runPatrolTest( + description: 'Should see thread view when login with basic auth successfully', + test: ($) async { + final loginWithBasicAuthScenario = LoginWithBasicAuthScenario($, + username: const String.fromEnvironment('USERNAME'), + hostUrl: const String.fromEnvironment('BASIC_AUTH_URL'), + email: const String.fromEnvironment('BASIC_AUTH_EMAIL'), + password: const String.fromEnvironment('PASSWORD'), + ); + + await loginWithBasicAuthScenario.execute(); + } + ); +} \ No newline at end of file diff --git a/integration_test/utils/scenario_utils_mixin.dart b/integration_test/utils/scenario_utils_mixin.dart new file mode 100644 index 000000000..2b119c510 --- /dev/null +++ b/integration_test/utils/scenario_utils_mixin.dart @@ -0,0 +1,9 @@ +import 'package:patrol/patrol.dart'; + +mixin ScenarioUtilsMixin { + Future grantNotificationPermission(NativeAutomator nativeAutomator) async { + if (await nativeAutomator.isPermissionDialogVisible(timeout: const Duration(seconds: 5))) { + await nativeAutomator.grantPermissionWhenInUse(); + } + } +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 86435990a..9ba0af392 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,25 +15,29 @@ import 'package:tmail_ui_user/main/utils/app_utils.dart'; import 'package:url_strategy/url_strategy.dart'; import 'package:worker_manager/worker_manager.dart'; -void main() async { +Future main() async { initLogger(() async { WidgetsFlutterBinding.ensureInitialized(); - ThemeUtils.setSystemLightUIStyle(); - - await Future.wait([ - MainBindings().dependencies(), - HiveCacheConfig.instance.setUp(), - Executor().warmUp(log: BuildUtils.isDebugMode), - AppUtils.loadEnvFile() - ]); - await HiveCacheConfig.instance.initializeEncryptionKey(); - - setPathUrlStrategy(); - - runApp(const TMailApp()); + await runTmail(); }); } +Future runTmail() async { + ThemeUtils.setSystemLightUIStyle(); + + await Future.wait([ + MainBindings().dependencies(), + HiveCacheConfig.instance.setUp(), + Executor().warmUp(log: BuildUtils.isDebugMode), + AppUtils.loadEnvFile() + ]); + await HiveCacheConfig.instance.initializeEncryptionKey(); + + setPathUrlStrategy(); + + runApp(const TMailApp()); +} + class TMailApp extends StatelessWidget { const TMailApp({Key? key}) : super(key: key); diff --git a/pubspec.lock b/pubspec.lock index 7cdc98a58..bde3028da 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1252,10 +1252,10 @@ packages: dependency: "direct main" description: name: json_annotation - sha256: c33da08e136c3df0190bd5bbe51ae1df4a7d96e7954d1d7249fea2968a72d317 + sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467 url: "https://pub.dev" source: hosted - version: "4.8.0" + version: "4.8.1" json_serializable: dependency: "direct dev" description: @@ -1480,6 +1480,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.1" + patrol: + dependency: "direct dev" + description: + name: patrol + sha256: ef07b0022f6eabee77655a3cde2364ff57cf22c29018d524476e972a5476724f + url: "https://pub.dev" + source: hosted + version: "3.11.1" + patrol_finders: + dependency: transitive + description: + name: patrol_finders + sha256: "6bf2c3093fbccd02f80f73fafc1bd021d76410cbab6e329be220b5e3bc58f072" + url: "https://pub.dev" + source: hosted + version: "2.1.2" pattern_formatter: dependency: transitive description: @@ -2201,4 +2217,4 @@ packages: version: "3.1.2" sdks: dart: ">=3.3.0 <4.0.0" - flutter: ">=3.20.0-7.0.pre.48" + flutter: ">=3.22.0" diff --git a/pubspec.yaml b/pubspec.yaml index 5cfb76793..0d8c50fb5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -270,6 +270,8 @@ dev_dependencies: http_mock_adapter: 0.4.2 + patrol: 3.11.1 + plugin_platform_interface: 2.1.8 dependency_overrides: @@ -282,6 +284,8 @@ dependency_overrides: url: https://github.com/linagora/flutter_file_picker ref: email_supported_5.3.1 + json_annotation: 4.8.1 + # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -355,3 +359,8 @@ flutter_native_splash: cider: link_template: tag: https://github.com/linagora/tmail-flutter/releases/tag/v%tag% # initial release link template + +patrol: + app_name: Twake Mail + android: + package_name: com.linagora.android.teammail