Test reporter (#28297)

* Wrap test.main with a custom processor
* Report test results to bigquery table
diff --git a/dev/bots/test.dart b/dev/bots/test.dart
index 08fe050..281a71b 100644
--- a/dev/bots/test.dart
+++ b/dev/bots/test.dart
@@ -5,8 +5,12 @@
 import 'dart:async';
 import 'dart:io';
 
+import 'package:googleapis/bigquery/v2.dart' as bq;
+import 'package:googleapis_auth/auth_io.dart' as auth;
+import 'package:http/http.dart' as http;
 import 'package:path/path.dart' as path;
 
+import 'flutter_compact_formatter.dart';
 import 'run_command.dart';
 
 typedef ShardRunner = Future<void> Function();
@@ -140,13 +144,31 @@
   await _verifyVersion(path.join(flutterRoot, 'version'));
 }
 
+Future<bq.BigqueryApi> _getBigqueryApi() async {
+  // TODO(dnfield): How will we do this on LUCI?
+  final String privateKey = Platform.environment['GCLOUD_SERVICE_ACCOUNT_KEY'];
+  if (privateKey == null || privateKey.isEmpty) {
+    return null;
+  }
+  final auth.ServiceAccountCredentials accountCredentials = auth.ServiceAccountCredentials( //.fromJson(credentials);
+    'flutter-ci-test-reporter@flutter-infra.iam.gserviceaccount.com',
+    auth.ClientId.serviceAccount('114390419920880060881.apps.googleusercontent.com'),
+    '-----BEGIN PRIVATE KEY-----\n$privateKey\n-----END PRIVATE KEY-----\n',
+  );
+  final List<String> scopes = <String>[bq.BigqueryApi.BigqueryInsertdataScope];
+  final http.Client client = await auth.clientViaServiceAccount(accountCredentials, scopes);
+  return bq.BigqueryApi(client);
+}
+
 Future<void> _runToolTests() async {
+  final bq.BigqueryApi bigqueryApi = await _getBigqueryApi();
   await _runSmokeTests();
 
   await _buildRunnerTest(
     path.join(flutterRoot, 'packages', 'flutter_tools'),
     flutterRoot,
     enableFlutterToolAsserts: true,
+    tableData: bigqueryApi?.tabledata,
   );
 
   print('${bold}DONE: All tests successful.$reset');
@@ -198,7 +220,6 @@
 }
 
 Future<void> _flutterBuildApk(String relativePathToApplication) async {
-  // TODO(dnfield): See if we can get Android SDK on all Cirrus platforms.
   if (
         (Platform.environment['ANDROID_HOME']?.isEmpty ?? true) &&
         (Platform.environment['ANDROID_SDK_ROOT']?.isEmpty ?? true)) {
@@ -255,32 +276,33 @@
 }
 
 Future<void> _runTests() async {
+  final bq.BigqueryApi bigqueryApi = await _getBigqueryApi();
   await _runSmokeTests();
 
-  await _runFlutterTest(path.join(flutterRoot, 'packages', 'flutter'));
+  await _runFlutterTest(path.join(flutterRoot, 'packages', 'flutter'), tableData: bigqueryApi?.tabledata);
   // Only packages/flutter/test/widgets/widget_inspector_test.dart really
   // needs to be run with --track-widget-creation but it is nice to run
   // all of the tests in package:flutter with the flag to ensure that
   // the Dart kernel transformer triggered by the flag does not break anything.
-  await _runFlutterTest(path.join(flutterRoot, 'packages', 'flutter'), options: <String>['--track-widget-creation']);
-  await _runFlutterTest(path.join(flutterRoot, 'packages', 'flutter_localizations'));
-  await _runFlutterTest(path.join(flutterRoot, 'packages', 'flutter_driver'));
-  await _runFlutterTest(path.join(flutterRoot, 'packages', 'flutter_test'));
-  await _runFlutterTest(path.join(flutterRoot, 'packages', 'fuchsia_remote_debug_protocol'));
-  await _pubRunTest(path.join(flutterRoot, 'dev', 'bots'));
-  await _pubRunTest(path.join(flutterRoot, 'dev', 'devicelab'));
-  await _pubRunTest(path.join(flutterRoot, 'dev', 'snippets'));
-  await _runFlutterTest(path.join(flutterRoot, 'dev', 'integration_tests', 'android_semantics_testing'));
-  await _runFlutterTest(path.join(flutterRoot, 'dev', 'manual_tests'));
-  await _runFlutterTest(path.join(flutterRoot, 'dev', 'tools', 'vitool'));
-  await _runFlutterTest(path.join(flutterRoot, 'examples', 'hello_world'));
-  await _runFlutterTest(path.join(flutterRoot, 'examples', 'layers'));
-  await _runFlutterTest(path.join(flutterRoot, 'examples', 'stocks'));
-  await _runFlutterTest(path.join(flutterRoot, 'examples', 'flutter_gallery'));
+  await _runFlutterTest(path.join(flutterRoot, 'packages', 'flutter'), options: <String>['--track-widget-creation'], tableData: bigqueryApi?.tabledata);
+  await _runFlutterTest(path.join(flutterRoot, 'packages', 'flutter_localizations'), tableData: bigqueryApi?.tabledata);
+  await _runFlutterTest(path.join(flutterRoot, 'packages', 'flutter_driver'), tableData: bigqueryApi?.tabledata);
+  await _runFlutterTest(path.join(flutterRoot, 'packages', 'flutter_test'), tableData: bigqueryApi?.tabledata);
+  await _runFlutterTest(path.join(flutterRoot, 'packages', 'fuchsia_remote_debug_protocol'), tableData: bigqueryApi?.tabledata);
+  await _pubRunTest(path.join(flutterRoot, 'dev', 'bots'), tableData: bigqueryApi?.tabledata);
+  await _pubRunTest(path.join(flutterRoot, 'dev', 'devicelab'), tableData: bigqueryApi?.tabledata);
+  await _pubRunTest(path.join(flutterRoot, 'dev', 'snippets'), tableData: bigqueryApi?.tabledata);
+  await _runFlutterTest(path.join(flutterRoot, 'dev', 'integration_tests', 'android_semantics_testing'), tableData: bigqueryApi?.tabledata);
+  await _runFlutterTest(path.join(flutterRoot, 'dev', 'manual_tests'), tableData: bigqueryApi?.tabledata);
+  await _runFlutterTest(path.join(flutterRoot, 'dev', 'tools', 'vitool'), tableData: bigqueryApi?.tabledata);
+  await _runFlutterTest(path.join(flutterRoot, 'examples', 'hello_world'), tableData: bigqueryApi?.tabledata);
+  await _runFlutterTest(path.join(flutterRoot, 'examples', 'layers'), tableData: bigqueryApi?.tabledata);
+  await _runFlutterTest(path.join(flutterRoot, 'examples', 'stocks'), tableData: bigqueryApi?.tabledata);
+  await _runFlutterTest(path.join(flutterRoot, 'examples', 'flutter_gallery'), tableData: bigqueryApi?.tabledata);
   // Regression test to ensure that code outside of package:flutter can run
   // with --track-widget-creation.
-  await _runFlutterTest(path.join(flutterRoot, 'examples', 'flutter_gallery'), options: <String>['--track-widget-creation']);
-  await _runFlutterTest(path.join(flutterRoot, 'examples', 'catalog'));
+  await _runFlutterTest(path.join(flutterRoot, 'examples', 'flutter_gallery'), options: <String>['--track-widget-creation'], tableData: bigqueryApi?.tabledata);
+  await _runFlutterTest(path.join(flutterRoot, 'examples', 'catalog'), tableData: bigqueryApi?.tabledata);
 
   print('${bold}DONE: All tests successful.$reset');
 }
@@ -310,10 +332,10 @@
 Future<void> _buildRunnerTest(
   String workingDirectory,
   String flutterRoot, {
-   String testPath,
-   bool enableFlutterToolAsserts = false,
-  }
-) {
+  String testPath,
+  bool enableFlutterToolAsserts = false,
+  bq.TabledataResourceApi tableData,
+}) async {
   final List<String> args = <String>['run', 'build_runner', 'test', '--', '-rcompact', '-j1'];
   if (!hasColor) {
     args.add('--no-color');
@@ -335,19 +357,21 @@
         toolsArgs += ' --enable-asserts';
     pubEnvironment['FLUTTER_TOOL_ARGS'] = toolsArgs.trim();
   }
-  return runCommand(
-    pub, args,
+
+  final Stream<String> testOutput = runAndGetStdout(pub, args,
     workingDirectory: workingDirectory,
     environment: pubEnvironment,
   );
+  await _processTestOutput(testOutput, tableData);
 }
 
 Future<void> _pubRunTest(
   String workingDirectory, {
   String testPath,
   bool enableFlutterToolAsserts = false,
-}) {
-  final List<String> args = <String>['run', 'test', '-rcompact', '-j1'];
+  bq.TabledataResourceApi tableData,
+}) async {
+  final List<String> args = <String>['run', 'test', '-rjson', '-j1'];
   if (!hasColor)
     args.add('--no-color');
   if (testPath != null)
@@ -364,11 +388,127 @@
         toolsArgs += ' --enable-asserts';
     pubEnvironment['FLUTTER_TOOL_ARGS'] = toolsArgs.trim();
   }
