| // Copyright 2014 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import 'dart:convert'; |
| import 'dart:io'; |
| import 'package:file/file.dart'; |
| import 'package:matcher/matcher.dart'; |
| import 'package:meta/meta.dart'; |
| |
| import 'package:path/path.dart' as path; |
| import 'package:vm_service/vm_service.dart' as vms; |
| import 'package:webdriver/async_io.dart' as async_io; |
| import 'package:webdriver/support/async.dart'; |
| |
| import '../common/error.dart'; |
| import '../common/message.dart'; |
| |
| import 'common.dart'; |
| import 'driver.dart'; |
| import 'timeline.dart'; |
| |
| /// An implementation of the Flutter Driver using the WebDriver. |
| /// |
| /// Example of how to test WebFlutterDriver: |
| /// 1. Launch WebDriver binary: ./chromedriver --port=4444 |
| /// 2. Run test script: flutter drive --target=test_driver/scroll_perf_web.dart -d web-server --release |
| class WebFlutterDriver extends FlutterDriver { |
| /// Creates a driver that uses a connection provided by the given |
| /// [_connection]. |
| WebFlutterDriver.connectedTo( |
| this._connection, { |
| bool printCommunication = false, |
| bool logCommunicationToFile = true, |
| }) : _printCommunication = printCommunication, |
| _logCommunicationToFile = logCommunicationToFile, |
| _startTime = DateTime.now(), |
| _driverId = _nextDriverId++ |
| { |
| _logFilePathName = path.join(testOutputsDirectory, 'flutter_driver_commands_$_driverId.log'); |
| } |
| |
| |
| final FlutterWebConnection _connection; |
| DateTime _startTime; |
| static int _nextDriverId = 0; |
| |
| /// The unique ID of this driver instance. |
| final int _driverId; |
| |
| /// Start time for tracing. |
| @visibleForTesting |
| DateTime get startTime => _startTime; |
| |
| @override |
| vms.Isolate get appIsolate => throw UnsupportedError('WebFlutterDriver does not support appIsolate'); |
| |
| @override |
| vms.VmService get serviceClient => throw UnsupportedError('WebFlutterDriver does not support serviceClient'); |
| |
| @override |
| async_io.WebDriver get webDriver => _connection._driver; |
| |
| /// 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; |
| |
| /// Logs are written here when _logCommunicationToFile is true. |
| late final String _logFilePathName; |
| |
| /// Getter for file pathname where logs are written when _logCommunicationToFile is true |
| String get logFilePathName => _logFilePathName; |
| |
| /// Creates a driver that uses a connection provided by the given |
| /// [hostUrl] which would fallback to environment variable VM_SERVICE_URL. |
| /// Driver also depends on environment variables DRIVER_SESSION_ID, |
| /// BROWSER_SUPPORTS_TIMELINE, DRIVER_SESSION_URI, DRIVER_SESSION_SPEC, |
| /// DRIVER_SESSION_CAPABILITIES and ANDROID_CHROME_ON_EMULATOR for |
| /// configurations. |
| /// |
| /// See [FlutterDriver.connect] for more documentation. |
| static Future<FlutterDriver> connectWeb({ |
| String? hostUrl, |
| bool printCommunication = false, |
| bool logCommunicationToFile = true, |
| Duration? timeout, |
| }) async { |
| hostUrl ??= Platform.environment['VM_SERVICE_URL']; |
| final Map<String, dynamic> settings = <String, dynamic>{ |
| 'support-timeline-action': Platform.environment['SUPPORT_TIMELINE_ACTION'] == 'true', |
| 'session-id': Platform.environment['DRIVER_SESSION_ID'], |
| 'session-uri': Platform.environment['DRIVER_SESSION_URI'], |
| 'session-spec': Platform.environment['DRIVER_SESSION_SPEC'], |
| 'android-chrome-on-emulator': Platform.environment['ANDROID_CHROME_ON_EMULATOR'] == 'true', |
| 'session-capabilities': Platform.environment['DRIVER_SESSION_CAPABILITIES'], |
| }; |
| final FlutterWebConnection connection = await FlutterWebConnection.connect |
| (hostUrl!, settings, timeout: timeout); |
| return WebFlutterDriver.connectedTo( |
| connection, |
| printCommunication: printCommunication, |
| logCommunicationToFile: logCommunicationToFile, |
| ); |
| } |
| |
| static DriverError _createMalformedExtensionResponseError(Object? data) { |
| throw DriverError( |
| 'Received malformed response from the FlutterDriver extension.\n' |
| 'Expected a JSON map containing a "response" field and, optionally, an ' |
| '"isError" field, but got ${data.runtimeType}: $data' |
| ); |
| } |
| |
| @override |
| Future<Map<String, dynamic>> sendCommand(Command command) async { |
| final Map<String, dynamic> response; |
| final Object? data; |
| final Map<String, String> serialized = command.serialize(); |
| _logCommunication('>>> $serialized'); |
| try { |
| data = await _connection.sendCommand("window.\$flutterDriver('${jsonEncode(serialized)}')", command.timeout); |
| |
| // The returned data is expected to be a string. If it's null or anything |
| // other than a string, something's wrong. |
| if (data is! String) { |
| throw _createMalformedExtensionResponseError(data); |
| } |
| |
| final Object? decoded = json.decode(data); |
| if (decoded is! Map<String, dynamic>) { |
| throw _createMalformedExtensionResponseError(data); |
| } else { |
| response = decoded; |
| } |
| |
| _logCommunication('<<< $response'); |
| } on DriverError catch(_) { |
| rethrow; |
| } catch (error, stackTrace) { |
| throw DriverError( |
| 'FlutterDriver command ${command.runtimeType} failed due to a remote error.\n' |
| 'Command sent: ${jsonEncode(serialized)}', |
| error, |
| stackTrace |
| ); |
| } |
| |
| final Object? isError = response['isError']; |
| final Object? responseData = response['response']; |
| if (isError is! bool?) { |
| throw _createMalformedExtensionResponseError(data); |
| } else if (isError ?? false) { |
| throw DriverError('Error in Flutter application: $responseData'); |
| } |
| |
| if (responseData is! Map<String, dynamic>) { |
| throw _createMalformedExtensionResponseError(data); |
| } |
| return responseData; |
| } |
| |
| @override |
| Future<void> close() => _connection.close(); |
| |
| @override |
| Future<void> waitUntilFirstFrameRasterized() async { |
| throw UnimplementedError(); |
| } |
| |
| void _logCommunication(String message) { |
| if (_printCommunication) { |
| driverLog('WebFlutterDriver', message); |
| } |
| if (_logCommunicationToFile) { |
| assert(_logFilePathName != null); |
| final File file = fs.file(_logFilePathName); |
| file.createSync(recursive: true); // no-op if file exists |
| file.writeAsStringSync('${DateTime.now()} $message\n', mode: FileMode.append, flush: true); |
| } |
| } |
| |
| @override |
| Future<List<int>> screenshot() async { |
| await Future<void>.delayed(const Duration(seconds: 2)); |
| |
| return _connection.screenshot(); |
| } |
| |
| @override |
| Future<void> startTracing({ |
| List<TimelineStream> streams = const <TimelineStream>[TimelineStream.all], |
| Duration timeout = kUnusuallyLongTimeout, |
| }) async { |
| _checkBrowserSupportsTimeline(); |
| } |
| |
| @override |
| Future<Timeline> stopTracingAndDownloadTimeline({Duration timeout = kUnusuallyLongTimeout}) async { |
| _checkBrowserSupportsTimeline(); |
| |
| final List<Map<String, dynamic>> events = <Map<String, dynamic>>[]; |
| for (final async_io.LogEntry entry in await _connection.logs.toList()) { |
| if (_startTime.isBefore(entry.timestamp)) { |
| final Map<String, dynamic> data = (jsonDecode(entry.message!) as Map<String, dynamic>)['message'] as Map<String, dynamic>; |
| if (data['method'] == 'Tracing.dataCollected') { |
| // 'ts' data collected from Chrome is in double format, conversion needed |
| try { |
| final Map<String, dynamic> params = data['params'] as Map<String, dynamic>; |
| params['ts'] = double.parse(params['ts'].toString()).toInt(); |
| } on FormatException catch (_) { |
| // data is corrupted, skip |
| continue; |
| } |
| events.add(data['params']! as Map<String, dynamic>); |
| } |
| } |
| } |
| final Map<String, dynamic> json = <String, dynamic>{ |
| 'traceEvents': events, |
| }; |
| return Timeline.fromJson(json); |
| } |
| |
| @override |
| Future<Timeline> traceAction(Future<dynamic> Function() action, { |
| List<TimelineStream> streams = const <TimelineStream>[TimelineStream.all], |
| bool retainPriorEvents = false, |
| }) async { |
| _checkBrowserSupportsTimeline(); |
| if (!retainPriorEvents) { |
| await clearTimeline(); |
| } |
| await startTracing(streams: streams); |
| await action(); |
| |
| return stopTracingAndDownloadTimeline(); |
| } |
| |
| @override |
| Future<void> clearTimeline({Duration timeout = kUnusuallyLongTimeout}) async { |
| _checkBrowserSupportsTimeline(); |
| |
| // Reset start time |
| _startTime = DateTime.now(); |
| } |
| |
| /// Checks whether browser supports Timeline related operations. |
| void _checkBrowserSupportsTimeline() { |
| if (!_connection.supportsTimelineAction) { |
| throw UnsupportedError('Timeline action is not supported by current testing browser'); |
| } |
| } |
| } |
| |
| /// Encapsulates connection information to an instance of a Flutter Web application. |
| class FlutterWebConnection { |
| /// Creates a FlutterWebConnection with WebDriver |
| /// and whether the WebDriver supports timeline action. |
| FlutterWebConnection(this._driver, this.supportsTimelineAction); |
| |
| final async_io.WebDriver _driver; |
| |
| /// Whether the connected WebDriver supports timeline action for Flutter Web Driver. |
| bool supportsTimelineAction; |
| |
| /// Starts WebDriver with the given [settings] and |
| /// establishes the connection to Flutter Web application. |
| static Future<FlutterWebConnection> connect( |
| String url, |
| Map<String, dynamic> settings, |
| {Duration? timeout}) async { |
| final String sessionId = settings['session-id'].toString(); |
| final Uri sessionUri = Uri.parse(settings['session-uri'].toString()); |
| final async_io.WebDriver driver = async_io.WebDriver( |
| sessionUri, |
| sessionId, |
| json.decode(settings['session-capabilities'] as String) as Map<String, dynamic>, |
| async_io.AsyncIoRequestClient(sessionUri.resolve('session/$sessionId/')), |
| _convertToSpec(settings['session-spec'].toString().toLowerCase())); |
| if (settings['android-chrome-on-emulator'] == true) { |
| final Uri localUri = Uri.parse(url); |
| // Converts to Android Emulator Uri. |
| // Hardcode the host to 10.0.2.2 based on |
| // https://developer.android.com/studio/run/emulator-networking |
| url = Uri(scheme: localUri.scheme, host: '10.0.2.2', port:localUri.port).toString(); |
| } |
| await driver.get(url); |
| |
| await waitUntilExtensionInstalled(driver, timeout); |
| return FlutterWebConnection(driver, settings['support-timeline-action'] as bool); |
| } |
| |
| /// Sends command via WebDriver to Flutter web application. |
| Future<dynamic> sendCommand(String script, Duration? duration) async { |
| // This code should not be reachable before the VM service extension is |
| // initialized. The VM service extension is expected to initialize both |
| // `$flutterDriverResult` and `$flutterDriver` variables before attempting |
| // to send commands. This part checks that `$flutterDriverResult` is present. |
| // `$flutterDriver` is not checked because it is covered by the `script` |
| // that's executed next. |
| try { |
| await _driver.execute(r'return $flutterDriverResult', <String>[]); |
| } catch (error, stackTrace) { |
| throw DriverError( |
| 'Driver extension has not been initialized correctly.\n' |
| 'If the test uses a custom VM service extension, make sure it conforms ' |
| 'to the protocol used by package:integration_test and ' |
| 'package:flutter_driver.\n' |
| 'If the test uses VM service extensions provided by the Flutter SDK, ' |
| 'then this error is likely caused by a bug in Flutter. Please report it ' |
| 'by filing a bug on GitHub:\n' |
| ' https://github.com/flutter/flutter/issues/new?template=2_bug.md', |
| error, |
| stackTrace, |
| ); |
| } |
| |
| String phase = 'executing'; |
| try { |
| // Execute the script, which should leave the result in the `$flutterDriverResult` global variable. |
| await _driver.execute(script, <void>[]); |
| |
| // Read the result. |
| phase = 'reading'; |
| final dynamic result = await waitFor<dynamic>( |
| () => _driver.execute(r'return $flutterDriverResult', <String>[]), |
| matcher: isNotNull, |
| timeout: duration ?? const Duration(days: 30), |
| ); |
| |
| // Reset the result to null to avoid polluting the results of future commands. |
| phase = 'resetting'; |
| await _driver.execute(r'$flutterDriverResult = null', <void>[]); |
| return result; |
| } catch (error, stackTrace) { |
| throw DriverError( |
| 'Error while $phase FlutterDriver result for command: $script', |
| error, |
| stackTrace, |
| ); |
| } |
| } |
| |
| /// Gets performance log from WebDriver. |
| Stream<async_io.LogEntry> get logs => _driver.logs.get(async_io.LogType.performance); |
| |
| /// Takes screenshot via WebDriver. |
| Future<List<int>> screenshot() => _driver.captureScreenshotAsList(); |
| |
| /// Closes the WebDriver. |
| Future<void> close() async { |
| await _driver.quit(closeSession: false); |
| } |
| } |
| |
| /// Waits until extension is installed. |
| Future<void> waitUntilExtensionInstalled(async_io.WebDriver driver, Duration? timeout) async { |
| await waitFor<void>(() => |
| driver.execute(r'return typeof(window.$flutterDriver)', <String>[]), |
| matcher: 'function', |
| timeout: timeout ?? const Duration(days: 365)); |
| } |
| |
| async_io.WebDriverSpec _convertToSpec(String specString) { |
| switch (specString.toLowerCase()) { |
| case 'webdriverspec.w3c': |
| return async_io.WebDriverSpec.W3c; |
| case 'webdriverspec.jsonwire': |
| return async_io.WebDriverSpec.JsonWire; |
| default: |
| return async_io.WebDriverSpec.Auto; |
| } |
| } |