// 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 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? 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? get remoteDebuggerUrl => null; /// The underlying process. /// /// This will fire once the process has started successfully. Future get _process => _processCompleter.future; final Completer _processCompleter = Completer(); /// 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 get onExit => _onExitCompleter.future; final Completer _onExitCompleter = Completer(); /// Standard IO streams for the underlying browser process. final List> _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. runZonedGuarded(() async { final Process process = await startBrowser(); _processCompleter.complete(process); final Uint8Buffer output = Uint8Buffer(); void 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); 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.delayed(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()); if (stackTrace == null) { 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 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 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 capture(math.Rectangle 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; }