| // 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, |
| }); |