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;
+}