driver.dart 14.4 KB
Newer Older
1 2 3 4 5
// 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';
6
import 'dart:io';
7
import 'package:json_rpc_2/json_rpc_2.dart' as rpc;
8 9
import 'package:vm_service_client/vm_service_client.dart';
import 'package:web_socket_channel/io.dart';
10 11 12 13 14 15

import 'error.dart';
import 'find.dart';
import 'gesture.dart';
import 'health.dart';
import 'message.dart';
16
import 'timeline.dart';
17

18
enum TimelineStream {
19 20 21
  all, api, compiler, dart, debugger, embedder, gc, isolate, vm
}

22
const List<TimelineStream> _defaultStreams = const <TimelineStream>[TimelineStream.all];
23 24

// See https://github.com/dart-lang/sdk/blob/master/runtime/vm/timeline.cc#L32
25 26 27 28 29 30 31 32 33 34 35 36
String _timelineStreamsToString(List<TimelineStream> streams) {
  final String contents = streams.map((TimelineStream stream) {
    switch(stream) {
      case TimelineStream.all: return 'all';
      case TimelineStream.api: return 'API';
      case TimelineStream.compiler: return 'Compiler';
      case TimelineStream.dart: return 'Dart';
      case TimelineStream.debugger: return 'Debugger';
      case TimelineStream.embedder: return 'Embedder';
      case TimelineStream.gc: return 'GC';
      case TimelineStream.isolate: return 'Isolate';
      case TimelineStream.vm: return 'VM';
37
      default:
38
        throw 'Unknown timeline stream $stream';
39 40 41 42 43
    }
  }).join(', ');
  return '[$contents]';
}

yjbanov's avatar
yjbanov committed
44
final Logger _log = new Logger('FlutterDriver');
45

46 47 48 49
/// A convenient accessor to frequently used finders.
///
/// Examples:
///
50
///     driver.tap(find.text('Save'));
51 52 53
///     driver.scroll(find.byValueKey(42));
const CommonFinders find = const CommonFinders._();

54 55 56 57 58 59 60
/// Computes a value.
///
/// If computation is asynchronous, the function may return a [Future].
///
/// See also [FlutterDriver.waitFor].
typedef dynamic EvaluatorFunction();

61 62
/// Drives a Flutter Application running in another process.
class FlutterDriver {
63
  FlutterDriver.connectedTo(this._serviceClient, this._peer, this._appIsolate);
64

65
  static const String _kFlutterExtensionMethod = 'ext.flutter.driver';
66 67
  static const String _kSetVMTimelineFlagsMethod = '_setVMTimelineFlags';
  static const String _kGetVMTimelineMethod = '_getVMTimeline';
68
  static const Duration _kDefaultTimeout = const Duration(seconds: 5);
69 70 71 72 73 74

