diff --git a/packages/flutter_test/lib/src/_matchers_io.dart b/packages/flutter_test/lib/src/_matchers_io.dart index 75d936fd43..63c9b52342 100644 --- a/packages/flutter_test/lib/src/_matchers_io.dart +++ b/packages/flutter_test/lib/src/_matchers_io.dart @@ -48,6 +48,26 @@ class MatchesGoldenFile extends AsyncMatcher { @override Future matchAsync(dynamic item) async { + final Uri testNameUri = goldenFileComparator.getTestUri(key, version); + + Uint8List? buffer; + if (item is Future>) { + buffer = Uint8List.fromList(await item); + } else if (item is List) { + buffer = Uint8List.fromList(item); + } + if (buffer != null) { + 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 (ex) { + return ex.message; + } + } Future imageFuture; if (item is Future) { imageFuture = item; @@ -62,11 +82,9 @@ class MatchesGoldenFile extends AsyncMatcher { } imageFuture = captureImage(elements.single); } else { - throw 'must provide a Finder, Image, or Future'; + throw 'must provide a Finder, Image, Future, List, or Future>'; } - final Uri testNameUri = goldenFileComparator.getTestUri(key, version); - final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.ensureInitialized() as TestWidgetsFlutterBinding; return binding.runAsync(() async { final ui.Image? image = await imageFuture; diff --git a/packages/flutter_test/test/matchers_test.dart b/packages/flutter_test/test/matchers_test.dart index 1c6782d01e..9e63df845f 100644 --- a/packages/flutter_test/test/matchers_test.dart +++ b/packages/flutter_test/test/matchers_test.dart @@ -352,7 +352,6 @@ void main() { }); group('matches', () { - testWidgets('if comparator succeeds', (WidgetTester tester) async { await tester.pumpWidget(boilerplate(const Text('hello'))); final Finder finder = find.byType(Text); @@ -361,6 +360,20 @@ void main() { expect(comparator.imageBytes, hasLength(greaterThan(0))); expect(comparator.golden, Uri.parse('foo.png')); }); + + testWidgets('list of integers', (WidgetTester tester) async { + await expectLater([1, 2], matchesGoldenFile('foo.png')); + expect(comparator.invocation, _ComparatorInvocation.compare); + expect(comparator.imageBytes, equals([1, 2])); + expect(comparator.golden, Uri.parse('foo.png')); + }); + + testWidgets('future list of integers', (WidgetTester tester) async { + await expectLater(Future>.value([1, 2]), matchesGoldenFile('foo.png')); + expect(comparator.invocation, _ComparatorInvocation.compare); + expect(comparator.imageBytes, equals([1, 2])); + expect(comparator.golden, Uri.parse('foo.png')); + }); }); group('does not match', () { diff --git a/packages/integration_test/README.md b/packages/integration_test/README.md index 207d52b04c..b25c3576c0 100644 --- a/packages/integration_test/README.md +++ b/packages/integration_test/README.md @@ -95,6 +95,77 @@ flutter drive \ -d web-server ``` +### Screenshots + +You can use `integration_test` to take screenshots of the UI rendered on the mobile device or +Web browser at a specific time during the test. + +This feature is currently supported on Android, and Web. + +#### Android + +**integration_test/screenshot_test.dart** + +```dart +void main() { + final IntegrationTestWidgetsFlutterBinding binding = + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('screenshot', (WidgetTester tester) async { + // Build the app. + app.main(); + + // This is required prior to taking the screenshot. + await binding.convertFlutterSurfaceToImage(); + + // Trigger a frame. + await tester.pumpAndSettle(); + await binding.takeScreenshot('screenshot-1'); + }); +} +``` + +You can use a driver script to pull in the screenshot from the device. +This way, you can store the images locally on your computer. + +**test_driver/integration_test.dart** + +```dart +import 'dart:io'; +import 'package:integration_test/integration_test_driver_extended.dart'; + +Future main() async { + await integrationDriver( + onScreenshot: (String screenshotName, List screenshotBytes) async { + final File image = File('$screenshotName.png'); + image.writeAsBytesSync(screenshotBytes); + // Return false if the screenshot is invalid. + return true; + }, + ); +} +``` + +#### Web + +**integration_test/screenshot_test.dart** + +```dart +void main() { + final IntegrationTestWidgetsFlutterBinding binding = + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('screenshot', (WidgetTester tester) async { + // Build the app. + app.main(); + + // Trigger a frame. + await tester.pumpAndSettle(); + await binding.takeScreenshot('screenshot-1'); + }); +} +``` + ## Android Device Testing Create an instrumentation test file in your application's diff --git a/packages/integration_test/android/build.gradle b/packages/integration_test/android/build.gradle index 96ad4d4faa..3f358c00cb 100644 --- a/packages/integration_test/android/build.gradle +++ b/packages/integration_test/android/build.gradle @@ -39,6 +39,8 @@ android { } dependencies { + // TODO(egarciad): These dependencies should not be added to release builds. + // https://github.com/flutter/flutter/issues/56591 api 'junit:junit:4.12' // https://developer.android.com/jetpack/androidx/releases/test/#1.2.0 diff --git a/packages/integration_test/android/src/main/java/dev/flutter/plugins/integration_test/FlutterDeviceScreenshot.java b/packages/integration_test/android/src/main/java/dev/flutter/plugins/integration_test/FlutterDeviceScreenshot.java new file mode 100644 index 0000000000..5494eda1e8 --- /dev/null +++ b/packages/integration_test/android/src/main/java/dev/flutter/plugins/integration_test/FlutterDeviceScreenshot.java @@ -0,0 +1,262 @@ +// 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. + +package dev.flutter.plugins.integration_test; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.os.Build; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.view.Choreographer; +import android.view.PixelCopy; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import io.flutter.embedding.android.FlutterActivity; +import io.flutter.embedding.android.FlutterSurfaceView; +import io.flutter.embedding.android.FlutterView; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.MethodChannel.Result; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.lang.StringBuilder; + +/** + * FlutterDeviceScreenshot is a utility class that allows to capture a screenshot + * that includes both Android views and the Flutter UI. + * + * To take screenshots, the rendering surface must be changed to {@code FlutterImageView}, + * since surfaces like {@code FlutterSurfaceView} and {@code FlutterTextureView} are opaque + * when the view hierarchy is rendered to a bitmap. + * + * It's also necessary to ask the framework to schedule a frame, and then add a listener + * that waits for that frame to be presented by the Android framework. + */ +@TargetApi(19) +class FlutterDeviceScreenshot { + /** + * Finds the {@code FlutterView} added to the {@code activity} view hierarchy. + * + *

This assumes that there's only one {@code FlutterView} per activity, which + * is always the case. + * + * @param activity typically, {code FlutterActivity}. + * @return the Flutter view. + */ + @Nullable + private static FlutterView getFlutterView(@NonNull Activity activity) { + return (FlutterView)activity.findViewById(FlutterActivity.FLUTTER_VIEW_ID); + } + + /** + * Whether the app is run with instrumentation. + * + * @return true if the app is running with instrumentation. + */ + static boolean hasInstrumentation() { + // TODO(egarciad): InstrumentationRegistry requires the uiautomator dependency. + // However, Flutter adds test dependencies to release builds. + // As a result, disable screenshots with instrumentation until the issue is fixed. + // https://github.com/flutter/flutter/issues/56591 + return false; + } + + /** + * Captures a screenshot using ui automation. + * + * @return byte array containing the screenshot. + */ + static byte[] captureWithUiAutomation() throws IOException { + return new byte[0]; + } + + // Whether the flutter surface is already converted to an image. + private static boolean flutterSurfaceConvertedToImage = false; + + /** + * Converts the Flutter surface to an image view. + * This allows to render the view hierarchy to a bitmap since + * {@code FlutterSurfaceView} and {@code FlutterTextureView} cannot be rendered to a bitmap. + * + * @param activity typically {@code FlutterActivity}. + */ + static void convertFlutterSurfaceToImage(@NonNull Activity activity) { + final FlutterView flutterView = getFlutterView(activity); + if (flutterView != null && !flutterSurfaceConvertedToImage) { + flutterView.convertToImageView(); + flutterSurfaceConvertedToImage = true; + } + } + + /** + * Restores the original Flutter surface. + * The new surface will either be {@code FlutterSurfaceView} or {@code FlutterTextureView}. + * + * @param activity typically {@code FlutterActivity}. + * @param onDone callback called once the surface has been restored. + */ + static void revertFlutterImage(@NonNull Activity activity) { + final FlutterView flutterView = getFlutterView(activity); + if (flutterView != null && flutterSurfaceConvertedToImage) { + flutterView.revertImageView(() -> { + flutterSurfaceConvertedToImage = false; + }); + } + } + + // Handlers use to capture a view. + private static Handler backgroundHandler; + private static Handler mainHandler; + + /** + * Captures a screenshot by drawing the view to a Canvas. + * + *

{@code convertFlutterSurfaceToImage} must be called prior to capturing the view, + * otherwise the result is an error. + * + * @param activity this is {@link FlutterActivity}. + * @param methodChannel the method channel to call into Dart. + * @param result the result for the method channel that will contain the byte array. + */ + static void captureView( + @NonNull Activity activity, @NonNull MethodChannel methodChannel, @NonNull Result result) { + final FlutterView flutterView = getFlutterView(activity); + if (flutterView == null) { + result.error("Could not copy the pixels", "FlutterView is null", null); + return; + } + if (!flutterSurfaceConvertedToImage) { + result.error("Could not copy the pixels", "Flutter surface must be converted to image first", null); + return; + } + + // Ask the framework to schedule a new frame. + methodChannel.invokeMethod("scheduleFrame", null); + + if (backgroundHandler == null) { + final HandlerThread screenshotBackgroundThread = new HandlerThread("screenshot"); + screenshotBackgroundThread.start(); + backgroundHandler = new Handler(screenshotBackgroundThread.getLooper()); + } + if (mainHandler == null) { + mainHandler = new Handler(Looper.getMainLooper()); + } + takeScreenshot(backgroundHandler, mainHandler, flutterView, result); + } + + /** + * Waits for the next Android frame. + * + * @param r a callback. + */ + private static void waitForAndroidFrame(Runnable r) { + Choreographer.getInstance() + .postFrameCallback( + new Choreographer.FrameCallback() { + @Override + public void doFrame(long frameTimeNanos) { + r.run(); + } + }); + } + + /** + * Waits until a Flutter frame is rendered by the Android OS. + * + * @param backgroundHandler the handler associated to a background thread. + * @param mainHandler the handler associated to the platform thread. + * @param view the flutter view. + * @param result the result that contains the byte array. + */ + private static void takeScreenshot( + @NonNull Handler backgroundHandler, + @NonNull Handler mainHandler, + @NonNull FlutterView view, + @NonNull Result result) { + final boolean acquired = view.acquireLatestImageViewFrame(); + // The next frame may already have already been comitted. + // The next frame is guaranteed to have the Flutter image. + waitForAndroidFrame( + () -> { + waitForAndroidFrame( + () -> { + if (acquired) { + FlutterDeviceScreenshot.convertViewToBitmap(view, result, backgroundHandler); + } else { + takeScreenshot(backgroundHandler, mainHandler, view, result); + } + }); + }); + } + + /** + * Renders {@code FlutterView} to a Bitmap. + * + * If successful, The byte array is provided in the result. + * + * @param flutterView the Flutter view. + * @param result the result that contains the byte array. + * @param backgroundHandler a background handler to avoid blocking the platform thread. + */ + private static void convertViewToBitmap( + @NonNull FlutterView flutterView, @NonNull Result result, @NonNull Handler backgroundHandler) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + final Bitmap bitmap = + Bitmap.createBitmap( + flutterView.getWidth(), flutterView.getHeight(), Bitmap.Config.RGB_565); + final Canvas canvas = new Canvas(bitmap); + flutterView.draw(canvas); + + final ByteArrayOutputStream output = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.PNG, /*quality=*/ 100, output); + result.success(output.toByteArray()); + return; + } + + final Bitmap bitmap = + Bitmap.createBitmap( + flutterView.getWidth(), flutterView.getHeight(), Bitmap.Config.ARGB_8888); + + final int[] flutterViewLocation = new int[2]; + flutterView.getLocationInWindow(flutterViewLocation); + final int flutterViewLeft = flutterViewLocation[0]; + final int flutterViewTop = flutterViewLocation[1]; + + final Rect flutterViewRect = + new Rect( + flutterViewLeft, + flutterViewTop, + flutterViewLeft + flutterView.getWidth(), + flutterViewTop + flutterView.getHeight()); + + final Activity flutterActivity = (Activity) flutterView.getContext(); + PixelCopy.request( + flutterActivity.getWindow(), + flutterViewRect, + bitmap, + (int copyResult) -> { + final Handler mainHandler = new Handler(Looper.getMainLooper()); + if (copyResult == PixelCopy.SUCCESS) { + final ByteArrayOutputStream output = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.PNG, /*quality=*/ 100, output); + mainHandler.post( + () -> { + result.success(output.toByteArray()); + }); + } else { + mainHandler.post( + () -> { + result.error("Could not copy the pixels", "result was " + copyResult, null); + }); + } + }, + backgroundHandler); + } +} diff --git a/packages/integration_test/android/src/main/java/dev/flutter/plugins/integration_test/IntegrationTestPlugin.java b/packages/integration_test/android/src/main/java/dev/flutter/plugins/integration_test/IntegrationTestPlugin.java index 81b557b824..7a230f0ead 100644 --- a/packages/integration_test/android/src/main/java/dev/flutter/plugins/integration_test/IntegrationTestPlugin.java +++ b/packages/integration_test/android/src/main/java/dev/flutter/plugins/integration_test/IntegrationTestPlugin.java @@ -4,26 +4,30 @@ package dev.flutter.plugins.integration_test; +import android.app.Activity; import android.content.Context; import com.google.common.util.concurrent.SettableFuture; import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.embedding.engine.plugins.activity.ActivityAware; +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; import io.flutter.plugin.common.BinaryMessenger; import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel.MethodCallHandler; import io.flutter.plugin.common.MethodChannel.Result; +import java.io.IOException; import java.util.Map; import java.util.concurrent.Future; /** IntegrationTestPlugin */ -public class IntegrationTestPlugin implements MethodCallHandler, FlutterPlugin { - private MethodChannel methodChannel; - +public class IntegrationTestPlugin implements MethodCallHandler, FlutterPlugin, ActivityAware { + private static final String CHANNEL = "plugins.flutter.io/integration_test"; private static final SettableFuture> testResultsSettable = SettableFuture.create(); - public static final Future> testResults = testResultsSettable; - private static final String CHANNEL = "plugins.flutter.io/integration_test"; + private MethodChannel methodChannel; + private Activity flutterActivity; + public static final Future> testResults = testResultsSettable; /** Plugin registration. */ @SuppressWarnings("deprecation") @@ -48,14 +52,70 @@ public class IntegrationTestPlugin implements MethodCallHandler, FlutterPlugin { methodChannel = null; } + @Override + public void onAttachedToActivity(ActivityPluginBinding binding) { + flutterActivity = binding.getActivity(); + } + + @Override + public void onReattachedToActivityForConfigChanges(ActivityPluginBinding binding) { + flutterActivity = binding.getActivity(); + } + + @Override + public void onDetachedFromActivity() { + flutterActivity = null; + } + + @Override + public void onDetachedFromActivityForConfigChanges() { + flutterActivity = null; + } + @Override public void onMethodCall(MethodCall call, Result result) { - if (call.method.equals("allTestsFinished")) { - Map results = call.argument("results"); - testResultsSettable.set(results); - result.success(null); - } else { - result.notImplemented(); + switch (call.method) { + case "allTestsFinished": + final Map results = call.argument("results"); + testResultsSettable.set(results); + result.success(null); + return; + case "convertFlutterSurfaceToImage": + if (flutterActivity == null) { + result.error("Could not convert to image", "Activity not initialized", null); + return; + } + FlutterDeviceScreenshot.convertFlutterSurfaceToImage(flutterActivity); + result.success(null); + return; + case "revertFlutterImage": + if (flutterActivity == null) { + result.error("Could not revert Flutter image", "Activity not initialized", null); + return; + } + FlutterDeviceScreenshot.revertFlutterImage(flutterActivity); + result.success(null); + return; + case "captureScreenshot": + if (FlutterDeviceScreenshot.hasInstrumentation()) { + byte[] image; + try { + image = FlutterDeviceScreenshot.captureWithUiAutomation(); + } catch (IOException exception) { + result.error("Could not capture screenshot", "UiAutomation failed", exception); + return; + } + result.success(image); + return; + } + if (flutterActivity == null) { + result.error("Could not capture screenshot", "Activity not initialized", null); + return; + } + FlutterDeviceScreenshot.captureView(flutterActivity, methodChannel, result); + return; + default: + result.notImplemented(); } } } diff --git a/packages/integration_test/example/android/app/src/main/AndroidManifest.xml b/packages/integration_test/example/android/app/src/main/AndroidManifest.xml index 189191cbe7..32833b71c9 100644 --- a/packages/integration_test/example/android/app/src/main/AndroidManifest.xml +++ b/packages/integration_test/example/android/app/src/main/AndroidManifest.xml @@ -14,12 +14,6 @@ found in the LICENSE file. --> android:name="io.flutter.app.FlutterApplication" android:label="integration_test_example" android:icon="@mipmap/ic_launcher"> - - firstPng = await binding.takeScreenshot('first'); + expect(firstPng.isNotEmpty, isTrue); + + // Pump another frame before taking the screenshot. + await tester.pumpAndSettle(); + final List secondPng = await binding.takeScreenshot('second'); + expect(secondPng.isNotEmpty, isTrue); + + expect(listEquals(firstPng, secondPng), isTrue); // Verify that platform version is retrieved. expect( diff --git a/packages/integration_test/example/integration_test/extended_test.dart b/packages/integration_test/example/integration_test/extended_test.dart index bcdf583a69..883bb08a44 100644 --- a/packages/integration_test/example/integration_test/extended_test.dart +++ b/packages/integration_test/example/integration_test/extended_test.dart @@ -4,8 +4,7 @@ // This is a Flutter widget test can take a screenshot. // -// NOTE: Screenshots are only supported on Web for now. For Web, this needs to -// be executed with the `test_driver/integration_test_extended_driver.dart`. +// For Web, this needs to be executed with the `test_driver/integration_test_extended_driver.dart`. // // To perform an interaction with a widget in your test, use the WidgetTester // utility that Flutter provides. For example, you can send tap and scroll diff --git a/packages/integration_test/example/test_driver/extended_integration_test.dart b/packages/integration_test/example/test_driver/extended_integration_test.dart index 0754d70e4e..7595392f04 100644 --- a/packages/integration_test/example/test_driver/extended_integration_test.dart +++ b/packages/integration_test/example/test_driver/extended_integration_test.dart @@ -10,6 +10,7 @@ Future main() async { await integrationDriver( driver: driver, onScreenshot: (String screenshotName, List screenshotBytes) async { + // Return false if the screenshot is invalid. return true; }, ); diff --git a/packages/integration_test/lib/_callback_io.dart b/packages/integration_test/lib/_callback_io.dart index 286487d3fd..8717305107 100644 --- a/packages/integration_test/lib/_callback_io.dart +++ b/packages/integration_test/lib/_callback_io.dart @@ -2,7 +2,13 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:ui'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + import 'common.dart'; +import 'src/channel.dart'; /// The dart:io implementation of [CallbackManager]. /// @@ -54,9 +60,53 @@ class IOCallbackManager implements CallbackManager { // comes up in the future. For example: `WebCallbackManager.cleanup`. } + // Whether the Flutter surface uses an Image. + bool _usesFlutterImage = false; + @override - Future takeScreenshot(String screenshot) { - throw UnimplementedError( - 'Screenshots are not implemented on this platform'); + Future convertFlutterSurfaceToImage() async { + assert(!_usesFlutterImage, 'Surface already converted to an image'); + await integrationTestChannel.invokeMethod( + 'convertFlutterSurfaceToImage', + null, + ); + _usesFlutterImage = true; + + addTearDown(() async { + assert(_usesFlutterImage, 'Surface is not an image'); + await integrationTestChannel.invokeMethod( + 'revertFlutterImage', + null, + ); + _usesFlutterImage = false; + }); + } + + @override + Future> takeScreenshot(String screenshot) async { + if (!_usesFlutterImage) { + throw StateError('Call convertFlutterSurfaceToImage() before taking a screenshot'); + } + integrationTestChannel.setMethodCallHandler(_onMethodChannelCall); + final List? rawBytes = await integrationTestChannel.invokeMethod>( + 'captureScreenshot', + null, + ); + if (rawBytes == null) { + throw StateError('Expected a list of bytes, but instead captureScreenshot returned null'); + } + return { + 'screenshotName': screenshot, + 'bytes': rawBytes, + }; + } + + Future _onMethodChannelCall(MethodCall call) async { + switch (call.method) { + case 'scheduleFrame': + window.scheduleFrame(); + break; + } + return null; } } diff --git a/packages/integration_test/lib/_callback_web.dart b/packages/integration_test/lib/_callback_web.dart index 6af405d7ef..0d1f82f82f 100644 --- a/packages/integration_test/lib/_callback_web.dart +++ b/packages/integration_test/lib/_callback_web.dart @@ -44,8 +44,15 @@ class WebCallbackManager implements CallbackManager { /// /// See: https://www.w3.org/TR/webdriver/#screen-capture. @override - Future takeScreenshot(String screenshotName) async { + Future> takeScreenshot(String screenshotName) async { await _sendWebDriverCommand(WebDriverCommand.screenshot(screenshotName)); + // Flutter Web doesn't provide the bytes. + return const {'bytes': []}; + } + + @override + Future convertFlutterSurfaceToImage() async { + // Noop on Web. } Future _sendWebDriverCommand(WebDriverCommand command) async { diff --git a/packages/integration_test/lib/common.dart b/packages/integration_test/lib/common.dart index a904072b64..67a61c3f22 100644 --- a/packages/integration_test/lib/common.dart +++ b/packages/integration_test/lib/common.dart @@ -5,6 +5,20 @@ import 'dart:async'; import 'dart:convert'; +/// A callback to use with [integrationDriver]. +/// +/// The callback receives the name of screenshot passed to `binding.takeScreenshot()` and +/// a PNG byte buffer. +/// +/// The callback returns `true` if the test passes or `false` otherwise. +/// +/// You can use this callback to store the bytes locally in a file or upload them to a service +/// that compares the image against a gold or baseline version. +/// +/// Since the function is executed on the host driving the test, you can access any environment +/// variable from it. +typedef ScreenshotCallback = Future Function(String name, List image); + /// Classes shared between `integration_test.dart` and `flutter drive` based /// adoptor (ex: `integration_test_driver.dart`). @@ -270,8 +284,12 @@ abstract class CallbackManager { Future> callback( Map params, IntegrationTestResults testRunner); - /// Request to take a screenshot of the application. - Future takeScreenshot(String screenshot); + /// Takes a screenshot of the application. + /// Returns the data that is sent back to the host. + Future> takeScreenshot(String screenshot); + + /// Android only. Converts the Flutter surface to an image view. + Future convertFlutterSurfaceToImage(); /// Cleanup and completers or locks used during the communication. void cleanup(); diff --git a/packages/integration_test/lib/integration_test.dart b/packages/integration_test/lib/integration_test.dart index 76aa7b2bc7..10548c47bf 100644 --- a/packages/integration_test/lib/integration_test.dart +++ b/packages/integration_test/lib/integration_test.dart @@ -17,6 +17,7 @@ import 'package:vm_service/vm_service_io.dart' as vm_io; import '_callback_io.dart' if (dart.library.html) '_callback_web.dart' as driver_actions; import '_extension_io.dart' if (dart.library.html) '_extension_web.dart'; import 'common.dart'; +import 'src/channel.dart'; const String _success = 'success'; @@ -51,7 +52,7 @@ class IntegrationTestWidgetsFlutterBinding extends LiveTestWidgetsFlutterBinding } try { - await _channel.invokeMethod( + await integrationTestChannel.invokeMethod( 'allTestsFinished', { 'results': results.map((String name, Object result) { @@ -144,9 +145,6 @@ https://flutter.dev/docs/testing/integration-tests#testing-on-firebase-test-lab return WidgetsBinding.instance!; } - static const MethodChannel _channel = - MethodChannel('plugins.flutter.io/integration_test'); - /// Test results that will be populated after the tests have completed. /// /// Keys are the test descriptions, and values are either [_success] or @@ -167,11 +165,29 @@ https://flutter.dev/docs/testing/integration-tests#testing-on-firebase-test-lab /// side. final CallbackManager callbackManager = driver_actions.callbackManager; - /// Taking a screenshot. + /// Takes a screenshot. /// - /// Called by test methods. Implementation differs for each platform. - Future takeScreenshot(String screenshotName) async { - await callbackManager.takeScreenshot(screenshotName); + /// On Android, you need to call `convertFlutterSurfaceToImage()`, and + /// pump a frame before taking a screenshot. + Future> takeScreenshot(String screenshotName) async { + reportData ??= {}; + reportData!['screenshots'] ??= []; + final Map data = await callbackManager.takeScreenshot(screenshotName); + assert(data.containsKey('bytes')); + + (reportData!['screenshots']! as List).add(data); + return data['bytes']! as List; + } + + /// Android only. Converts the Flutter surface to an image view. + /// Be aware that if you are conducting a perf test, you may not want to call + /// this method since the this is an expensive operation that affects the + /// rendering of a Flutter app. + /// + /// Once the screenshot is taken, call `revertFlutterImage()` to restore + /// the original Flutter surface. + Future convertFlutterSurfaceToImage() async { + await callbackManager.convertFlutterSurfaceToImage(); } /// The callback function to response the driver side input. diff --git a/packages/integration_test/lib/integration_test_driver.dart b/packages/integration_test/lib/integration_test_driver.dart index e01a229d27..149eec4314 100644 --- a/packages/integration_test/lib/integration_test_driver.dart +++ b/packages/integration_test/lib/integration_test_driver.dart @@ -45,13 +45,6 @@ Future writeResponseData( /// Adaptor to run an integration test using `flutter drive`. /// -/// `timeout` controls the longest time waited before the test ends. -/// It is not necessarily the execution time for the test app: the test may -/// finish sooner than the `timeout`. -/// -/// `responseDataCallback` is the handler for processing [Response.data]. -/// The default value is `writeResponseData`. -/// /// To an integration test `.dart` using `flutter drive`, put a file named /// `_test.dart` in the app's `test_driver` directory: /// @@ -63,6 +56,21 @@ Future writeResponseData( /// Future main() async => integrationDriver(); /// /// ``` +/// +/// ## Parameters: +/// +/// `timeout` controls the longest time waited before the test ends. +/// It is not necessarily the execution time for the test app: the test may +/// finish sooner than the `timeout`. +/// +/// `responseDataCallback` is the handler for processing [Response.data]. +/// The default value is `writeResponseData`. +/// +/// `onScreenshot` can be used to process the screenshots taken during the test. +/// An example could be that this callback compares the byte array against a baseline image, +/// and it returns `true` if both images are equal. +/// +/// As a result, returning `false` from `onScreenshot` will make the test fail. Future integrationDriver({ Duration timeout = const Duration(minutes: 20), ResponseDataCallback? responseDataCallback = writeResponseData, @@ -70,6 +78,7 @@ Future integrationDriver({ final FlutterDriver driver = await FlutterDriver.connect(); final String jsonResult = await driver.requestData(null, timeout: timeout); final Response response = Response.fromJson(jsonResult); + await driver.close(); if (response.allTestsPassed) { diff --git a/packages/integration_test/lib/integration_test_driver_extended.dart b/packages/integration_test/lib/integration_test_driver_extended.dart index 3132b17b45..9131858cce 100644 --- a/packages/integration_test/lib/integration_test_driver_extended.dart +++ b/packages/integration_test/lib/integration_test_driver_extended.dart @@ -9,11 +9,36 @@ import 'package:flutter_driver/flutter_driver.dart'; import 'common.dart'; -/// A callback to use with [integrationDriver]. -typedef ScreenshotCallback = Future Function(String name, List image); - -/// Example Integration Test which can also run WebDriver command depending on -/// the requests coming from the test methods. +/// Adaptor to run an integration test using `flutter drive`. +/// +/// To an integration test `.dart` using `flutter drive`, put a file named +/// `_test.dart` in the app's `test_driver` directory: +/// +/// ```dart +/// import 'dart:async'; +/// +/// import 'package:integration_test/integration_test_driver_extended.dart'; +/// +/// Future main() async { +/// final FlutterDriver driver = await FlutterDriver.connect(); +/// await integrationDriver( +/// driver: driver, +/// onScreenshot: (String screenshotName, List screenshotBytes) async { +/// return true; +/// }, +/// ); +/// } +/// ``` +/// +/// ## Parameters: +/// +/// `driver` A custom driver. Defaults to `FlutterDriver.connect()`. +/// +/// `onScreenshot` can be used to process the screenshots taken during the test. +/// An example could be that this callback compares the byte array against a baseline image, +/// and it returns `true` if both images are equal. +/// +/// As a result, returning `false` from `onScreenshot` will make the test fail. Future integrationDriver( {FlutterDriver? driver, ScreenshotCallback? onScreenshot}) async { driver ??= await FlutterDriver.connect(); @@ -66,6 +91,30 @@ Future integrationDriver( print('result $jsonResponse'); } + if (response.data != null && response.data!['screenshots'] != null && onScreenshot != null) { + final List screenshots = response.data!['screenshots'] as List; + final List failures = []; + for (final dynamic screenshot in screenshots) { + final Map data = screenshot as Map; + final List screenshotBytes = data['bytes'] as List; + final String screenshotName = data['screenshotName'] as String; + + bool ok = false; + try { + ok = await onScreenshot(screenshotName, screenshotBytes.cast()); + } catch (exception) { + throw StateError('Screenshot failure:\n' + 'onScreenshot("$screenshotName", ) threw an exception: $exception'); + } + if (!ok) { + failures.add(screenshotName); + } + } + if (failures.isNotEmpty) { + throw StateError('The following screenshot tests failed: ${failures.join(', ')}'); + } + } + await driver.close(); if (response.allTestsPassed) { diff --git a/packages/integration_test/lib/src/channel.dart b/packages/integration_test/lib/src/channel.dart new file mode 100644 index 0000000000..7c25f0c06c --- /dev/null +++ b/packages/integration_test/lib/src/channel.dart @@ -0,0 +1,9 @@ +// 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/services.dart'; + +/// The method channel used to report the result of the tests to the platform. +/// On Android, this is relevant when running instrumented tests. +const MethodChannel integrationTestChannel = MethodChannel('plugins.flutter.io/integration_test');