| // Copyright 2013 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:convert'; |
| import 'dart:io'; |
| import 'dart:math' as math; |
| |
| import 'package:image/image.dart'; |
| import 'package:path/path.dart' as path; |
| import 'package:test_api/backend.dart'; |
| import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart' |
| as wip; |
| |
| import 'browser.dart'; |
| import 'browser_process.dart'; |
| import 'chrome_installer.dart'; |
| import 'common.dart'; |
| import 'environment.dart'; |
| import 'package_lock.dart'; |
| |
| /// Provides an environment for desktop Chrome. |
| class ChromeEnvironment implements BrowserEnvironment { |
| ChromeEnvironment({ |
| required bool useDwarf, |
| }) : _useDwarf = useDwarf; |
| |
| late final BrowserInstallation _installation; |
| |
| final bool _useDwarf; |
| |
| @override |
| Future<Browser> launchBrowserInstance( |
| Uri url, { |
| bool debug = false, |
| }) async { |
| return Chrome( |
| url, |
| _installation, |
| debug: debug, |
| useDwarf: _useDwarf |
| ); |
| } |
| |
| @override |
| Runtime get packageTestRuntime => Runtime.chrome; |
| |
| @override |
| Future<void> prepare() async { |
| final String version = packageLock.chromeLock.version; |
| _installation = await getOrInstallChrome( |
| version, |
| infoLog: isCi ? stdout : DevNull(), |
| ); |
| } |
| |
| @override |
| Future<void> cleanup() async {} |
| |
| @override |
| String get packageTestConfigurationYamlFile => 'dart_test_chrome.yaml'; |
| |
| @override |
| final String name = 'Chrome'; |
| } |
| |
| /// Runs desktop Chrome. |
| /// |
| /// Most of the communication with the browser is expected to happen via HTTP, |
| /// so this exposes a bare-bones API. The browser starts as soon as the class is |
| /// constructed, and is killed when [close] is called. |
| /// |
| /// Any errors starting or running the process are reported through [onExit]. |
| class Chrome extends Browser { |
| /// Starts a new instance of Chrome open to the given [url], which may be a |
| /// [Uri] or a [String]. |
| factory Chrome( |
| Uri url, |
| BrowserInstallation installation, { |
| required bool debug, |
| required bool useDwarf, |
| }) { |
| final Completer<Uri> remoteDebuggerCompleter = Completer<Uri>.sync(); |
| return Chrome._(BrowserProcess(() async { |
| // A good source of various Chrome CLI options: |
| // https://peter.sh/experiments/chromium-command-line-switches/ |
| // |
| // Things to try: |
| // --font-render-hinting |
| // --enable-font-antialiasing |
| // --gpu-rasterization-msaa-sample-count |
| // --disable-gpu |
| // --disallow-non-exact-resource-reuse |
| // --disable-font-subpixel-positioning |
| final bool isChromeNoSandbox = |
| Platform.environment['CHROME_NO_SANDBOX'] == 'true'; |
| final String dir = await generateUserDirectory(installation, useDwarf); |
| final List<String> args = <String>[ |
| '--user-data-dir=$dir', |
| url.toString(), |
| if (!debug) |
| '--headless', |
| if (isChromeNoSandbox) |
| '--no-sandbox', |
| // When headless, this is the actual size of the viewport. |
| if (!debug) |
| '--window-size=$kMaxScreenshotWidth,$kMaxScreenshotHeight', |
| // When debugging, run in maximized mode so there's enough room for DevTools. |
| if (debug) |
| '--start-maximized', |
| if (debug) |
| '--auto-open-devtools-for-tabs', |
| if (useDwarf) |
| '--devtools-flags=enabledExperiments=wasmDWARFDebugging', |
| // Always run unit tests at a 1x scale factor |
| '--force-device-scale-factor=1', |
| if (!useDwarf) |
| // DWARF debugging requires a Chrome extension. |
| '--disable-extensions', |
| '--disable-popup-blocking', |
| // Indicates that the browser is in "browse without sign-in" (Guest session) mode. |
| '--bwsi', |
| '--no-first-run', |
| '--no-default-browser-check', |
| '--disable-default-apps', |
| '--disable-translate', |
| '--remote-debugging-port=$kDevtoolsPort', |
| |
| // SwiftShader support on ARM macs is disabled until they upgrade to a newer |
| // version of LLVM, see https://issuetracker.google.com/issues/165000222. In |
| // headless Chrome, the default is to use SwiftShader as a software renderer |
| // for WebGL contexts. In order to work around this limitation, we can force |
| // GPU rendering with this flag. |
| if (environment.isMacosArm) |
| '--use-angle=metal', |
| ]; |
| |
| final Process process = |
| await _spawnChromiumProcess(installation.executable, args); |
| |
| remoteDebuggerCompleter.complete( |
| getRemoteDebuggerUrl(Uri.parse('http://localhost:$kDevtoolsPort'))); |
| |
| unawaited(process.exitCode |
| .then((_) => Directory(dir).deleteSync(recursive: true))); |
| |
| return process; |
| }), remoteDebuggerCompleter.future); |
| } |
| |
| Chrome._(this._process, this.remoteDebuggerUrl); |
| |
| static Future<String> generateUserDirectory( |
| BrowserInstallation installation, |
| bool useDwarf |
| ) async { |
| final String userDirectoryPath = environment |
| .webUiDartToolDir |
| .createTempSync('test_chrome_user_data_') |
| .resolveSymbolicLinksSync(); |
| if (!useDwarf) { |
| return userDirectoryPath; |
| } |
| |
| // Using DWARF debugging info requires installation of a Chrome extension. |
| // We can prompt for this, but in order to avoid prompting on every single |
| // browser launch, we cache the user directory after it has been installed. |
| final Directory baselineUserDirectory = Directory(path.join( |
| environment.webUiDartToolDir.path, |
| 'chrome_user_data_base', |
| )); |
| final Directory dwarfExtensionInstallDirectory = Directory(path.join( |
| baselineUserDirectory.path, |
| 'Default', |
| 'Extensions', |
| // This is the ID of the dwarf debugging extension. |
| 'pdcpmagijalfljmkmjngeonclgbbannb', |
| )); |
| if (!baselineUserDirectory.existsSync()) { |
| baselineUserDirectory.createSync(recursive: true); |
| } |
| if (!dwarfExtensionInstallDirectory.existsSync()) { |
| print('DWARF debugging requested. Launching Chrome. Please install the ' |
| 'extension and then exit Chrome when the installation is complete...'); |
| final Process addExtension = await Process.start( |
| installation.executable, |
| <String>[ |
| '--user-data-dir=${baselineUserDirectory.path}', |
| 'https://goo.gle/wasm-debugging-extension', |
| '--bwsi', |
| '--no-first-run', |
| '--no-default-browser-check', |
| '--disable-default-apps', |
| '--disable-translate', |
| ] |
| ); |
| await addExtension.exitCode; |
| } |
| for (final FileSystemEntity input in baselineUserDirectory.listSync(recursive: true)) { |
| final String relative = path.relative(input.path, from: baselineUserDirectory.path); |
| final String outputPath = path.join(userDirectoryPath, relative); |
| if (input is Directory) { |
| await Directory(outputPath).create(recursive: true); |
| } else if (input is File) { |
| await input.copy(outputPath); |
| } |
| } |
| return userDirectoryPath; |
| } |
| |
| final BrowserProcess _process; |
| |
| @override |
| final Future<Uri> remoteDebuggerUrl; |
| |
| @override |
| Future<void> get onExit => _process.onExit; |
| |
| @override |
| Future<void> close() => _process.close(); |
| |
| // Always compare screenshots when running tests locally. On CI only compare |
| // on Linux. |
| @override |
| bool get supportsScreenshots => Platform.isLinux || !isLuci; |
| |
| /// Capture a screenshot of the web content. |
| /// |
| /// Uses Webkit Inspection Protocol server's `captureScreenshot` API. |
| /// |
| /// [region] is used to decide which part of the web content will be used in |
| /// test image. It includes starting coordinate x,y as well as height and |
| /// width of the area to capture. |
| /// |
| /// This method can be used for both macOS and Linux. |
| // TODO(yjbanov): extends tests to Window, https://github.com/flutter/flutter/issues/65673 |
| @override |
| Future<Image> captureScreenshot(math.Rectangle<num>? region) async { |
| final wip.ChromeConnection chromeConnection = |
| wip.ChromeConnection('localhost', kDevtoolsPort); |
| final wip.ChromeTab? chromeTab = await chromeConnection.getTab( |
| (wip.ChromeTab chromeTab) => chromeTab.url.contains('localhost')); |
| if (chromeTab == null) { |
| throw StateError( |
| 'Failed locate Chrome tab with the test page', |
| ); |
| } |
| final wip.WipConnection wipConnection = await chromeTab.connect(); |
| |
| Map<String, dynamic>? captureScreenshotParameters; |
| if (region != null) { |
| captureScreenshotParameters = <String, dynamic>{ |
| 'format': 'png', |
| 'clip': <String, dynamic>{ |
| 'x': region.left, |
| 'y': region.top, |
| 'width': region.width, |
| 'height': region.height, |
| 'scale': |
| // This is NOT the DPI of the page, instead it's the "zoom level". |
| 1, |
| }, |
| }; |
| } |
| |
| // Setting hardware-independent screen parameters: |
| // https://chromedevtools.github.io/devtools-protocol/tot/Emulation |
| await wipConnection |
| .sendCommand('Emulation.setDeviceMetricsOverride', <String, dynamic>{ |
| 'width': kMaxScreenshotWidth, |
| 'height': kMaxScreenshotHeight, |
| 'deviceScaleFactor': 1, |
| 'mobile': false, |
| }); |
| final wip.WipResponse response = await wipConnection.sendCommand( |
| 'Page.captureScreenshot', captureScreenshotParameters); |
| |
| final Image screenshot = |
| decodePng(base64.decode(response.result!['data'] as String))!; |
| |
| return screenshot; |
| } |
| |
| } |
| |
| /// Used by [Chrome] to detect a glibc bug and retry launching the |
| /// browser. |
| /// |
| /// Once every few thousands of launches we hit this glibc bug: |
| /// |
| /// https://sourceware.org/bugzilla/show_bug.cgi?id=19329. |
| /// |
| /// When this happens Chrome spits out something like the following then exits with code 127: |
| /// |
| /// Inconsistency detected by ld.so: ../elf/dl-tls.c: 493: _dl_allocate_tls_init: Assertion `listp->slotinfo[cnt].gen <= GL(dl_tls_generation)' failed! |
| const String _kGlibcError = 'Inconsistency detected by ld.so'; |
| |
| Future<Process> _spawnChromiumProcess(String executable, List<String> args, { String? workingDirectory }) async { |
| // Keep attempting to launch the browser until one of: |
| // - Chrome launched successfully, in which case we just return from the loop. |
| // - The tool detected an unretriable Chrome error, in which case we throw ToolExit. |
| while (true) { |
| final Process process = await Process.start(executable, args, workingDirectory: workingDirectory); |
| |
| process.stdout |
| .transform(utf8.decoder) |
| .transform(const LineSplitter()) |
| .listen((String line) { |
| print('[CHROME STDOUT]: $line'); |
| }); |
| |
| // Wait until the DevTools are listening before trying to connect. This is |
| // only required for flutter_test --platform=chrome and not flutter run. |
| bool hitGlibcBug = false; |
| await process.stderr |
| .transform(utf8.decoder) |
| .transform(const LineSplitter()) |
| .map((String line) { |
| print('[CHROME STDERR]:$line'); |
| if (line.contains(_kGlibcError)) { |
| hitGlibcBug = true; |
| } |
| return line; |
| }) |
| .firstWhere((String line) => line.startsWith('DevTools listening'), orElse: () { |
| if (hitGlibcBug) { |
| const String message = 'Encountered glibc bug ' |
| 'https://sourceware.org/bugzilla/show_bug.cgi?id=19329. ' |
| 'Will try launching browser again.'; |
| print(message); |
| return message; |
| } |
| print('Failed to launch browser. Command used to launch it: ${args.join(' ')}'); |
| throw Exception( |
| 'Failed to launch browser. Make sure you are using an up-to-date ' |
| 'Chrome or Edge. Otherwise, consider using -d web-server instead ' |
| 'and filing an issue at https://github.com/flutter/flutter/issues.', |
| ); |
| }); |
| |
| if (!hitGlibcBug) { |
| return process; |
| } |
| |
| // A precaution that avoids accumulating browser processes, in case the |
| // glibc bug doesn't cause the browser to quit and we keep looping and |
| // launching more processes. |
| |
| // It's OK to not await the future here, as this is a best-effort process |
| // clean-up only. If we're executing this line, this means things are off |
| // the rails already due to the glibc bug, and we're just scrambling to keep |
| // the system stable. |
| // ignore: unawaited_futures |
| process.exitCode.timeout(const Duration(seconds: 1), onTimeout: () { |
| process.kill(); |
| return -1; |
| }); |
| } |
| } |
| |
| /// Returns the full URL of the Chrome remote debugger for the main page. |
| /// |
| /// This takes the [base] remote debugger URL (which points to a browser-wide |
| /// page) and uses its JSON API to find the resolved URL for debugging the host |
| /// page. |
| Future<Uri> getRemoteDebuggerUrl(Uri base) async { |
| try { |
| final HttpClient client = HttpClient(); |
| final HttpClientRequest request = await client.getUrl(base.resolve('/json/list')); |
| final HttpClientResponse response = await request.close(); |
| final List<dynamic>? jsonObject = |
| await json.fuse(utf8).decoder.bind(response).single as List<dynamic>?; |
| return base.resolve((jsonObject!.first as Map<dynamic, dynamic>)['devtoolsFrontendUrl'] as String); |
| } catch (_) { |
| // If we fail to talk to the remote debugger protocol, give up and return |
| // the raw URL rather than crashing. |
| return base; |
| } |
| } |