diff --git a/.github/workflows/test_mobile.yml b/.github/workflows/test_mobile.yml new file mode 100644 index 0000000000..31bfa50791 --- /dev/null +++ b/.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 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 984d98d430..22480f94ab 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ docker/upload uploads -coverage \ No newline at end of file +coverage + +mobile/gradle.properties diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 49edbdcf73..fd8a3ac217 100644 --- a/mobile/assets/i18n/en-US.json +++ b/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", diff --git a/mobile/integration_test/module_login/login_input_validation_test.dart b/mobile/integration_test/module_login/login_input_validation_test.dart new file mode 100644 index 0000000000..de34b54c92 --- /dev/null +++ b/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); + }); + }); +} diff --git a/mobile/integration_test/test_utils/general_helper.dart b/mobile/integration_test/test_utils/general_helper.dart new file mode 100644 index 0000000000..0c4ee72912 --- /dev/null +++ b/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 initialize() async { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive; + + // Load hive, localization... + await app.initApp(); + + return binding; + } + + static Future 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 Function(WidgetTester) test) { + testWidgets(description, (widgetTester) async { + await ImmichTestHelper.loadApp(widgetTester); + await test(widgetTester); + }); +} \ No newline at end of file diff --git a/mobile/integration_test/test_utils/login_helper.dart b/mobile/integration_test/test_utils/login_helper.dart new file mode 100644 index 0000000000..549db3ba20 --- /dev/null +++ b/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 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 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 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)); + } +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 5a84a8e110..eaae6a39e9 100644 --- a/mobile/lib/main.dart +++ b/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 openBoxes() async { await Future.wait([ Hive.openBox(immichLoggerBox), Hive.openBox(userInfoBox), @@ -47,6 +46,16 @@ void main() async { if (!Platform.isAndroid) Hive.openBox(backgroundBackupInfoBox), EasyLocalization.ensureInitialized(), ]); +} + +Future 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()), ); } diff --git a/mobile/lib/modules/login/ui/login_form.dart b/mobile/lib/modules/login/ui/login_form.dart index a355527523..8db1784054 100644 --- a/mobile/lib/modules/login/ui/login_form.dart +++ b/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, diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 2870a23466..537d0d65f3 100644 --- a/mobile/pubspec.lock +++ b/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: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 4880121306..2562836133 100644 --- a/mobile/pubspec.yaml +++ b/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