| // 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 'dart:convert'; |
| import 'dart:io'; |
| |
| import 'package:file/file.dart' as f; |
| import 'package:fuchsia_remote_debug_protocol/fuchsia_remote_debug_protocol.dart' as fuchsia; |
| import 'package:json_rpc_2/error_code.dart' as error_code; |
| import 'package:json_rpc_2/json_rpc_2.dart' as rpc; |
| import 'package:meta/meta.dart'; |
| import 'package:path/path.dart' as p; |
| import 'package:vm_service_client/vm_service_client.dart'; |
| import 'package:web_socket_channel/io.dart'; |
| |
| import '../common/diagnostics_tree.dart'; |
| import '../common/error.dart'; |
| import '../common/find.dart'; |
| import '../common/frame_sync.dart'; |
| import '../common/fuchsia_compat.dart'; |
| import '../common/geometry.dart'; |
| import '../common/gesture.dart'; |
| import '../common/health.dart'; |
| import '../common/message.dart'; |
| import '../common/render_tree.dart'; |
| import '../common/request_data.dart'; |
| import '../common/semantics.dart'; |
| import '../common/text.dart'; |
| import 'common.dart'; |
| import 'timeline.dart'; |
| |
| /// Timeline stream identifier. |
| enum TimelineStream { |
| /// A meta-identifier that instructs the Dart VM to record all streams. |
| all, |
| |
| /// Marks events related to calls made via Dart's C API. |
| api, |
| |
| /// Marks events from the Dart VM's JIT compiler. |
| compiler, |
| |
| /// Marks events emitted using the `dart:developer` API. |
| dart, |
| |
| /// Marks events from the Dart VM debugger. |
| debugger, |
| |
| /// Marks events emitted using the `dart_tools_api.h` C API. |
| embedder, |
| |
| /// Marks events from the garbage collector. |
| gc, |
| |
| /// Marks events related to message passing between Dart isolates. |
| isolate, |
| |
| /// Marks internal VM events. |
| vm, |
| } |
| |
| const List<TimelineStream> _defaultStreams = <TimelineStream>[TimelineStream.all]; |
| |
| /// How long to wait before showing a message saying that |
| /// things seem to be taking a long time. |
| @visibleForTesting |
| const Duration kUnusuallyLongTimeout = Duration(seconds: 5); |
| |
| /// The amount of time we wait prior to making the next attempt to connect to |
| /// the VM service. |
| const Duration _kPauseBetweenReconnectAttempts = Duration(seconds: 1); |
| |
| // See https://github.com/dart-lang/sdk/blob/master/runtime/vm/timeline.cc#L32 |
| String _timelineStreamsToString(List<TimelineStream> streams) { |
| final String contents = streams.map<String>((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'; |
| default: |
| throw 'Unknown timeline stream $stream'; |
| } |
| }).join(', '); |
| return '[$contents]'; |
| } |
| |
| final Logger _log = Logger('FlutterDriver'); |
| |
| Future<T> _warnIfSlow<T>({ |
| @required Future<T> future, |
| @required Duration timeout, |
| @required String message, |
| }) { |
| assert(future != null); |
| assert(timeout != null); |
| assert(message != null); |
| return future..timeout(timeout, onTimeout: () { _log.warning(message); return null; }); |
| } |
| |
| /// A convenient accessor to frequently used finders. |
| /// |
| /// Examples: |
| /// |
| /// driver.tap(find.text('Save')); |
| /// driver.scroll(find.byValueKey(42)); |
| const CommonFinders find = CommonFinders._(); |
| |
| /// Computes a value. |
| /// |
| /// If computation is asynchronous, the function may return a [Future]. |
| /// |
| /// See also [FlutterDriver.waitFor]. |
| typedef EvaluatorFunction = dynamic Function(); |
| |
| /// Drives a Flutter Application running in another process. |
| class FlutterDriver { |
| /// Creates a driver that uses a connection provided by the given |
| /// [serviceClient], [_peer] and [appIsolate]. |
| @visibleForTesting |
| FlutterDriver.connectedTo( |
| this.serviceClient, |
| this._peer, |
| this.appIsolate, { |
| bool printCommunication = false, |
| bool logCommunicationToFile = true, |
| }) : _printCommunication = printCommunication, |
| _logCommunicationToFile = logCommunicationToFile, |
| _driverId = _nextDriverId++; |
| |
| static const String _flutterExtensionMethodName = 'ext.flutter.driver'; |
| static const String _setVMTimelineFlagsMethodName = 'setVMTimelineFlags'; |
| static const String _getVMTimelineMethodName = 'getVMTimeline'; |
| static const String _clearVMTimelineMethodName = 'clearVMTimeline'; |
| static const String _collectAllGarbageMethodName = '_collectAllGarbage'; |
| |
| static int _nextDriverId = 0; |
| |
| // The additional blank line in the beginning is for _log.warning. |
| static const String _kDebugWarning = ''' |
| |
| ┏╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍┓ |
| ┇ ⚠ THIS BENCHMARK IS BEING RUN IN DEBUG MODE ⚠ ┇ |
| ┡╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍┦ |
| │ │ |
| │ Numbers obtained from a benchmark while asserts are │ |
| │ enabled will not accurately reflect the performance │ |
| │ that will be experienced by end users using release ╎ |
| │ builds. Benchmarks should be run using this command ┆ |
| │ line: flutter drive --profile test_perf.dart ┊ |
| │ ┊ |
| └─────────────────────────────────────────────────╌┄┈ 🐢 |
| '''; |
| |
| /// 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). If |
| /// not specified, the URL specified by the `VM_SERVICE_URL` environment |
| /// variable is used. One or the other must be specified. |
| /// |
| /// `printCommunication` determines whether the command communication between |
| /// the test and the app should be printed to stdout. |
| /// |
| /// `logCommunicationToFile` determines whether the command communication |
| /// between the test and the app should be logged to `flutter_driver_commands.log`. |
| /// |
| /// `isolateNumber` determines the specific isolate to connect to. |
| /// If this is left as `null`, will connect to the first isolate found |
| /// running on `dartVmServiceUrl`. |
| /// |
| /// `fuchsiaModuleTarget` specifies the pattern for determining which mod to |
| /// control. When running on a Fuchsia device, either this or the environment |
| /// variable `FUCHSIA_MODULE_TARGET` must be set (the environment variable is |
| /// treated as a substring pattern). This field will be ignored if |
| /// `isolateNumber` is set, as this is already enough information to connect |
| /// to an isolate. |
| /// |
| /// The return value is a future. This method never times out, though it may |
| /// fail (completing with an error). A timeout can be applied by the caller |
| /// using [Future.timeout] if necessary. |
| static Future<FlutterDriver> connect({ |
| String dartVmServiceUrl, |
| bool printCommunication = false, |
| bool logCommunicationToFile = true, |
| int isolateNumber, |
| Pattern fuchsiaModuleTarget, |
| }) async { |
| // If running on a Fuchsia device, connect to the first isolate whose name |
| // matches FUCHSIA_MODULE_TARGET. |
| // |
| // If the user has already supplied an isolate number/URL to the Dart VM |
| // service, then this won't be run as it is unnecessary. |
| if (Platform.isFuchsia && isolateNumber == null) { |
| // TODO(awdavies): Use something other than print. On fuchsia |
| // `stderr`/`stdout` appear to have issues working correctly. |
| flutterDriverLog.listen(print); |
| fuchsiaModuleTarget ??= Platform.environment['FUCHSIA_MODULE_TARGET']; |
| if (fuchsiaModuleTarget == null) { |
| throw DriverError( |
| 'No Fuchsia module target has been specified.\n' |
| 'Please make sure to specify the FUCHSIA_MODULE_TARGET ' |
| 'environment variable.' |
| ); |
| } |
| final fuchsia.FuchsiaRemoteConnection fuchsiaConnection = |
| await FuchsiaCompat.connect(); |
| final List<fuchsia.IsolateRef> refs = |
| await fuchsiaConnection.getMainIsolatesByPattern(fuchsiaModuleTarget); |
| final fuchsia.IsolateRef ref = refs.first; |
| isolateNumber = ref.number; |
| dartVmServiceUrl = ref.dartVm.uri.toString(); |
| await fuchsiaConnection.stop(); |
| FuchsiaCompat.cleanup(); |
| } |
| |
| dartVmServiceUrl ??= Platform.environment['VM_SERVICE_URL']; |
| |
| if (dartVmServiceUrl == null) { |
| throw DriverError( |
| 'Could not determine URL to connect to application.\n' |
| 'Either the VM_SERVICE_URL environment variable should be set, or an explicit ' |
| 'URL should be provided to the FlutterDriver.connect() method.' |
| ); |
| } |
| |
| // Connect to Dart VM services |
| _log.info('Connecting to Flutter application at $dartVmServiceUrl'); |
| final VMServiceClientConnection connection = |
| await vmServiceConnectFunction(dartVmServiceUrl); |
| final VMServiceClient client = connection.client; |
| final VM vm = await client.getVM(); |
| final VMIsolateRef isolateRef = isolateNumber == |
| null ? vm.isolates.first : |
| vm.isolates.firstWhere( |
| (VMIsolateRef isolate) => isolate.number == isolateNumber); |
| _log.trace('Isolate found with number: ${isolateRef.number}'); |
| |
| VMIsolate isolate = await isolateRef.loadRunnable(); |
| |
| // 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. |
| // |
| // 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) { |
| isolate = await isolateRef.loadRunnable(); |
| } |
| |
| final FlutterDriver driver = FlutterDriver.connectedTo( |
| client, connection.peer, isolate, |
| printCommunication: printCommunication, |
| logCommunicationToFile: logCommunicationToFile, |
| ); |
| |
| driver._dartVmReconnectUrl = dartVmServiceUrl; |
| |
| // 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<dynamic> resumeLeniently() { |
| _log.trace('Attempting to resume isolate'); |
| return isolate.resume().catchError((dynamic e) { |
| const int 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; |
| } |
| }); |
| } |
| |
| /// Waits for a signal from the VM service that the extension is registered. |
| /// Returns [_flutterExtensionMethodName] |
| Future<String> waitForServiceExtension() { |
| return isolate.onExtensionAdded.firstWhere((String extension) { |
| return extension == _flutterExtensionMethodName; |
| }); |
| } |
| |
| /// Tells the Dart VM Service to notify us about "Isolate" events. |
| /// |
| /// This is a workaround for an issue in package:vm_service_client, which |
| /// subscribes to the "Isolate" stream lazily upon subscription, which |
| /// results in lost events. |
| /// |
| /// Details: https://github.com/dart-lang/vm_service_client/issues/17 |
| Future<void> enableIsolateStreams() async { |
| await connection.peer.sendRequest('streamListen', <String, String>{ |
| 'streamId': 'Isolate', |
| }); |
| } |
| |
| // Attempt to resume isolate if it was paused |
| if (isolate.pauseEvent is VMPauseStartEvent) { |
| _log.trace('Isolate is paused at start.'); |
| |
| // 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. |
| await enableIsolateStreams(); |
| final Future<String> whenServiceExtensionReady = waitForServiceExtension(); |
| final Future<dynamic> whenResumed = resumeLeniently(); |
| await whenResumed; |
| |
| _log.trace('Waiting for service extension'); |
| // We will never receive the extension event if the user does not |
| // register it. If that happens, show a message but continue waiting. |
| await _warnIfSlow<String>( |
| future: whenServiceExtensionReady, |
| timeout: kUnusuallyLongTimeout, |
| message: 'Flutter Driver extension is taking a long time to become available. ' |
| 'Ensure your test app (often "lib/main.dart") imports ' |
| '"package:flutter_driver/driver_extension.dart" and ' |
| 'calls enableFlutterDriverExtension() as the first call in main().', |
| ); |
| } 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.' |
| ); |
| } |
| |
| // Invoked checkHealth and try to fix delays in the registration of Service |
| // extensions |
| Future<Health> checkHealth() async { |
| try { |
| // At this point the service extension must be installed. Verify it. |
| return await driver.checkHealth(); |
| } on rpc.RpcException catch (e) { |
| if (e.code != error_code.METHOD_NOT_FOUND) { |
| rethrow; |
| } |
| _log.trace( |
| 'Check Health failed, try to wait for the service extensions to be' |
| 'registered.' |
| ); |
| await enableIsolateStreams(); |
| await waitForServiceExtension(); |
| return driver.checkHealth(); |
| } |
| } |
| |
| final Health health = await checkHealth(); |
| if (health.status != HealthStatus.ok) { |
| await client.close(); |
| throw DriverError('Flutter application health check failed.'); |
| } |
| |
| _log.info('Connected to Flutter application.'); |
| return driver; |
| } |
| |
| /// The unique ID of this driver instance. |
| final int _driverId; |
| |
| /// Client connected to the Dart VM running the Flutter application |
| /// |
| /// You can use [VMServiceClient] to check VM version, flags and get |
| /// notified when a new isolate has been instantiated. That could be |
| /// useful if your application spawns multiple isolates that you |
| /// would like to instrument. |
| final VMServiceClient serviceClient; |
| |
| /// JSON-RPC client useful for sending raw JSON requests. |
| rpc.Peer _peer; |
| |
| String _dartVmReconnectUrl; |
| |
| Future<void> _restorePeerConnectionIfNeeded() async { |
| if (!_peer.isClosed || _dartVmReconnectUrl == null) { |
| return; |
| } |
| |
| _log.warning( |
| 'Peer connection is closed! Trying to restore the connection...' |
| ); |
| |
| final String webSocketUrl = _getWebSocketUrl(_dartVmReconnectUrl); |
| final WebSocket ws = await WebSocket.connect(webSocketUrl); |
| ws.done.whenComplete(() => _checkCloseCode(ws)); |
| _peer = rpc.Peer( |
| IOWebSocketChannel(ws).cast(), |
| onUnhandledError: _unhandledJsonRpcError, |
| )..listen(); |
| } |
| |
| /// The main isolate hosting the Flutter application. |
| /// |
| /// If you used the [registerExtension] API to instrument your application, |
| /// you can use this [VMIsolate] to call these extension methods via |
| /// [invokeExtension]. |
| final VMIsolate appIsolate; |
| |
| /// Whether to print communication between host and app to `stdout`. |
| final bool _printCommunication; |
| |
| /// Whether to log communication between host and app to `flutter_driver_commands.log`. |
| final bool _logCommunicationToFile; |
| |
| Future<Map<String, dynamic>> _sendCommand(Command command) async { |
| Map<String, dynamic> response; |
| try { |
| final Map<String, String> serialized = command.serialize(); |
| _logCommunication('>>> $serialized'); |
| final Future<Map<String, dynamic>> future = appIsolate.invokeExtension( |
| _flutterExtensionMethodName, |
| serialized, |
| ).then<Map<String, dynamic>>((Object value) => value); |
| response = await _warnIfSlow<Map<String, dynamic>>( |
| future: future, |
| timeout: command.timeout ?? kUnusuallyLongTimeout, |
| message: '${command.kind} message is taking a long time to complete...', |
| ); |
| _logCommunication('<<< $response'); |
| } catch (error, stackTrace) { |
| throw DriverError( |
| 'Failed to fulfill ${command.runtimeType} due to remote error', |
| error, |
| stackTrace, |
| ); |
| } |
| if (response['isError']) |
| throw DriverError('Error in Flutter application: ${response['response']}'); |
| return response['response']; |
| } |
| |
| void _logCommunication(String message) { |
| if (_printCommunication) |
| _log.info(message); |
| if (_logCommunicationToFile) { |
| final f.File file = fs.file(p.join(testOutputsDirectory, 'flutter_driver_commands_$_driverId.log')); |
| file.createSync(recursive: true); // no-op if file exists |
| file.writeAsStringSync('${DateTime.now()} $message\n', mode: f.FileMode.append, flush: true); |
| } |
| } |
| |
| /// Checks the status of the Flutter Driver extension. |
| Future<Health> checkHealth({ Duration timeout }) async { |
| return Health.fromJson(await _sendCommand(GetHealth(timeout: timeout))); |
| } |
| |
| /// Returns a dump of the render tree. |
| Future<RenderTree> getRenderTree({ Duration timeout }) async { |
| return RenderTree.fromJson(await _sendCommand(GetRenderTree(timeout: timeout))); |
| } |
| |
| /// Taps at the center of the widget located by [finder]. |
| Future<void> tap(SerializableFinder finder, { Duration timeout }) async { |
| await _sendCommand(Tap(finder, timeout: timeout)); |
| } |
| |
| /// Waits until [finder] locates the target. |
| Future<void> waitFor(SerializableFinder finder, { Duration timeout }) async { |
| await _sendCommand(WaitFor(finder, timeout: timeout)); |
| } |
| |
| /// Waits until [finder] can no longer locate the target. |
| Future<void> waitForAbsent(SerializableFinder finder, { Duration timeout }) async { |
| await _sendCommand(WaitForAbsent(finder, timeout: timeout)); |
| } |
| |
| /// Waits until there are no more transient callbacks in the queue. |
| /// |
| /// Use this method when you need to wait for the moment when the application |
| /// becomes "stable", for example, prior to taking a [screenshot]. |
| Future<void> waitUntilNoTransientCallbacks({ Duration timeout }) async { |
| await _sendCommand(WaitUntilNoTransientCallbacks(timeout: timeout)); |
| } |
| |
| Future<DriverOffset> _getOffset(SerializableFinder finder, OffsetType type, { Duration timeout }) async { |
| final GetOffset command = GetOffset(finder, type, timeout: timeout); |
| final GetOffsetResult result = GetOffsetResult.fromJson(await _sendCommand(command)); |
| return DriverOffset(result.dx, result.dy); |
| } |
| |
| /// Returns the point at the top left of the widget identified by `finder`. |
| /// |
| /// The offset is expressed in logical pixels and can be translated to |
| /// device pixels via [Window.devicePixelRatio]. |
| Future<DriverOffset> getTopLeft(SerializableFinder finder, { Duration timeout }) async { |
| return _getOffset(finder, OffsetType.topLeft, timeout: timeout); |
| } |
| |
| /// Returns the point at the top right of the widget identified by `finder`. |
| /// |
| /// The offset is expressed in logical pixels and can be translated to |
| /// device pixels via [Window.devicePixelRatio]. |
| Future<DriverOffset> getTopRight(SerializableFinder finder, { Duration timeout }) async { |
| return _getOffset(finder, OffsetType.topRight, timeout: timeout); |
| } |
| |
| /// Returns the point at the bottom left of the widget identified by `finder`. |
| /// |
| /// The offset is expressed in logical pixels and can be translated to |
| /// device pixels via [Window.devicePixelRatio]. |
| Future<DriverOffset> getBottomLeft(SerializableFinder finder, { Duration timeout }) async { |
| return _getOffset(finder, OffsetType.bottomLeft, timeout: timeout); |
| } |
| |
| /// Returns the point at the bottom right of the widget identified by `finder`. |
| /// |
| /// The offset is expressed in logical pixels and can be translated to |
| /// device pixels via [Window.devicePixelRatio]. |
| Future<DriverOffset> getBottomRight(SerializableFinder finder, { Duration timeout }) async { |
| return _getOffset(finder, OffsetType.bottomRight, timeout: timeout); |
| } |
| |
| /// Returns the point at the center of the widget identified by `finder`. |
| /// |
| /// The offset is expressed in logical pixels and can be translated to |
| /// device pixels via [Window.devicePixelRatio]. |
| Future<DriverOffset> getCenter(SerializableFinder finder, { Duration timeout }) async { |
| return _getOffset(finder, OffsetType.center, timeout: timeout); |
| } |
| |
| /// Returns a JSON map of the [DiagnosticsNode] that is associated with the |
| /// [RenderObject] identified by `finder`. |
| /// |
| /// The `subtreeDepth` argument controls how many layers of children will be |
| /// included in the result. It defaults to zero, which means that no children |
| /// of the [RenderObject] identified by `finder` will be part of the result. |
| /// |
| /// The `includeProperties` argument controls whether properties of the |
| /// [DiagnosticsNode]s will be included in the result. It defaults to true. |
| /// |
| /// [RenderObject]s are responsible for positioning, layout, and painting on |
| /// the screen, based on the configuration from a [Widget]. Callers that need |
| /// information about size or position should use this method. |
| /// |
| /// A widget may indirectly create multiple [RenderObject]s, which each |
| /// implement some aspect of the widget configuration. A 1:1 relationship |
| /// should not be assumed. |
| /// |
| /// See also: |
| /// |
| /// * [getWidgetDiagnostics], which gets the [DiagnosticsNode] of a [Widget]. |
| Future<Map<String, Object>> getRenderObjectDiagnostics( |
| SerializableFinder finder, { |
| int subtreeDepth = 0, |
| bool includeProperties = true, |
| Duration timeout, |
| }) async { |
| return _sendCommand(GetDiagnosticsTree( |
| finder, |
| DiagnosticsType.renderObject, |
| subtreeDepth: subtreeDepth, |
| includeProperties: includeProperties, |
| timeout: timeout, |
| )); |
| } |
| |
| /// Returns a JSON map of the [DiagnosticsNode] that is associated with the |
| /// [Widget] identified by `finder`. |
| /// |
| /// The `subtreeDepth` argument controls how many layers of children will be |
| /// included in the result. It defaults to zero, which means that no children |
| /// of the [Widget] identified by `finder` will be part of the result. |
| /// |
| /// The `includeProperties` argument controls whether properties of the |
| /// [DiagnosticsNode]s will be included in the result. It defaults to true. |
| /// |
| /// [Widget]s describe configuration for the rendering tree. Individual |
| /// widgets may create multiple [RenderObject]s to actually layout and paint |
| /// the desired configuration. |
| /// |
| /// See also: |
| /// |
| /// * [getRenderObjectDiagnostics], which gets the [DiagnosticsNode] of a |
| /// [RenderObject]. |
| Future<Map<String, Object>> getWidgetDiagnostics( |
| SerializableFinder finder, { |
| int subtreeDepth = 0, |
| bool includeProperties = true, |
| Duration timeout, |
| }) async { |
| return _sendCommand(GetDiagnosticsTree( |
| finder, |
| DiagnosticsType.renderObject, |
| subtreeDepth: subtreeDepth, |
| includeProperties: includeProperties, |
| timeout: timeout, |
| )); |
| } |
| |
| /// 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 length of the action. |
| /// |
| /// The move events are generated at a given [frequency] in Hz (or events per |
| /// second). It defaults to 60Hz. |
| Future<void> scroll(SerializableFinder finder, double dx, double dy, Duration duration, { int frequency = 60, Duration timeout }) async { |
| await _sendCommand(Scroll(finder, dx, dy, duration, frequency, timeout: timeout)); |
| } |
| |
| /// Scrolls the Scrollable ancestor of the widget located by [finder] |
| /// until the widget is completely visible. |
| /// |
| /// If the widget located by [finder] is contained by a scrolling widget |
| /// that lazily creates its children, like [ListView] or [CustomScrollView], |
| /// then this method may fail because [finder] doesn't actually exist. |
| /// The [scrollUntilVisible] method can be used in this case. |
| Future<void> scrollIntoView(SerializableFinder finder, { double alignment = 0.0, Duration timeout }) async { |
| await _sendCommand(ScrollIntoView(finder, alignment: alignment, timeout: timeout)); |
| } |
| |
| /// Repeatedly [scroll] the widget located by [scrollable] by [dxScroll] and |
| /// [dyScroll] until [item] is visible, and then use [scrollIntoView] to |
| /// ensure the item's final position matches [alignment]. |
| /// |
| /// The [scrollable] must locate the scrolling widget that contains [item]. |
| /// Typically `find.byType('ListView')` or `find.byType('CustomScrollView')`. |
| /// |
| /// At least one of [dxScroll] and [dyScroll] must be non-zero. |
| /// |
| /// If [item] is below the currently visible items, then specify a negative |
| /// value for [dyScroll] that's a small enough increment to expose [item] |
| /// without potentially scrolling it up and completely out of view. Similarly |
| /// if [item] is above, then specify a positive value for [dyScroll]. |
| /// |
| /// If [item] is to the right of the currently visible items, then |
| /// specify a negative value for [dxScroll] that's a small enough increment to |
| /// expose [item] without potentially scrolling it up and completely out of |
| /// view. Similarly if [item] is to the left, then specify a positive value |
| /// for [dyScroll]. |
| /// |
| /// The [timeout] value should be long enough to accommodate as many scrolls |
| /// as needed to bring an item into view. The default is to not time out. |
| Future<void> scrollUntilVisible( |
| SerializableFinder scrollable, |
| SerializableFinder item, { |
| double alignment = 0.0, |
| double dxScroll = 0.0, |
| double dyScroll = 0.0, |
| Duration timeout, |
| }) async { |
| assert(scrollable != null); |
| assert(item != null); |
| assert(alignment != null); |
| assert(dxScroll != null); |
| assert(dyScroll != null); |
| assert(dxScroll != 0.0 || dyScroll != 0.0); |
| |
| // Kick off an (unawaited) waitFor that will complete when the item we're |
| // looking for finally scrolls onscreen. We add an initial pause to give it |
| // the chance to complete if the item is already onscreen; if not, scroll |
| // repeatedly until we either find the item or time out. |
| bool isVisible = false; |
| waitFor(item, timeout: timeout).then<void>((_) { isVisible = true; }); |
| await Future<void>.delayed(const Duration(milliseconds: 500)); |
| while (!isVisible) { |
| await scroll(scrollable, dxScroll, dyScroll, const Duration(milliseconds: 100)); |
| await Future<void>.delayed(const Duration(milliseconds: 500)); |
| } |
| |
| return scrollIntoView(item, alignment: alignment); |
| } |
| |
| /// Returns the text in the `Text` widget located by [finder]. |
| Future<String> getText(SerializableFinder finder, { Duration timeout }) async { |
| return GetTextResult.fromJson(await _sendCommand(GetText(finder, timeout: timeout))).text; |
| } |
| |
| /// Enters `text` into the currently focused text input, such as the |
| /// [EditableText] widget. |
| /// |
| /// This method does not use the operating system keyboard to enter text. |
| /// Instead it emulates text entry by sending events identical to those sent |
| /// by the operating system keyboard (the "TextInputClient.updateEditingState" |
| /// method channel call). |
| /// |
| /// Generally the behavior is dependent on the implementation of the widget |
| /// receiving the input. Usually, editable widgets, such as [EditableText] and |
| /// those built on top of it would replace the currently entered text with the |
| /// provided `text`. |
| /// |
| /// It is assumed that the widget receiving text input is focused prior to |
| /// calling this method. Typically, a test would activate a widget, e.g. using |
| /// [tap], then call this method. |
| /// |
| /// For this method to work, text emulation must be enabled (see |
| /// [setTextEntryEmulation]). Text emulation is enabled by default. |
| /// |
| /// Example: |
| /// |
| /// ```dart |
| /// test('enters text in a text field', () async { |
| /// var textField = find.byValueKey('enter-text-field'); |
| /// await driver.tap(textField); // acquire focus |
| /// await driver.enterText('Hello!'); // enter text |
| /// await driver.waitFor(find.text('Hello!')); // verify text appears on UI |
| /// await driver.enterText('World!'); // enter another piece of text |
| /// await driver.waitFor(find.text('World!')); // verify new text appears |
| /// }); |
| /// ``` |
| Future<void> enterText(String text, { Duration timeout }) async { |
| await _sendCommand(EnterText(text, timeout: timeout)); |
| } |
| |
| /// Configures text entry emulation. |
| /// |
| /// If `enabled` is true, enables text entry emulation via [enterText]. If |
| /// `enabled` is false, disables it. By default text entry emulation is |
| /// enabled. |
| /// |
| /// When disabled, [enterText] will fail with a [DriverError]. When an |
| /// [EditableText] is focused, the operating system's configured keyboard |
| /// method is invoked, such as an on-screen keyboard on a phone or a tablet. |
| /// |
| /// When enabled, the operating system's configured keyboard will not be |
| /// invoked when the widget is focused, as the [SystemChannels.textInput] |
| /// channel will be mocked out. |
| Future<void> setTextEntryEmulation({ @required bool enabled, Duration timeout }) async { |
| assert(enabled != null); |
| await _sendCommand(SetTextEntryEmulation(enabled, timeout: timeout)); |
| } |
| |
| /// Sends a string and returns a string. |
| /// |
| /// This enables generic communication between the driver and the application. |
| /// It's expected that the application has registered a [DataHandler] |
| /// callback in [enableFlutterDriverExtension] that can successfully handle |
| /// these requests. |
| Future<String> requestData(String message, { Duration timeout }) async { |
| return RequestDataResult.fromJson(await _sendCommand(RequestData(message, timeout: timeout))).message; |
| } |
| |
| /// Turns semantics on or off in the Flutter app under test. |
| /// |
| /// Returns true when the call actually changed the state from on to off or |
| /// vice versa. |
| Future<bool> setSemantics(bool enabled, { Duration timeout }) async { |
| final SetSemanticsResult result = SetSemanticsResult.fromJson(await _sendCommand(SetSemantics(enabled, timeout: timeout))); |
| return result.changedState; |
| } |
| |
| /// Retrieves the semantics node id for the object returned by `finder`, or |
| /// the nearest ancestor with a semantics node. |
| /// |
| /// Throws an error if `finder` returns multiple elements or a semantics |
| /// node is not found. |
| /// |
| /// Semantics must be enabled to use this method, either using a platform |
| /// specific shell command or [setSemantics]. |
| Future<int> getSemanticsId(SerializableFinder finder, { Duration timeout }) async { |
| final Map<String, dynamic> jsonResponse = await _sendCommand(GetSemanticsId(finder, timeout: timeout)); |
| final GetSemanticsIdResult result = GetSemanticsIdResult.fromJson(jsonResponse); |
| return result.id; |
| } |
| |
| /// Take a screenshot. |
| /// |
| /// The image will be returned as a PNG. |
| Future<List<int>> screenshot() async { |
| // HACK: this artificial delay here is to deal with a race between the |
| // driver script and the GPU thread. The issue is that driver API |
| // synchronizes with the framework based on transient callbacks, which |
| // are out of sync with the GPU thread. Here's the timeline of events |
| // in ASCII art: |
| // |
| // ------------------------------------------------------------------- |
| // Without this delay: |
| // ------------------------------------------------------------------- |
| // UI : <-- build --> |
| // GPU : <-- rasterize --> |
| // Gap : | random | |
| // Driver: <-- screenshot --> |
| // |
| // In the diagram above, the gap is the time between the last driver |
| // action taken, such as a `tap()`, and the subsequent call to |
| // `screenshot()`. The gap is random because it is determined by the |
| // unpredictable network communication between the driver process and |
| // the application. If this gap is too short, which it typically will |
| // be, the screenshot is taken before the GPU thread is done |
| // rasterizing the frame, so the screenshot of the previous frame is |
| // taken, which is wrong. |
| // |
| // ------------------------------------------------------------------- |
| // With this delay, if we're lucky: |
| // ------------------------------------------------------------------- |
| // UI : <-- build --> |
| // GPU : <-- rasterize --> |
| // Gap : | 2 seconds or more | |
| // Driver: <-- screenshot --> |
| // |
| // The two-second gap should be long enough for the GPU thread to |
| // finish rasterizing the frame, but not longer than necessary to keep |
| // driver tests as fast a possible. |
| // |
| // ------------------------------------------------------------------- |
| // With this delay, if we're not lucky: |
| // ------------------------------------------------------------------- |
| // UI : <-- build --> |
| // GPU : <-- rasterize randomly slow today --> |
| // Gap : | 2 seconds or more | |
| // Driver: <-- screenshot --> |
| // |
| // In practice, sometimes the device gets really busy for a while and |
| // even two seconds isn't enough, which means that this is still racy |
| // and a source of flakes. |
| await Future<void>.delayed(const Duration(seconds: 2)); |
| |
| final Map<String, dynamic> result = await _peer.sendRequest('_flutter.screenshot'); |
| return base64.decode(result['screenshot']); |
| } |
| |
| /// Returns the Flags set in the Dart VM as JSON. |
| /// |
| /// See the complete documentation for [the `getFlagList` Dart VM service |
| /// method][getFlagList]. |
| /// |
| /// Example return value: |
| /// |
| /// [ |
| /// { |
| /// "name": "timeline_recorder", |
| /// "comment": "Select the timeline recorder used. Valid values: ring, endless, startup, and systrace.", |
| /// "modified": false, |
| /// "_flagType": "String", |
| /// "valueAsString": "ring" |
| /// }, |
| /// ... |
| /// ] |
| /// |
| /// [getFlagList]: https://github.com/dart-lang/sdk/blob/master/runtime/vm/service/service.md#getflaglist |
| Future<List<Map<String, dynamic>>> getVmFlags() async { |
| await _restorePeerConnectionIfNeeded(); |
| final Map<String, dynamic> result = await _peer.sendRequest('getFlagList'); |
| return result != null |
| ? result['flags'].cast<Map<String,dynamic>>() |
| : const <Map<String, dynamic>>[]; |
| } |
| |
| /// Starts recording performance traces. |
| /// |
| /// The `timeout` argument causes a warning to be displayed to the user if the |
| /// operation exceeds the specified timeout; it does not actually cancel the |
| /// operation. |
| Future<void> startTracing({ |
| List<TimelineStream> streams = _defaultStreams, |
| Duration timeout = kUnusuallyLongTimeout, |
| }) async { |
| assert(streams != null && streams.isNotEmpty); |
| assert(timeout != null); |
| try { |
| await _warnIfSlow<void>( |
| future: _peer.sendRequest(_setVMTimelineFlagsMethodName, <String, String>{ |
| 'recordedStreams': _timelineStreamsToString(streams), |
| }), |
| timeout: timeout, |
| message: 'VM is taking an unusually long time to respond to being told to start tracing...', |
| ); |
| } catch (error, stackTrace) { |
| throw DriverError( |
| 'Failed to start tracing due to remote error', |
| error, |
| stackTrace, |
| ); |
| } |
| } |
| |
| /// Stops recording performance traces and downloads the timeline. |
| /// |
| /// The `timeout` argument causes a warning to be displayed to the user if the |
| /// operation exceeds the specified timeout; it does not actually cancel the |
| /// operation. |
| Future<Timeline> stopTracingAndDownloadTimeline({ |
| Duration timeout = kUnusuallyLongTimeout, |
| }) async { |
| assert(timeout != null); |
| try { |
| await _warnIfSlow<void>( |
| future: _peer.sendRequest(_setVMTimelineFlagsMethodName, <String, String>{'recordedStreams': '[]'}), |
| timeout: timeout, |
| message: 'VM is taking an unusually long time to respond to being told to stop tracing...', |
| ); |
| return Timeline.fromJson(await _peer.sendRequest(_getVMTimelineMethodName)); |
| } catch (error, stackTrace) { |
| throw DriverError( |
| 'Failed to stop tracing due to remote error', |
| error, |
| stackTrace, |
| ); |
| } |
| } |
| |
| Future<bool> _isPrecompiledMode() async { |
| final List<Map<String, dynamic>> flags = await getVmFlags(); |
| for(Map<String, dynamic> flag in flags) { |
| if (flag['name'] == 'precompiled_mode') { |
| return flag['valueAsString'] == 'true'; |
| } |
| } |
| return false; |
| } |
| |
| /// 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 |
| /// [stopTracingAndDownloadTimeline]. |
| /// |
| /// [streams] limits the recorded timeline event streams to only the ones |
| /// listed. By default, all streams are recorded. |
| /// |
| /// If [retainPriorEvents] is true, retains events recorded prior to calling |
| /// [action]. Otherwise, prior events are cleared before calling [action]. By |
| /// default, prior events are cleared. |
| /// |
| /// If this is run in debug mode, a warning message will be printed to suggest |
| /// running the benchmark in profile mode instead. |
| Future<Timeline> traceAction( |
| Future<dynamic> action(), { |
| List<TimelineStream> streams = _defaultStreams, |
| bool retainPriorEvents = false, |
| }) async { |
| if (!retainPriorEvents) { |
| await clearTimeline(); |
| } |
| await startTracing(streams: streams); |
| await action(); |
| |
| if (!(await _isPrecompiledMode())) { |
| _log.warning(_kDebugWarning); |
| } |
| return stopTracingAndDownloadTimeline(); |
| } |
| |
| /// Clears all timeline events recorded up until now. |
| /// |
| /// The `timeout` argument causes a warning to be displayed to the user if the |
| /// operation exceeds the specified timeout; it does not actually cancel the |
| /// operation. |
| Future<void> clearTimeline({ |
| Duration timeout = kUnusuallyLongTimeout, |
| }) async { |
| assert(timeout != null); |
| try { |
| await _warnIfSlow<void>( |
| future: _peer.sendRequest(_clearVMTimelineMethodName, <String, String>{}), |
| timeout: timeout, |
| message: 'VM is taking an unusually long time to respond to being told to clear its timeline buffer...', |
| ); |
| } catch (error, stackTrace) { |
| throw DriverError( |
| 'Failed to clear event timeline due to remote error', |
| error, |
| stackTrace, |
| ); |
| } |
| } |
| |
| /// [action] will be executed with the frame sync mechanism disabled. |
| /// |
| /// By default, Flutter Driver waits until there is no pending frame scheduled |
| /// in the app under test before executing an action. This mechanism is called |
| /// "frame sync". It greatly reduces flakiness because Flutter Driver will not |
| /// execute an action while the app under test is undergoing a transition. |
| /// |
| /// Having said that, sometimes it is necessary to disable the frame sync |
| /// mechanism (e.g. if there is an ongoing animation in the app, it will |
| /// never reach a state where there are no pending frames scheduled and the |
| /// action will time out). For these cases, the sync mechanism can be disabled |
| /// by wrapping the actions to be performed by this [runUnsynchronized] method. |
| /// |
| /// With frame sync disabled, its the responsibility of the test author to |
| /// ensure that no action is performed while the app is undergoing a |
| /// transition to avoid flakiness. |
| Future<T> runUnsynchronized<T>(Future<T> action(), { Duration timeout }) async { |
| await _sendCommand(SetFrameSync(false, timeout: timeout)); |
| T result; |
| try { |
| result = await action(); |
| } finally { |
| await _sendCommand(SetFrameSync(true, timeout: timeout)); |
| } |
| return result; |
| } |
| |
| /// Force a garbage collection run in the VM. |
| Future<void> forceGC() async { |
| try { |
| await _peer |
| .sendRequest(_collectAllGarbageMethodName, <String, String>{ |
| 'isolateId': 'isolates/${appIsolate.numberAsString}', |
| }); |
| } catch (error, stackTrace) { |
| throw DriverError( |
| 'Failed to force a GC due to remote error', |
| error, |
| stackTrace, |
| ); |
| } |
| } |
| |
| /// Closes the underlying connection to the VM service. |
| /// |
| /// Returns a [Future] that fires once the connection has been closed. |
| Future<void> close() async { |
| // Don't leak vm_service_client-specific objects, if any |
| await serviceClient.close(); |
| await _peer.close(); |
| } |
| } |
| |
| /// Encapsulates connection information to an instance of a Flutter application. |
| @visibleForTesting |
| class VMServiceClientConnection { |
| /// Creates an instance of this class given a [client] and a [peer]. |
| VMServiceClientConnection(this.client, this.peer); |
| |
| /// 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; |
| } |
| |
| /// A function that connects to a Dart VM service given the [url]. |
| typedef VMServiceConnectFunction = Future<VMServiceClientConnection> Function(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; |
| } |
| |
| /// The JSON RPC 2 spec says that a notification from a client must not respond |
| /// to the client. It's possible the client sent a notification as a "ping", but |
| /// the service isn't set up yet to respond. |
| /// |
| /// For example, if the client sends a notification message to the server for |
| /// 'streamNotify', but the server has not finished loading, it will throw an |
| /// exception. Since the message is a notification, the server follows the |
| /// specification and does not send a response back, but is left with an |
| /// unhandled exception. That exception is safe for us to ignore - the client |
| /// is signaling that it will try again later if it doesn't get what it wants |
| /// here by sending a notification. |
| // This may be ignoring too many exceptions. It would be best to rewrite |
| // the client code to not use notifications so that it gets error replies back |
| // and can decide what to do from there. |
| // TODO(dnfield): https://github.com/flutter/flutter/issues/31813 |
| bool _ignoreRpcError(dynamic error) { |
| if (error is rpc.RpcException) { |
| final rpc.RpcException exception = error; |
| return exception.data == null || exception.data['id'] == null; |
| } else if (error is String && error.startsWith('JSON-RPC error -32601')) { |
| return true; |
| } |
| return false; |
| } |
| |
| void _unhandledJsonRpcError(dynamic error, dynamic stack) { |
| if (_ignoreRpcError(error)) { |
| return; |
| } |
| _log.trace('Unhandled RPC error:\n$error\n$stack'); |
| // TODO(dnfield): https://github.com/flutter/flutter/issues/31813 |
| // assert(false); |
| } |
| |
| String _getWebSocketUrl(String url) { |
| Uri uri = Uri.parse(url); |
| final List<String> pathSegments = <String>[]; |
| // If there's an authentication code (default), we need to add it to our path. |
| if (uri.pathSegments.isNotEmpty) { |
| pathSegments.add(uri.pathSegments.first); |
| } |
| pathSegments.add('ws'); |
| if (uri.scheme == 'http') |
| uri = uri.replace(scheme: 'ws', pathSegments: pathSegments); |
| return uri.toString(); |
| } |
| |
| void _checkCloseCode(WebSocket ws) { |
| if (ws.closeCode != 1000 && ws.closeCode != null) { |
| _log.warning('$ws is closed with an unexpected code ${ws.closeCode}'); |
| } |
| } |
| |
| /// Waits for a real Dart VM service to become available, then connects using |
| /// the [VMServiceClient]. |
| Future<VMServiceClientConnection> _waitAndConnect(String url) async { |
| final String webSocketUrl = _getWebSocketUrl(url); |
| int attempts = 0; |
| while (true) { |
| WebSocket ws1; |
| WebSocket ws2; |
| try { |
| ws1 = await WebSocket.connect(webSocketUrl); |
| ws2 = await WebSocket.connect(webSocketUrl); |
| |
| ws1.done.whenComplete(() => _checkCloseCode(ws1)); |
| ws2.done.whenComplete(() => _checkCloseCode(ws2)); |
| |
| return VMServiceClientConnection( |
| VMServiceClient(IOWebSocketChannel(ws1).cast()), |
| rpc.Peer( |
| IOWebSocketChannel(ws2).cast(), |
| onUnhandledError: _unhandledJsonRpcError, |
| )..listen(), |
| ); |
| } catch (e) { |
| await ws1?.close(); |
| await ws2?.close(); |
| if (attempts > 5) |
| _log.warning('It is taking an unusually long time to connect to the VM...'); |
| attempts += 1; |
| await Future<void>.delayed(_kPauseBetweenReconnectAttempts); |
| } |
| } |
| } |
| |
| /// Provides convenient accessors to frequently used finders. |
| class CommonFinders { |
| const CommonFinders._(); |
| |
| /// Finds [Text] and [EditableText] widgets containing string equal to [text]. |
| SerializableFinder text(String text) => ByText(text); |
| |
| /// Finds widgets by [key]. Only [String] and [int] values can be used. |
| SerializableFinder byValueKey(dynamic key) => ByValueKey(key); |
| |
| /// Finds widgets with a tooltip with the given [message]. |
| SerializableFinder byTooltip(String message) => ByTooltipMessage(message); |
| |
| /// Finds widgets with the given semantics [label]. |
| SerializableFinder bySemanticsLabel(Pattern label) => BySemanticsLabel(label); |
| |
| /// Finds widgets whose class name matches the given string. |
| SerializableFinder byType(String type) => ByType(type); |
| |
| /// Finds the back button on a Material or Cupertino page's scaffold. |
| SerializableFinder pageBack() => const PageBack(); |
| |
| /// Finds the widget that is an ancestor of the `of` parameter and that |
| /// matches the `matching` parameter. |
| /// |
| /// If the `matchRoot` argument is true then the widget specified by `of` will |
| /// be considered for a match. The argument defaults to false. |
| SerializableFinder ancestor({ |
| @required SerializableFinder of, |
| @required SerializableFinder matching, |
| bool matchRoot = false, |
| }) => Ancestor(of: of, matching: matching, matchRoot: matchRoot); |
| |
| /// Finds the widget that is an descendant of the `of` parameter and that |
| /// matches the `matching` parameter. |
| /// |
| /// If the `matchRoot` argument is true then the widget specified by `of` will |
| /// be considered for a match. The argument defaults to false. |
| SerializableFinder descendant({ |
| @required SerializableFinder of, |
| @required SerializableFinder matching, |
| bool matchRoot = false, |
| }) => Descendant(of: of, matching: matching, matchRoot: matchRoot); |
| } |
| |
| /// An immutable 2D floating-point offset used by Flutter Driver. |
| class DriverOffset { |
| /// Creates an offset. |
| const DriverOffset(this.dx, this.dy); |
| |
| /// The x component of the offset. |
| final double dx; |
| |
| /// The y component of the offset. |
| final double dy; |
| |
| @override |
| String toString() => '$runtimeType($dx, $dy)'; |
| |
| @override |
| bool operator ==(dynamic other) { |
| if (other is! DriverOffset) |
| return false; |
| final DriverOffset typedOther = other; |
| return dx == typedOther.dx && dy == typedOther.dy; |
| } |
| |
| @override |
| int get hashCode => dx.hashCode + dy.hashCode; |
| } |