Commit b6a2efb7 authored by Kate Lovett's avatar Kate Lovett

Committing progress. Documentation and testing incomplete/in progress. Code cleanup needed as well.

parent efe744a3
// 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';
import 'dart:typed_data';
import 'package:file/file.dart';
......@@ -12,6 +12,8 @@ import 'package:meta/meta.dart';
import 'package:flutter_goldens_client/client.dart';
export 'package:flutter_goldens_client/client.dart';
//TODO(katelovett): Tests [flutter_goldens_test.dart] and inline documentation
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
......@@ -25,12 +27,14 @@ Future<void> main(FutureOr<void> testMain()) async {
///
/// 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`.
/// minimum. To satisfy this requirement, this comparator uses the
/// [SkiaGoldClient] to upload widgets for golden tests and process results.
///
/// 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.
/// This comparator will instantiate the [SkiaGoldClient] and process the
/// results of the test.
class FlutterGoldenFileComparator implements GoldenFileComparator {
/// Creates a [FlutterGoldenFileComparator] that will resolve golden file
/// URIs relative to the specified [basedir].
......@@ -49,48 +53,36 @@ class FlutterGoldenFileComparator implements GoldenFileComparator {
@visibleForTesting
final FileSystem fs;
/// Instance of the [SkiaGoldClient] for executing tests.
final SkiaGoldClient _skiaClient = SkiaGoldClient();
/// 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.
///
/// The [goldens] and [defaultComparator] parameters are visible for testing
/// The [defaultComparator] parameter is visible for testing
/// purposes only.
static Future<FlutterGoldenFileComparator> fromDefaultComparator({
GoldensClient goldens,
LocalFileComparator defaultComparator,
}) async {
defaultComparator ??= goldenFileComparator;
// Prepare the goldens repo.
goldens ??= GoldensClient();
await goldens.prepare();
// Calculate the appropriate basedir for the current test context.
final FileSystem fs = goldens.fs;
const FileSystem fs = LocalFileSystem();
final Directory testDirectory = fs.directory(defaultComparator.basedir);
final String testDirectoryRelativePath = fs.path.relative(testDirectory.path, from: goldens.flutterRoot.path);
return FlutterGoldenFileComparator(goldens.repositoryRoot.childDirectory(testDirectoryRelativePath).uri);
final Directory flutterRoot = fs.directory(Platform.environment[_kFlutterRootKey]);
final Directory goldenRoot = flutterRoot.childDirectory(fs.path.join('bin', 'cache', 'pkg', 'goldens'));
final String testDirectoryRelativePath = fs.path.relative(testDirectory.path, from: flutterRoot.path);
return FlutterGoldenFileComparator(goldenRoot.childDirectory(testDirectoryRelativePath).uri);
}
@override
Future<bool> compare(Uint8List imageBytes, Uri golden) async {
final File goldenFile = _getGoldenFile(golden);
if (!goldenFile.existsSync()) {
throw 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.
if (goldenBytes.length != imageBytes.length) {
return false;
}
for (int i = 0; i < goldenBytes.length; i++) {
if (goldenBytes[i] != imageBytes[i]) {
return false;
}
}
return true;
bool authorized = await _skiaClient.auth(fs.directory(basedir));
bool tested = await _skiaClient.imgtest(golden.path, goldenFile);
return true; // TEMP
//TODO(katelovett): Process results
}
@override
......@@ -103,4 +95,5 @@ class FlutterGoldenFileComparator implements GoldenFileComparator {
File _getGoldenFile(Uri uri) {
return fs.directory(basedir).childFile(fs.file(uri).path);
}
}
// 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.
//TODO(katelovett): Change to Skia Gold Client
import 'dart:async';
import 'dart:convert' as convert;
import 'dart:io' as io;
import 'package:file/file.dart';
......@@ -13,165 +14,116 @@ import 'package:process/process.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/wiki/Writing-a-golden-file-test-for-package%3Aflutter
//TODO(katelovett): Tests [flutter_goldens_test.dart] and inline documentation
const String _kFlutterRootKey = 'FLUTTER_ROOT';
const String _kGoldctlKey = 'GOLDCTL';
const String _kServiceAccountKey = 'GOLD_SERVICE_ACCOUNT';
const String _kSkiaGoldInstance = 'SKIA_GOLD_INSTANCE';
/// 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.
class GoldensClient {
/// Create a handle to a local clone of the goldens repository.
GoldensClient({
class SkiaGoldClient {
SkiaGoldClient({
this.fs = const LocalFileSystem(),
this.platform = const LocalPlatform(),
this.process = const LocalProcessManager(),
});
/// The file system to use for storing the local clone of the repository.
///
/// This is useful in tests, where a local file system (the default) can
/// be replaced by a memory file system.
final FileSystem fs;
/// A wrapper for the [dart:io.Platform] API.
///
/// This is useful in tests, where the system platform (the default) can
/// be replaced by a mock platform instance.
final Platform platform;
/// A controller for launching subprocesses.
///
/// This is useful in tests, where the real process manager (the default)
/// can be replaced by a mock process manager that doesn't really create
/// subprocesses.
final ProcessManager process;
Directory _workDirectory;
RandomAccessFile _lock;
//TODO(katelovett): Environment variables swapped out for final CI implementation
String get _goldctl => platform.environment[_kGoldctlKey];
String get _serviceAccount => platform.environment[_kServiceAccountKey];
String get _skiaGoldInstance => platform.environment[_kSkiaGoldInstance];
Directory get _flutterRoot => fs.directory(platform.environment[_kFlutterRootKey]);
/// The local [Directory] where the Flutter repository is hosted.
///
/// Uses the [fs] file system.
Directory get flutterRoot => fs.directory(platform.environment[_kFlutterRootKey]);
Future<bool> auth(Directory workDirectory) async {
_workDirectory = workDirectory;
List<String> authArguments = <String>['auth'];
if(_serviceAccount == null)
throw const NonZeroExitCode(1, 'No Service Account found.');
/// The local [Directory] where the goldens repository is hosted.
///
/// Uses the [fs] file system.
Directory get repositoryRoot => flutterRoot.childDirectory(fs.path.join('bin', 'cache', 'pkg', 'goldens'));
authArguments += <String>[
'--service-account', _serviceAccount,
'--work-dir', _workDirectory.childDirectory('temp').path,
];
/// 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 _checkCanSync();
await _syncTo(goldensCommit);
}
} finally {
await _releaseLock();
}
final io.ProcessResult authResults = io.Process.runSync(_goldctl, authArguments);
if (authResults.exitCode != 0) {
final StringBuffer buf = StringBuffer();
buf
..writeln('Flutter + Skia Gold auth failed.')
..writeln('stdout: ${authResults.stdout}')
..writeln('stderr: ${authResults.stderr}');
throw NonZeroExitCode(authResults.exitCode, buf.toString());
}
return true;
}
Future<String> _getGoldensCommit() async {
final File versionFile = flutterRoot.childFile(fs.path.join('bin', 'internal', 'goldens.version'));
return (await versionFile.readAsString()).trim();
}
Future<bool> imgtest(String testName, File goldenFile) async {
List<String> imgtestArguments = <String>[
'imgtest',
'add',
];
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;
}
}
final String commitHash = await _getCommitHash();
final String keys = '${_workDirectory.path}keys.json';
final String failures = '${_workDirectory.path}failures.json';
await io.File(keys).writeAsString(_getKeysJSON());
await io.File(failures).create();
Future<void> _initRepository() async {
await repositoryRoot.create(recursive: true);
await _runCommands(
<String>[
'git init',
'git remote add upstream https://github.com/flutter/goldens.git',
'git remote set-url --push upstream git@github.com:flutter/goldens.git',
],
workingDirectory: repositoryRoot,
);
}
imgtestArguments += <String>[
'--instance', _skiaGoldInstance,
'--work-dir', _workDirectory.childDirectory('temp').path,
'--commit', commitHash,
'--test-name', testName,
'--png-file', goldenFile.path,
'--keys-file', keys,
'--failure-file', failures,
'--passfail',
];
Future<void> _checkCanSync() async {
final io.ProcessResult result = await process.run(
<String>['git', 'status', '--porcelain'],
workingDirectory: repositoryRoot.path,
);
if (result.stdout.trim().isNotEmpty) {
if(imgtestArguments.contains(null)) {
final StringBuffer buf = StringBuffer();
buf
..writeln('flutter_goldens git checkout at ${repositoryRoot.path} has local changes and cannot be synced.')
..writeln('To reset your client to a clean state, and lose any local golden test changes:')
..writeln('cd ${repositoryRoot.path}')
..writeln('git reset --hard HEAD')
..writeln('git clean -x -d -f -f');
buf.writeln('null argument for Skia Gold imgtest:');
imgtestArguments.forEach(buf.writeln);
throw NonZeroExitCode(1, buf.toString());
}
}
Future<void> _syncTo(String commit) async {
await _runCommands(
<String>[
'git pull upstream master',
'git fetch upstream $commit',
'git reset --hard FETCH_HEAD',
],
workingDirectory: repositoryRoot,
);
final io.ProcessResult imgtestResult = io.Process.runSync(_goldctl, imgtestArguments);
if (imgtestResult.exitCode != 0) {
final StringBuffer buf = StringBuffer();
buf
..writeln('Flutter + Skia Gold imgtest failed.')
..writeln('If this is the first execution of this test, it may need to be triaged.')
..writeln('\tIn this case, re-run the test after triage is completed.')
..writeln('stdout: ${imgtestResult.stdout}')
..writeln('stderr: ${imgtestResult.stderr}');
throw NonZeroExitCode(imgtestResult.exitCode, buf.toString());
}
return true;
}
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,
Future<String> _getCommitHash() async {
if (!_flutterRoot.existsSync()) {
return null;
} else {
final io.ProcessResult revParse = await process.run(
<String>['git', 'rev-parse', 'HEAD'],
workingDirectory: _flutterRoot.path,
);
if (result.exitCode != 0) {
throw NonZeroExitCode(result.exitCode, result.stderr);
}
return revParse.exitCode == 0 ? revParse.stdout.trim() : null;
}
}
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.blockingExclusive);
}
Future<void> _releaseLock() async {
await _lock.close();
_lock = null;
String _getKeysJSON() {
return convert.json.encode(
<String, dynamic>{
'Operating System' : io.Platform.operatingSystem,
'Operating System Version' : io.Platform.operatingSystemVersion,
'Dart Version' : io.Platform.version,
});
}
}
/// Exception that signals a process' exit with a non-zero exit code.
......@@ -194,3 +146,164 @@ class NonZeroExitCode implements Exception {
return 'Exit code $exitCode: $stderr';
}
}
//
///// 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.
//class GoldensClient {
// /// Create a handle to a local clone of the goldens repository.
// GoldensClient({
// this.fs = const LocalFileSystem(),
// this.platform = const LocalPlatform(),
// this.process = const LocalProcessManager(),
// });
//
// /// The file system to use for storing the local clone of the repository.
// ///
// /// This is useful in tests, where a local file system (the default) can
// /// be replaced by a memory file system.
// final FileSystem fs;
//
// /// A wrapper for the [dart:io.Platform] API.
// ///
// /// This is useful in tests, where the system platform (the default) can
// /// be replaced by a mock platform instance.
// final Platform platform;
//
// /// A controller for launching subprocesses.
// ///
// /// This is useful in tests, where the real process manager (the default)
// /// can be replaced by a mock process manager that doesn't really create
// /// subprocesses.
// final ProcessManager process;
//
// RandomAccessFile _lock;
//
// /// The local [Directory] where the Flutter repository is hosted.
// ///
// /// Uses the [fs] file system.
// Directory get flutterRoot => fs.directory(platform.environment[_kFlutterRootKey]);
//
// /// The local [Directory] where the goldens repository is hosted.
// ///
// /// Uses the [fs] file system.
// 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 {
// print('GoldensClient.prepare');
// 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 _checkCanSync();
// 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',
// 'git remote set-url --push upstream git@github.com:flutter/goldens.git',
// ],
// workingDirectory: repositoryRoot,
// );
// }
//
// Future<void> _checkCanSync() async {
// final io.ProcessResult result = await process.run(
// <String>['git', 'status', '--porcelain'],
// workingDirectory: repositoryRoot.path,
// );
// if (result.stdout.trim().isNotEmpty) {
// final StringBuffer buf = StringBuffer();
// buf
// ..writeln('flutter_goldens git checkout at ${repositoryRoot.path} has local changes and cannot be synced.')
// ..writeln('To reset your client to a clean state, and lose any local golden test changes:')
// ..writeln('cd ${repositoryRoot.path}')
// ..writeln('git reset --hard HEAD')
// ..writeln('git clean -x -d -f -f');
// throw NonZeroExitCode(1, buf.toString());
// }
// }
//
// 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 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.blockingExclusive);
// }
//
// Future<void> _releaseLock() async {
// await _lock.close();
// _lock = null;
// }
//}
\ No newline at end of file
......@@ -296,6 +296,16 @@ AsyncMatcher matchesGoldenFile(dynamic key) {
throw ArgumentError('Unexpected type for golden file: ${key.runtimeType}');
}
/// TODO(katelovett): Documentation
AsyncMatcher matchesSkiaGoldFile(dynamic key) {
if (key is Uri) {
return _MatchesSkiaGoldFile(key);
} else if (key is String) {
return _MatchesSkiaGoldFile.forStringPath(key);
}
throw ArgumentError('Unexpected type for Skia Gold file: ${key.runtimeType}');
}
/// Asserts that a [Finder], [Future<ui.Image>], or [ui.Image] matches a
/// reference image identified by [image].
///
......@@ -1688,6 +1698,53 @@ class _MatchesGoldenFile extends AsyncMatcher {
description.add('one widget whose rasterized image matches golden image "$key"');
}
class _MatchesSkiaGoldFile extends AsyncMatcher {
const _MatchesSkiaGoldFile(this.key);
_MatchesSkiaGoldFile.forStringPath(String path) : key = Uri.parse(path);
final Uri key;
@override
Future<String> matchAsync(dynamic item) async {
Future<ui.Image> imageFuture;
if (item is Future<ui.Image>) {
imageFuture = item;
}else if (item is ui.Image) {
imageFuture = Future<ui.Image>.value(item);
} else {
final Finder finder = item;
final Iterable<Element> elements = finder.evaluate();
if (elements.isEmpty) {
return 'could not be rendered because no widget was found.';
} else if (elements.length > 1) {
return 'matched too many widgets.';
}
imageFuture = _captureImage(elements.single);
}
final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.ensureInitialized();
return binding.runAsync<String>(() async {
final ui.Image image = await imageFuture;
final ByteData bytes = await image.toByteData(format: ui.ImageByteFormat.png)
.timeout(const Duration(seconds: 10), onTimeout: () => null);
if (bytes == null)
return 'Failed to generate screenshot from engine within the 10,000ms timeout';
await goldenFileComparator.update(key, bytes.buffer.asUint8List());
try {
final bool success = await goldenFileComparator.compare(null, key);
return success ? null : 'Skia Gold test fail.';
} on TestFailure catch (ex) {
return ex.message;
}
}, additionalTime: const Duration(seconds: 11));
}
@override
Description describe(Description description) =>
description.add('one widget whose rasterized images matches Skia Gold image $key');
}
class _MatchesSemanticsData extends Matcher {
_MatchesSemanticsData({
this.label,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment