blob: b3265866da3da782fd94190e704df4212296f885 [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: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);
}
}