Add device selection to `et run` (#51184)

- `et run` now detects the target device automatically and builds the
related engine builds.
- `et run -- -d <device-id>` also works.
diff --git a/tools/engine_tool/README.md b/tools/engine_tool/README.md
index ecf2d13..fac2464 100644
--- a/tools/engine_tool/README.md
+++ b/tools/engine_tool/README.md
@@ -16,6 +16,7 @@
 * `help` - Prints helpful information about commands and usage.
 * `format` - Formats files in the engine tree using various off-the-shelf
 formatters.
+* `run` - Runs a flutter application with a local build of the engine.
 * `query builds` - Lists the CI builds described under `ci/builders` that the
 host platform is capable of executing.
 
diff --git a/tools/engine_tool/lib/src/commands/run_command.dart b/tools/engine_tool/lib/src/commands/run_command.dart
index 52c61ed..0a62819 100644
--- a/tools/engine_tool/lib/src/commands/run_command.dart
+++ b/tools/engine_tool/lib/src/commands/run_command.dart
@@ -8,6 +8,7 @@
 import 'package:process_runner/process_runner.dart';
 
 import '../build_utils.dart';
+import '../run_utils.dart';
 import 'command.dart';
 import 'flags.dart';
 
@@ -48,7 +49,7 @@
   String get description => 'Run a flutter app with a local engine build'
       'All arguments after -- are forwarded to flutter run, e.g.: '
       'et run -- --profile'
-      'et run -- -d chrome'
+      'et run -- -d macos'
       'See `flutter run --help` for a listing';
 
   Build? _lookup(String configName) {
@@ -76,17 +77,23 @@
     return null;
   }
 
-  String _selectTargetConfig() {
-    final String configName = argResults![configFlag] as String;
-    if (configName.isNotEmpty) {
-      return configName;
+  String _getDeviceId() {
+    if (argResults!.rest.contains('-d')) {
+      final int index = argResults!.rest.indexOf('-d') + 1;
+      if (index < argResults!.rest.length) {
+        return argResults!.rest[index];
+      }
     }
-    // TODO(johnmccutchan): We need a way to invoke flutter tool and be told
-    // which OS and CPU architecture the selected device requires, for now
-    // use some hard coded values:
-    const String targetOS = 'android';
-    const String cpuArch = 'arm64';
+    if (argResults!.rest.contains('--device-id')) {
+      final int index = argResults!.rest.indexOf('--device-id') + 1;
+      if (index < argResults!.rest.length) {
+        return argResults!.rest[index];
+      }
+    }
+    return '';
+  }
 
+  String _getMode() {
     // Sniff the build mode from the args that will be passed to flutter run.
     String mode = 'debug';
     if (argResults!.rest.contains('--profile')) {
@@ -94,8 +101,23 @@
     } else if (argResults!.rest.contains('--release')) {
       mode = 'release';
     }
+    return mode;
+  }
 
-    return '${targetOS}_${mode}_$cpuArch';
+  Future<String?> _selectTargetConfig() async {
+    final String configName = argResults![configFlag] as String;
+    if (configName.isNotEmpty) {
+      return configName;
+    }
+    final String deviceId = _getDeviceId();
+    final RunTarget? target =
+        await detectAndSelectRunTarget(environment, deviceId);
+    if (target == null) {
+      return 'host_debug';
+    }
+    environment.logger.status(
+        'Building to run on "${target.name}" running ${target.targetPlatform}');
+    return target.buildConfigFor(_getMode());
   }
 
   @override
@@ -104,7 +126,11 @@
       environment.logger.error('Cannot find flutter command in your path');
       return 1;
     }
-    final String configName = _selectTargetConfig();
+    final String? configName = await _selectTargetConfig();
+    if (configName == null) {
+      environment.logger.error('Could not find target config');
+      return 1;
+    }
     final Build? build = _lookup(configName);
     final Build? hostBuild = _findHostBuild(build);
     if (build == null) {
diff --git a/tools/engine_tool/lib/src/json_utils.dart b/tools/engine_tool/lib/src/json_utils.dart
new file mode 100644
index 0000000..b83c432
--- /dev/null
+++ b/tools/engine_tool/lib/src/json_utils.dart
@@ -0,0 +1,79 @@
+// Copyright 2013 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.
+
+void _appendTypeError(
+  Map<String, Object?> map,
+  String field,
+  String expected,
+  List<String> errors, {
+  Object? element,
+}) {
+  if (element == null) {
+    final Type actual = map[field]!.runtimeType;
+    errors.add(
+      'For field "$field", expected type: $expected, actual type: $actual.',
+    );
+  } else {
+    final Type actual = element.runtimeType;
+    errors.add(
+      'For element "$element" of "$field", '
+      'expected type: $expected, actual type: $actual',
+    );
+  }
+}
+
+/// Type safe getter of a List<String> field from map.
+List<String>? stringListOfJson(
+  Map<String, Object?> map,
+  String field,
+  List<String> errors,
+) {
+  if (map[field] == null) {
+    return <String>[];
+  }
+  if (map[field]! is! List<Object?>) {
+    _appendTypeError(map, field, 'list', errors);
+    return null;
+  }
+  for (final Object? obj in map[field]! as List<Object?>) {
+    if (obj is! String) {
+      _appendTypeError(map, field, element: obj, 'string', errors);
+      return null;
+    }
+  }
+  return (map[field]! as List<Object?>).cast<String>();
+}
+
+/// Type safe getter of a String field from map.
+String? stringOfJson(
+  Map<String, Object?> map,
+  String field,
+  List<String> errors,
+) {
+  if (map[field] == null) {
+    return '<undef>';
+  }
+  if (map[field]! is! String) {
+    _appendTypeError(map, field, 'string', errors);
+    return null;
+  }
+  return map[field]! as String;
+}
+
+/// Type safe getter of an int field from map.
+int? intOfJson(
+  Map<String, Object?> map,
+  String field,
+  List<String> errors, {
+  int fallback = 0,
+}) {
+  if (map[field] == null) {
+    return fallback;
+  }
+  if (map[field]! is! int) {
+    _appendTypeError(map, field, 'int', errors);
+    return null;
+  }
+  return map[field]! as int;
+}
diff --git a/tools/engine_tool/lib/src/run_utils.dart b/tools/engine_tool/lib/src/run_utils.dart
new file mode 100644
index 0000000..5c2f720
--- /dev/null
+++ b/tools/engine_tool/lib/src/run_utils.dart
@@ -0,0 +1,131 @@
+// Copyright 2013 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:convert';
+
+import 'package:process_runner/process_runner.dart';
+
+import 'environment.dart';
+import 'json_utils.dart';
+
+const String _targetPlatformKey = 'targetPlatform';
+const String _nameKey = 'name';
+const String _idKey = 'id';
+
+/// Target to run a flutter application on.
+class RunTarget {
+  /// Construct a RunTarget from a JSON map.
+  factory RunTarget.fromJson(Map<String, Object> map) {
+    final List<String> errors = <String>[];
+    final String name = stringOfJson(map, _nameKey, errors)!;
+    final String id = stringOfJson(map, _idKey, errors)!;
+    final String targetPlatform =
+        stringOfJson(map, _targetPlatformKey, errors)!;
+
+    if (errors.isNotEmpty) {
+      throw FormatException('Failed to parse RunTarget: ${errors.join('\n')}');
+    }
+    return RunTarget._(name, id, targetPlatform);
+  }
+
+  RunTarget._(this.name, this.id, this.targetPlatform);
+
+  /// Name of target device.
+  final String name;
+
+  /// Id of target device.
+  final String id;
+
+  /// Target platform of device.
+  final String targetPlatform;
+
+  /// BuildConfig name for compilation mode.
+  String buildConfigFor(String mode) {
+    switch (targetPlatform) {
+      case 'android-arm64':
+        return 'android_${mode}_arm64';
+      case 'darwin':
+        return 'host_$mode';
+      case 'web-javascript':
+        return 'chrome_$mode';
+      default:
+        throw UnimplementedError('No mapping for $targetPlatform');
+    }
+  }
+}
+
+/// Parse the raw output of `flutter devices --machine`.
+List<RunTarget> parseDevices(Environment env, String flutterDevicesMachine) {
+  late final List<dynamic> decoded;
+  try {
+    decoded = jsonDecode(flutterDevicesMachine) as List<dynamic>;
+  } on FormatException catch (e) {
+    env.logger.error(
+        'Failed to parse flutter devices output: $e\n\n$flutterDevicesMachine\n\n');
+    return <RunTarget>[];
+  }
+
+  final List<RunTarget> r = <RunTarget>[];
+  for (final dynamic device in decoded) {
+    if (device is! Map<String, Object?>) {
+      return <RunTarget>[];
+    }
+    if (!device.containsKey(_nameKey) || !device.containsKey(_idKey)) {
+      env.logger.error('device is missing required fields:\n$device\n');
+      return <RunTarget>[];
+    }
+    if (!device.containsKey(_targetPlatformKey)) {
+      env.logger.warning('Skipping ${device[_nameKey]}: '
+          'Could not find $_targetPlatformKey in device description.');
+      continue;
+    }
+    late final RunTarget target;
+    try {
+      target = RunTarget.fromJson(device.cast<String, Object>());
+    } on FormatException catch (e) {
+      env.logger.error(e);
+      return <RunTarget>[];
+    }
+    r.add(target);
+  }
+
+  return r;
+}
+
+/// Return the default device to be used.
+RunTarget? defaultDevice(Environment env, List<RunTarget> targets) {
+  if (targets.isEmpty) {
+    return null;
+  }
+  return targets.first;
+}
+
+/// Select a run target.
+RunTarget? selectRunTarget(Environment env, String flutterDevicesMachine,
+    [String? idPrefix]) {
+  final List<RunTarget> targets = parseDevices(env, flutterDevicesMachine);
+  if (idPrefix != null && idPrefix.isNotEmpty) {
+    for (final RunTarget target in targets) {
+      if (target.id.startsWith(idPrefix)) {
+        return target;
+      }
+    }
+  }
+  return defaultDevice(env, targets);
+}
+
+/// Detects available targets and then selects one.
+Future<RunTarget?> detectAndSelectRunTarget(Environment env,
+    [String? idPrefix]) async {
+  final ProcessRunnerResult result = await env.processRunner
+      .runProcess(<String>['flutter', 'devices', '--machine']);
+  if (result.exitCode != 0) {
+    env.logger.error('flutter devices --machine failed:\n'
+        'EXIT_CODE:${result.exitCode}\n'
+        'STDOUT:\n${result.stdout}'
+        'STDERR:\n${result.stderr}');
+    return null;
+  }
+  return selectRunTarget(env, result.stdout, idPrefix);
+}
diff --git a/tools/engine_tool/test/fixtures.dart b/tools/engine_tool/test/fixtures.dart
index 89d7a7d..8d025cb 100644
--- a/tools/engine_tool/test/fixtures.dart
+++ b/tools/engine_tool/test/fixtures.dart
@@ -110,3 +110,76 @@
   ]
 }
 ''';
+
+String attachedDevices() => '''
+[
+  {
+    "name": "sdk gphone64 arm64",
+    "id": "emulator-5554",
+    "isSupported": true,
+    "targetPlatform": "android-arm64",
+    "emulator": true,
+    "sdk": "Android 14 (API 34)",
+    "capabilities": {
+      "hotReload": true,
+      "hotRestart": true,
+      "screenshot": true,
+      "fastStart": true,
+      "flutterExit": true,
+      "hardwareRendering": true,
+      "startPaused": true
+    }
+  },
+  {
+    "name": "macOS",
+    "id": "macos",
+    "isSupported": true,
+    "targetPlatform": "darwin",
+    "emulator": false,
+    "sdk": "macOS 14.3.1 23D60 darwin-arm64",
+    "capabilities": {
+      "hotReload": true,
+      "hotRestart": true,
+      "screenshot": false,
+      "fastStart": false,
+      "flutterExit": true,
+      "hardwareRendering": false,
+      "startPaused": true
+    }
+  },
+  {
+    "name": "Mac Designed for iPad",
+    "id": "mac-designed-for-ipad",
+    "isSupported": true,
+    "targetPlatform": "darwin",
+    "emulator": false,
+    "sdk": "macOS 14.3.1 23D60 darwin-arm64",
+    "capabilities": {
+      "hotReload": true,
+      "hotRestart": true,
+      "screenshot": false,
+      "fastStart": false,
+      "flutterExit": true,
+      "hardwareRendering": false,
+      "startPaused": true
+    }
+  },
+  {
+    "name": "Chrome",
+    "id": "chrome",
+    "isSupported": true,
+    "targetPlatform": "web-javascript",
+    "emulator": false,
+    "sdk": "Google Chrome 122.0.6261.94",
+    "capabilities": {
+      "hotReload": true,
+      "hotRestart": true,
+      "screenshot": false,
+      "fastStart": false,
+      "flutterExit": false,
+      "hardwareRendering": false,
+      "startPaused": true
+    }
+  }
+]
+''';
diff --git a/tools/engine_tool/test/run_command_test.dart b/tools/engine_tool/test/run_command_test.dart
index 26c6db01..93fbfb6 100644
--- a/tools/engine_tool/test/run_command_test.dart
+++ b/tools/engine_tool/test/run_command_test.dart
@@ -11,6 +11,7 @@
 import 'package:engine_tool/src/commands/command_runner.dart';
 import 'package:engine_tool/src/environment.dart';
 import 'package:engine_tool/src/logger.dart';
+import 'package:engine_tool/src/run_utils.dart';
 import 'package:litetest/litetest.dart';
 import 'package:platform/platform.dart';
 import 'package:process_fakes/process_fakes.dart';
@@ -63,10 +64,16 @@
         processRunner: ProcessRunner(
           processManager: FakeProcessManager(onStart: (List<String> command) {
             runHistory.add(command);
-            return FakeProcess();
+            switch (command) {
+              case ['flutter', 'devices', '--machine']:
+                return FakeProcess(stdout: fixtures.attachedDevices());
+              default:
+                return FakeProcess();
+            }
           }, onRun: (List<String> command) {
-            runHistory.add(command);
-            return io.ProcessResult(81, 0, '', '');
+            // Should not be executed.
+            assert(false);
+            return io.ProcessResult(81, 1, '', '');
           }),
         ),
         logger: logger,
@@ -85,8 +92,69 @@
     final int result =
         await runner.run(<String>['run', '--', '--weird_argument']);
     expect(result, equals(0));
-    expect(runHistory.length, greaterThanOrEqualTo(5));
-    expect(runHistory[4],
+    expect(runHistory.length, greaterThanOrEqualTo(6));
+    expect(runHistory[5],
         containsStringsInOrder(<String>['flutter', 'run', '--weird_argument']));
   });
+
+  test('parse devices list', () async {
+    final Logger logger = Logger.test();
+    final (Environment env, _) = linuxEnv(logger);
+    final List<RunTarget> targets =
+        parseDevices(env, fixtures.attachedDevices());
+    expect(targets.length, equals(4));
+    final RunTarget android = targets[0];
+    expect(android.name, contains('gphone64'));
+    expect(android.buildConfigFor('debug'), equals('android_debug_arm64'));
+  });
+
+  test('default device', () async {
+    final Logger logger = Logger.test();
+    final (Environment env, _) = linuxEnv(logger);
+    final List<RunTarget> targets =
+        parseDevices(env, fixtures.attachedDevices());
+    expect(targets.length, equals(4));
+    final RunTarget? defaultTarget = defaultDevice(env, targets);
+    expect(defaultTarget, isNotNull);
+    expect(defaultTarget!.name, contains('gphone64'));
+    expect(
+        defaultTarget.buildConfigFor('debug'), equals('android_debug_arm64'));
+  });
+
+  test('device select', () async {
+    final Logger logger = Logger.test();
+    final (Environment env, _) = linuxEnv(logger);
+    RunTarget target = selectRunTarget(env, fixtures.attachedDevices())!;
+    expect(target.name, contains('gphone64'));
+    target = selectRunTarget(env, fixtures.attachedDevices(), 'mac')!;
+    expect(target.name, contains('macOS'));
+  });
+
+  test('flutter run device select', () async {
+    final Logger logger = Logger.test();
+    final (Environment env, List<List<String>> runHistory) = linuxEnv(logger);
+    final ToolCommandRunner runner = ToolCommandRunner(
+      environment: env,
+      configs: configs,
+    );
+    // Request that the emulator device is used. The emulator is an Android
+    // ARM64 device.
+    final int result =
+        await runner.run(<String>['run', '--', '-d', 'emulator']);
+    expect(result, equals(0));
+    expect(runHistory.length, greaterThanOrEqualTo(6));
+    // Observe that we selected android_debug_arm64 as the target.
+    expect(
+        runHistory[5],
+        containsStringsInOrder(<String>[
+          'flutter',
+          'run',
+          '--local-engine',
+          'android_debug_arm64',
+          '--local-engine-host',
+          'host_debug',
+          '-d',
+          'emulator'
+        ]));
+  });
 }