[devicelab] Add results path flag to test runner (#72765)
diff --git a/dev/devicelab/bin/run.dart b/dev/devicelab/bin/run.dart
index 8582b96..c3f46be 100644
--- a/dev/devicelab/bin/run.dart
+++ b/dev/devicelab/bin/run.dart
@@ -42,6 +42,9 @@
/// Whether to exit on first test failure.
bool exitOnFirstTestFailure;
+/// Path to write test results to.
+String resultsPath;
+
/// File containing a service account token.
///
/// If passed, the test run results will be uploaded to Flutter infrastructure.
@@ -65,6 +68,16 @@
return;
}
+ deviceId = args['device-id'] as String;
+ exitOnFirstTestFailure = args['exit'] as bool;
+ gitBranch = args['git-branch'] as String;
+ localEngine = args['local-engine'] as String;
+ localEngineSrcPath = args['local-engine-src-path'] as String;
+ luciBuilder = args['luci-builder'] as String;
+ resultsPath = args['results-file'] as String;
+ serviceAccountTokenFile = args['service-account-token-file'] as String;
+ silent = args['silent'] as bool;
+
if (!args.wasParsed('task')) {
if (args.wasParsed('stage') || args.wasParsed('all')) {
addTasks(
@@ -89,15 +102,6 @@
return;
}
- deviceId = args['device-id'] as String;
- exitOnFirstTestFailure = args['exit'] as bool;
- gitBranch = args['git-branch'] as String;
- localEngine = args['local-engine'] as String;
- localEngineSrcPath = args['local-engine-src-path'] as String;
- luciBuilder = args['luci-builder'] as String;
- serviceAccountTokenFile = args['service-account-token-file'] as String;
- silent = args['silent'] as bool;
-
if (args.wasParsed('ab')) {
await _runABTest();
} else {
@@ -120,8 +124,17 @@
print(const JsonEncoder.withIndent(' ').convert(result));
section('Finished task "$taskName"');
- if (serviceAccountTokenFile != null) {
+ if (resultsPath != null) {
+ final Cocoon cocoon = Cocoon();
+ await cocoon.writeTaskResultToFile(
+ builderName: luciBuilder,
+ gitBranch: gitBranch,
+ result: result,
+ resultsPath: resultsPath,
+ );
+ } else if (serviceAccountTokenFile != null) {
final Cocoon cocoon = Cocoon(serviceAccountTokenPath: serviceAccountTokenFile);
+
/// Cocoon references LUCI tasks by the [luciBuilder] instead of [taskName].
await cocoon.sendTaskResult(builderName: luciBuilder, result: result, gitBranch: gitBranch);
}
@@ -224,7 +237,7 @@
File file = File(parts[0] + parts[1]);
int i = 1;
while (file.existsSync()) {
- file = File(parts[0]+i.toString()+parts[1]);
+ file = File(parts[0] + i.toString() + parts[1]);
i++;
}
return file;
@@ -355,10 +368,7 @@
'locally. Defaults to \$FLUTTER_ENGINE if set, or tries to guess at\n'
'the location based on the value of the --flutter-root option.',
)
- ..addOption(
- 'luci-builder',
- help: '[Flutter infrastructure] Name of the LUCI builder being run on.'
- )
+ ..addOption('luci-builder', help: '[Flutter infrastructure] Name of the LUCI builder being run on.')
..addFlag(
'match-host-platform',
defaultsTo: true,
@@ -368,6 +378,11 @@
'`required_agent_capabilities`\nin the `manifest.yaml` file.',
)
..addOption(
+ 'results-file',
+ help: '[Flutter infrastructure] File path for test results. If passed with\n'
+ 'task, will write test results to the file.'
+ )
+ ..addOption(
'service-account-token-file',
help: '[Flutter infrastructure] Authentication for uploading results.',
)
diff --git a/dev/devicelab/bin/test_runner.dart b/dev/devicelab/bin/test_runner.dart
new file mode 100644
index 0000000..fa8dc01
--- /dev/null
+++ b/dev/devicelab/bin/test_runner.dart
@@ -0,0 +1,21 @@
+// Copyright 2014 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:io';
+
+import 'package:args/command_runner.dart';
+import 'package:flutter_devicelab/command/upload_metrics.dart';
+
+final CommandRunner<void> runner =
+ CommandRunner<void>('devicelab_runner', 'DeviceLab test runner for recording performance metrics on applications')
+ ..addCommand(UploadMetricsCommand());
+
+Future<void> main(List<String> rawArgs) async {
+ runner.run(rawArgs).catchError((dynamic error) {
+ stderr.writeln('$error\n');
+ stderr.writeln('Usage:\n');
+ stderr.writeln(runner.usage);
+ exit(64); // Exit code 64 indicates a usage error.
+ });
+}
diff --git a/dev/devicelab/lib/command/upload_metrics.dart b/dev/devicelab/lib/command/upload_metrics.dart
new file mode 100644
index 0000000..f4935f5
--- /dev/null
+++ b/dev/devicelab/lib/command/upload_metrics.dart
@@ -0,0 +1,32 @@
+// Copyright 2014 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 'package:args/command_runner.dart';
+
+import '../framework/cocoon.dart';
+
+class UploadMetricsCommand extends Command<void> {
+ UploadMetricsCommand() {
+ argParser.addOption('results-file', help: 'Test results JSON to upload to Cocoon.');
+ argParser.addOption(
+ 'service-account-token-file',
+ help: 'Authentication token for uploading results.',
+ );
+ }
+
+ @override
+ String get name => 'upload-metrics';
+
+ @override
+ String get description => '[Flutter infrastructure] Upload metrics data to Cocoon';
+
+ @override
+ Future<void> run() async {
+ final String resultsPath = argResults['results-file'] as String;
+ final String serviceAccountTokenFile = argResults['service-account-token-file'] as String;
+
+ final Cocoon cocoon = Cocoon(serviceAccountTokenPath: serviceAccountTokenFile);
+ return cocoon.sendResultsPath(resultsPath);
+ }
+}
diff --git a/dev/devicelab/lib/framework/cocoon.dart b/dev/devicelab/lib/framework/cocoon.dart
index fe62fbb..7f5c8c6 100644
--- a/dev/devicelab/lib/framework/cocoon.dart
+++ b/dev/devicelab/lib/framework/cocoon.dart
@@ -34,9 +34,9 @@
Cocoon({
String serviceAccountTokenPath,
@visibleForTesting Client httpClient,
- @visibleForTesting FileSystem filesystem,
+ @visibleForTesting this.fs = const LocalFileSystem(),
@visibleForTesting this.processRunSync = Process.runSync,
- }) : _httpClient = AuthenticatedCocoonClient(serviceAccountTokenPath, httpClient: httpClient, filesystem: filesystem);
+ }) : _httpClient = AuthenticatedCocoonClient(serviceAccountTokenPath, httpClient: httpClient, filesystem: fs);
/// Client to make http requests to Cocoon.
final AuthenticatedCocoonClient _httpClient;
@@ -46,6 +46,9 @@
/// Url used to send results to.
static const String baseCocoonApiUrl = 'https://flutter-dashboard.appspot.com/api';
+ /// Underlying [FileSystem] to use.
+ final FileSystem fs;
+
static final Logger logger = Logger('CocoonClient');
String get commitSha => _commitSha ?? _readCommitSha();
@@ -61,8 +64,25 @@
return _commitSha = result.stdout as String;
}
+ /// Upload the JSON results in [resultsPath] to Cocoon.
+ ///
+ /// Flutter infrastructure's workflow is:
+ /// 1. Run DeviceLab test, writing results to a known path
+ /// 2. Request service account token from luci auth (valid for at least 3 minutes)
+ /// 3. Upload results from (1) to Cocooon
+ Future<void> sendResultsPath(String resultsPath) async {
+ final File resultFile = fs.file(resultsPath);
+ final Map<String, dynamic> resultsJson = json.decode(await resultFile.readAsString()) as Map<String, dynamic>;
+ await _sendUpdateTaskRequest(resultsJson);
+ }
+
/// Send [TaskResult] to Cocoon.
- Future<void> sendTaskResult({@required String builderName, @required TaskResult result, @required String gitBranch}) async {
+ // TODO(chillers): Remove when sendResultsPath is used in prod. https://github.com/flutter/flutter/issues/72457
+ Future<void> sendTaskResult({
+ @required String builderName,
+ @required TaskResult result,
+ @required String gitBranch,
+ }) async {
assert(builderName != null);
assert(gitBranch != null);
assert(result != null);
@@ -73,7 +93,45 @@
print('${rec.level.name}: ${rec.time}: ${rec.message}');
});
- final Map<String, dynamic> status = <String, dynamic>{
+ final Map<String, dynamic> updateRequest = _constructUpdateRequest(
+ gitBranch: gitBranch,
+ builderName: builderName,
+ result: result,
+ );
+ await _sendUpdateTaskRequest(updateRequest);
+ }
+
+ /// Write the given parameters into an update task request and store the JSON in [resultsPath].
+ Future<void> writeTaskResultToFile({
+ @required String builderName,
+ @required String gitBranch,
+ @required TaskResult result,
+ @required String resultsPath,
+ }) async {
+ assert(builderName != null);
+ assert(gitBranch != null);
+ assert(result != null);
+ assert(resultsPath != null);
+
+ final Map<String, dynamic> updateRequest = _constructUpdateRequest(
+ gitBranch: gitBranch,
+ builderName: builderName,
+ result: result,
+ );
+ final File resultFile = fs.file(resultsPath);
+ if (resultFile.existsSync()) {
+ resultFile.deleteSync();
+ }
+ resultFile.createSync();
+ resultFile.writeAsStringSync(json.encode(updateRequest));
+ }
+
+ Map<String, dynamic> _constructUpdateRequest({
+ @required String builderName,
+ @required TaskResult result,
+ @required String gitBranch,
+ }) {
+ final Map<String, dynamic> updateRequest = <String, dynamic>{
'CommitBranch': gitBranch,
'CommitSha': commitSha,
'BuilderName': builderName,
@@ -81,7 +139,7 @@
};
// Make a copy of result data because we may alter it for validation below.
- status['ResultData'] = result.data;
+ updateRequest['ResultData'] = result.data;
final List<String> validScoreKeys = <String>[];
if (result.benchmarkScoreKeys != null) {
@@ -95,9 +153,13 @@
}
}
}
- status['BenchmarkScoreKeys'] = validScoreKeys;
+ updateRequest['BenchmarkScoreKeys'] = validScoreKeys;
- final Map<String, dynamic> response = await _sendCocoonRequest('update-task-status', status);
+ return updateRequest;
+ }
+
+ Future<void> _sendUpdateTaskRequest(Map<String, dynamic> postBody) async {
+ final Map<String, dynamic> response = await _sendCocoonRequest('update-task-status', postBody);
if (response['Name'] != null) {
logger.info('Updated Cocoon with results from this task');
} else {
diff --git a/dev/devicelab/test/cocoon_test.dart b/dev/devicelab/test/cocoon_test.dart
index 8485142..a1f9ccd 100644
--- a/dev/devicelab/test/cocoon_test.dart
+++ b/dev/devicelab/test/cocoon_test.dart
@@ -48,7 +48,7 @@
_processResult = ProcessResult(1, 0, commitSha, '');
cocoon = Cocoon(
serviceAccountTokenPath: serviceAccountTokenPath,
- filesystem: fs,
+ fs: fs,
httpClient: mockClient,
processRunSync: runSyncStub,
);
@@ -60,7 +60,7 @@
_processResult = ProcessResult(1, 1, '', '');
cocoon = Cocoon(
serviceAccountTokenPath: serviceAccountTokenPath,
- filesystem: fs,
+ fs: fs,
httpClient: mockClient,
processRunSync: runSyncStub,
);
@@ -68,12 +68,69 @@
expect(() => cocoon.commitSha, throwsA(isA<CocoonException>()));
});
+ test('writes expected update task json', () async {
+ _processResult = ProcessResult(1, 0, commitSha, '');
+ final TaskResult result = TaskResult.fromJson(<String, dynamic>{
+ 'success': true,
+ 'data': <String, dynamic>{
+ 'i': 0,
+ 'j': 0,
+ 'not_a_metric': 'something',
+ },
+ 'benchmarkScoreKeys': <String>['i', 'j'],
+ });
+
+ cocoon = Cocoon(
+ fs: fs,
+ processRunSync: runSyncStub,
+ );
+
+ const String resultsPath = 'results.json';
+ await cocoon.writeTaskResultToFile(
+ builderName: 'builderAbc',
+ gitBranch: 'master',
+ result: result,
+ resultsPath: resultsPath,
+ );
+
+ final String resultJson = fs.file(resultsPath).readAsStringSync();
+ const String expectedJson = '{'
+ '"CommitBranch":"master",'
+ '"CommitSha":"$commitSha",'
+ '"BuilderName":"builderAbc",'
+ '"NewStatus":"Succeeded",'
+ '"ResultData":{"i":0.0,"j":0.0,"not_a_metric":"something"},'
+ '"BenchmarkScoreKeys":["i","j"]}';
+ expect(resultJson, expectedJson);
+ });
+
+ test('uploads expected update task payload from results file', () async {
+ _processResult = ProcessResult(1, 0, commitSha, '');
+ cocoon = Cocoon(
+ fs: fs,
+ httpClient: mockClient,
+ processRunSync: runSyncStub,
+ serviceAccountTokenPath: serviceAccountTokenPath,
+ );
+
+ const String resultsPath = 'results.json';
+ const String updateTaskJson = '{'
+ '"CommitBranch":"master",'
+ '"CommitSha":"$commitSha",'
+ '"BuilderName":"builderAbc",'
+ '"NewStatus":"Succeeded",'
+ '"ResultData":{"i":0.0,"j":0.0,"not_a_metric":"something"},'
+ '"BenchmarkScoreKeys":["i","j"]}';
+ fs.file(resultsPath).writeAsStringSync(updateTaskJson);
+ await cocoon.sendResultsPath(resultsPath);
+ });
+
test('sends expected request from successful task', () async {
mockClient = MockClient((Request request) async => Response('{}', 200));
cocoon = Cocoon(
serviceAccountTokenPath: serviceAccountTokenPath,
- filesystem: fs,
+ fs: fs,
httpClient: mockClient,
);
@@ -87,12 +144,13 @@
cocoon = Cocoon(
serviceAccountTokenPath: serviceAccountTokenPath,
- filesystem: fs,
+ fs: fs,
httpClient: mockClient,
);
final TaskResult result = TaskResult.success(<String, dynamic>{});
- expect(() => cocoon.sendTaskResult(builderName: 'builderAbc', gitBranch: 'branchAbc', result: result), throwsA(isA<ClientException>()));
+ expect(() => cocoon.sendTaskResult(builderName: 'builderAbc', gitBranch: 'branchAbc', result: result),
+ throwsA(isA<ClientException>()));
});
test('null git branch throws error', () async {
@@ -100,12 +158,13 @@
cocoon = Cocoon(
serviceAccountTokenPath: serviceAccountTokenPath,
- filesystem: fs,
+ fs: fs,
httpClient: mockClient,
);
final TaskResult result = TaskResult.success(<String, dynamic>{});
- expect(() => cocoon.sendTaskResult(builderName: 'builderAbc', gitBranch: null, result: result), throwsA(isA<AssertionError>()));
+ expect(() => cocoon.sendTaskResult(builderName: 'builderAbc', gitBranch: null, result: result),
+ throwsA(isA<AssertionError>()));
});
});