blob: 1d094d0a48e9469058b025a2e5c61af76d09534e [file] [log] [blame]
import 'dart:async';
import 'dart:convert';
import 'dart:io' as io;
import 'dart:typed_data';
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';
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,
Map<String, String>? dimensions,
bool verbose = false,
io.ProcessResult Function(List<String> command) onRun = _runUnhandled,
}) {
return SkiaGoldClient(
fixture.workDirectory,
dimensions: dimensions,
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');
} 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, '', 'error-text');
},
);
try {
await client.auth();
} catch (error) {
expect('$error', contains('Skia Gold authorization failed.'));
expect('$error', contains('error-text'));
}
} 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 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,
);
} 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, '', 'error-text');
},
);
try {
await client.addImg(
'test-name.foo',
io.File(p.join(fixture.workDirectory.path, 'temp', 'golden.png')),
screenshotSize: 1000,
);
} catch (error) {
expect('$error', contains('Skia Gold image test failed.'));
expect('$error', contains('error-text'));
}
} 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, '', 'error-text');
},
);
try {
await client.addImg(
'test-name.foo',
io.File(p.join(fixture.workDirectory.path, 'temp', 'golden.png')),
screenshotSize: 1000,
);
} catch (error) {
expect('$error', contains('Skia Gold image test failed.'));
expect('$error', contains('error-text'));
}
} 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();
final io.Directory workDirectory = io.Directory.systemTemp.createTempSync('skia_gold_client_test');
final _FakeHttpClient httpClient = _FakeHttpClient();
final StringSink outputSink = StringBuffer();
void dispose() {
workDirectory.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);
}
}