diff --git a/.cirrus.yml b/.cirrus.yml index 9a091276ebe63..1f9e1e6b505ee 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -63,6 +63,18 @@ task: test_framework_script: | cd $FRAMEWORK_PATH/flutter/packages/flutter ../../bin/flutter test --local-engine=host_debug_unopt + - name: build_and_test_web_linux_firefox + compile_host_script: | + cd $ENGINE_PATH/src + ./flutter/tools/gn --unoptimized --full-dart-sdk + ninja -C out/host_debug_unopt + test_web_engine_firefox_script: | + cd $ENGINE_PATH/src/flutter/web_sdk/web_engine_tester + $ENGINE_PATH/src/out/host_debug_unopt/dart-sdk/bin/pub get + cd $ENGINE_PATH/src/flutter/lib/web_ui + $ENGINE_PATH/src/out/host_debug_unopt/dart-sdk/bin/pub get + export DART="$ENGINE_PATH/src/out/host_debug_unopt/dart-sdk/bin/dart" + $DART dev/firefox_installer_test.dart - name: build_and_test_android_unopt_debug env: USE_ANDROID: "True" diff --git a/lib/web_ui/dev/browser.dart b/lib/web_ui/dev/browser.dart new file mode 100644 index 0000000000000..8301289acf541 --- /dev/null +++ b/lib/web_ui/dev/browser.dart @@ -0,0 +1,144 @@ +// 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 'package:pedantic/pedantic.dart'; +import 'package:stack_trace/stack_trace.dart'; +import 'package:typed_data/typed_buffers.dart'; + +import 'package:test_api/src/utils.dart'; // ignore: implementation_imports + +/// An interface for running browser instances. +/// +/// This is intentionally coarse-grained: browsers are controlled primary from +/// inside a single tab. Thus this interface only provides support for closing +/// the browser and seeing if it closes itself. +/// +/// Any errors starting or running the browser process are reported through +/// [onExit]. +abstract class Browser { + String get name; + + /// The Observatory URL for this browser. + /// + /// This will return `null` for browsers that aren't running the Dart VM, or + /// if the Observatory URL can't be found. + Future get observatoryUrl => null; + + /// The remote debugger URL for this browser. + /// + /// This will return `null` for browsers that don't support remote debugging, + /// or if the remote debugging URL can't be found. + Future get remoteDebuggerUrl => null; + + /// The underlying process. + /// + /// This will fire once the process has started successfully. + Future get _process => _processCompleter.future; + final _processCompleter = Completer(); + + /// Whether [close] has been called. + var _closed = false; + + /// A future that completes when the browser exits. + /// + /// If there's a problem starting or running the browser, this will complete + /// with an error. + Future get onExit => _onExitCompleter.future; + final _onExitCompleter = Completer(); + + /// Standard IO streams for the underlying browser process. + final _ioSubscriptions = []; + + /// Creates a new browser. + /// + /// This is intended to be called by subclasses. They pass in [startBrowser], + /// which asynchronously returns the browser process. Any errors in + /// [startBrowser] (even those raised asynchronously after it returns) are + /// piped to [onExit] and will cause the browser to be killed. + Browser(Future startBrowser()) { + // Don't return a Future here because there's no need for the caller to wait + // for the process to actually start. They should just wait for the HTTP + // request instead. + runZoned(() async { + var process = await startBrowser(); + _processCompleter.complete(process); + + var output = Uint8Buffer(); + drainOutput(Stream> stream) { + try { + _ioSubscriptions + .add(stream.listen(output.addAll, cancelOnError: true)); + } on StateError catch (_) {} + } + + // If we don't drain the stdout and stderr the process can hang. + drainOutput(process.stdout); + drainOutput(process.stderr); + + var exitCode = await process.exitCode; + + // This hack dodges an otherwise intractable race condition. When the user + // presses Control-C, the signal is sent to the browser and the test + // runner at the same time. It's possible for the browser to exit before + // the [Browser.close] is called, which would trigger the error below. + // + // A negative exit code signals that the process exited due to a signal. + // However, it's possible that this signal didn't come from the user's + // Control-C, in which case we do want to throw the error. The only way to + // resolve the ambiguity is to wait a brief amount of time and see if this + // browser is actually closed. + if (!_closed && exitCode < 0) { + await Future.delayed(Duration(milliseconds: 200)); + } + + if (!_closed && exitCode != 0) { + var outputString = utf8.decode(output); + var message = '$name failed with exit code $exitCode.'; + if (outputString.isNotEmpty) { + message += '\nStandard output:\n$outputString'; + } + + throw Exception(message); + } + + _onExitCompleter.complete(); + }, onError: (error, StackTrace stackTrace) { + // Ignore any errors after the browser has been closed. + if (_closed) return; + + // Make sure the process dies even if the error wasn't fatal. + _process.then((process) => process.kill()); + + if (stackTrace == null) stackTrace = Trace.current(); + if (_onExitCompleter.isCompleted) return; + _onExitCompleter.completeError( + Exception('Failed to run $name: ${getErrorMessage(error)}.'), + stackTrace); + }); + } + + /// Kills the browser process. + /// + /// Returns the same [Future] as [onExit], except that it won't emit + /// exceptions. + Future close() async { + _closed = true; + + // If we don't manually close the stream the test runner can hang. + // For example this happens with Chrome Headless. + // See SDK issue: https://github.com/dart-lang/sdk/issues/31264 + for (var stream in _ioSubscriptions) { + unawaited(stream.cancel()); + } + + (await _process).kill(); + + // Swallow exceptions. The user should explicitly use [onExit] for these. + return onExit.catchError((_) {}); + } +} diff --git a/lib/web_ui/dev/chrome.dart b/lib/web_ui/dev/chrome.dart new file mode 100644 index 0000000000000..f2d1e3a6e9b63 --- /dev/null +++ b/lib/web_ui/dev/chrome.dart @@ -0,0 +1,85 @@ +// 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:io'; + +import 'package:pedantic/pedantic.dart'; + +import 'package:test_core/src/util/io.dart'; // ignore: implementation_imports + +import 'browser.dart'; +import 'chrome_installer.dart'; +import 'common.dart'; + +/// A class for running an instance of 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 { + @override + final name = 'Chrome'; + + @override + final Future remoteDebuggerUrl; + + static String version; + + /// Starts a new instance of Chrome open to the given [url], which may be a + /// [Uri] or a [String]. + factory Chrome(Uri url, {bool debug = false}) { + assert(version != null); + var remoteDebuggerCompleter = Completer.sync(); + return Chrome._(() async { + final BrowserInstallation installation = await getOrInstallChrome( + version, + infoLog: isCirrus ? stdout : DevNull(), + ); + + // 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'; + var dir = createTempDir(); + var args = [ + '--user-data-dir=$dir', + url.toString(), + if (!debug) '--headless', + if (isChromeNoSandbox) '--no-sandbox', + '--window-size=$kMaxScreenshotWidth,$kMaxScreenshotHeight', // When headless, this is the actual size of the viewport + '--disable-extensions', + '--disable-popup-blocking', + '--bwsi', + '--no-first-run', + '--no-default-browser-check', + '--disable-default-apps', + '--disable-translate', + '--remote-debugging-port=$kDevtoolsPort', + ]; + + final Process process = await Process.start(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._(Future startBrowser(), this.remoteDebuggerUrl) + : super(startBrowser); +} diff --git a/lib/web_ui/dev/common.dart b/lib/web_ui/dev/common.dart index 99d764b08e060..e96872da283c5 100644 --- a/lib/web_ui/dev/common.dart +++ b/lib/web_ui/dev/common.dart @@ -8,6 +8,12 @@ import 'package:meta/meta.dart'; import 'package:path/path.dart' as path; import 'package:yaml/yaml.dart'; +/// The port number for debugging. +const int kDevtoolsPort = 12345; +const int kMaxScreenshotWidth = 1024; +const int kMaxScreenshotHeight = 1024; +const double kMaxDiffRateFailure = 0.28 / 100; // 0.28% + class BrowserInstallerException implements Exception { BrowserInstallerException(this.message); @@ -60,20 +66,16 @@ class _LinuxBinding implements PlatformBinding { path.join(versionDir.path, 'chrome-linux', 'chrome'); @override - String getFirefoxDownloadUrl(String version) { - return 'https://download-installer.cdn.mozilla.net/pub/firefox/releases/${version}/linux-x86_64/en-US/firefox-${version}.tar.bz2'; - } + String getFirefoxDownloadUrl(String version) => + 'https://download-installer.cdn.mozilla.net/pub/firefox/releases/${version}/linux-x86_64/en-US/firefox-${version}.tar.bz2'; @override - String getFirefoxExecutablePath(io.Directory versionDir) { - // TODO: implement getFirefoxExecutablePath - return null; - } + String getFirefoxExecutablePath(io.Directory versionDir) => + path.join(versionDir.path, 'firefox', 'firefox'); @override - String getFirefoxLatestVersionUrl() { - return 'https://download.mozilla.org/?product=firefox-latest&os=linux64&lang=en-US'; - } + String getFirefoxLatestVersionUrl() => + 'https://download.mozilla.org/?product=firefox-latest&os=linux64&lang=en-US'; } class _MacBinding implements PlatformBinding { @@ -87,7 +89,6 @@ class _MacBinding implements PlatformBinding { String getChromeDownloadUrl(String version) => '$_kBaseDownloadUrl/Mac%2F$version%2Fchrome-mac.zip?alt=media'; - @override String getChromeExecutablePath(io.Directory versionDir) => path.join( versionDir.path, 'chrome-mac', @@ -97,28 +98,24 @@ class _MacBinding implements PlatformBinding { 'Chromium'); @override - String getFirefoxDownloadUrl(String version) { - // TODO: implement getFirefoxDownloadUrl - return null; - } + String getFirefoxDownloadUrl(String version) => + 'https://download-installer.cdn.mozilla.net/pub/firefox/releases/${version}/mac/en-US/firefox-${version}.dmg'; @override String getFirefoxExecutablePath(io.Directory versionDir) { - // TODO: implement getFirefoxExecutablePath - return null; + throw UnimplementedError(); } @override - String getFirefoxLatestVersionUrl() { - return 'https://download.mozilla.org/?product=firefox-latest&os=osx&lang=en-US'; - } + String getFirefoxLatestVersionUrl() => + 'https://download.mozilla.org/?product=firefox-latest&os=osx&lang=en-US'; } class BrowserInstallation { - const BrowserInstallation({ - @required this.version, - @required this.executable, - }); + const BrowserInstallation( + {@required this.version, + @required this.executable, + fetchLatestChromeVersion}); /// Browser version. final String version; @@ -126,3 +123,20 @@ class BrowserInstallation { /// Path the the browser executable. final String executable; } + +/// A string sink that swallows all input. +class DevNull implements StringSink { + @override + void write(Object obj) {} + + @override + void writeAll(Iterable objects, [String separator = ""]) {} + + @override + void writeCharCode(int charCode) {} + + @override + void writeln([Object obj = ""]) {} +} + +bool get isCirrus => io.Platform.environment['CIRRUS_CI'] == 'true'; diff --git a/lib/web_ui/dev/firefox.dart b/lib/web_ui/dev/firefox.dart new file mode 100644 index 0000000000000..e1a43301eb128 --- /dev/null +++ b/lib/web_ui/dev/firefox.dart @@ -0,0 +1,71 @@ +// 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:io'; + +import 'package:pedantic/pedantic.dart'; + +import 'package:test_core/src/util/io.dart'; // ignore: implementation_imports + +import 'browser.dart'; +import 'firefox_installer.dart'; +import 'common.dart'; + +/// A class for running an instance of Firefox. +/// +/// 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 Firefox extends Browser { + @override + final name = 'Firefox'; + + @override + final Future remoteDebuggerUrl; + + static String version; + + /// Starts a new instance of Firefox open to the given [url], which may be a + /// [Uri] or a [String]. + factory Firefox(Uri url, {bool debug = false}) { + assert(version != null); + var remoteDebuggerCompleter = Completer.sync(); + return Firefox._(() async { + final BrowserInstallation installation = await getOrInstallFirefox( + version, + infoLog: isCirrus ? stdout : DevNull(), + ); + + // A good source of various Firefox Command Line options: + // https://developer.mozilla.org/en-US/docs/Mozilla/Command_Line_Options#Browser + // + var dir = createTempDir(); + var args = [ + url.toString(), + if (!debug) '--headless', + '-width $kMaxScreenshotWidth' + '-height $kMaxScreenshotHeight', + '-new-window', + '-new-instance', + '--start-debugger-server $kDevtoolsPort', + ]; + + final Process process = await Process.start(installation.executable, args); + + remoteDebuggerCompleter.complete(getRemoteDebuggerUrl( + Uri.parse('http://localhost:$kDevtoolsPort'))); + + unawaited(process.exitCode + .then((_) => Directory(dir).deleteSync(recursive: true))); + + return process; + }, remoteDebuggerCompleter.future); + } + + Firefox._(Future startBrowser(), this.remoteDebuggerUrl) + : super(startBrowser); +} diff --git a/lib/web_ui/dev/firefox_installer.dart b/lib/web_ui/dev/firefox_installer.dart index ccf7a7e0d8eb9..641b9ad638591 100644 --- a/lib/web_ui/dev/firefox_installer.dart +++ b/lib/web_ui/dev/firefox_installer.dart @@ -10,6 +10,59 @@ import 'package:path/path.dart' as path; import 'common.dart'; import 'environment.dart'; +/// Returns the installation of Firefox, installing it if necessary. +/// +/// If [requestedVersion] is null, uses the version specified on the +/// command-line. If not specified on the command-line, uses the version +/// specified in the "browser_lock.yaml" file. +/// +/// If [requestedVersion] is not null, installs that version. The value +/// may be "latest" (the latest stable Firefox version), "system" +/// (manually installed Firefox on the current operating system), or an +/// exact version number such as 69.0.3. Versions of Firefox can be found here: +/// +/// https://download-installer.cdn.mozilla.net/pub/firefox/releases/ +Future getOrInstallFirefox( + String requestedVersion, { + StringSink infoLog, +}) async { + // These tests are aimed to run only on the Linux containers in Cirrus. + // Therefore Firefox installation is implemented only for Linux now. + if (!io.Platform.isLinux) { + throw UnimplementedError(); + } + + infoLog ??= io.stdout; + + if (requestedVersion == 'system') { + return BrowserInstallation( + version: 'system', + executable: await _findSystemFirefoxExecutable(), + ); + } + + FirefoxInstaller installer; + try { + installer = requestedVersion == 'latest' + ? await FirefoxInstaller.latest() + : FirefoxInstaller(version: requestedVersion); + + if (installer.isInstalled) { + infoLog.writeln( + 'Installation was skipped because Firefox version ${installer.version} is already installed.'); + } else { + infoLog.writeln('Installing Firefox version: ${installer.version}'); + await installer.install(); + final BrowserInstallation installation = installer.getInstallation(); + infoLog.writeln( + 'Installations complete. To launch it run ${installation.executable}'); + } + return installer.getInstallation(); + } finally { + installer?.close(); + } +} + /// Manages the installation of a particular [version] of Firefox. class FirefoxInstaller { factory FirefoxInstaller({ @@ -96,7 +149,7 @@ class FirefoxInstaller { )); final io.File downloadedFile = - io.File(path.join(versionDir.path, 'firefox.zip')); + io.File(path.join(versionDir.path, 'firefox-${version}.tar.bz2')); await download.stream.pipe(downloadedFile.openWrite()); return downloadedFile; @@ -105,7 +158,19 @@ class FirefoxInstaller { /// Uncompress the downloaded browser files. /// See [version]. Future _uncompress(io.File downloadedFile) async { - /// TODO(nturgut): Implement Install. + final io.ProcessResult unzipResult = await io.Process.run('tar', [ + '-x', + '-f', + downloadedFile.path, + '-C', + versionDir.path, + ]); + + if (unzipResult.exitCode != 0) { + throw BrowserInstallerException( + 'Failed to unzip the downloaded Firefox archive ${downloadedFile.path}.\n' + 'The unzip process exited with code ${unzipResult.exitCode}.'); + } } void close() { @@ -113,6 +178,18 @@ class FirefoxInstaller { } } +Future _findSystemFirefoxExecutable() async { + final io.ProcessResult which = + await io.Process.run('which', ['firefox']); + + if (which.exitCode != 0) { + throw BrowserInstallerException( + 'Failed to locate system Firefox installation.'); + } + + return which.stdout; +} + /// Fetches the latest available Chrome build version. Future fetchLatestFirefoxVersion() async { final RegExp forFirefoxVersion = RegExp("firefox-[0-9.]\+[0-9]"); diff --git a/lib/web_ui/dev/firefox_installer_test.dart b/lib/web_ui/dev/firefox_installer_test.dart new file mode 100644 index 0000000000000..0d6c73e6b129a --- /dev/null +++ b/lib/web_ui/dev/firefox_installer_test.dart @@ -0,0 +1,45 @@ +// 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. + +@TestOn('vm && linux') + +import 'dart:io' as io; + +import 'package:path/path.dart' as path; +import 'package:test/test.dart'; + +import 'common.dart'; +import 'environment.dart'; +import 'firefox_installer.dart'; + +void main() async { + void deleteFirefoxInstallIfExists() { + final io.Directory firefoxInstallationDir = io.Directory( + path.join(environment.webUiDartToolDir.path, 'firefox'), + ); + + if (firefoxInstallationDir.existsSync()) { + firefoxInstallationDir.deleteSync(recursive: true); + } + } + + setUpAll(() { + deleteFirefoxInstallIfExists(); + }); + + tearDown(() { + deleteFirefoxInstallIfExists(); + }); + + test('installs a given version of Firefox', () async { + FirefoxInstaller installer = FirefoxInstaller(version: '69.0.2'); + expect(installer.isInstalled, isFalse); + + BrowserInstallation installation = await getOrInstallFirefox('69.0.2'); + + expect(installation.version, '69.0.2'); + expect(installer.isInstalled, isTrue); + expect(io.File(installation.executable).existsSync(), isTrue); + }); +} diff --git a/lib/web_ui/dev/test_platform.dart b/lib/web_ui/dev/test_platform.dart index 99236d759b8f5..bf1b5f95614d0 100644 --- a/lib/web_ui/dev/test_platform.dart +++ b/lib/web_ui/dev/test_platform.dart @@ -12,16 +12,13 @@ import 'package:http_multi_server/http_multi_server.dart'; import 'package:image/image.dart'; import 'package:package_resolver/package_resolver.dart'; import 'package:path/path.dart' as p; -import 'package:pedantic/pedantic.dart'; import 'package:pool/pool.dart'; import 'package:shelf/shelf.dart' as shelf; import 'package:shelf/shelf_io.dart' as shelf_io; import 'package:shelf_static/shelf_static.dart'; import 'package:shelf_web_socket/shelf_web_socket.dart'; import 'package:shelf_packages_handler/shelf_packages_handler.dart'; -import 'package:stack_trace/stack_trace.dart'; import 'package:stream_channel/stream_channel.dart'; -import 'package:typed_data/typed_buffers.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:test_api/src/backend/runtime.dart'; // ignore: implementation_imports @@ -40,17 +37,12 @@ import 'package:test_core/src/runner/load_exception.dart'; // ignore: implementa import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart' as wip; -import 'chrome_installer.dart'; +import 'browser.dart'; +import 'chrome.dart'; import 'common.dart'; import 'environment.dart' as env; import 'goldens.dart'; -/// The port number Chrome exposes for debugging. -const int _kChromeDevtoolsPort = 12345; -const int _kMaxScreenshotWidth = 1024; -const int _kMaxScreenshotHeight = 1024; -const double _kMaxDiffRateFailure = 0.28/100; // 0.28% - class BrowserPlatform extends PlatformPlugin { /// Starts the server. /// @@ -149,7 +141,7 @@ class BrowserPlatform extends PlatformPlugin { final bool write = requestData['write']; final double maxDiffRate = requestData['maxdiffrate']; final Map region = requestData['region']; - final String result = await _diffScreenshot(filename, write, maxDiffRate ?? _kMaxDiffRateFailure, region); + final String result = await _diffScreenshot(filename, write, maxDiffRate ?? kMaxDiffRateFailure, region); return shelf.Response.ok(json.encode(result)); } @@ -189,7 +181,7 @@ To automatically create this file call matchGoldenFile('$filename', write: true) } final wip.ChromeConnection chromeConnection = - wip.ChromeConnection('localhost', _kChromeDevtoolsPort); + wip.ChromeConnection('localhost', kDevtoolsPort); final wip.ChromeTab chromeTab = await chromeConnection.getTab( (wip.ChromeTab chromeTab) => chromeTab.url.contains('localhost')); final wip.WipConnection wipConnection = await chromeTab.connect(); @@ -211,8 +203,8 @@ To automatically create this file call matchGoldenFile('$filename', write: true) // Setting hardware-independent screen parameters: // https://chromedevtools.github.io/devtools-protocol/tot/Emulation await wipConnection.sendCommand('Emulation.setDeviceMetricsOverride', { - 'width': _kMaxScreenshotWidth, - 'height': _kMaxScreenshotHeight, + 'width': kMaxScreenshotWidth, + 'height': kMaxScreenshotHeight, 'deviceScaleFactor': 1, 'mobile': false, }); @@ -840,225 +832,4 @@ class _BrowserEnvironment implements Environment { CancelableOperation displayPause() => _manager._displayPause(); } -/// An interface for running browser instances. -/// -/// This is intentionally coarse-grained: browsers are controlled primary from -/// inside a single tab. Thus this interface only provides support for closing -/// the browser and seeing if it closes itself. -/// -/// Any errors starting or running the browser process are reported through -/// [onExit]. -abstract class Browser { - String get name; - - /// The Observatory URL for this browser. - /// - /// This will return `null` for browsers that aren't running the Dart VM, or - /// if the Observatory URL can't be found. - Future get observatoryUrl => null; - - /// The remote debugger URL for this browser. - /// - /// This will return `null` for browsers that don't support remote debugging, - /// or if the remote debugging URL can't be found. - Future get remoteDebuggerUrl => null; - - /// The underlying process. - /// - /// This will fire once the process has started successfully. - Future get _process => _processCompleter.future; - final _processCompleter = Completer(); - - /// Whether [close] has been called. - var _closed = false; - - /// A future that completes when the browser exits. - /// - /// If there's a problem starting or running the browser, this will complete - /// with an error. - Future get onExit => _onExitCompleter.future; - final _onExitCompleter = Completer(); - - /// Standard IO streams for the underlying browser process. - final _ioSubscriptions = []; - - /// Creates a new browser. - /// - /// This is intended to be called by subclasses. They pass in [startBrowser], - /// which asynchronously returns the browser process. Any errors in - /// [startBrowser] (even those raised asynchronously after it returns) are - /// piped to [onExit] and will cause the browser to be killed. - Browser(Future startBrowser()) { - // Don't return a Future here because there's no need for the caller to wait - // for the process to actually start. They should just wait for the HTTP - // request instead. - runZoned(() async { - var process = await startBrowser(); - _processCompleter.complete(process); - - var output = Uint8Buffer(); - drainOutput(Stream> stream) { - try { - _ioSubscriptions - .add(stream.listen(output.addAll, cancelOnError: true)); - } on StateError catch (_) {} - } - - // If we don't drain the stdout and stderr the process can hang. - drainOutput(process.stdout); - drainOutput(process.stderr); - - var exitCode = await process.exitCode; - - // This hack dodges an otherwise intractable race condition. When the user - // presses Control-C, the signal is sent to the browser and the test - // runner at the same time. It's possible for the browser to exit before - // the [Browser.close] is called, which would trigger the error below. - // - // A negative exit code signals that the process exited due to a signal. - // However, it's possible that this signal didn't come from the user's - // Control-C, in which case we do want to throw the error. The only way to - // resolve the ambiguity is to wait a brief amount of time and see if this - // browser is actually closed. - if (!_closed && exitCode < 0) { - await Future.delayed(Duration(milliseconds: 200)); - } - - if (!_closed && exitCode != 0) { - var outputString = utf8.decode(output); - var message = '$name failed with exit code $exitCode.'; - if (outputString.isNotEmpty) { - message += '\nStandard output:\n$outputString'; - } - - throw Exception(message); - } - - _onExitCompleter.complete(); - }, onError: (error, StackTrace stackTrace) { - // Ignore any errors after the browser has been closed. - if (_closed) return; - - // Make sure the process dies even if the error wasn't fatal. - _process.then((process) => process.kill()); - - if (stackTrace == null) stackTrace = Trace.current(); - if (_onExitCompleter.isCompleted) return; - _onExitCompleter.completeError( - Exception('Failed to run $name: ${getErrorMessage(error)}.'), - stackTrace); - }); - } - - /// Kills the browser process. - /// - /// Returns the same [Future] as [onExit], except that it won't emit - /// exceptions. - Future close() async { - _closed = true; - - // If we don't manually close the stream the test runner can hang. - // For example this happens with Chrome Headless. - // See SDK issue: https://github.com/dart-lang/sdk/issues/31264 - for (var stream in _ioSubscriptions) { - unawaited(stream.cancel()); - } - - (await _process).kill(); - - // Swallow exceptions. The user should explicitly use [onExit] for these. - return onExit.catchError((_) {}); - } -} - -/// A class for running an instance of 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 { - @override - final name = 'Chrome'; - - @override - final Future remoteDebuggerUrl; - - static String version; - - /// Starts a new instance of Chrome open to the given [url], which may be a - /// [Uri] or a [String]. - factory Chrome(Uri url, {bool debug = false}) { - assert(version != null); - var remoteDebuggerCompleter = Completer.sync(); - return Chrome._(() async { - final BrowserInstallation installation = await getOrInstallChrome( - version, - infoLog: isCirrus ? stdout : _DevNull(), - ); - - // 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'; - var dir = createTempDir(); - var args = [ - '--user-data-dir=$dir', - url.toString(), - if (!debug) '--headless', - if (isChromeNoSandbox) '--no-sandbox', - '--window-size=$_kMaxScreenshotWidth,$_kMaxScreenshotHeight', // When headless, this is the actual size of the viewport - '--disable-extensions', - '--disable-popup-blocking', - '--bwsi', - '--no-first-run', - '--no-default-browser-check', - '--disable-default-apps', - '--disable-translate', - '--remote-debugging-port=$_kChromeDevtoolsPort', - ]; - - final Process process = await Process.start(installation.executable, args); - - remoteDebuggerCompleter.complete(getRemoteDebuggerUrl( - Uri.parse('http://localhost:$_kChromeDevtoolsPort'))); - - unawaited(process.exitCode - .then((_) => Directory(dir).deleteSync(recursive: true))); - - return process; - }, remoteDebuggerCompleter.future); - } - - Chrome._(Future startBrowser(), this.remoteDebuggerUrl) - : super(startBrowser); -} - -/// A string sink that swallows all input. -class _DevNull implements StringSink { - @override - void write(Object obj) { - } - - @override - void writeAll(Iterable objects, [String separator = ""]) { - } - - @override - void writeCharCode(int charCode) { - } - - @override - void writeln([Object obj = ""]) { - } -} - bool get isCirrus => Platform.environment['CIRRUS_CI'] == 'true'; diff --git a/lib/web_ui/dev/test_runner.dart b/lib/web_ui/dev/test_runner.dart index a2a1851407b6a..842b4e4e7adfd 100644 --- a/lib/web_ui/dev/test_runner.dart +++ b/lib/web_ui/dev/test_runner.dart @@ -14,6 +14,7 @@ import 'package:test_api/src/backend/runtime.dart'; // ignore: implementation_im import 'package:test_core/src/executable.dart' as test; // ignore: implementation_imports +import 'chrome.dart'; import 'chrome_installer.dart'; import 'test_platform.dart'; import 'environment.dart';