// Copyright 2016 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 'package:vm_service_client/vm_service_client.dart'; import 'package:matcher/matcher.dart'; import 'package:json_rpc_2/json_rpc_2.dart' as rpc; import 'error.dart'; import 'find.dart'; import 'gesture.dart'; import 'health.dart'; import 'matcher_util.dart'; import 'message.dart'; import 'retry.dart'; final Logger _log = new Logger('FlutterDriver'); /// Computes a value. /// /// If computation is asynchronous, the function may return a [Future]. /// /// See also [FlutterDriver.waitFor]. typedef dynamic EvaluatorFunction(); /// Drives a Flutter Application running in another process. class FlutterDriver { static const String _kFlutterExtensionMethod = 'ext.flutter_driver'; static const Duration _kDefaultTimeout = const Duration(seconds: 5); static const Duration _kDefaultPauseBetweenRetries = const Duration(milliseconds: 160); /// Connects to a Flutter application. /// /// Resumes the application if it is currently paused (e.g. at a breakpoint). /// /// [dartVmServiceUrl] is the URL to Dart observatory (a.k.a. VM service). By /// default it connects to `http://localhost:8181`. static Future<FlutterDriver> connect({String dartVmServiceUrl: 'http://localhost:8181'}) async { // Connect to Dart VM servcies _log.info('Connecting to Flutter application at $dartVmServiceUrl'); VMServiceClient client = await vmServiceConnectFunction(dartVmServiceUrl); VM vm = await client.getVM(); _log.trace('Looking for the isolate'); VMIsolate isolate = await vm.isolates.first.loadRunnable(); // TODO(yjbanov): for a very brief moment the isolate could report that it // is resumed, right before it goes into "paused on start" state. There's no // robust way to deal with it other than waiting and querying for the // isolate data again. 300 millis should be sufficient as the isolate is in // the runnable state (i.e. it loaded and parsed all the Dart code) and // going from here to the `main()` method should be trivial. // // See: https://github.com/dart-lang/sdk/issues/25902 if (isolate.pauseEvent is VMResumeEvent) { await new Future.delayed(new Duration(milliseconds: 300)); isolate = await vm.isolates.first.loadRunnable(); } FlutterDriver driver = new FlutterDriver.connectedTo(client, isolate); // Attempts to resume the isolate, but does not crash if it fails because // the isolate is already resumed. There could be a race with other tools, // such as a debugger, any of which could have resumed the isolate. Future resumeLeniently() { _log.trace('Attempting to resume isolate'); return isolate.resume().catchError((e) { const vmMustBePausedCode = 101; if (e is rpc.RpcException && e.code == vmMustBePausedCode) { // No biggie; something else must have resumed the isolate _log.warning( 'Attempted to resume an already resumed isolate. This may happen ' 'when we lose a race with another tool (usually a debugger) that ' 'is connected to the same isolate.' ); } else { // Failed to resume due to another reason. Fail hard. throw e; } }); } // Attempt to resume isolate if it was paused if (isolate.pauseEvent is VMPauseStartEvent) { _log.trace('Isolate is paused at start.'); // Waits for a signal from the VM service that the extension is registered Future waitForServiceExtension() { return isolate.onExtensionAdded.firstWhere((String extension) { return extension == _kFlutterExtensionMethod; }); } // If the isolate is paused at the start, e.g. via the --start-paused // option, then the VM service extension is not registered yet. Wait for // it to be registered. Future whenResumed = resumeLeniently(); Future whenServiceExtensionReady = Future.any(<Future>[ waitForServiceExtension(), // We will never receive the extension event if the user does not // register it. If that happens time out. new Future<String>.delayed(const Duration(seconds: 10), () => 'timeout') ]); await whenResumed; _log.trace('Waiting for service extension'); dynamic signal = await whenServiceExtensionReady; if (signal == 'timeout') { throw new DriverError( 'Timed out waiting for Flutter Driver extension to become available. ' 'To enable the driver extension call registerFlutterDriverExtension ' 'first thing in the main method of your application.' ); } } else if (isolate.pauseEvent is VMPauseExitEvent || isolate.pauseEvent is VMPauseBreakpointEvent || isolate.pauseEvent is VMPauseExceptionEvent || isolate.pauseEvent is VMPauseInterruptedEvent) { // If the isolate is paused for any other reason, assume the extension is // already there. _log.trace('Isolate is paused mid-flight.'); await resumeLeniently(); } else if (isolate.pauseEvent is VMResumeEvent) { _log.trace('Isolate is not paused. Assuming application is ready.'); } else { _log.warning( 'Unknown pause event type ${isolate.pauseEvent.runtimeType}. ' 'Assuming application is ready.' ); } // At this point the service extension must be installed. Verify it. Health health = await driver.checkHealth(); if (health.status != HealthStatus.ok) { client.close(); throw new DriverError('Flutter application health check failed.'); } _log.info('Connected to Flutter application.'); return driver; } FlutterDriver.connectedTo(this._serviceClient, this._appIsolate); /// Client connected to the Dart VM running the Flutter application final VMServiceClient _serviceClient; /// The main isolate hosting the Flutter application final VMIsolateRef _appIsolate; Future<Map<String, dynamic>> _sendCommand(Command command) async { Map<String, String> parameters = <String, String>{'command': command.kind} ..addAll(command.serialize()); return _appIsolate.invokeExtension(_kFlutterExtensionMethod, parameters) .then((Map<String, dynamic> result) => result, onError: (error, stackTrace) { throw new DriverError( 'Failed to fulfill ${command.runtimeType} due to remote error', error, stackTrace ); }); } /// Checks the status of the Flutter Driver extension. Future<Health> checkHealth() async { return Health.fromJson(await _sendCommand(new GetHealth())); } /// Finds the UI element with the given [key]. Future<ObjectRef> findByValueKey(dynamic key) async { return ObjectRef.fromJson(await _sendCommand(new Find(new ByValueKey(key)))); } /// Finds the UI element for the tooltip with the given [message]. Future<ObjectRef> findByTooltipMessage(String message) async { return ObjectRef.fromJson(await _sendCommand(new Find(new ByTooltipMessage(message)))); } /// Finds the text element with the given [text]. Future<ObjectRef> findByText(String text) async { return ObjectRef.fromJson(await _sendCommand(new Find(new ByText(text)))); } Future<Null> tap(ObjectRef ref) async { return await _sendCommand(new Tap(ref)).then((_) => null); } /// Tell the driver to perform a scrolling action. /// /// A scrolling action begins with a "pointer down" event, which commonly maps /// to finger press on the touch screen or mouse button press. A series of /// "pointer move" events follow. The action is completed by a "pointer up" /// event. /// /// [dx] and [dy] specify the total offset for the entire scrolling action. /// /// [duration] specifies the lenght of the action. /// /// The move events are generated at a given [frequency] in Hz (or events per /// second). It defaults to 60Hz. Future<Null> scroll(ObjectRef ref, double dx, double dy, Duration duration, {int frequency: 60}) async { return await _sendCommand(new Scroll(ref, dx, dy, duration, frequency)).then((_) => null); } Future<String> getText(ObjectRef ref) async { GetTextResult result = GetTextResult.fromJson(await _sendCommand(new GetText(ref))); return result.text; } /// Calls the [evaluator] repeatedly until the result of the evaluation /// satisfies the [matcher]. /// /// Returns the result of the evaluation. Future<String> waitFor(EvaluatorFunction evaluator, Matcher matcher, { Duration timeout: _kDefaultTimeout, Duration pauseBetweenRetries: _kDefaultPauseBetweenRetries }) async { return retry(() async { dynamic value = await evaluator(); MatchResult matchResult = match(value, matcher); if (!matchResult.hasMatched) { return new Future.error(matchResult.mismatchDescription); } return value; }, timeout, pauseBetweenRetries); } /// Closes the underlying connection to the VM service. /// /// Returns a [Future] that fires once the connection has been closed. // TODO(yjbanov): cleanup object references Future close() => _serviceClient.close().then((_) { // Don't leak vm_service_client-specific objects, if any return null; }); } /// A function that connects to a Dart VM service given the [url]. typedef Future<VMServiceClient> VMServiceConnectFunction(String url); /// The connection function used by [FlutterDriver.connect]. /// /// Overwrite this function if you require a custom method for connecting to /// the VM service. VMServiceConnectFunction vmServiceConnectFunction = _waitAndConnect; /// Restores [vmServiceConnectFunction] to its default value. void restoreVmServiceConnectFunction() { vmServiceConnectFunction = _waitAndConnect; } /// Waits for a real Dart VM service to become available, then connects using /// the [VMServiceClient]. /// /// Times out after 30 seconds. Future<VMServiceClient> _waitAndConnect(String url) async { Stopwatch timer = new Stopwatch(); Future<VMServiceClient> attemptConnection() { return VMServiceClient.connect(url) .catchError((e) async { if (timer.elapsed < const Duration(seconds: 30)) { _log.info('Waiting for application to start'); await new Future.delayed(const Duration(seconds: 1)); return attemptConnection(); } else { _log.critical( 'Application has not started in 30 seconds. ' 'Giving up.' ); throw e; } }); } return attemptConnection(); }