| // 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; | |
| } | |
| } |