From 7f8ad8a4cb1bb8a2986f11070e5c1cce0d2256cb Mon Sep 17 00:00:00 2001 From: Matan Lurey Date: Fri, 26 Jul 2024 09:09:29 -0700 Subject: [PATCH] Add and use an integration test with native(ADB) screenshots (#152326) Closes https://github.com/flutter/flutter/issues/152325. This PR is large due to generate `flutter create --platforms android`. A quick summary: - Moves the integration test from `packages/flutter_driver/test` to `dev/integration_tests` - Created a sample Flutter app that draws a blue rectangle - Forked a subset of `package:flutter_goldens` that will work on the standalone Dart VM - Forked a subset of `goldens.dart` (from `flutter_test`) to `src/native/goldens.dart` (i.e. `matchesGoldenFile`) This ... works locally, but as usual I have no idea if it will work on Skia Gold so let's roll some dice. --- .../run_flutter_driver_android_tests.dart | 15 +- .../android_driver_test/.metadata | 30 ++ .../android_driver_test/README.md | 6 + .../android_driver_test/android/.gitignore | 13 + .../android/app/build.gradle | 48 ++++ .../android/app/src/debug/AndroidManifest.xml | 11 + .../android/app/src/main/AndroidManifest.xml | 49 ++++ .../android_driver_test/MainActivity.kt | 25 ++ .../res/drawable-v21/launch_background.xml | 16 ++ .../main/res/drawable/launch_background.xml | 16 ++ .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 22 ++ .../app/src/main/res/values/styles.xml | 22 ++ .../app/src/profile/AndroidManifest.xml | 11 + .../android_driver_test/android/build.gradle | 22 ++ .../android/gradle.properties | 3 + .../gradle/wrapper/gradle-wrapper.properties | 5 + .../android/settings.gradle | 29 ++ .../android_driver_test/lib/main.dart | 29 ++ .../android_driver_test/pubspec.yaml | 72 +++++ .../test_driver/.gitignore | 4 + .../test_driver/_flutter_goldens_fork.dart | 105 +++++++ .../test_driver/main_test.dart | 43 +++ .../lib/src/native/android.dart | 76 ++++-- .../flutter_driver/lib/src/native/driver.dart | 10 + .../lib/src/native/goldens.dart | 258 ++++++++++++++++++ .../flutter_driver/lib/src/native_driver.dart | 12 + 31 files changed, 932 insertions(+), 20 deletions(-) create mode 100644 dev/integration_tests/android_driver_test/.metadata create mode 100644 dev/integration_tests/android_driver_test/README.md create mode 100644 dev/integration_tests/android_driver_test/android/.gitignore create mode 100644 dev/integration_tests/android_driver_test/android/app/build.gradle create mode 100644 dev/integration_tests/android_driver_test/android/app/src/debug/AndroidManifest.xml create mode 100644 dev/integration_tests/android_driver_test/android/app/src/main/AndroidManifest.xml create mode 100644 dev/integration_tests/android_driver_test/android/app/src/main/kotlin/com/example/android_driver_test/MainActivity.kt create mode 100644 dev/integration_tests/android_driver_test/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 dev/integration_tests/android_driver_test/android/app/src/main/res/drawable/launch_background.xml create mode 100644 dev/integration_tests/android_driver_test/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 dev/integration_tests/android_driver_test/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 dev/integration_tests/android_driver_test/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 dev/integration_tests/android_driver_test/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 dev/integration_tests/android_driver_test/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 dev/integration_tests/android_driver_test/android/app/src/main/res/values-night/styles.xml create mode 100644 dev/integration_tests/android_driver_test/android/app/src/main/res/values/styles.xml create mode 100644 dev/integration_tests/android_driver_test/android/app/src/profile/AndroidManifest.xml create mode 100644 dev/integration_tests/android_driver_test/android/build.gradle create mode 100644 dev/integration_tests/android_driver_test/android/gradle.properties create mode 100644 dev/integration_tests/android_driver_test/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 dev/integration_tests/android_driver_test/android/settings.gradle create mode 100644 dev/integration_tests/android_driver_test/lib/main.dart create mode 100644 dev/integration_tests/android_driver_test/pubspec.yaml create mode 100644 dev/integration_tests/android_driver_test/test_driver/.gitignore create mode 100644 dev/integration_tests/android_driver_test/test_driver/_flutter_goldens_fork.dart create mode 100644 dev/integration_tests/android_driver_test/test_driver/main_test.dart create mode 100644 packages/flutter_driver/lib/src/native/goldens.dart create mode 100644 packages/flutter_driver/lib/src/native_driver.dart 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 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0y~yVDJE84rT@hh9qO>QU(SF$r9IylHmNblJdl&R0hYC z{G?O`&)mfH)S%SFl*+=BsWuD@4C?}XLR=Xb7#RJ!7{h04Zu=H;^aq4w44VZJaXaum zG@>0o`I5 z6U6gpN>(hBs@ovlyiK}omrVN}&28UY_JdsVcA=Cl0|SF_NswPKgMfg5KtMnO2sE4r zf%}pIPku2lFue41aSVw#{PxUsz9t6&hrp0GdlTMw^~h@f`fs1s!olsWbZ#C0v7PJ9 zExVjNeb(Ol`RU07XFf|X@qSK7qke0_C&kUH>MnD(8u-mi5B zt(&|xZ@%@sBNk%nt_5+E&U1QAlyT>>sMB2Zd5T5*R3&Sld5=98o%0qdm+@@-JjKB8 z(xcUiGOB?O=e)LCdt!o~wXBiQlFwyn4J{(JaZG%Q-e|yQz{EjrrIztFmwg@gt#&=FffMC4mtWmbKAF&qdyqKXG0ipk&vT5 zAmZ%d{SXF=bCKq@Z-GZZ;tV#~j83JDzFmxd-Hahq8AGNqhE8V;o5>h9i!po_WB6=l zU2kT?Kvt(bR^JA;;7+;DUGlvL6sMk6nsrHO&Q;BA-wby`jSBSdXJueu5G@Jv3uX`y zP*5;1FzBB@fBpXR=g+?ff&5MV!VC-yOFdm2Lp+YZy>e2h$w0vMVh8iNMNLhM)++IJ z{QqCg*LgRGXKpKZaOLSMJFo8MNc{figWv3mbJp+w-BoBm(|-hT|4dcbJ2lEyX!??Y`jpZ81*D{;rF6b`?;0>>pbJRKPRw2)?8wK8GH9> zL)n=k&vkA#ITl(SSsBW`??>AG{pM|AK2Jc#t6>y4N#$)H8ZEGkUc!`m{6pbTImMF#2{f z`gJk-_b~?aGX_m$44TXsJQ-wH$W+GA>5O4B7{g{VhRtFOpT!tHo5?tW$tsn}K99+{ zjLE&4*|Uz>tC88eNi3sZJbQ{n(LBkDd1t=Sn-plsE283-d*czTa-zm@iQD#kujt@|Kvt4hp|H z71QJA-@DE-vCc=}m3vdfeqD}l&W>B;IduNM%M_}RPx;yPVrk2E_T?&?d+vqXsA}xF zGk52_Ny78;-$vOoENNLjTll#WOG?YLuZ?eO;uVxk)@?h*_~6>qo(~l}_f@BaD>p=c zm>yaWRpaQm|`^KGX?!;c=*_;KycpYksb1 rdBk${xlomwC<})`ozHbP0l+XkKcl<5t literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0y~yV3+{H9Lx+13>Rhybuln7NS3%plmzFem6RtIr7}3C z$Dctjh(SM?K|h4SAdJB{hQT_6!9JhSFr3jep3y9U z(L9OKDxJ|flhG!d(I$t{HiywRm(ebd(Y}z;v6#`Zgwd&#(Yb=rrHawDn$fL>(XE!z zt&Y*Xj?trm(W8;kvysuWiP5u}(X)lotC`WOh0&*-(Wisaw}a8QlhLo6(XWTmzlYJk zmocD^F|eO8Z~|lCM8?2Lj6o9_gC>F8A3T{ccnV|4RK}2LjG@yRLuW9C&0q|h$rv_^ zF?<$d_-uBwU=HgjPRC?Uw`?x2A}+rQ?!a2^kVfvX7VhwN?#NE=s4niP9-inPl?69d z7T#1{bVqf`J=LWT)RsR~Tk%+JQT9|ZQFKYxDR#LKT37?|`uT^vIyZoR#b9vmDf()v(?iz7_L)oF78 zkJJ<)r8pUt8M$!5-N#*39u$gn zR>-(G$wg0`aolam=a?PG9$oC(GH>-!X0d*^l*gJvuFJp95x7~EvFLL3EEhhxt`pq; z%_lBC*86lUSiR`uj?O)w{s`}^n&Wz}@?RH+ochG+^SU~oY!t72t=ZWjXFc({Ty9i< zoXX3Kohi2Ju9xPeP296QFr{gETuR&Xh?K77YDUL{MQ1+I?2fS6ciP3jaR24wmqlki zKfgU-wdbO}>o1>vX;$n~9ltDoY4ItEi|?MFbkSNqMRUkMU-}~Bs z)+NdNzgg`VE)XKX=tVT|2z5{L4{YJjHWS*rIm-E6Ws`IFvdRnm87H fR&jd1QhDAN-h`R^uRUjAU|{fc^>bP0l+XkKAv&s= literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmeAS@N?(olHy`uVBq!ia0y~yU^oE69Lx+145>_WOc@v$BuiW)N`mv#O3D+9QW+dm z@{>{(JaZG%Q-e|yQz{EjrrIztFy0FA32|j$U7=vLLgJC#>aTJ4T9D`W`gLwjjbtZ#N4x^eAqnb0LmM5cOIHO54qe%>- zNi3sjJfm3xqeU{KMGB*38lzP@qg4i@bta=t7NczrqirsuT`r?t9;018qkTT3eF39G z5u;-~4uK+6lW`UY0|SF-NswPKgMfko7&thbzyBTz-gcI+Wnf^o^K@|x zskrs_3VX0{ph#Pyi^G})N%|2R%{L?-ZV(U%Y+lqcfu(WAio}U)uIrV(+3WUaecD+A zmQ6SJem|Gdo_Kp z-*x@+`J3Y19B#kQTkx(t_`-K<>p6dn#rh_GuokuWC9d+nXX1zFdIcZVw{qEhc&=6O z^|eUFqwV?=KCBk6c(Pr4!l%_@70=G=P581}wBp75D22D-njfy%?{qk*AOB#Z{bq-g z`q2+|)-Q6ns2@Io{eMN{rubzF`u|EA+wSjG@UL6#aQc1N1oQe;4(H#;OmN?SS>~@F ztIeOC+&g|0i+=ccTfE@YZP5>((!~ot`|Cb zb^PFdrt-VXypJEaJ95?T2$v)Ws{O#V4Ednvz`|Pb} zf4VOe!@bWom-}e`tT2YtP3!%mny=z+}-K6mV2ZEt5Icwo(g z%Zr!wX>UAuh9QhqUj7<;)5IBxDQpH>KRz6|eoM6P*rQ_`EdPlXYgN?$U-3W2|NYf9 z3~H?J-^u1!lq%j3KgpL6_~F~TxcJXA&m~w0?N0Rh)m167XXobE9?sqiHmlnth&$|i z@b&H6->;epMCgPeb()B(Tr|RH~O+1PwlVxdT!ROU2OGQ3PvhLeQAwP zO|AMWqpQ}lM;-?Ix_3Z(ZoZK*%HiZ|EUDeVf^rGH<21r@EM zSf|zZ`p5aNP1ut4@|gFs0GYW>Kcl68^QPJ_Y + + + + + + + + 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';