Change iOS device discovery from polling to long-running observation (#58137)
diff --git a/packages/flutter_tools/lib/src/base/utils.dart b/packages/flutter_tools/lib/src/base/utils.dart index 8c63b66..1e7f0db 100644 --- a/packages/flutter_tools/lib/src/base/utils.dart +++ b/packages/flutter_tools/lib/src/base/utils.dart
@@ -104,6 +104,12 @@ removedItems.forEach(_removedController.add); } + void removeItem(T item) { + if (_items.remove(item)) { + _removedController.add(item); + } + } + /// Close the streams. void dispose() { _addedController.close();
diff --git a/packages/flutter_tools/lib/src/commands/daemon.dart b/packages/flutter_tools/lib/src/commands/daemon.dart index 2e6ddc4..6147d96 100644 --- a/packages/flutter_tools/lib/src/commands/daemon.dart +++ b/packages/flutter_tools/lib/src/commands/daemon.dart
@@ -790,18 +790,20 @@ /// Enable device events. Future<void> enable(Map<String, dynamic> args) { + final List<Future<void>> calls = <Future<void>>[]; for (final PollingDeviceDiscovery discoverer in _discoverers) { - discoverer.startPolling(); + calls.add(discoverer.startPolling()); } - return Future<void>.value(); + return Future.wait<void>(calls); } /// Disable device events. - Future<void> disable(Map<String, dynamic> args) { + Future<void> disable(Map<String, dynamic> args) async { + final List<Future<void>> calls = <Future<void>>[]; for (final PollingDeviceDiscovery discoverer in _discoverers) { - discoverer.stopPolling(); + calls.add(discoverer.stopPolling()); } - return Future<void>.value(); + return Future.wait<void>(calls); } /// Forward a host port to a device port.
diff --git a/packages/flutter_tools/lib/src/device.dart b/packages/flutter_tools/lib/src/device.dart index cef0855..6c19930 100644 --- a/packages/flutter_tools/lib/src/device.dart +++ b/packages/flutter_tools/lib/src/device.dart
@@ -82,6 +82,7 @@ platform: globals.platform, xcdevice: globals.xcdevice, iosWorkflow: globals.iosWorkflow, + logger: globals.logger, ), IOSSimulators(iosSimulatorUtils: globals.iosSimulatorUtils), FuchsiaDevices( @@ -289,14 +290,18 @@ static const Duration _pollingTimeout = Duration(seconds: 30); final String name; - ItemListNotifier<Device> _items; + + @protected + @visibleForTesting + ItemListNotifier<Device> deviceNotifier; + Timer _timer; Future<List<Device>> pollingGetDevices({ Duration timeout }); - void startPolling() { + Future<void> startPolling() async { if (_timer == null) { - _items ??= ItemListNotifier<Device>(); + deviceNotifier ??= ItemListNotifier<Device>(); // Make initial population the default, fast polling timeout. _timer = _initTimer(null); } @@ -306,7 +311,7 @@ return Timer(_pollingInterval, () async { try { final List<Device> devices = await pollingGetDevices(timeout: pollingTimeout); - _items.updateWithNewList(devices); + deviceNotifier.updateWithNewList(devices); } on TimeoutException { globals.printTrace('Device poll timed out. Will retry.'); } @@ -315,7 +320,7 @@ }); } - void stopPolling() { + Future<void> stopPolling() async { _timer?.cancel(); _timer = null; } @@ -327,23 +332,23 @@ @override Future<List<Device>> discoverDevices({ Duration timeout }) async { - _items = null; + deviceNotifier = null; return _populateDevices(timeout: timeout); } Future<List<Device>> _populateDevices({ Duration timeout }) async { - _items ??= ItemListNotifier<Device>.from(await pollingGetDevices(timeout: timeout)); - return _items.items; + deviceNotifier ??= ItemListNotifier<Device>.from(await pollingGetDevices(timeout: timeout)); + return deviceNotifier.items; } Stream<Device> get onAdded { - _items ??= ItemListNotifier<Device>(); - return _items.onAdded; + deviceNotifier ??= ItemListNotifier<Device>(); + return deviceNotifier.onAdded; } Stream<Device> get onRemoved { - _items ??= ItemListNotifier<Device>(); - return _items.onRemoved; + deviceNotifier ??= ItemListNotifier<Device>(); + return deviceNotifier.onRemoved; } void dispose() => stopPolling();
diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart index 51e354d..2d052cd 100644 --- a/packages/flutter_tools/lib/src/ios/devices.dart +++ b/packages/flutter_tools/lib/src/ios/devices.dart
@@ -16,6 +16,7 @@ import '../base/logger.dart'; import '../base/platform.dart'; import '../base/process.dart'; +import '../base/utils.dart'; import '../build_info.dart'; import '../convert.dart'; import '../device.dart'; @@ -35,14 +36,22 @@ Platform platform, XCDevice xcdevice, IOSWorkflow iosWorkflow, + Logger logger, }) : _platform = platform ?? globals.platform, _xcdevice = xcdevice ?? globals.xcdevice, _iosWorkflow = iosWorkflow ?? globals.iosWorkflow, + _logger = logger ?? globals.logger, super('iOS devices'); + @override + void dispose() { + _observedDeviceEventsSubscription?.cancel(); + } + final Platform _platform; final XCDevice _xcdevice; final IOSWorkflow _iosWorkflow; + final Logger _logger; @override bool get supportsPlatform => _platform.isMacOS; @@ -50,6 +59,60 @@ @override bool get canListAnything => _iosWorkflow.canListDevices; + StreamSubscription<Map<XCDeviceEvent, String>> _observedDeviceEventsSubscription; + + @override + Future<void> startPolling() async { + if (!_platform.isMacOS) { + throw UnsupportedError( + 'Control of iOS devices or simulators only supported on macOS.' + ); + } + + deviceNotifier ??= ItemListNotifier<Device>(); + + // Start by populating all currently attached devices. + deviceNotifier.updateWithNewList(await pollingGetDevices()); + + // cancel any outstanding subscriptions. + await _observedDeviceEventsSubscription?.cancel(); + _observedDeviceEventsSubscription = _xcdevice.observedDeviceEvents().listen( + _onDeviceEvent, + onError: (dynamic error, StackTrace stack) { + _logger.printTrace('Process exception running xcdevice observe:\n$error\n$stack'); + }, onDone: () { + // If xcdevice is killed or otherwise dies, polling will be stopped. + // No retry is attempted and the polling client will have to restart polling + // (restart the IDE). Avoid hammering on a process that is + // continuously failing. + _logger.printTrace('xcdevice observe stopped'); + }, + cancelOnError: true, + ); + } + + Future<void> _onDeviceEvent(Map<XCDeviceEvent, String> event) async { + final XCDeviceEvent eventType = event.containsKey(XCDeviceEvent.attach) ? XCDeviceEvent.attach : XCDeviceEvent.detach; + final String deviceIdentifier = event[eventType]; + final Device knownDevice = deviceNotifier.items + .firstWhere((Device device) => device.id == deviceIdentifier, orElse: () => null); + + // Ignore already discovered devices (maybe populated at the beginning). + if (eventType == XCDeviceEvent.attach && knownDevice == null) { + // There's no way to get details for an individual attached device, + // so repopulate them all. + final List<Device> devices = await pollingGetDevices(); + deviceNotifier.updateWithNewList(devices); + } else if (eventType == XCDeviceEvent.detach && knownDevice != null) { + deviceNotifier.removeItem(knownDevice); + } + } + + @override + Future<void> stopPolling() async { + await _observedDeviceEventsSubscription?.cancel(); + } + @override Future<List<Device>> pollingGetDevices({ Duration timeout }) async { if (!_platform.isMacOS) {
diff --git a/packages/flutter_tools/lib/src/macos/xcode.dart b/packages/flutter_tools/lib/src/macos/xcode.dart index ceb0eb5..191fff8 100644 --- a/packages/flutter_tools/lib/src/macos/xcode.dart +++ b/packages/flutter_tools/lib/src/macos/xcode.dart
@@ -194,6 +194,11 @@ } } +enum XCDeviceEvent { + attach, + detach, +} + /// A utility class for interacting with Xcode xcdevice command line tools. class XCDevice { XCDevice({ @@ -218,7 +223,14 @@ platform: platform, processManager: processManager, ), - _xcode = xcode; + _xcode = xcode { + + _setupDeviceIdentifierByEventStream(); + } + + void dispose() { + _deviceObservationProcess?.kill(); + } final ProcessUtils _processUtils; final Logger _logger; @@ -226,6 +238,19 @@ final IOSDeploy _iosDeploy; final Xcode _xcode; + List<dynamic> _cachedListResults; + Process _deviceObservationProcess; + StreamController<Map<XCDeviceEvent, String>> _deviceIdentifierByEvent; + + void _setupDeviceIdentifierByEventStream() { + // _deviceIdentifierByEvent Should always be available for listeners + // in case polling needs to be stopped and restarted. + _deviceIdentifierByEvent = StreamController<Map<XCDeviceEvent, String>>.broadcast( + onListen: _startObservingTetheredIOSDevices, + onCancel: _stopObservingTetheredIOSDevices, + ); + } + bool get isInstalled => _xcode.isInstalledAndMeetsVersionCheck && xcdevicePath != null; String _xcdevicePath; @@ -287,7 +312,99 @@ return null; } - List<dynamic> _cachedListResults; + /// Observe identifiers (UDIDs) of devices as they attach and detach. + /// + /// Each attach and detach event is a tuple of one event type + /// and identifier. + Stream<Map<XCDeviceEvent, String>> observedDeviceEvents() { + if (!isInstalled) { + _logger.printTrace("Xcode not found. Run 'flutter doctor' for more information."); + return null; + } + return _deviceIdentifierByEvent.stream; + } + + // Attach: d83d5bc53967baa0ee18626ba87b6254b2ab5418 + // Detach: d83d5bc53967baa0ee18626ba87b6254b2ab5418 + final RegExp _observationIdentifierPattern = RegExp(r'^(\w*): (\w*)$'); + + Future<void> _startObservingTetheredIOSDevices() async { + try { + if (_deviceObservationProcess != null) { + throw Exception('xcdevice observe restart failed'); + } + + // Run in interactive mode (via script) to convince + // xcdevice it has a terminal attached in order to redirect stdout. + _deviceObservationProcess = await _processUtils.start( + <String>[ + 'script', + '-t', + '0', + '/dev/null', + 'xcrun', + 'xcdevice', + 'observe', + '--both', + ], + ); + + final StreamSubscription<String> stdoutSubscription = _deviceObservationProcess.stdout + .transform<String>(utf8.decoder) + .transform<String>(const LineSplitter()) + .listen((String line) { + + // xcdevice observe example output of UDIDs: + // + // Listening for all devices, on both interfaces. + // Attach: d83d5bc53967baa0ee18626ba87b6254b2ab5418 + // Detach: d83d5bc53967baa0ee18626ba87b6254b2ab5418 + // Attach: d83d5bc53967baa0ee18626ba87b6254b2ab5418 + final RegExpMatch match = _observationIdentifierPattern.firstMatch(line); + if (match != null && match.groupCount == 2) { + final String verb = match.group(1).toLowerCase(); + final String identifier = match.group(2); + if (verb.startsWith('attach')) { + _deviceIdentifierByEvent.add(<XCDeviceEvent, String>{ + XCDeviceEvent.attach: identifier + }); + } else if (verb.startsWith('detach')) { + _deviceIdentifierByEvent.add(<XCDeviceEvent, String>{ + XCDeviceEvent.detach: identifier + }); + } + } + }); + final StreamSubscription<String> stderrSubscription = _deviceObservationProcess.stderr + .transform<String>(utf8.decoder) + .transform<String>(const LineSplitter()) + .listen((String line) { + _logger.printTrace('xcdevice observe error: $line'); + }); + unawaited(_deviceObservationProcess.exitCode.then((int status) { + _logger.printTrace('xcdevice exited with code $exitCode'); + unawaited(stdoutSubscription.cancel()); + unawaited(stderrSubscription.cancel()); + }).whenComplete(() async { + if (_deviceIdentifierByEvent.hasListener) { + // Tell listeners the process died. + await _deviceIdentifierByEvent.close(); + } + _deviceObservationProcess = null; + + // Reopen it so new listeners can resume polling. + _setupDeviceIdentifierByEventStream(); + })); + } on ProcessException catch (exception, stackTrace) { + _deviceIdentifierByEvent.addError(exception, stackTrace); + } on ArgumentError catch (exception, stackTrace) { + _deviceIdentifierByEvent.addError(exception, stackTrace); + } + } + + void _stopObservingTetheredIOSDevices() { + _deviceObservationProcess?.kill(); + } /// [timeout] defaults to 2 seconds. Future<List<IOSDevice>> getAvailableIOSDevices({ Duration timeout }) async {
diff --git a/packages/flutter_tools/test/general.shard/base_utils_test.dart b/packages/flutter_tools/test/general.shard/base_utils_test.dart index 2a9e605..ccd7533 100644 --- a/packages/flutter_tools/test/general.shard/base_utils_test.dart +++ b/packages/flutter_tools/test/general.shard/base_utils_test.dart
@@ -18,19 +18,25 @@ final Future<List<String>> removedStreamItems = list.onRemoved.toList(); list.updateWithNewList(<String>['aaa']); - list.updateWithNewList(<String>['aaa', 'bbb']); - list.updateWithNewList(<String>['bbb']); + list.removeItem('bogus'); + list.updateWithNewList(<String>['aaa', 'bbb', 'ccc']); + list.updateWithNewList(<String>['bbb', 'ccc']); + list.removeItem('bbb'); + + expect(list.items, <String>['ccc']); list.dispose(); final List<String> addedItems = await addedStreamItems; final List<String> removedItems = await removedStreamItems; - expect(addedItems.length, 2); + expect(addedItems.length, 3); expect(addedItems.first, 'aaa'); expect(addedItems[1], 'bbb'); + expect(addedItems[2], 'ccc'); - expect(removedItems.length, 1); + expect(removedItems.length, 2); expect(removedItems.first, 'aaa'); + expect(removedItems[1], 'bbb'); }); }); }
diff --git a/packages/flutter_tools/test/general.shard/device_test.dart b/packages/flutter_tools/test/general.shard/device_test.dart index bad20c2..4ddc184 100644 --- a/packages/flutter_tools/test/general.shard/device_test.dart +++ b/packages/flutter_tools/test/general.shard/device_test.dart
@@ -66,9 +66,9 @@ group('PollingDeviceDiscovery', () { testUsingContext('startPolling', () async { - FakeAsync().run((FakeAsync time) { + FakeAsync().run((FakeAsync time) async { final FakePollingDeviceDiscovery pollingDeviceDiscovery = FakePollingDeviceDiscovery(); - pollingDeviceDiscovery.startPolling(); + await pollingDeviceDiscovery.startPolling(); time.elapse(const Duration(milliseconds: 4001)); time.flushMicrotasks(); // First check should use the default polling timeout @@ -79,7 +79,7 @@ time.flushMicrotasks(); // Subsequent polling should be much longer. expect(pollingDeviceDiscovery.lastPollingTimeout, const Duration(seconds: 30)); - pollingDeviceDiscovery.stopPolling(); + await pollingDeviceDiscovery.stopPolling(); }); }); });
diff --git a/packages/flutter_tools/test/general.shard/ios/devices_test.dart b/packages/flutter_tools/test/general.shard/ios/devices_test.dart index ba4d78d..4d39209 100644 --- a/packages/flutter_tools/test/general.shard/ios/devices_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/devices_test.dart
@@ -262,15 +262,17 @@ }); }); - group('pollingGetDevices', () { + group('polling', () { MockXcdevice mockXcdevice; MockArtifacts mockArtifacts; MockCache mockCache; FakeProcessManager fakeProcessManager; - Logger logger; + BufferLogger logger; IOSDeploy iosDeploy; IMobileDevice iMobileDevice; IOSWorkflow mockIosWorkflow; + IOSDevice device1; + IOSDevice device2; setUp(() { mockXcdevice = MockXcdevice(); @@ -292,33 +294,8 @@ processManager: fakeProcessManager, logger: logger, ); - }); - final List<Platform> unsupportedPlatforms = <Platform>[linuxPlatform, windowsPlatform]; - for (final Platform unsupportedPlatform in unsupportedPlatforms) { - testWithoutContext('throws Unsupported Operation exception on ${unsupportedPlatform.operatingSystem}', () async { - final IOSDevices iosDevices = IOSDevices( - platform: unsupportedPlatform, - xcdevice: mockXcdevice, - iosWorkflow: mockIosWorkflow, - ); - when(mockXcdevice.isInstalled).thenReturn(false); - expect( - () async { await iosDevices.pollingGetDevices(); }, - throwsA(isA<UnsupportedError>()), - ); - }); - } - - testWithoutContext('returns attached devices', () async { - final IOSDevices iosDevices = IOSDevices( - platform: macPlatform, - xcdevice: mockXcdevice, - iosWorkflow: mockIosWorkflow, - ); - when(mockXcdevice.isInstalled).thenReturn(true); - - final IOSDevice device = IOSDevice( + device1 = IOSDevice( 'd83d5bc53967baa0ee18626ba87b6254b2ab5418', name: 'Paired iPhone', sdkVersion: '13.3', @@ -331,22 +308,185 @@ fileSystem: MemoryFileSystem.test(), interfaceType: IOSDeviceInterface.usb, ); + + device2 = IOSDevice( + '43ad2fda7991b34fe1acbda82f9e2fd3d6ddc9f7', + name: 'iPhone 6s', + sdkVersion: '13.3', + cpuArchitecture: DarwinArch.arm64, + artifacts: mockArtifacts, + iosDeploy: iosDeploy, + iMobileDevice: iMobileDevice, + logger: logger, + platform: macPlatform, + fileSystem: MemoryFileSystem.test(), + interfaceType: IOSDeviceInterface.usb, + ); + }); + + testWithoutContext('start polling', () async { + final IOSDevices iosDevices = IOSDevices( + platform: macPlatform, + xcdevice: mockXcdevice, + iosWorkflow: mockIosWorkflow, + logger: logger, + ); + when(mockXcdevice.isInstalled).thenReturn(true); + + int fetchDevicesCount = 0; when(mockXcdevice.getAvailableIOSDevices()) - .thenAnswer((Invocation invocation) => Future<List<IOSDevice>>.value(<IOSDevice>[device])); + .thenAnswer((Invocation invocation) { + if (fetchDevicesCount == 0) { + // Initial time, no devices. + fetchDevicesCount++; + return Future<List<IOSDevice>>.value(<IOSDevice>[]); + } else if (fetchDevicesCount == 1) { + // Simulate 2 devices added later. + fetchDevicesCount++; + return Future<List<IOSDevice>>.value(<IOSDevice>[device1, device2]); + } + fail('Too many calls to getAvailableTetheredIOSDevices'); + }); + + int addedCount = 0; + final Completer<void> added = Completer<void>(); + iosDevices.onAdded.listen((Device device) { + addedCount++; + // 2 devices will be added. + // Will throw over-completion if called more than twice. + if (addedCount >= 2) { + added.complete(); + } + }); + + final Completer<void> removed = Completer<void>(); + iosDevices.onRemoved.listen((Device device) { + // Will throw over-completion if called more than once. + removed.complete(); + }); + + final StreamController<Map<XCDeviceEvent, String>> eventStream = StreamController<Map<XCDeviceEvent, String>>(); + when(mockXcdevice.observedDeviceEvents()).thenAnswer((_) => eventStream.stream); + + await iosDevices.startPolling(); + verify(mockXcdevice.getAvailableIOSDevices()).called(1); + + expect(iosDevices.deviceNotifier.items, isEmpty); + expect(eventStream.hasListener, isTrue); + + eventStream.add(<XCDeviceEvent, String>{ + XCDeviceEvent.attach: 'd83d5bc53967baa0ee18626ba87b6254b2ab5418' + }); + await added.future; + expect(iosDevices.deviceNotifier.items.length, 2); + expect(iosDevices.deviceNotifier.items, contains(device1)); + expect(iosDevices.deviceNotifier.items, contains(device2)); + + eventStream.add(<XCDeviceEvent, String>{ + XCDeviceEvent.detach: 'd83d5bc53967baa0ee18626ba87b6254b2ab5418' + }); + await removed.future; + expect(iosDevices.deviceNotifier.items, <Device>[device2]); + + // Remove stream will throw over-completion if called more than once + // which proves this is ignored. + eventStream.add(<XCDeviceEvent, String>{ + XCDeviceEvent.detach: 'bogus' + }); + + expect(addedCount, 2); + + await iosDevices.stopPolling(); + + expect(eventStream.hasListener, isFalse); + }); + + testWithoutContext('polling can be restarted if stream is closed', () async { + final IOSDevices iosDevices = IOSDevices( + platform: macPlatform, + xcdevice: mockXcdevice, + iosWorkflow: mockIosWorkflow, + logger: logger, + ); + when(mockXcdevice.isInstalled).thenReturn(true); + + when(mockXcdevice.getAvailableIOSDevices()) + .thenAnswer((Invocation invocation) => Future<List<IOSDevice>>.value(<IOSDevice>[])); + + final StreamController<Map<XCDeviceEvent, String>> eventStream = StreamController<Map<XCDeviceEvent, String>>(); + final StreamController<Map<XCDeviceEvent, String>> rescheduledStream = StreamController<Map<XCDeviceEvent, String>>(); + + bool reschedule = false; + when(mockXcdevice.observedDeviceEvents()).thenAnswer((Invocation invocation) { + if (!reschedule) { + reschedule = true; + return eventStream.stream; + } + return rescheduledStream.stream; + }); + + await iosDevices.startPolling(); + expect(eventStream.hasListener, isTrue); + verify(mockXcdevice.getAvailableIOSDevices()).called(1); + + // Pretend xcdevice crashed. + await eventStream.close(); + expect(logger.traceText, contains('xcdevice observe stopped')); + + // Confirm a restart still gets streamed events. + await iosDevices.startPolling(); + + expect(eventStream.hasListener, isFalse); + expect(rescheduledStream.hasListener, isTrue); + + await iosDevices.stopPolling(); + expect(rescheduledStream.hasListener, isFalse); + }); + + final List<Platform> unsupportedPlatforms = <Platform>[linuxPlatform, windowsPlatform]; + for (final Platform unsupportedPlatform in unsupportedPlatforms) { + testWithoutContext('pollingGetDevices throws Unsupported Operation exception on ${unsupportedPlatform.operatingSystem}', () async { + final IOSDevices iosDevices = IOSDevices( + platform: unsupportedPlatform, + xcdevice: mockXcdevice, + iosWorkflow: mockIosWorkflow, + logger: logger, + ); + when(mockXcdevice.isInstalled).thenReturn(false); + expect( + () async { await iosDevices.pollingGetDevices(); }, + throwsA(isA<UnsupportedError>()), + ); + }); + } + + testWithoutContext('pollingGetDevices returns attached devices', () async { + final IOSDevices iosDevices = IOSDevices( + platform: macPlatform, + xcdevice: mockXcdevice, + iosWorkflow: mockIosWorkflow, + logger: logger, + ); + when(mockXcdevice.isInstalled).thenReturn(true); + + when(mockXcdevice.getAvailableIOSDevices()) + .thenAnswer((Invocation invocation) => Future<List<IOSDevice>>.value(<IOSDevice>[device1])); final List<Device> devices = await iosDevices.pollingGetDevices(); expect(devices, hasLength(1)); - expect(identical(devices.first, device), isTrue); + expect(identical(devices.first, device1), isTrue); }); }); group('getDiagnostics', () { MockXcdevice mockXcdevice; IOSWorkflow mockIosWorkflow; + Logger logger; setUp(() { mockXcdevice = MockXcdevice(); mockIosWorkflow = MockIOSWorkflow(); + logger = BufferLogger.test(); }); final List<Platform> unsupportedPlatforms = <Platform>[linuxPlatform, windowsPlatform]; @@ -356,6 +496,7 @@ platform: unsupportedPlatform, xcdevice: mockXcdevice, iosWorkflow: mockIosWorkflow, + logger: logger, ); when(mockXcdevice.isInstalled).thenReturn(false); expect((await iosDevices.getDiagnostics()).first, 'Control of iOS devices or simulators only supported on macOS.'); @@ -367,6 +508,7 @@ platform: macPlatform, xcdevice: mockXcdevice, iosWorkflow: mockIosWorkflow, + logger: logger, ); when(mockXcdevice.isInstalled).thenReturn(true); when(mockXcdevice.getDiagnostics())
diff --git a/packages/flutter_tools/test/general.shard/macos/xcode_test.dart b/packages/flutter_tools/test/general.shard/macos/xcode_test.dart index b60f9a2..afbaa64 100644 --- a/packages/flutter_tools/test/general.shard/macos/xcode_test.dart +++ b/packages/flutter_tools/test/general.shard/macos/xcode_test.dart
@@ -2,6 +2,8 @@ // 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:file/memory.dart'; import 'package:flutter_tools/src/artifacts.dart'; import 'package:flutter_tools/src/base/io.dart' show ProcessException, ProcessResult; @@ -19,7 +21,7 @@ import '../../src/context.dart'; void main() { - Logger logger; + BufferLogger logger; setUp(() { logger = BufferLogger.test(); @@ -353,6 +355,59 @@ }); }); + group('observe device events', () { + testWithoutContext('Xcode not installed', () async { + when(mockXcode.isInstalledAndMeetsVersionCheck).thenReturn(false); + + expect(xcdevice.observedDeviceEvents(), isNull); + expect(logger.traceText, contains("Xcode not found. Run 'flutter doctor' for more information.")); + }); + + testUsingContext('relays events', () async { + when(mockXcode.isInstalledAndMeetsVersionCheck).thenReturn(true); + fakeProcessManager.addCommand(const FakeCommand( + command: <String>['xcrun', '--find', 'xcdevice'], + stdout: '/path/to/xcdevice', + )); + + fakeProcessManager.addCommand(const FakeCommand( + command: <String>[ + 'script', + '-t', + '0', + '/dev/null', + 'xcrun', + 'xcdevice', + 'observe', + '--both', + ], stdout: 'Attach: d83d5bc53967baa0ee18626ba87b6254b2ab5418\n' + 'Detach: d83d5bc53967baa0ee18626ba87b6254b2ab5418', + stderr: 'Some error', + )); + + final Completer<void> attach = Completer<void>(); + final Completer<void> detach = Completer<void>(); + + // Attach: d83d5bc53967baa0ee18626ba87b6254b2ab5418 + // Detach: d83d5bc53967baa0ee18626ba87b6254b2ab5418 + xcdevice.observedDeviceEvents().listen((Map<XCDeviceEvent, String> event) { + expect(event.length, 1); + if (event.containsKey(XCDeviceEvent.attach)) { + expect(event[XCDeviceEvent.attach], 'd83d5bc53967baa0ee18626ba87b6254b2ab5418'); + attach.complete(); + } else if (event.containsKey(XCDeviceEvent.detach)) { + expect(event[XCDeviceEvent.detach], 'd83d5bc53967baa0ee18626ba87b6254b2ab5418'); + detach.complete(); + } else { + fail('Unexpected event'); + } + }); + await attach.future; + await detach.future; + expect(logger.traceText, contains('xcdevice observe error: Some error')); + }); + }); + group('available devices', () { final FakePlatform macPlatform = FakePlatform(operatingSystem: 'macos');