Implements --machine flag for `devices` command (#50581)
diff --git a/packages/flutter_tools/lib/src/commands/devices.dart b/packages/flutter_tools/lib/src/commands/devices.dart index 47d2870..0bf3360 100644 --- a/packages/flutter_tools/lib/src/commands/devices.dart +++ b/packages/flutter_tools/lib/src/commands/devices.dart
@@ -6,13 +6,21 @@ import '../base/common.dart'; import '../base/utils.dart'; +import '../convert.dart'; import '../device.dart'; import '../doctor.dart'; import '../globals.dart' as globals; import '../runner/flutter_command.dart'; class DevicesCommand extends FlutterCommand { + DevicesCommand() { + + argParser.addFlag('machine', + negatable: false, + help: 'Output device information in machine readable structured JSON format', + ); + argParser.addOption( 'timeout', abbr: 't', @@ -73,6 +81,8 @@ globals.printStatus('• $diagnostic', hangingIndent: 2); } } + } else if (boolArg('machine')) { + await printDevicesAsJson(devices); } else { globals.printStatus('${devices.length} connected ${pluralize('device', devices.length)}:\n'); await Device.printDevices(devices); @@ -80,4 +90,13 @@ return FlutterCommandResult.success(); } + + Future<void> printDevicesAsJson(List<Device> devices) async { + globals.printStatus( + const JsonEncoder.withIndent(' ').convert( + await Future.wait(devices.map((Device d) => d.toJson())) + ) + ); + } + }
diff --git a/packages/flutter_tools/lib/src/device.dart b/packages/flutter_tools/lib/src/device.dart index b5a54fb..7240b6f 100644 --- a/packages/flutter_tools/lib/src/device.dart +++ b/packages/flutter_tools/lib/src/device.dart
@@ -538,6 +538,28 @@ await descriptions(devices).forEach(globals.printStatus); } + /// Convert the Device object to a JSON representation suitable for serialization. + Future<Map<String, Object>> toJson() async { + final bool isLocalEmu = await isLocalEmulator; + return <String, Object>{ + 'name': name, + 'id': id, + 'isSupported': isSupported(), + 'targetPlatform': getNameForTargetPlatform(await targetPlatform), + 'emulator': isLocalEmu, + 'sdk': await sdkNameAndVersion, + 'capabilities': <String, Object>{ + 'hotReload': supportsHotReload, + 'hotRestart': supportsHotRestart, + 'screenshot': supportsScreenshot, + 'fastStart': supportsFastStart, + 'flutterExit': supportsFlutterExit, + 'hardwareRendering': isLocalEmu && await supportsHardwareRendering, + 'startPaused': supportsStartPaused, + } + }; + } + /// Clean up resources allocated by device /// /// For example log readers or port forwarders.
diff --git a/packages/flutter_tools/test/commands.shard/hermetic/devices_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/devices_test.dart index 7846cea..9bbbf21 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/devices_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/devices_test.dart
@@ -15,6 +15,7 @@ import '../../src/common.dart'; import '../../src/context.dart'; +import '../../src/fake_devices.dart'; void main() { group('devices', () { @@ -36,6 +37,53 @@ DeviceManager: () => DeviceManager(), ProcessManager: () => MockProcessManager(), }); + + testUsingContext('Outputs parsable JSON with --machine flag', () async { + final DevicesCommand command = DevicesCommand(); + await createTestCommandRunner(command).run(<String>['devices', '--machine']); + expect( + json.decode(testLogger.statusText), + <Map<String,Object>>[ + <String, Object>{ + 'name': 'ephemeral', + 'id': 'ephemeral', + 'isSupported': true, + 'targetPlatform': 'android-arm', + 'emulator': true, + 'sdk': 'Test SDK (1.2.3)', + 'capabilities': <String, Object>{ + 'hotReload': true, + 'hotRestart': true, + 'screenshot': false, + 'fastStart': false, + 'flutterExit': true, + 'hardwareRendering': true, + 'startPaused': true + } + }, + <String,Object>{ + 'name': 'webby', + 'id': 'webby', + 'isSupported': true, + 'targetPlatform': 'web-javascript', + 'emulator': true, + 'sdk': 'Web SDK (1.2.4)', + 'capabilities': <String, Object>{ + 'hotReload': true, + 'hotRestart': true, + 'screenshot': false, + 'fastStart': false, + 'flutterExit': true, + 'hardwareRendering': false, + 'startPaused': true + } + } + ] + ); + }, overrides: <Type, Generator>{ + DeviceManager: () => _FakeDeviceManager(), + ProcessManager: () => MockProcessManager(), + }); }); } @@ -66,3 +114,16 @@ return ProcessResult(0, 0, '', ''); } } + +class _FakeDeviceManager extends DeviceManager { + _FakeDeviceManager(); + + @override + Future<List<Device>> getAllConnectedDevices() => + Future<List<Device>>.value(fakeDevices.map((FakeDeviceJsonData d) => d.dev).toList()); + + @override + Future<List<Device>> refreshAllConnectedDevices({Duration timeout}) => + getAllConnectedDevices(); + +}
diff --git a/packages/flutter_tools/test/commands.shard/permeable/devices_test.dart b/packages/flutter_tools/test/commands.shard/permeable/devices_test.dart new file mode 100644 index 0000000..f8191eb --- /dev/null +++ b/packages/flutter_tools/test/commands.shard/permeable/devices_test.dart
@@ -0,0 +1,93 @@ +// Copyright 2014 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:file/file.dart'; +import 'package:args/command_runner.dart'; +import 'package:flutter_tools/src/device.dart'; +import 'package:flutter_tools/src/web/chrome.dart'; +import 'package:flutter_tools/src/commands/devices.dart'; +import 'package:flutter_tools/src/base/logger.dart'; +import 'package:flutter_tools/src/base/config.dart'; +import 'package:flutter_tools/src/globals.dart' as globals; +import 'package:flutter_tools/src/base/context.dart'; + +import '../../src/common.dart'; +import '../../src/context.dart'; + +void main() { + group('devices', () { + Directory configDir; + Config config; + + tearDown(() { + if (configDir != null) { + tryToDelete(configDir); + configDir = null; + } + }); + + setUpAll(() { + configDir ??= globals.fs.systemTempDirectory.createTempSync( + 'flutter_config_dir_test.', + ); + config = Config.test( + Config.kFlutterSettings, + directory: configDir, + logger: globals.logger, + )..setValue('enable-web', true); + }); + + // Test assumes no devices connected. + // Should return only `web-server` device + testUsingContext('Test the --machine flag', () async { + final BufferLogger logger = context.get<Logger>() as BufferLogger; + final DevicesCommand command = DevicesCommand(); + final CommandRunner<void> runner = createTestCommandRunner(command); + await runner.run(<String>['devices', '--machine']); + expect( + json.decode(logger.statusText), + <Map<String,Object>>[ + <String, Object>{ + 'name': 'Web Server', + 'id': 'web-server', + 'isSupported': true, + 'targetPlatform': 'web-javascript', + 'emulator': false, + 'sdk': 'Flutter Tools', + 'capabilities': <String, Object>{ + 'hotReload': true, + 'hotRestart': true, + 'screenshot': false, + 'fastStart': false, + 'flutterExit': true, + 'hardwareRendering': false, + 'startPaused': true + } + } + ] + ); + }, + overrides: <Type, Generator>{ + DeviceManager: () => DeviceManager(), + Config: () => config, + ChromeLauncher: () => _DisabledChromeLauncher(), + }); + }); +} + +// Without ChromeLauncher DeviceManager constructor fails with noSuchMethodError +// trying to call canFindChrome on null +// Also, Chrome may have different versions on different machines and +// JSON will not match, because the `sdk` field of the Device contains version number +// Mock the launcher to make it appear that we don't have Chrome. +class _DisabledChromeLauncher implements ChromeLauncher { + @override + bool canFindChrome() => false; + + @override + Future<Chrome> launch(String url, {bool headless = false, int debugPort, bool skipCheck = false, Directory cacheDir}) + => Future<Chrome>.error('Chrome disabled'); +} \ No newline at end of file
diff --git a/packages/flutter_tools/test/general.shard/device_test.dart b/packages/flutter_tools/test/general.shard/device_test.dart index fbf8a06..42a0131 100644 --- a/packages/flutter_tools/test/general.shard/device_test.dart +++ b/packages/flutter_tools/test/general.shard/device_test.dart
@@ -12,6 +12,7 @@ import '../src/common.dart'; import '../src/context.dart'; +import '../src/fake_devices.dart'; import '../src/mocks.dart'; void main() { @@ -24,9 +25,9 @@ }); testUsingContext('getDeviceById', () async { - final _MockDevice device1 = _MockDevice('Nexus 5', '0553790d0a4e726f'); - final _MockDevice device2 = _MockDevice('Nexus 5X', '01abfc49119c410e'); - final _MockDevice device3 = _MockDevice('iPod touch', '82564b38861a9a5'); + final FakeDevice device1 = FakeDevice('Nexus 5', '0553790d0a4e726f'); + final FakeDevice device2 = FakeDevice('Nexus 5X', '01abfc49119c410e'); + final FakeDevice device3 = FakeDevice('iPod touch', '82564b38861a9a5'); final List<Device> devices = <Device>[device1, device2, device3]; final DeviceManager deviceManager = TestDeviceManager(devices); @@ -42,42 +43,42 @@ }); testUsingContext('getAllConnectedDevices caches', () async { - final _MockDevice device1 = _MockDevice('Nexus 5', '0553790d0a4e726f'); + final FakeDevice device1 = FakeDevice('Nexus 5', '0553790d0a4e726f'); final TestDeviceManager deviceManager = TestDeviceManager(<Device>[device1]); expect(await deviceManager.getAllConnectedDevices(), <Device>[device1]); - final _MockDevice device2 = _MockDevice('Nexus 5X', '01abfc49119c410e'); + final FakeDevice device2 = FakeDevice('Nexus 5X', '01abfc49119c410e'); deviceManager.resetDevices(<Device>[device2]); expect(await deviceManager.getAllConnectedDevices(), <Device>[device1]); }); testUsingContext('refreshAllConnectedDevices does not cache', () async { - final _MockDevice device1 = _MockDevice('Nexus 5', '0553790d0a4e726f'); + final FakeDevice device1 = FakeDevice('Nexus 5', '0553790d0a4e726f'); final TestDeviceManager deviceManager = TestDeviceManager(<Device>[device1]); expect(await deviceManager.refreshAllConnectedDevices(), <Device>[device1]); - final _MockDevice device2 = _MockDevice('Nexus 5X', '01abfc49119c410e'); + final FakeDevice device2 = FakeDevice('Nexus 5X', '01abfc49119c410e'); deviceManager.resetDevices(<Device>[device2]); expect(await deviceManager.refreshAllConnectedDevices(), <Device>[device2]); }); }); group('Filter devices', () { - _MockDevice ephemeral; - _MockDevice nonEphemeralOne; - _MockDevice nonEphemeralTwo; - _MockDevice unsupported; - _MockDevice webDevice; - _MockDevice fuchsiaDevice; + FakeDevice ephemeral; + FakeDevice nonEphemeralOne; + FakeDevice nonEphemeralTwo; + FakeDevice unsupported; + FakeDevice webDevice; + FakeDevice fuchsiaDevice; setUp(() { - ephemeral = _MockDevice('ephemeral', 'ephemeral', true); - nonEphemeralOne = _MockDevice('nonEphemeralOne', 'nonEphemeralOne', false); - nonEphemeralTwo = _MockDevice('nonEphemeralTwo', 'nonEphemeralTwo', false); - unsupported = _MockDevice('unsupported', 'unsupported', true, false); - webDevice = _MockDevice('webby', 'webby') + ephemeral = FakeDevice('ephemeral', 'ephemeral', true); + nonEphemeralOne = FakeDevice('nonEphemeralOne', 'nonEphemeralOne', false); + nonEphemeralTwo = FakeDevice('nonEphemeralTwo', 'nonEphemeralTwo', false); + unsupported = FakeDevice('unsupported', 'unsupported', true, false); + webDevice = FakeDevice('webby', 'webby') ..targetPlatform = Future<TargetPlatform>.value(TargetPlatform.web_javascript); - fuchsiaDevice = _MockDevice('fuchsiay', 'fuchsiay') + fuchsiaDevice = FakeDevice('fuchsiay', 'fuchsiay') ..targetPlatform = Future<TargetPlatform>.value(TargetPlatform.fuchsia_x64); }); @@ -182,6 +183,17 @@ }); }); }); + + group('JSON encode devices', () { + testUsingContext('Consistency of JSON representation', () async { + expect( + // This tests that fakeDevices is a list of tuples where "second" is the + // correct JSON representation of the "first". Actual values are irrelevant + await Future.wait(fakeDevices.map((FakeDeviceJsonData d) => d.dev.toJson())), + fakeDevices.map((FakeDeviceJsonData d) => d.json) + ); + }); + }); } class TestDeviceManager extends DeviceManager { @@ -208,27 +220,4 @@ } } -class _MockDevice extends Device { - _MockDevice(this.name, String id, [bool ephemeral = true, this._isSupported = true]) : super( - id, - platformType: PlatformType.web, - category: Category.mobile, - ephemeral: ephemeral, - ); - - final bool _isSupported; - - @override - final String name; - - @override - Future<TargetPlatform> targetPlatform = Future<TargetPlatform>.value(TargetPlatform.android_arm); - - @override - void noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); - - @override - bool isSupportedForProject(FlutterProject flutterProject) => _isSupported; -} - class MockProcess extends Mock implements Process {}
diff --git a/packages/flutter_tools/test/src/fake_devices.dart b/packages/flutter_tools/test/src/fake_devices.dart new file mode 100644 index 0000000..5dd2beb --- /dev/null +++ b/packages/flutter_tools/test/src/fake_devices.dart
@@ -0,0 +1,96 @@ +// Copyright 2014 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:async'; +import 'package:flutter_tools/src/build_info.dart'; +import 'package:flutter_tools/src/device.dart'; +import 'package:flutter_tools/src/project.dart'; + +/// A list of fake devices to test JSON serialization +/// (`Device.toJson()` and `--machine` flag for `devices` command) +List<FakeDeviceJsonData> fakeDevices = <FakeDeviceJsonData>[ + FakeDeviceJsonData( + FakeDevice('ephemeral', 'ephemeral', true), + <String, Object>{ + 'name': 'ephemeral', + 'id': 'ephemeral', + 'isSupported': true, + 'targetPlatform': 'android-arm', + 'emulator': true, + 'sdk': 'Test SDK (1.2.3)', + 'capabilities': <String, Object>{ + 'hotReload': true, + 'hotRestart': true, + 'screenshot': false, + 'fastStart': false, + 'flutterExit': true, + 'hardwareRendering': true, + 'startPaused': true + } + } + ), + FakeDeviceJsonData( + FakeDevice('webby', 'webby') + ..targetPlatform = Future<TargetPlatform>.value(TargetPlatform.web_javascript) + ..sdkNameAndVersion = Future<String>.value('Web SDK (1.2.4)'), + <String,Object>{ + 'name': 'webby', + 'id': 'webby', + 'isSupported': true, + 'targetPlatform': 'web-javascript', + 'emulator': true, + 'sdk': 'Web SDK (1.2.4)', + 'capabilities': <String, Object>{ + 'hotReload': true, + 'hotRestart': true, + 'screenshot': false, + 'fastStart': false, + 'flutterExit': true, + 'hardwareRendering': false, + 'startPaused': true + } + } + ), +]; + +/// Fake device to test `devices` command +class FakeDevice extends Device { + FakeDevice(this.name, String id, [bool ephemeral = true, this._isSupported = true]) : super( + id, + platformType: PlatformType.web, + category: Category.mobile, + ephemeral: ephemeral, + ); + + final bool _isSupported; + + @override + final String name; + + @override + Future<TargetPlatform> targetPlatform = Future<TargetPlatform>.value(TargetPlatform.android_arm); + + @override + void noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); + + @override + bool isSupportedForProject(FlutterProject flutterProject) => _isSupported; + + @override + bool isSupported() => _isSupported; + + @override + Future<bool> isLocalEmulator = Future<bool>.value(true); + + @override + Future<String> sdkNameAndVersion = Future<String>.value('Test SDK (1.2.3)'); +} + +/// Combines fake device with its canonical JSON representation +class FakeDeviceJsonData { + FakeDeviceJsonData(this.dev, this.json); + + final FakeDevice dev; + final Map<String, Object> json; +}