| // Copyright 2018 The Flutter Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. |
| |
| // ignore_for_file: avoid_print |
| |
| import 'dart:async'; |
| import 'dart:convert'; |
| import 'dart:io'; |
| |
| import 'package:devtools_app/devtools_app.dart'; |
| import 'package:vm_service/utils.dart'; |
| import 'package:vm_service/vm_service.dart'; |
| import 'package:vm_service/vm_service_io.dart'; |
| |
| // TODO(kenz): eventually delete this class in favor of |
| // integration_test/test_infra/test_app_driver.dart once the tests that |
| // depend on this class are moved over to be true integration tests. |
| |
| /// This class was copied from |
| /// flutter/packages/flutter_tools/test/integration/test_driver.dart. Its |
| /// supporting classes were also copied from flutter/packages/flutter_tools. |
| /// Those files are marked as such and live in the parent directory of this file |
| /// (flutter_tools/). |
| |
| // Set this to true for debugging to get JSON written to stdout. |
| const _printDebugOutputToStdOut = false; |
| const defaultTimeout = Duration(seconds: 40); |
| const appStartTimeout = Duration(seconds: 240); |
| const quitTimeout = Duration(seconds: 10); |
| |
| abstract class FlutterTestDriver { |
| FlutterTestDriver(this.projectFolder, {String? logPrefix}) |
| : _logPrefix = logPrefix != null ? '$logPrefix: ' : ''; |
| |
| final Directory projectFolder; |
| final String _logPrefix; |
| late Process proc; |
| late int procPid; |
| final stdoutController = StreamController<String>.broadcast(); |
| final stderrController = StreamController<String>.broadcast(); |
| final _allMessages = StreamController<String>.broadcast(); |
| final errorBuffer = StringBuffer(); |
| late String lastResponse; |
| late Uri _vmServiceWsUri; |
| bool hasExited = false; |
| |
| VmServiceWrapper? vmService; |
| |
| String get lastErrorInfo => errorBuffer.toString(); |
| |
| Stream<String> get stderr => stderrController.stream; |
| Stream<String> get stdout => stdoutController.stream; |
| |
| Uri get vmServiceUri => _vmServiceWsUri; |
| |
| String _debugPrint(String msg) { |
| const maxLength = 500; |
| final truncatedMsg = |
| msg.length > maxLength ? '${msg.substring(0, maxLength)}...' : msg; |
| _allMessages.add(truncatedMsg); |
| if (_printDebugOutputToStdOut) { |
| print('$_logPrefix$truncatedMsg'); |
| } |
| return msg; |
| } |
| |
| Future<void> setupProcess( |
| List<String> args, { |
| required String flutterExecutable, |
| FlutterRunConfiguration runConfig = const FlutterRunConfiguration(), |
| File? pidFile, |
| }) async { |
| final testArgs = [ |
| ...args, |
| if (runConfig.withDebugger) '--start-paused', |
| if (pidFile != null) ...['--pid-file', pidFile.path], |
| ]; |
| |
| _debugPrint('Spawning flutter $testArgs in ${projectFolder.path}'); |
| |
| proc = await Process.start( |
| flutterExecutable, |
| testArgs, |
| workingDirectory: projectFolder.path, |
| environment: <String, String>{ |
| 'FLUTTER_TEST': 'true', |
| 'DART_VM_OPTIONS': '', |
| }, |
| ); |
| // This class doesn't use the result of the future. It's made available |
| // via a getter for external uses. |
| unawaited( |
| proc.exitCode.then((int code) { |
| _debugPrint('Process exited ($code)'); |
| hasExited = true; |
| }), |
| ); |
| transformToLines( |
| proc.stdout, |
| ).listen((String line) => stdoutController.add(line)); |
| transformToLines( |
| proc.stderr, |
| ).listen((String line) => stderrController.add(line)); |
| |
| // Capture stderr to a buffer so we can show it all if any requests fail. |
| stderrController.stream.listen(errorBuffer.writeln); |
| |
| // This is just debug printing to aid running/debugging tests locally. |
| stdoutController.stream.listen(_debugPrint); |
| stderrController.stream.listen(_debugPrint); |
| } |
| |
| Future<int> killGracefully() { |
| _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; |
| } |
| |
| String? flutterIsolateId; |
| |
| Future<String> getFlutterIsolateId() async { |
| // Currently these tests only have a single isolate. If this |
| // ceases to be the case, this code will need changing. |
| if (flutterIsolateId == null) { |
| final vm = await vmService!.getVM(); |
| flutterIsolateId = vm.isolates!.first.id!; |
| } |
| return flutterIsolateId!; |
| } |
| |
| Future<Isolate> _getFlutterIsolate() async { |
| return await vmService!.getIsolate(await getFlutterIsolateId()); |
| } |
| |
| Future<Isolate> waitForPause() async { |
| _debugPrint('Waiting for isolate to pause'); |
| final flutterIsolate = await getFlutterIsolateId(); |
| |
| Future<Isolate> waitForPause() async { |
| final pauseEvent = Completer<Event>(); |
| |
| // Start listening for pause events. |
| final pauseSub = vmService!.onDebugEvent |
| .where( |
| (Event event) => |
| event.isolate!.id == flutterIsolate && |
| event.kind!.startsWith('Pause'), |
| ) |
| .listen(pauseEvent.complete); |
| |
| // But also check if the isolate was already paused (only after we've set |
| // up the sub) to avoid races. If it was paused, we don't need to wait |
| // for the event. |
| final isolate = await vmService!.getIsolate(flutterIsolate); |
| if (!isolate.pauseEvent!.kind!.startsWith('Pause')) { |
| await pauseEvent.future; |
| } |
| |
| // Cancel the sub on either of the above. |
| await pauseSub.cancel(); |
| |
| return _getFlutterIsolate(); |
| } |
| |
| return _timeoutWithMessages<Isolate>( |
| waitForPause, |
| message: 'Isolate did not pause', |
| ); |
| } |
| |
| Future<Isolate?> resume({String? step, bool wait = true}) async { |
| _debugPrint('Sending resume ($step)'); |
| await _timeoutWithMessages<Object?>( |
| () async => vmService!.resume(await getFlutterIsolateId(), step: step), |
| message: 'Isolate did not respond to resume ($step)', |
| ); |
| return wait ? waitForPause() : null; |
| } |
| |
| Future<Map<String, dynamic>> waitFor({ |
| String? event, |
| int? id, |
| Duration? timeout, |
| bool ignoreAppStopEvent = false, |
| }) { |
| final response = Completer<Map<String, dynamic>>(); |
| late StreamSubscription<String> sub; |
| sub = stdoutController.stream.listen((String line) async { |
| final 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 error = StringBuffer(); |
| error.write('Received app.stop event while waiting for '); |
| error.write( |
| '${event != null ? '$event event' : 'response to request $id.'}.\n\n', |
| ); |
| final params = json['params']; |
| if (params != null && params is Map<String, Object?>) { |
| if (params['error'] != null) { |
| error.write('${params['error']}\n\n'); |
| } |
| if (params['trace'] != null) { |
| error.write('${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 messages = StringBuffer(); |
| final start = DateTime.now(); |
| void logMessage(String m) { |
| final ms = DateTime.now().difference(start).inMilliseconds; |
| messages.writeln('[+ ${ms.toString().padLeft(5)}] $m'); |
| } |
| |
| final sub = _allMessages.stream.listen(logMessage); |
| |
| return f() |
| .timeout( |
| timeout ?? defaultTimeout, |
| onTimeout: () { |
| logMessage('<timed out>'); |
| throw '$message'; |
| }, |
| ) |
| .catchError((Object? error) { |
| throw '$error\nReceived:\n${messages.toString()}'; |
| }) |
| .whenComplete(() => sub.cancel()); |
| } |
| |
| Map<String, Object?>? _parseFlutterResponse(String line) { |
| if (line.startsWith('[') && line.endsWith(']')) { |
| try { |
| final Map<String, dynamic>? resp = (json.decode(line) as List)[0]; |
| lastResponse = line; |
| return resp; |
| } catch (e) { |
| // Not valid JSON, so likely some other output that was surrounded by [brackets] |
| return null; |
| } |
| } |
| return null; |
| } |
| } |
| |
| class FlutterRunTestDriver extends FlutterTestDriver { |
| FlutterRunTestDriver(super.projectFolder, {super.logPrefix}); |
| |
| String? _currentRunningAppId; |
| |
| Future<void> run({ |
| required String flutterExecutable, |
| FlutterRunConfiguration runConfig = const FlutterRunConfiguration(), |
| File? pidFile, |
| }) async { |
| final args = <String>['run', '--machine']; |
| if (runConfig.trackWidgetCreation) { |
| args.add('--track-widget-creation'); |
| } |
| if (runConfig.entryScript != null) { |
| args.addAll(['-t', runConfig.entryScript ?? '']); |
| } |
| args.addAll(['-d', 'flutter-tester']); |
| await setupProcess( |
| args, |
| flutterExecutable: flutterExecutable, |
| runConfig: runConfig, |
| pidFile: pidFile, |
| ); |
| } |
| |
| @override |
| Future<void> setupProcess( |
| List<String> args, { |
| required String flutterExecutable, |
| FlutterRunConfiguration runConfig = const FlutterRunConfiguration(), |
| File? pidFile, |
| }) async { |
| await super.setupProcess( |
| args, |
| flutterExecutable: flutterExecutable, |
| runConfig: runConfig, |
| pidFile: pidFile, |
| ); |
| |
| // 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 connected = await waitFor(event: 'daemon.connected'); |
| final Map<String, dynamic> params = connected['params']; |
| procPid = 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 started = waitFor(event: 'app.started', timeout: appStartTimeout); |
| |
| if (runConfig.withDebugger) { |
| final debugPort = await waitFor( |
| event: 'app.debugPort', |
| timeout: appStartTimeout, |
| ); |
| final Map<String, dynamic> params = debugPort['params']; |
| final String wsUriString = params['wsUri']; |
| _vmServiceWsUri = Uri.parse(wsUriString); |
| |
| // Map to WS URI. |
| _vmServiceWsUri = convertToWebSocketUrl( |
| serviceProtocolUrl: _vmServiceWsUri, |
| ); |
| |
| vmService = await vmServiceConnectUriWithFactory<VmServiceWrapper>( |
| _vmServiceWsUri.toString(), |
| vmServiceFactory: |
| ({ |
| // ignore: avoid-dynamic, mirrors types of [VmServiceFactory]. |
| required Stream<dynamic> /*String|List<int>*/ inStream, |
| required void Function(String message) writeMessage, |
| Log? log, |
| DisposeHandler? disposeHandler, |
| Future? streamClosed, |
| String? wsUri, |
| bool trackFutures = false, |
| }) => VmServiceWrapper.defaultFactory( |
| inStream: inStream, |
| writeMessage: writeMessage, |
| log: log, |
| disposeHandler: disposeHandler, |
| streamClosed: streamClosed, |
| wsUri: wsUri, |
| trackFutures: true, |
| ), |
| ); |
| |
| final vmServiceLocal = vmService!; |
| vmServiceLocal.onSend.listen((String s) => _debugPrint('==> $s')); |
| vmServiceLocal.onReceive.listen((String s) => _debugPrint('<== $s')); |
| await Future.wait(<Future<Success>>[ |
| vmServiceLocal.streamListen(EventStreams.kIsolate), |
| vmServiceLocal.streamListen(EventStreams.kDebug), |
| ]); |
| |
| // On hot restarts, the isolate ID we have for the Flutter thread will |
| // exit so we need to invalidate our cached ID. |
| vmServiceLocal.onIsolateEvent.listen((Event event) { |
| if (event.kind == EventKind.kIsolateExit && |
| event.isolate!.id == flutterIsolateId) { |
| flutterIsolateId = null; |
| } |
| }); |
| |
| // 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 (runConfig.pauseOnExceptions) { |
| await vmServiceLocal.setIsolatePauseMode( |
| await getFlutterIsolateId(), |
| exceptionPauseMode: ExceptionPauseMode.kUnhandled, |
| ); |
| } |
| await resume(wait: false); |
| } |
| |
| // Now await the started event; if it had already happened the future will |
| // have already completed. |
| final Map<String, dynamic> startedParams = (await started)['params']; |
| _currentRunningAppId = startedParams['appId']; |
| } |
| |
| Future<void> hotRestart({bool pause = false}) => |
| _restart(fullRestart: true, pause: pause); |
| |
| Future<void> hotReload() => _restart(); |
| |
| Future<void> _restart({bool fullRestart = false, bool pause = false}) async { |
| if (_currentRunningAppId == null) { |
| throw Exception('App has not started yet'); |
| } |
| |
| final hotReloadResp = await _sendRequest('app.restart', <String, Object?>{ |
| 'appId': _currentRunningAppId, |
| 'fullRestart': fullRestart, |
| 'pause': pause, |
| }); |
| |
| if (hotReloadResp == null || |
| (hotReloadResp as Map<String, Object?>)['code'] != 0) { |
| _throwErrorResponse( |
| 'Hot ${fullRestart ? 'restart' : 'reload'} request failed', |
| ); |
| } |
| } |
| |
| Future<int> stop() async { |
| final vmServiceLocal = vmService; |
| if (vmServiceLocal != null) { |
| _debugPrint('Closing VM service'); |
| await Future.delayed(const Duration(milliseconds: 500)); |
| await vmServiceLocal.dispose(); |
| } |
| if (_currentRunningAppId != null) { |
| _debugPrint('Stopping app'); |
| await Future.any<void>(<Future<void>>[ |
| proc.exitCode, |
| _sendRequest('app.stop', <String, Object?>{ |
| '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); |
| } |
| |
| int id = 1; |
| |
| Future<Object?> _sendRequest(String method, Object? params) async { |
| final requestId = id++; |
| final request = <String, Object?>{ |
| 'id': requestId, |
| 'method': method, |
| 'params': params, |
| }; |
| final jsonEncoded = json.encode(<Map<String, Object?>>[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 responseFuture = waitFor( |
| id: requestId, |
| ignoreAppStopEvent: method == 'app.stop', |
| ); |
| proc.stdin.writeln(jsonEncoded); |
| final 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()); |
| } |
| |
| class FlutterRunConfiguration { |
| const FlutterRunConfiguration({ |
| this.withDebugger = false, |
| this.pauseOnExceptions = false, |
| this.trackWidgetCreation = true, |
| this.entryScript, |
| }); |
| |
| final bool withDebugger; |
| final bool pauseOnExceptions; |
| final bool trackWidgetCreation; |
| final String? entryScript; |
| } |