driver.dart 15.2 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

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

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

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

// See https://github.com/dart-lang/sdk/blob/master/runtime/vm/timeline.cc#L32
26 27 28 29 30 31 32 33 34 35 36 37
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';
38
      default:
39
        throw 'Unknown timeline stream $stream';
40 41 42 43 44
    }
  }).join(', ');
  return '[$contents]';
}

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

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

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

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

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

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

86 87 88 89 90
    // 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.
91
    //
92 93 94 95 96 97 98
    // 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) {
99
      await new Future<Null>.delayed(new Duration(milliseconds: 300));
100 101 102
      isolate = await vm.isolates.first.loadRunnable();
    }

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

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

      // 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
140
      Future<dynamic> whenResumed = resumeLeniently();
141
      Future<dynamic> whenServiceExtensionReady = Future.any/*<dynamic>*/(<Future<dynamic>>[
142 143 144 145 146 147 148 149 150 151 152
        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. '
153 154 155
          'Ensure your test app (often: lib/main.dart) imports '
          '"package:flutter_driver/driver_extension.dart" and '
          'calls enableFlutterDriverExtension() as the first call in main().'
156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177
        );
      }
    } 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) {
178
      await client.close();
179 180 181 182 183 184 185 186 187
      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;
188 189
  /// JSON-RPC client useful for sending raw JSON requests.
  final rpc.Peer _peer;
190 191 192 193
  /// The main isolate hosting the Flutter application
  final VMIsolateRef _appIsolate;

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

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

212 213
  /// Taps at the center of the widget located by [finder].
  Future<Null> tap(SerializableFinder finder) async {
214 215
    await _sendCommand(new Tap(finder));
    return null;
216
  }
217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234

  /// Sets the text value of the `Input` widget located by [finder].
  ///
  /// This command invokes the `onChanged` handler of the `Input` widget with
  /// the provided [text].
  Future<Null> setInputText(SerializableFinder finder, String text) async {
    await _sendCommand(new SetInputText(finder, text));
    return null;
  }

  /// Submits the current text value of the `Input` widget located by [finder].
  ///
  /// This command invokes the `onSubmitted` handler of the `Input` widget and
  /// the returns the submitted text value.
  Future<String> submitInputText(SerializableFinder finder) async {
    Map<String, dynamic> json = await _sendCommand(new SubmitInputText(finder));
    return json['text'];
  }
235

236 237 238 239
  /// Waits until [finder] locates the target.
  Future<Null> waitFor(SerializableFinder finder, {Duration timeout: _kDefaultTimeout}) async {
    await _sendCommand(new WaitFor(finder, timeout: timeout));
    return null;
240 241
  }

242 243 244 245 246 247 248 249 250 251 252 253 254
  /// 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.
255 256
  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);
257 258
  }

259 260 261 262 263 264
  /// 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);
  }

265 266 267
  /// 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;
268 269
  }

270
  /// Starts recording performance traces.
271 272
  Future<Null> startTracing({List<TimelineStream> streams: _defaultStreams}) async {
    assert(streams != null && streams.length > 0);
273
    try {
pq's avatar
pq committed
274
      await _peer.sendRequest(_kSetVMTimelineFlagsMethod, <String, String>{
275
        'recordedStreams': _timelineStreamsToString(streams)
276
      });
277 278 279 280 281 282 283 284 285 286
      return null;
    } catch(error, stackTrace) {
      throw new DriverError(
        'Failed to start tracing due to remote error',
        error,
        stackTrace
      );
    }
  }

287 288
  /// Stops recording performance traces and downloads the timeline.
  Future<Timeline> stopTracingAndDownloadTimeline() async {
289
    try {
pq's avatar
pq committed
290
      await _peer.sendRequest(_kSetVMTimelineFlagsMethod, <String, String>{'recordedStreams': '[]'});
291
      return new Timeline.fromJson(await _peer.sendRequest(_kGetVMTimelineMethod));
292 293
    } catch(error, stackTrace) {
      throw new DriverError(
294
        'Failed to stop tracing due to remote error',
295 296 297
        error,
        stackTrace
      );
298 299 300 301 302 303 304 305 306
    }
  }

  /// 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
307
  /// [stopTracingAndDownloadTimeline].
308 309
  Future<Timeline> traceAction(Future<dynamic> action(), { List<TimelineStream> streams: _defaultStreams }) async {
    await startTracing(streams: streams);
310
    await action();
311
    return stopTracingAndDownloadTimeline();
312 313
  }

314 315 316 317
  /// Closes the underlying connection to the VM service.
  ///
  /// Returns a [Future] that fires once the connection has been closed.
  // TODO(yjbanov): cleanup object references
318
  Future<Null> close() async {
319
    // Don't leak vm_service_client-specific objects, if any
320 321 322
    await _serviceClient.close();
    await _peer.close();
  }
323
}
yjbanov's avatar
yjbanov committed
324

325 326 327 328 329 330 331 332 333 334 335 336 337 338
/// 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
339
/// A function that connects to a Dart VM service given the [url].
340
typedef Future<VMServiceClientConnection> VMServiceConnectFunction(String url);
yjbanov's avatar
yjbanov committed
341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356

/// 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.
357
Future<VMServiceClientConnection> _waitAndConnect(String url) async {
358
  Stopwatch timer = new Stopwatch()..start();
359 360 361 362 363 364 365 366 367 368 369

  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(
370 371
        new VMServiceClient(new IOWebSocketChannel(ws1).cast()),
        new rpc.Peer(new IOWebSocketChannel(ws2).cast())..listen()
372 373
      );
    } catch(e) {
374 375
      await ws1?.close();
      await ws2?.close();
376 377 378 379 380 381 382 383 384 385 386 387 388

      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
389
  }
390

yjbanov's avatar
yjbanov committed
391 392
  return attemptConnection();
}
393 394 395 396 397 398 399 400 401 402 403 404 405 406

/// 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);
}