  /// 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
75 76
  /// default it connects to `http://localhost:8183`.
  static Future<FlutterDriver> connect({String dartVmServiceUrl: 'http://localhost:8183'}) async {
77 78
    // Connect to Dart VM servcies
    _log.info('Connecting to Flutter application at $dartVmServiceUrl');
79 80
    VMServiceClientConnection connection = await vmServiceConnectFunction(dartVmServiceUrl);
    VMServiceClient client = connection.client;
81 82
    VM vm = await client.getVM();
    _log.trace('Looking for the isolate');
83 84
    VMIsolate isolate = await vm.isolates.first.loadRunnable();

85 86 87 88 89
    // TODO(yjbanov): vm_service_client does not support "None" pause event yet.
    // It is currently reported as `null`, but we cannot rely on it because
    // eventually the event will be reported as a non-`null` object. For now,
    // list all the events we know about. Later we'll check for "None" event
    // explicitly.
90
    //
91 92 93 94 95 96 97
    // See: https://github.com/dart-lang/vm_service_client/issues/4
    if (isolate.pauseEvent is! VMPauseStartEvent &&
        isolate.pauseEvent is! VMPauseExitEvent &&
        isolate.pauseEvent is! VMPauseBreakpointEvent &&
        isolate.pauseEvent is! VMPauseExceptionEvent &&
        isolate.pauseEvent is! VMPauseInterruptedEvent &&
        isolate.pauseEvent is! VMResumeEvent) {
98
      await new Future<Null>.delayed(new Duration(milliseconds: 300));
99 100 101
      isolate = await vm.isolates.first.loadRunnable();
    }

102
    FlutterDriver driver = new FlutterDriver.connectedTo(client, connection.peer, isolate);
103 104 105 106

    // 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.
Ian Hickson's avatar
Ian Hickson committed
107
    Future<dynamic> resumeLeniently() {
108
      _log.trace('Attempting to resume isolate');
109 110
      return isolate.resume().catchError((dynamic e) {
        const int vmMustBePausedCode = 101;
111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129
        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
Ian Hickson's avatar
Ian Hickson committed
130
      Future<String> waitForServiceExtension() {
131
        return isolate.onExtensionAdded.firstWhere((String extension) {
132
          return extension == _kFlutterExtensionMethod;
133 134 135 136 137 138
        });
      }

      // 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.
Ian Hickson's avatar
Ian Hickson committed
139
      Future<dynamic> whenResumed = resumeLeniently();
140
      Future<dynamic> whenServiceExtensionReady = Future.any/*<dynamic>*/(<Future<dynamic>>[
141 142 143 144 145 146 147 148 149 150 151
        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. '
152 153 154
          'Ensure your test app (often: lib/main.dart) imports '
          '"package:flutter_driver/driver_extension.dart" and '
          'calls enableFlutterDriverExtension() as the first call in main().'
155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186
        );
      }
    } 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;
  }

  /// Client connected to the Dart VM running the Flutter application
  final VMServiceClient _serviceClient;
187 188
  /// JSON-RPC client useful for sending raw JSON requests.
  final rpc.Peer _peer;
189 190 191 192
  /// The main isolate hosting the Flutter application
  final VMIsolateRef _appIsolate;

  Future<Map<String, dynamic>> _sendCommand(Command command) async {
193 194
    Map<String, String> parameters = <String, String>{'command': command.kind}
      ..addAll(command.serialize());
195 196 197 198 199 200 201 202 203
    try {
      return await _appIsolate.invokeExtension(_kFlutterExtensionMethod, parameters);
    } catch (error, stackTrace) {
      throw new DriverError(
        'Failed to fulfill ${command.runtimeType} due to remote error',
        error,
        stackTrace
      );
    }
204 205 206 207 208 209 210
  }

  /// Checks the status of the Flutter Driver extension.
  Future<Health> checkHealth() async {
    return Health.fromJson(await _sendCommand(new GetHealth()));
  }

211 212
  /// Taps at the center of the widget located by [finder].
  Future<Null> tap(SerializableFinder finder) async {
213 214
    await _sendCommand(new Tap(finder));
    return null;
215 216
  }

217 218 219 220
  /// Waits until [finder] locates the target.
  Future<Null> waitFor(SerializableFinder finder, {Duration timeout: _kDefaultTimeout}) async {
    await _sendCommand(new WaitFor(finder, timeout: timeout));
    return null;
221 222
  }

223 224 225 226 227 228 229 230 231 232 233 234 235
  /// 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.
236 237
  Future<Null> scroll(SerializableFinder finder, double dx, double dy, Duration duration, {int frequency: 60}) async {
    return await _sendCommand(new Scroll(finder, dx, dy, duration, frequency)).then((Map<String, dynamic> _) => null);
238 239
  }

240 241 242 243 244 245
  /// Scrolls the Scrollable ancestor of the widget located by [finder]
  /// until the widget is completely visible.
  Future<Null> scrollIntoView(SerializableFinder finder) async {
    return await _sendCommand(new ScrollIntoView(finder)).then((Map<String, dynamic> _) => null);
  }

246 247 248
  /// Returns the text in the `Text` widget located by [finder].
  Future<String> getText(SerializableFinder finder) async {
    return GetTextResult.fromJson(await _sendCommand(new GetText(finder))).text;
249 250
  }

251
  /// Starts recording performance traces.
252 253
  Future<Null> startTracing({List<TimelineStream> streams: _defaultStreams}) async {
    assert(streams != null && streams.length > 0);
254
    try {
pq's avatar
pq committed
255
      await _peer.sendRequest(_kSetVMTimelineFlagsMethod, <String, String>{
256
        'recordedStreams': _timelineStreamsToString(streams)
257
      });
258 259 260 261 262 263 264 265 266 267
      return null;
    } catch(error, stackTrace) {
      throw new DriverError(
        'Failed to start tracing due to remote error',
        error,
        stackTrace
      );
    }
  }

268 269
  /// Stops recording performance traces and downloads the timeline.
  Future<Timeline> stopTracingAndDownloadTimeline() async {
270
    try {
pq's avatar
pq committed
271
      await _peer.sendRequest(_kSetVMTimelineFlagsMethod, <String, String>{'recordedStreams': '[]'});
272
      return new Timeline.fromJson(await _peer.sendRequest(_kGetVMTimelineMethod));
273 274
    } catch(error, stackTrace) {
      throw new DriverError(
275
        'Failed to stop tracing due to remote error',
276 277 278
        error,
        stackTrace
      );
279 280 281 282 283 284 285 286 287
    }
  }

  /// Runs [action] and outputs a performance trace for it.
  ///
  /// Waits for the `Future` returned by [action] to complete prior to stopping
  /// the trace.
  ///
  /// This is merely a convenience wrapper on top of [startTracing] and
288
  /// [stopTracingAndDownloadTimeline].
289 290
  Future<Timeline> traceAction(Future<dynamic> action(), { List<TimelineStream> streams: _defaultStreams }) async {
    await startTracing(streams: streams);
291
    await action();
292
    return stopTracingAndDownloadTimeline();
293 294
  }

295 296 297 298
  /// Closes the underlying connection to the VM service.
  ///
  /// Returns a [Future] that fires once the connection has been closed.
  // TODO(yjbanov): cleanup object references
299
  Future<Null> close() => _serviceClient.close().then((_) {
300 301 302 303
    // Don't leak vm_service_client-specific objects, if any
    return null;
  });
}
yjbanov's avatar
yjbanov committed
304

305 306 307 308 309 310 311 312 313 314 315 316 317 318
/// Encapsulates connection information to an instance of a Flutter application.
class VMServiceClientConnection {
  /// Use this for structured access to the VM service's public APIs.
  final VMServiceClient client;

  /// Use this to make arbitrary raw JSON-RPC calls.
  ///
  /// This object allows reaching into private VM service APIs. Use with
  /// caution.
  final rpc.Peer peer;

  VMServiceClientConnection(this.client, this.peer);
}

yjbanov's avatar
yjbanov committed
319
/// A function that connects to a Dart VM service given the [url].
320
typedef Future<VMServiceClientConnection> VMServiceConnectFunction(String url);
yjbanov's avatar
yjbanov committed
321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336

/// 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.
337
Future<VMServiceClientConnection> _waitAndConnect(String url) async {
338
  Stopwatch timer = new Stopwatch()..start();
339 340 341 342 343 344 345 346 347 348 349

  Future<VMServiceClientConnection> attemptConnection() async {
    Uri uri = Uri.parse(url);
    if (uri.scheme == 'http') uri = uri.replace(scheme: 'ws', path: '/ws');

    WebSocket ws1;
    WebSocket ws2;
    try {
      ws1 = await WebSocket.connect(uri.toString());
      ws2 = await WebSocket.connect(uri.toString());
      return new VMServiceClientConnection(
350 351
        new VMServiceClient(new IOWebSocketChannel(ws1)),
        new rpc.Peer(new IOWebSocketChannel(ws2))..listen()
352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371
      );
    } catch(e) {
      if (ws1 != null)
        ws1.close();

      if (ws2 != null)
        ws2.close();

      if (timer.elapsed < const Duration(seconds: 30)) {
        _log.info('Waiting for application to start');
        await new Future<Null>.delayed(const Duration(seconds: 1));
        return attemptConnection();
      } else {
        _log.critical(
          'Application has not started in 30 seconds. '
          'Giving up.'
        );
        throw e;
      }
    }
yjbanov's avatar
yjbanov committed
372
  }
373

yjbanov's avatar
yjbanov committed
374 375
  return attemptConnection();
}
376 377 378 379 380 381 382 383 384 385 386 387 388 389

/// Provides convenient accessors to frequently used finders.
class CommonFinders {
  const CommonFinders._();

  /// Finds [Text] widgets containing string equal to [text].
  SerializableFinder text(String text) => new ByText(text);

  /// Finds widgets by [key].
  SerializableFinder byValueKey(dynamic key) => new ByValueKey(key);

  /// Finds widgets with a tooltip with the given [message].
  SerializableFinder byTooltip(String message) => new ByTooltipMessage(message);
}