| // Copyright 2014 The Flutter 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 'dart:io' as io; // flutter_ignore: dart_io_import | |
| import 'package:file/file.dart'; | |
| import 'package:flutter_tools/src/base/file_system.dart'; | |
| import 'package:flutter_tools/src/base/io.dart'; | |
| import 'package:flutter_tools/src/base/utils.dart'; | |
| import 'package:process/process.dart'; | |
| import 'package:vm_service/vm_service.dart'; | |
| import 'package:vm_service/vm_service_io.dart'; | |
| import '../src/common.dart'; | |
| import 'test_utils.dart'; | |
| // Set this to true for debugging to get verbose logs written to stdout. | |
| // The logs include the following: | |
| // <=stdout= data that the flutter tool running in --verbose mode wrote to stdout. | |
| // <=stderr= data that the flutter tool running in --verbose mode wrote to stderr. | |
| // =stdin=> data that the test sent to the flutter tool over stdin. | |
| // =vm=> data that was sent over the VM service channel to the app running on the test device. | |
| // <=vm= data that was sent from the app on the test device over the VM service channel. | |
| // Messages regarding what the test is doing. | |
| // If this is false, then only critical errors and logs when things appear to be | |
| // taking a long time are printed to the console. | |
| const bool _printDebugOutputToStdOut = false; | |
| final DateTime startTime = DateTime.now(); | |
| const Duration defaultTimeout = Duration(seconds: 5); | |
| const Duration appStartTimeout = Duration(seconds: 120); | |
| const Duration quitTimeout = Duration(seconds: 10); | |
| abstract class FlutterTestDriver { | |
| FlutterTestDriver( | |
| this._projectFolder, { | |
| String? logPrefix, | |
| }) : _logPrefix = logPrefix != null ? '$logPrefix: ' : ''; | |
| final Directory _projectFolder; | |
| final String _logPrefix; | |
| Process? _process; | |
| int? _processPid; | |
| 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; | |
| Uri? _vmServiceWsUri; | |
| int? _attachPort; | |
| bool _hasExited = false; | |
| VmService? _vmService; | |
| String get lastErrorInfo => _errorBuffer.toString(); | |
| Stream<String> get stdout => _stdout.stream; | |
| Stream<String> get stderr => _stderr.stream; | |
| int? get vmServicePort => _vmServiceWsUri?.port; | |
| bool get hasExited => _hasExited; | |
| Uri? get vmServiceWsUri => _vmServiceWsUri; | |
| String lastTime = ''; | |
| void _debugPrint(String message, { String topic = '' }) { | |
| const int maxLength = 2500; | |
| final String truncatedMessage = message.length > maxLength ? '${message.substring(0, maxLength)}...' : message; | |
| final String line = '${topic.padRight(10)} $truncatedMessage'; | |
| _allMessages.add(line); | |
| final int timeInSeconds = DateTime.now().difference(startTime).inSeconds; | |
| String time = '${timeInSeconds.toString().padLeft(5)}s '; | |
| if (time == lastTime) { | |
| time = ' ' * time.length; | |
| } else { | |
| lastTime = time; | |
| } | |
| if (_printDebugOutputToStdOut) { | |
| // This is the one place in this file that can call print. It is gated by | |
| // _printDebugOutputToStdOut which should not be set to true in CI; it is | |
| // intended only for use in local debugging. | |
| // ignore: avoid_print | |
| print('$time$_logPrefix$line'); | |
| } else { | |
| printOnFailure('$time$_logPrefix$line'); | |
| } | |
| } | |
| Future<void> _setupProcess( | |
| List<String> arguments, { | |
| String? script, | |
| bool withDebugger = false, | |
| bool singleWidgetReloads = false, | |
| }) async { | |
| final String flutterBin = fileSystem.path.join(getFlutterRoot(), 'bin', 'flutter'); | |
| if (withDebugger) { | |
| arguments.add('--start-paused'); | |
| } | |
| if (_printDebugOutputToStdOut) { | |
| arguments.add('--verbose'); | |
| } | |
| if (script != null) { | |
| arguments.add(script); | |
| } | |
| _debugPrint('Spawning flutter $arguments in ${_projectFolder.path}'); | |
| const ProcessManager processManager = LocalProcessManager(); | |
| _process = await processManager.start( | |
| <String>[flutterBin] | |
| .followedBy(arguments) | |
| .toList(), | |
| workingDirectory: _projectFolder.path, | |
| // The web environment variable has the same effect as `flutter config --enable-web`. | |
| environment: <String, String>{ | |
| 'FLUTTER_TEST': 'true', | |
| 'FLUTTER_WEB': 'true', | |
| if (singleWidgetReloads) | |
| 'FLUTTER_SINGLE_WIDGET_RELOAD': 'true', | |
| }, | |
| ); | |
| // This class doesn't use the result of the future. It's made available | |
| // via a getter for external uses. | |
| unawaited(_process!.exitCode.then((int code) { | |
| _debugPrint('Process exited ($code)'); | |
| _hasExited = true; | |
| })); | |
| transformToLines(_process!.stdout).listen(_stdout.add); | |
| transformToLines(_process!.stderr).listen(_stderr.add); | |
| // 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((String message) => _debugPrint(message, topic: '<=stdout=')); | |
| _stderr.stream.listen((String message) => _debugPrint(message, topic: '<=stderr=')); | |
| } | |
| Future<void> get done async => _process?.exitCode; | |
| Future<void> connectToVmService({ bool pauseOnExceptions = false }) async { | |
| _vmService = await vmServiceConnectUri('$_vmServiceWsUri'); | |
| _vmService!.onSend.listen((String s) => _debugPrint(s, topic: '=vm=>')); | |
| _vmService!.onReceive.listen((String s) => _debugPrint(s, topic: '<=vm=')); | |
| final Completer<void> isolateStarted = Completer<void>(); | |
| _vmService!.onIsolateEvent.listen((Event event) { | |
| if (event.kind == EventKind.kIsolateStart) { | |
| isolateStarted.complete(); | |
| } else if (event.kind == EventKind.kIsolateExit && event.isolate?.id == _flutterIsolateId) { | |
| // Hot restarts cause all the isolates to exit, so we need to refresh | |
| // our idea of what the Flutter isolate ID is. | |
| _flutterIsolateId = null; | |
| } | |
| }); | |
| await Future.wait(<Future<Success>>[ | |
| _vmService!.streamListen('Isolate'), | |
| _vmService!.streamListen('Debug'), | |
| ]); | |
| if ((await _vmService!.getVM()).isolates?.isEmpty ?? true) { | |
| await isolateStarted.future; | |
| } | |
| await waitForPause(); | |
| if (pauseOnExceptions) { | |
| await _vmService!.setIsolatePauseMode( | |
| await _getFlutterIsolateId(), | |
| exceptionPauseMode: ExceptionPauseMode.kUnhandled, | |
| ); | |
| } | |
| } | |
| Future<Response> callServiceExtension( | |
| String extension, { | |
| Map<String, Object?> args = const <String, Object>{}, | |
| }) async { | |
| final int? port = _vmServiceWsUri != null ? vmServicePort : _attachPort; | |
| final VmService vmService = await vmServiceConnectUri('ws://localhost:$port/ws'); | |
| final Isolate isolate = await waitForExtension(vmService, extension); | |
| return vmService.callServiceExtension( | |
| extension, | |
| isolateId: isolate.id, | |
| args: args, | |
| ); | |
| } | |
| Future<int> quit() => _killGracefully(); | |
| Future<int> _killGracefully() async { | |
| if (_processPid == null) { | |
| return -1; | |
| } | |
| // If we try to kill the process while it's paused, we'll end up terminating | |
| // it forcefully and it won't terminate child processes, so we need to ensure | |
| // it's running before terminating. | |
| await resume().timeout(defaultTimeout) | |
| .then( | |
| (Isolate? isolate) => isolate, | |
| onError: (Object e) { | |
| _debugPrint('Ignoring failure to resume during shutdown'); | |
| return null; | |
| }, | |
| ); | |
| _debugPrint('Sending SIGTERM to $_processPid..'); | |
| io.Process.killPid(_processPid!); | |
| return _process!.exitCode.timeout(quitTimeout, onTimeout: _killForcefully); | |
| } | |
| Future<int> _killForcefully() { | |
| _debugPrint('Sending SIGKILL to $_processPid..'); | |
| ProcessSignal.sigkill.send(_processPid!); | |
| return _process!.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 vm = await _vmService!.getVM(); | |
| _flutterIsolateId = vm.isolates!.single.id; | |
| } | |
| return _flutterIsolateId!; | |
| } | |
| Future<Isolate> getFlutterIsolate() async { | |
| final Isolate isolate = await _vmService!.getIsolate(await _getFlutterIsolateId()); | |
| return isolate; | |
| } | |
| /// Add a breakpoint and wait for it to trip the program execution. | |
| /// | |
| /// Only call this when you are absolutely sure that the program under test | |
| /// will hit the breakpoint _in the future_. | |
| /// | |
| /// In particular, do not call this if the program is currently racing to pass | |
| /// the line of code you are breaking on. Pretend that calling this will take | |
| /// an hour before setting the breakpoint. Would the code still eventually hit | |
| /// the breakpoint and stop? | |
| Future<void> breakAt(Uri uri, int line) async { | |
| final String isolateId = await _getFlutterIsolateId(); | |
| final Future<Event> event = subscribeToPauseEvent(isolateId); | |
| await addBreakpoint(uri, line); | |
| await waitForPauseEvent(isolateId, event); | |
| } | |
| Future<void> addBreakpoint(Uri uri, int line) async { | |
| _debugPrint('Sending breakpoint for: $uri:$line'); | |
| await _vmService!.addBreakpointWithScriptUri( | |
| await _getFlutterIsolateId(), | |
| uri.toString(), | |
| line, | |
| ); | |
| } | |
| Future<Event> subscribeToPauseEvent(String isolateId) => subscribeToDebugEvent('Pause', isolateId); | |
| Future<Event> subscribeToResumeEvent(String isolateId) => subscribeToDebugEvent('Resume', isolateId); | |
| Future<Isolate> waitForPauseEvent(String isolateId, Future<Event> event) => | |
| waitForDebugEvent('Pause', isolateId, event); | |
| Future<Isolate> waitForResumeEvent(String isolateId, Future<Event> event) => | |
| waitForDebugEvent('Resume', isolateId, event); | |
| Future<Isolate> waitForPause() async => subscribeAndWaitForDebugEvent('Pause', await _getFlutterIsolateId()); | |
| Future<Isolate> waitForResume() async => subscribeAndWaitForDebugEvent('Resume', await _getFlutterIsolateId()); | |
| Future<Isolate> subscribeAndWaitForDebugEvent(String kind, String isolateId) { | |
| final Future<Event> event = subscribeToDebugEvent(kind, isolateId); | |
| return waitForDebugEvent(kind, isolateId, event); | |
| } | |
| /// Subscribes to debug events containing [kind]. | |
| /// | |
| /// Returns a future that completes when the [kind] event is received. | |
| /// | |
| /// This method should be called before the command that triggers | |
| /// the event to subscribe to the event in time, for example: | |
| /// | |
| /// ```dart | |
| /// var event = subscribeToDebugEvent('Pause', id); // Subscribe to 'pause' events. | |
| /// ... // Code that pauses the app. | |
| /// await waitForDebugEvent('Pause', id, event); // Isolate is paused now. | |
| /// ``` | |
| Future<Event> subscribeToDebugEvent(String kind, String isolateId) { | |
| _debugPrint('Start listening for $kind events'); | |
| return _vmService!.onDebugEvent | |
| .where((Event event) { | |
| return event.isolate?.id == isolateId | |
| && (event.kind?.startsWith(kind) ?? false); | |
| }).first; | |
| } | |
| /// Wait for the [event] if needed. | |
| /// | |
| /// Return immediately if the isolate is already in the desired state. | |
| Future<Isolate> waitForDebugEvent(String kind, String isolateId, Future<Event> event) { | |
| return _timeoutWithMessages<Isolate>( | |
| () async { | |
| // But also check if the isolate was already at the state we need (only after we've | |
| // set up the subscription) to avoid races. If it already in the desired state, we | |
| // don't need to wait for the event. | |
| final VmService vmService = _vmService!; | |
| final Isolate isolate = await vmService.getIsolate(isolateId); | |
| if (isolate.pauseEvent?.kind?.startsWith(kind) ?? false) { | |
| _debugPrint('Isolate was already at "$kind" (${isolate.pauseEvent!.kind}).'); | |
| event.ignore(); | |
| } else { | |
| _debugPrint('Waiting for "$kind" event to arrive...'); | |
| await event; | |
| } | |
| return vmService.getIsolate(isolateId); | |
| }, | |
| task: 'Waiting for isolate to $kind', | |
| ); | |
| } | |
| Future<Isolate?> resume({ bool waitForNextPause = false }) => _resume(null, waitForNextPause); | |
| Future<Isolate?> stepOver({ bool waitForNextPause = true }) => _resume(StepOption.kOver, waitForNextPause); | |
| Future<Isolate?> stepOverAsync({ bool waitForNextPause = true }) => _resume(StepOption.kOverAsyncSuspension, waitForNextPause); | |
| Future<Isolate?> stepInto({ bool waitForNextPause = true }) => _resume(StepOption.kInto, waitForNextPause); | |
| Future<Isolate?> stepOut({ bool waitForNextPause = true }) => _resume(StepOption.kOut, waitForNextPause); | |
| Future<bool> isAtAsyncSuspension() async { | |
| final Isolate isolate = await getFlutterIsolate(); | |
| return isolate.pauseEvent?.atAsyncSuspension ?? false; | |
| } | |
| Future<Isolate?> stepOverOrOverAsyncSuspension({ bool waitForNextPause = true }) async { | |
| if (await isAtAsyncSuspension()) { | |
| return stepOverAsync(waitForNextPause: waitForNextPause); | |
| } | |
| return stepOver(waitForNextPause: waitForNextPause); | |
| } | |
| Future<Isolate?> _resume(String? step, bool waitForNextPause) async { | |
| final String isolateId = await _getFlutterIsolateId(); | |
| final Future<Event> resume = subscribeToResumeEvent(isolateId); | |
| final Future<Event> pause = subscribeToPauseEvent(isolateId); | |
| await _timeoutWithMessages<Object?>( | |
| () async => _vmService!.resume(isolateId, step: step), | |
| task: 'Resuming isolate (step=$step)', | |
| ); | |
| await waitForResumeEvent(isolateId, resume); | |
| return waitForNextPause ? waitForPauseEvent(isolateId, pause) : null; | |
| } | |
| Future<ObjRef> evaluateInFrame(String expression) async { | |
| return _timeoutWithMessages<ObjRef>( | |
| () async => await _vmService!.evaluateInFrame(await _getFlutterIsolateId(), 0, expression) as ObjRef, | |
| task: 'Evaluating expression ($expression)', | |
| ); | |
| } | |
| Future<ObjRef> evaluate(String targetId, String expression) async { | |
| return _timeoutWithMessages<ObjRef>( | |
| () async => await _vmService!.evaluate(await _getFlutterIsolateId(), targetId, expression) as ObjRef, | |
| task: 'Evaluating expression ($expression for $targetId)', | |
| ); | |
| } | |
| Future<Frame> getTopStackFrame() async { | |
| final String flutterIsolateId = await _getFlutterIsolateId(); | |
| final Stack stack = await _vmService!.getStack(flutterIsolateId); | |
| final List<Frame>? frames = stack.frames; | |
| if (frames == null || frames.isEmpty) { | |
| throw Exception('Stack is empty'); | |
| } | |
| return frames.first; | |
| } | |
| Future<SourcePosition?> getSourceLocation() async { | |
| final String flutterIsolateId = await _getFlutterIsolateId(); | |
| final Frame frame = await getTopStackFrame(); | |
| final Script script = await _vmService!.getObject(flutterIsolateId, frame.location!.script!.id!) as Script; | |
| return _lookupTokenPos(script.tokenPosTable!, frame.location!.tokenPos!); | |
| } | |
| SourcePosition? _lookupTokenPos(List<List<int>> table, int tokenPos) { | |
| for (final List<int> row in table) { | |
| final int lineNumber = row[0]; | |
| int index = 1; | |
| for (index = 1; index < row.length - 1; index += 2) { | |
| if (row[index] == tokenPos) { | |
| return SourcePosition(lineNumber, row[index + 1]); | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| Future<Map<String, Object?>> _waitFor({ | |
| String? event, | |
| int? id, | |
| Duration timeout = defaultTimeout, | |
| bool ignoreAppStopEvent = false, | |
| }) async { | |
| assert(event != null || id != null); | |
| assert(event == null || id == null); | |
| final String interestingOccurrence = event != null ? '$event event' : 'response to request $id'; | |
| final Completer<Map<String, Object?>> response = Completer<Map<String, Object?>>(); | |
| StreamSubscription<String>? subscription; | |
| subscription = _stdout.stream.listen((String line) async { | |
| final Map<String, Object?>? json = parseFlutterResponse(line); | |
| _lastResponse = line; | |
| if (json == null) { | |
| return; | |
| } | |
| if ((event != null && json['event'] == event) || | |
| (id != null && json['id'] == id)) { | |
| await subscription?.cancel(); | |
| _debugPrint('OK ($interestingOccurrence)'); | |
| response.complete(json); | |
| } else if (!ignoreAppStopEvent && json['event'] == 'app.stop') { | |
| await subscription?.cancel(); | |
| final StringBuffer error = StringBuffer(); | |
| error.write('Received app.stop event while waiting for $interestingOccurrence\n\n$_errorBuffer'); | |
| final Object? jsonParams = json['params']; | |
| if (jsonParams is Map<String, Object?>) { | |
| if (jsonParams['error'] != null) { | |
| error.write('${jsonParams['error']}\n\n'); | |
| } | |
| final Object? trace = jsonParams['trace']; | |
| if (trace != null) { | |
| error.write('$trace\n\n'); | |
| } | |
| } | |
| response.completeError(Exception(error.toString())); | |
| } | |
| }); | |
| return _timeoutWithMessages( | |
| () => response.future, | |
| timeout: timeout, | |
| task: 'Expecting $interestingOccurrence', | |
| ).whenComplete(subscription.cancel); | |
| } | |
| Future<T> _timeoutWithMessages<T>( | |
| Future<T> Function() callback, { | |
| required String task, | |
| Duration timeout = defaultTimeout, | |
| }) { | |
| if (_printDebugOutputToStdOut) { | |
| _debugPrint('$task...'); | |
| final Timer longWarning = Timer(timeout, () => _debugPrint('$task is taking longer than usual...')); | |
| return callback().whenComplete(longWarning.cancel); | |
| } | |
| // We're not showing all output to the screen, so let's capture the output | |
| // that we would have printed if we were, and output it if we take longer | |
| // than the timeout or if we get an error. | |
| final StringBuffer messages = StringBuffer('$task\n'); | |
| final DateTime start = DateTime.now(); | |
| bool timeoutExpired = false; | |
| void logMessage(String logLine) { | |
| final int ms = DateTime.now().difference(start).inMilliseconds; | |
| final String formattedLine = '[+ ${ms.toString().padLeft(5)}] $logLine'; | |
| messages.writeln(formattedLine); | |
| } | |
| final StreamSubscription<String> subscription = _allMessages.stream.listen(logMessage); | |
| final Timer longWarning = Timer(timeout, () { | |
| _debugPrint(messages.toString()); | |
| timeoutExpired = true; | |
| _debugPrint('$task is taking longer than usual...'); | |
| }); | |
| final Future<T> future = callback().whenComplete(longWarning.cancel); | |
| return future.then( | |
| (T t) => t, | |
| onError: (Object error) { | |
| if (!timeoutExpired) { | |
| timeoutExpired = true; | |
| _debugPrint(messages.toString()); | |
| } | |
| throw error; // ignore: only_throw_errors | |
| }, | |
| ).whenComplete(() => subscription.cancel()); | |
| } | |
| } | |
| class FlutterRunTestDriver extends FlutterTestDriver { | |
| FlutterRunTestDriver( | |
| super.projectFolder, { | |
| super.logPrefix, | |
| this.spawnDdsInstance = true, | |
| }); | |
| String? _currentRunningAppId; | |
| String? _currentRunningDeviceId; | |
| String? _currentRunningMode; | |
| String? get currentRunningDeviceId => _currentRunningDeviceId; | |
| String? get currentRunningMode => _currentRunningMode; | |
| Future<void> run({ | |
| bool withDebugger = false, | |
| bool startPaused = false, | |
| bool pauseOnExceptions = false, | |
| bool chrome = false, | |
| bool expressionEvaluation = true, | |
| bool structuredErrors = false, | |
| bool singleWidgetReloads = false, | |
| bool serveObservatory = false, | |
| String? script, | |
| List<String>? additionalCommandArgs, | |
| }) async { | |
| await _setupProcess( | |
| <String>[ | |
| 'run', | |
| if (!chrome) | |
| '--disable-service-auth-codes', | |
| '--machine', | |
| if (!spawnDdsInstance) '--no-dds', | |
| '--${serveObservatory ? '' : 'no-'}serve-observatory', | |
| ...getLocalEngineArguments(), | |
| '-d', | |
| if (chrome) | |
| ...<String>[ | |
| 'chrome', | |
| '--web-run-headless', | |
| if (!expressionEvaluation) '--no-web-enable-expression-evaluation', | |
| ] | |
| else | |
| 'flutter-tester', | |
| if (structuredErrors) | |
| '--dart-define=flutter.inspector.structuredErrors=true', | |
| ...?additionalCommandArgs, | |
| ], | |
| withDebugger: withDebugger, | |
| startPaused: startPaused, | |
| pauseOnExceptions: pauseOnExceptions, | |
| script: script, | |
| singleWidgetReloads: singleWidgetReloads, | |
| ); | |
| } | |
| Future<void> attach( | |
| int port, { | |
| bool withDebugger = false, | |
| bool startPaused = false, | |
| bool pauseOnExceptions = false, | |
| bool singleWidgetReloads = false, | |
| bool serveObservatory = false, | |
| List<String>? additionalCommandArgs, | |
| }) async { | |
| _attachPort = port; | |
| await _setupProcess( | |
| <String>[ | |
| 'attach', | |
| ...getLocalEngineArguments(), | |
| '--machine', | |
| if (!spawnDdsInstance) | |
| '--no-dds', | |
| '--${serveObservatory ? '' : 'no-'}serve-observatory', | |
| '-d', | |
| 'flutter-tester', | |
| '--debug-port', | |
| '$port', | |
| ...?additionalCommandArgs, | |
| ], | |
| withDebugger: withDebugger, | |
| startPaused: startPaused, | |
| pauseOnExceptions: pauseOnExceptions, | |
| singleWidgetReloads: singleWidgetReloads, | |
| attachPort: port, | |
| ); | |
| } | |
| @override | |
| Future<void> _setupProcess( | |
| List<String> args, { | |
| String? script, | |
| bool withDebugger = false, | |
| bool startPaused = false, | |
| bool pauseOnExceptions = false, | |
| bool singleWidgetReloads = false, | |
| int? attachPort, | |
| }) async { | |
| assert(!startPaused || withDebugger); | |
| await super._setupProcess( | |
| args, | |
| script: script, | |
| withDebugger: withDebugger, | |
| singleWidgetReloads: singleWidgetReloads, | |
| ); | |
| final Completer<void> prematureExitGuard = Completer<void>(); | |
| // If the process exits before all of the `await`s below are done, then it | |
| // exited prematurely. This causes the currently suspended `await` to | |
| // deadlock until the test times out. Instead, this causes the test to fail | |
| // fast. | |
| unawaited(_process?.exitCode.then((_) { | |
| if (!prematureExitGuard.isCompleted) { | |
| prematureExitGuard.completeError(Exception('Process exited prematurely: ${args.join(' ')}: $_errorBuffer')); | |
| } | |
| })); | |
| unawaited(() async { | |
| try { | |
| // Stash the PID so that we can terminate the VM more reliably than using | |
| // _process.kill() (`flutter` is a shell script so _process itself is a | |
| // shell, not the flutter tool's Dart process). | |
| final Map<String, Object?> connected = await _waitFor(event: 'daemon.connected'); | |
| _processPid = (connected['params'] as Map<String, Object?>?)?['pid'] as int?; | |
| // 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, Object?>> start = _waitFor(event: 'app.start', timeout: appStartTimeout); | |
| final Future<Map<String, Object?>> started = _waitFor(event: 'app.started', timeout: appStartTimeout); | |
| if (withDebugger) { | |
| final Map<String, Object?> debugPort = await _waitFor(event: 'app.debugPort', timeout: appStartTimeout); | |
| final String wsUriString = (debugPort['params']! as Map<String, Object?>)['wsUri']! as String; | |
| _vmServiceWsUri = Uri.parse(wsUriString); | |
| await connectToVmService(pauseOnExceptions: pauseOnExceptions); | |
| if (!startPaused) { | |
| await resume(); | |
| } | |
| } | |
| // In order to call service extensions from test runners started with | |
| // attach, we need to store the port that the test runner was attached | |
| // to. | |
| if (_vmServiceWsUri == null && attachPort != null) { | |
| _attachPort = attachPort; | |
| } | |
| // Now await the start/started events; if it had already happened the future will | |
| // have already completed. | |
| final Map<String, Object?>? startParams = (await start)['params'] as Map<String, Object?>?; | |
| final Map<String, Object?>? startedParams = (await started)['params'] as Map<String, Object?>?; | |
| _currentRunningAppId = startedParams?['appId'] as String?; | |
| _currentRunningDeviceId = startParams?['deviceId'] as String?; | |
| _currentRunningMode = startParams?['mode'] as String?; | |
| prematureExitGuard.complete(); | |
| } on Exception catch (error, stackTrace) { | |
| prematureExitGuard.completeError(Exception(error.toString()), stackTrace); | |
| } | |
| }()); | |
| return prematureExitGuard.future; | |
| } | |
| Future<void> hotRestart({ bool pause = false, bool debounce = false}) => _restart(fullRestart: true, pause: pause); | |
| Future<void> hotReload({ bool debounce = false, int? debounceDurationOverrideMs }) => | |
| _restart(debounce: debounce, debounceDurationOverrideMs: debounceDurationOverrideMs); | |
| Future<void> scheduleFrame() async { | |
| if (_currentRunningAppId == null) { | |
| throw Exception('App has not started yet'); | |
| } | |
| await _sendRequest( | |
| 'app.callServiceExtension', | |
| <String, Object?>{'appId': _currentRunningAppId, 'methodName': 'ext.ui.window.scheduleFrame'}, | |
| ); | |
| } | |
| Future<void> _restart({ bool fullRestart = false, bool pause = false, bool debounce = false, int? debounceDurationOverrideMs }) async { | |
| if (_currentRunningAppId == null) { | |
| throw Exception('App has not started yet'); | |
| } | |
| _debugPrint('Performing ${ pause ? "paused " : "" }${ fullRestart ? "hot restart" : "hot reload" }...'); | |
| final Map<String, Object?>? hotReloadResponse = await _sendRequest( | |
| 'app.restart', | |
| <String, Object?>{'appId': _currentRunningAppId, 'fullRestart': fullRestart, 'pause': pause, 'debounce': debounce, 'debounceDurationOverrideMs': debounceDurationOverrideMs}, | |
| ) as Map<String, Object?>?; | |
| _debugPrint('${fullRestart ? "Hot restart" : "Hot reload"} complete.'); | |
| if (hotReloadResponse == null || hotReloadResponse['code'] != 0) { | |
| _throwErrorResponse('Hot ${fullRestart ? 'restart' : 'reload'} request failed'); | |
| } | |
| } | |
| Future<int> detach() async { | |
| final Process? process = _process; | |
| if (process == null) { | |
| return 0; | |
| } | |
| final VmService? vmService = _vmService; | |
| if (vmService != null) { | |
| _debugPrint('Closing VM service...'); | |
| await vmService.dispose(); | |
| } | |
| if (_currentRunningAppId != null) { | |
| _debugPrint('Detaching from app...'); | |
| await Future.any<void>(<Future<void>>[ | |
| process.exitCode, | |
| _sendRequest( | |
| 'app.detach', | |
| <String, Object?>{'appId': _currentRunningAppId}, | |
| ), | |
| ]).timeout( | |
| quitTimeout, | |
| onTimeout: () { _debugPrint('app.detach did not return within $quitTimeout'); }, | |
| ); | |
| _currentRunningAppId = null; | |
| } | |
| _debugPrint('Waiting for process to end...'); | |
| return process.exitCode.timeout(quitTimeout, onTimeout: _killGracefully); | |
| } | |
| Future<int> stop() async { | |
| final VmService? vmService = _vmService; | |
| if (vmService != null) { | |
| _debugPrint('Closing VM service...'); | |
| await vmService.dispose(); | |
| } | |
| final Process? process = _process; | |
| if (_currentRunningAppId != null) { | |
| _debugPrint('Stopping application...'); | |
| await Future.any<void>(<Future<void>>[ | |
| process!.exitCode, | |
| _sendRequest( | |
| 'app.stop', | |
| <String, Object?>{'appId': _currentRunningAppId}, | |
| ), | |
| ]).timeout( | |
| quitTimeout, | |
| onTimeout: () { _debugPrint('app.stop did not return within $quitTimeout'); }, | |
| ); | |
| _currentRunningAppId = null; | |
| } | |
| if (process != null) { | |
| _debugPrint('Waiting for process to end...'); | |
| return process.exitCode.timeout(quitTimeout, onTimeout: _killGracefully); | |
| } | |
| return 0; | |
| } | |
| int id = 1; | |
| Future<Object?> _sendRequest(String method, Object? params) async { | |
| final int requestId = id++; | |
| final Map<String, Object?> request = <String, Object?>{ | |
| 'id': requestId, | |
| 'method': method, | |
| 'params': params, | |
| }; | |
| final String jsonEncoded = json.encode(<Map<String, Object?>>[request]); | |
| _debugPrint(jsonEncoded, topic: '=stdin=>'); | |
| // 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, Object?>> responseFuture = _waitFor( | |
| id: requestId, | |
| ignoreAppStopEvent: method == 'app.stop' || method == 'app.detach', | |
| ); | |
| _process?.stdin.writeln(jsonEncoded); | |
| final Map<String, Object?> response = await responseFuture; | |
| if (response['error'] != null || response['result'] == null) { | |
| _throwErrorResponse('Unexpected error response'); | |
| } | |
| return response['result']; | |
| } | |
| void _throwErrorResponse(String message) { | |
| throw Exception('$message\n\n$_lastResponse\n\n$_errorBuffer'.trim()); | |
| } | |
| final bool spawnDdsInstance; | |
| } | |
| class FlutterTestTestDriver extends FlutterTestDriver { | |
| FlutterTestTestDriver(super.projectFolder, {super.logPrefix}); | |
| Future<void> test({ | |
| String testFile = 'test/test.dart', | |
| String? deviceId, | |
| bool withDebugger = false, | |
| bool pauseOnExceptions = false, | |
| bool coverage = false, | |
| Future<void> Function()? beforeStart, | |
| }) async { | |
| await _setupProcess(<String>[ | |
| 'test', | |
| ...getLocalEngineArguments(), | |
| '--disable-service-auth-codes', | |
| '--machine', | |
| if (coverage) | |
| '--coverage', | |
| if (deviceId != null) | |
| ...<String>['-d', deviceId], | |
| ], script: testFile, withDebugger: withDebugger, pauseOnExceptions: pauseOnExceptions, beforeStart: beforeStart); | |
| } | |
| @override | |
| Future<void> _setupProcess( | |
| List<String> args, { | |
| String? script, | |
| bool withDebugger = false, | |
| bool pauseOnExceptions = false, | |
| Future<void> Function()? beforeStart, | |
| bool singleWidgetReloads = false, | |
| }) async { | |
| await super._setupProcess( | |
| args, | |
| script: script, | |
| withDebugger: withDebugger, | |
| singleWidgetReloads: singleWidgetReloads, | |
| ); | |
| // 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, Object?>? version = await _waitForJson(); | |
| _processPid = version?['pid'] as int?; | |
| if (withDebugger) { | |
| final Map<String, Object?> startedProcessParams = | |
| (await _waitFor(event: 'test.startedProcess', timeout: appStartTimeout))['params']! as Map<String, Object?>; | |
| final String vmServiceHttpString = startedProcessParams['vmServiceUri']! as String; | |
| _vmServiceWsUri = Uri.parse(vmServiceHttpString).replace(scheme: 'ws', path: '/ws'); | |
| await connectToVmService(pauseOnExceptions: pauseOnExceptions); | |
| // Allow us to run code before we start, eg. to set up breakpoints. | |
| if (beforeStart != null) { | |
| await beforeStart(); | |
| } | |
| await resume(); | |
| } | |
| } | |
| Future<Map<String, Object?>?> _waitForJson({ | |
| Duration timeout = defaultTimeout, | |
| }) async { | |
| return _timeoutWithMessages<Map<String, Object?>?>( | |
| () => _stdout.stream.map<Map<String, Object?>?>(_parseJsonResponse) | |
| .firstWhere((Map<String, Object?>? output) => output != null), | |
| timeout: timeout, | |
| task: 'Waiting for JSON', | |
| ); | |
| } | |
| Map<String, Object?>? _parseJsonResponse(String line) { | |
| try { | |
| return castStringKeyedMap(json.decode(line)); | |
| } on Exception { | |
| // Not valid JSON, so likely some other output. | |
| return null; | |
| } | |
| } | |
| Future<void> waitForCompletion() async { | |
| final Completer<bool> done = Completer<bool>(); | |
| // Waiting for `{"success":true,"type":"done",...}` line indicating | |
| // end of test run. | |
| final StreamSubscription<String> subscription = _stdout.stream.listen( | |
| (String line) async { | |
| final Map<String, Object?>? json = _parseJsonResponse(line); | |
| if (json != null && json['type'] != null && json['success'] != null) { | |
| done.complete(json['type'] == 'done' && json['success'] == true); | |
| } | |
| }); | |
| await resume(); | |
| final Future<void> timeoutFuture = | |
| Future<void>.delayed(defaultTimeout); | |
| await Future.any<void>(<Future<void>>[done.future, timeoutFuture]); | |
| await subscription.cancel(); | |
| if (!done.isCompleted) { | |
| await quit(); | |
| } | |
| } | |
| } | |
| Stream<String> transformToLines(Stream<List<int>> byteStream) { | |
| return byteStream.transform<String>(utf8.decoder).transform<String>(const LineSplitter()); | |
| } | |
| Map<String, Object?>? parseFlutterResponse(String line) { | |
| if (line.startsWith('[') && line.endsWith(']') && line.length > 2) { | |
| try { | |
| final Map<String, Object?>? response = castStringKeyedMap((json.decode(line) as List<Object?>)[0]); | |
| return response; | |
| } on FormatException { | |
| // Not valid JSON, so likely some other output that was surrounded by [brackets] | |
| return null; | |
| } | |
| } | |
| return null; | |
| } | |
| class SourcePosition { | |
| SourcePosition(this.line, this.column); | |
| final int line; | |
| final int column; | |
| } | |
| Future<Isolate> waitForExtension(VmService vmService, String extension) async { | |
| final Completer<void> completer = Completer<void>(); | |
| try { | |
| await vmService.streamListen(EventStreams.kExtension); | |
| } on RPCError { | |
| // Do nothing, already subscribed. | |
| } | |
| vmService.onExtensionEvent.listen((Event event) { | |
| if (event.json?['extensionKind'] == 'Flutter.FrameworkInitialization') { | |
| completer.complete(); | |
| } | |
| }); | |
| final IsolateRef isolateRef = (await vmService.getVM()).isolates!.first; | |
| final Isolate isolate = await vmService.getIsolate(isolateRef.id!); | |
| if (isolate.extensionRPCs!.contains(extension)) { | |
| return isolate; | |
| } | |
| await completer.future; | |
| return isolate; | |
| } |