// 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: 20); const Duration appStartTimeout = Duration(seconds: 60); const Duration quitTimeout = Duration(seconds: 5); class FlutterTestDriver { final Directory _projectFolder; Process _proc; int _procPid; final StreamController _stdout = new StreamController.broadcast(); final StreamController _stderr = new StreamController.broadcast(); final StreamController _allMessages = new StreamController.broadcast(); final StringBuffer _errorBuffer = new StringBuffer(); String _lastResponse; String _currentRunningAppId; Uri _vmServiceWsUri; int _vmServicePort; FlutterTestDriver(this._projectFolder); VMServiceClient vmService; String get lastErrorInfo => _errorBuffer.toString(); int get vmServicePort => _vmServicePort; 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(truncatedMsg); } return msg; } // TODO(dantup): Is there a better way than spawning a proc? This breaks debugging.. // However, there's a lot of logic inside RunCommand that wouldn't be good // to duplicate here. Future run({bool withDebugger = false}) async { await _setupProcess([ 'run', '--machine', '-d', 'flutter-tester', ], withDebugger: withDebugger); } Future attach(int port, {bool withDebugger = false}) async { await _setupProcess([ 'attach', '--machine', '-d', 'flutter-tester', '--debug-port', '$port', ], withDebugger: withDebugger); } Future _setupProcess(List args, {bool withDebugger = false}) async { final String flutterBin = fs.path.join(getFlutterRoot(), 'bin', 'flutter'); _debugPrint('Spawning flutter $args in ${_projectFolder.path}'); const ProcessManager _processManager = LocalProcessManager(); _proc = await _processManager.start( [flutterBin] .followedBy(args) .followedBy(withDebugger ? ['--start-paused'] : []) .toList(), workingDirectory: _projectFolder.path, environment: {'FLUTTER_TEST': '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 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> started = _waitFor(event: 'app.started', timeout: appStartTimeout); if (withDebugger) { final Map 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 channel = new IOWebSocketChannel.connect(_vmServiceWsUri) .cast() .changeStream((Stream stream) => stream.map(_debugPrint)) .changeSink((StreamSink sink) => new StreamController() ..stream.listen((String s) => sink.add(_debugPrint(s)))); vmService = new 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(); 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 hotRestart({bool pause = false}) => _restart(fullRestart: true, pause: pause); Future hotReload() => _restart(fullRestart: false); Future _restart({bool fullRestart = false, bool pause = false}) async { if (_currentRunningAppId == null) throw new Exception('App has not started yet'); final dynamic hotReloadResp = await _sendRequest( 'app.restart', {'appId': _currentRunningAppId, 'fullRestart': fullRestart, 'pause': pause} ); if (hotReloadResp == null || hotReloadResp['code'] != 0) _throwErrorResponse('Hot ${fullRestart ? 'restart' : 'reload'} request failed'); } Future 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 _sendRequest( 'app.stop', {'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 _killGracefully() async { if (_procPid == null) return -1; _debugPrint('Sending SIGTERM to $_procPid..'); Process.killPid(_procPid); return _proc.exitCode.timeout(quitTimeout, onTimeout: _killForcefully); } Future _killForcefully() { _debugPrint('Sending SIGKILL to $_procPid..'); Process.killPid(_procPid, ProcessSignal.SIGKILL); return _proc.exitCode; } Future addBreakpoint(String path, int line) async { final VM vm = await vmService.getVM(); final VMIsolate isolate = await vm.isolates.first.load(); _debugPrint('Sending breakpoint for $path:$line'); await isolate.addBreakpoint(path, line); } Future waitForPause() async { final VM vm = await vmService.getVM(); final VMIsolate isolate = await vm.isolates.first.load(); _debugPrint('Waiting for isolate to pause'); await _timeoutWithMessages(isolate.waitUntilPaused, message: 'Isolate did not pause'); return isolate.load(); } Future resume({ bool wait = true }) => _resume(wait: wait); Future stepOver({ bool wait = true }) => _resume(step: VMStep.over, wait: wait); Future stepInto({ bool wait = true }) => _resume(step: VMStep.into, wait: wait); Future stepOut({ bool wait = true }) => _resume(step: VMStep.out, wait: wait); Future _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(() => isolate.resume(step: step), message: 'Isolate did not respond to resume ($step)'); return wait ? waitForPause() : null; } Future breakAt(String path, 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(path, line); return resume(); } else { await addBreakpoint(path, line); await hotReload(); return waitForPause(); } } Future evaluateExpression(String expression) async { final VMFrame topFrame = await getTopStackFrame(); return _timeoutWithMessages(() => topFrame.evaluate(expression), message: 'Timed out evaluating expression ($expression)'); } Future 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 new Exception('Stack is empty'); } return stack.frames.first; } Future getSourceLocation() async { final VMFrame frame = await getTopStackFrame(); final VMScript script = await frame.location.script.load(); return script.sourceLocation(frame.location.token); } Future> _waitFor({String event, int id, Duration timeout}) async { final Completer> response = new Completer>(); final StreamSubscription sub = _stdout.stream.listen((String line) { final dynamic json = _parseFlutterResponse(line); if (json == null) { return; } else if ( (event != null && json['event'] == event) || (id != null && json['id'] == id)) { response.complete(json); } }); return _timeoutWithMessages(() => response.future, timeout: timeout, message: event != null ? 'Did not receive expected $event event.' : 'Did not receive response to request "$id".') .whenComplete(() => sub.cancel()); } Future _timeoutWithMessages(Future 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 = new StringBuffer(); final DateTime start = new DateTime.now(); void logMessage(String m) { final int ms = new DateTime.now().difference(start).inMilliseconds; messages.writeln('[+ ${ms.toString().padLeft(5)}] $m'); } final StreamSubscription sub = _allMessages.stream.listen(logMessage); return f().timeout(timeout ?? defaultTimeout, onTimeout: () { logMessage(''); throw '$message\nReceived:\n${messages.toString()}'; }).whenComplete(() => sub.cancel()); } Map _parseFlutterResponse(String line) { if (line.startsWith('[') && line.endsWith(']')) { try { final Map 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 _sendRequest(String method, dynamic params) async { final int requestId = id++; final Map req = { 'id': requestId, 'method': method, 'params': params }; final String jsonEncoded = json.encode(>[req]); _debugPrint(jsonEncoded); // Set up the response future before we send the request to avoid any // races. final Future> responseFuture = _waitFor(id: requestId); _proc.stdin.writeln(jsonEncoded); final Map resp = await responseFuture; if (resp['error'] != null || resp['result'] == null) _throwErrorResponse('Unexpected error response'); return resp['result']; } void _throwErrorResponse(String msg) { throw '$msg\n\n$_lastResponse\n\n${_errorBuffer.toString()}'.trim(); } } Stream _transformToLines(Stream> byteStream) { return byteStream.transform(utf8.decoder).transform(const LineSplitter()); }