blob: 9bd7cbb526c77c64b4986547dce94390639e1cb1 [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:skia_gold_client/skia_gold_client.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/image_compare.dart';
import 'browser.dart';
import 'environment.dart' as env;
import 'felt_config.dart';
import 'utils.dart';
const Map<String, String> coopCoepHeaders = <String, String>{
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
};
/// Custom test platform that serves web engine unit tests.
class BrowserPlatform extends PlatformPlugin {
BrowserPlatform._(this.suite, {
required this.browserEnvironment,
required this.server,
required this.isDebug,
required this.isVerbose,
required this.doUpdateScreenshotGoldens,
required this.packageConfig,
required this.skiaClient,
required this.overridePathToCanvasKit,
}) {
// The cascade of request handlers.
final 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 /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)
.add(_canvasKitOverrideHandler)
// Serves files from the bundle's output build directory
.add(createSimpleDirectoryHandler(getBundleBuildDirectory(suite.testBundle)))
// Serves files from the out/web_tests/artifacts directory at the root (/) URL path.
.add(createSimpleDirectoryHandler(env.environment.webTestsArtifactsDir))
// Serves files from the test set directory
.add(createSimpleDirectoryHandler(getTestSetDirectory(suite.testBundle.testSet)))
.add(_testImageListingHandler)
// Serves the initial HTML for the test.
.add(_testBootstrapHandler)
// Serves source files from the engine src root for devtools debugging.
.add(_createSourceHandler())
// Serves files from the root of web_ui. Some tests download assets that are embedded
// directly in the test folder, such as test/engine/image/sample_image1.png etc
.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())
.add(_screenshotHandler)
// Generates and serves a test payload of given length, split into chunks
// of given size. Reponds to requests to /long_test_payload.
.add(_testPayloadGenerator)
// If none of the handlers above handled the request, return 404.
.add(_fileNotFoundCatcher);
server.mount(cascade.handler);
}
/// 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(TestSuite suite, {
required BrowserEnvironment browserEnvironment,
required bool doUpdateScreenshotGoldens,
required SkiaGoldClient? skiaClient,
required String? overridePathToCanvasKit,
required bool isVerbose,
}) async {
final shelf_io.IOServer server =
shelf_io.IOServer(await HttpMultiServer.loopback(0));
return BrowserPlatform._(
suite,
browserEnvironment: browserEnvironment,
server: server,
isDebug: Configuration.current.pauseAfterLoad,
isVerbose: isVerbose,
doUpdateScreenshotGoldens: doUpdateScreenshotGoldens,
packageConfig: await loadPackageConfigUri((await Isolate.packageConfig)!),
skiaClient: skiaClient,
overridePathToCanvasKit: overridePathToCanvasKit,
);
}
final TestSuite suite;
/// 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;
final bool isVerbose;
/// 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('/');
bool get isWasm => suite.testBundle.compileConfig.compiler == Compiler.dart2wasm;
bool get needsCrossOriginIsolated => isWasm && suite.testBundle.compileConfig.renderer == Renderer.skwasm;
/// 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();
/// 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;
/// A client for communicating with the Skia Gold backend to fetch, compare
/// and update images.
final SkiaGoldClient? skiaClient;
final String? overridePathToCanvasKit;
/// If a path to a custom local build of CanvasKit was specified, serve from
/// there instead of serving the default CanvasKit in the build/ directory.
Future<shelf.Response> _canvasKitOverrideHandler(
shelf.Request request) async {
final String? pathOverride = overridePathToCanvasKit;
if (pathOverride == null || !request.url.path.startsWith('canvaskit/')) {
return shelf.Response.notFound('Not a request for CanvasKit.');
}
final File file = File(p.joinAll(<String>[
pathOverride,
...p.split(request.url.path).skip(1),
]));
if (!file.existsSync()) {
return shelf.Response.notFound('File not found: ${request.url.path}');
}
final String extension = p.extension(file.path);
final String? contentType = contentTypes[extension];
if (contentType == null) {
final String error =
'Failed to determine Content-Type for "${request.url.path}".';
stderr.writeln(error);
return shelf.Response.internalServerError(body: error);
}
return shelf.Response.ok(
file.readAsBytesSync(),
headers: <String, Object>{
HttpHeaders.contentTypeHeader: contentType,
},
);
}
/// Lists available test images under `out/web_tests/test_images`.
Future<shelf.Response> _testImageListingHandler(shelf.Request request) async {
const Map<String, String> supportedImageTypes = <String, String>{
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.bmp': 'image/bmp',
};
if (request.url.path != 'test_images/') {
return shelf.Response.notFound('Not found.');
}
final Directory testImageDirectory = Directory(p.join(
env.environment.webTestsArtifactsDir.path,
'test_images',
));
final List<String> testImageFiles = testImageDirectory
.listSync(recursive: true)
.whereType<File>()
.map<String>(
(File file) => p.relative(file.path, from: testImageDirectory.path))
.where(
(String path) => supportedImageTypes.containsKey(p.extension(path)))
.toList();
return shelf.Response.ok(
json.encode(testImageFiles),
headers: <String, Object>{
HttpHeaders.contentTypeHeader: 'application/json',
},
);
}
Future<shelf.Response> _fileNotFoundCatcher(shelf.Request request) async {
if (isVerbose) {
print('HTTP 404: ${request.url}');
}
return shelf.Response.notFound('File not found');
}
shelf.Handler _createSourceHandler() => (shelf.Request request) async {
final String path = p.fromUri(request.url);
final String extension = p.extension(path);
final bool isSource =
extension == '.dart' ||
extension == '.c' ||
extension == '.cc' ||
extension == '.cpp' ||
extension == '.h';
if (isSource && p.isRelative(path)) {
final String fullPath = p.join(env.environment.engineSrcDir.path, path);
final File file = File(fullPath);
if (file.existsSync()) {
return shelf.Response.ok(file.openRead());
}
}
return shelf.Response.notFound('Not found.');
};
/// 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> _testPayloadGenerator(shelf.Request request) async {
if (!request.requestedUri.path.endsWith('/long_test_payload')) {
return shelf.Response.notFound(
'This request is not handled by the test payload generator');
}
final int payloadLength = int.parse(request.requestedUri.queryParameters['length']!);
final int chunkLength = int.parse(request.requestedUri.queryParameters['chunk']!);
final StreamController<List<int>> controller = StreamController<List<int>>();
Future<void> fillPayload() async {
int remainingByteCount = payloadLength;
int byteCounter = 0;
while (remainingByteCount > 0) {
final int currentChunkLength = min(chunkLength, remainingByteCount);
final List<int> chunk = List<int>.generate(
currentChunkLength,
(int i) => (byteCounter + i) & 0xFF,
);
byteCounter = (byteCounter + currentChunkLength) & 0xFF;
remainingByteCount -= currentChunkLength;
controller.add(chunk);
await Future<void>.delayed(const Duration(milliseconds: 100));
}
await controller.close();
}
// Kick off payload filling function but don't block on it. The stream should
// be returned immediately, and the client should receive data in chunks.
unawaited(fillPayload());
return shelf.Response.ok(
controller.stream,
headers: <String, String>{
'Content-Type': 'application/octet-stream',
'Content-Length': '$payloadLength',
},
);
}
Future<shelf.Response> _screenshotHandler(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;
if (!(await browserManager).supportsScreenshots) {
if (isVerbose) {
print(
'Skipping screenshot check for $filename. Current browser/OS '
'combination does not support screenshots.',
);
}
return shelf.Response.ok(json.encode('OK'));
}
final Map<String, dynamic> region =
requestData['region'] as Map<String, dynamic>;
final bool isCanvaskitTest = requestData['isCanvaskitTest'] as bool;
final String result = await _diffScreenshot(filename, region, isCanvaskitTest);
return shelf.Response.ok(json.encode(result));
}
Future<String> _diffScreenshot(
String filename,
Map<String, dynamic> region,
bool isCanvaskitTest,
) async {
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 (await browserManager).captureScreenshot(regionAsRectange);
return compareImage(
screenshot,
doUpdateScreenshotGoldens,
filename,
getSkiaGoldDirectoryForSuite(suite),
skiaClient,
isCanvaskitTest: isCanvaskitTest,
verbose: isVerbose,
);
}
static const Map<String, String> contentTypes = <String, String>{
'.js': 'text/javascript',
'.mjs': 'text/javascript',
'.wasm': 'application/wasm',
'.html': 'text/html',
'.htm': 'text/html',
'.css': 'text/css',
'.ico': 'image/icon-x',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.bmp': 'image/bmp',
'.svg': 'image/svg+xml',
'.json': 'application/json',
'.map': 'application/json',
'.ttf': 'font/ttf',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
};
/// Creates a simple file handler that serves files whose URLs and paths are
/// statically known.
///
/// This is used for trivial use-cases, such as `favicon.ico`, host pages, etc.
shelf.Handler createSimpleDirectoryHandler(Directory directory) {
return (shelf.Request request) {
final File fileInDirectory = File(p.join(
directory.path,
request.url.path,
));
if (!fileInDirectory.existsSync()) {
return shelf.Response.notFound('File not found: ${request.url.path}');
}
final String extension = p.extension(fileInDirectory.path);
final String? contentType = contentTypes[extension];
if (contentType == null) {
final String error =
'Failed to determine Content-Type for "${request.url.path}".';
stderr.writeln(error);
return shelf.Response.internalServerError(body: error);
}
final bool isScript =
extension == '.js' ||
extension == '.mjs' ||
extension == '.html';
return shelf.Response.ok(
fileInDirectory.readAsBytesSync(),
headers: <String, Object>{
HttpHeaders.contentTypeHeader: contentType,
if (isScript && needsCrossOriginIsolated)
...coopCoepHeaders,
},
);
};
}
String getCanvasKitVariant() {
switch (suite.runConfig.variant) {
case CanvasKitVariant.full:
return 'full';
case CanvasKitVariant.chromium:
return 'chromium';
case null:
return 'auto';
}
}
/// 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';
final bool linkSkwasm = suite.testBundle.compileConfig.renderer == Renderer.skwasm;
// Link to the Dart wrapper.
final String scriptBase = htmlEscape.convert(p.basename(test));
final String link = '<link rel="x-dart-test" href="$scriptBase"${linkSkwasm ? " skwasm" : ""}>';
final String testRunner = isWasm ? '/test_dart2wasm.js' : 'packages/test/dart.js';
return shelf.Response.ok('''
<!DOCTYPE html>
<html>
<head>
<title>${htmlEscape.convert(test)} Test</title>
<meta name="assetBase" content="/">
<script>
window.flutterConfiguration = {
canvasKitBaseUrl: "/canvaskit/",
canvasKitVariant: "${getCanvasKitVariant()}",
};
</script>
$link
<script src="$testRunner"></script>
</head>
</html>
''', headers: <String, String>{
'Content-Type': 'text/html',
if (needsCrossOriginIsolated)
...coopCoepHeaders
});
}
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(path)}.html'));
_checkNotClosed();
final BrowserManager? browserManager = await _startBrowserManager();
if (browserManager == null) {
throw StateError(
'Failed to initialize browser manager for ${browserEnvironment.name}');
}
_checkNotClosed();
final RunnerSuite runnerSuite =
await browserManager.load(path, suiteUrl, suiteConfig, message);
_checkNotClosed();
return runnerSuite;
}
Future<BrowserManager?>? _browserManager;
Future<BrowserManager> get browserManager async => (await _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('host/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,
sourceMapDirectory: isWasm ? null : getBundleBuildDirectory(suite.testBundle),
);
// 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 {
/// Creates a new BrowserManager that communicates with the browser over
/// [webSocket].
BrowserManager._(
this.packageConfig,
this._browser,
this._browserEnvironment,
this._sourceMapDirectory,
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);
}
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 directory containing sourcemaps for test files
final Directory? _sourceMapDirectory;
/// 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,
Directory? sourceMapDirectory,
bool debug = false,
}) async {
final Browser browser = await _newBrowser(
url,
browserEnvironment,
debug: debug,
);
return _startBrowserManager(
browserEnvironment: browserEnvironment,
url: url,
future: future,
packageConfig: packageConfig,
browser: browser,
sourceMapDirectory: sourceMapDirectory,
debug: debug,
);
}
static Future<BrowserManager?> _startBrowserManager({
required BrowserEnvironment browserEnvironment,
required Uri url,
required Future<WebSocketChannel> future,
required PackageConfig packageConfig,
required Browser browser,
Directory? sourceMapDirectory,
bool debug = false,
}) {
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,
sourceMapDirectory,
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 Future<Browser> _newBrowser(
Uri url,
BrowserEnvironment browserEnvironment, {
bool debug = false,
}) {
return browserEnvironment.launchBrowserInstance(
url,
debug: debug,
);
}
/// Loads [_BrowserEnvironment].
Future<_BrowserEnvironment> _loadBrowserEnvironment() async {
return _BrowserEnvironment(
this,
await _browser.vmServiceUrl,
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();
}));
if (Configuration.current.pauseAfterLoad) {
print('Browser loaded. Press enter to start tests...');
stdin.readLineSync();
}
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);
if (_sourceMapDirectory == null) {
// We don't have mapping for wasm yet. But we should send a message
// to let the host page move forward.
controller!.channel('test.browser.mapper').sink.add(null);
} else {
final String sourceMapFileName =
'${p.basename(path)}.browser_test.dart.js.map';
final String pathToTest = p.dirname(path);
final String mapPath = p.join(
_sourceMapDirectory!.path,
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);
case 'resume':
_pauseCompleter?.complete();
default:
// Unreachable.
assert(false);
break;
}
}
bool get supportsScreenshots => _browser.supportsScreenshots;
Future<Image> captureScreenshot(Rectangle<num> region) =>
_browser.captureScreenshot(region);
/// Closes the manager and releases any resources it owns, including closing
/// the browser.
Future<void> close() => _closeMemoizer.runOnce(() {
if (Configuration.current.pauseAfterLoad) {
print('Test run finished. Press enter to close browser...');
stdin.readLineSync();
}
_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 {
_BrowserEnvironment(this._manager, this.observatoryUrl,
this.remoteDebuggerUrl, this.onRestart);
final BrowserManager _manager;
@override
final bool supportsDebugging = true;
@override
final Uri? observatoryUrl;
@override
final Uri? remoteDebuggerUrl;
@override
final Stream<dynamic> onRestart;
@override
CancelableOperation<void> displayPause() => _manager._displayPause();
}
bool get isCirrus => Platform.environment['CIRRUS_CI'] == 'true';