From 2be4570d3a3b3a54cdbe48f5ca162d21e1a90351 Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Tue, 22 Sep 2020 14:59:14 -0700 Subject: [PATCH] Stream logging from attached debugger on iOS (#66092) (#66390) --- .../flutter_tools/lib/src/ios/devices.dart | 72 +++- .../lib/src/ios/fallback_discovery.dart | 50 +-- .../flutter_tools/lib/src/ios/ios_deploy.dart | 244 ++++++++++- .../lib/src/protocol_discovery.dart | 11 +- .../general.shard/ios/ios_deploy_test.dart | 265 ++++++++++-- .../ios/ios_device_logger_test.dart | 383 ++++++++++++------ .../ios/ios_device_start_prebuilt_test.dart | 66 +-- 7 files changed, 842 insertions(+), 249 deletions(-) diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart index 9bad5a3d472..fb7aea57798 100644 --- a/packages/flutter_tools/lib/src/ios/devices.dart +++ b/packages/flutter_tools/lib/src/ios/devices.dart @@ -212,6 +212,9 @@ class IOSDevice extends Device { DevicePortForwarder _portForwarder; + @visibleForTesting + IOSDeployDebugger iosDeployDebugger; + @override Future get isLocalEmulator async => false; @@ -395,23 +398,38 @@ class IOSDevice extends Device { timeout: timeoutConfiguration.slowOperation); try { ProtocolDiscovery observatoryDiscovery; + int installationResult = 1; if (debuggingOptions.debuggingEnabled) { _logger.printTrace('Debugging is enabled, connecting to observatory'); + iosDeployDebugger = _iosDeploy.prepareDebuggerForLaunch( + deviceId: id, + bundlePath: bundle.path, + launchArguments: launchArguments, + interfaceType: interfaceType, + ); + + final DeviceLogReader deviceLogReader = getLogReader(app: package); + if (deviceLogReader is IOSDeviceLogReader) { + deviceLogReader.debuggerStream = iosDeployDebugger; + } observatoryDiscovery = ProtocolDiscovery.observatory( - getLogReader(app: package), + deviceLogReader, portForwarder: portForwarder, + throttleDuration: fallbackPollingDelay, + throttleTimeout: fallbackThrottleTimeout ?? const Duration(seconds: 5), hostPort: debuggingOptions.hostVmServicePort, devicePort: debuggingOptions.deviceVmServicePort, ipv6: ipv6, - throttleTimeout: fallbackThrottleTimeout ?? const Duration(seconds: 1), + ); + installationResult = await iosDeployDebugger.launchAndAttach() ? 0 : 1; + } else { + installationResult = await _iosDeploy.launchApp( + deviceId: id, + bundlePath: bundle.path, + launchArguments: launchArguments, + interfaceType: interfaceType, ); } - final int installationResult = await _iosDeploy.runApp( - deviceId: id, - bundlePath: bundle.path, - launchArguments: launchArguments, - interfaceType: interfaceType, - ); if (installationResult != 0) { _logger.printError('Could not run ${bundle.path} on $id.'); _logger.printError('Try launching Xcode and selecting "Product > Run" to fix the problem:'); @@ -465,7 +483,11 @@ class IOSDevice extends Device { IOSApp app, { String userIdentifier, }) async { - // Currently we don't have a way to stop an app running on iOS. + // If the debugger is not attached, killing the ios-deploy process won't stop the app. + if (iosDeployDebugger!= null && iosDeployDebugger.debuggerAttached) { + // Avoid null. + return iosDeployDebugger?.exit() == true; + } return false; } @@ -655,6 +677,13 @@ class IOSDeviceLogReader extends DeviceLogReader { // Matches a syslog line from any app. RegExp _anyLineRegex; + // Logging from native code/Flutter engine is prefixed by timestamp and process metadata: + // 2020-09-15 19:15:10.931434-0700 Runner[541:226276] Did finish launching. + // 2020-09-15 19:15:10.931434-0700 Runner[541:226276] [Category] Did finish launching. + // + // Logging from the dart code has no prefixing metadata. + final RegExp _debuggerLoggingRegex = RegExp(r'^\S* \S* \S*\[[0-9:]*] (.*)'); + StreamController _linesController; List> _loggingSubscriptions; @@ -687,6 +716,10 @@ class IOSDeviceLogReader extends DeviceLogReader { } void logMessage(vm_service.Event event) { + if (_iosDeployDebugger != null && _iosDeployDebugger.debuggerAttached) { + // Prefer the more complete logs from the attached debugger. + return; + } final String message = processVmServiceMessage(event); if (message.isNotEmpty) { _linesController.add(message); @@ -699,6 +732,26 @@ class IOSDeviceLogReader extends DeviceLogReader { ]); } + /// Log reader will listen to [debugger.logLines] and will detach debugger on dispose. + set debuggerStream(IOSDeployDebugger debugger) { + // Logging is gathered from syslog on iOS 13 and earlier. + if (_majorSdkVersion < _minimumUniversalLoggingSdkVersion) { + return; + } + _iosDeployDebugger = debugger; + // Add the debugger logs to the controller created on initialization. + _loggingSubscriptions.add(debugger.logLines.listen( + (String line) => _linesController.add(_debuggerLineHandler(line)), + onError: _linesController.addError, + onDone: _linesController.close, + cancelOnError: true, + )); + } + IOSDeployDebugger _iosDeployDebugger; + + // Strip off the logging metadata (leave the category), or just echo the line. + String _debuggerLineHandler(String line) => _debuggerLoggingRegex?.firstMatch(line)?.group(1) ?? line; + void _listenToSysLog() { // syslog is not written on iOS 13+. if (_majorSdkVersion >= _minimumUniversalLoggingSdkVersion) { @@ -758,6 +811,7 @@ class IOSDeviceLogReader extends DeviceLogReader { loggingSubscription.cancel(); } _idevicesyslogProcess?.kill(); + _iosDeployDebugger?.detach(); } } diff --git a/packages/flutter_tools/lib/src/ios/fallback_discovery.dart b/packages/flutter_tools/lib/src/ios/fallback_discovery.dart index 56c8147eff9..42f337f5702 100644 --- a/packages/flutter_tools/lib/src/ios/fallback_discovery.dart +++ b/packages/flutter_tools/lib/src/ios/fallback_discovery.dart @@ -81,6 +81,29 @@ class FallbackDiscovery { return result; } + try { + final Uri result = await _protocolDiscovery.uri; + if (result != null) { + UsageEvent( + _kEventName, + 'log-success', + flutterUsage: _flutterUsage, + ).send(); + return result; + } + } on ArgumentError { + // In the event of an invalid InternetAddress, this code attempts to catch + // an ArgumentError from protocol_discovery.dart + } on Exception catch (err) { + _logger.printTrace(err.toString()); + } + _logger.printTrace('Failed to connect with log scanning, falling back to mDNS'); + UsageEvent( + _kEventName, + 'log-failure', + flutterUsage: _flutterUsage, + ).send(); + try { final Uri result = await _mDnsObservatoryDiscovery.getObservatoryUri( packageId, @@ -99,35 +122,12 @@ class FallbackDiscovery { } on Exception catch (err) { _logger.printTrace(err.toString()); } - _logger.printTrace('Failed to connect with mDNS, falling back to log scanning'); + _logger.printTrace('Failed to connect with mDNS'); UsageEvent( _kEventName, 'mdns-failure', flutterUsage: _flutterUsage, ).send(); - - try { - final Uri result = await _protocolDiscovery.uri; - if (result != null) { - UsageEvent( - _kEventName, - 'fallback-success', - flutterUsage: _flutterUsage, - ).send(); - return result; - } - } on ArgumentError { - // In the event of an invalid InternetAddress, this code attempts to catch - // an ArgumentError from protocol_discovery.dart - } on Exception catch (err) { - _logger.printTrace(err.toString()); - } - _logger.printTrace('Failed to connect with log scanning'); - UsageEvent( - _kEventName, - 'fallback-failure', - flutterUsage: _flutterUsage, - ).send(); return null; } @@ -148,7 +148,7 @@ class FallbackDiscovery { assumedWsUri = Uri.parse('ws://localhost:$hostPort/ws'); } on Exception catch (err) { _logger.printTrace(err.toString()); - _logger.printTrace('Failed to connect directly, falling back to mDNS'); + _logger.printTrace('Failed to connect directly, falling back to log scanning'); _sendFailureEvent(err, assumedDevicePort); return null; } diff --git a/packages/flutter_tools/lib/src/ios/ios_deploy.dart b/packages/flutter_tools/lib/src/ios/ios_deploy.dart index c1e90a74fc9..3945bdc225a 100644 --- a/packages/flutter_tools/lib/src/ios/ios_deploy.dart +++ b/packages/flutter_tools/lib/src/ios/ios_deploy.dart @@ -2,15 +2,20 @@ // 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:meta/meta.dart'; import 'package:process/process.dart'; import '../artifacts.dart'; +import '../base/common.dart'; +import '../base/io.dart'; import '../base/logger.dart'; import '../base/platform.dart'; import '../base/process.dart'; import '../build_info.dart'; import '../cache.dart'; +import '../convert.dart'; import 'code_signing.dart'; import 'devices.dart'; @@ -107,10 +112,47 @@ class IOSDeploy { ); } + /// Returns [IOSDeployDebugger] wrapping attached debugger logic. + /// + /// This method does not install the app. Call [IOSDeployDebugger.launchAndAttach()] + /// to install and attach the debugger to the specified app bundle. + IOSDeployDebugger prepareDebuggerForLaunch({ + @required String deviceId, + @required String bundlePath, + @required List launchArguments, + @required IOSDeviceInterface interfaceType, + }) { + // Interactive debug session to support sending the lldb detach command. + final List launchCommand = [ + 'script', + '-t', + '0', + '/dev/null', + _binaryPath, + '--id', + deviceId, + '--bundle', + bundlePath, + '--debug', + if (interfaceType != IOSDeviceInterface.network) + '--no-wifi', + if (launchArguments.isNotEmpty) ...[ + '--args', + launchArguments.join(' '), + ], + ]; + return IOSDeployDebugger( + launchCommand: launchCommand, + logger: _logger, + processUtils: _processUtils, + iosDeployEnv: iosDeployEnv, + ); + } + /// Installs and then runs the specified app bundle. /// /// Uses ios-deploy and returns the exit code. - Future runApp({ + Future launchApp({ @required String deviceId, @required String bundlePath, @required List launchArguments, @@ -169,30 +211,202 @@ class IOSDeploy { return true; } - // Maps stdout line stream. Must return original line. - String _monitorFailure(String stdout) { - // Installation issues. - if (stdout.contains(noProvisioningProfileErrorOne) || stdout.contains(noProvisioningProfileErrorTwo)) { - _logger.printError(noProvisioningProfileInstruction, emphasis: true); + String _monitorFailure(String stdout) => _monitorIOSDeployFailure(stdout, _logger); +} + +/// lldb attach state flow. +enum _IOSDeployDebuggerState { + detached, + launching, + attached, +} + +/// Wrapper to launch app and attach the debugger with ios-deploy. +class IOSDeployDebugger { + IOSDeployDebugger({ + @required Logger logger, + @required ProcessUtils processUtils, + @required List launchCommand, + @required Map iosDeployEnv, + }) : _processUtils = processUtils, + _logger = logger, + _launchCommand = launchCommand, + _iosDeployEnv = iosDeployEnv, + _debuggerState = _IOSDeployDebuggerState.detached; + + /// Create a [IOSDeployDebugger] for testing. + /// + /// Sets the command to "ios-deploy" and environment to an empty map. + @visibleForTesting + factory IOSDeployDebugger.test({ + @required ProcessManager processManager, + Logger logger, + }) { + final Logger debugLogger = logger ?? BufferLogger.test(); + return IOSDeployDebugger( + logger: debugLogger, + processUtils: ProcessUtils(logger: debugLogger, processManager: processManager), + launchCommand: ['ios-deploy'], + iosDeployEnv: {}, + ); + } + + final Logger _logger; + final ProcessUtils _processUtils; + final List _launchCommand; + final Map _iosDeployEnv; + + Process _iosDeployProcess; + + Stream get logLines => _debuggerOutput.stream; + final StreamController _debuggerOutput = StreamController.broadcast(); + + bool get debuggerAttached => _debuggerState == _IOSDeployDebuggerState.attached; + _IOSDeployDebuggerState _debuggerState; + + // (lldb) run + // https://github.com/ios-control/ios-deploy/blob/1.11.2-beta.1/src/ios-deploy/ios-deploy.m#L51 + static final RegExp _lldbRun = RegExp(r'\(lldb\)\s*run'); + + /// Launch the app on the device, and attach the debugger. + /// + /// Returns whether or not the debugger successfully attached. + Future launchAndAttach() async { + // Return when the debugger attaches, or the ios-deploy process exits. + final Completer debuggerCompleter = Completer(); + try { + _iosDeployProcess = await _processUtils.start( + _launchCommand, + environment: _iosDeployEnv, + ); + String lastLineFromDebugger; + final StreamSubscription stdoutSubscription = _iosDeployProcess.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((String line) { + _monitorIOSDeployFailure(line, _logger); + + // (lldb) run + // success + // 2020-09-15 13:42:25.185474-0700 Runner[477:181141] flutter: Observatory listening on http://127.0.0.1:57782/ + if (_lldbRun.hasMatch(line)) { + _logger.printTrace(line); + _debuggerState = _IOSDeployDebuggerState.launching; + return; + } + // Next line after "run" must be "success", or the attach failed. + // Example: "error: process launch failed" + if (_debuggerState == _IOSDeployDebuggerState.launching) { + _logger.printTrace(line); + final bool attachSuccess = line == 'success'; + _debuggerState = attachSuccess ? _IOSDeployDebuggerState.attached : _IOSDeployDebuggerState.detached; + if (!debuggerCompleter.isCompleted) { + debuggerCompleter.complete(attachSuccess); + } + return; + } + if (line.contains('PROCESS_STOPPED') || + line.contains('PROCESS_EXITED')) { + // The app exited or crashed, so stop echoing the output. + // Don't pass any further ios-deploy debugging messages to the log reader after it exits. + _debuggerState = _IOSDeployDebuggerState.detached; + _logger.printTrace(line); + return; + } + if (_debuggerState != _IOSDeployDebuggerState.attached) { + _logger.printTrace(line); + return; + } + if (lastLineFromDebugger != null && lastLineFromDebugger.isNotEmpty && line.isEmpty) { + // The lldb console stream from ios-deploy is separated lines by an extra \r\n. + // To avoid all lines being double spaced, if the last line from the + // debugger was not an empty line, skip this empty line. + // This will still cause "legit" logged newlines to be doubled... + } else { + _debuggerOutput.add(line); + } + lastLineFromDebugger = line; + }); + final StreamSubscription stderrSubscription = _iosDeployProcess.stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((String line) { + _monitorIOSDeployFailure(line, _logger); + _logger.printTrace(line); + }); + unawaited(_iosDeployProcess.exitCode.then((int status) { + _logger.printTrace('ios-deploy exited with code $exitCode'); + _debuggerState = _IOSDeployDebuggerState.detached; + unawaited(stdoutSubscription.cancel()); + unawaited(stderrSubscription.cancel()); + }).whenComplete(() async { + if (_debuggerOutput.hasListener) { + // Tell listeners the process died. + await _debuggerOutput.close(); + } + if (!debuggerCompleter.isCompleted) { + debuggerCompleter.complete(false); + } + _iosDeployProcess = null; + })); + } on ProcessException catch (exception, stackTrace) { + _logger.printTrace('ios-deploy failed: $exception'); + _debuggerState = _IOSDeployDebuggerState.detached; + _debuggerOutput.addError(exception, stackTrace); + } on ArgumentError catch (exception, stackTrace) { + _logger.printTrace('ios-deploy failed: $exception'); + _debuggerState = _IOSDeployDebuggerState.detached; + _debuggerOutput.addError(exception, stackTrace); + } + // Wait until the debugger attaches, or the attempt fails. + return debuggerCompleter.future; + } + + bool exit() { + final bool success = (_iosDeployProcess == null) || _iosDeployProcess.kill(); + _iosDeployProcess = null; + return success; + } + + void detach() { + if (!debuggerAttached) { + return; + } + + try { + // Detach lldb from the app process. + _iosDeployProcess?.stdin?.writeln('process detach'); + _debuggerState = _IOSDeployDebuggerState.detached; + } on SocketException catch (error) { + // Best effort, try to detach, but maybe the app already exited or already detached. + _logger.printTrace('Could not detach from debugger: $error'); + } + } +} + +// Maps stdout line stream. Must return original line. +String _monitorIOSDeployFailure(String stdout, Logger logger) { + // Installation issues. + if (stdout.contains(noProvisioningProfileErrorOne) || stdout.contains(noProvisioningProfileErrorTwo)) { + logger.printError(noProvisioningProfileInstruction, emphasis: true); // Launch issues. - } else if (stdout.contains(deviceLockedError)) { - _logger.printError(''' + } else if (stdout.contains(deviceLockedError)) { + logger.printError(''' ═══════════════════════════════════════════════════════════════════════════════════ Your device is locked. Unlock your device first before running. ═══════════════════════════════════════════════════════════════════════════════════''', - emphasis: true); - } else if (stdout.contains(unknownAppLaunchError)) { - _logger.printError(''' + emphasis: true); + } else if (stdout.contains(unknownAppLaunchError)) { + logger.printError(''' ═══════════════════════════════════════════════════════════════════════════════════ Error launching app. Try launching from within Xcode via: open ios/Runner.xcworkspace Your Xcode version may be too old for your iOS version. ═══════════════════════════════════════════════════════════════════════════════════''', - emphasis: true); - } - - return stdout; + emphasis: true); } + + return stdout; } diff --git a/packages/flutter_tools/lib/src/protocol_discovery.dart b/packages/flutter_tools/lib/src/protocol_discovery.dart index 367bccd7a53..3d1bc3dd2dc 100644 --- a/packages/flutter_tools/lib/src/protocol_discovery.dart +++ b/packages/flutter_tools/lib/src/protocol_discovery.dart @@ -34,7 +34,7 @@ class ProtocolDiscovery { factory ProtocolDiscovery.observatory( DeviceLogReader logReader, { DevicePortForwarder portForwarder, - Duration throttleDuration = const Duration(milliseconds: 200), + Duration throttleDuration, Duration throttleTimeout, @required int hostPort, @required int devicePort, @@ -45,7 +45,7 @@ class ProtocolDiscovery { logReader, kObservatoryService, portForwarder: portForwarder, - throttleDuration: throttleDuration, + throttleDuration: throttleDuration ?? const Duration(milliseconds: 200), throttleTimeout: throttleTimeout, hostPort: hostPort, devicePort: devicePort, @@ -225,7 +225,7 @@ class _BufferedStreamController { /// /// For example, consider a `waitDuration` of `10ms`, and list of event names /// and arrival times: `a (0ms), b (5ms), c (11ms), d (21ms)`. -/// The events `c` and `d` will be produced as a result. +/// The events `a`, `c`, and `d` will be produced as a result. StreamTransformer _throttle({ @required Duration waitDuration, }) { @@ -240,10 +240,13 @@ StreamTransformer _throttle({ handleData: (S value, EventSink sink) { latestLine = value; + final bool isFirstMessage = lastExecution == null; final int currentTime = DateTime.now().millisecondsSinceEpoch; lastExecution ??= currentTime; final int remainingTime = currentTime - lastExecution; - final int nextExecutionTime = remainingTime > waitDuration.inMilliseconds + + // Always send the first event immediately. + final int nextExecutionTime = isFirstMessage || remainingTime > waitDuration.inMilliseconds ? 0 : waitDuration.inMilliseconds - remainingTime; diff --git a/packages/flutter_tools/test/general.shard/ios/ios_deploy_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_deploy_test.dart index 121c4f96fa9..e36d466ceeb 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_deploy_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_deploy_test.dart @@ -2,10 +2,15 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; +import 'dart:convert'; + import 'package:flutter_tools/src/artifacts.dart'; +import 'package:flutter_tools/src/base/file_system.dart'; import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/base/platform.dart'; import 'package:flutter_tools/src/cache.dart'; +import 'package:flutter_tools/src/ios/devices.dart'; import 'package:flutter_tools/src/ios/ios_deploy.dart'; import 'package:mockito/mockito.dart'; import 'package:process/process.dart'; @@ -21,50 +26,230 @@ void main () { expect(environment['PATH'], startsWith('/usr/bin')); }); - testWithoutContext('IOSDeploy.uninstallApp calls ios-deploy with correct arguments and returns 0 on success', () async { - const String deviceId = '123'; - const String bundleId = 'com.example.app'; - final FakeProcessManager processManager = FakeProcessManager.list([ - const FakeCommand(command: [ - 'ios-deploy', - '--id', - deviceId, - '--uninstall_only', - '--bundle_id', - bundleId, - ]) - ]); - final IOSDeploy iosDeploy = setUpIOSDeploy(processManager); - final int exitCode = await iosDeploy.uninstallApp( - deviceId: deviceId, - bundleId: bundleId, - ); + group('IOSDeploy.prepareDebuggerForLaunch', () { + testWithoutContext('calls ios-deploy with correct arguments and returns when debugger attaches', () async { + final FakeProcessManager processManager = FakeProcessManager.list([ + FakeCommand( + command: [ + 'script', + '-t', + '0', + '/dev/null', + 'ios-deploy', + '--id', + '123', + '--bundle', + '/', + '--debug', + '--args', + [ + '--enable-dart-profiling', + ].join(' '), + ], environment: const { + 'PATH': '/usr/bin:/usr/local/bin:/usr/bin', + 'DYLD_LIBRARY_PATH': '/path/to/libs', + }, + stdout: '(lldb) run\nsuccess\nDid finish launching.', + ), + ]); + final IOSDeploy iosDeploy = setUpIOSDeploy(processManager); + final IOSDeployDebugger iosDeployDebugger = iosDeploy.prepareDebuggerForLaunch( + deviceId: '123', + bundlePath: '/', + launchArguments: ['--enable-dart-profiling'], + interfaceType: IOSDeviceInterface.network, + ); - expect(exitCode, 0); - expect(processManager.hasRemainingExpectations, false); + expect(await iosDeployDebugger.launchAndAttach(), isTrue); + expect(await iosDeployDebugger.logLines.toList(), ['Did finish launching.']); + expect(processManager.hasRemainingExpectations, false); + }); }); - testWithoutContext('IOSDeploy.uninstallApp returns non-zero exit code when ios-deploy does the same', () async { - const String deviceId = '123'; - const String bundleId = 'com.example.app'; - final FakeProcessManager processManager = FakeProcessManager.list([ - const FakeCommand(command: [ - 'ios-deploy', - '--id', - deviceId, - '--uninstall_only', - '--bundle_id', - bundleId, - ], exitCode: 1) - ]); - final IOSDeploy iosDeploy = setUpIOSDeploy(processManager); - final int exitCode = await iosDeploy.uninstallApp( - deviceId: deviceId, - bundleId: bundleId, - ); + group('IOSDeployDebugger', () { + group('launch', () { + BufferLogger logger; - expect(exitCode, 1); - expect(processManager.hasRemainingExpectations, false); + setUp(() { + logger = BufferLogger.test(); + }); + + testWithoutContext('debugger attached', () async { + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand( + command: ['ios-deploy'], + stdout: '(lldb) run\r\nsuccess\r\nsuccess\r\nLog on attach1\r\n\r\nLog on attach2\r\n\r\n\r\n\r\nPROCESS_STOPPED\r\nLog after process exit', + ), + ]); + final IOSDeployDebugger iosDeployDebugger = IOSDeployDebugger.test( + processManager: processManager, + logger: logger, + ); + final List receivedLogLines = []; + final Stream logLines = iosDeployDebugger.logLines + ..listen(receivedLogLines.add); + + expect(await iosDeployDebugger.launchAndAttach(), isTrue); + await logLines.toList(); + expect(receivedLogLines, [ + 'success', // ignore first "success" from lldb, but log subsequent ones from real logging. + 'Log on attach1', + 'Log on attach2', + '', '']); + }); + + testWithoutContext('attach failed', () async { + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand( + command: ['ios-deploy'], + // A success after an error should never happen, but test that we're handling random "successes" anyway. + stdout: '(lldb) run\r\nerror: process launch failed\r\nsuccess\r\nLog on attach1', + ), + ]); + final IOSDeployDebugger iosDeployDebugger = IOSDeployDebugger.test( + processManager: processManager, + logger: logger, + ); + final List receivedLogLines = []; + final Stream logLines = iosDeployDebugger.logLines + ..listen(receivedLogLines.add); + + expect(await iosDeployDebugger.launchAndAttach(), isFalse); + await logLines.toList(); + // Debugger lines are double spaced, separated by an extra \r\n. Skip the extra lines. + // Still include empty lines other than the extra added newlines. + expect(receivedLogLines, isEmpty); + }); + + testWithoutContext('no provisioning profile 1, stdout', () async { + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand( + command: ['ios-deploy'], + stdout: 'Error 0xe8008015', + ), + ]); + final IOSDeployDebugger iosDeployDebugger = IOSDeployDebugger.test( + processManager: processManager, + logger: logger, + ); + + await iosDeployDebugger.launchAndAttach(); + expect(logger.errorText, contains('No Provisioning Profile was found')); + }); + + testWithoutContext('no provisioning profile 2, stderr', () async { + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand( + command: ['ios-deploy'], + stderr: 'Error 0xe8000067', + ), + ]); + final IOSDeployDebugger iosDeployDebugger = IOSDeployDebugger.test( + processManager: processManager, + logger: logger, + ); + await iosDeployDebugger.launchAndAttach(); + expect(logger.errorText, contains('No Provisioning Profile was found')); + }); + + testWithoutContext('device locked', () async { + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand( + command: ['ios-deploy'], + stdout: 'e80000e2', + ), + ]); + final IOSDeployDebugger iosDeployDebugger = IOSDeployDebugger.test( + processManager: processManager, + logger: logger, + ); + await iosDeployDebugger.launchAndAttach(); + expect(logger.errorText, contains('Your device is locked.')); + }); + + testWithoutContext('device locked', () async { + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand( + command: ['ios-deploy'], + stdout: 'Error 0xe8000022', + ), + ]); + final IOSDeployDebugger iosDeployDebugger = IOSDeployDebugger.test( + processManager: processManager, + logger: logger, + ); + await iosDeployDebugger.launchAndAttach(); + expect(logger.errorText, contains('Try launching from within Xcode')); + }); + }); + + testWithoutContext('detach', () async { + final StreamController> stdin = StreamController>(); + final Stream stdinStream = stdin.stream.transform(const Utf8Decoder()); + final FakeProcessManager processManager = FakeProcessManager.list([ + FakeCommand( + command: const [ + 'ios-deploy', + ], + stdout: '(lldb) run\nsuccess', + stdin: IOSink(stdin.sink), + ), + ]); + final IOSDeployDebugger iosDeployDebugger = IOSDeployDebugger.test( + processManager: processManager, + ); + await iosDeployDebugger.launchAndAttach(); + iosDeployDebugger.detach(); + expect(await stdinStream.first, 'process detach'); + }); + }); + + group('IOSDeploy.uninstallApp', () { + testWithoutContext('calls ios-deploy with correct arguments and returns 0 on success', () async { + const String deviceId = '123'; + const String bundleId = 'com.example.app'; + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: [ + 'ios-deploy', + '--id', + deviceId, + '--uninstall_only', + '--bundle_id', + bundleId, + ]) + ]); + final IOSDeploy iosDeploy = setUpIOSDeploy(processManager); + final int exitCode = await iosDeploy.uninstallApp( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(exitCode, 0); + expect(processManager.hasRemainingExpectations, false); + }); + + testWithoutContext('returns non-zero exit code when ios-deploy does the same', () async { + const String deviceId = '123'; + const String bundleId = 'com.example.app'; + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: [ + 'ios-deploy', + '--id', + deviceId, + '--uninstall_only', + '--bundle_id', + bundleId, + ], exitCode: 1) + ]); + final IOSDeploy iosDeploy = setUpIOSDeploy(processManager); + final int exitCode = await iosDeploy.uninstallApp( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(exitCode, 1); + expect(processManager.hasRemainingExpectations, false); + }); }); } diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_logger_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_logger_test.dart index bd95e87df0a..1bdfa8ae4d9 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_logger_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_logger_test.dart @@ -10,6 +10,7 @@ import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/convert.dart'; import 'package:flutter_tools/src/device.dart'; import 'package:flutter_tools/src/ios/devices.dart'; +import 'package:flutter_tools/src/ios/ios_deploy.dart'; import 'package:flutter_tools/src/ios/mac.dart'; import 'package:mockito/mockito.dart'; import 'package:vm_service/vm_service.dart'; @@ -30,167 +31,299 @@ void main() { artifacts = MockArtifacts(); logger = BufferLogger.test(); when(artifacts.getArtifactPath(Artifact.idevicesyslog, platform: TargetPlatform.ios)) - .thenReturn('idevice-syslog'); + .thenReturn('idevice-syslog'); }); - testWithoutContext('decodeSyslog decodes a syslog-encoded line', () { - final String decoded = decodeSyslog( - r'I \M-b\M^]\M-$\M-o\M-8\M^O syslog \M-B\M-/\' - r'134_(\M-c\M^C\M^D)_/\M-B\M-/ \M-l\M^F\240!'); + group('syslog stream', () { + testWithoutContext('decodeSyslog decodes a syslog-encoded line', () { + final String decoded = decodeSyslog( + r'I \M-b\M^]\M-$\M-o\M-8\M^O syslog \M-B\M-/\' + r'134_(\M-c\M^C\M^D)_/\M-B\M-/ \M-l\M^F\240!'); - expect(decoded, r'I ❤️ syslog ¯\_(ツ)_/¯ 솠!'); - }); + expect(decoded, r'I ❤️ syslog ¯\_(ツ)_/¯ 솠!'); + }); - testWithoutContext('decodeSyslog passes through un-decodeable lines as-is', () { - final String decoded = decodeSyslog(r'I \M-b\M^O syslog!'); + testWithoutContext('decodeSyslog passes through un-decodeable lines as-is', () { + final String decoded = decodeSyslog(r'I \M-b\M^O syslog!'); - expect(decoded, r'I \M-b\M^O syslog!'); - }); + expect(decoded, r'I \M-b\M^O syslog!'); + }); - testWithoutContext('IOSDeviceLogReader suppresses non-Flutter lines from output with syslog', () async { - processManager.addCommand( - const FakeCommand( - command: [ - 'idevice-syslog', '-u', '1234', - ], - stdout: ''' + testWithoutContext('IOSDeviceLogReader suppresses non-Flutter lines from output with syslog', () async { + processManager.addCommand( + const FakeCommand( + command: [ + 'idevice-syslog', '-u', '1234', + ], + stdout: ''' Runner(Flutter)[297] : A is for ari Runner(libsystem_asl.dylib)[297] : libMobileGestalt MobileGestaltSupport.m:153: pid 123 (Runner) does not have sandbox access for frZQaeyWLUvLjeuEK43hmg and IS NOT appropriately entitled Runner(libsystem_asl.dylib)[297] : libMobileGestalt MobileGestalt.c:550: no access to InverseDeviceID (see ) Runner(Flutter)[297] : I is for ichigo Runner(UIKit)[297] : E is for enpitsu" ''' - ), - ); - final DeviceLogReader logReader = IOSDeviceLogReader.test( - iMobileDevice: IMobileDevice( - artifacts: artifacts, - processManager: processManager, - cache: fakeCache, - logger: logger, - ), - ); - final List lines = await logReader.logLines.toList(); + ), + ); + final DeviceLogReader logReader = IOSDeviceLogReader.test( + iMobileDevice: IMobileDevice( + artifacts: artifacts, + processManager: processManager, + cache: fakeCache, + logger: logger, + ), + ); + final List lines = await logReader.logLines.toList(); - expect(lines, ['A is for ari', 'I is for ichigo']); - }); + expect(lines, ['A is for ari', 'I is for ichigo']); + }); - testWithoutContext('IOSDeviceLogReader includes multi-line Flutter logs in the output with syslog', () async { - processManager.addCommand( - const FakeCommand( - command: [ - 'idevice-syslog', '-u', '1234', - ], - stdout: ''' + testWithoutContext('IOSDeviceLogReader includes multi-line Flutter logs in the output with syslog', () async { + processManager.addCommand( + const FakeCommand( + command: [ + 'idevice-syslog', '-u', '1234', + ], + stdout: ''' Runner(Flutter)[297] : This is a multi-line message, with another Flutter message following it. Runner(Flutter)[297] : This is a multi-line message, with a non-Flutter log message following it. Runner(libsystem_asl.dylib)[297] : libMobileGestalt ''' - ), - ); - final DeviceLogReader logReader = IOSDeviceLogReader.test( - iMobileDevice: IMobileDevice( - artifacts: artifacts, - processManager: processManager, - cache: fakeCache, - logger: logger, - ), - ); - final List lines = await logReader.logLines.toList(); + ), + ); + final DeviceLogReader logReader = IOSDeviceLogReader.test( + iMobileDevice: IMobileDevice( + artifacts: artifacts, + processManager: processManager, + cache: fakeCache, + logger: logger, + ), + ); + final List lines = await logReader.logLines.toList(); - expect(lines, [ - 'This is a multi-line message,', - ' with another Flutter message following it.', - 'This is a multi-line message,', - ' with a non-Flutter log message following it.', - ]); - }); + expect(lines, [ + 'This is a multi-line message,', + ' with another Flutter message following it.', + 'This is a multi-line message,', + ' with a non-Flutter log message following it.', + ]); + }); - testWithoutContext('includes multi-line Flutter logs in the output', () async { - processManager.addCommand( - const FakeCommand( - command: [ - 'idevice-syslog', '-u', '1234', - ], - stdout: ''' + testWithoutContext('includes multi-line Flutter logs in the output', () async { + processManager.addCommand( + const FakeCommand( + command: [ + 'idevice-syslog', '-u', '1234', + ], + stdout: ''' Runner(Flutter)[297] : This is a multi-line message, with another Flutter message following it. Runner(Flutter)[297] : This is a multi-line message, with a non-Flutter log message following it. Runner(libsystem_asl.dylib)[297] : libMobileGestalt ''', - ), - ); + ), + ); - final DeviceLogReader logReader = IOSDeviceLogReader.test( - iMobileDevice: IMobileDevice( - artifacts: artifacts, - processManager: processManager, - cache: fakeCache, - logger: logger, - ), - ); - final List lines = await logReader.logLines.toList(); + final DeviceLogReader logReader = IOSDeviceLogReader.test( + iMobileDevice: IMobileDevice( + artifacts: artifacts, + processManager: processManager, + cache: fakeCache, + logger: logger, + ), + ); + final List lines = await logReader.logLines.toList(); - expect(lines, [ - 'This is a multi-line message,', - ' with another Flutter message following it.', - 'This is a multi-line message,', - ' with a non-Flutter log message following it.', - ]); + expect(lines, [ + 'This is a multi-line message,', + ' with another Flutter message following it.', + 'This is a multi-line message,', + ' with a non-Flutter log message following it.', + ]); + }); }); - testWithoutContext('IOSDeviceLogReader can listen to VM Service logs', () async { - final MockVmService vmService = MockVmService(); - final DeviceLogReader logReader = IOSDeviceLogReader.test( - useSyslog: false, - iMobileDevice: IMobileDevice( - artifacts: artifacts, - processManager: processManager, - cache: fakeCache, - logger: logger, - ), - ); - final StreamController stdoutController = StreamController(); - final StreamController stderController = StreamController(); - final Completer stdoutCompleter = Completer(); - final Completer stderrCompleter = Completer(); - when(vmService.streamListen('Stdout')).thenAnswer((Invocation invocation) { - return stdoutCompleter.future; - }); - when(vmService.streamListen('Stderr')).thenAnswer((Invocation invocation) { - return stderrCompleter.future; - }); - when(vmService.onStdoutEvent).thenAnswer((Invocation invocation) { - return stdoutController.stream; - }); - when(vmService.onStderrEvent).thenAnswer((Invocation invocation) { - return stderController.stream; - }); - logReader.connectedVMService = vmService; + group('VM service', () { + testWithoutContext('IOSDeviceLogReader can listen to VM Service logs', () async { + final MockVmService vmService = MockVmService(); + final DeviceLogReader logReader = IOSDeviceLogReader.test( + useSyslog: false, + iMobileDevice: IMobileDevice( + artifacts: artifacts, + processManager: processManager, + cache: fakeCache, + logger: logger, + ), + ); + final StreamController stdoutController = StreamController(); + final StreamController stderController = StreamController(); + final Completer stdoutCompleter = Completer(); + final Completer stderrCompleter = Completer(); + when(vmService.streamListen('Stdout')).thenAnswer((Invocation invocation) { + return stdoutCompleter.future; + }); + when(vmService.streamListen('Stderr')).thenAnswer((Invocation invocation) { + return stderrCompleter.future; + }); + when(vmService.onStdoutEvent).thenAnswer((Invocation invocation) { + return stdoutController.stream; + }); + when(vmService.onStderrEvent).thenAnswer((Invocation invocation) { + return stderController.stream; + }); + logReader.connectedVMService = vmService; - stdoutCompleter.complete(Success()); - stderrCompleter.complete(Success()); - stdoutController.add(Event( - kind: 'Stdout', - timestamp: 0, - bytes: base64.encode(utf8.encode(' This is a message ')), - )); - stderController.add(Event( - kind: 'Stderr', - timestamp: 0, - bytes: base64.encode(utf8.encode(' And this is an error ')), - )); + stdoutCompleter.complete(Success()); + stderrCompleter.complete(Success()); + stdoutController.add(Event( + kind: 'Stdout', + timestamp: 0, + bytes: base64.encode(utf8.encode(' This is a message ')), + )); + stderController.add(Event( + kind: 'Stderr', + timestamp: 0, + bytes: base64.encode(utf8.encode(' And this is an error ')), + )); - // Wait for stream listeners to fire. - await expectLater(logReader.logLines, emitsInAnyOrder([ - equals(' This is a message '), - equals(' And this is an error '), - ])); + // Wait for stream listeners to fire. + await expectLater(logReader.logLines, emitsInAnyOrder([ + equals(' This is a message '), + equals(' And this is an error '), + ])); + }); + + testWithoutContext('IOSDeviceLogReader ignores VM Service logs when attached to debugger', () async { + final MockVmService vmService = MockVmService(); + final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( + useSyslog: false, + iMobileDevice: IMobileDevice( + artifacts: artifacts, + processManager: processManager, + cache: fakeCache, + logger: logger, + ), + ); + final StreamController stdoutController = StreamController(); + final StreamController stderController = StreamController(); + final Completer stdoutCompleter = Completer(); + final Completer stderrCompleter = Completer(); + when(vmService.streamListen('Stdout')).thenAnswer((Invocation invocation) { + return stdoutCompleter.future; + }); + when(vmService.streamListen('Stderr')).thenAnswer((Invocation invocation) { + return stderrCompleter.future; + }); + when(vmService.onStdoutEvent).thenAnswer((Invocation invocation) { + return stdoutController.stream; + }); + when(vmService.onStderrEvent).thenAnswer((Invocation invocation) { + return stderController.stream; + }); + logReader.connectedVMService = vmService; + + stdoutCompleter.complete(Success()); + stderrCompleter.complete(Success()); + stdoutController.add(Event( + kind: 'Stdout', + timestamp: 0, + bytes: base64.encode(utf8.encode(' This is a message ')), + )); + stderController.add(Event( + kind: 'Stderr', + timestamp: 0, + bytes: base64.encode(utf8.encode(' And this is an error ')), + )); + + final MockIOSDeployDebugger iosDeployDebugger = MockIOSDeployDebugger(); + when(iosDeployDebugger.debuggerAttached).thenReturn(true); + + final Stream debuggingLogs = Stream.fromIterable([ + 'Message from debugger' + ]); + when(iosDeployDebugger.logLines).thenAnswer((Invocation invocation) => debuggingLogs); + logReader.debuggerStream = iosDeployDebugger; + + // Wait for stream listeners to fire. + await expectLater(logReader.logLines, emitsInAnyOrder([ + equals('Message from debugger'), + ])); + }); + }); + + group('debugger stream', () { + testWithoutContext('IOSDeviceLogReader removes metadata prefix from lldb output', () async { + final Stream debuggingLogs = Stream.fromIterable([ + '2020-09-15 19:15:10.931434-0700 Runner[541:226276] Did finish launching.', + '2020-09-15 19:15:10.931434-0700 Runner[541:226276] [Category] Did finish launching from logging category.', + 'stderr from dart', + '', + ]); + + final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( + iMobileDevice: IMobileDevice( + artifacts: artifacts, + processManager: processManager, + cache: fakeCache, + logger: logger, + ), + useSyslog: false, + ); + final MockIOSDeployDebugger iosDeployDebugger = MockIOSDeployDebugger(); + when(iosDeployDebugger.logLines).thenAnswer((Invocation invocation) => debuggingLogs); + logReader.debuggerStream = iosDeployDebugger; + final Future> logLines = logReader.logLines.toList(); + + expect(await logLines, [ + 'Did finish launching.', + '[Category] Did finish launching from logging category.', + 'stderr from dart', + '', + ]); + }); + + testWithoutContext('errors on debugger stream closes log stream', () async { + final Stream debuggingLogs = Stream.error('ios-deploy error'); + final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( + iMobileDevice: IMobileDevice( + artifacts: artifacts, + processManager: processManager, + cache: fakeCache, + logger: logger, + ), + useSyslog: false, + ); + final Completer streamComplete = Completer(); + final MockIOSDeployDebugger iosDeployDebugger = MockIOSDeployDebugger(); + when(iosDeployDebugger.logLines).thenAnswer((Invocation invocation) => debuggingLogs); + logReader.logLines.listen(null, onError: (Object error) => streamComplete.complete()); + logReader.debuggerStream = iosDeployDebugger; + + await streamComplete.future; + }); + + testWithoutContext('detaches debugger', () async { + final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( + iMobileDevice: IMobileDevice( + artifacts: artifacts, + processManager: processManager, + cache: fakeCache, + logger: logger, + ), + useSyslog: false, + ); + final MockIOSDeployDebugger iosDeployDebugger = MockIOSDeployDebugger(); + when(iosDeployDebugger.logLines).thenAnswer((Invocation invocation) => const Stream.empty()); + logReader.debuggerStream = iosDeployDebugger; + + logReader.dispose(); + verify(iosDeployDebugger.detach()); + }); }); } class MockArtifacts extends Mock implements Artifacts {} class MockVmService extends Mock implements VmService {} +class MockIOSDeployDebugger extends Mock implements IOSDeployDebugger {} diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart index 12c87774421..806f7ffc155 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart @@ -67,19 +67,25 @@ const FakeCommand kLaunchReleaseCommand = FakeCommand( // The command used to actually launch the app with args in debug. const FakeCommand kLaunchDebugCommand = FakeCommand(command: [ + 'script', + '-t', + '0', + '/dev/null', 'ios-deploy', '--id', '123', '--bundle', '/', + '--debug', '--no-wifi', - '--justlaunch', '--args', '--enable-dart-profiling --enable-service-port-fallback --disable-service-auth-codes --observatory-port=60700 --enable-checked-mode --verify-entry-points' ], environment: { 'PATH': '/usr/bin:null', 'DYLD_LIBRARY_PATH': '/path/to/libraries', -}); +}, +stdout: '(lldb) run\nsuccess', +); void main() { // TODO(jonahwilliams): This test doesn't really belong here but @@ -102,7 +108,7 @@ void main() { }); // Still uses context for analytics and mDNS. - testUsingContext('IOSDevice.startApp succeeds in debug mode via mDNS discovery', () async { + testUsingContext('IOSDevice.startApp succeeds in debug mode via mDNS discovery when log reading fails', () async { final FileSystem fileSystem = MemoryFileSystem.test(); final FakeProcessManager processManager = FakeProcessManager.list([ kDeployCommand, @@ -145,6 +151,7 @@ void main() { debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug), platformArgs: {}, fallbackPollingDelay: Duration.zero, + fallbackThrottleTimeout: const Duration(milliseconds: 10), ); verify(globals.flutterUsage.sendEvent('ios-handshake', 'mdns-success')).called(1); @@ -157,7 +164,7 @@ void main() { }); // Still uses context for analytics and mDNS. - testUsingContext('IOSDevice.startApp succeeds in debug mode when mDNS fails', () async { + testUsingContext('IOSDevice.startApp succeeds in debug mode via log reading', () async { final FileSystem fileSystem = MemoryFileSystem.test(); final FakeProcessManager processManager = FakeProcessManager.list([ kDeployCommand, @@ -183,34 +190,32 @@ void main() { device.portForwarder = const NoOpDevicePortForwarder(); device.setLogReader(iosApp, deviceLogReader); - // Now that the reader is used, start writing messages to it. + // Start writing messages to the log reader. Timer.run(() { deviceLogReader.addLine('Foo'); deviceLogReader.addLine('Observatory listening on http://127.0.0.1:456'); }); - when(MDnsObservatoryDiscovery.instance.getObservatoryUri(any, any, usesIpv6: anyNamed('usesIpv6'))) - .thenAnswer((Invocation invocation) async => null); final LaunchResult launchResult = await device.startApp(iosApp, prebuiltApplication: true, debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug), platformArgs: {}, fallbackPollingDelay: Duration.zero, + fallbackThrottleTimeout: const Duration(milliseconds: 10), ); expect(launchResult.started, true); expect(launchResult.hasObservatory, true); - verify(globals.flutterUsage.sendEvent('ios-handshake', 'mdns-failure')).called(1); - verify(globals.flutterUsage.sendEvent('ios-handshake', 'fallback-success')).called(1); + verify(globals.flutterUsage.sendEvent('ios-handshake', 'log-success')).called(1); + verifyNever(globals.flutterUsage.sendEvent('ios-handshake', 'mdns-failure')); expect(await device.stopApp(iosApp), false); }, overrides: { - Usage: () => MockUsage(), MDnsObservatoryDiscovery: () => MockMDnsObservatoryDiscovery(), + Usage: () => MockUsage(), }); // Still uses context for analytics and mDNS. - testUsingContext('IOSDevice.startApp fails in debug mode when mDNS fails and ' - 'when Observatory URI is malformed', () async { + testUsingContext('IOSDevice.startApp fails in debug mode when Observatory URI is malformed', () async { final FileSystem fileSystem = MemoryFileSystem.test(); final FakeProcessManager processManager = FakeProcessManager.list([ kDeployCommand, @@ -239,16 +244,15 @@ void main() { // Now that the reader is used, start writing messages to it. Timer.run(() { deviceLogReader.addLine('Foo'); - deviceLogReader.addLine('Observatory listening on http:/:/127.0.0.1:456'); + deviceLogReader.addLine('Observatory listening on http://127.0.0.1:456abc'); }); - when(MDnsObservatoryDiscovery.instance.getObservatoryUri(any, any, usesIpv6: anyNamed('usesIpv6'))) - .thenAnswer((Invocation invocation) async => null); final LaunchResult launchResult = await device.startApp(iosApp, prebuiltApplication: true, debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug), platformArgs: {}, fallbackPollingDelay: Duration.zero, + // fallbackThrottleTimeout: const Duration(milliseconds: 10), ); expect(launchResult.started, false); @@ -259,8 +263,8 @@ void main() { label: anyNamed('label'), value: anyNamed('value'), )).called(1); + verify(globals.flutterUsage.sendEvent('ios-handshake', 'log-failure')).called(1); verify(globals.flutterUsage.sendEvent('ios-handshake', 'mdns-failure')).called(1); - verify(globals.flutterUsage.sendEvent('ios-handshake', 'fallback-failure')).called(1); }, overrides: { MDnsObservatoryDiscovery: () => MockMDnsObservatoryDiscovery(), Usage: () => MockUsage(), @@ -388,6 +392,7 @@ void main() { debuggingOptions: DebuggingOptions.disabled(BuildInfo.release), platformArgs: {}, fallbackPollingDelay: Duration.zero, + fallbackThrottleTimeout: const Duration(milliseconds: 10), ); expect(launchResult.started, true); @@ -405,13 +410,17 @@ void main() { kDeployCommand, FakeCommand( command: [ + 'script', + '-t', + '0', + '/dev/null', 'ios-deploy', '--id', '123', '--bundle', '/', + '--debug', '--no-wifi', - '--justlaunch', // The arguments below are determined by what is passed into // the debugging options argument to startApp. '--args', @@ -436,7 +445,8 @@ void main() { ], environment: const { 'PATH': '/usr/bin:null', 'DYLD_LIBRARY_PATH': '/path/to/libraries', - } + }, + stdout: '(lldb) run\nsuccess', ) ]); final IOSDevice device = setUpIOSDevice( @@ -455,22 +465,15 @@ void main() { bundleName: 'Runner', bundleDir: fileSystem.currentDirectory, ); - final Uri uri = Uri( - scheme: 'http', - host: '127.0.0.1', - port: 1234, - path: 'observatory', - ); + final FakeDeviceLogReader deviceLogReader = FakeDeviceLogReader(); - device.setLogReader(iosApp, FakeDeviceLogReader()); device.portForwarder = const NoOpDevicePortForwarder(); + device.setLogReader(iosApp, deviceLogReader); - when(MDnsObservatoryDiscovery.instance.getObservatoryUri( - any, - any, - usesIpv6: anyNamed('usesIpv6'), - hostVmservicePort: anyNamed('hostVmservicePort') - )).thenAnswer((Invocation invocation) async => uri); + // Start writing messages to the log reader. + Timer.run(() { + deviceLogReader.addLine('Observatory listening on http://127.0.0.1:1234'); + }); final LaunchResult launchResult = await device.startApp(iosApp, prebuiltApplication: true, @@ -492,6 +495,7 @@ void main() { ), platformArgs: {}, fallbackPollingDelay: Duration.zero, + fallbackThrottleTimeout: const Duration(milliseconds: 10), ); expect(launchResult.started, true);