From ccd070a5b42d18d1b8dfce4e8b59417373122dc2 Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Tue, 18 Sep 2018 09:32:39 -0400 Subject: [PATCH] Add back screenshot extension that was temporarily disabled. (#21828) Add back screenshot extension that was temporarily disabled. Add matchesReferenceImage matcher to test that the screenshot extension generates equivalent images to InspectorService.instance.screenshot. --- .../lib/src/widgets/widget_inspector.dart | 31 +++++ .../foundation/service_extensions_test.dart | 2 +- .../test/widgets/widget_inspector_test.dart | 48 ++++++-- packages/flutter_test/lib/src/matchers.dart | 111 +++++++++++++++++- .../test/reference_image_test.dart | 68 +++++++++++ 5 files changed, 249 insertions(+), 11 deletions(-) create mode 100644 packages/flutter_test/test/reference_image_test.dart diff --git a/packages/flutter/lib/src/widgets/widget_inspector.dart b/packages/flutter/lib/src/widgets/widget_inspector.dart index 09683a8165f..c9a06d96aa9 100644 --- a/packages/flutter/lib/src/widgets/widget_inspector.dart +++ b/packages/flutter/lib/src/widgets/widget_inspector.dart @@ -13,6 +13,7 @@ import 'dart:ui' as ui window, ClipOp, Image, + ImageByteFormat, Paragraph, Picture, PictureRecorder, @@ -1026,6 +1027,36 @@ class WidgetInspectorService { name: 'isWidgetCreationTracked', callback: isWidgetCreationTracked, ); + assert(() { + registerServiceExtension( + name: 'screenshot', + callback: (Map parameters) async { + assert(parameters.containsKey('id')); + assert(parameters.containsKey('width')); + assert(parameters.containsKey('height')); + + final ui.Image image = await screenshot( + toObject(parameters['id']), + width: double.parse(parameters['width']), + height: double.parse(parameters['height']), + margin: parameters.containsKey('margin') ? + double.parse(parameters['margin']) : 0.0, + maxPixelRatio: parameters.containsKey('maxPixelRatio') ? + double.parse(parameters['maxPixelRatio']) : 1.0, + debugPaint: parameters['debugPaint'] == 'true', + ); + if (image == null) { + return {'result': null}; + } + final ByteData byteData = await image.toByteData(format:ui.ImageByteFormat.png); + + return { + 'result': base64.encoder.convert(Uint8List.view(byteData.buffer)), + }; + }, + ); + return true; + }()); } /// Clear all InspectorService object references. diff --git a/packages/flutter/test/foundation/service_extensions_test.dart b/packages/flutter/test/foundation/service_extensions_test.dart index 1166e444f74..6fcb04f0ed5 100644 --- a/packages/flutter/test/foundation/service_extensions_test.dart +++ b/packages/flutter/test/foundation/service_extensions_test.dart @@ -512,7 +512,7 @@ void main() { // If you add a service extension... TEST IT! :-) // ...then increment this number. - expect(binding.extensions.length, 37); + expect(binding.extensions.length, 38); expect(console, isEmpty); debugPrint = debugPrintThrottled; diff --git a/packages/flutter/test/widgets/widget_inspector_test.dart b/packages/flutter/test/widgets/widget_inspector_test.dart index 1816a30cc96..e695a167531 100644 --- a/packages/flutter/test/widgets/widget_inspector_test.dart +++ b/packages/flutter/test/widgets/widget_inspector_test.dart @@ -5,7 +5,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io' show Platform; -import 'dart:ui' as ui show PictureRecorder; +import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -1545,18 +1545,48 @@ class TestWidgetInspectorService extends Object with WidgetInspectorService { matchesGoldenFile('inspector.clipRect_debugPaint.png'), ); + final Element clipRect = find.byType(ClipRRect).evaluate().single; + + final Future clipRectScreenshot = service.screenshot( + clipRect, + width: 100.0, + height: 100.0, + margin: 20.0, + debugPaint: true, + ); // Add a margin so that the clip icon shows up in the screenshot. // This golden image is platform dependent due to the clip icon. await expectLater( - service.screenshot( - find.byType(ClipRRect).evaluate().single, - width: 100.0, - height: 100.0, - margin: 20.0, - debugPaint: true, - ), + clipRectScreenshot, matchesGoldenFile('inspector.clipRect_debugPaint_margin.png'), - skip: !Platform.isLinux + skip: !Platform.isLinux, + ); + + // Verify we get the same image if we go through the service extension + // instead of invoking the screenshot method directly. + final Future base64ScreenshotFuture = service.testExtension( + 'screenshot', + { + 'id': service.toId(clipRect, 'group'), + 'width': '100.0', + 'height': '100.0', + 'margin': '20.0', + 'debugPaint': 'true', + }, + ); + + final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding + .ensureInitialized(); + final ui.Image screenshotImage = await binding.runAsync(() async { + final String base64Screenshot = await base64ScreenshotFuture; + final ui.Codec codec = await ui.instantiateImageCodec(base64.decode(base64Screenshot)); + final ui.FrameInfo frame = await codec.getNextFrame(); + return frame.image; + }, additionalTime: const Duration(seconds: 11)); + + await expectLater( + screenshotImage, + matchesReferenceImage(await clipRectScreenshot), ); // Test with a very visible debug paint diff --git a/packages/flutter_test/lib/src/matchers.dart b/packages/flutter_test/lib/src/matchers.dart index 231cce33b9d..c30759a5c68 100644 --- a/packages/flutter_test/lib/src/matchers.dart +++ b/packages/flutter_test/lib/src/matchers.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'dart:math' as math; +import 'dart:typed_data'; import 'dart:ui' as ui; import 'dart:ui'; @@ -275,9 +276,11 @@ Matcher coversSameAreaAs(Path expectedPath, {@required Rect areaToCompare, int s /// See also: /// /// * [goldenFileComparator], which acts as the backend for this matcher. +/// * [matchesReferenceImage], which should be used instead if you want to +/// verify that two different code paths create identical images. /// * [flutter_test] for a discussion of test configurations, whereby callers /// may swap out the backend for this matcher. -Matcher matchesGoldenFile(dynamic key) { +AsyncMatcher matchesGoldenFile(dynamic key) { if (key is Uri) { return _MatchesGoldenFile(key); } else if (key is String) { @@ -286,6 +289,42 @@ Matcher matchesGoldenFile(dynamic key) { throw ArgumentError('Unexpected type for golden file: ${key.runtimeType}'); } +/// Asserts that a [Finder], [Future], or [ui.Image] matches a +/// reference image identified by [image]. +/// +/// For the case of a [Finder], the [Finder] must match exactly one widget and +/// the rendered image of the first [RepaintBoundary] ancestor of the widget is +/// treated as the image for the widget. +/// +/// This is an asynchronous matcher, meaning that callers should use +/// [expectLater] when using this matcher and await the future returned by +/// [expectLater]. +/// +/// ## Sample code +/// +/// ```dart +/// final ui.Paint paint = ui.Paint() +/// ..style = ui.PaintingStyle.stroke +/// ..strokeWidth = 1.0; +/// final ui.PictureRecorder recorder = ui.PictureRecorder(); +/// final ui.Canvas pictureCanvas = ui.Canvas(recorder); +/// pictureCanvas.drawCircle(Offset.zero, 20.0, paint); +/// final ui.Picture picture = recorder.endRecording(); +/// ui.Image referenceImage = picture.toImage(50, 50); +/// +/// await expectLater(find.text('Save'), matchesReferenceImage(referenceImage)); +/// await expectLater(image, matchesReferenceImage(referenceImage); +/// await expectLater(imageFuture, matchesReferenceImage(referenceImage)); +/// ``` +/// +/// See also: +/// +/// * [matchesGoldenFile], which should be used instead if you need to verify +/// that a [Finder] or [ui.Image] matches a golden image. +AsyncMatcher matchesReferenceImage(ui.Image image) { + return _MatchesReferenceImage(image); +} + /// Asserts that a [SemanticsData] contains the specified information. /// /// If either the label, hint, value, textDirection, or rect fields are not @@ -1513,6 +1552,76 @@ Future _captureImage(Element element) { return layer.toImage(renderObject.paintBounds); } +int _countDifferentPixels(Uint8List imageA, Uint8List imageB) { + assert(imageA.length == imageB.length); + int delta = 0; + for (int i = 0; i < imageA.length; i+=4) { + if (imageA[i] != imageB[i] || + imageA[i+1] != imageB[i+1] || + imageA[i+2] != imageB[i+2] || + imageA[i+3] != imageB[i+3]) { + delta++; + } + } + return delta; +} + +class _MatchesReferenceImage extends AsyncMatcher { + const _MatchesReferenceImage(this.referenceImage); + + final ui.Image referenceImage; + + @override + Future matchAsync(dynamic item) async { + Future imageFuture; + if (item is Future) { + imageFuture = item; + } else if (item is ui.Image) { + imageFuture = Future.value(item); + } else { + final Finder finder = item; + final Iterable elements = finder.evaluate(); + if (elements.isEmpty) { + return 'could not be rendered because no widget was found'; + } else if (elements.length > 1) { + return 'matched too many widgets'; + } + imageFuture = _captureImage(elements.single); + } + + final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.ensureInitialized(); + return binding.runAsync(() async { + final ui.Image image = await imageFuture; + final ByteData bytes = await image.toByteData() + .timeout(const Duration(seconds: 10), onTimeout: () => null); + if (bytes == null) { + return 'Failed to generate an image from engine within the 10,000ms timeout.'; + } + + final ByteData referenceBytes = await referenceImage.toByteData() + .timeout(const Duration(seconds: 10), onTimeout: () => null); + if (referenceBytes == null) { + return 'Failed to generate an image from engine within the 10,000ms timeout.'; + } + + if (referenceImage.height != image.height || referenceImage.width != image.width) { + return 'does not match as width or height do not match. $image != $referenceImage'; + } + + final int countDifferentPixels = _countDifferentPixels( + Uint8List.view(bytes.buffer), + Uint8List.view(referenceBytes.buffer), + ); + return countDifferentPixels == 0 ? null : 'does not match on $countDifferentPixels pixels'; + }, additionalTime: const Duration(seconds: 21)); + } + + @override + Description describe(Description description) { + return description.add('rasterized image matches that of a $referenceImage reference image'); + } +} + class _MatchesGoldenFile extends AsyncMatcher { const _MatchesGoldenFile(this.key); diff --git a/packages/flutter_test/test/reference_image_test.dart b/packages/flutter_test/test/reference_image_test.dart new file mode 100644 index 00000000000..5e97180e85e --- /dev/null +++ b/packages/flutter_test/test/reference_image_test.dart @@ -0,0 +1,68 @@ +// Copyright 2018 The Chromium 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:ui' as ui; + +import 'package:flutter_test/flutter_test.dart'; + +ui.Image createTestImage(int width, int height, ui.Color color) { + final ui.Paint paint = ui.Paint() + ..style = ui.PaintingStyle.stroke + ..strokeWidth = 1.0 + ..color = color; + final ui.PictureRecorder recorder = ui.PictureRecorder(); + final ui.Canvas pictureCanvas = ui.Canvas(recorder); + pictureCanvas.drawCircle(Offset.zero, 20.0, paint); + final ui.Picture picture = recorder.endRecording(); + return picture.toImage(width, height); +} + +void main() { + const ui.Color red = ui.Color.fromARGB(255, 255, 0, 0); + const ui.Color green = ui.Color.fromARGB(255, 0, 255, 0); + const ui.Color transparentRed = ui.Color.fromARGB(128, 255, 0, 0); + + group('succeeds', () { + testWidgets('when images have the same content', (WidgetTester tester) async { + await expectLater( + createTestImage(100, 100, red), + matchesReferenceImage(createTestImage(100, 100, red)), + ); + await expectLater( + createTestImage(100, 100, green), + matchesReferenceImage(createTestImage(100, 100, green)), + ); + + await expectLater( + createTestImage(100, 100, transparentRed), + matchesReferenceImage(createTestImage(100, 100, transparentRed)), + ); + }); + + testWidgets('when images are identical', (WidgetTester tester) async { + final ui.Image image = createTestImage(100, 100, red); + await expectLater(image, matchesReferenceImage(image)); + }); + }); + + group('fails', () { + testWidgets('when image sizes do not match', (WidgetTester tester) async { + expect( + await matchesReferenceImage(createTestImage(50, 50, red)).matchAsync(createTestImage(100, 100, red)), + equals('does not match as width or height do not match. [100×100] != [50×50]'), + ); + }); + + testWidgets('when image pixels do not match', (WidgetTester tester) async { + expect( + await matchesReferenceImage(createTestImage(100, 100, red)).matchAsync(createTestImage(100, 100, transparentRed)), + equals('does not match on 53 pixels'), + ); + expect( + await matchesReferenceImage(createTestImage(100, 100, red)).matchAsync(createTestImage(100, 100, green)), + equals('does not match on 57 pixels'), + ); + }); + }); +}