| // 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:pedantic/pedantic.dart'; |
| import 'package:stack_trace/stack_trace.dart'; |
| import 'package:test_api/src/backend/runtime.dart'; |
| import 'package:typed_data/typed_buffers.dart'; |
| |
| /// Provides the environment for a specific web browser. |
| abstract class BrowserEnvironment { |
| /// The [Runtime] used by `package:test` to identify this browser type. |
| Runtime get packageTestRuntime; |
| |
| /// The name of the configuration YAML file used to configure `package:test`. |
| /// |
| /// The configuration file is expected to be a direct child of the `web_ui` |
| /// directory. |
| String get packageTestConfigurationYamlFile; |
| |
| /// Prepares the OS environment to run tests for this browser. |
| /// |
| /// This may include things like staring web drivers, iOS Simulators, and/or |
| /// Android emulators. |
| /// |
| /// Typically the browser environment is prepared once and supports multiple |
| /// browser instances. |
| Future<void> prepareEnvironment(); |
| |
| /// Launches a browser instance. |
| /// |
| /// The browser will be directed to open the provided [url]. |
| /// |
| /// If [debug] is true and the browser supports debugging, launches the |
| /// browser in debug mode by pausing test execution after the code is loaded |
| /// but before calling the `main()` function of the test, giving the |
| /// developer a chance to set breakpoints. |
| Browser launchBrowserInstance(Uri url, {bool debug = false}); |
| |
| /// Returns the screenshot manager used by this browser. |
| /// |
| /// If the browser does not support screenshots, returns null. |
| ScreenshotManager? getScreenshotManager(); |
| } |
| |
| /// 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. |
| /// |
| /// Returns `null` for browsers that aren't running the Dart VM, or |
| /// if the Observatory URL can't be found. |
| Future<Uri>? get observatoryUrl => null; |
| |
| /// The remote debugger URL for this browser. |
| /// |
| /// Returns `null` for browsers that don't support remote debugging, |
| /// or if the remote debugging URL can't be found. |
| Future<Uri>? get remoteDebuggerUrl => null; |
| |
| /// The underlying process. |
| /// |
| /// This will fire once the process has started successfully. |
| Future<Process> get _process => _processCompleter.future; |
| final Completer<Process> _processCompleter = Completer<Process>(); |
| |
| /// Whether [close] has been called. |
| bool _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<void> get onExit => _onExitCompleter.future; |
| final Completer<void> _onExitCompleter = Completer<void>(); |
| |
| /// Standard IO streams for the underlying browser process. |
| final List<StreamSubscription<void>> _ioSubscriptions = <StreamSubscription<void>>[]; |
| |
| /// 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<Process> Function() 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. |
| runZonedGuarded(() async { |
| final Process process = await startBrowser(); |
| _processCompleter.complete(process); |
| |
| final Uint8Buffer output = Uint8Buffer(); |
| void drainOutput(Stream<List<int>> 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); |
| |
| final int 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<void>.delayed(const Duration(milliseconds: 200)); |
| } |
| |
| if (!_closed && exitCode != 0) { |
| final String outputString = utf8.decode(output); |
| String message = '$name failed with exit code $exitCode.'; |
| if (outputString.isNotEmpty) { |
| message += '\nStandard output:\n$outputString'; |
| } |
| |
| throw Exception(message); |
| } |
| |
| _onExitCompleter.complete(); |
| }, (dynamic 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) => process.kill()); |
| |
| stackTrace ??= Trace.current(); |
| |
| if (_onExitCompleter.isCompleted) { |
| return; |
| } |
| _onExitCompleter.completeError( |
| Exception('Failed to run $name: $error.'), |
| stackTrace, |
| ); |
| }); |
| } |
| |
| /// Kills the browser process. |
| /// |
| /// Returns the same [Future] as [onExit], except that it won't emit |
| /// exceptions. |
| Future<void> 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 (final StreamSubscription<void> stream in _ioSubscriptions) { |
| unawaited(stream.cancel()); |
| } |
| |
| (await _process).kill(); |
| |
| // Swallow exceptions. The user should explicitly use [onExit] for these. |
| return onExit.catchError((dynamic _) {}); |
| } |
| } |
| |
| /// Interface for capturing screenshots from a browser. |
| abstract class ScreenshotManager { |
| /// Capture a screenshot. |
| /// |
| /// Please read the details for the implementing classes. |
| Future<Image> capture(math.Rectangle<num> region); |
| |
| /// Suffix to be added to the end of the filename. |
| /// |
| /// Example file names: |
| /// - Chrome, no-suffix: backdrop_filter_clip_moved.actual.png |
| /// - iOS Safari: backdrop_filter_clip_moved.iOS_Safari.actual.png |
| String get filenameSuffix; |
| } |