flutter/packages/flutter_goldens/lib/flutter_goldens.dart
Todd Volkert 65079ad5f2
Add Flutter-repo-specific golden comparator (#17205)
In order to avoid checking binaries into flutter/flutter,
this comparator can be used to retrieve golden files from
a sibling flutter/goldens repository.

https://github.com/flutter/flutter/issues/16859
2018-05-03 07:39:41 -07:00

218 lines
7.3 KiB
Dart

// 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:async';
import 'dart:io' as io;
import 'dart:typed_data';
import 'package:collection/collection.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meta/meta.dart';
import 'package:platform/platform.dart';
import 'package:process/process.dart';
const String _kFlutterRootKey = 'FLUTTER_ROOT';
/// 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.
Future<void> main(FutureOr<void> testMain()) async {
goldenFileComparator = await FlutterGoldenFileComparator.fromDefaultComparator();
await testMain();
}
/// A golden file comparator specific to the `flutter/flutter` repository.
///
/// Within the https://github.com/flutter/flutter repository, it's important
/// not to check-in binaries in order to keep the size of the repository to a
/// minimum. To satisfy this requirement, this comparator retrieves the golden
/// files from a sibling repository, `flutter/goldens`.
///
/// This comparator will locally clone the `flutter/goldens` repository into
/// the `$FLUTTER_ROOT/bin/cache/pkg/goldens` folder, then perform the comparison against
/// the files therein.
class FlutterGoldenFileComparator implements GoldenFileComparator {
@visibleForTesting
FlutterGoldenFileComparator(
this.goldens,
this.basedir, {
this.fs: const LocalFileSystem(),
});
final GoldensClient goldens;
final Uri basedir;
final FileSystem fs;
/// Creates a new [FlutterGoldenFileComparator] that mirrors the relative
/// path resolution of the default [goldenFileComparator].
///
/// By the time the future completes, the clone of the `flutter/goldens`
/// repository is guaranteed to be ready use.
static Future<FlutterGoldenFileComparator> fromDefaultComparator() async {
final LocalFileComparator defaultComparator = goldenFileComparator;
final GoldensClient goldens = new GoldensClient();
await goldens.prepare();
return new FlutterGoldenFileComparator(goldens, defaultComparator.basedir);
}
@override
Future<bool> compare(Uint8List imageBytes, Uri golden) async {
final File goldenFile = _getGoldenFile(golden);
if (!goldenFile.existsSync()) {
throw new TestFailure('Could not be compared against non-existent file: "$golden"');
}
final List<int> goldenBytes = await goldenFile.readAsBytes();
// TODO(tvolkert): Improve the intelligence of this comparison.
return const ListEquality<int>().equals(goldenBytes, imageBytes);
}
@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);
}
File _getGoldenFile(Uri uri) {
final File relativeFile = fs.file(uri);
final Directory testDirectory = fs.directory(basedir);
final String relativeBase = fs.path.relative(testDirectory.path, from: goldens.flutterRoot.path);
return goldens.repositoryRoot.childDirectory(relativeBase).childFile(relativeFile.path);
}
}
/// A class that represents a clone of the https://github.com/flutter/goldens
/// repository, nested within the `bin/cache` directory of the caller's Flutter
/// repository.
@visibleForTesting
class GoldensClient {
GoldensClient({
this.fs: const LocalFileSystem(),
this.platform: const LocalPlatform(),
this.process: const LocalProcessManager(),
});
final FileSystem fs;
final Platform platform;
final ProcessManager process;
RandomAccessFile _lock;
Directory get flutterRoot => fs.directory(platform.environment[_kFlutterRootKey]);
Directory get repositoryRoot => flutterRoot.childDirectory(fs.path.join('bin', 'cache', 'pkg', 'goldens'));
/// Prepares the local clone of the `flutter/goldens` repository for golden
/// file testing.
///
/// This ensures that the goldens repository has been cloned into its
/// expected location within `bin/cache` and that it is synced to the Git
/// revision specified in `bin/internal/goldens.version`.
///
/// While this is preparing the repository, it obtains a file lock such that
/// [GoldensClient] instances in other processes or isolates will not
/// duplicate the work that this is doing.
Future<void> prepare() async {
final String goldensCommit = await _getGoldensCommit();
String currentCommit = await _getCurrentCommit();
if (currentCommit != goldensCommit) {
await _obtainLock();
try {
// Check the current commit again now that we have the lock.
currentCommit = await _getCurrentCommit();
if (currentCommit != goldensCommit) {
if (currentCommit == null) {
await _initRepository();
}
await _syncTo(goldensCommit);
}
} finally {
await _releaseLock();
}
}
}
Future<String> _getGoldensCommit() async {
final File versionFile = flutterRoot.childFile(fs.path.join('bin', 'internal', 'goldens.version'));
return (await versionFile.readAsString()).trim();
}
Future<String> _getCurrentCommit() async {
if (!repositoryRoot.existsSync()) {
return null;
} else {
final io.ProcessResult revParse = await process.run(
<String>['git', 'rev-parse', 'HEAD'],
workingDirectory: repositoryRoot.path,
);
return revParse.exitCode == 0 ? revParse.stdout.trim() : null;
}
}
Future<void> _initRepository() async {
await repositoryRoot.create(recursive: true);
await _runCommands(
<String>[
'git init',
'git remote add upstream https://github.com/flutter/goldens.git',
],
workingDirectory: repositoryRoot,
);
}
Future<void> _syncTo(String commit) async {
await _runCommands(
<String>[
'git pull upstream master',
'git fetch upstream $commit',
'git reset --hard FETCH_HEAD',
],
workingDirectory: repositoryRoot,
);
}
Future<void> _runCommands(
List<String> commands, {
Directory workingDirectory,
}) async {
for (String command in commands) {
final List<String> parts = command.split(' ');
final io.ProcessResult result = await process.run(
parts,
workingDirectory: workingDirectory?.path,
);
if (result.exitCode != 0) {
throw new NonZeroExitCode(result.exitCode, result.stderr);
}
}
}
Future<void> _obtainLock() async {
final File lockFile = flutterRoot.childFile(fs.path.join('bin', 'cache', 'goldens.lockfile'));
await lockFile.create(recursive: true);
_lock = await lockFile.open(mode: io.FileMode.WRITE);
await _lock.lock(io.FileLock.BLOCKING_EXCLUSIVE);
}
Future<void> _releaseLock() async {
await _lock.close();
_lock = null;
}
}
/// Exception that signals a process' exit with a non-zero exit code.
class NonZeroExitCode implements Exception {
const NonZeroExitCode(this.exitCode, this.stderr) : assert(exitCode != 0);
final int exitCode;
final String stderr;
@override
String toString() {
return 'Exit code $exitCode: $stderr';
}
}