| // 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; |
| import 'dart:typed_data'; |
| |
| import 'package:engine_repo_tools/engine_repo_tools.dart'; |
| import 'package:litetest/litetest.dart'; |
| import 'package:path/path.dart' as p; |
| import 'package:process_fakes/process_fakes.dart'; |
| import 'package:skia_gold_client/skia_gold_client.dart'; |
| import 'package:skia_gold_client/src/release_version.dart'; |
| |
| void main() { |
| /// A mock commit hash that is used to simulate a successful git call. |
| const String mockCommitHash = '1234567890abcdef'; |
| |
| /// Simulating what a presubmit environment would look like. |
| const Map<String, String> presubmitEnv = <String, String>{ |
| 'GOLDCTL': 'python tools/goldctl.py', |
| 'GOLD_TRYJOB': 'flutter/engine/1234567890', |
| 'LOGDOG_STREAM_PREFIX': 'buildbucket/cr-buildbucket.appspot.com/1234567890/+/logdog', |
| 'LUCI_CONTEXT': '{}', |
| }; |
| |
| /// Simulating what a postsubmit environment would look like. |
| const Map<String, String> postsubmitEnv = <String, String>{ |
| 'GOLDCTL': 'python tools/goldctl.py', |
| 'LOGDOG_STREAM_PREFIX': 'buildbucket/cr-buildbucket.appspot.com/1234567890/+/logdog', |
| 'LUCI_CONTEXT': '{}' |
| }; |
| |
| /// Simulating what a local environment would look like. |
| const Map<String, String> localEnv = <String, String>{}; |
| |
| /// Creates a [SkiaGoldClient] with the given [dimensions] and [verbose] flag. |
| /// |
| /// Optionally, the [onRun] function can be provided to handle the execution |
| /// of the command-line tool. If not provided, it throws an |
| /// [UnsupportedError] by default. |
| /// |
| /// Side-effects of the client can be observed through the test fixture. |
| SkiaGoldClient createClient( |
| _TestFixture fixture, { |
| required Map<String, String> environment, |
| ReleaseVersion? engineVersion, |
| Map<String, String>? dimensions, |
| bool verbose = false, |
| io.ProcessResult Function(List<String> command) onRun = _runUnhandled, |
| }) { |
| return SkiaGoldClient.forTesting( |
| fixture.workDirectory, |
| dimensions: dimensions, |
| engineRoot: Engine.fromSrcPath(fixture.engineSrcDir.path), |
| httpClient: fixture.httpClient, |
| processManager: FakeProcessManager( |
| onRun: onRun, |
| ), |
| verbose: verbose, |
| stderr: fixture.outputSink, |
| environment: environment, |
| ); |
| } |
| |
| /// Creates a `temp/auth_opt.json` file in the working directory. |
| /// |
| /// This simulates what the goldctl tool does when it runs. |
| void createAuthOptDotJson(String workDirectory) { |
| final io.File authOptDotJson = io.File(p.join(workDirectory, 'temp', 'auth_opt.json')); |
| authOptDotJson.createSync(recursive: true); |
| authOptDotJson.writeAsStringSync('{"GSUtil": false}'); |
| } |
| |
| test('fails if GOLDCTL is not set', () async { |
| final _TestFixture fixture = _TestFixture(); |
| try { |
| final SkiaGoldClient client = createClient( |
| fixture, |
| environment: localEnv, |
| ); |
| try { |
| await client.auth(); |
| fail('auth should fail if GOLDCTL is not set'); |
| } on StateError catch (error) { |
| expect('$error', contains('GOLDCTL is not set')); |
| } |
| } finally { |
| fixture.dispose(); |
| } |
| }); |
| |
| test('auth executes successfully', () async { |
| final _TestFixture fixture = _TestFixture(); |
| try { |
| final SkiaGoldClient client = createClient( |
| fixture, |
| environment: presubmitEnv, |
| onRun: (List<String> command) { |
| expect(command, <String>[ |
| 'python tools/goldctl.py', |
| 'auth', |
| '--work-dir', |
| p.join(fixture.workDirectory.path, 'temp'), |
| '--luci', |
| ]); |
| createAuthOptDotJson(fixture.workDirectory.path); |
| return io.ProcessResult(0, 0, '', ''); |
| }, |
| ); |
| await client.auth(); |
| } finally { |
| fixture.dispose(); |
| } |
| }); |
| |
| test('auth is only invoked once per instance', () async { |
| final _TestFixture fixture = _TestFixture(); |
| try { |
| int callsToGoldctl = 0; |
| final SkiaGoldClient client = createClient( |
| fixture, |
| environment: presubmitEnv, |
| onRun: (List<String> command) { |
| callsToGoldctl++; |
| expect(command, <String>[ |
| 'python tools/goldctl.py', |
| 'auth', |
| '--work-dir', |
| p.join(fixture.workDirectory.path, 'temp'), |
| '--luci', |
| ]); |
| createAuthOptDotJson(fixture.workDirectory.path); |
| return io.ProcessResult(0, 0, '', ''); |
| }, |
| ); |
| |
| await client.auth(); |
| await client.auth(); |
| expect(callsToGoldctl, 1); |
| } finally { |
| fixture.dispose(); |
| } |
| }); |
| |
| test('auth executes successfully with verbose logging', () async { |
| final _TestFixture fixture = _TestFixture(); |
| try { |
| final SkiaGoldClient client = createClient( |
| fixture, |
| environment: presubmitEnv, |
| verbose: true, |
| onRun: (List<String> command) { |
| expect(command, <String>[ |
| 'python tools/goldctl.py', |
| 'auth', |
| '--verbose', |
| '--work-dir', |
| p.join(fixture.workDirectory.path, 'temp'), |
| '--luci', |
| ]); |
| return io.ProcessResult(0, 0, 'stdout', 'stderr'); |
| }, |
| ); |
| |
| await client.auth(); |
| expect(fixture.outputSink.toString(), contains('stdout:\nstdout')); |
| expect(fixture.outputSink.toString(), contains('stderr:\nstderr')); |
| } finally { |
| fixture.dispose(); |
| } |
| }); |
| |
| test('auth fails', () async { |
| final _TestFixture fixture = _TestFixture(); |
| try { |
| final SkiaGoldClient client = createClient( |
| fixture, |
| environment: presubmitEnv, |
| onRun: (List<String> command) { |
| return io.ProcessResult(1, 0, 'stdout-text', 'stderr-text'); |
| }, |
| ); |
| |
| try { |
| await client.auth(); |
| } on SkiaGoldProcessError catch (error) { |
| expect(error.command, contains('auth')); |
| expect(error.stdout, 'stdout-text'); |
| expect(error.stderr, 'stderr-text'); |
| expect(error.message, contains('Skia Gold authorization failed')); |
| } |
| } finally { |
| fixture.dispose(); |
| } |
| }); |
| |
| test('addImg [pre-submit] executes successfully', () async { |
| final _TestFixture fixture = _TestFixture(); |
| try { |
| final SkiaGoldClient client = createClient( |
| fixture, |
| environment: presubmitEnv, |
| onRun: (List<String> command) { |
| if (command case ['git', ...]) { |
| return io.ProcessResult(0, 0, mockCommitHash, ''); |
| } |
| if (command case ['python tools/goldctl.py', 'imgtest', 'init', ...]) { |
| return io.ProcessResult(0, 0, '', ''); |
| } |
| expect(command, <String>[ |
| 'python tools/goldctl.py', |
| 'imgtest', |
| 'add', |
| '--work-dir', |
| p.join(fixture.workDirectory.path, 'temp'), |
| '--test-name', |
| 'test-name', |
| '--png-file', |
| p.join(fixture.workDirectory.path, 'temp', 'golden.png'), |
| '--add-test-optional-key', |
| 'image_matching_algorithm:fuzzy', |
| '--add-test-optional-key', |
| 'fuzzy_max_different_pixels:10', |
| '--add-test-optional-key', |
| 'fuzzy_pixel_delta_threshold:0', |
| ]); |
| return io.ProcessResult(0, 0, '', ''); |
| }, |
| ); |
| |
| await client.addImg( |
| 'test-name.foo', |
| io.File(p.join(fixture.workDirectory.path, 'temp', 'golden.png')), |
| screenshotSize: 1000, |
| ); |
| } finally { |
| fixture.dispose(); |
| } |
| }); |
| |
| test('addImg [pre-submit] executes successfully with a release version', () async { |
| // Adds a suffix of "_Release_3_21" to the test name. |
| final _TestFixture fixture = _TestFixture( |
| // Creates a file called "engine/src/fluter/.engine-release.version" with the contents "3.21". |
| engineVersion: ReleaseVersion( |
| major: 3, |
| minor: 21, |
| ), |
| ); |
| try { |
| final SkiaGoldClient client = createClient( |
| fixture, |
| environment: presubmitEnv, |
| onRun: (List<String> command) { |
| if (command case ['git', ...]) { |
| return io.ProcessResult(0, 0, mockCommitHash, ''); |
| } |
| if (command case ['python tools/goldctl.py', 'imgtest', 'init', ...]) { |
| return io.ProcessResult(0, 0, '', ''); |
| } |
| expect(command, <String>[ |
| 'python tools/goldctl.py', |
| 'imgtest', |
| 'add', |
| '--work-dir', |
| p.join(fixture.workDirectory.path, 'temp'), |
| '--test-name', |
| // This is the significant change. |
| 'test-name_Release_3_21', |
| '--png-file', |
| p.join(fixture.workDirectory.path, 'temp', 'golden.png'), |
| '--add-test-optional-key', |
| 'image_matching_algorithm:fuzzy', |
| '--add-test-optional-key', |
| 'fuzzy_max_different_pixels:10', |
| '--add-test-optional-key', |
| 'fuzzy_pixel_delta_threshold:0', |
| ]); |
| return io.ProcessResult(0, 0, '', ''); |
| }, |
| ); |
| |
| await client.addImg( |
| 'test-name.foo', |
| io.File(p.join(fixture.workDirectory.path, 'temp', 'golden.png')), |
| screenshotSize: 1000, |
| ); |
| } finally { |
| fixture.dispose(); |
| } |
| }); |
| |
| test('addImg [pre-submit] executes successfully with verbose logging', () async { |
| final _TestFixture fixture = _TestFixture(); |
| try { |
| final SkiaGoldClient client = createClient( |
| fixture, |
| environment: presubmitEnv, |
| verbose: true, |
| onRun: (List<String> command) { |
| if (command case ['git', ...]) { |
| return io.ProcessResult(0, 0, mockCommitHash, ''); |
| } |
| if (command case ['python tools/goldctl.py', 'imgtest', 'init', ...]) { |
| return io.ProcessResult(0, 0, '', ''); |
| } |
| expect(command, <String>[ |
| 'python tools/goldctl.py', |
| 'imgtest', |
| 'add', |
| '--verbose', |
| '--work-dir', |
| p.join(fixture.workDirectory.path, 'temp'), |
| '--test-name', |
| 'test-name', |
| '--png-file', |
| p.join(fixture.workDirectory.path, 'temp', 'golden.png'), |
| '--add-test-optional-key', |
| 'image_matching_algorithm:fuzzy', |
| '--add-test-optional-key', |
| 'fuzzy_max_different_pixels:10', |
| '--add-test-optional-key', |
| 'fuzzy_pixel_delta_threshold:0', |
| ]); |
| return io.ProcessResult(0, 0, 'stdout', 'stderr'); |
| }, |
| ); |
| |
| await client.addImg( |
| 'test-name.foo', |
| io.File(p.join(fixture.workDirectory.path, 'temp', 'golden.png')), |
| screenshotSize: 1000, |
| ); |
| |
| expect(fixture.outputSink.toString(), contains('stdout:\nstdout')); |
| expect(fixture.outputSink.toString(), contains('stderr:\nstderr')); |
| } finally { |
| fixture.dispose(); |
| } |
| }); |
| |
| // A success case (exit code 0) with a message of "Untriaged" is OK. |
| test('addImg [pre-submit] succeeds but has an untriaged image', () async { |
| final _TestFixture fixture = _TestFixture(); |
| try { |
| final SkiaGoldClient client = createClient( |
| fixture, |
| environment: presubmitEnv, |
| onRun: (List<String> command) { |
| if (command case ['git', ...]) { |
| return io.ProcessResult(0, 0, mockCommitHash, ''); |
| } |
| if (command case ['python tools/goldctl.py', 'imgtest', 'init', ...]) { |
| return io.ProcessResult(0, 0, '', ''); |
| } |
| expect(command, <String>[ |
| 'python tools/goldctl.py', |
| 'imgtest', |
| 'add', |
| '--work-dir', |
| p.join(fixture.workDirectory.path, 'temp'), |
| '--test-name', |
| 'test-name', |
| '--png-file', |
| p.join(fixture.workDirectory.path, 'temp', 'golden.png'), |
| '--add-test-optional-key', |
| 'image_matching_algorithm:fuzzy', |
| '--add-test-optional-key', |
| 'fuzzy_max_different_pixels:10', |
| '--add-test-optional-key', |
| 'fuzzy_pixel_delta_threshold:0', |
| ]); |
| // Intentionally returning a non-zero exit code. |
| return io.ProcessResult(0, 1, 'Untriaged', ''); |
| }, |
| ); |
| |
| await client.addImg( |
| 'test-name.foo', |
| io.File(p.join(fixture.workDirectory.path, 'temp', 'golden.png')), |
| screenshotSize: 1000, |
| ); |
| |
| // Expect a stderr log message. |
| final String log = fixture.outputSink.toString(); |
| expect(log, contains('Untriaged image detected')); |
| } finally { |
| fixture.dispose(); |
| } |
| }); |
| |
| test('addImg [pre-submit] fails due to an unexpected error', () async { |
| final _TestFixture fixture = _TestFixture(); |
| try { |
| final SkiaGoldClient client = createClient( |
| fixture, |
| environment: presubmitEnv, |
| onRun: (List<String> command) { |
| if (command case ['git', ...]) { |
| return io.ProcessResult(0, 0, mockCommitHash, ''); |
| } |
| if (command case ['python tools/goldctl.py', 'imgtest', 'init', ...]) { |
| return io.ProcessResult(0, 0, '', ''); |
| } |
| return io.ProcessResult(1, 0, 'stdout-text', 'stderr-text'); |
| }, |
| ); |
| |
| try { |
| await client.addImg( |
| 'test-name.foo', |
| io.File(p.join(fixture.workDirectory.path, 'temp', 'golden.png')), |
| screenshotSize: 1000, |
| ); |
| } on SkiaGoldProcessError catch (error) { |
| expect(error.message, contains('Skia Gold image test failed.')); |
| expect(error.stdout, 'stdout-text'); |
| expect(error.stderr, 'stderr-text'); |
| expect(error.command, contains('imgtest add')); |
| } |
| } finally { |
| fixture.dispose(); |
| } |
| }); |
| |
| test('addImg [post-submit] executes successfully', () async { |
| final _TestFixture fixture = _TestFixture(); |
| try { |
| final SkiaGoldClient client = createClient( |
| fixture, |
| environment: postsubmitEnv, |
| onRun: (List<String> command) { |
| if (command case ['git', ...]) { |
| return io.ProcessResult(0, 0, mockCommitHash, ''); |
| } |
| if (command case ['python tools/goldctl.py', 'imgtest', 'init', ...]) { |
| return io.ProcessResult(0, 0, '', ''); |
| } |
| expect(command, <String>[ |
| 'python tools/goldctl.py', |
| 'imgtest', |
| 'add', |
| '--work-dir', |
| p.join(fixture.workDirectory.path, 'temp'), |
| '--test-name', |
| 'test-name', |
| '--png-file', |
| p.join(fixture.workDirectory.path, 'temp', 'golden.png'), |
| '--passfail', |
| '--add-test-optional-key', |
| 'image_matching_algorithm:fuzzy', |
| '--add-test-optional-key', |
| 'fuzzy_max_different_pixels:10', |
| '--add-test-optional-key', |
| 'fuzzy_pixel_delta_threshold:0', |
| ]); |
| return io.ProcessResult(0, 0, '', ''); |
| }, |
| ); |
| |
| await client.addImg( |
| 'test-name.foo', |
| io.File(p.join(fixture.workDirectory.path, 'temp', 'golden.png')), |
| screenshotSize: 1000, |
| ); |
| } finally { |
| fixture.dispose(); |
| } |
| }); |
| |
| test('addImg [post-submit] executes successfully with verbose logging', () async { |
| final _TestFixture fixture = _TestFixture(); |
| try { |
| final SkiaGoldClient client = createClient( |
| fixture, |
| environment: postsubmitEnv, |
| verbose: true, |
| onRun: (List<String> command) { |
| if (command case ['git', ...]) { |
| return io.ProcessResult(0, 0, mockCommitHash, ''); |
| } |
| if (command case ['python tools/goldctl.py', 'imgtest', 'init', ...]) { |
| return io.ProcessResult(0, 0, '', ''); |
| } |
| expect(command, <String>[ |
| 'python tools/goldctl.py', |
| 'imgtest', |
| 'add', |
| '--verbose', |
| '--work-dir', |
| p.join(fixture.workDirectory.path, 'temp'), |
| '--test-name', |
| 'test-name', |
| '--png-file', |
| p.join(fixture.workDirectory.path, 'temp', 'golden.png'), |
| '--passfail', |
| '--add-test-optional-key', |
| 'image_matching_algorithm:fuzzy', |
| '--add-test-optional-key', |
| 'fuzzy_max_different_pixels:10', |
| '--add-test-optional-key', |
| 'fuzzy_pixel_delta_threshold:0', |
| ]); |
| return io.ProcessResult(0, 0, 'stdout', 'stderr'); |
| }, |
| ); |
| |
| await client.addImg( |
| 'test-name.foo', |
| io.File(p.join(fixture.workDirectory.path, 'temp', 'golden.png')), |
| screenshotSize: 1000, |
| ); |
| |
| expect(fixture.outputSink.toString(), contains('stdout:\nstdout')); |
| expect(fixture.outputSink.toString(), contains('stderr:\nstderr')); |
| } finally { |
| fixture.dispose(); |
| } |
| }); |
| |
| test('addImg [post-submit] fails due to an unapproved image', () async { |
| final _TestFixture fixture = _TestFixture(); |
| try { |
| final SkiaGoldClient client = createClient( |
| fixture, |
| environment: postsubmitEnv, |
| onRun: (List<String> command) { |
| if (command case ['git', ...]) { |
| return io.ProcessResult(0, 0, mockCommitHash, ''); |
| } |
| if (command case ['python tools/goldctl.py', 'imgtest', 'init', ...]) { |
| return io.ProcessResult(0, 0, '', ''); |
| } |
| return io.ProcessResult(1, 0, 'stdout-text', 'stderr-text'); |
| }, |
| ); |
| |
| try { |
| await client.addImg( |
| 'test-name.foo', |
| io.File(p.join(fixture.workDirectory.path, 'temp', 'golden.png')), |
| screenshotSize: 1000, |
| ); |
| } on SkiaGoldProcessError catch (error) { |
| expect(error.message, contains('Skia Gold image test failed.')); |
| expect(error.stdout, 'stdout-text'); |
| expect(error.stderr, 'stderr-text'); |
| expect(error.command, contains('imgtest add')); |
| } |
| } finally { |
| fixture.dispose(); |
| } |
| }); |
| |
| test('getExpectationsForTest returns the latest positive digest', () async { |
| final _TestFixture fixture = _TestFixture(); |
| try { |
| final SkiaGoldClient client = createClient( |
| fixture, |
| environment: presubmitEnv, |
| onRun: (List<String> command) { |
| expect(command, <String>[ |
| 'python tools/goldctl.py', |
| 'imgtest', |
| 'get', |
| '--work-dir', |
| p.join(fixture.workDirectory.path, 'temp'), |
| '--test-name', |
| 'test-name', |
| ]); |
| return io.ProcessResult(0, 0, '{"digest":"digest"}', ''); |
| }, |
| ); |
| |
| final String hash = client.getTraceID('test-name'); |
| fixture.httpClient.setJsonResponse( |
| Uri.parse('https://flutter-engine-gold.skia.org/json/v2/latestpositivedigest/$hash'), |
| <String, Object?>{ |
| 'digest': 'digest', |
| }, |
| ); |
| |
| final String? digest = await client.getExpectationForTest('test-name'); |
| expect(digest, 'digest'); |
| } finally { |
| fixture.dispose(); |
| } |
| }); |
| } |
| |
| final class _TestFixture { |
| _TestFixture({ |
| ReleaseVersion? engineVersion, |
| }) { |
| workDirectory = rootDirectory.createTempSync('working'); |
| |
| // Create the engine/src directory. |
| engineSrcDir = io.Directory(p.join(rootDirectory.path, 'engine', 'src')); |
| engineSrcDir.createSync(recursive: true); |
| |
| // Create a .engine-release.version file in the engine root. |
| final io.Directory flutterDir = io.Directory(p.join(engineSrcDir.path, 'flutter')); |
| flutterDir.createSync(recursive: true); |
| |
| final String version = engineVersion?.toString() ?? 'none'; |
| io.File(p.join(flutterDir.path, '.engine-release.version')).writeAsStringSync(version); |
| } |
| |
| final io.Directory rootDirectory = io.Directory.systemTemp.createTempSync('skia_gold_client_test'); |
| late final io.Directory workDirectory; |
| late final io.Directory engineSrcDir; |
| |
| final _FakeHttpClient httpClient = _FakeHttpClient(); |
| final StringSink outputSink = StringBuffer(); |
| |
| void dispose() { |
| rootDirectory.deleteSync(recursive: true); |
| } |
| } |
| |
| io.ProcessResult _runUnhandled(List<String> command) { |
| throw UnimplementedError('Unhandled run: ${command.join(' ')}'); |
| } |
| |
| /// An in-memory fake of [io.HttpClient] that allows [getUrl] to be mocked. |
| /// |
| /// This class is used to simulate a response from the server. |
| /// |
| /// Any other methods called on this class will throw a [NoSuchMethodError]. |
| final class _FakeHttpClient implements io.HttpClient { |
| final Map<Uri, Object?> _expectedResponses = <Uri, Object?>{}; |
| |
| /// Sets an expected response for the given [request] to [jsonEncodableValue]. |
| /// |
| /// This method is used to simulate a response from the server. |
| void setJsonResponse(Uri request, Object? jsonEncodableValue) { |
| _expectedResponses[request] = jsonEncodableValue; |
| } |
| |
| @override |
| Future<io.HttpClientRequest> getUrl(Uri url) async { |
| final Object? response = _expectedResponses[url]; |
| if (response == null) { |
| throw StateError('No request expected for $url'); |
| } |
| return _FakeHttpClientRequest.withJsonResponse(response); |
| } |
| |
| @override |
| Object? noSuchMethod(Invocation invocation) { |
| return super.noSuchMethod(invocation); |
| } |
| } |
| |
| final class _FakeHttpClientRequest implements io.HttpClientRequest { |
| factory _FakeHttpClientRequest.withJsonResponse(Object? jsonResponse) { |
| final Uint8List bytes = utf8.encoder.convert(jsonEncode(jsonResponse)); |
| return _FakeHttpClientRequest._(_FakeHttpClientResponse(bytes)); |
| } |
| |
| _FakeHttpClientRequest._(this._response); |
| |
| final io.HttpClientResponse _response; |
| |
| @override |
| Future<io.HttpClientResponse> close() async { |
| return _response; |
| } |
| |
| @override |
| Object? noSuchMethod(Invocation invocation) { |
| return super.noSuchMethod(invocation); |
| } |
| } |
| |
| final class _FakeHttpClientResponse extends Stream<List<int>> |
| implements io.HttpClientResponse { |
| _FakeHttpClientResponse(this._bytes); |
| |
| final Uint8List _bytes; |
| |
| @override |
| StreamSubscription<List<int>> listen( |
| void Function(List<int> event)? onData, { |
| Function? onError, |
| void Function()? onDone, |
| bool? cancelOnError, |
| }) { |
| return Stream<List<int>>.fromIterable(<List<int>>[_bytes]).listen( |
| onData, |
| onError: onError, |
| onDone: onDone, |
| cancelOnError: cancelOnError, |
| ); |
| } |
| |
| @override |
| int get statusCode => 200; |
| |
| @override |
| Object? noSuchMethod(Invocation invocation) { |
| return super.noSuchMethod(invocation); |
| } |
| } |