[test] Infra for running tests on Fuchsia emulator

The IO tests aren't working yet, but basic tests work:
tools/test.py -n dartk-fuchsia-debug-x64 language_2/list/literal3_test

You may need to run this first:
sudo chmod 666 /dev/kvm

Change-Id: I04915ce11f671f1d493f9eeb6bc832089ba9bfa4
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/154828
Commit-Queue: Liam Appelbe <liama@google.com>
Reviewed-by: Zach Anderson <zra@google.com>
Reviewed-by: Ryan Macnak <rmacnak@google.com>
Reviewed-by: William Hesse <whesse@google.com>
diff --git a/BUILD.gn b/BUILD.gn
index 41563a1..fe0376d 100644
--- a/BUILD.gn
+++ b/BUILD.gn
@@ -157,14 +157,18 @@
   import("third_party/fuchsia/sdk/linux/build/component.gni")
   import("third_party/fuchsia/sdk/linux/build/package.gni")
 
-  fuchsia_component("dart_sdk_fuchsia_test_component") {
+  fuchsia_component("fuchsia_test_component") {
     testonly = true
     data_deps = [ "runtime/bin:dart" ]
     manifest = "build/fuchsia/dart.cmx"
 
-    resource_files = [ ".packages" ]
+    resource_files = [
+      ".packages",
+      "pkg/testing/test/hello_test.dart",
+    ]
     resource_dirs = [
       "tests/standalone",
+      "tests/language_2",
       "pkg/async_helper",
       "pkg/expect",
       "pkg/meta",
@@ -190,8 +194,16 @@
         exec_script("tools/fuchsia/find_resources.py", resource_dirs, "json")
   }
 
-  fuchsia_package("dart_sdk_fuchsia_test_package") {
+  fuchsia_package("fuchsia_test_package") {
+    package_name = "dart_test_"
+    if (is_debug) {
+      package_name += "debug"
+    } else if (is_release) {
+      package_name += "release"
+    } else if (is_product) {
+      package_name += "product"
+    }
     testonly = true
-    deps = [ ":dart_sdk_fuchsia_test_component" ]
+    deps = [ ":fuchsia_test_component" ]
   }
 }
diff --git a/pkg/test_runner/lib/src/configuration.dart b/pkg/test_runner/lib/src/configuration.dart
index dfc966e..c5d61b0 100644
--- a/pkg/test_runner/lib/src/configuration.dart
+++ b/pkg/test_runner/lib/src/configuration.dart
@@ -459,6 +459,7 @@
         mode.name.substring(0, 1).toUpperCase() + mode.name.substring(1);
 
     if (system == System.android) result += "Android";
+    if (system == System.fuchsia) result += "Fuchsia";
 
     if (sanitizer != Sanitizer.none) {
       result += sanitizer.name.toUpperCase();
diff --git a/pkg/test_runner/lib/src/fuchsia.dart b/pkg/test_runner/lib/src/fuchsia.dart
new file mode 100644
index 0000000..874ded5
--- /dev/null
+++ b/pkg/test_runner/lib/src/fuchsia.dart
@@ -0,0 +1,225 @@
+// Copyright (c) 2020, the Dart project authors.  Please see the AUTHORS file
+// for details. 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';
+
+import 'repository.dart';
+import 'utils.dart';
+
+class FuchsiaEmulator {
+  static final Uri toolsDir =
+      Repository.uri.resolve('third_party/fuchsia/sdk/linux/bin/');
+  static final String femuTool = toolsDir.resolve('femu.sh').toFilePath();
+  static final String fserveTool = toolsDir.resolve('fserve.sh').toFilePath();
+  static final String fpubTool = toolsDir.resolve('fpublish.sh').toFilePath();
+  static final String fsshTool = toolsDir.resolve('fssh.sh').toFilePath();
+  static final RegExp emulatorReadyPattern =
+      RegExp(r'Using unique host name (.+)\.local\.');
+  static final RegExp emulatorPidPattern =
+      RegExp(r'([0-9]+) .* qemu-system-x86');
+  static final String serverReadyPattern = '[pm serve] serving';
+
+  static FuchsiaEmulator _inst;
+
+  Process _emu;
+  Process _server;
+  String _deviceName;
+
+  static Future<void> publishPackage(
+      int emuCpus, String buildDir, String mode) async {
+    if (_inst == null) {
+      _inst = FuchsiaEmulator();
+      await _inst._start(emuCpus);
+    }
+    await _inst._publishPackage(buildDir, mode);
+  }
+
+  static void stop() {
+    _inst?._stop();
+  }
+
+  static List<String> getTestArgs(String mode, List<String> arguments) {
+    return _inst._getSshArgs(
+        mode,
+        arguments.map((arg) =>
+            arg.replaceAll(Repository.uri.toFilePath(), '/pkg/data/')));
+  }
+
+  Future<void> _start(int emuCpus) async {
+    // Start the emulator.
+    DebugLogger.info('Starting Fuchsia emulator with $emuCpus CPUs');
+    _emu = await Process.start('xvfb-run', [
+      femuTool,
+      '--image',
+      'qemu-x64',
+      '-N',
+      '--headless',
+      '-s',
+      '$emuCpus'
+    ]);
+
+    // Wait until the emulator is ready and has a valid device name.
+    var deviceNameFuture = Completer<String>();
+    var emuStdout = StringBuffer();
+    var emuStderr = StringBuffer();
+    _emu.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen(
+        (String line) {
+      if (!deviceNameFuture.isCompleted) {
+        emuStdout.write(line);
+        emuStdout.write('\n');
+        var match = emulatorReadyPattern.firstMatch(line);
+        if (match != null) {
+          deviceNameFuture.complete(match.group(1));
+        }
+      }
+    }, onDone: () {
+      if (!deviceNameFuture.isCompleted) {
+        deviceNameFuture.completeError(
+            'Fuchsia emulator terminated unexpectedly.\n\n' +
+                _formatOutputs(emuStdout.toString(), emuStderr.toString()));
+      }
+      _stop();
+    });
+    _emu.stderr
+        .transform(utf8.decoder)
+        .transform(const LineSplitter())
+        .listen((String line) {
+      if (!deviceNameFuture.isCompleted) {
+        emuStderr.write(line);
+        emuStderr.write('\n');
+      }
+    });
+    _deviceName = await deviceNameFuture.future;
+    DebugLogger.info('Fuchsia emulator ready: $_deviceName');
+
+    // Start the server.
+    DebugLogger.info('Starting Fuchsia package server');
+    _server = await Process.start(fserveTool, [
+      '--bucket',
+      'fuchsia-sdk',
+      '--image',
+      'qemu-x64',
+      '--device-name',
+      _deviceName
+    ]);
+
+    // Wait until the server is ready to serve packages.
+    var serverReadyFuture = Completer<String>();
+    var serverStdout = StringBuffer();
+    var serverStderr = StringBuffer();
+    _server.stdout
+        .transform(utf8.decoder)
+        .transform(const LineSplitter())
+        .listen((String line) {
+      if (!serverReadyFuture.isCompleted) {
+        serverStdout.write(line);
+        serverStdout.write('\n');
+        if (line.contains(serverReadyPattern)) {
+          serverReadyFuture.complete();
+        }
+      }
+    }, onDone: () {
+      if (!serverReadyFuture.isCompleted) {
+        serverReadyFuture.completeError(
+            'Fuchsia package server terminated unexpectedly.\n\n' +
+                _formatOutputs(
+                    serverStdout.toString(), serverStderr.toString()));
+      }
+      _stop();
+    });
+    _server.stderr
+        .transform(utf8.decoder)
+        .transform(const LineSplitter())
+        .listen((String line) {
+      if (!serverReadyFuture.isCompleted) {
+        serverStderr.write(line);
+        serverStderr.write('\n');
+      }
+    });
+    await serverReadyFuture.future;
+    DebugLogger.info('Fuchsia package server ready');
+  }
+
+  List<String> _getSshArgs(String mode, Iterable<String> args) {
+    var sshArgs = [
+      '--device-name',
+      _deviceName,
+      'run',
+      'fuchsia-pkg://fuchsia.com/dart_test_$mode#meta/dart.cmx'
+    ];
+    return sshArgs..addAll(args);
+  }
+
+  Future<void> _publishPackage(String buildDir, String mode) async {
+    var packageFile = '$buildDir/gen/dart_test_$mode/dart_test_$mode.far';
+    DebugLogger.info('Publishing package: $packageFile');
+    var result = await Process.run(fpubTool, [packageFile]);
+    if (result.exitCode != 0) {
+      _stop();
+      _throwResult('Publishing package', result);
+    }
+
+    // Verify that the publication was successful by running hello_test.dart.
+    // This also forces the emulator to download the published package from the
+    // server, rather than waiting until the first tests are run. It can take a
+    // minute or two to transfer, and we don't want to eat into the timeout
+    // timer of the first tests.
+    DebugLogger.info('Verifying publication');
+    result = await Process.run(fsshTool,
+        _getSshArgs(mode, ['/pkg/data/pkg/testing/test/hello_test.dart']));
+    if (result.exitCode != 0 || result.stdout != 'Hello, World!\n') {
+      _stop();
+      _throwResult('Verifying publication', result);
+    }
+    DebugLogger.info('Publication successful');
+  }
+
+  void _stop() {
+    if (_emu != null) {
+      DebugLogger.info('Stopping Fuchsia emulator');
+      _emu.kill(ProcessSignal.sigint);
+      _emu = null;
+
+      // Killing femu.sh seems to leave the underlying emulator running. So
+      // manually find the process and terminate it by PID.
+      var result = Process.runSync('ps', []);
+      var emuPid = int.tryParse(
+          emulatorPidPattern.firstMatch(result.stdout as String)?.group(1) ??
+              "");
+      if (result.exitCode != 0 || emuPid == null) {
+        _throwResult('Searching for emulator process', result);
+      }
+      Process.killPid(emuPid);
+      DebugLogger.info('Fuchsia emulator stopped');
+    }
+
+    if (_server != null) {
+      DebugLogger.info('Stopping Fuchsia package server');
+      _server.kill();
+      _server = null;
+
+      // fserve.sh starts a package manager process in the background. We need
+      // to manually kill this process, using fserve.sh again.
+      var result = Process.runSync(fserveTool, ['--kill']);
+      if (result.exitCode != 0) {
+        _throwResult('Killing package manager', result);
+      }
+      DebugLogger.info('Fuchsia package server stopped');
+    }
+  }
+
+  String _formatOutputs(String stdout, String stderr) {
+    var output = "";
+    if (stdout.isNotEmpty) output += "=== STDOUT ===\n$stdout\n";
+    if (stderr.isNotEmpty) output += "=== STDERR ===\n$stderr\n";
+    return output;
+  }
+
+  void _throwResult(String name, ProcessResult result) {
+    throw '$name failed with exit code: ${result.exitCode}\n\n' +
+        _formatOutputs(result.stdout as String, result.stderr as String);
+  }
+}
diff --git a/pkg/test_runner/lib/src/runtime_configuration.dart b/pkg/test_runner/lib/src/runtime_configuration.dart
index 477c73c..ad3f498 100644
--- a/pkg/test_runner/lib/src/runtime_configuration.dart
+++ b/pkg/test_runner/lib/src/runtime_configuration.dart
@@ -7,6 +7,7 @@
 import 'command.dart';
 import 'compiler_configuration.dart';
 import 'configuration.dart';
+import 'fuchsia.dart';
 import 'repository.dart';
 import 'utils.dart';
 
@@ -42,6 +43,8 @@
       case Runtime.vm:
         if (configuration.system == System.android) {
           return DartkAdbRuntimeConfiguration();
+        } else if (configuration.system == System.fuchsia) {
+          return DartkFuchsiaEmulatorRuntimeConfiguration();
         }
         return StandaloneDartRuntimeConfiguration();
 
@@ -379,6 +382,34 @@
   }
 }
 
+class DartkFuchsiaEmulatorRuntimeConfiguration
+    extends DartVmRuntimeConfiguration {
+  List<Command> computeRuntimeCommands(
+      CommandArtifact artifact,
+      List<String> arguments,
+      Map<String, String> environmentOverrides,
+      List<String> extraLibs,
+      bool isCrashExpected) {
+    var script = artifact.filename;
+    var type = artifact.mimeType;
+    if (script != null &&
+        type != 'application/dart' &&
+        type != 'application/dart-snapshot' &&
+        type != 'application/kernel-ir' &&
+        type != 'application/kernel-ir-fully-linked') {
+      throw "Dart VM cannot run files of type '$type'.";
+    }
+    var runtimeArgs =
+        FuchsiaEmulator.getTestArgs(_configuration.mode.name, arguments);
+    if (isCrashExpected) {
+      runtimeArgs.insert(0, '--suppress-core-dump');
+    }
+    return [
+      VMCommand(FuchsiaEmulator.fsshTool, runtimeArgs, environmentOverrides)
+    ];
+  }
+}
+
 class SelfCheckRuntimeConfiguration extends DartVmRuntimeConfiguration {
   final List<String> selfCheckers = <String>[];
 
diff --git a/pkg/test_runner/lib/src/test_configurations.dart b/pkg/test_runner/lib/src/test_configurations.dart
index 9e0d5a1..32201e8 100644
--- a/pkg/test_runner/lib/src/test_configurations.dart
+++ b/pkg/test_runner/lib/src/test_configurations.dart
@@ -10,6 +10,7 @@
 import 'browser_controller.dart';
 import 'co19_test_config.dart';
 import 'configuration.dart';
+import 'fuchsia.dart';
 import 'path.dart';
 import 'process_queue.dart';
 import 'terminal.dart';
@@ -152,6 +153,11 @@
         }
       }
     }
+
+    if (configuration.system == System.fuchsia) {
+      await FuchsiaEmulator.publishPackage(configuration.taskCount,
+          configuration.buildDirectory, configuration.mode.name);
+    }
   }
 
   // If we only need to print out status files for test suites
@@ -170,6 +176,7 @@
     for (var configuration in configurations) {
       configuration.stopServers();
     }
+    FuchsiaEmulator.stop();
 
     DebugLogger.close();
     if (!firstConf.keepGeneratedFiles) {
diff --git a/tools/bots/test_matrix.json b/tools/bots/test_matrix.json
index f6e7271..5ed6cd3 100644
--- a/tools/bots/test_matrix.json
+++ b/tools/bots/test_matrix.json
@@ -701,6 +701,7 @@
       }
     },
     "dartk-(linux|mac|win)-(debug|product|release)-(ia32|x64)": {},
+    "dartk-fuchsia-(debug|product|release)-x64": {},
     "dartk-linux-debug-(ia32|x64)-canary": {
       "options": {
         "builder-tag": "canary"