blob: 7eeff6044d9770400a28e83026ab9e50b0586f33 [file] [log] [blame]
// 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' as io;
import 'package:path/path.dart' as p;
import 'package:skia_gold_client/skia_gold_client.dart';
import 'src/digests_json_format.dart';
/// Used by [harvest] to process a directory for Skia Gold upload.
abstract class Harvester {
/// Creates a new [Harvester] from the directory at [workDirectory].
///
/// The directory is expected to match the following structure:
/// ```txt
/// workDirectory/
/// - digest.json
/// - test_name_1.png
/// - test_name_2.png
/// - ...
/// ```
///
/// The format of `digest.json` is expected to match the following:
/// ```jsonc
/// {
/// "dimensions": {
/// // Key-value pairs of dimensions to provide to Skia Gold.
/// // For example:
/// "platform": "linux",
/// },
/// "entries": [
/// // Each entry is a test-run with the following format:
/// {
/// // Path must be a direct sibling of digest.json.
/// "filename": "test_name_1.png",
///
/// // Called `screenshotSize` in Skia Gold (width * height).
/// "width": 100,
/// "height": 100,
///
/// // Called `differentPixelsRate` in Skia Gold.
/// "maxDiffPixelsPercent": 0.01,
///
/// // Called `pixelColorDelta` in Skia Gold.
/// "maxColorDelta": 0
/// }
/// ]
/// }
/// ```
static Future<Harvester> create(
io.Directory workDirectory, StringSink stderr,
{AddImageToSkiaGold? addImageToSkiaGold}) async {
final io.File file = io.File(p.join(workDirectory.path, 'digest.json'));
if (!file.existsSync()) {
// Check if the directory exists or if the file is just missing.
if (!workDirectory.existsSync()) {
throw ArgumentError('Directory not found: ${workDirectory.path}.');
}
// Lookup sibling files to help the user understand what's missing.
final List<io.FileSystemEntity> files = workDirectory.listSync();
throw StateError(
'File "digest.json" not found in ${workDirectory.path}.\n\n'
'Found files: ${files.map((io.FileSystemEntity e) => p.basename(e.path)).join(', ')}',
);
}
final Digests digests = Digests.parse(file.readAsStringSync());
if (addImageToSkiaGold != null) {
return _DryRunHarvester(digests, stderr, workDirectory, addImageToSkiaGold);
} else {
return SkiaGoldHarvester._create(digests, stderr, workDirectory);
}
}
Future<void> _addImg(
String testName,
io.File goldenFile, {
double differentPixelsRate,
int pixelColorDelta,
required int screenshotSize,
});
Future<void> _auth();
Digests get _digests;
StringSink get _stderr;
io.Directory get _workDirectory;
}
/// A [Harvester] that communicates with a real [SkiaGoldClient].
class SkiaGoldHarvester implements Harvester {
SkiaGoldHarvester._init(
this._digests, this._stderr, this._workDirectory, this.client);
@override
final Digests _digests;
@override
final StringSink _stderr;
@override
final io.Directory _workDirectory;
/// The [SkiaGoldClient] that will be used for harvesting.
final SkiaGoldClient client;
static Future<SkiaGoldHarvester> _create(
Digests digests, StringSink stderr, io.Directory workDirectory) async {
final SkiaGoldClient client =
SkiaGoldClient(workDirectory, dimensions: digests.dimensions);
return SkiaGoldHarvester._init(digests, stderr, workDirectory, client);
}
@override
Future<void> _addImg(String testName, io.File goldenFile,
{double differentPixelsRate = 0.01,
int pixelColorDelta = 0,
required int screenshotSize}) async {
return client.addImg(testName, goldenFile,
differentPixelsRate: differentPixelsRate,
pixelColorDelta: pixelColorDelta,
screenshotSize: screenshotSize);
}
@override
Future<void> _auth() {
return client.auth();
}
}
/// A [Harvester] that doesn't harvest, just calls a callback.
class _DryRunHarvester implements Harvester {
_DryRunHarvester(
this._digests, this._stderr, this._workDirectory,
this._addImageToSkiaGold);
@override
final Digests _digests;
@override
final StringSink _stderr;
@override
final io.Directory _workDirectory;
final AddImageToSkiaGold _addImageToSkiaGold;
@override
Future<void> _addImg(String testName, io.File goldenFile,
{double differentPixelsRate = 0.01,
int pixelColorDelta = 0,
required int screenshotSize}) async {
return _addImageToSkiaGold(testName, goldenFile,
differentPixelsRate: differentPixelsRate,
pixelColorDelta: pixelColorDelta,
screenshotSize: screenshotSize);
}
@override
Future<void> _auth() async {
_stderr.writeln('using dimensions: ${_digests.dimensions}');
}
}
/// Uploads the images of digests in [harvester] to Skia Gold.
Future<void> harvest(Harvester harvester) async {
await harvester._auth();
final List<Future<void>> pendingComparisons = <Future<void>>[];
for (final DigestEntry entry in harvester._digests.entries) {
final io.File goldenFile =
io.File(p.join(harvester._workDirectory.path, entry.filename));
final Future<void> future = harvester
._addImg(
entry.filename,
goldenFile,
screenshotSize: entry.width * entry.height,
differentPixelsRate: entry.maxDiffPixelsPercent,
pixelColorDelta: entry.maxColorDelta,
)
.catchError((Object e) {
harvester._stderr.writeln('Failed to add image to Skia Gold: $e');
throw FailedComparisonException(entry.filename);
});
pendingComparisons.add(future);
}
await Future.wait(pendingComparisons);
}
/// An exception thrown when a comparison fails.
final class FailedComparisonException implements Exception {
/// Creates a new instance of [FailedComparisonException].
const FailedComparisonException(this.testName);
/// The test name that failed.
final String testName;
@override
String toString() => 'Failed comparison: $testName';
}
/// A function that uploads an image to Skia Gold.
typedef AddImageToSkiaGold = Future<void> Function(
String testName,
io.File goldenFile, {
double differentPixelsRate,
int pixelColorDelta,
required int screenshotSize,
});