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 | |
5 | import 'dart:async'; |
6 | import 'dart:math' as math; |
7 | |
8 | import 'package:file/file.dart' ; |
9 | import 'package:meta/meta.dart' ; |
10 | import 'package:package_config/package_config.dart' ; |
11 | import 'package:webdriver/async_io.dart' as async_io; |
12 | |
13 | import '../base/common.dart'; |
14 | import '../base/io.dart'; |
15 | import '../base/logger.dart'; |
16 | import '../base/platform.dart'; |
17 | import '../base/process.dart'; |
18 | import '../base/terminal.dart'; |
19 | import '../base/utils.dart'; |
20 | import '../build_info.dart'; |
21 | import '../convert.dart'; |
22 | import '../device.dart'; |
23 | import '../globals.dart' as globals; |
24 | import '../project.dart'; |
25 | import '../resident_runner.dart'; |
26 | import '../web/web_runner.dart'; |
27 | import 'drive_service.dart'; |
28 | |
29 | /// An implementation of the driver service for web debug and release applications. |
30 | class 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. |
283 | enum 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 |
324 | Map<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 | |