-  return runCommand(
-    pub, args,
+  final Stream<String> testOutput = runAndGetStdout(pub, args,
     workingDirectory: workingDirectory,
-    environment: pubEnvironment,
   );
+  await _processTestOutput(testOutput, tableData);
+}
+
+enum CiProviders {
+  cirrus,
+  luci,
+}
+
+CiProviders _getCiProvider() {
+  if (Platform.environment['CIRRUS_CI'] == 'true') {
+    return CiProviders.cirrus;
+  }
+  if (Platform.environment['LUCI_CONTEXT'] != null) {
+    return CiProviders.luci;
+  }
+  return null;
+}
+
+String _getCiProviderName() {
+  switch(_getCiProvider()) {
+    case CiProviders.cirrus:
+      return 'cirrusci';
+    case CiProviders.luci:
+      return 'luci';
+  }
+  return 'unknown';
+}
+
+int _getPrNumber() {
+  switch(_getCiProvider()) {
+    case CiProviders.cirrus:
+      return int.tryParse(Platform.environment['CIRRUS_PR']);
+    case CiProviders.luci:
+      return -1; // LUCI doesn't know about this.
+  }
+  return -1;
+}
+
+Future<String> _getAuthors() async {
+  final String exe = Platform.isWindows ? '.exe' : '';
+  final String author = await runAndGetStdout(
+    'git$exe', <String>['log', _getGitHash(), '--pretty="%an <%ae>"'],
+    workingDirectory: flutterRoot,
+  ).first;
+  return author;
+}
+
+String _getCiUrl() {
+  switch(_getCiProvider()) {
+    case CiProviders.cirrus:
+      return 'https://cirrus-ci.com/task/${Platform.environment['CIRRUS_TASK_ID']}';
+    case CiProviders.luci:
+      return 'https://ci.chromium.org/p/flutter/g/framework/console'; // TODO(dnfield): can we get a direct link to the actual build?
+  }
+  return '';
+}
+
+String _getGitHash() {
+  switch(_getCiProvider()) {
+    case CiProviders.cirrus:
+      return Platform.environment['CIRRUS_CHANGE_IN_REPO'];
+    case CiProviders.luci:
+      return 'HEAD'; // TODO(dnfield): Set this in the env for LUCI.
+  }
+  return '';
+}
+
+Future<void> _processTestOutput(Stream<String> testOutput, bq.TabledataResourceApi tableData) async {
+  final FlutterCompactFormatter formatter = FlutterCompactFormatter();
+  await testOutput.forEach(formatter.processRawOutput);
+  if (tableData == null || formatter.tests.isEmpty) {
+    return;
+  }
+  final bq.TableDataInsertAllRequest request = bq.TableDataInsertAllRequest();
+  final String authors = await _getAuthors();
+  request.rows = List<bq.TableDataInsertAllRequestRows>.from(
+    formatter.tests.map<bq.TableDataInsertAllRequestRows>((TestResult result) =>
+      bq.TableDataInsertAllRequestRows.fromJson(<String, dynamic> {
+        'json': <String, dynamic>{
+          'source': <String, dynamic>{
+            'provider': _getCiProviderName(),
+            'url': _getCiUrl(),
+            'platform': <String, dynamic>{
+              'os': Platform.operatingSystem,
+              'version': Platform.operatingSystemVersion,
+            },
+          },
+          'test': <String, dynamic>{
+            'name': result.name,
+            'result': result.status.toString(),
+            'file': result.path,
+            'line': result.line,
+            'column': result.column,
+            'time': result.totalTime,
+          },
+          'git': <String, dynamic>{
+            'author': authors,
+            'pull_request': _getPrNumber(),
+            'commit': _getGitHash(),
+            'organization': 'flutter',
+            'repository': 'flutter',
+          },
+          'error': result.status != TestStatus.failed ? null : <String, dynamic>{
+            'message': result.errorMessage,
+            'stack_trace': result.stackTrace,
+          },
+          'information': result.messages,
+        },
+      }),
+    ),
+    growable: false,
+  );
+  final bq.TableDataInsertAllResponse response = await tableData.insertAll(request, 'flutter-infra', 'tests', 'ci');
+  if (response.insertErrors != null && response.insertErrors.isNotEmpty) {
+    print('${red}BigQuery insert errors:');
+    print(response.toJson());
+    print(reset);
+  }
 }
 
 class EvalResult {
@@ -390,10 +530,16 @@
   List<String> options = const <String>[],
   bool skip = false,
   Duration timeout = _kLongTimeout,
-}) {
+  bq.TabledataResourceApi tableData,
+}) async {
   final List<String> args = <String>['test']..addAll(options);
   if (flutterTestArgs != null && flutterTestArgs.isNotEmpty)
     args.addAll(flutterTestArgs);
+
+  if (!expectFailure) {
+    args.add('--machine');
+  }
+
   if (script != null) {
     final String fullScriptPath = path.join(workingDirectory, script);
     if (!FileSystemEntity.isFileSync(fullScriptPath)) {
@@ -408,13 +554,21 @@
     }
     args.add(script);
   }
-  return runCommand(flutter, args,
+  if (expectFailure) {
+    return runCommand(flutter, args,
+      workingDirectory: workingDirectory,
+      expectNonZeroExit: true,
+      printOutput: printOutput,
+      skip: skip,
+      timeout: timeout,
+    );
+  }
+  final Stream<String> testOutput = runAndGetStdout(flutter, args,
     workingDirectory: workingDirectory,
     expectNonZeroExit: expectFailure,
-    printOutput: printOutput,
-    skip: skip,
     timeout: timeout,
   );
+  await _processTestOutput(testOutput, tableData);
 }
 
 Future<void> _verifyVersion(String filename) async {