From 1505fc67b8fbcdd9f79aa712c91c65ebb3418aa7 Mon Sep 17 00:00:00 2001 From: John McCutchan Date: Mon, 4 Mar 2024 15:02:23 -0800 Subject: [PATCH] Add device selection to `et run` - `et run` now detects the target device automatically and builds the related engine builds. - `et run -- -d ` also works. --- tools/engine_tool/README.md | 1 + .../lib/src/commands/run_command.dart | 50 +++++-- tools/engine_tool/lib/src/json_utils.dart | 79 +++++++++++ tools/engine_tool/lib/src/run_utils.dart | 131 ++++++++++++++++++ tools/engine_tool/test/fixtures.dart | 73 ++++++++++ tools/engine_tool/test/run_command_test.dart | 78 ++++++++++- 6 files changed, 395 insertions(+), 17 deletions(-) create mode 100644 tools/engine_tool/lib/src/json_utils.dart create mode 100644 tools/engine_tool/lib/src/run_utils.dart diff --git a/tools/engine_tool/README.md b/tools/engine_tool/README.md index ecf2d13fd8425..fac2464a943b8 100644 --- a/tools/engine_tool/README.md +++ b/tools/engine_tool/README.md @@ -16,6 +16,7 @@ The tool has the following commands. * `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 52c61ed481ea6..0a62819419206 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:engine_build_configs/engine_build_configs.dart'; 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 @@ final class RunCommand extends CommandBase { 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 @@ final class RunCommand extends CommandBase { 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 @@ final class RunCommand extends CommandBase { } else if (argResults!.rest.contains('--release')) { mode = 'release'; } + return mode; + } - return '${targetOS}_${mode}_$cpuArch'; + Future _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 @@ final class RunCommand extends CommandBase { 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 0000000000000..b83c432bd10be --- /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 map, + String field, + String expected, + List 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 field from map. +List? stringListOfJson( + Map map, + String field, + List errors, +) { + if (map[field] == null) { + return []; + } + if (map[field]! is! List) { + _appendTypeError(map, field, 'list', errors); + return null; + } + for (final Object? obj in map[field]! as List) { + if (obj is! String) { + _appendTypeError(map, field, element: obj, 'string', errors); + return null; + } + } + return (map[field]! as List).cast(); +} + +/// Type safe getter of a String field from map. +String? stringOfJson( + Map map, + String field, + List errors, +) { + if (map[field] == null) { + return ''; + } + 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 map, + String field, + List 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 0000000000000..5c2f720879c00 --- /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 map) { + final List errors = []; + 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 parseDevices(Environment env, String flutterDevicesMachine) { + late final List decoded; + try { + decoded = jsonDecode(flutterDevicesMachine) as List; + } on FormatException catch (e) { + env.logger.error( + 'Failed to parse flutter devices output: $e\n\n$flutterDevicesMachine\n\n'); + return []; + } + + final List r = []; + for (final dynamic device in decoded) { + if (device is! Map) { + return []; + } + if (!device.containsKey(_nameKey) || !device.containsKey(_idKey)) { + env.logger.error('device is missing required fields:\n$device\n'); + return []; + } + 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()); + } on FormatException catch (e) { + env.logger.error(e); + return []; + } + r.add(target); + } + + return r; +} + +/// Return the default device to be used. +RunTarget? defaultDevice(Environment env, List targets) { + if (targets.isEmpty) { + return null; + } + return targets.first; +} + +/// Select a run target. +RunTarget? selectRunTarget(Environment env, String flutterDevicesMachine, + [String? idPrefix]) { + final List 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 detectAndSelectRunTarget(Environment env, + [String? idPrefix]) async { + final ProcessRunnerResult result = await env.processRunner + .runProcess(['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 89d7a7da1c30b..8d025cba5186b 100644 --- a/tools/engine_tool/test/fixtures.dart +++ b/tools/engine_tool/test/fixtures.dart @@ -110,3 +110,76 @@ String testConfig(String os) => ''' ] } '''; + +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 26c6db01cd416..93fbfb6fd9d33 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_repo_tools/engine_repo_tools.dart'; 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 @@ void main() { processRunner: ProcessRunner( processManager: FakeProcessManager(onStart: (List command) { runHistory.add(command); - return FakeProcess(); + switch (command) { + case ['flutter', 'devices', '--machine']: + return FakeProcess(stdout: fixtures.attachedDevices()); + default: + return FakeProcess(); + } }, onRun: (List 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 @@ void main() { final int result = await runner.run(['run', '--', '--weird_argument']); expect(result, equals(0)); - expect(runHistory.length, greaterThanOrEqualTo(5)); - expect(runHistory[4], + expect(runHistory.length, greaterThanOrEqualTo(6)); + expect(runHistory[5], containsStringsInOrder(['flutter', 'run', '--weird_argument'])); }); + + test('parse devices list', () async { + final Logger logger = Logger.test(); + final (Environment env, _) = linuxEnv(logger); + final List 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 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> 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(['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([ + 'flutter', + 'run', + '--local-engine', + 'android_debug_arm64', + '--local-engine-host', + 'host_debug', + '-d', + 'emulator' + ])); + }); }