| // 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:async'; | |
| import 'dart:convert' show Encoding, json; | |
| import 'dart:io'; | |
| import 'package:file/file.dart'; | |
| import 'package:file/local.dart'; | |
| import 'package:http/http.dart'; | |
| import 'package:logging/logging.dart'; | |
| import 'package:meta/meta.dart'; | |
| import 'task_result.dart'; | |
| import 'utils.dart'; | |
| typedef ProcessRunSync = ProcessResult Function( | |
| String, | |
| List<String>, { | |
| Map<String, String>? environment, | |
| bool includeParentEnvironment, | |
| bool runInShell, | |
| Encoding? stderrEncoding, | |
| Encoding? stdoutEncoding, | |
| String? workingDirectory, | |
| }); | |
| /// Class for test runner to interact with Flutter's infrastructure service, Cocoon. | |
| /// | |
| /// Cocoon assigns bots to run these devicelab tasks on real devices. | |
| /// To retrieve these results, the test runner needs to send results back so the database can be updated. | |
| class Cocoon { | |
| Cocoon({ | |
| String? serviceAccountTokenPath, | |
| @visibleForTesting Client? httpClient, | |
| @visibleForTesting this.fs = const LocalFileSystem(), | |
| @visibleForTesting this.processRunSync = Process.runSync, | |
| @visibleForTesting this.requestRetryLimit = 5, | |
| @visibleForTesting this.requestTimeoutLimit = 30, | |
| }) : _httpClient = AuthenticatedCocoonClient(serviceAccountTokenPath, httpClient: httpClient, filesystem: fs); | |
| /// Client to make http requests to Cocoon. | |
| final AuthenticatedCocoonClient _httpClient; | |
| final ProcessRunSync processRunSync; | |
| /// Url used to send results to. | |
| static const String baseCocoonApiUrl = 'https://flutter-dashboard.appspot.com/api'; | |
| /// Threshold to auto retry a failed test. | |
| static const int retryNumber = 2; | |
| /// Underlying [FileSystem] to use. | |
| final FileSystem fs; | |
| static final Logger logger = Logger('CocoonClient'); | |
| @visibleForTesting | |
| final int requestRetryLimit; | |
| @visibleForTesting | |
| final int requestTimeoutLimit; | |
| String get commitSha => _commitSha ?? _readCommitSha(); | |
| String? _commitSha; | |
| /// Parse the local repo for the current running commit. | |
| String _readCommitSha() { | |
| final ProcessResult result = processRunSync('git', <String>['rev-parse', 'HEAD']); | |
| if (result.exitCode != 0) { | |
| throw CocoonException(result.stderr as String); | |
| } | |
| return _commitSha = result.stdout as String; | |
| } | |
| /// Update test status to Cocoon. | |
| /// | |
| /// Flutter infrastructure's workflow is: | |
| /// 1. Run DeviceLab test | |
| /// 2. Request service account token from luci auth (valid for at least 3 minutes) | |
| /// 3. Update test status from (1) to Cocoon | |
| /// | |
| /// The `resultsPath` is not available for all tests. When it doesn't show up, we | |
| /// need to append `CommitBranch`, `CommitSha`, and `BuilderName`. | |
| Future<void> sendTaskStatus({ | |
| String? resultsPath, | |
| bool? isTestFlaky, | |
| String? gitBranch, | |
| String? builderName, | |
| String? testStatus, | |
| String? builderBucket, | |
| }) async { | |
| Map<String, dynamic> resultsJson = <String, dynamic>{}; | |
| if (resultsPath != null) { | |
| final File resultFile = fs.file(resultsPath); | |
| resultsJson = json.decode(await resultFile.readAsString()) as Map<String, dynamic>; | |
| } else { | |
| resultsJson['CommitBranch'] = gitBranch; | |
| resultsJson['CommitSha'] = commitSha; | |
| resultsJson['BuilderName'] = builderName; | |
| resultsJson['NewStatus'] = testStatus; | |
| } | |
| resultsJson['TestFlaky'] = isTestFlaky ?? false; | |
| if (_shouldUpdateCocoon(resultsJson, builderBucket ?? 'prod')) { | |
| await retry( | |
| () async => _sendUpdateTaskRequest(resultsJson).timeout(Duration(seconds: requestTimeoutLimit)), | |
| retryIf: (Exception e) => e is SocketException || e is TimeoutException || e is ClientException, | |
| maxAttempts: requestRetryLimit, | |
| ); | |
| } | |
| } | |
| /// Only post-submit tests on `master` are allowed to update in cocoon. | |
| bool _shouldUpdateCocoon(Map<String, dynamic> resultJson, String builderBucket) { | |
| const List<String> supportedBranches = <String>['master']; | |
| return supportedBranches.contains(resultJson['CommitBranch']) && builderBucket == 'prod'; | |
| } | |
| /// Write the given parameters into an update task request and store the JSON in [resultsPath]. | |
| Future<void> writeTaskResultToFile({ | |
| String? builderName, | |
| String? gitBranch, | |
| required TaskResult result, | |
| required String resultsPath, | |
| }) async { | |
| final Map<String, dynamic> updateRequest = _constructUpdateRequest( | |
| gitBranch: gitBranch, | |
| builderName: builderName, | |
| result: result, | |
| ); | |
| final File resultFile = fs.file(resultsPath); | |
| if (resultFile.existsSync()) { | |
| resultFile.deleteSync(); | |
| } | |
| logger.fine('Writing results: ${json.encode(updateRequest)}'); | |
| resultFile.createSync(); | |
| resultFile.writeAsStringSync(json.encode(updateRequest)); | |
| } | |
| Map<String, dynamic> _constructUpdateRequest({ | |
| String? builderName, | |
| required TaskResult result, | |
| String? gitBranch, | |
| }) { | |
| final Map<String, dynamic> updateRequest = <String, dynamic>{ | |
| 'CommitBranch': gitBranch, | |
| 'CommitSha': commitSha, | |
| 'BuilderName': builderName, | |
| 'NewStatus': result.succeeded ? 'Succeeded' : 'Failed', | |
| }; | |
| logger.fine('Update request: $updateRequest'); | |
| // Make a copy of result data because we may alter it for validation below. | |
| updateRequest['ResultData'] = result.data; | |
| final List<String> validScoreKeys = <String>[]; | |
| if (result.benchmarkScoreKeys != null) { | |
| for (final String scoreKey in result.benchmarkScoreKeys!) { | |
| final Object score = result.data![scoreKey] as Object; | |
| if (score is num) { | |
| // Convert all metrics to double, which provide plenty of precision | |
| // without having to add support for multiple numeric types in Cocoon. | |
| result.data![scoreKey] = score.toDouble(); | |
| validScoreKeys.add(scoreKey); | |
| } | |
| } | |
| } | |
| updateRequest['BenchmarkScoreKeys'] = validScoreKeys; | |
| return updateRequest; | |
| } | |
| Future<void> _sendUpdateTaskRequest(Map<String, dynamic> postBody) async { | |
| logger.info('Attempting to send update task request to Cocoon.'); | |
| 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 { | |
| logger.info(response); | |
| logger.severe('Failed to updated Cocoon with results from this task'); | |
| } | |
| } | |
| /// Make an API request to Cocoon. | |
| Future<Map<String, dynamic>> _sendCocoonRequest(String apiPath, [dynamic jsonData]) async { | |
| final Uri url = Uri.parse('$baseCocoonApiUrl/$apiPath'); | |
| /// Retry requests to Cocoon as sometimes there are issues with the servers, such | |
| /// as version changes to the backend, datastore issues, or latency issues. | |
| final Response response = await retry( | |
| () => _httpClient.post(url, body: json.encode(jsonData)), | |
| retryIf: (Exception e) => e is SocketException || e is TimeoutException || e is ClientException, | |
| maxAttempts: requestRetryLimit, | |
| ); | |
| return json.decode(response.body) as Map<String, dynamic>; | |
| } | |
| } | |
| /// [HttpClient] for sending authenticated requests to Cocoon. | |
| class AuthenticatedCocoonClient extends BaseClient { | |
| AuthenticatedCocoonClient( | |
| this._serviceAccountTokenPath, { | |
| @visibleForTesting Client? httpClient, | |
| @visibleForTesting FileSystem? filesystem, | |
| }) : _delegate = httpClient ?? Client(), | |
| _fs = filesystem ?? const LocalFileSystem(); | |
| /// Authentication token to have the ability to upload and record test results. | |
| /// | |
| /// This is intended to only be passed on automated runs on LUCI post-submit. | |
| final String? _serviceAccountTokenPath; | |
| /// Underlying [HttpClient] to send requests to. | |
| final Client _delegate; | |
| /// Underlying [FileSystem] to use. | |
| final FileSystem _fs; | |
| /// Value contained in the service account token file that can be used in http requests. | |
| String get serviceAccountToken => _serviceAccountToken ?? _readServiceAccountTokenFile(); | |
| String? _serviceAccountToken; | |
| /// Get [serviceAccountToken] from the given service account file. | |
| String _readServiceAccountTokenFile() { | |
| return _serviceAccountToken = _fs.file(_serviceAccountTokenPath).readAsStringSync().trim(); | |
| } | |
| @override | |
| Future<StreamedResponse> send(BaseRequest request) async { | |
| request.headers['Service-Account-Token'] = serviceAccountToken; | |
| final StreamedResponse response = await _delegate.send(request); | |
| if (response.statusCode != 200) { | |
| throw ClientException( | |
| 'AuthenticatedClientError:\n' | |
| ' URI: ${request.url}\n' | |
| ' HTTP Status: ${response.statusCode}\n' | |
| ' Response body:\n' | |
| '${(await Response.fromStream(response)).body}', | |
| request.url); | |
| } | |
| return response; | |
| } | |
| } | |
| class CocoonException implements Exception { | |
| CocoonException(this.message); | |
| /// The message to show to the issuer to explain the error. | |
| final String message; | |
| @override | |
| String toString() => 'CocoonException: $message'; | |
| } |