// 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:async'; import 'dart:math' as math; import 'package:file/file.dart'; import 'package:meta/meta.dart'; import 'package:package_config/package_config.dart'; import 'package:webdriver/async_io.dart' as async_io; import '../base/common.dart'; import '../base/io.dart'; import '../base/logger.dart'; import '../base/platform.dart'; import '../base/process.dart'; import '../base/terminal.dart'; import '../base/utils.dart'; import '../build_info.dart'; import '../convert.dart'; import '../device.dart'; import '../globals.dart' as globals; import '../project.dart'; import '../resident_runner.dart'; import '../web/web_runner.dart'; import 'drive_service.dart'; /// An implementation of the driver service for web debug and release applications. class WebDriverService extends DriverService { WebDriverService({ required ProcessUtils processUtils, required String dartSdkPath, required Platform platform, required Logger logger, required Terminal terminal, required OutputPreferences outputPreferences, }) : _processUtils = processUtils, _dartSdkPath = dartSdkPath, _platform = platform, _logger = logger, _terminal = terminal, _outputPreferences = outputPreferences; final ProcessUtils _processUtils; final String _dartSdkPath; final Platform _platform; final Logger _logger; final Terminal _terminal; final OutputPreferences _outputPreferences; late ResidentRunner _residentRunner; Uri? _webUri; @visibleForTesting Uri? get webUri => _webUri; /// The result of [ResidentRunner.run]. /// /// This is expected to stay `null` throughout the test, as the application /// must be running until [stop] is called. If it becomes non-null, it likely /// indicates a bug. int? _runResult; @override Future start( BuildInfo buildInfo, Device device, DebuggingOptions debuggingOptions, { File? applicationBinary, String? route, String? userIdentifier, String? mainPath, Map platformArgs = const {}, }) async { final FlutterDevice flutterDevice = await FlutterDevice.create( device, target: mainPath, buildInfo: buildInfo, platform: globals.platform, ); _residentRunner = webRunnerFactory!.createWebRunner( flutterDevice, target: mainPath, debuggingOptions: buildInfo.isRelease ? DebuggingOptions.disabled( buildInfo, webDevServerConfig: debuggingOptions.webDevServerConfig, webRenderer: debuggingOptions.webRenderer, webUseWasm: debuggingOptions.webUseWasm, ) : DebuggingOptions.enabled( buildInfo, webDevServerConfig: debuggingOptions.webDevServerConfig, disablePortPublication: debuggingOptions.disablePortPublication, webRenderer: debuggingOptions.webRenderer, webUseWasm: debuggingOptions.webUseWasm, ), stayResident: true, flutterProject: FlutterProject.current(), fileSystem: globals.fs, analytics: globals.analytics, logger: _logger, terminal: _terminal, platform: _platform, outputPreferences: _outputPreferences, systemClock: globals.systemClock, ); final appStartedCompleter = Completer.sync(); final Future runFuture = _residentRunner.run( appStartedCompleter: appStartedCompleter, route: route, ); var isAppStarted = false; await Future.any(>[ runFuture.then((int? result) { _runResult = result; return null; }), appStartedCompleter.future.then((_) { isAppStarted = true; return null; }), ]); if (_runResult != null) { throwToolExit( 'Application exited before the test started. Check web driver logs ' 'for possible application-side errors.', ); } if (!isAppStarted) { throwToolExit('Failed to start application'); } if (_residentRunner.uri == null) { throwToolExit('Unable to connect to the app. URL not available.'); } if (debuggingOptions.webLaunchUrl != null) { // It should throw an error if the provided url is invalid so no tryParse _webUri = Uri.parse(debuggingOptions.webLaunchUrl!); } else { _webUri = _residentRunner.uri; } } @override Future startTest( String testFile, List arguments, PackageConfig packageConfig, { bool? headless, String? chromeBinary, String? browserName, bool? androidEmulator, int? driverPort, List webBrowserFlags = const [], List? browserDimension, String? profileMemory, }) async { late async_io.WebDriver webDriver; final Browser browser = Browser.fromCliName(browserName); final isAndroidChrome = browser == Browser.androidChrome; late int width; late int height; Map? mobileEmulation; // Do not resize Android Chrome browser. // For PC Chrome use mobileEmulation if dpr is provided. if (!isAndroidChrome && browserDimension != null) { try { final int len = browserDimension.length; if (len != 2 && len != 3) { throw const FormatException(); } width = int.parse(browserDimension[0]); height = int.parse(browserDimension[1]); if (len == 3) { mobileEmulation = { 'deviceMetrics': { 'width': width, 'height': height, 'pixelRatio': double.parse(browserDimension[2]), }, 'userAgent': 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, ' 'like Gecko) Chrome/131.0.6778.200 Mobile Safari/537.36', }; } } on FormatException { throwToolExit('Browser dimension is invalid. Try --browser-dimension=1600x1024[@1]'); } } try { webDriver = await async_io.createDriver( uri: Uri.parse('http://localhost:$driverPort/'), desired: getDesiredCapabilities( browser, headless, webBrowserFlags: webBrowserFlags, chromeBinary: chromeBinary, mobileEmulation: mobileEmulation, ), ); } on SocketException catch (error) { _logger.printTrace('$error'); throwToolExit( 'Unable to start a WebDriver session for web testing.\n' 'Make sure you have the correct WebDriver server (e.g. chromedriver) running at $driverPort.\n' 'For instructions on how to obtain and run a WebDriver server, see:\n' 'https://flutter.dev/to/integration-test-on-web\n', ); } if (!isAndroidChrome && browserDimension != null) { final async_io.Window window = await webDriver.window; await window.setLocation(const math.Point(0, 0)); await window.setSize(math.Rectangle(0, 0, width, height)); } final int result = await _processUtils.stream( [_dartSdkPath, ...arguments, testFile], environment: { ..._platform.environment, 'VM_SERVICE_URL': _webUri.toString(), ..._additionalDriverEnvironment(webDriver, browserName, androidEmulator), }, ); await webDriver.quit(); return result; } @override Future stop({String? userIdentifier}) async { final appDidFinishPrematurely = _runResult != null; await _residentRunner.exitApp(); await _residentRunner.cleanupAtFinish(); if (appDidFinishPrematurely) { throwToolExit( 'Application exited before the test finished. Check web driver logs ' 'for possible application-side errors.', ); } } Map _additionalDriverEnvironment( async_io.WebDriver webDriver, String? browserName, bool? androidEmulator, ) { return { 'DRIVER_SESSION_ID': webDriver.id, 'DRIVER_SESSION_URI': webDriver.uri.toString(), 'DRIVER_SESSION_SPEC': webDriver.spec.toString(), 'DRIVER_SESSION_CAPABILITIES': json.encode(webDriver.capabilities), 'SUPPORT_TIMELINE_ACTION': (Browser.fromCliName(browserName) == Browser.chrome).toString(), 'FLUTTER_WEB_TEST': 'true', 'ANDROID_CHROME_ON_EMULATOR': (Browser.fromCliName(browserName) == Browser.androidChrome && androidEmulator!) .toString(), }; } @override Future reuseApplication( Uri vmServiceUri, Device device, DebuggingOptions debuggingOptions, ) async { throwToolExit('--use-existing-app is not supported with flutter web driver'); } } /// A list of supported browsers. enum Browser implements CliEnum { /// Chrome on Android: https://developer.chrome.com/docs/multidevice/android/ androidChrome, /// Chrome: https://www.google.com/chrome/ chrome, /// Edge: https://www.microsoft.com/edge edge, /// Firefox: https://www.mozilla.org/en-US/firefox/ firefox, /// Safari in iOS: https://www.apple.com/safari/ iosSafari, /// Safari in macOS: https://www.apple.com/safari/ safari; @override String get helpText => switch (this) { Browser.androidChrome => 'Chrome on Android (see also "--android-emulator").', Browser.chrome => 'Google Chrome on this computer (see also "--chrome-binary").', Browser.edge => 'Microsoft Edge on this computer (Windows only).', Browser.firefox => 'Mozilla Firefox on this computer.', Browser.iosSafari => 'Apple Safari on an iOS device.', Browser.safari => 'Apple Safari on this computer (macOS only).', }; @override String get cliName => kebabCase(name); static Browser fromCliName(String? value) => Browser.values.singleWhere( (Browser element) => element.cliName == value, orElse: () => throw UnsupportedError('Browser $value not supported'), ); } /// Returns desired capabilities for given [browser], [headless], [chromeBinary] /// and [webBrowserFlags]. @visibleForTesting Map getDesiredCapabilities( Browser browser, bool? headless, { List webBrowserFlags = const [], String? chromeBinary, Map? mobileEmulation, }) => switch (browser) { Browser.chrome => { 'acceptInsecureCerts': true, 'browserName': 'chrome', 'goog:loggingPrefs': { async_io.LogType.browser: 'INFO', async_io.LogType.performance: 'ALL', }, 'goog:chromeOptions': { 'w3c': true, 'args': [ '--bwsi', '--disable-background-timer-throttling', '--disable-default-apps', '--disable-extensions', '--disable-popup-blocking', '--disable-translate', '--no-default-browser-check', '--no-sandbox', '--no-first-run', if (headless!) '--headless', ...webBrowserFlags, ], 'perfLoggingPrefs': { 'traceCategories': 'devtools.timeline,' 'v8,blink.console,benchmark,blink,' 'blink.user_timing', }, 'binary': ?chromeBinary, 'mobileEmulation': ?mobileEmulation, }, }, Browser.firefox => { 'acceptInsecureCerts': true, 'browserName': 'firefox', 'moz:firefoxOptions': { 'args': [if (headless!) '-headless', ...webBrowserFlags], 'prefs': { 'dom.file.createInChild': true, 'dom.timeout.background_throttling_max_budget': -1, 'media.autoplay.default': 0, 'media.gmp-manager.url': '', 'media.gmp-provider.enabled': false, 'network.captive-portal-service.enabled': false, 'security.insecure_field_warning.contextual.enabled': false, 'test.currentTimeOffsetSeconds': 11491200, }, 'log': {'level': 'trace'}, }, }, Browser.edge => {'acceptInsecureCerts': true, 'browserName': 'edge'}, Browser.safari => {'browserName': 'safari'}, Browser.iosSafari => { 'platformName': 'ios', 'browserName': 'safari', 'safari:useSimulator': true, }, Browser.androidChrome => { 'browserName': 'chrome', 'platformName': 'android', 'goog:chromeOptions': { 'androidPackage': 'com.android.chrome', 'args': ['--disable-fullscreen', ...webBrowserFlags], }, }, };