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'
+ ]));
+ });
}