diff --git a/dev/bots/suite_runners/run_flutter_driver_android_tests.dart b/dev/bots/suite_runners/run_flutter_driver_android_tests.dart index a73dbfc5f2..71cec906eb 100644 --- a/dev/bots/suite_runners/run_flutter_driver_android_tests.dart +++ b/dev/bots/suite_runners/run_flutter_driver_android_tests.dart @@ -3,15 +3,22 @@ // found in the LICENSE file. import 'package:path/path.dart' as path; +import '../run_command.dart'; import '../utils.dart'; Future runFlutterDriverAndroidTests() async { print('Running Flutter Driver Android tests...'); - await runDartTest( - path.join(flutterRoot, 'packages', 'flutter_driver'), - testPaths: [ - 'test/src/native_tests/android', + // TODO(matanlurey): Should we be using another instrumentation method? + await runCommand( + 'flutter', + [ + 'drive', ], + workingDirectory: path.join( + 'dev', + 'integration_tests', + 'android_driver_test', + ), ); } diff --git a/dev/integration_tests/android_driver_test/.metadata b/dev/integration_tests/android_driver_test/.metadata new file mode 100644 index 0000000000..4cff445185 --- /dev/null +++ b/dev/integration_tests/android_driver_test/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "6a346df51b97840ff2c06805e5482be28c6dd7c4" + channel: "[user-branch]" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 6a346df51b97840ff2c06805e5482be28c6dd7c4 + base_revision: 6a346df51b97840ff2c06805e5482be28c6dd7c4 + - platform: android + create_revision: 6a346df51b97840ff2c06805e5482be28c6dd7c4 + base_revision: 6a346df51b97840ff2c06805e5482be28c6dd7c4 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/dev/integration_tests/android_driver_test/README.md b/dev/integration_tests/android_driver_test/README.md new file mode 100644 index 0000000000..f3ae358a3a --- /dev/null +++ b/dev/integration_tests/android_driver_test/README.md @@ -0,0 +1,6 @@ +# Flutter Driver Android Integration Tests + +This directory contains a sample app and tests that demonstrate how to use the +(experimental) _native_ Flutter Driver API to drive Flutter apps that run on +Android devices or emulators, interact with and capture screenshots of the app, +and compare the screenshots against golden images. diff --git a/dev/integration_tests/android_driver_test/android/.gitignore b/dev/integration_tests/android_driver_test/android/.gitignore new file mode 100644 index 0000000000..55afd919c6 --- /dev/null +++ b/dev/integration_tests/android_driver_test/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/dev/integration_tests/android_driver_test/android/app/build.gradle b/dev/integration_tests/android_driver_test/android/app/build.gradle new file mode 100644 index 0000000000..0f5328b878 --- /dev/null +++ b/dev/integration_tests/android_driver_test/android/app/build.gradle @@ -0,0 +1,48 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +plugins { + id "com.android.application" + id "kotlin-android" + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id "dev.flutter.flutter-gradle-plugin" +} + +android { + namespace = "com.example.android_driver_test" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.android_driver_test" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.debug + } + } +} + +flutter { + source = "../.." +} diff --git a/dev/integration_tests/android_driver_test/android/app/src/debug/AndroidManifest.xml b/dev/integration_tests/android_driver_test/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000000..e00f903eae --- /dev/null +++ b/dev/integration_tests/android_driver_test/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/dev/integration_tests/android_driver_test/android/app/src/main/AndroidManifest.xml b/dev/integration_tests/android_driver_test/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..bd4d55b4c8 --- /dev/null +++ b/dev/integration_tests/android_driver_test/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dev/integration_tests/android_driver_test/android/app/src/main/kotlin/com/example/android_driver_test/MainActivity.kt b/dev/integration_tests/android_driver_test/android/app/src/main/kotlin/com/example/android_driver_test/MainActivity.kt new file mode 100644 index 0000000000..6c2d815128 --- /dev/null +++ b/dev/integration_tests/android_driver_test/android/app/src/main/kotlin/com/example/android_driver_test/MainActivity.kt @@ -0,0 +1,25 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +@file:Suppress("PackageName") + +package com.example.android_driver_test + +import android.os.Bundle +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // https://developer.android.com/training/system-ui + val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) + windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()) + actionBar?.hide() + } +} diff --git a/dev/integration_tests/android_driver_test/android/app/src/main/res/drawable-v21/launch_background.xml b/dev/integration_tests/android_driver_test/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000000..c7238f4ec6 --- /dev/null +++ b/dev/integration_tests/android_driver_test/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/dev/integration_tests/android_driver_test/android/app/src/main/res/drawable/launch_background.xml b/dev/integration_tests/android_driver_test/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000000..32d7798786 --- /dev/null +++ b/dev/integration_tests/android_driver_test/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/dev/integration_tests/android_driver_test/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/dev/integration_tests/android_driver_test/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..db77bb4b7b Binary files /dev/null and b/dev/integration_tests/android_driver_test/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/dev/integration_tests/android_driver_test/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/dev/integration_tests/android_driver_test/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..17987b79bb Binary files /dev/null and b/dev/integration_tests/android_driver_test/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/dev/integration_tests/android_driver_test/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/dev/integration_tests/android_driver_test/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..09d4391482 Binary files /dev/null and b/dev/integration_tests/android_driver_test/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/dev/integration_tests/android_driver_test/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/dev/integration_tests/android_driver_test/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..d5f1c8d34e Binary files /dev/null and b/dev/integration_tests/android_driver_test/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/dev/integration_tests/android_driver_test/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/dev/integration_tests/android_driver_test/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..4d6372eebd Binary files /dev/null and b/dev/integration_tests/android_driver_test/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/dev/integration_tests/android_driver_test/android/app/src/main/res/values-night/styles.xml b/dev/integration_tests/android_driver_test/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000000..d25ab93016 --- /dev/null +++ b/dev/integration_tests/android_driver_test/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/dev/integration_tests/android_driver_test/android/app/src/main/res/values/styles.xml b/dev/integration_tests/android_driver_test/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000000..7fd33a93af --- /dev/null +++ b/dev/integration_tests/android_driver_test/android/app/src/main/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/dev/integration_tests/android_driver_test/android/app/src/profile/AndroidManifest.xml b/dev/integration_tests/android_driver_test/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000000..e00f903eae --- /dev/null +++ b/dev/integration_tests/android_driver_test/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/dev/integration_tests/android_driver_test/android/build.gradle b/dev/integration_tests/android_driver_test/android/build.gradle new file mode 100644 index 0000000000..96ac13cda7 --- /dev/null +++ b/dev/integration_tests/android_driver_test/android/build.gradle @@ -0,0 +1,22 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = "../build" +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/dev/integration_tests/android_driver_test/android/gradle.properties b/dev/integration_tests/android_driver_test/android/gradle.properties new file mode 100644 index 0000000000..2597170821 --- /dev/null +++ b/dev/integration_tests/android_driver_test/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/dev/integration_tests/android_driver_test/android/gradle/wrapper/gradle-wrapper.properties b/dev/integration_tests/android_driver_test/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..7bb2df6ba6 --- /dev/null +++ b/dev/integration_tests/android_driver_test/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip diff --git a/dev/integration_tests/android_driver_test/android/settings.gradle b/dev/integration_tests/android_driver_test/android/settings.gradle new file mode 100644 index 0000000000..9f47aeeecb --- /dev/null +++ b/dev/integration_tests/android_driver_test/android/settings.gradle @@ -0,0 +1,29 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.1.0" apply false + id "org.jetbrains.kotlin.android" version "1.8.22" apply false +} + +include ":app" diff --git a/dev/integration_tests/android_driver_test/lib/main.dart b/dev/integration_tests/android_driver_test/lib/main.dart new file mode 100644 index 0000000000..261f79498d --- /dev/null +++ b/dev/integration_tests/android_driver_test/lib/main.dart @@ -0,0 +1,29 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io' as io; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_driver/driver_extension.dart'; + +void main() { + enableFlutterDriverExtension(); + + if (kIsWeb || !io.Platform.isAndroid) { + throw UnsupportedError('This app should only run on Android devices.'); + } + + runApp(const MainApp()); +} + +final class MainApp extends StatelessWidget { + const MainApp({super.key}); + + @override + Widget build(BuildContext context) { + // Draw a full-screen blue rectangle. + return const DecoratedBox(decoration: BoxDecoration(color: Colors.blue)); + } +} diff --git a/dev/integration_tests/android_driver_test/pubspec.yaml b/dev/integration_tests/android_driver_test/pubspec.yaml new file mode 100644 index 0000000000..f56deb8c89 --- /dev/null +++ b/dev/integration_tests/android_driver_test/pubspec.yaml @@ -0,0 +1,72 @@ +name: android_driver_test +publish_to: none + +environment: + sdk: ^3.6.0-77.0.dev + +dependencies: + flutter: + sdk: flutter + flutter_driver: + sdk: flutter + + async: 2.11.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + boolean_selector: 2.1.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + characters: 1.3.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + collection: 1.18.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + file: 7.0.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + matcher: 0.12.16+1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + material_color_utilities: 0.11.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + meta: 1.15.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + path: 1.9.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + source_span: 1.10.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + stack_trace: 1.11.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + stream_channel: 2.1.2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + string_scanner: 1.3.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + sync_http: 0.3.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + term_glyph: 1.2.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + test_api: 0.7.3 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + vector_math: 2.1.4 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + vm_service: 14.2.4 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + webdriver: 3.0.3 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + +dev_dependencies: + flutter_goldens: + sdk: flutter + platform: 3.1.5 + process: 5.0.2 + test: 1.25.8 + + _fe_analyzer_shared: 72.0.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + analyzer: 6.7.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + args: 2.5.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + convert: 3.1.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + coverage: 1.8.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + crypto: 3.0.3 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + frontend_server_client: 4.0.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + glob: 2.1.2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + http_multi_server: 3.2.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + http_parser: 4.0.2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + io: 1.0.4 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + js: 0.7.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + logging: 1.2.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + mime: 1.0.5 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + node_preamble: 2.0.2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + package_config: 2.1.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + pool: 1.5.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + pub_semver: 2.1.4 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + shelf: 1.4.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + shelf_packages_handler: 3.0.2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + shelf_static: 1.1.2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + shelf_web_socket: 1.0.4 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + source_map_stack_trace: 2.1.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + source_maps: 0.10.12 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + test_core: 0.6.5 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + typed_data: 1.3.2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + watcher: 1.1.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + web: 0.5.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + web_socket_channel: 2.4.5 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + webkit_inspection_protocol: 1.2.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + yaml: 3.1.2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + +# PUBSPEC CHECKSUM: f6a7 diff --git a/dev/integration_tests/android_driver_test/test_driver/.gitignore b/dev/integration_tests/android_driver_test/test_driver/.gitignore new file mode 100644 index 0000000000..8314044774 --- /dev/null +++ b/dev/integration_tests/android_driver_test/test_driver/.gitignore @@ -0,0 +1,4 @@ +# In flutter/flutter, all screenshot tests are run on Skia Gold. +# +# However, local development might require running screenshot tests locally. +*.png diff --git a/dev/integration_tests/android_driver_test/test_driver/_flutter_goldens_fork.dart b/dev/integration_tests/android_driver_test/test_driver/_flutter_goldens_fork.dart new file mode 100644 index 0000000000..b885f8e2af --- /dev/null +++ b/dev/integration_tests/android_driver_test/test_driver/_flutter_goldens_fork.dart @@ -0,0 +1,105 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// A fork of `package:flutter_goldens/flutter_goldens.dart` without the +/// dependency on `package:flutter_test` or `package:flutter`; this allows +/// the library to be used in a standalone Dart VM context. +library; + +import 'dart:async'; +import 'dart:io' as io; +import 'dart:typed_data'; + +import 'package:file/local.dart'; +import 'package:flutter_driver/src/native_driver.dart'; +import 'package:flutter_goldens/skia_client.dart'; +import 'package:path/path.dart' as path; +import 'package:platform/platform.dart'; +import 'package:process/process.dart'; + +const LocalFileSystem _localFs = LocalFileSystem(); + +// TODO(matanlurey): Refactor flutter_goldens to just re-use that code instead. +Future testExecutable( + FutureOr Function() testMain, { + String? namePrefix, +}) async { + assert( + goldenFileComparator is NaiveLocalFileComparator, + 'The flutter_goldens_fork library should be used from a *_test.dart file ' + 'where the "goldenFileComparator" has not yet been set. This is to ensure ' + 'that the correct comparator is used for the current test environment.', + ); + final io.Directory tmpDir = io.Directory.systemTemp.createTempSync('android_driver_test'); + goldenFileComparator = _GoldenFileComparator( + SkiaGoldClient( + _localFs.directory(tmpDir.path), + fs: _localFs, + process: const LocalProcessManager(), + platform: const LocalPlatform(), + httpClient: io.HttpClient(), + log: io.stderr.writeln, + ), + namePrefix: namePrefix, + isPresubmit: false, + ); +} + +final class _GoldenFileComparator extends GoldenFileComparator { + _GoldenFileComparator( + this.skiaClient, { + required this.isPresubmit, + this.namePrefix, + Uri? baseDir, + }) : baseDir = baseDir ?? Uri.parse(path.dirname(io.Platform.script.path)); + + final Uri baseDir; + final SkiaGoldClient skiaClient; + final String? namePrefix; + final bool isPresubmit; + + @override + Future compare(Uint8List imageBytes, Uri golden) async { + if (isPresubmit) { + await skiaClient.tryjobInit(); + } else { + await skiaClient.imgtestInit(); + } + + golden = _addPrefix(golden); + await update(golden, imageBytes); + + final io.File goldenFile = _getGoldenFile(golden); + if (isPresubmit) { + await skiaClient.tryjobAdd(golden.path, _localFs.file(goldenFile.path)); + return true; + } else { + return skiaClient.imgtestAdd(golden.path, _localFs.file(goldenFile.path)); + } + } + + @override + Future update(Uri golden, Uint8List imageBytes) async { + final io.File goldenFile = _getGoldenFile(golden); + await goldenFile.parent.create(recursive: true); + await goldenFile.writeAsBytes(imageBytes, flush: true); + } + + io.File _getGoldenFile(Uri uri) { + return io.File.fromUri(baseDir.resolveUri(uri)); + } + + Uri _addPrefix(Uri golden) { + assert( + golden.toString().split('.').last == 'png', + 'Golden files in the Flutter framework must end with the file extension ' + '.png.', + ); + return Uri.parse([ + if (namePrefix != null) namePrefix!, + baseDir.pathSegments[baseDir.pathSegments.length - 2], + golden.toString(), + ].join('.')); + } +} diff --git a/dev/integration_tests/android_driver_test/test_driver/main_test.dart b/dev/integration_tests/android_driver_test/test_driver/main_test.dart new file mode 100644 index 0000000000..49e681368e --- /dev/null +++ b/dev/integration_tests/android_driver_test/test_driver/main_test.dart @@ -0,0 +1,43 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_driver/flutter_driver.dart'; +import 'package:flutter_driver/src/native_driver.dart'; +import 'package:test/test.dart'; + +import '_flutter_goldens_fork.dart'; + +// TODO(matanlurey): This is done automatically by 'flutter test' but not by +// 'flutter drive'. If we get closer to shipping the native 'flutter drive' +// command, we should look into if 'flutter_test_config.dart', or a similar +// mechanism, can be used to configure this automatically. +void main() async { + await testExecutable(_main); +} + +Future _main() async { + // To generate golden files locally, uncomment the following line. + // autoUpdateGoldenFiles = true; + + late FlutterDriver flutterDriver; + late NativeDriver nativeDriver; + + setUpAll(() async { + flutterDriver = await FlutterDriver.connect(); + nativeDriver = await AndroidNativeDriver.connect(); + }); + + tearDownAll(() async { + await nativeDriver.close(); + await flutterDriver.close(); + }); + + test('should screenshot and match a full-screen blue rectangle', () async { + await flutterDriver.waitFor(find.byType('DecoratedBox')); + await expectLater( + nativeDriver.screenshot(), + matchesGoldenFile('android_driver_test.BlueRectangle.png'), + ); + }, timeout: Timeout.none); +} diff --git a/packages/flutter_driver/lib/src/native/android.dart b/packages/flutter_driver/lib/src/native/android.dart index 4a3a66fcc5..e561af57fe 100644 --- a/packages/flutter_driver/lib/src/native/android.dart +++ b/packages/flutter_driver/lib/src/native/android.dart @@ -5,6 +5,7 @@ // Examples can assume: // import 'package:flutter_driver/src/native/android.dart'; +import 'dart:convert'; import 'dart:io' as io; import 'dart:typed_data'; @@ -20,44 +21,57 @@ final class AndroidNativeDriver implements NativeDriver { /// The [tempDirectory] argument can be used to specify a custom directory /// where the driver will store temporary files. If not provided, a temporary /// directory will be created in the system's temporary directory. + /// + /// @nodoc @visibleForTesting AndroidNativeDriver({ required AndroidDeviceTarget target, String? adbPath, io.Directory? tempDirectory, - }) : _adbPath = adbPath ?? 'adb', - _target = target, - _tmpDir = tempDirectory ?? io.Directory.systemTemp.createTempSync('flutter_driver.'); + }) : _adbPath = adbPath ?? 'adb', + _target = target, + _tmpDir = tempDirectory ?? + io.Directory.systemTemp.createTempSync('flutter_driver.'); /// Connects to a device or emulator identified by [target]. static Future connect({ - AndroidDeviceTarget target = const AndroidDeviceTarget.onlyEmulatorOrDevice(), + AndroidDeviceTarget? target, }) async { + target ??= const AndroidDeviceTarget.onlyEmulatorOrDevice(); final AndroidNativeDriver driver = AndroidNativeDriver(target: target); await driver._smokeTest(); return driver; } - Future _smokeTest() async { - final io.ProcessResult version = await io.Process.run( + Future _adb( + List args, { + Encoding? stdoutEncoding = io.systemEncoding, + }) { + return io.Process.run( _adbPath, - const ['version'], + [ + ..._target._toAdbArgs(), + ...args, + ], + stdoutEncoding: stdoutEncoding, ); + } + + Future _smokeTest() async { + final io.ProcessResult version = await _adb(['version']); if (version.exitCode != 0) { throw StateError('Failed to run `$_adbPath version`: ${version.stderr}'); } - final io.ProcessResult devices = await io.Process.run( - _adbPath, + final io.ProcessResult echo = await _adb( [ - ..._target._toAdbArgs(), 'shell', 'echo', 'connected', ], ); - if (devices.exitCode != 0) { - throw StateError('Failed to connect to target: ${devices.stderr}'); + if (echo.exitCode != 0) { + throw StateError('Failed to connect to target: ${echo.stderr}'); } } @@ -70,10 +84,38 @@ final class AndroidNativeDriver implements NativeDriver { await _tmpDir.delete(recursive: true); } + @override + Future configureForScreenshotTesting() async { + const Map settings = { + 'show_surface_updates': '1', + 'transition_animation_scale': '0', + 'window_animation_scale': '0', + 'animator_duration_scale': '0', + }; + + for (final MapEntry entry in settings.entries) { + final io.ProcessResult result = await _adb( + [ + 'shell', + 'settings', + 'put', + 'global', + entry.key, + entry.value, + ], + ); + + if (result.exitCode != 0) { + throw StateError('Failed to configure device: ${result.stderr}'); + } + } + } + @override Future screenshot() async { - final io.ProcessResult result = await io.Process.run( - _adbPath, + // Similar pause to the one in `.screenshot()`. + await Future.delayed(const Duration(seconds: 2)); + final io.ProcessResult result = await _adb( [ ..._target._toAdbArgs(), 'exec-out', @@ -134,7 +176,8 @@ sealed class AndroidDeviceTarget { /// ```dart /// const AndroidDeviceTarget target = AndroidDeviceTarget.bySerial('emulator-5554'); /// ``` - const factory AndroidDeviceTarget.bySerial(String serialNumber) = _SerialDeviceTarget; + const factory AndroidDeviceTarget.bySerial(String serialNumber) = + _SerialDeviceTarget; /// Represents the only running emulator _or_ connected device. /// @@ -144,7 +187,8 @@ sealed class AndroidDeviceTarget { /// Represents the only running emulator on the host machine. /// /// This is equivalent to using `adb -e`, a _single_ emulator must be running. - const factory AndroidDeviceTarget.onlyRunningEmulator() = _SingleEmulatorTarget; + const factory AndroidDeviceTarget.onlyRunningEmulator() = + _SingleEmulatorTarget; /// Represents the only connected device on the host machine. /// diff --git a/packages/flutter_driver/lib/src/native/driver.dart b/packages/flutter_driver/lib/src/native/driver.dart index fd7025abd4..d0b30b0be8 100644 --- a/packages/flutter_driver/lib/src/native/driver.dart +++ b/packages/flutter_driver/lib/src/native/driver.dart @@ -26,6 +26,16 @@ abstract interface class NativeDriver { /// After calling this method, the driver is no longer usable. Future close(); + /// Configures the device for screenshot testing. + /// + /// Where possible, this method should suppress system UI elements that are + /// not part of the application under test, such as the status bar or + /// navigation bar, and disable animations that might interfere with + /// screenshot comparison. + /// + /// The exact details of what is configured are platform-specific. + Future configureForScreenshotTesting(); + /// Take a screenshot using a platform-specific mechanism. /// /// The image is returned as an opaque handle that can be used to retrieve diff --git a/packages/flutter_driver/lib/src/native/goldens.dart b/packages/flutter_driver/lib/src/native/goldens.dart new file mode 100644 index 0000000000..01c39d1904 --- /dev/null +++ b/packages/flutter_driver/lib/src/native/goldens.dart @@ -0,0 +1,258 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// A partial copy of [flutter_test.matchesGoldenFile] and supporting code. +/// +/// Flutter driver runs in the standalone Dart VM, which does not have access to +/// the Flutter test library or `dart:ui`. This file provides a subset of the +/// functionality of `flutter_test`'s `matchesGoldenFile` function, and we can +/// consider refactoring this code to be shared between the two libraries +/// (https://github.com/flutter/flutter/issues/152257). +/// +/// ## TODOs +/// +/// - [ ] Figure out a code-sharing strategy with `flutter_test`. +/// +/// The basics, such as the matcher and APIs, could definitely be shared. +/// +/// - [ ] Consider how local image diffing could be shared, if at all. +/// +/// The `flutter_test` implementation uses `dart:ui`, which is not available +/// today in a Flutter driver script. We could either (a) provide a different +/// implementation for Flutter driver, or (b) provide a way, such as using +/// `flutter_tester` to run the tests in a Flutter context, to use the same +/// implementation. +/// +/// - [ ] Teach `flutter drive` to use and provide `--update-goldens` flag. +/// +/// @docImport 'package:flutter_test/flutter_test.dart' as flutter_test; +library; + +import 'dart:async'; +import 'dart:io' as io; +import 'dart:typed_data'; + +import 'package:matcher/matcher.dart'; + +// Similar to `flutter_test`, we ignore the implementation import. +// ignore: implementation_imports +import 'package:matcher/src/expect/async_matcher.dart'; + +import 'package:path/path.dart' as path; +import 'package:test_api/test_api.dart'; + +import 'driver.dart'; + +/// Whether golden files should be automatically updated during tests rather +/// than compared to the image bytes recorded by the tests. +/// +/// When this is `true`, [matchesGoldenFile] will always report a successful +/// match, because the bytes being tested implicitly become the new golden. +bool autoUpdateGoldenFiles = false; + +/// Compares pixels against those of a golden image file. +/// +/// This comparator is used as the backend for [matchesGoldenFile]. +/// +/// By default, an exact pixel match to a local golden file is used. +GoldenFileComparator goldenFileComparator = const NaiveLocalFileComparator._(); + +/// Compares image pixels against a golden image file. +/// +/// Instances of this comparator will be used as the backend for +/// [matchesGoldenFile]. +abstract mixin class GoldenFileComparator { + /// Compares the pixels of decoded png [imageBytes] against the golden file + /// identified by [golden]. + /// + /// The returned future completes with a boolean value that indicates whether + /// the pixels decoded from [imageBytes] match the golden file's pixels. + /// + /// In the case of comparison mismatch, the comparator may choose to throw a + /// [TestFailure] if it wants to control the failure message, often in the + /// form of a [ComparisonResult] that provides detailed information about the + /// mismatch. + /// + /// The method by which [golden] is located and by which its bytes are loaded + /// is left up to the implementation class. For instance, some implementations + /// may load files from the local file system, whereas others may load files + /// over the network or from a remote repository. + Future compare(Uint8List imageBytes, Uri golden); + + /// Updates the golden file identified by [golden] with [imageBytes]. + /// + /// This will be invoked in lieu of [compare] when [autoUpdateGoldenFiles] + /// is `true` (which gets set automatically by the test framework when the + /// user runs `flutter drive --update-goldens`). + /// + /// The method by which [golden] is located and by which its bytes are written + /// is left up to the implementation class. + Future update(Uri golden, Uint8List imageBytes); + + /// Returns a new golden file [Uri] to incorporate any [version] number with + /// the [key]. + /// + /// The [version] is an optional int that can be used to differentiate + /// historical golden files. + /// + /// Version numbers are used in golden file tests for package:flutter. You can + /// learn more about these tests [here](https://github.com/flutter/flutter/blob/main/docs/contributing/testing/Writing-a-golden-file-test-for-package-flutter.md). + Uri getTestUri(Uri key, int? version) { + if (version == null) { + return key; + } + final String keyString = key.toString(); + final String extension = path.extension(keyString); + return Uri.parse('${keyString.split(extension).join()}.$version$extension'); + } +} + +/// The default [GoldenFileComparator] implementation for `flutter drive`. +/// +/// This comparator performs a pixel-for-pixel comparison of the decoded PNGs, +/// returning true only if there's an exact match. In cases where the captured +/// test image does not match the golden file, this comparator will provide a +/// fairly unhelpful error message, which could be improved in the future. +final class NaiveLocalFileComparator with GoldenFileComparator { + const NaiveLocalFileComparator._(); + + @override + Future compare(Uint8List imageBytes, Uri golden) async { + final io.File goldenFile = _getTestFilePath(golden); + final Uint8List goldenBytes; + try { + goldenBytes = await goldenFile.readAsBytes(); + } on io.PathNotFoundException { + throw TestFailure('Golden file not found: $golden'); + } + + if (goldenBytes.length != imageBytes.length) { + return false; + } + + for (int i = 0; i < goldenBytes.length; i++) { + if (goldenBytes[i] != imageBytes[i]) { + return false; + } + } + + return true; + } + + @override + Future update(Uri golden, Uint8List imageBytes) async { + final io.File goldenFile = _getTestFilePath(golden); + await goldenFile.parent.create(recursive: true); + await goldenFile.writeAsBytes(imageBytes); + } + + /// Returns a path relative to the test script. + /// + /// This is hacky and unreliable, but it's the best we can do until we have + /// more integration with the `flutter` CLI (which does all the heavy lifting + /// for us in `flutter_test`). + io.File _getTestFilePath(Uri golden) { + final String testScriptPath = io.Platform.script.toFilePath(); + final String testScriptDir = path.dirname(testScriptPath); + return io.File(path.join(testScriptDir, golden.path)); + } +} + +// Examples can assume: +// import 'package:flutter_driver/src/native/driver.dart'; +// import 'package:flutter_driver/src/native/goldens.dart'; +// import 'package:test/test.dart'; +// late NativeDriver nativeDriver; + +/// Asserts that a [NativeScreenshot], [Future], or +/// [List] matches the golden image file indentified by [key], with an +/// optional [version] number]. +/// +/// The [key] may be either a [Uri] or a [String] representation of a URL. +/// +/// The [version] is a number that can be used to differentiate historical +/// golden files. This parameter is optional. +/// +/// This is an asynchronous matcher, meaning that callers should use +/// [flutter_test.expectLater] when using this matcher and await the future +/// returned by [flutter_test.expectLater]. +/// +/// ## Golden File Testing +/// +/// The term __golden file__ refers to a master image that is considered the +/// true renmdering of a given widget, state, application, or other visual +/// representation you have chosen to capture. +/// +/// The master golden image files are tested against can be created or updated +/// by running `flutter drive --update-goldens` on the test. +/// +/// {@tool snippet} +/// Sample invocations of [matchesGoldenFile]. +/// +/// ```dart +/// await expectLater( +/// nativeDriver.screenshot(), +/// matchesGoldenFile('save.png'), +/// ); +/// ``` +/// {@end-tool} +AsyncMatcher matchesGoldenFile(Object key, {int? version}) { + return switch (key) { + Uri() => _MatchesGoldenFile(key, version), + String() => _MatchesGoldenFile.forStringPath(key, version), + _ => throw ArgumentError( + 'Unexpected type for golden file: ${key.runtimeType}'), + }; +} + +/// The matcher created by [matchesGoldenFile]. +final class _MatchesGoldenFile extends AsyncMatcher { + /// Creates an instance of [MatchesGoldenFile]. + const _MatchesGoldenFile(this.key, this.version); + + /// Creates an instance of [MatchesGoldenFile] from a [String] path. + _MatchesGoldenFile.forStringPath(String path, this.version) + : key = Uri.parse(path); + + /// The [key] to the golden image. + final Uri key; + + /// The [version] of the golden image. + final int? version; + + @override + Future matchAsync(Object? item) async { + final Uri testNameUri = goldenFileComparator.getTestUri(key, version); + + final Uint8List buffer; + if (item is FutureOr>) { + buffer = Uint8List.fromList(await item); + } else if (item is FutureOr) { + buffer = await (await item).readAsBytes(); + } else { + throw ArgumentError( + 'Unexpected type for golden file: ${item.runtimeType}', + ); + } + + if (autoUpdateGoldenFiles) { + await goldenFileComparator.update(testNameUri, buffer); + return null; + } + try { + final bool success = await goldenFileComparator.compare( + buffer, + testNameUri, + ); + return success ? null : 'does not match'; + } on TestFailure catch (e) { + return e.message; + } + } + + @override + Description describe(Description description) { + return description.add('app screenshot image matches golden file "$key"'); + } +} diff --git a/packages/flutter_driver/lib/src/native_driver.dart b/packages/flutter_driver/lib/src/native_driver.dart new file mode 100644 index 0000000000..01442610ab --- /dev/null +++ b/packages/flutter_driver/lib/src/native_driver.dart @@ -0,0 +1,12 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +@experimental +library; + +import 'package:meta/meta.dart'; + +export 'native/android.dart'; +export 'native/driver.dart'; +export 'native/goldens.dart';