| // Copyright 2018 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import 'dart:async'; |
| import 'dart:convert'; |
| |
| import 'package:file/file.dart'; |
| import 'package:flutter_tools/src/base/file_system.dart'; |
| import 'package:flutter_tools/src/base/io.dart'; |
| import 'package:process/process.dart'; |
| import 'package:source_span/source_span.dart'; |
| import 'package:stream_channel/stream_channel.dart'; |
| import 'package:vm_service_client/vm_service_client.dart'; |
| import 'package:web_socket_channel/io.dart'; |
| |
| import '../src/common.dart'; |
| |
| // Set this to true for debugging to get JSON written to stdout. |
| const bool _printJsonAndStderr = false; |
| const Duration defaultTimeout = Duration(seconds: 40); |
| const Duration appStartTimeout = Duration(seconds: 120); |
| const Duration quitTimeout = Duration(seconds: 10); |
| |
| class FlutterTestDriver { |
| FlutterTestDriver(this._projectFolder, {String logPrefix}): |
| _logPrefix = logPrefix != null ? '$logPrefix: ' : ''; |
| |
| final Directory _projectFolder; |
| final String _logPrefix; |
| Process _proc; |
| int _procPid; |
| final StreamController<String> _stdout = StreamController<String>.broadcast(); |
| final StreamController<String> _stderr = StreamController<String>.broadcast(); |
| final StreamController<String> _allMessages = StreamController<String>.broadcast(); |
| final StringBuffer _errorBuffer = StringBuffer(); |
| String _lastResponse; |
| String _currentRunningAppId; |
| Uri _vmServiceWsUri; |
| int _vmServicePort; |
| bool _hasExited = false; |
| |
| VMServiceClient vmService; |
| String get lastErrorInfo => _errorBuffer.toString(); |
| int get vmServicePort => _vmServicePort; |
| bool get hasExited => _hasExited; |
| |
| String _debugPrint(String msg) { |
| const int maxLength = 500; |
| final String truncatedMsg = |
| msg.length > maxLength ? msg.substring(0, maxLength) + '...' : msg; |
| _allMessages.add(truncatedMsg); |
| if (_printJsonAndStderr) { |
| print('$_logPrefix$truncatedMsg'); |
| } |
| return msg; |
| } |
| |
| Future<void> run({ |
| bool withDebugger = false, |
| bool pauseOnExceptions = false, |
| File pidFile, |
| }) async { |
| await _setupProcess(<String>[ |
| 'run', |
| '--machine', |
| '-d', |
| 'flutter-tester', |
| ], withDebugger: withDebugger, pauseOnExceptions: pauseOnExceptions, pidFile: pidFile); |
| } |
| |
| Future<void> attach( |
| int port, { |
| bool withDebugger = false, |
| bool pauseOnExceptions = false, |
| File pidFile, |
| }) async { |
| await _setupProcess(<String>[ |
| 'attach', |
| '--machine', |
| '-d', |
| 'flutter-tester', |
| '--debug-port', |
| '$port', |
| ], withDebugger: withDebugger, pauseOnExceptions: pauseOnExceptions, pidFile: pidFile); |
| } |
| |
| Future<void> _setupProcess( |
| List<String> args, { |
| bool withDebugger = false, |
| bool pauseOnExceptions = false, |
| File pidFile, |
| }) async { |
| final String flutterBin = fs.path.join(getFlutterRoot(), 'bin', 'flutter'); |
| if (withDebugger) { |
| args.add('--start-paused'); |
| } |
| if (pidFile != null) { |
| args.addAll(<String>['--pid-file', pidFile.path]); |
| } |
| _debugPrint('Spawning flutter $args in ${_projectFolder.path}'); |
| |
| const ProcessManager _processManager = LocalProcessManager(); |
| _proc = await _processManager.start( |
| <String>[flutterBin] |
| .followedBy(args) |
| .toList(), |
| workingDirectory: _projectFolder.path, |
| environment: <String, String>{'FLUTTER_TEST': 'true'}); |
| |
| // This class doesn't use the result of the future. It's made available |
| // via a getter for external uses. |
| _proc.exitCode.then((int code) { // ignore: unawaited_futures |
| _debugPrint('Process exited ($code)'); |
| _hasExited = true; |
| }); |
| _transformToLines(_proc.stdout).listen((String line) => _stdout.add(line)); |
| _transformToLines(_proc.stderr).listen((String line) => _stderr.add(line)); |
| |
| // Capture stderr to a buffer so we can show it all if any requests fail. |
| _stderr.stream.listen(_errorBuffer.writeln); |
| |
| // This is just debug printing to aid running/debugging tests locally. |
| _stdout.stream.listen(_debugPrint); |
| _stderr.stream.listen(_debugPrint); |
| |
| // Stash the PID so that we can terminate the VM more reliably than using |
| // _proc.kill() (because _proc is a shell, because `flutter` is a shell |
| // script). |
| final Map<String, dynamic> connected = await _waitFor(event: 'daemon.connected'); |
| _procPid = connected['params']['pid']; |
| |
| // Set this up now, but we don't wait it yet. We want to make sure we don't |
| // miss it while waiting for debugPort below. |
| final Future<Map<String, dynamic>> started = _waitFor(event: 'app.started', |
| timeout: appStartTimeout); |
| |
| if (withDebugger) { |
| final Map<String, dynamic> debugPort = await _waitFor(event: 'app.debugPort', |
| timeout: appStartTimeout); |
| final String wsUriString = debugPort['params']['wsUri']; |
| _vmServiceWsUri = Uri.parse(wsUriString); |
| _vmServicePort = debugPort['params']['port']; |
| // Proxy the stream/sink for the VM Client so we can debugPrint it. |
| final StreamChannel<String> channel = IOWebSocketChannel.connect(_vmServiceWsUri) |
| .cast<String>() |
| .changeStream((Stream<String> stream) => stream.map<String>(_debugPrint)) |
| .changeSink((StreamSink<String> sink) => |
| StreamController<String>() |
| ..stream.listen((String s) => sink.add(_debugPrint(s)))); |
| vmService = VMServiceClient(channel); |
| |
| // Because we start paused, resume so the app is in a "running" state as |
| // expected by tests. Tests will reload/restart as required if they need |
| // to hit breakpoints, etc. |
| await waitForPause(); |
| if (pauseOnExceptions) { |
| await (await getFlutterIsolate()).setExceptionPauseMode(VMExceptionPauseMode.unhandled); |
| } |
| await resume(wait: false); |
| } |
| |
| // Now await the started event; if it had already happened the future will |
| // have already completed. |
| _currentRunningAppId = (await started)['params']['appId']; |
| } |
| |
| Future<void> hotRestart({bool pause = false}) => _restart(fullRestart: true, pause: pause); |
| Future<void> hotReload() => _restart(fullRestart: false); |
| |
| Future<void> _restart({bool fullRestart = false, bool pause = false}) async { |
| if (_currentRunningAppId == null) |
| throw Exception('App has not started yet'); |
| |
| final dynamic hotReloadResp = await _sendRequest( |
| 'app.restart', |
| <String, dynamic>{'appId': _currentRunningAppId, 'fullRestart': fullRestart, 'pause': pause} |
| ); |
| |
| if (hotReloadResp == null || hotReloadResp['code'] != 0) |
| _throwErrorResponse('Hot ${fullRestart ? 'restart' : 'reload'} request failed'); |
| } |
| |
| Future<int> detach() async { |
| if (vmService != null) { |
| _debugPrint('Closing VM service'); |
| await vmService.close() |
| .timeout(quitTimeout, |
| onTimeout: () { _debugPrint('VM Service did not quit within $quitTimeout'); }); |
| } |
| if (_currentRunningAppId != null) { |
| _debugPrint('Detaching from app'); |
| await Future.any<void>(<Future<void>>[ |
| _proc.exitCode, |
| _sendRequest( |
| 'app.detach', |
| <String, dynamic>{'appId': _currentRunningAppId} |
| ), |
| ]).timeout( |
| quitTimeout, |
| onTimeout: () { _debugPrint('app.detach did not return within $quitTimeout'); } |
| ); |
| _currentRunningAppId = null; |
| } |
| _debugPrint('Waiting for process to end'); |
| return _proc.exitCode.timeout(quitTimeout, onTimeout: _killGracefully); |
| } |
| |
| Future<int> stop() async { |
| if (vmService != null) { |
| _debugPrint('Closing VM service'); |
| await vmService.close() |
| .timeout(quitTimeout, |
| onTimeout: () { _debugPrint('VM Service did not quit within $quitTimeout'); }); |
| } |
| if (_currentRunningAppId != null) { |
| _debugPrint('Stopping app'); |
| await Future.any<void>(<Future<void>>[ |
| _proc.exitCode, |
| _sendRequest( |
| 'app.stop', |
| <String, dynamic>{'appId': _currentRunningAppId} |
| ), |
| ]).timeout( |
| quitTimeout, |
| onTimeout: () { _debugPrint('app.stop did not return within $quitTimeout'); } |
| ); |
| _currentRunningAppId = null; |
| } |
| _debugPrint('Waiting for process to end'); |
| return _proc.exitCode.timeout(quitTimeout, onTimeout: _killGracefully); |
| } |
| |
| Future<int> quit() => _killGracefully(); |
| |
| Future<int> _killGracefully() async { |
| if (_procPid == null) |
| return -1; |
| _debugPrint('Sending SIGTERM to $_procPid..'); |
| Process.killPid(_procPid); |
| return _proc.exitCode.timeout(quitTimeout, onTimeout: _killForcefully); |
| } |
| |
| Future<int> _killForcefully() { |
| _debugPrint('Sending SIGKILL to $_procPid..'); |
| Process.killPid(_procPid, ProcessSignal.SIGKILL); |
| return _proc.exitCode; |
| } |
| |
| Future<VMIsolate> getFlutterIsolate() async { |
| // Currently these tests only have a single isolate. If this |
| // ceases to be the case, this code will need changing. |
| final VM vm = await vmService.getVM(); |
| return await vm.isolates.single.load(); |
| } |
| |
| Future<void> addBreakpoint(Uri uri, int line) async { |
| final VMIsolate isolate = await getFlutterIsolate(); |
| _debugPrint('Sending breakpoint for $uri:$line'); |
| await isolate.addBreakpoint(uri, line); |
| } |
| |
| Future<VMIsolate> waitForPause() async { |
| final VM vm = await vmService.getVM(); |
| final VMIsolate isolate = await vm.isolates.first.load(); |
| _debugPrint('Waiting for isolate to pause'); |
| await _timeoutWithMessages<dynamic>(isolate.waitUntilPaused, |
| message: 'Isolate did not pause'); |
| return isolate.load(); |
| } |
| |
| Future<VMIsolate> resume({ bool wait = true }) => _resume(wait: wait); |
| Future<VMIsolate> stepOver({ bool wait = true }) => _resume(step: VMStep.over, wait: wait); |
| Future<VMIsolate> stepInto({ bool wait = true }) => _resume(step: VMStep.into, wait: wait); |
| Future<VMIsolate> stepOut({ bool wait = true }) => _resume(step: VMStep.out, wait: wait); |
| |
| Future<VMIsolate> _resume({VMStep step, bool wait = true}) async { |
| final VM vm = await vmService.getVM(); |
| final VMIsolate isolate = await vm.isolates.first.load(); |
| _debugPrint('Sending resume ($step)'); |
| await _timeoutWithMessages<dynamic>(() => isolate.resume(step: step), |
| message: 'Isolate did not respond to resume ($step)'); |
| return wait ? waitForPause() : null; |
| } |
| |
| Future<VMIsolate> breakAt(Uri uri, int line, { bool restart = false }) async { |
| if (restart) { |
| // For a hot restart, we need to send the breakpoints after the restart |
| // so we need to pause during the restart to avoid races. |
| await hotRestart(pause: true); |
| await addBreakpoint(uri, line); |
| return resume(); |
| } else { |
| await addBreakpoint(uri, line); |
| await hotReload(); |
| return waitForPause(); |
| } |
| } |
| |
| Future<VMInstanceRef> evaluateExpression(String expression) async { |
| final VMFrame topFrame = await getTopStackFrame(); |
| return _timeoutWithMessages<VMInstanceRef>(() => topFrame.evaluate(expression), |
| message: 'Timed out evaluating expression ($expression)'); |
| } |
| |
| Future<VMFrame> getTopStackFrame() async { |
| final VM vm = await vmService.getVM(); |
| final VMIsolate isolate = await vm.isolates.first.load(); |
| final VMStack stack = await isolate.getStack(); |
| if (stack.frames.isEmpty) { |
| throw Exception('Stack is empty'); |
| } |
| return stack.frames.first; |
| } |
| |
| Future<FileLocation> getSourceLocation() async { |
| final VMFrame frame = await getTopStackFrame(); |
| final VMScript script = await frame.location.script.load(); |
| return script.sourceLocation(frame.location.token); |
| } |
| |
| Future<Map<String, dynamic>> _waitFor({ |
| String event, |
| int id, |
| Duration timeout, |
| bool ignoreAppStopEvent = false, |
| }) async { |
| final Completer<Map<String, dynamic>> response = Completer<Map<String, dynamic>>(); |
| StreamSubscription<String> sub; |
| sub = _stdout.stream.listen((String line) async { |
| final dynamic json = _parseFlutterResponse(line); |
| if (json == null) { |
| return; |
| } else if ( |
| (event != null && json['event'] == event) |
| || (id != null && json['id'] == id)) { |
| await sub.cancel(); |
| response.complete(json); |
| } else if (!ignoreAppStopEvent && json['event'] == 'app.stop') { |
| await sub.cancel(); |
| final StringBuffer error = StringBuffer(); |
| error.write('Received app.stop event while waiting for '); |
| error.write('${event != null ? '$event event' : 'response to request $id.'}.\n\n'); |
| if (json['params'] != null && json['params']['error'] != null) { |
| error.write('${json['params']['error']}\n\n'); |
| } |
| if (json['params'] != null && json['params']['trace'] != null) { |
| error.write('${json['params']['trace']}\n\n'); |
| } |
| response.completeError(error.toString()); |
| } |
| }); |
| |
| return _timeoutWithMessages<Map<String, dynamic>>(() => response.future, |
| timeout: timeout, |
| message: event != null |
| ? 'Did not receive expected $event event.' |
| : 'Did not receive response to request "$id".') |
| .whenComplete(() => sub.cancel()); |
| } |
| |
| Future<T> _timeoutWithMessages<T>(Future<T> Function() f, {Duration timeout, String message}) { |
| // Capture output to a buffer so if we don't get the response we want we can show |
| // the output that did arrive in the timeout error. |
| final StringBuffer messages = StringBuffer(); |
| final DateTime start = DateTime.now(); |
| void logMessage(String m) { |
| final int ms = DateTime.now().difference(start).inMilliseconds; |
| messages.writeln('[+ ${ms.toString().padLeft(5)}] $m'); |
| } |
| final StreamSubscription<String> sub = _allMessages.stream.listen(logMessage); |
| |
| return f().timeout(timeout ?? defaultTimeout, onTimeout: () { |
| logMessage('<timed out>'); |
| throw '$message'; |
| }).catchError((dynamic error) { |
| throw '$error\nReceived:\n${messages.toString()}'; |
| }).whenComplete(() => sub.cancel()); |
| } |
| |
| Map<String, dynamic> _parseFlutterResponse(String line) { |
| if (line.startsWith('[') && line.endsWith(']')) { |
| try { |
| final Map<String, dynamic> resp = json.decode(line)[0]; |
| _lastResponse = line; |
| return resp; |
| } catch (e) { |
| // Not valid JSON, so likely some other output that was surrounded by [brackets] |
| return null; |
| } |
| } |
| return null; |
| } |
| |
| int id = 1; |
| Future<dynamic> _sendRequest(String method, dynamic params) async { |
| final int requestId = id++; |
| final Map<String, dynamic> request = <String, dynamic>{ |
| 'id': requestId, |
| 'method': method, |
| 'params': params |
| }; |
| final String jsonEncoded = json.encode(<Map<String, dynamic>>[request]); |
| _debugPrint(jsonEncoded); |
| |
| // Set up the response future before we send the request to avoid any |
| // races. If the method we're calling is app.stop then we tell waitFor not |
| // to throw if it sees an app.stop event before the response to this request. |
| final Future<Map<String, dynamic>> responseFuture = _waitFor( |
| id: requestId, |
| ignoreAppStopEvent: method == 'app.stop', |
| ); |
| _proc.stdin.writeln(jsonEncoded); |
| final Map<String, dynamic> response = await responseFuture; |
| |
| if (response['error'] != null || response['result'] == null) |
| _throwErrorResponse('Unexpected error response'); |
| |
| return response['result']; |
| } |
| |
| void _throwErrorResponse(String msg) { |
| throw '$msg\n\n$_lastResponse\n\n${_errorBuffer.toString()}'.trim(); |
| } |
| } |
| |
| Stream<String> _transformToLines(Stream<List<int>> byteStream) { |
| return byteStream.transform<String>(utf8.decoder).transform<String>(const LineSplitter()); |
| } |