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.
This commit is contained in:
Matan Lurey
2024-07-26 09:09:29 -07:00
committed by GitHub
parent 4b5d24e6a8
commit 7f8ad8a4cb
31 changed files with 932 additions and 20 deletions

View File

@@ -3,15 +3,22 @@
// found in the LICENSE file.
import 'package:path/path.dart' as path;
import '../run_command.dart';
import '../utils.dart';
Future<void> runFlutterDriverAndroidTests() async {
print('Running Flutter Driver Android tests...');
await runDartTest(
path.join(flutterRoot, 'packages', 'flutter_driver'),
testPaths: <String>[
'test/src/native_tests/android',
// TODO(matanlurey): Should we be using another instrumentation method?
await runCommand(
'flutter',
<String>[
'drive',
],
workingDirectory: path.join(
'dev',
'integration_tests',
'android_driver_test',
),
);
}

View File

@@ -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'

View File

@@ -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.

View File

@@ -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

View File

@@ -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 = "../.."
}

View File

@@ -0,0 +1,11 @@
<!-- 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. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,49 @@
<!-- 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. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="android_driver_test"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@@ -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()
}
}

View File

@@ -0,0 +1,16 @@
<!-- 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. -->
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@@ -0,0 +1,16 @@
<!-- 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. -->
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -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. -->
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -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. -->
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,11 @@
<!-- 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. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -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
}

View File

@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true

View File

@@ -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

View File

@@ -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"

View File

@@ -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));
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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<void> testExecutable(
FutureOr<void> 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<bool> 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<void> 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(<String>[
if (namePrefix != null) namePrefix!,
baseDir.pathSegments[baseDir.pathSegments.length - 2],
golden.toString(),
].join('.'));
}
}

View File

@@ -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<void> _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);
}

View File

@@ -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<AndroidNativeDriver> 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<void> _smokeTest() async {
final io.ProcessResult version = await io.Process.run(
Future<io.ProcessResult> _adb(
List<String> args, {
Encoding? stdoutEncoding = io.systemEncoding,
}) {
return io.Process.run(
_adbPath,
const <String>['version'],
<String>[
..._target._toAdbArgs(),
...args,
],
stdoutEncoding: stdoutEncoding,
);
}
Future<void> _smokeTest() async {
final io.ProcessResult version = await _adb(<String>['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(
<String>[
..._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<void> configureForScreenshotTesting() async {
const Map<String, String> settings = <String, String>{
'show_surface_updates': '1',
'transition_animation_scale': '0',
'window_animation_scale': '0',
'animator_duration_scale': '0',
};
for (final MapEntry<String, String> entry in settings.entries) {
final io.ProcessResult result = await _adb(
<String>[
'shell',
'settings',
'put',
'global',
entry.key,
entry.value,
],
);
if (result.exitCode != 0) {
throw StateError('Failed to configure device: ${result.stderr}');
}
}
}
@override
Future<NativeScreenshot> screenshot() async {
final io.ProcessResult result = await io.Process.run(
_adbPath,
// Similar pause to the one in `<FlutterDriver>.screenshot()`.
await Future<void>.delayed(const Duration(seconds: 2));
final io.ProcessResult result = await _adb(
<String>[
..._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.
///

View File

@@ -26,6 +26,16 @@ abstract interface class NativeDriver {
/// After calling this method, the driver is no longer usable.
Future<void> 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<void> configureForScreenshotTesting();
/// Take a screenshot using a platform-specific mechanism.
///
/// The image is returned as an opaque handle that can be used to retrieve

View File

@@ -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<bool> 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<void> 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<bool> 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<void> 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<NativeScreenshot>], or
/// [List<int>] 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<String?> matchAsync(Object? item) async {
final Uri testNameUri = goldenFileComparator.getTestUri(key, version);
final Uint8List buffer;
if (item is FutureOr<List<int>>) {
buffer = Uint8List.fromList(await item);
} else if (item is FutureOr<NativeScreenshot>) {
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"');
}
}

View File

@@ -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';