Jelajahi Sumber

feat(mobile): Add integration tests (#1359)

Matthias Rupp 2 tahun lalu
induk
melakukan
f4c90426a5

+ 44 - 0
.github/workflows/test_mobile.yml

@@ -0,0 +1,44 @@
+name: Flutter Integration Tests
+
+on:
+  push:
+    branches: [ "main" ]
+  pull_request:
+
+jobs:
+  build:
+    runs-on: macos-latest
+    steps:
+      - uses: actions/checkout@v2
+      - uses: actions/setup-java@v2
+        with:
+          distribution: 'adopt'
+          java-version: '11'
+      - name: Cache android SDK
+        uses: actions/cache@v2
+        id: android-sdk
+        with:
+          key: android-sdk
+          path: |
+            /usr/local/lib/android/
+            ~/.android
+      - name: Setup Android SDK
+        if: steps.android-sdk.outputs.cache-hit != 'true'
+        uses: android-actions/setup-android@v2
+      - name: Setup Flutter SDK
+        uses: subosito/flutter-action@v1
+        with:
+          channel: 'stable'
+      - name: Run integration tests
+        uses: reactivecircus/android-emulator-runner@v2.27.0
+        with:
+          working-directory: ./mobile
+          api-level: 29
+          arch: x86_64
+          profile: pixel
+          target: default
+          emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim
+          disable-linux-hw-accel: false
+          script: |
+            flutter pub get
+            flutter test integration_test

+ 3 - 1
.gitignore

@@ -5,4 +5,6 @@
 
 docker/upload
 uploads
-coverage
+coverage
+
+mobile/gradle.properties

+ 2 - 1
mobile/assets/i18n/en-US.json

@@ -114,8 +114,9 @@
   "library_page_new_album": "New album",
   "login_form_button_text": "Login",
   "login_form_email_hint": "youremail@email.com",
-  "login_form_endpoint_hint": "http://your-server-ip:port/",
+  "login_form_endpoint_hint": "https://your-server-ip:port/",
   "login_form_endpoint_url": "Server Endpoint URL",
+  "login_form_err_http_insecure": "Http is unencrypted and therefore not recommended. Please use https unless you are using Immich exclusively in your home network.",
   "login_form_err_invalid_email": "Invalid Email",
   "login_form_err_invalid_url": "Invalid URL",
   "login_form_err_leading_whitespace": "Leading whitespace",

+ 36 - 0
mobile/integration_test/module_login/login_input_validation_test.dart

@@ -0,0 +1,36 @@
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import '../test_utils/general_helper.dart';
+import '../test_utils/login_helper.dart';
+
+void main() async {
+  await ImmichTestHelper.initialize();
+
+  group("Login input validation test", () {
+    immichWidgetTest("Test http warning message", (tester) async {
+      await ImmichTestLoginHelper.waitForLoginScreen(tester);
+      await ImmichTestLoginHelper.acknowledgeNewServerVersion(tester);
+
+      // Test https URL
+      await ImmichTestLoginHelper.enterLoginCredentials(
+        tester,
+        server: "https://demo.immich.app/api",
+      );
+
+      await tester.pump(const Duration(milliseconds: 300));
+
+      expect(find.text("login_form_err_http_insecure".tr()), findsNothing);
+
+      // Test http URL
+      await ImmichTestLoginHelper.enterLoginCredentials(
+        tester,
+        server: "http://demo.immich.app/api",
+      );
+
+      await tester.pump(const Duration(milliseconds: 300));
+
+      expect(find.text("login_form_err_http_insecure".tr()), findsOneWidget);
+    });
+  });
+}

+ 40 - 0
mobile/integration_test/test_utils/general_helper.dart

@@ -0,0 +1,40 @@
+
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:hive/hive.dart';
+import 'package:immich_mobile/main.dart';
+import 'package:integration_test/integration_test.dart';
+
+import 'package:immich_mobile/main.dart' as app;
+
+class ImmichTestHelper {
+
+  static Future<IntegrationTestWidgetsFlutterBinding> initialize() async {
+    final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+    binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
+
+    // Load hive, localization...
+    await app.initApp();
+
+    return binding;
+  }
+
+  static Future<void> loadApp(WidgetTester tester) async {
+    // Clear all data from Hive
+    await Hive.deleteFromDisk();
+    await app.openBoxes();
+    // Load main Widget
+    await tester.pumpWidget(app.getMainWidget());
+    // Post run tasks
+    await tester.pumpAndSettle();
+    await EasyLocalization.ensureInitialized();
+  }
+
+}
+
+void immichWidgetTest(String description, Future<void> Function(WidgetTester) test) {
+  testWidgets(description, (widgetTester) async {
+    await ImmichTestHelper.loadApp(widgetTester);
+    await test(widgetTester);
+  });
+}

+ 55 - 0
mobile/integration_test/test_utils/login_helper.dart

@@ -0,0 +1,55 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+class ImmichTestLoginHelper {
+  static Future<void> waitForLoginScreen(WidgetTester tester,
+      {int timeoutSeconds = 20}) async {
+    for (var i = 0; i < timeoutSeconds; i++) {
+      // Search for "IMMICH" test in the app bar
+      final result = find.text("IMMICH");
+      if (tester.any(result)) {
+        // Wait 5s until everything settled
+        await tester.pump(const Duration(seconds: 5));
+        return;
+      }
+
+      // Wait 1s before trying again
+      await Future.delayed(const Duration(seconds: 1));
+    }
+
+    fail("Timeout while waiting for login screen");
+  }
+
+  static Future<bool> acknowledgeNewServerVersion(WidgetTester tester) async {
+    final result = find.text("Acknowledge");
+    if (!tester.any(result)) {
+      return false;
+    }
+
+    await tester.tap(result);
+    await tester.pump();
+
+    return true;
+  }
+
+  static Future<void> enterLoginCredentials(
+    WidgetTester tester, {
+    String server = "",
+    String email = "",
+    String password = "",
+  }) async {
+    final loginForms = find.byType(TextFormField);
+
+    await tester.pump(const Duration(milliseconds: 500));
+    await tester.enterText(loginForms.at(0), email);
+
+    await tester.pump(const Duration(milliseconds: 500));
+    await tester.enterText(loginForms.at(1), password);
+
+    await tester.pump(const Duration(milliseconds: 500));
+    await tester.enterText(loginForms.at(2), server);
+
+    await tester.pump(const Duration(milliseconds: 500));
+  }
+}

+ 22 - 13
mobile/lib/main.dart

@@ -29,12 +29,11 @@ import 'package:immich_mobile/utils/immich_app_theme.dart';
 import 'constants/hive_box.dart';
 
 void main() async {
-  await Hive.initFlutter();
-  Hive.registerAdapter(HiveSavedLoginInfoAdapter());
-  Hive.registerAdapter(HiveBackupAlbumsAdapter());
-  Hive.registerAdapter(HiveDuplicatedAssetsAdapter());
-  Hive.registerAdapter(ImmichLoggerMessageAdapter());
+  await initApp();
+  runApp(getMainWidget());
+}
 
+Future<void> openBoxes() async {
   await Future.wait([
     Hive.openBox<ImmichLoggerMessage>(immichLoggerBox),
     Hive.openBox(userInfoBox),
@@ -47,6 +46,16 @@ void main() async {
     if (!Platform.isAndroid) Hive.openBox(backgroundBackupInfoBox),
     EasyLocalization.ensureInitialized(),
   ]);
+}
+
+Future<void> initApp() async {
+  await Hive.initFlutter();
+  Hive.registerAdapter(HiveSavedLoginInfoAdapter());
+  Hive.registerAdapter(HiveBackupAlbumsAdapter());
+  Hive.registerAdapter(HiveDuplicatedAssetsAdapter());
+  Hive.registerAdapter(ImmichLoggerMessageAdapter());
+
+  await openBoxes();
 
   SystemChrome.setSystemUIOverlayStyle(
     const SystemUiOverlayStyle(
@@ -65,15 +74,15 @@ void main() async {
 
   // Initialize Immich Logger Service
   ImmichLogger().init();
+}
 
-  runApp(
-    EasyLocalization(
-      supportedLocales: locales,
-      path: translationsPath,
-      useFallbackTranslations: true,
-      fallbackLocale: locales.first,
-      child: const ProviderScope(child: ImmichApp()),
-    ),
+Widget getMainWidget() {
+  return EasyLocalization(
+    supportedLocales: locales,
+    path: translationsPath,
+    useFallbackTranslations: true,
+    fallbackLocale: locales.first,
+    child: const ProviderScope(child: ImmichApp()),
   );
 }
 

+ 6 - 0
mobile/lib/modules/login/ui/login_form.dart

@@ -223,6 +223,11 @@ class ServerEndpointInput extends StatelessWidget {
         parsedUrl.host.isEmpty) {
       return 'login_form_err_invalid_url'.tr();
     }
+
+    if (!parsedUrl.scheme.startsWith("https")) {
+      return 'login_form_err_http_insecure'.tr();
+    }
+
     return null;
   }
 
@@ -234,6 +239,7 @@ class ServerEndpointInput extends StatelessWidget {
         labelText: 'login_form_endpoint_url'.tr(),
         border: const OutlineInputBorder(),
         hintText: 'login_form_endpoint_hint'.tr(),
+        errorMaxLines: 4
       ),
       validator: _validateInput,
       autovalidateMode: AutovalidateMode.always,

+ 36 - 0
mobile/pubspec.lock

@@ -307,6 +307,11 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "0.4.0"
+  flutter_driver:
+    dependency: transitive
+    description: flutter
+    source: sdk
+    version: "0.0.0"
   flutter_hooks:
     dependency: "direct main"
     description:
@@ -392,6 +397,11 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "2.1.2"
+  fuchsia_remote_debug_protocol:
+    dependency: transitive
+    description: flutter
+    source: sdk
+    version: "0.0.0"
   glob:
     dependency: transitive
     description:
@@ -504,6 +514,11 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "2.5.0"
+  integration_test:
+    dependency: "direct dev"
+    description: flutter
+    source: sdk
+    version: "0.0.0"
   intl:
     dependency: "direct main"
     description:
@@ -1041,6 +1056,13 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "1.1.1"
+  sync_http:
+    dependency: transitive
+    description:
+      name: sync_http
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.3.1"
   synchronized:
     dependency: transitive
     description:
@@ -1202,6 +1224,13 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "2.0.10"
+  vm_service:
+    dependency: transitive
+    description:
+      name: vm_service
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "9.0.0"
   wakelock:
     dependency: transitive
     description:
@@ -1251,6 +1280,13 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "2.2.0"
+  webdriver:
+    dependency: transitive
+    description:
+      name: webdriver
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "3.0.0"
   win32:
     dependency: transitive
     description:

+ 2 - 0
mobile/pubspec.yaml

@@ -57,6 +57,8 @@ dev_dependencies:
   build_runner: ^2.2.1
   auto_route_generator: ^5.0.2
   flutter_launcher_icons: "^0.9.2"
+  integration_test:
+    sdk: flutter
 
 flutter:
   uses-material-design: true