Stream logging from attached debugger on iOS (#66092) (#66390)

This commit is contained in:
Jenn Magder 2020-09-22 14:59:14 -07:00 committed by GitHub
parent de85509171
commit 2be4570d3a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 842 additions and 249 deletions

View file

@ -212,6 +212,9 @@ class IOSDevice extends Device {
DevicePortForwarder _portForwarder;
@visibleForTesting
IOSDeployDebugger iosDeployDebugger;
@override
Future<bool> 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');
observatoryDiscovery = ProtocolDiscovery.observatory(
getLogReader(app: package),
portForwarder: portForwarder,
hostPort: debuggingOptions.hostVmServicePort,
devicePort: debuggingOptions.deviceVmServicePort,
ipv6: ipv6,
throttleTimeout: fallbackThrottleTimeout ?? const Duration(seconds: 1),
);
}
final int installationResult = await _iosDeploy.runApp(
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(
deviceLogReader,
portForwarder: portForwarder,
throttleDuration: fallbackPollingDelay,
throttleTimeout: fallbackThrottleTimeout ?? const Duration(seconds: 5),
hostPort: debuggingOptions.hostVmServicePort,
devicePort: debuggingOptions.deviceVmServicePort,
ipv6: ipv6,
);
installationResult = await iosDeployDebugger.launchAndAttach() ? 0 : 1;
} else {
installationResult = await _iosDeploy.launchApp(
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<String> _linesController;
List<StreamSubscription<void>> _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();
}
}

View file

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

View file

@ -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<String> launchArguments,
@required IOSDeviceInterface interfaceType,
}) {
// Interactive debug session to support sending the lldb detach command.
final List<String> launchCommand = <String>[
'script',
'-t',
'0',
'/dev/null',
_binaryPath,
'--id',
deviceId,
'--bundle',
bundlePath,
'--debug',
if (interfaceType != IOSDeviceInterface.network)
'--no-wifi',
if (launchArguments.isNotEmpty) ...<String>[
'--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<int> runApp({
Future<int> launchApp({
@required String deviceId,
@required String bundlePath,
@required List<String> launchArguments,
@ -169,21 +211,194 @@ class IOSDeploy {
return true;
}
// Maps stdout line stream. Must return original line.
String _monitorFailure(String stdout) {
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<String> launchCommand,
@required Map<String, String> 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: <String>['ios-deploy'],
iosDeployEnv: <String, String>{},
);
}
final Logger _logger;
final ProcessUtils _processUtils;
final List<String> _launchCommand;
final Map<String, String> _iosDeployEnv;
Process _iosDeployProcess;
Stream<String> get logLines => _debuggerOutput.stream;
final StreamController<String> _debuggerOutput = StreamController<String>.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<bool> launchAndAttach() async {
// Return when the debugger attaches, or the ios-deploy process exits.
final Completer<bool> debuggerCompleter = Completer<bool>();
try {
_iosDeployProcess = await _processUtils.start(
_launchCommand,
environment: _iosDeployEnv,
);
String lastLineFromDebugger;
final StreamSubscription<String> stdoutSubscription = _iosDeployProcess.stdout
.transform<String>(utf8.decoder)
.transform<String>(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<String> stderrSubscription = _iosDeployProcess.stderr
.transform<String>(utf8.decoder)
.transform<String>(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);
logger.printError(noProvisioningProfileInstruction, emphasis: true);
// Launch issues.
} else if (stdout.contains(deviceLockedError)) {
_logger.printError('''
logger.printError('''
Your device is locked. Unlock your device first before running.
''',
emphasis: true);
} else if (stdout.contains(unknownAppLaunchError)) {
_logger.printError('''
logger.printError('''
Error launching app. Try launching from within Xcode via:
open ios/Runner.xcworkspace
@ -194,5 +409,4 @@ Your Xcode version may be too old for your iOS version.
}
return stdout;
}
}

View file

@ -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<T> {
///
/// 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<S, S> _throttle<S>({
@required Duration waitDuration,
}) {
@ -240,10 +240,13 @@ StreamTransformer<S, S> _throttle<S>({
handleData: (S value, EventSink<S> 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;

View file

@ -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,7 +26,186 @@ void main () {
expect(environment['PATH'], startsWith('/usr/bin'));
});
testWithoutContext('IOSDeploy.uninstallApp calls ios-deploy with correct arguments and returns 0 on success', () async {
group('IOSDeploy.prepareDebuggerForLaunch', () {
testWithoutContext('calls ios-deploy with correct arguments and returns when debugger attaches', () async {
final FakeProcessManager processManager = FakeProcessManager.list(<FakeCommand>[
FakeCommand(
command: <String>[
'script',
'-t',
'0',
'/dev/null',
'ios-deploy',
'--id',
'123',
'--bundle',
'/',
'--debug',
'--args',
<String>[
'--enable-dart-profiling',
].join(' '),
], environment: const <String, String>{
'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: <String>['--enable-dart-profiling'],
interfaceType: IOSDeviceInterface.network,
);
expect(await iosDeployDebugger.launchAndAttach(), isTrue);
expect(await iosDeployDebugger.logLines.toList(), <String>['Did finish launching.']);
expect(processManager.hasRemainingExpectations, false);
});
});
group('IOSDeployDebugger', () {
group('launch', () {
BufferLogger logger;
setUp(() {
logger = BufferLogger.test();
});
testWithoutContext('debugger attached', () async {
final FakeProcessManager processManager = FakeProcessManager.list(<FakeCommand>[
const FakeCommand(
command: <String>['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<String> receivedLogLines = <String>[];
final Stream<String> logLines = iosDeployDebugger.logLines
..listen(receivedLogLines.add);
expect(await iosDeployDebugger.launchAndAttach(), isTrue);
await logLines.toList();
expect(receivedLogLines, <String>[
'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(<FakeCommand>[
const FakeCommand(
command: <String>['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<String> receivedLogLines = <String>[];
final Stream<String> 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(<FakeCommand>[
const FakeCommand(
command: <String>['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(<FakeCommand>[
const FakeCommand(
command: <String>['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(<FakeCommand>[
const FakeCommand(
command: <String>['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(<FakeCommand>[
const FakeCommand(
command: <String>['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<List<int>> stdin = StreamController<List<int>>();
final Stream<String> stdinStream = stdin.stream.transform<String>(const Utf8Decoder());
final FakeProcessManager processManager = FakeProcessManager.list(<FakeCommand>[
FakeCommand(
command: const <String>[
'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(<FakeCommand>[
@ -44,7 +228,7 @@ void main () {
expect(processManager.hasRemainingExpectations, false);
});
testWithoutContext('IOSDeploy.uninstallApp returns non-zero exit code when ios-deploy does the same', () async {
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(<FakeCommand>[
@ -66,6 +250,7 @@ void main () {
expect(exitCode, 1);
expect(processManager.hasRemainingExpectations, false);
});
});
}
IOSDeploy setUpIOSDeploy(ProcessManager processManager) {

View file

@ -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';
@ -33,6 +34,7 @@ void main() {
.thenReturn('idevice-syslog');
});
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-/\'
@ -141,7 +143,9 @@ Runner(libsystem_asl.dylib)[297] <Notice>: libMobileGestalt
' with a non-Flutter log message following it.',
]);
});
});
group('VM service', () {
testWithoutContext('IOSDeviceLogReader can listen to VM Service logs', () async {
final MockVmService vmService = MockVmService();
final DeviceLogReader logReader = IOSDeviceLogReader.test(
@ -190,7 +194,136 @@ Runner(libsystem_asl.dylib)[297] <Notice>: libMobileGestalt
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<Event> stdoutController = StreamController<Event>();
final StreamController<Event> stderController = StreamController<Event>();
final Completer<Success> stdoutCompleter = Completer<Success>();
final Completer<Success> stderrCompleter = Completer<Success>();
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<String> debuggingLogs = Stream<String>.fromIterable(<String>[
'Message from debugger'
]);
when(iosDeployDebugger.logLines).thenAnswer((Invocation invocation) => debuggingLogs);
logReader.debuggerStream = iosDeployDebugger;
// Wait for stream listeners to fire.
await expectLater(logReader.logLines, emitsInAnyOrder(<Matcher>[
equals('Message from debugger'),
]));
});
});
group('debugger stream', () {
testWithoutContext('IOSDeviceLogReader removes metadata prefix from lldb output', () async {
final Stream<String> debuggingLogs = Stream<String>.fromIterable(<String>[
'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<List<String>> logLines = logReader.logLines.toList();
expect(await logLines, <String>[
'Did finish launching.',
'[Category] Did finish launching from logging category.',
'stderr from dart',
'',
]);
});
testWithoutContext('errors on debugger stream closes log stream', () async {
final Stream<String> debuggingLogs = Stream<String>.error('ios-deploy error');
final IOSDeviceLogReader logReader = IOSDeviceLogReader.test(
iMobileDevice: IMobileDevice(
artifacts: artifacts,
processManager: processManager,
cache: fakeCache,
logger: logger,
),
useSyslog: false,
);
final Completer<void> streamComplete = Completer<void>();
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<String>.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 {}

View file

@ -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: <String>[
'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: <String, String>{
'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(<FakeCommand>[
kDeployCommand,
@ -145,6 +151,7 @@ void main() {
debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug),
platformArgs: <String, dynamic>{},
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(<FakeCommand>[
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: <String, dynamic>{},
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: <Type, Generator>{
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(<FakeCommand>[
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: <String, dynamic>{},
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: <Type, Generator>{
MDnsObservatoryDiscovery: () => MockMDnsObservatoryDiscovery(),
Usage: () => MockUsage(),
@ -388,6 +392,7 @@ void main() {
debuggingOptions: DebuggingOptions.disabled(BuildInfo.release),
platformArgs: <String, dynamic>{},
fallbackPollingDelay: Duration.zero,
fallbackThrottleTimeout: const Duration(milliseconds: 10),
);
expect(launchResult.started, true);
@ -405,13 +410,17 @@ void main() {
kDeployCommand,
FakeCommand(
command: <String>[
'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 <String, String>{
'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: <String, dynamic>{},
fallbackPollingDelay: Duration.zero,
fallbackThrottleTimeout: const Duration(milliseconds: 10),
);
expect(launchResult.started, true);