blob: 17a09e53735d04c91e1d92c90ce30b60f3051997 [file]
// 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.
// We use `print` for logging here.
// ignore_for_file: avoid_print
/// @docImport 'dart:io';
library;
import 'dart:async' show Completer, FutureOr;
import 'dart:io' as io show HttpClient, OSError, SocketException;
import 'dart:ui' as ui;
import 'package:file/file.dart';
import 'package:file/local.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:platform/platform.dart';
import 'package:process/process.dart';
import 'skia_client.dart';
export 'skia_client.dart';
// If you are here trying to figure out how to use golden files in the Flutter
// repo itself, consider reading this wiki page:
// https://github.com/flutter/flutter/blob/main/docs/contributing/testing/Writing-a-golden-file-test-for-package-flutter.md
// If you are trying to debug this package, you may like to use the golden test
// titled "Inconsequential golden test" in this file:
// /packages/flutter/test/widgets/basic_test.dart
// TODO(ianh): sort the parameters and arguments in this file so they use a consistent order throughout.
const String _kFlutterRootKey = 'FLUTTER_ROOT';
bool _isMainBranch(String? branch) {
return branch == 'main' || branch == 'master';
}
/// Main method that can be used in a `flutter_test_config.dart` file to set
/// [goldenFileComparator] to an instance of [FlutterGoldenFileComparator] that
/// works for the current test. _Which_ [FlutterGoldenFileComparator] is
/// instantiated is based on the current testing environment.
///
/// When set, the `namePrefix` is prepended to the names of all gold images.
///
/// This function assumes the [goldenFileComparator] has been set to a
/// [LocalFileComparator], which happens in the bootstrap code used when running
/// tests using `flutter test`. This should not be called when running a test
/// using `flutter run`, as in that environment, the [goldenFileComparator] is a
/// [TrivialComparator].
///
/// An [HttpClient] is created when this method is called. That client is used
/// to communicate with the Skia Gold servers. Any [HttpOverrides] set in this
/// will affect whether this is effective or not. For example, if the current
/// override provides a mock client that always fails, then all calls to gold
/// comparison functions will fail.
Future<void> testExecutable(FutureOr<void> Function() testMain, {String? namePrefix}) async {
assert(
goldenFileComparator is LocalFileComparator,
'The flutter_goldens package should be used from a flutter_test_config.dart '
'file, which is only invoked when using "flutter test". The "flutter test" '
'bootstrap logic sets "goldenFileComparator" to a LocalFileComparator. It '
'appears in this instance however that the "goldenFileComparator" is a '
'${goldenFileComparator.runtimeType}.\n'
'See also: https://flutter.dev/to/flutter-test-docs',
);
const Platform platform = LocalPlatform();
const FileSystem fs = LocalFileSystem();
const ProcessManager process = LocalProcessManager();
final httpClient = io.HttpClient();
if (FlutterPostSubmitFileComparator.isForEnvironment(platform)) {
goldenFileComparator = await FlutterPostSubmitFileComparator.fromLocalFileComparator(
localFileComparator: goldenFileComparator as LocalFileComparator,
platform: platform,
namePrefix: namePrefix,
log: print,
fs: fs,
process: process,
httpClient: httpClient,
);
} else if (FlutterPreSubmitFileComparator.isForEnvironment(platform)) {
goldenFileComparator = await FlutterPreSubmitFileComparator.fromLocalFileComparator(
localFileComparator: goldenFileComparator as LocalFileComparator,
platform: platform,
namePrefix: namePrefix,
log: print,
fs: fs,
process: process,
httpClient: httpClient,
);
} else if (FlutterSkippingFileComparator.isForEnvironment(platform)) {
goldenFileComparator = FlutterSkippingFileComparator.fromLocalFileComparator(
localFileComparator: goldenFileComparator as LocalFileComparator,
'Golden file testing is not executed on LUCI environments outside of '
'flutter, or in test shards that are not configured for using goldctl.',
platform: platform,
namePrefix: namePrefix,
log: print,
fs: fs,
process: process,
httpClient: httpClient,
);
} else {
goldenFileComparator = await FlutterLocalFileComparator.fromLocalFileComparator(
localFileComparator: goldenFileComparator as LocalFileComparator,
platform: platform,
log: print,
fs: fs,
process: process,
httpClient: httpClient,
);
}
await testMain();
}
/// Abstract base class golden file comparator specific to the `flutter/flutter`
/// repository.
///
/// Golden file testing for the `flutter/flutter` repository is handled by three
/// different [FlutterGoldenFileComparator]s, depending on the current testing
/// environment.
///
/// * The [FlutterPostSubmitFileComparator] is utilized during post-submit
/// testing, after a pull request has landed on the master branch. This
/// comparator uses the [SkiaGoldClient] and the `goldctl` tool to upload
/// tests to the [Flutter Gold dashboard](https://flutter-gold.skia.org).
/// Flutter Gold manages the master golden files for the `flutter/flutter`
/// repository.
///
/// * The [FlutterPreSubmitFileComparator] is utilized in pre-submit testing,
/// before a pull request lands on the master branch. This
/// comparator uses the [SkiaGoldClient] to execute tryjobs, allowing
/// contributors to view and check in visual differences before landing the
/// change.
///
/// * The [FlutterLocalFileComparator] is used for local development testing.
/// This comparator will use the [SkiaGoldClient] to request baseline images
/// from [Flutter Gold](https://flutter-gold.skia.org) and manually compare
/// pixels. If a difference is detected, this comparator will
/// generate failure output illustrating the found difference. If a baseline
/// is not found for a given test image, it will consider it a new test and
/// output the new image for verification.
///
/// The [FlutterSkippingFileComparator] is utilized to skip tests outside
/// of the appropriate environments described above. Currently, some Luci
/// environments do not execute golden file testing, and as such do not require
/// a comparator. This comparator is also used when an internet connection is
/// unavailable.
abstract class FlutterGoldenFileComparator extends GoldenFileComparator {
/// Creates a [FlutterGoldenFileComparator] that will resolve golden file
/// URIs relative to the specified [basedir], and retrieve golden baselines
/// using the [skiaClient]. The [basedir] is used for writing and accessing
/// information and files for interacting with the [skiaClient]. When testing
/// locally, the [basedir] will also contain any diffs from failed tests, or
/// goldens generated from newly introduced tests.
@visibleForTesting
FlutterGoldenFileComparator(
this.basedir,
this.skiaClient, {
required this.fs,
required this.platform,
this.namePrefix,
required this.log,
});
/// The directory to which golden file URIs will be resolved in [compare] and
/// [update].
final Uri basedir;
/// A client for uploading image tests and making baseline requests to the
/// Flutter Gold Dashboard.
final SkiaGoldClient skiaClient;
/// The file system used to perform file access.
final FileSystem fs;
/// The environment (current working directory, identity of the OS,
/// environment variables, etc).
final Platform platform;
/// The prefix that is added to all golden names.
final String? namePrefix;
/// The logging function to use when reporting messages to the console.
final LogCallback log;
@override
Future<void> update(Uri golden, Uint8List imageBytes) async {
final File goldenFile = getGoldenFile(golden);
await goldenFile.parent.create(recursive: true);
await goldenFile.writeAsBytes(imageBytes, flush: true);
}
@override
Uri getTestUri(Uri key, int? version) => key;
/// Calculate the appropriate basedir for the current test context.
///
/// The optional [suffix] argument is used by the
/// [FlutterPostSubmitFileComparator] and the [FlutterPreSubmitFileComparator].
/// These [FlutterGoldenFileComparator]s randomize their base directories to
/// maintain thread safety while using the `goldctl` tool.
@protected
@visibleForTesting
static Directory getBaseDirectory(
LocalFileComparator defaultComparator, {
required Platform platform,
String? suffix,
required FileSystem fs,
}) {
final Directory flutterRoot = fs.directory(platform.environment[_kFlutterRootKey]);
final Directory comparisonRoot = switch (suffix) {
null => flutterRoot.childDirectory(fs.path.join('bin', 'cache', 'pkg', 'skia_goldens')),
_ => fs.systemTempDirectory.createTempSync(suffix),
};
final String testPath = fs.directory(defaultComparator.basedir).path;
return comparisonRoot.childDirectory(fs.path.relative(testPath, from: flutterRoot.path));
}
/// Returns the golden [File] identified by the given [Uri].
@protected
File getGoldenFile(Uri uri) {
final File goldenFile = fs.directory(basedir).childFile(fs.file(uri).path);
return goldenFile;
}
/// Prepends the golden URL with the library name that encloses the current
/// test.
Uri _addPrefix(Uri golden) {
// Ensure the Uri ends in .png as the SkiaClient expects
assert(
golden.toString().split('.').last == 'png',
'Golden files in the Flutter framework must end with the file extension '
'.png.',
);
return Uri.parse(
<String>[
?namePrefix,
basedir.pathSegments[basedir.pathSegments.length - 2],
golden.toString(),
].join('.'),
);
}
}
/// A [FlutterGoldenFileComparator] for testing golden images with Skia Gold in
/// post-submit.
///
/// For testing across all platforms, the [SkiaGoldClient] is used to upload
/// images for framework-related golden tests and process results.
///
/// See also:
///
/// * [GoldenFileComparator], the abstract class that
/// [FlutterGoldenFileComparator] implements.
/// * [FlutterPreSubmitFileComparator], another
/// [FlutterGoldenFileComparator] that tests golden images before changes are
/// merged into the master branch.
/// * [FlutterLocalFileComparator], another
/// [FlutterGoldenFileComparator] that tests golden images locally on your
/// current machine.
class FlutterPostSubmitFileComparator extends FlutterGoldenFileComparator {
/// Creates a [FlutterPostSubmitFileComparator] that will test golden file
/// images against Skia Gold.
///
/// The [fs] parameter is useful in tests, where the default
/// file system can be replaced by mock instances.
FlutterPostSubmitFileComparator(
super.basedir,
super.skiaClient, {
required super.fs,
required super.platform,
super.namePrefix,
required super.log,
});
/// Creates a new [FlutterPostSubmitFileComparator] that mirrors the relative
/// path resolution of the provided `localFileComparator`.
///
/// The [goldens] parameter is visible for testing purposes only.
static Future<FlutterPostSubmitFileComparator> fromLocalFileComparator({
SkiaGoldClient? goldens,
required LocalFileComparator localFileComparator,
required Platform platform,
String? namePrefix,
required LogCallback log,
required FileSystem fs,
required ProcessManager process,
required io.HttpClient httpClient,
}) async {
final Directory baseDirectory = FlutterGoldenFileComparator.getBaseDirectory(
localFileComparator,
platform: platform,
suffix: 'flutter_goldens_postsubmit.',
fs: fs,
);
baseDirectory.createSync(recursive: true);
goldens ??= SkiaGoldClient(
baseDirectory,
log: log,
platform: platform,
fs: fs,
process: process,
httpClient: httpClient,
);
await goldens.auth();
return FlutterPostSubmitFileComparator(
baseDirectory.uri,
goldens,
platform: platform,
namePrefix: namePrefix,
log: log,
fs: fs,
);
}
@override
Future<bool> compare(Uint8List imageBytes, Uri golden) async {
await skiaClient.imgtestInit();
golden = _addPrefix(golden);
await update(golden, imageBytes);
final File goldenFile = getGoldenFile(golden);
try {
return await skiaClient.imgtestAdd(golden.path, goldenFile);
} on SkiaException catch (e) {
// Convert SkiaException -> TestFailure so that this class implements the
// contract of GoldenFileComparator, and matchesGoldenFile() converts the
// TestFailure into a standard reported test error (with a better stack
// trace, for example).
//
// https://github.com/flutter/flutter/issues/162621
throw TestFailure('$e');
}
}
/// Decides based on the current environment if goldens tests should be
/// executed through Skia Gold.
static bool isForEnvironment(Platform platform) {
final bool luciPostSubmit =
platform.environment.containsKey('SWARMING_TASK_ID') &&
platform.environment.containsKey('GOLDCTL')
// Luci tryjob environments contain this value to inform the [FlutterPreSubmitComparator].
&&
!platform.environment.containsKey('GOLD_TRYJOB')
// Only run on main branch.
&&
_isMainBranch(platform.environment['GIT_BRANCH']);
return luciPostSubmit;
}
}
/// A [FlutterGoldenFileComparator] for testing golden images before changes are
/// merged into the master branch. The comparator executes tryjobs using the
/// [SkiaGoldClient].
///
/// See also:
///
/// * [GoldenFileComparator], the abstract class that
/// [FlutterGoldenFileComparator] implements.
/// * [FlutterPostSubmitFileComparator], another
/// [FlutterGoldenFileComparator] that uploads tests to the Skia Gold
/// dashboard in post-submit.
/// * [FlutterLocalFileComparator], another
/// [FlutterGoldenFileComparator] that tests golden images locally on your
/// current machine.
class FlutterPreSubmitFileComparator extends FlutterGoldenFileComparator {
/// Creates a [FlutterPreSubmitFileComparator] that will test golden file
/// images against baselines requested from Flutter Gold.
///
/// The [fs] parameter is useful in tests, where the default
/// file system can be replaced by mock instances.
FlutterPreSubmitFileComparator(
super.basedir,
super.skiaClient, {
required super.fs,
required super.platform,
super.namePrefix,
required super.log,
});
/// Creates a new [FlutterPreSubmitFileComparator] that mirrors the
/// relative path resolution of the default [goldenFileComparator].
///
/// The [goldens] parameter is visible for testing purposes only.
static Future<FlutterGoldenFileComparator> fromLocalFileComparator({
SkiaGoldClient? goldens,
required LocalFileComparator localFileComparator,
required Platform platform,
Directory? testBasedir,
String? namePrefix,
required LogCallback log,
required FileSystem fs,
required ProcessManager process,
required io.HttpClient httpClient,
}) async {
final Directory baseDirectory =
testBasedir ??
FlutterGoldenFileComparator.getBaseDirectory(
localFileComparator,
platform: platform,
suffix: 'flutter_goldens_presubmit.',
fs: fs,
);
if (!baseDirectory.existsSync()) {
baseDirectory.createSync(recursive: true);
}
goldens ??= SkiaGoldClient(
baseDirectory,
platform: platform,
log: log,
fs: fs,
process: process,
httpClient: httpClient,
);
await goldens.auth();
return FlutterPreSubmitFileComparator(
baseDirectory.uri,
goldens,
platform: platform,
namePrefix: namePrefix,
log: log,
fs: fs,
);
}
@override
Future<bool> compare(Uint8List imageBytes, Uri golden) async {
await skiaClient.tryjobInit();
golden = _addPrefix(golden);
await update(golden, imageBytes);
final File goldenFile = getGoldenFile(golden);
await skiaClient.tryjobAdd(golden.path, goldenFile);
// This will always return true since golden file test failures are managed
// in pre-submit checks by the flutter-gold status check.
return true;
}
/// Decides based on the current environment if goldens tests should be
/// executed as pre-submit tests with Skia Gold.
static bool isForEnvironment(Platform platform) {
final bool luciPreSubmit =
platform.environment.containsKey('SWARMING_TASK_ID') &&
platform.environment.containsKey('GOLDCTL') &&
platform.environment.containsKey('GOLD_TRYJOB')
// Only run on the main branch
&&
_isMainBranch(platform.environment['GIT_BRANCH']);
return luciPreSubmit;
}
}
/// A [FlutterGoldenFileComparator] for testing conditions that do not execute
/// golden file tests.
///
/// Currently, this comparator is used on Luci environments when executing tests
/// outside of the flutter/flutter repository.
///
/// See also:
///
/// * [FlutterPostSubmitFileComparator], another [FlutterGoldenFileComparator]
/// that tests golden images through Skia Gold.
/// * [FlutterPreSubmitFileComparator], another
/// [FlutterGoldenFileComparator] that tests golden images before changes are
/// merged into the master branch.
/// * [FlutterLocalFileComparator], another
/// [FlutterGoldenFileComparator] that tests golden images locally on your
/// current machine.
class FlutterSkippingFileComparator extends FlutterGoldenFileComparator {
/// Creates a [FlutterSkippingFileComparator] that will skip tests that
/// are not in the right environment for golden file testing.
FlutterSkippingFileComparator(
super.basedir,
super.skiaClient,
this.reason, {
super.namePrefix,
required super.platform,
required super.log,
required super.fs,
});
/// Describes the reason for using the [FlutterSkippingFileComparator].
final String reason;
/// Creates a new [FlutterSkippingFileComparator] that mirrors the
/// relative path resolution of the given [localFileComparator].
static FlutterSkippingFileComparator fromLocalFileComparator(
String reason, {
required LocalFileComparator localFileComparator,
String? namePrefix,
required Platform platform,
required LogCallback log,
required FileSystem fs,
required ProcessManager process,
required io.HttpClient httpClient,
}) {
final Uri basedir = localFileComparator.basedir;
final skiaClient = SkiaGoldClient(
fs.directory(basedir),
platform: platform,
log: log,
fs: fs,
process: process,
httpClient: httpClient,
);
return FlutterSkippingFileComparator(
basedir,
skiaClient,
reason,
namePrefix: namePrefix,
platform: platform,
log: log,
fs: fs,
);
}
@override
Future<bool> compare(Uint8List imageBytes, Uri golden) async {
log('Skipping "$golden" test: $reason');
return true;
}
@override
Future<void> update(Uri golden, Uint8List imageBytes) async {}
/// Decides, based on the current environment, if this comparator should be
/// used.
///
/// If we are in a CI environment, i.e. LUCI, but are not using the other
/// comparators, we skip. Otherwise we would fallback to the local comparator,
/// for which failures cannot be resolved in a CI environment.
static bool isForEnvironment(Platform platform) {
return platform.environment.containsKey('SWARMING_TASK_ID');
}
}
/// A [FlutterGoldenFileComparator] for testing golden images locally on your
/// current machine.
///
/// This comparator utilizes the [SkiaGoldClient] to request baseline images for
/// the given device under test for comparison. This comparator is initialized
/// when conditions for all other [FlutterGoldenFileComparator]s have not been
/// met, see the `isForEnvironment` method for each one listed below.
///
/// The [FlutterLocalFileComparator] is intended to run on local machines and
/// serve as a smoke test during development. As such, it will not be able to
/// detect unintended changes on environments other than the currently executing
/// machine, until they are tested using the [FlutterPreSubmitFileComparator].
///
/// See also:
///
/// * [GoldenFileComparator], the abstract class that
/// [FlutterGoldenFileComparator] implements.
/// * [FlutterPostSubmitFileComparator], another
/// [FlutterGoldenFileComparator] that uploads tests to the Skia Gold
/// dashboard.
/// * [FlutterPreSubmitFileComparator], another
/// [FlutterGoldenFileComparator] that tests golden images before changes are
/// merged into the master branch.
/// * [FlutterSkippingFileComparator], another
/// [FlutterGoldenFileComparator] that controls post-submit testing
/// conditions that do not execute golden file tests.
class FlutterLocalFileComparator extends FlutterGoldenFileComparator with LocalComparisonOutput {
/// Creates a [FlutterLocalFileComparator] that will test golden file
/// images against baselines requested from Flutter Gold.
///
/// The [fs] parameter is useful in tests, where the default
/// file system can be replaced by mock instances.
FlutterLocalFileComparator(
super.basedir,
super.skiaClient, {
required super.fs,
required super.platform,
required super.log,
});
/// Creates a new [FlutterLocalFileComparator] that mirrors the
/// relative path resolution of the given [localFileComparator].
///
/// The [goldens] and [baseDirectory] parameters are
/// visible for testing purposes only.
static Future<FlutterGoldenFileComparator> fromLocalFileComparator({
SkiaGoldClient? goldens,
required LocalFileComparator localFileComparator,
required Platform platform,
Directory? baseDirectory,
required LogCallback log,
required FileSystem fs,
required ProcessManager process,
required io.HttpClient httpClient,
}) async {
baseDirectory ??= FlutterGoldenFileComparator.getBaseDirectory(
localFileComparator,
platform: platform,
fs: fs,
);
if (!baseDirectory.existsSync()) {
baseDirectory.createSync(recursive: true);
}
goldens ??= SkiaGoldClient(
baseDirectory,
platform: platform,
log: log,
fs: fs,
process: process,
httpClient: httpClient,
);
try {
// Check if we can reach Gold.
await goldens.getExpectationForTest('');
} on io.OSError catch (_) {
return FlutterSkippingFileComparator(
baseDirectory.uri,
goldens,
'OSError occurred, could not reach Gold. '
'Switching to FlutterSkippingGoldenFileComparator.',
platform: platform,
log: log,
fs: fs,
);
} on io.SocketException catch (_) {
return FlutterSkippingFileComparator(
baseDirectory.uri,
goldens,
'SocketException occurred, could not reach Gold. '
'Switching to FlutterSkippingGoldenFileComparator.',
platform: platform,
log: log,
fs: fs,
);
} on FormatException catch (_) {
return FlutterSkippingFileComparator(
baseDirectory.uri,
goldens,
'FormatException occurred, could not reach Gold. '
'Switching to FlutterSkippingGoldenFileComparator.',
platform: platform,
log: log,
fs: fs,
);
}
return FlutterLocalFileComparator(
baseDirectory.uri,
goldens,
platform: platform,
log: log,
fs: fs,
);
}
@override
Future<bool> compare(Uint8List imageBytes, Uri golden) async {
golden = _addPrefix(golden);
final String testName = skiaClient.cleanTestName(golden.path);
final String? testExpectation = await skiaClient.getExpectationForTest(testName);
if (testExpectation == null || testExpectation.isEmpty) {
log(
'No expectations provided by Skia Gold for test: $golden. '
'This may be a new test. If this is an unexpected result, check '
'https://flutter-gold.skia.org.\n'
'Validate image output found at $basedir',
);
await update(golden, imageBytes);
return true;
}
final List<int> goldenBytes = await skiaClient.getImageBytes(testExpectation);
if (skiaClient.isBrowserTest) {
return _fuzzyCompareWeb(
actualBytes: imageBytes,
expectedBytes: goldenBytes,
golden: golden,
testName: testName,
);
}
final ComparisonResult result = await GoldenFileComparator.compareLists(
imageBytes,
goldenBytes,
);
if (result.passed) {
result.dispose();
return true;
}
final String error = await generateFailureOutput(result, golden, basedir);
result.dispose();
throw FlutterError(error);
}
/// Computes the Manhattan distance (L1 norm) between two RGBA pixels.
///
/// Calculates `|r1 - r2| + |g1 - g2| + |b1 - b2| + |a1 - a2|`. The result
/// ranges from `0` (identical colors) to `1020` (maximum difference, e.g.
/// solid black vs solid white with full opacity difference).
static int _colorDelta(int r1, int g1, int b1, int a1, int r2, int g2, int b2, int a2) {
return (r1 - r2).abs() + (g1 - g2).abs() + (b1 - b2).abs() + (a1 - a2).abs();
}
/// Converts a raw RGBA8888 byte buffer of dimension [width] x [height] into
/// an uncompressed [ui.Image].
static Future<ui.Image> _createImageFromPixels(ByteData bytes, int width, int height) {
final completer = Completer<ui.Image>();
ui.decodeImageFromPixels(
bytes.buffer.asUint8List(),
width,
height,
ui.PixelFormat.rgba8888,
completer.complete,
);
return completer.future;
}
/// Performs a tolerance-aware fuzzy comparison between [actualBytes] and
/// [expectedBytes] for web browser golden tests.
///
/// Browser raster engines, font renderers, and GPU backends exhibit slight
/// cross-platform rasterization variations (e.g. subpixel antialiasing and
/// glyph rounding). To accommodate these without false positives:
///
/// 1. Pixels whose Manhattan color delta is within `maxColorDelta` pass.
/// 2. If the direct pixel mismatches, a 3x3 neighborhood search in the expected
/// image is performed to allow for 1-pixel subpixel layout shifts.
/// 3. The test passes if the overall differing pixel ratio is <= `maxDifferentPixelsRate`.
///
/// If the comparison fails, failure artifacts (`actual.png`, `expected.png`,
/// `diff.png`) are written to disk and a [FlutterError] is thrown.
Future<bool> _fuzzyCompareWeb({
required Uint8List actualBytes,
required List<int> expectedBytes,
required Uri golden,
required String testName,
}) async {
if (listEquals(actualBytes, expectedBytes)) {
return true;
}
final ui.Codec actualCodec = await ui.instantiateImageCodec(actualBytes);
final ui.Image actualImage = (await actualCodec.getNextFrame()).image;
actualCodec.dispose();
final ui.Codec expectedCodec = await ui.instantiateImageCodec(
Uint8List.fromList(expectedBytes),
);
final ui.Image expectedImage = (await expectedCodec.getNextFrame()).image;
expectedCodec.dispose();
ui.Image? diffImage;
try {
final ByteData? actualRgba = await actualImage.toByteData();
final ByteData? expectedRgba = await expectedImage.toByteData();
final int width = actualImage.width;
final int height = actualImage.height;
final int expectedWidth = expectedImage.width;
final int expectedHeight = expectedImage.height;
// Maximum allowed Manhattan color delta across RGBA channels (7 per RGB channel = 21)
// to tolerate subtle antialiasing differences.
const int maxColorDelta = 7 * 3;
// Maximum proportion of differing pixels allowed (10%) to absorb perimeter antialiasing fringes.
const maxDifferentPixelsRate = 0.1;
if (width != expectedWidth ||
height != expectedHeight ||
actualRgba == null ||
expectedRgba == null) {
final diffBytes = ByteData(width * height * 4);
for (var i = 0; i < width * height * 4; i += 4) {
diffBytes.setUint8(i, 255);
diffBytes.setUint8(i + 1, 0);
diffBytes.setUint8(i + 2, 127);
diffBytes.setUint8(i + 3, 255);
}
diffImage = await _createImageFromPixels(diffBytes, width, height);
final ByteData? diffPngData = await diffImage.toByteData(format: ui.ImageByteFormat.png);
final Uint8List diffPngBytes = diffPngData!.buffer.asUint8List();
await _writeFailureAndThrow(
golden: golden,
testName: testName,
actualBytes: actualBytes,
expectedBytes: Uint8List.fromList(expectedBytes),
diffPngBytes: diffPngBytes,
diffPixelCount: width * height,
totalPixels: width * height,
maxDifferentPixelsRate: maxDifferentPixelsRate,
customMessage:
'Image dimensions do not match (actual: ${width}x$height, expected: ${expectedWidth}x$expectedHeight).',
);
return false;
}
final Uint8List actualPixels = actualRgba.buffer.asUint8List(
actualRgba.offsetInBytes,
actualRgba.lengthInBytes,
);
final Uint8List expectedPixels = expectedRgba.buffer.asUint8List(
expectedRgba.offsetInBytes,
expectedRgba.lengthInBytes,
);
final int totalPixels = width * height;
var diffPixelCount = 0;
final diffBytes = ByteData(width * height * 4);
for (var y = 0; y < height; y += 1) {
for (var x = 0; x < width; x += 1) {
final int offset = (y * width + x) * 4;
final int r1 = actualPixels[offset];
final int g1 = actualPixels[offset + 1];
final int b1 = actualPixels[offset + 2];
final int a1 = actualPixels[offset + 3];
var pixelMatched = false;
// Check direct pixel first
final int r2 = expectedPixels[offset];
final int g2 = expectedPixels[offset + 1];
final int b2 = expectedPixels[offset + 2];
final int a2 = expectedPixels[offset + 3];
if (_colorDelta(r1, g1, b1, a1, r2, g2, b2, a2) <= maxColorDelta) {
pixelMatched = true;
} else {
// Search 3x3 neighborhood in expected image
neighborhoodSearch:
for (var dy = -1; dy <= 1; dy += 1) {
final int ny = y + dy;
if (ny < 0 || ny >= height) {
continue;
}
for (var dx = -1; dx <= 1; dx += 1) {
final int nx = x + dx;
if (nx < 0 || nx >= width) {
continue;
}
final int neighborOffset = (ny * width + nx) * 4;
final int nr2 = expectedPixels[neighborOffset];
final int ng2 = expectedPixels[neighborOffset + 1];
final int nb2 = expectedPixels[neighborOffset + 2];
final int na2 = expectedPixels[neighborOffset + 3];
if (_colorDelta(r1, g1, b1, a1, nr2, ng2, nb2, na2) <= maxColorDelta) {
pixelMatched = true;
break neighborhoodSearch;
}
}
}
}
if (pixelMatched) {
diffBytes.setUint8(offset, r1);
diffBytes.setUint8(offset + 1, g1);
diffBytes.setUint8(offset + 2, b1);
diffBytes.setUint8(offset + 3, a1);
} else {
diffPixelCount += 1;
// Highlight mismatched pixels with bright Magenta (#FF007F) for clear visual diffing.
diffBytes.setUint8(offset, 255);
diffBytes.setUint8(offset + 1, 0);
diffBytes.setUint8(offset + 2, 127);
diffBytes.setUint8(offset + 3, 255);
}
}
}
final double diffRate = totalPixels == 0 ? 0.0 : diffPixelCount / totalPixels;
if (diffRate <= maxDifferentPixelsRate) {
return true;
}
diffImage = await _createImageFromPixels(diffBytes, width, height);
final ByteData? diffPngData = await diffImage.toByteData(format: ui.ImageByteFormat.png);
final Uint8List diffPngBytes = diffPngData!.buffer.asUint8List();
await _writeFailureAndThrow(
golden: golden,
testName: testName,
actualBytes: actualBytes,
expectedBytes: Uint8List.fromList(expectedBytes),
diffPngBytes: diffPngBytes,
diffPixelCount: diffPixelCount,
totalPixels: totalPixels,
maxDifferentPixelsRate: maxDifferentPixelsRate,
);
return false;
} finally {
actualImage.dispose();
expectedImage.dispose();
diffImage?.dispose();
}
}
/// Writes failure artifacts (`actual.png`, `expected.png`, `diff.png`) to the
/// golden cache failure directory and throws a formatted [FlutterError].
Future<void> _writeFailureAndThrow({
required Uri golden,
required String testName,
required Uint8List actualBytes,
required Uint8List expectedBytes,
required Uint8List diffPngBytes,
required int diffPixelCount,
required int totalPixels,
required double maxDifferentPixelsRate,
String? customMessage,
}) async {
final Directory failureDir;
if (platform.environment.containsKey(_kFlutterRootKey)) {
failureDir = fs
.directory(platform.environment[_kFlutterRootKey])
.childDirectory('.dart_tool')
.childDirectory('flutter_goldens_cache')
.childDirectory('failures')
.childDirectory(testName);
} else {
failureDir = fs.directory(basedir).childDirectory('failures').childDirectory(testName);
}
failureDir.createSync(recursive: true);
final File actualFile = failureDir.childFile('actual.png');
final File expectedFile = failureDir.childFile('expected.png');
final File diffFile = failureDir.childFile('diff.png');
actualFile.writeAsBytesSync(actualBytes, flush: true);
expectedFile.writeAsBytesSync(expectedBytes, flush: true);
diffFile.writeAsBytesSync(diffPngBytes, flush: true);
final double diffRate = totalPixels == 0 ? 0.0 : diffPixelCount / totalPixels;
final error =
'Golden comparison failed for test "$golden".\n'
'${customMessage != null ? '$customMessage\n' : ''}'
'Pixel difference: ${(diffRate * 100).toStringAsFixed(2)}% ($diffPixelCount / $totalPixels pixels differed, max allowed: ${(maxDifferentPixelsRate * 100).toStringAsFixed(1)}%).\n'
'Failure artifacts written to:\n'
' actual: ${actualFile.uri}\n'
' expected: ${expectedFile.uri}\n'
' diff: ${diffFile.uri}';
throw FlutterError(error);
}
}