blob: 481de1524a54975da9b2031872d4155cdbd7caab [file] [log] [blame]
// 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:isolate';
import 'dart:math';
import 'package:async/async.dart';
import 'package:http_multi_server/http_multi_server.dart';
import 'package:image/image.dart';
import 'package:package_config/package_config.dart';
import 'package:path/path.dart' as p;
import 'package:pool/pool.dart';
import 'package:shelf/shelf.dart' as shelf;
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_packages_handler/shelf_packages_handler.dart';
import 'package:shelf_static/shelf_static.dart';
import 'package:shelf_web_socket/shelf_web_socket.dart';
import 'package:stream_channel/stream_channel.dart';
import 'package:test_api/src/backend/runtime.dart';
import 'package:test_api/src/backend/suite_platform.dart';
import 'package:test_core/src/runner/configuration.dart';
import 'package:test_core/src/runner/environment.dart';
import 'package:test_core/src/runner/platform.dart';
import 'package:test_core/src/runner/plugin/platform_helpers.dart';
import 'package:test_core/src/runner/runner_suite.dart';
import 'package:test_core/src/runner/suite.dart';
import 'package:test_core/src/util/io.dart';
import 'package:test_core/src/util/stack_trace_mapper.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:web_test_utils/goldens.dart';
import 'package:web_test_utils/image_compare.dart';
import 'browser.dart';
import 'common.dart';
import 'environment.dart' as env;
/// Custom test platform that serves web engine unit tests.
class BrowserPlatform extends PlatformPlugin {
/// Starts the server.
///
/// [browserEnvironment] provides the browser environment to run the test.
///
/// If [doUpdateScreenshotGoldens] is true updates screenshot golden files
/// instead of failing the test on screenshot mismatches.
static Future<BrowserPlatform> start({
required BrowserEnvironment browserEnvironment,
required bool doUpdateScreenshotGoldens,
}) async {
final shelf_io.IOServer server = shelf_io.IOServer(await HttpMultiServer.loopback(0));
return BrowserPlatform._(
browserEnvironment: browserEnvironment,
server: server,
isDebug: Configuration.current.pauseAfterLoad,
faviconPath: p.fromUri(await Isolate.resolvePackageUri(
Uri.parse('package:test/src/runner/browser/static/favicon.ico'))),
doUpdateScreenshotGoldens: doUpdateScreenshotGoldens,
packageConfig: await loadPackageConfigUri((await Isolate.packageConfig)!),
);
}
/// If true, runs the browser with a visible windows (i.e. not headless) and
/// pauses before running the tests to give the developer a chance to set
/// breakpoints in the code.
final bool isDebug;
/// The underlying server.
final shelf.Server server;
/// Provides the environment for the browser running tests.
final BrowserEnvironment browserEnvironment;
/// The URL for this server.
Uri get url => server.url.resolve('/');
/// A [OneOffHandler] for servicing WebSocket connections for
/// [BrowserManager]s.
///
/// This is one-off because each [BrowserManager] can only connect to a single
/// WebSocket,
final OneOffHandler _webSocketHandler = OneOffHandler();
/// Handles taking screenshots during tests.
///
/// Implementation will differ depending on the browser.
ScreenshotManager? _screenshotManager;
/// Whether [close] has been called.
bool get _closed => _closeMemo.hasRun;
/// Whether to update screenshot golden files.
final bool doUpdateScreenshotGoldens;
late final shelf.Handler _packageUrlHandler = packagesDirHandler();
final PackageConfig packageConfig;
BrowserPlatform._({
required this.browserEnvironment,
required this.server,
required this.isDebug,
required String faviconPath,
required this.doUpdateScreenshotGoldens,
required this.packageConfig,
}) {
// The cascade of request handlers.
shelf.Cascade cascade = shelf.Cascade()
// The web socket that carries the test channels for running tests and
// reporting restuls. See [_browserManagerFor] and [BrowserManager.start]
// for details on how the channels are established.
.add(_webSocketHandler.handler)
// Serves /favicon.ico
.add(createFileHandler(faviconPath))
// Serves /packages/* requests; fetches files and sources from
// pubspec dependencies.
//
// Includes:
// * Requests for Dart sources from source maps
// * Assets that are part of the engine sources, such as Ahem.ttf
.add(_packageUrlHandler)
// Serves files from the web_ui/build/ directory at the root (/) URL path.
//
// Includes:
// * Precompiles .js files for tests
// * Sourcemaps
.add(createStaticHandler(env.environment.webUiBuildDir.path))
// Serves the initial HTML for the test.
.add(_testBootstrapHandler)
// Serves files from the root of web_ui.
//
// This is needed because sourcemaps refer to local files, i.e. those
// that don't come from package dependencies, relative to web_ui/.
//
// Examples of URLs that this handles:
// * /test/alarm_clock_test.dart
// * /lib/src/engine/alarm_clock.dart
.add(createStaticHandler(env.environment.webUiRootDir.path))
// Serves absolute package URLs (i.e. not /packages/* but /Users/user/*/hosted/pub.dartlang.org/*).
// This handler goes last, after all more specific handlers failed to handle the request.
.add(_createAbsolutePackageUrlHandler());
_screenshotManager = browserEnvironment.getScreenshotManager();
if (_screenshotManager != null) {
cascade = cascade.add(_screeshotHandler);
}
server.mount(cascade.handler);
}
/// Handles URLs pointing to Dart sources using absolute URI paths.
///
/// Dart source paths that dart2js puts in source maps for pub packages are
/// relative to the source map file. Example:
///
/// ../../../../../../../../../Users/yegor/AppData/Local/Pub/Cache/hosted/pub.dartlang.org/stack_trace-1.10.0/lib/src/frame.dart
///
/// When the browser requests the file from the source map it sends a GET
/// request like this:
///
/// GET /Users/yegor/AppData/Local/Pub/Cache/hosted/pub.dartlang.org/stack_trace-1.10.0/lib/src/frame.dart
///
/// There's no predictable structure in this URL. It's unclear whether this
/// is a request for a source file, or someone trying to hack your
/// workstation.
///
/// This handler treats the URL as an absolute path, but instead of
/// unconditionally serving it, it first checks with `package_config.json` on
/// whether this is a request for a Dart source that's listed in pubspec
/// dependencies. For example, the `stack_trace` package would be listed in
/// `package_config.json` as:
///
/// file:///C:/Users/yegor/AppData/Local/Pub/Cache/hosted/pub.dartlang.org/stack_trace-1.10.0
///
/// If the requested URL points into one of the packages in the package config,
/// the file is served. Otherwise, HTTP 404 is returned without file contents.
///
/// To handle drive letters (C:\) and *nix file system roots, the URL and
/// package paths are initially stripped of the root and compared to each
/// other as prefixes. To actually read the file, the file system root is
/// prepended before creating the file.
shelf.Handler _createAbsolutePackageUrlHandler() {
final Map<String, Package> urlToPackage = <String, Package>{};
for (final Package package in packageConfig.packages) {
// Turns the URI as encoded in package_config.json to a file path.
final String configPath = p.fromUri(package.root);
// Strips drive letter and root prefix, if any, for example:
//
// C:\Users\user\AppData => Users\user\AppData
// /home/user/path.dart => home/user/path.dart
final String rootRelativePath = p.relative(configPath, from: p.rootPrefix(configPath));
urlToPackage[p.toUri(rootRelativePath).path] = package;
}
return (shelf.Request request) async {
final String requestedPath = request.url.path;
// The cast is needed because keys are non-null String, so there's no way
// to return null for a mismatch.
final String? packagePath = urlToPackage.keys.cast<String?>().firstWhere(
(String? packageUrl) => requestedPath.startsWith(packageUrl!),
orElse: () => null,
);
if (packagePath == null) {
return shelf.Response.notFound('Not a pub.dartlang.org request');
}
// Attach the root prefix, such as drive letter, and convert from URI to path.
// Examples:
//
// Users\user\AppData => C:\Users\user\AppData
// home/user/path.dart => /home/user/path.dart
final Package package = urlToPackage[packagePath]!;
final String filePath = p.join(
p.rootPrefix(p.fromUri(package.root.path)),
p.fromUri(requestedPath),
);
final File fileInPackage = File(filePath);
if (!fileInPackage.existsSync()) {
return shelf.Response.notFound('File not found: $requestedPath');
}
return shelf.Response.ok(fileInPackage.openRead());
};
}
Future<shelf.Response> _screeshotHandler(shelf.Request request) async {
if (!request.requestedUri.path.endsWith('/screenshot')) {
return shelf.Response.notFound(
'This request is not handled by the screenshot handler');
}
final String payload = await request.readAsString();
final Map<String, dynamic> requestData =
json.decode(payload) as Map<String, dynamic>;
final String filename = requestData['filename'] as String;
final bool write = requestData['write'] as bool;
final double maxDiffRate = requestData.containsKey('maxdiffrate')
? (requestData['maxdiffrate'] as num)
.toDouble() // can be parsed as either int or double
: kMaxDiffRateFailure;
final Map<String, dynamic> region =
requestData['region'] as Map<String, dynamic>;
final PixelComparison pixelComparison = PixelComparison.values.firstWhere(
(PixelComparison value) => value.toString() == requestData['pixelComparison']);
final String result = await _diffScreenshot(
filename, write, maxDiffRate, region, pixelComparison);
return shelf.Response.ok(json.encode(result));
}
Future<String> _diffScreenshot(
String filename,
bool write,
double maxDiffRateFailure,
Map<String, dynamic> region,
PixelComparison pixelComparison) async {
if (doUpdateScreenshotGoldens) {
write = true;
}
filename =
filename.replaceAll('.png', '${_screenshotManager!.filenameSuffix}.png');
String goldensDirectory;
if (filename.startsWith('__local__')) {
filename = filename.substring('__local__/'.length);
goldensDirectory = p.join(
env.environment.webUiRootDir.path,
'test',
'golden_files',
);
} else {
goldensDirectory = p.join(
env.environment.webUiGoldensRepositoryDirectory.path,
'engine',
'web',
);
}
final Rectangle<num> regionAsRectange = Rectangle<num>(
region['x'] as num,
region['y'] as num,
region['width'] as num,
region['height'] as num,
);
// Take screenshot.
final Image screenshot = await _screenshotManager!.capture(regionAsRectange);
return compareImage(
screenshot,
doUpdateScreenshotGoldens,
filename,
pixelComparison,
maxDiffRateFailure,
goldensDirectory: goldensDirectory,
write: write);
}
/// Serves the HTML file that bootstraps the test.
shelf.Response _testBootstrapHandler(shelf.Request request) {
final String path = p.fromUri(request.url);
if (path.endsWith('.html')) {
final String test = p.withoutExtension(path) + '.dart';
// Link to the Dart wrapper.
final String scriptBase = htmlEscape.convert(p.basename(test));
final String link = '<link rel="x-dart-test" href="$scriptBase">';
return shelf.Response.ok('''
<!DOCTYPE html>
<html>
<head>
<title>${htmlEscape.convert(test)} Test</title>
$link
<script src="packages/test/dart.js"></script>
</head>
</html>
''', headers: <String, String>{'Content-Type': 'text/html'});
}
return shelf.Response.notFound('Not found.');
}
void _checkNotClosed() {
if (_closed) {
throw StateError('Cannot load test suite. Test platform is closed.');
}
}
/// Loads the test suite at [path] on the platform [platform].
///
/// This will start a browser to load the suite if one isn't already running.
/// Throws an [ArgumentError] if `platform.platform` isn't a browser.
@override
Future<RunnerSuite> load(String path, SuitePlatform platform,
SuiteConfiguration suiteConfig, Object message) async {
_checkNotClosed();
if (suiteConfig.precompiledPath == null) {
throw Exception('This test platform only supports precompiled JS.');
}
final Runtime browser = platform.runtime;
assert(suiteConfig.runtimes.contains(browser.identifier));
if (!browser.isBrowser) {
throw ArgumentError('$browser is not a browser.');
}
_checkNotClosed();
final Uri suiteUrl = url.resolveUri(
p.toUri(p.withoutExtension(p.relative(path, from: env.environment.webUiBuildDir.path)) + '.html'));
_checkNotClosed();
final BrowserManager? browserManager = await _startBrowserManager();
if (browserManager == null) {
throw StateError('Failed to initialize browser manager for ${browser.name}');
}
_checkNotClosed();
final RunnerSuite suite = await browserManager.load(path, suiteUrl, suiteConfig, message);
_checkNotClosed();
return suite;
}
@override
StreamChannel<dynamic> loadChannel(String path, SuitePlatform platform) =>
throw UnimplementedError();
Future<BrowserManager?>? _browserManager;
/// Starts a browser manager for the browser provided by [browserEnvironment];
///
/// If no browser manager is running yet, starts one.
Future<BrowserManager?> _startBrowserManager() {
if (_browserManager != null) {
return _browserManager!;
}
final Completer<WebSocketChannel> completer = Completer<WebSocketChannel>.sync();
final String path = _webSocketHandler.create(webSocketHandler(completer.complete));
final Uri webSocketUrl = url.replace(scheme: 'ws').resolve(path);
final Uri hostUrl = url
.resolve('packages/web_engine_tester/static/index.html')
.replace(queryParameters: <String, dynamic>{
'managerUrl': webSocketUrl.toString(),
'debug': isDebug.toString()
});
final Future<BrowserManager?> future = BrowserManager.start(
browserEnvironment: browserEnvironment,
url: hostUrl,
future: completer.future,
packageConfig: packageConfig,
debug: isDebug,
);
// Store null values for browsers that error out so we know not to load them
// again.
_browserManager = future.catchError((dynamic _) => null);
return future;
}
/// Close all the browsers that the server currently has open.
///
/// Note that this doesn't close the server itself. Browser tests can still be
/// loaded, they'll just spawn new browsers.
@override
Future<void> closeEphemeral() async {
if (_browserManager != null) {
final BrowserManager? result = await _browserManager!;
await result?.close();
}
}
/// Closes the server and releases all its resources.
///
/// Returns a [Future] that completes once the server is closed and its
/// resources have been fully released.
@override
Future<void> close() {
return _closeMemo.runOnce(() async {
final List<Future<void>> futures = <Future<void>>[];
futures.add(Future<void>.microtask(() async {
if (_browserManager != null) {
final BrowserManager? result = await _browserManager!;
await result?.close();
}
}));
futures.add(server.close());
await Future.wait(futures);
});
}
final AsyncMemoizer<dynamic> _closeMemo = AsyncMemoizer<dynamic>();
}
/// A Shelf handler that provides support for one-time handlers.
///
/// This is useful for handlers that only expect to be hit once before becoming
/// invalid and don't need to have a persistent URL.
class OneOffHandler {
/// A map from URL paths to handlers.
final Map<String, shelf.Handler> _handlers = <String, shelf.Handler>{};
/// The counter of handlers that have been activated.
int _counter = 0;
/// The actual [shelf.Handler] that dispatches requests.
shelf.Handler get handler => _onRequest;
/// Creates a new one-off handler that forwards to [handler].
///
/// Returns a string that's the URL path for hitting this handler, relative to
/// the URL for the one-off handler itself.
///
/// [handler] will be unmounted as soon as it receives a request.
String create(shelf.Handler handler) {
final String path = _counter.toString();
_handlers[path] = handler;
_counter++;
return path;
}
/// Dispatches [request] to the appropriate handler.
FutureOr<shelf.Response> _onRequest(shelf.Request request) {
final List<String> components = p.url.split(request.url.path);
if (components.isEmpty) {
return shelf.Response.notFound(null);
}
final String path = components.removeAt(0);
final shelf.Handler? handler = _handlers.remove(path);
if (handler == null) {
return shelf.Response.notFound(null);
}
return handler(request.change(path: path));
}
}
/// Manages the connection to a single running browser.
///
/// This is in charge of telling the browser which test suites to load and
/// converting its responses into [Suite] objects.
class BrowserManager {
final PackageConfig packageConfig;
/// The browser instance that this is connected to via [_channel].
final Browser _browser;
/// The browser environment for this test.
final BrowserEnvironment _browserEnvironment;
/// The channel used to communicate with the browser.
///
/// This is connected to a page running `static/host.dart`.
late final MultiChannel<dynamic> _channel;
/// A pool that ensures that limits the number of initial connections the
/// manager will wait for at once.
///
/// This isn't the *total* number of connections; any number of iframes may be
/// loaded in the same browser. However, the browser can only load so many at
/// once, and we want a timeout in case they fail so we only wait for so many
/// at once.
final Pool _pool = Pool(8);
/// The ID of the next suite to be loaded.
///
/// This is used to ensure that the suites can be referred to consistently
/// across the client and server.
int _suiteID = 0;
/// Whether the channel to the browser has closed.
bool _closed = false;
/// The completer for [_BrowserEnvironment.displayPause].
///
/// This will be `null` as long as the browser isn't displaying a pause
/// screen.
CancelableCompleter<void>? _pauseCompleter;
/// The controller for [_BrowserEnvironment.onRestart].
final StreamController<dynamic> _onRestartController = StreamController<dynamic>.broadcast();
/// The environment to attach to each suite.
late final Future<_BrowserEnvironment> _environment;
/// Controllers for every suite in this browser.
///
/// These are used to mark suites as debugging or not based on the browser's
/// pings.
final Set<RunnerSuiteController> _controllers = <RunnerSuiteController>{};
// A timer that's reset whenever we receive a message from the browser.
//
// Because the browser stops running code when the user is actively debugging,
// this lets us detect whether they're debugging reasonably accurately.
late final RestartableTimer _timer;
/// Starts the browser identified by [runtime] and has it connect to [url].
///
/// [url] should serve a page that establishes a WebSocket connection with
/// this process. That connection, once established, should be emitted via
/// [future]. If [debug] is true, starts the browser in debug mode, with its
/// debugger interfaces on and detected.
///
/// The [settings] indicate how to invoke this browser's executable.
///
/// Returns the browser manager, or throws an [Exception] if a
/// connection fails to be established.
static Future<BrowserManager?> start({
required BrowserEnvironment browserEnvironment,
required Uri url,
required Future<WebSocketChannel> future,
required PackageConfig packageConfig,
bool debug = false,
}) {
final Browser browser = _newBrowser(url, browserEnvironment, debug: debug);
final Completer<BrowserManager> completer = Completer<BrowserManager>();
// For the cases where we use a delegator such as `adb` (for Android) or
// `xcrun` (for IOS), these delegator processes can shut down before the
// websocket is available. Therefore do not throw an error if process
// exits with exitCode 0. Note that `browser` will throw and error if the
// exit code was not 0, which will be processed by the next callback.
browser.onExit.catchError((Object error, StackTrace stackTrace) {
if (completer.isCompleted) {
return;
}
completer.completeError(error, stackTrace);
});
future.then((WebSocketChannel webSocket) {
if (completer.isCompleted) {
return;
}
completer.complete(BrowserManager._(packageConfig, browser, browserEnvironment, webSocket));
}).catchError((Object error, StackTrace stackTrace) {
browser.close();
if (completer.isCompleted) {
return null;
}
completer.completeError(error, stackTrace);
});
return completer.future;
}
/// Starts the browser and requests that it load the test page at [url].
///
/// If [debug] is true, starts the browser in debug mode.
static Browser _newBrowser(Uri url, BrowserEnvironment browserEnvironment, {bool debug = false}) {
return browserEnvironment.launchBrowserInstance(url, debug: debug);
}
/// Creates a new BrowserManager that communicates with the browser over
/// [webSocket].
BrowserManager._(this.packageConfig, this._browser, this._browserEnvironment, WebSocketChannel webSocket) {
// The duration should be short enough that the debugging console is open as
// soon as the user is done setting breakpoints, but long enough that a test
// doing a lot of synchronous work doesn't trigger a false positive.
//
// Start this canceled because we don't want it to start ticking until we
// get some response from the iframe.
_timer = RestartableTimer(const Duration(seconds: 3), () {
for (final RunnerSuiteController controller in _controllers) {
controller.setDebugging(true);
}
})
..cancel();
// Whenever we get a message, no matter which child channel it's for, we the
// know browser is still running code which means the user isn't debugging.
_channel = MultiChannel<dynamic>(
webSocket.cast<String>().transform(jsonDocument).changeStream((Stream<Object?> stream) {
return stream.map((Object? message) {
if (!_closed) {
_timer.reset();
}
for (final RunnerSuiteController controller in _controllers) {
controller.setDebugging(false);
}
return message;
});
}));
_environment = _loadBrowserEnvironment();
_channel.stream
.listen((dynamic message) => _onMessage(message as Map<dynamic, dynamic>), onDone: close);
}
/// Loads [_BrowserEnvironment].
Future<_BrowserEnvironment> _loadBrowserEnvironment() async {
return _BrowserEnvironment(this, await _browser.observatoryUrl,
await _browser.remoteDebuggerUrl, _onRestartController.stream);
}
/// Tells the browser the load a test suite from the URL [url].
///
/// [url] should be an HTML page with a reference to the JS-compiled test
/// suite. [path] is the path of the original test suite file, which is used
/// for reporting. [suiteConfig] is the configuration for the test suite.
Future<RunnerSuite> load(String path, Uri url, SuiteConfiguration suiteConfig,
Object message) async {
url = url.replace(
fragment: Uri.encodeFull(jsonEncode(<String, dynamic>{
'metadata': suiteConfig.metadata.serialize(),
'browser': _browserEnvironment.packageTestRuntime.identifier
})));
final int suiteID = _suiteID++;
RunnerSuiteController? controller;
void closeIframe() {
if (_closed) {
return;
}
_controllers.remove(controller);
_channel.sink.add(<String, dynamic>{'command': 'closeSuite', 'id': suiteID});
}
// The virtual channel will be closed when the suite is closed, in which
// case we should unload the iframe.
final VirtualChannel<dynamic> virtualChannel = _channel.virtualChannel();
final int suiteChannelID = virtualChannel.id;
final StreamChannel<dynamic> suiteChannel = virtualChannel.transformStream(
StreamTransformer<dynamic, dynamic>.fromHandlers(handleDone: (EventSink<dynamic> sink) {
closeIframe();
sink.close();
}));
return _pool.withResource<RunnerSuite>(() async {
_channel.sink.add(<String, dynamic>{
'command': 'loadSuite',
'url': url.toString(),
'id': suiteID,
'channel': suiteChannelID
});
try {
controller = deserializeSuite(path, currentPlatform(_browserEnvironment.packageTestRuntime),
suiteConfig, await _environment, suiteChannel, message);
final String sourceMapFileName =
'${p.basename(path)}.browser_test.dart.js.map';
final String pathToTest = p.dirname(path);
final String mapPath = p.join(env.environment.webUiRootDir.path,
'build', pathToTest, sourceMapFileName);
final Map<String, Uri> packageMap = <String, Uri>{
for (Package p in packageConfig.packages) p.name: p.packageUriRoot
};
final JSStackTraceMapper mapper = JSStackTraceMapper(
await File(mapPath).readAsString(),
mapUrl: p.toUri(mapPath),
packageMap: packageMap,
sdkRoot: p.toUri(sdkDir),
);
controller!.channel('test.browser.mapper').sink.add(mapper.serialize());
_controllers.add(controller!);
return await controller!.suite;
} catch (_) {
closeIframe();
rethrow;
}
});
}
/// An implementation of [Environment.displayPause].
CancelableOperation<void> _displayPause() {
CancelableCompleter<void>? pauseCompleter = _pauseCompleter;
if (pauseCompleter != null) {
return pauseCompleter.operation;
}
pauseCompleter = CancelableCompleter<void>(onCancel: () {
_channel.sink.add(<String, String>{'command': 'resume'});
_pauseCompleter = null;
});
_pauseCompleter = pauseCompleter;
pauseCompleter.operation.value.whenComplete(() {
_pauseCompleter = null;
});
_channel.sink.add(<String, String>{'command': 'displayPause'});
return pauseCompleter.operation;
}
/// The callback for handling messages received from the host page.
void _onMessage(Map<dynamic, dynamic> message) {
switch (message['command'] as String) {
case 'ping':
break;
case 'restart':
_onRestartController.add(null);
break;
case 'resume':
_pauseCompleter?.complete();
break;
default:
// Unreachable.
assert(false);
break;
}
}
/// Closes the manager and releases any resources it owns, including closing
/// the browser.
Future<void> close() => _closeMemoizer.runOnce(() {
_closed = true;
_timer.cancel();
_pauseCompleter?.complete();
_pauseCompleter = null;
_controllers.clear();
return _browser.close();
});
final AsyncMemoizer<dynamic> _closeMemoizer = AsyncMemoizer<dynamic>();
}
/// An implementation of [Environment] for the browser.
///
/// All methods forward directly to [BrowserManager].
class _BrowserEnvironment implements Environment {
final BrowserManager _manager;
@override
final bool supportsDebugging = true;
@override
final Uri? observatoryUrl;
@override
final Uri? remoteDebuggerUrl;
@override
final Stream<dynamic> onRestart;
_BrowserEnvironment(this._manager, this.observatoryUrl,
this.remoteDebuggerUrl, this.onRestart);
@override
CancelableOperation<void> displayPause() => _manager._displayPause();
}
bool get isCirrus => Platform.environment['CIRRUS_CI'] == 'true';