Add a basic e2e-test for `skia_gold_client`. (#51078)
Basically, I take blank images of red/blue/green squares, like this:

... and use Image Magick to annotate it with the current git hash:
```sh
$ drt ./testing/skia_gold_client/tool/generate.dart
Writing annotation "8069cb4ca1" on images in testing/skia_gold_client/tool/source_images and saving them in testing/skia_gold_client/tool/e2e_fixtures.
Writing to testing/skia_gold_client/tool/e2e_fixtures/solid_red_square.png
Writing to testing/skia_gold_client/tool/e2e_fixtures/solid_green_square.png
Writing to testing/skia_gold_client/tool/e2e_fixtures/solid_blue_square.png
Done: wrote 3 image.
```

... then, I upload the digests very similar to how we do it in `scenario_app`.
---
The idea here is to have a way for me to know if Skia gold is working the way I/we expect, independent of a more complicated test suite with various race-y or flake-y conditions. We could also augment it with tests of "dimensions" properties.
diff --git a/ci/builders/linux_android_emulator.json b/ci/builders/linux_android_emulator.json
index 305608f..918c8e6 100644
--- a/ci/builders/linux_android_emulator.json
+++ b/ci/builders/linux_android_emulator.json
@@ -60,6 +60,12 @@
},
{
"language": "dart",
+ "name": "skia_gold_client/e2e_test",
+ "script": "flutter/testing/skia_gold_client/tool/e2e_test.dart",
+ "max_attempts": 1
+ },
+ {
+ "language": "dart",
"name": "Android Scenario App Integration Tests (Impeller/Vulkan)",
"test_timeout_secs": 900,
"max_attempts": 2,
diff --git a/testing/skia_gold_client/pubspec.yaml b/testing/skia_gold_client/pubspec.yaml
index a7ba15d..d119b11 100644
--- a/testing/skia_gold_client/pubspec.yaml
+++ b/testing/skia_gold_client/pubspec.yaml
@@ -22,10 +22,16 @@
process: any
dev_dependencies:
+ args: any
+ git_repo_tools: any
litetest: any
process_fakes: any
dependency_overrides:
+ args:
+ path: ../../../third_party/dart/third_party/pkg/args
+ async:
+ path: ../../../third_party/dart/third_party/pkg/async
async_helper:
path: ../../../third_party/dart/pkg/async_helper
collection:
@@ -38,6 +44,8 @@
path: ../../../third_party/dart/pkg/expect
file:
path: ../../../third_party/dart/third_party/pkg/file/packages/file
+ git_repo_tools:
+ path: ../../tools/pkg/git_repo_tools
litetest:
path: ../../testing/litetest
meta:
@@ -50,6 +58,8 @@
path: ../../third_party/pkg/process
process_fakes:
path: ../../tools/pkg/process_fakes
+ process_runner:
+ path: ../../third_party/pkg/process_runner
smith:
path: ../../../third_party/dart/pkg/smith
typed_data:
diff --git a/testing/skia_gold_client/test/skia_gold_client_test.dart b/testing/skia_gold_client/test/skia_gold_client_test.dart
index 1d094d0..0c76212 100644
--- a/testing/skia_gold_client/test/skia_gold_client_test.dart
+++ b/testing/skia_gold_client/test/skia_gold_client_test.dart
@@ -1,3 +1,7 @@
+// Copyright 2013 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:async';
import 'dart:convert';
import 'dart:io' as io;
diff --git a/testing/skia_gold_client/tool/e2e_fixtures/solid_blue_square.png b/testing/skia_gold_client/tool/e2e_fixtures/solid_blue_square.png
new file mode 100644
index 0000000..e15f0c8
--- /dev/null
+++ b/testing/skia_gold_client/tool/e2e_fixtures/solid_blue_square.png
Binary files differ
diff --git a/testing/skia_gold_client/tool/e2e_fixtures/solid_green_square.png b/testing/skia_gold_client/tool/e2e_fixtures/solid_green_square.png
new file mode 100644
index 0000000..0b6f149
--- /dev/null
+++ b/testing/skia_gold_client/tool/e2e_fixtures/solid_green_square.png
Binary files differ
diff --git a/testing/skia_gold_client/tool/e2e_fixtures/solid_red_square.png b/testing/skia_gold_client/tool/e2e_fixtures/solid_red_square.png
new file mode 100644
index 0000000..53388f4
--- /dev/null
+++ b/testing/skia_gold_client/tool/e2e_fixtures/solid_red_square.png
Binary files differ
diff --git a/testing/skia_gold_client/tool/e2e_test.dart b/testing/skia_gold_client/tool/e2e_test.dart
new file mode 100644
index 0000000..79e23e6
--- /dev/null
+++ b/testing/skia_gold_client/tool/e2e_test.dart
@@ -0,0 +1,143 @@
+// Copyright 2013 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';
+
+import 'package:engine_repo_tools/engine_repo_tools.dart';
+import 'package:path/path.dart' as path;
+import 'package:skia_gold_client/skia_gold_client.dart';
+
+/// An E2E test for the Skia Gold client.
+///
+/// Attempts to answer the question: "Does the Skia Gold client, and our custom
+/// integration with it (GitHub, LUCI, etc), work as expected?" in an automated
+/// way.
+///
+/// In a sibling directory, `e2e_fixtures`, there are static (checked-in) files
+/// that represent (fake) generated output to be uploaded and verified by the
+/// Skia Gold client. This test, when run on CI, will use these fixtures to
+/// simulate the process of uploading and verifying real output.
+///
+/// For example, try changing the contents of a fixture file, and then uploading
+/// a PR with this change. The CI will run this test, and it should fail (on
+/// pre-submit), since the fixture file no longer matches the expected output.
+///
+/// Next, after the PR is merged, the CI will run this test again, and it should
+/// pass (on post-submit), since the fixture file now matches the expected
+/// output.
+///
+/// There are also tests for the "dimensions" feature of the Skia Gold client,
+/// which live in `e2e_fixtures/dimensions`. These tests are similar to the
+/// regular tests, but experiment with different fake dimensions to ensure that
+/// our CI environment is correctly handling this feature.
+void main() async {
+ // If the client is not available, we can't run the test.
+ if (!SkiaGoldClient.isAvailable()) {
+ stderr.writeln('Skia gold is unavailable in this environment.');
+ exitCode = 1;
+ return;
+ }
+
+ // If we're not in an engine repo, we can't run the test.
+ final Engine? engine = Engine.tryFindWithin();
+ if (engine == null) {
+ stderr.writeln('Must run within the engine repo.');
+ exitCode = 1;
+ return;
+ }
+
+ // Create a client.
+ final SkiaGoldClient skiaGoldClient = SkiaGoldClient(
+ engine.flutterDir,
+ );
+
+ // Authenticate the client.
+ await skiaGoldClient.auth();
+
+ const String prefix = 'SkiaGoldClientE2ETest';
+ const List<_Digest> digests = <_Digest>[
+ _Digest(
+ name: '${prefix}_SolidBlueSquare',
+ source: 'e2e_fixtures/solid_blue_square.png',
+ pixelCount: 512 * 512,
+ ),
+ _Digest(
+ name: '${prefix}_SolidRedSquare',
+ source: 'e2e_fixtures/solid_red_square.png',
+ pixelCount: 768 * 768,
+ ),
+ _Digest(
+ name: '${prefix}_SolidGreenSquare',
+ source: 'e2e_fixtures/solid_green_square.png',
+ pixelCount: 1200 * 1200,
+ ),
+ ];
+
+ // Upload the digests to Skia Gold.
+ final Set<_Digest> comparisonsFailed = <_Digest>{};
+ for (final _Digest digest in digests) {
+ final String digestPath = digest.source;
+ final String digestName = digest.name;
+ final File digestFile = File(path.join(
+ engine.flutterDir.path,
+ 'testing',
+ 'skia_gold_client',
+ 'tool',
+ digestPath,
+ ));
+ if (!digestFile.existsSync()) {
+ stderr.writeln('The digest file "$digestPath" does not exist.');
+ exitCode = 1;
+ return;
+ }
+
+ print('Uploading digest: $digestName ($digestPath): ${digest.pixelCount} pixels...');
+
+ try {
+ await skiaGoldClient.addImg(
+ digestName,
+ digestFile,
+ screenshotSize: 0,
+ );
+ stderr.writeln('Comparison success: $digestName');
+ } on Exception catch (e) {
+ stderr.writeln('Comparison failure: $digestName: $e');
+ comparisonsFailed.add(digest);
+ }
+ }
+
+ if (comparisonsFailed.isNotEmpty) {
+ stdout.writeln('${comparisonsFailed.length} digest(s) failed.');
+ for (final _Digest digest in comparisonsFailed) {
+ stderr.writeln(' ${digest.name} (${digest.source})');
+ }
+ exitCode = 1;
+ return;
+ }
+}
+
+final class _Digest {
+ const _Digest({
+ required this.name,
+ required this.source,
+ required this.pixelCount,
+ });
+
+ /// The name of the digest/test.
+ final String name;
+
+ /// The source of the digest (e.g. the path to the image file).
+ final String source;
+
+ /// The number of pixels in the image.
+ final int pixelCount;
+
+ @override
+ int get hashCode => source.hashCode;
+
+ @override
+ bool operator ==(Object other) {
+ return other is _Digest && other.source == source;
+ }
+}
diff --git a/testing/skia_gold_client/tool/generate.dart b/testing/skia_gold_client/tool/generate.dart
new file mode 100644
index 0000000..a9753ae
--- /dev/null
+++ b/testing/skia_gold_client/tool/generate.dart
@@ -0,0 +1,120 @@
+// Copyright 2013 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';
+
+import 'package:args/args.dart';
+import 'package:engine_repo_tools/engine_repo_tools.dart';
+import 'package:git_repo_tools/git_repo_tools.dart';
+import 'package:path/path.dart' as path;
+
+/// Takes the images in `source_images`, writes text on them (i.e. git hash)
+/// and saves them in `e2e_fixtures`. By default, no arguments are needed but
+/// ImageMagick must be installed.
+void main(List<String> args) async {
+ final Engine? engine = Engine.tryFindWithin();
+
+ final ArgParser parser = ArgParser()
+ ..addFlag(
+ 'help',
+ abbr: 'h',
+ help: 'Prints usage information.',
+ negatable: false,
+ )
+ ..addOption(
+ 'image-magick-convert-bin',
+ help: 'The path to the ImageMagick `convert` executable.',
+ defaultsTo: 'convert',
+ hide: true,
+ )
+ ..addOption(
+ 'annotation',
+ abbr: 'a',
+ help: 'The text to write on the images.',
+ defaultsTo: engine == null
+ ? null
+ : await GitRepo.fromRoot(engine.flutterDir).headSha(short: true),
+ )
+ ..addOption(
+ 'source',
+ abbr: 's',
+ help: 'The directory containing the images to be modified.',
+ defaultsTo: engine == null
+ ? null
+ : path.join(
+ engine.flutterDir.path,
+ 'testing',
+ 'skia_gold_client',
+ 'tool',
+ 'source_images',
+ ),
+ )
+ ..addOption(
+ 'output',
+ abbr: 'o',
+ help: 'The directory to save the modified images in.',
+ defaultsTo: engine == null
+ ? null
+ : path.join(
+ engine.flutterDir.path,
+ 'testing',
+ 'skia_gold_client',
+ 'tool',
+ 'e2e_fixtures',
+ ),
+ );
+
+ final ArgResults results = parser.parse(args);
+ if (results['help'] as bool) {
+ print(parser.usage);
+ return;
+ }
+
+ final String relativeDir = engine?.flutterDir.path ?? '';
+ final String imageMagickConvertBin = results['image-magick-convert-bin'] as String;
+ final String annotation = results['annotation'] as String;
+ final String source = results['source'] as String;
+ final String output = results['output'] as String;
+
+ print(
+ 'Writing annotation "$annotation" on images in '
+ '${path.relative(source, from: relativeDir)} and saving them in '
+ '${path.relative(output, from: relativeDir)}.',
+ );
+
+ final List<String> sourceImages = Directory(source)
+ .listSync()
+ .whereType<File>()
+ .map((File file) => file.path)
+ .toList();
+
+ // For each source image, write the annotation and save it in the output directory.
+ for (final String sourceImage in sourceImages) {
+ final String outputImage = path.join(
+ output,
+ '${path.basenameWithoutExtension(sourceImage)}.png',
+ );
+ print('Writing to ${path.relative(outputImage, from: relativeDir)}');
+ await Process.run(
+ imageMagickConvertBin,
+ <String>[
+ sourceImage,
+ '-fill',
+ 'white',
+ '-undercolor',
+ 'black',
+ '-gravity',
+ 'SouthEast',
+ '-pointsize',
+ '24',
+ '-annotate',
+ '+10+10',
+ annotation,
+ outputImage,
+ ],
+ );
+ }
+
+ print('Done: wrote ${sourceImages.length} image.');
+}
diff --git a/testing/skia_gold_client/tool/source_images/solid_blue_square.png b/testing/skia_gold_client/tool/source_images/solid_blue_square.png
new file mode 100644
index 0000000..424d663
--- /dev/null
+++ b/testing/skia_gold_client/tool/source_images/solid_blue_square.png
Binary files differ
diff --git a/testing/skia_gold_client/tool/source_images/solid_green_square.png b/testing/skia_gold_client/tool/source_images/solid_green_square.png
new file mode 100644
index 0000000..162932f
--- /dev/null
+++ b/testing/skia_gold_client/tool/source_images/solid_green_square.png
Binary files differ
diff --git a/testing/skia_gold_client/tool/source_images/solid_red_square.png b/testing/skia_gold_client/tool/source_images/solid_red_square.png
new file mode 100644
index 0000000..5cf7bbf
--- /dev/null
+++ b/testing/skia_gold_client/tool/source_images/solid_red_square.png
Binary files differ
diff --git a/tools/pkg/git_repo_tools/lib/git_repo_tools.dart b/tools/pkg/git_repo_tools/lib/git_repo_tools.dart
index 40fd077..db28e6d 100644
--- a/tools/pkg/git_repo_tools/lib/git_repo_tools.dart
+++ b/tools/pkg/git_repo_tools/lib/git_repo_tools.dart
@@ -43,6 +43,17 @@
/// getter will return the same result.
late final Future<List<io.File>> changedFiles = _changedFiles();
+ /// Returns the SHA of the current HEAD commit.
+ Future<String> headSha({bool short = false}) async {
+ final ProcessRunnerResult result = await ProcessRunner(
+ defaultWorkingDirectory: root,
+ processManager: _processManager,
+ ).runProcess(
+ <String>['git', 'rev-parse', if (short) '--short' ,'HEAD'],
+ );
+ return result.stdout.trim();
+ }
+
Future<List<io.File>> _changedFiles() async {
final ProcessRunner processRunner = ProcessRunner(
defaultWorkingDirectory: root,