1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'dart:async';
6import 'dart:math' as math;
7
8import 'package:file/file.dart';
9import 'package:meta/meta.dart';
10import 'package:package_config/package_config.dart';
11import 'package:webdriver/async_io.dart' as async_io;
12
13import '../base/common.dart';
14import '../base/io.dart';
15import '../base/logger.dart';
16import '../base/platform.dart';
17import '../base/process.dart';
18import '../base/terminal.dart';
19import '../base/utils.dart';
20import '../build_info.dart';
21import '../convert.dart';
22import '../device.dart';
23import '../globals.dart' as globals;
24import '../project.dart';
25import '../resident_runner.dart';
26import '../web/web_runner.dart';
27import 'drive_service.dart';
28
29/// An implementation of the driver service for web debug and release applications.
30class WebDriverService extends DriverService {
31 WebDriverService({
32 required ProcessUtils processUtils,
33 required String dartSdkPath,
34 required Platform platform,
35 required Logger logger,
36 required Terminal terminal,
37 required OutputPreferences outputPreferences,
38 }) : _processUtils = processUtils,
39 _dartSdkPath = dartSdkPath,
40 _platform = platform,
41 _logger = logger,
42 _terminal = terminal,
43 _outputPreferences = outputPreferences;
44
45 final ProcessUtils _processUtils;
46 final String _dartSdkPath;
47 final Platform _platform;
48 final Logger _logger;
49 final Terminal _terminal;
50 final OutputPreferences _outputPreferences;
51
52 late ResidentRunner _residentRunner;
53 Uri? _webUri;
54
55 @visibleForTesting
56 Uri? get webUri => _webUri;
57
58 /// The result of [ResidentRunner.run].
59 ///
60 /// This is expected to stay `null` throughout the test, as the application
61 /// must be running until [stop] is called. If it becomes non-null, it likely
62 /// indicates a bug.
63 int? _runResult;
64
65 @override
66 Future<void> start(
67 BuildInfo buildInfo,
68 Device device,
69 DebuggingOptions debuggingOptions, {
70 File? applicationBinary,
71 String? route,
72 String? userIdentifier,
73 String? mainPath,
74 Map<String, Object> platformArgs = const <String, Object>{},
75 }) async {
76 final FlutterDevice flutterDevice = await FlutterDevice.create(
77 device,
78 target: mainPath,
79 buildInfo: buildInfo,
80 platform: globals.platform,
81 );
82 _residentRunner = webRunnerFactory!.createWebRunner(
83 flutterDevice,
84 target: mainPath,
85 debuggingOptions: buildInfo.isRelease
86 ? DebuggingOptions.disabled(
87 buildInfo,
88 port: debuggingOptions.port,
89 hostname: debuggingOptions.hostname,
90 webRenderer: debuggingOptions.webRenderer,
91 webUseWasm: debuggingOptions.webUseWasm,
92 webHeaders: debuggingOptions.webHeaders,
93 )
94 : DebuggingOptions.enabled(
95 buildInfo,
96 port: debuggingOptions.port,
97 hostname: debuggingOptions.hostname,
98 disablePortPublication: debuggingOptions.disablePortPublication,
99 webRenderer: debuggingOptions.webRenderer,
100 webUseWasm: debuggingOptions.webUseWasm,
101 webHeaders: debuggingOptions.webHeaders,
102 ),
103 stayResident: true,
104 flutterProject: FlutterProject.current(),
105 fileSystem: globals.fs,
106 analytics: globals.analytics,
107 logger: _logger,
108 terminal: _terminal,
109 platform: _platform,
110 outputPreferences: _outputPreferences,
111 systemClock: globals.systemClock,
112 );
113 final appStartedCompleter = Completer<void>.sync();
114 final Future<int?> runFuture = _residentRunner.run(
115 appStartedCompleter: appStartedCompleter,
116 route: route,
117 );
118
119 var isAppStarted = false;
120 await Future.any(<Future<Object?>>[
121 runFuture.then((int? result) {
122 _runResult = result;
123 return null;
124 }),
125 appStartedCompleter.future.then((_) {
126 isAppStarted = true;
127 return null;
128 }),
129 ]);
130
131 if (_runResult != null) {
132 throwToolExit(
133 'Application exited before the test started. Check web driver logs '
134 'for possible application-side errors.',
135 );
136 }
137
138 if (!isAppStarted) {
139 throwToolExit('Failed to start application');
140 }
141
142 if (_residentRunner.uri == null) {
143 throwToolExit('Unable to connect to the app. URL not available.');
144 }
145
146 if (debuggingOptions.webLaunchUrl != null) {
147 // It should throw an error if the provided url is invalid so no tryParse
148 _webUri = Uri.parse(debuggingOptions.webLaunchUrl!);
149 } else {
150 _webUri = _residentRunner.uri;
151 }
152 }
153
154 @override
155 Future<int> startTest(
156 String testFile,
157 List<String> arguments,
158 PackageConfig packageConfig, {
159 bool? headless,
160 String? chromeBinary,
161 String? browserName,
162 bool? androidEmulator,
163 int? driverPort,
164 List<String> webBrowserFlags = const <String>[],
165 List<String>? browserDimension,
166 String? profileMemory,
167 }) async {
168 late async_io.WebDriver webDriver;
169 final Browser browser = Browser.fromCliName(browserName);
170 final isAndroidChrome = browser == Browser.androidChrome;
171 late int width;
172 late int height;
173 Map<String, dynamic>? mobileEmulation;
174
175 // Do not resize Android Chrome browser.
176 // For PC Chrome use mobileEmulation if dpr is provided.
177 if (!isAndroidChrome && browserDimension != null) {
178 try {
179 final int len = browserDimension.length;
180 if (len != 2 && len != 3) {
181 throw const FormatException();
182 }
183 width = int.parse(browserDimension[0]);
184 height = int.parse(browserDimension[1]);
185 if (len == 3) {
186 mobileEmulation = <String, dynamic>{
187 'deviceMetrics': <String, dynamic>{
188 'width': width,
189 'height': height,
190 'pixelRatio': double.parse(browserDimension[2]),
191 },
192 'userAgent':
193 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, '
194 'like Gecko) Chrome/131.0.6778.200 Mobile Safari/537.36',
195 };
196 }
197 } on FormatException {
198 throwToolExit('Browser dimension is invalid. Try --browser-dimension=1600x1024[@1]');
199 }
200 }
201
202 try {
203 webDriver = await async_io.createDriver(
204 uri: Uri.parse('http://localhost:$driverPort/'),
205 desired: getDesiredCapabilities(
206 browser,
207 headless,
208 webBrowserFlags: webBrowserFlags,
209 chromeBinary: chromeBinary,
210 mobileEmulation: mobileEmulation,
211 ),
212 );
213 } on SocketException catch (error) {
214 _logger.printTrace('$error');
215 throwToolExit(
216 'Unable to start a WebDriver session for web testing.\n'
217 'Make sure you have the correct WebDriver server (e.g. chromedriver) running at $driverPort.\n'
218 'For instructions on how to obtain and run a WebDriver server, see:\n'
219 'https://flutter.dev/to/integration-test-on-web\n',
220 );
221 }
222
223 if (!isAndroidChrome && browserDimension != null) {
224 final async_io.Window window = await webDriver.window;
225 await window.setLocation(const math.Point<int>(0, 0));
226 await window.setSize(math.Rectangle<int>(0, 0, width, height));
227 }
228 final int result = await _processUtils.stream(
229 <String>[_dartSdkPath, ...arguments, testFile],
230 environment: <String, String>{
231 ..._platform.environment,
232 'VM_SERVICE_URL': _webUri.toString(),
233 ..._additionalDriverEnvironment(webDriver, browserName, androidEmulator),
234 },
235 );
236 await webDriver.quit();
237 return result;
238 }
239
240 @override
241 Future<void> stop({String? userIdentifier}) async {
242 final appDidFinishPrematurely = _runResult != null;
243 await _residentRunner.exitApp();
244 await _residentRunner.cleanupAtFinish();
245
246 if (appDidFinishPrematurely) {
247 throwToolExit(
248 'Application exited before the test finished. Check web driver logs '
249 'for possible application-side errors.',
250 );
251 }
252 }
253
254 Map<String, String> _additionalDriverEnvironment(
255 async_io.WebDriver webDriver,
256 String? browserName,
257 bool? androidEmulator,
258 ) {
259 return <String, String>{
260 'DRIVER_SESSION_ID': webDriver.id,
261 'DRIVER_SESSION_URI': webDriver.uri.toString(),
262 'DRIVER_SESSION_SPEC': webDriver.spec.toString(),
263 'DRIVER_SESSION_CAPABILITIES': json.encode(webDriver.capabilities),
264 'SUPPORT_TIMELINE_ACTION': (Browser.fromCliName(browserName) == Browser.chrome).toString(),
265 'FLUTTER_WEB_TEST': 'true',
266 'ANDROID_CHROME_ON_EMULATOR':
267 (Browser.fromCliName(browserName) == Browser.androidChrome && androidEmulator!)
268 .toString(),
269 };
270 }
271
272 @override
273 Future<void> reuseApplication(
274 Uri vmServiceUri,
275 Device device,
276 DebuggingOptions debuggingOptions,
277 ) async {
278 throwToolExit('--use-existing-app is not supported with flutter web driver');
279 }
280}
281
282/// A list of supported browsers.
283enum Browser implements CliEnum {
284 /// Chrome on Android: https://developer.chrome.com/docs/multidevice/android/
285 androidChrome,
286
287 /// Chrome: https://www.google.com/chrome/
288 chrome,
289
290 /// Edge: https://www.microsoft.com/edge
291 edge,
292
293 /// Firefox: https://www.mozilla.org/en-US/firefox/
294 firefox,
295
296 /// Safari in iOS: https://www.apple.com/safari/
297 iosSafari,
298
299 /// Safari in macOS: https://www.apple.com/safari/
300 safari;
301
302 @override
303 String get helpText => switch (this) {
304 Browser.androidChrome => 'Chrome on Android (see also "--android-emulator").',
305 Browser.chrome => 'Google Chrome on this computer (see also "--chrome-binary").',
306 Browser.edge => 'Microsoft Edge on this computer (Windows only).',
307 Browser.firefox => 'Mozilla Firefox on this computer.',
308 Browser.iosSafari => 'Apple Safari on an iOS device.',
309 Browser.safari => 'Apple Safari on this computer (macOS only).',
310 };
311
312 @override
313 String get cliName => kebabCase(name);
314
315 static Browser fromCliName(String? value) => Browser.values.singleWhere(
316 (Browser element) => element.cliName == value,
317 orElse: () => throw UnsupportedError('Browser $value not supported'),
318 );
319}
320
321/// Returns desired capabilities for given [browser], [headless], [chromeBinary]
322/// and [webBrowserFlags].
323@visibleForTesting
324Map<String, dynamic> getDesiredCapabilities(
325 Browser browser,
326 bool? headless, {
327 List<String> webBrowserFlags = const <String>[],
328 String? chromeBinary,
329 Map<String, dynamic>? mobileEmulation,
330}) => switch (browser) {
331 Browser.chrome => <String, dynamic>{
332 'acceptInsecureCerts': true,
333 'browserName': 'chrome',
334 'goog:loggingPrefs': <String, String>{
335 async_io.LogType.browser: 'INFO',
336 async_io.LogType.performance: 'ALL',
337 },
338 'goog:chromeOptions': <String, dynamic>{
339 'w3c': true,
340 'args': <String>[
341 '--bwsi',
342 '--disable-background-timer-throttling',
343 '--disable-default-apps',
344 '--disable-extensions',
345 '--disable-popup-blocking',
346 '--disable-translate',
347 '--no-default-browser-check',
348 '--no-sandbox',
349 '--no-first-run',
350 if (headless!) '--headless',
351 ...webBrowserFlags,
352 ],
353 'perfLoggingPrefs': <String, String>{
354 'traceCategories':
355 'devtools.timeline,'
356 'v8,blink.console,benchmark,blink,'
357 'blink.user_timing',
358 },
359 if (chromeBinary != null) 'binary': chromeBinary,
360 if (mobileEmulation != null) 'mobileEmulation': mobileEmulation,
361 },
362 },
363 Browser.firefox => <String, dynamic>{
364 'acceptInsecureCerts': true,
365 'browserName': 'firefox',
366 'moz:firefoxOptions': <String, dynamic>{
367 'args': <String>[if (headless!) '-headless', ...webBrowserFlags],
368 'prefs': <String, dynamic>{
369 'dom.file.createInChild': true,
370 'dom.timeout.background_throttling_max_budget': -1,
371 'media.autoplay.default': 0,
372 'media.gmp-manager.url': '',
373 'media.gmp-provider.enabled': false,
374 'network.captive-portal-service.enabled': false,
375 'security.insecure_field_warning.contextual.enabled': false,
376 'test.currentTimeOffsetSeconds': 11491200,
377 },
378 'log': <String, String>{'level': 'trace'},
379 },
380 },
381 Browser.edge => <String, dynamic>{'acceptInsecureCerts': true, 'browserName': 'edge'},
382 Browser.safari => <String, dynamic>{'browserName': 'safari'},
383 Browser.iosSafari => <String, dynamic>{
384 'platformName': 'ios',
385 'browserName': 'safari',
386 'safari:useSimulator': true,
387 },
388 Browser.androidChrome => <String, dynamic>{
389 'browserName': 'chrome',
390 'platformName': 'android',
391 'goog:chromeOptions': <String, dynamic>{
392 'androidPackage': 'com.android.chrome',
393 'args': <String>['--disable-fullscreen', ...webBrowserFlags],
394 },
395 },
396};
397