blob: 437c9bb4cfc3ca80efbb541e45664f83da23b0e4 [file] [log] [blame]
// Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file
// for details. 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:async/async.dart';
import 'package:http_multi_server/http_multi_server.dart';
import 'package:path/path.dart' as p;
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';
// ignore: deprecated_member_use
import 'package:test_api/backend.dart' show Runtime, SuitePlatform;
import 'package:test_core/src/runner/configuration.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/package_version.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/platform.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/plugin/customizable_platform.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/runner_suite.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/suite.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/wasm_compiler_pool.dart'; // ignore: implementation_imports
import 'package:test_core/src/util/io.dart'; // ignore: implementation_imports
import 'package:test_core/src/util/package_config.dart'; // ignore: implementation_imports
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:yaml/yaml.dart';
import '../../util/math.dart';
import '../../util/one_off_handler.dart';
import '../../util/path_handler.dart';
import '../browser/browser_manager.dart';
import '../executable_settings.dart';
import 'default_settings.dart';
class BrowserWasmPlatform extends PlatformPlugin
implements CustomizablePlatform<ExecutableSettings> {
/// Starts the server.
///
/// [root] is the root directory that the server should serve. It defaults to
/// the working directory.
static Future<BrowserWasmPlatform> start({String? root}) async {
print('WARNING: Running with the experimental wasm platform. This platform '
'is temporary and will be removed in the future, without a breaking '
'change release, in favor of a different method of running wasm tests.'
'This platform should only be used for experimentation at this time.'
'\n');
var server = shelf_io.IOServer(await HttpMultiServer.loopback(0));
var packageConfig = await currentPackageConfig;
return BrowserWasmPlatform._(
server,
Configuration.current,
p.fromUri(packageConfig.resolve(
Uri.parse('package:test/src/runner/browser/static/favicon.ico'))),
p.fromUri(packageConfig.resolve(
Uri.parse('package:test/src/runner/wasm/static/default.html.tpl'))),
p.fromUri(packageConfig.resolve(Uri.parse(
'package:test/src/runner/wasm/static/run_wasm_chrome.js'))),
root: root);
}
/// The test runner configuration.
final Configuration _config;
/// The underlying server.
final shelf.Server _server;
/// A randomly-generated secret.
///
/// This is used to ensure that other users on the same system can't snoop
/// on data being served through this server.
final _secret = randomUrlSecret();
/// The URL for this server.
Uri get url => _server.url.resolve('$_secret/');
/// A [OneOffHandler] for servicing WebSocket connections for
/// [BrowserManager]s.
///
/// This is one-off because each [BrowserManager] can only connect to a single
/// WebSocket,
final _webSocketHandler = OneOffHandler();
/// A [PathHandler] used to serve compiled WASM tests.
final _wasmHandler = PathHandler();
/// The [WasmCompilerPool] managing active instances of `dart2wasm`.
final _compilers = WasmCompilerPool();
/// The temporary directory in which compiled WASM is emitted.
final _compiledDir = createTempDir();
/// The root directory served statically by this server.
final String _root;
/// Whether [close] has been called.
bool get _closed => _closeMemo.hasRun;
/// A map from browser identifiers to futures that will complete to the
/// [BrowserManager]s for those browsers, or `null` if they failed to load.
///
/// This should only be accessed through [_browserManagerFor].
final _browserManagers = <Runtime, Future<BrowserManager?>>{};
/// Settings for invoking each browser.
///
/// This starts out with the default settings, which may be overridden by user settings.
final _browserSettings =
Map<Runtime, ExecutableSettings>.from(defaultSettings);
/// A map from test suite paths to Futures that will complete once those
/// suites are finished compiling.
///
/// This is used to make sure that a given test suite is only compiled once
/// per run, rather than once per browser per run.
final _compileFutures = <String, Future<void>>{};
/// The default template for html tests.
final String _defaultTemplatePath;
/// The `package:test` side wrapper for the Dart2Wasm runtime.
final String _jsRuntimeWrapper;
BrowserWasmPlatform._(this._server, Configuration config, String faviconPath,
this._defaultTemplatePath, this._jsRuntimeWrapper,
{String? root})
: _config = config,
_root = root ?? p.current {
var cascade = shelf.Cascade().add(_webSocketHandler.handler);
if (_config.pubServeUrl != null) {
throw UnsupportedError(
'WASM browser tests don\'t support the `--pub-serve` argument');
}
cascade = cascade
.add(packagesDirHandler())
.add(_wasmHandler.handler)
.add(createStaticHandler(_root))
.add(_wrapperHandler);
var pipeline = shelf.Pipeline()
.addMiddleware(PathHandler.nestedIn(_secret))
.addHandler(cascade.handler);
_server.mount(shelf.Cascade()
.add(createFileHandler(faviconPath))
.add(pipeline)
.handler);
}
/// A handler that serves wrapper files used to bootstrap tests.
shelf.Response _wrapperHandler(shelf.Request request) {
var path = p.fromUri(request.url);
if (path.endsWith('.html')) {
var test = '${p.withoutExtension(path)}.dart';
var scriptBase = htmlEscape.convert(p.basename(test));
var link = '<link rel="x-dart-test" href="$scriptBase">';
var testName = htmlEscape.convert(test);
var template = _config.customHtmlTemplatePath ?? _defaultTemplatePath;
var contents = File(template).readAsStringSync();
var jsRuntime = p.basename('$test.browser_test.dart.mjs');
var processedContents = contents
// Checked during loading phase that there is only one {{testScript}} placeholder.
.replaceFirst('{{testScript}}', link)
.replaceFirst('{{jsRuntimeUrl}}', jsRuntime)
.replaceFirst(
'{{wasmUrl}}', p.basename('$test.browser_test.dart.wasm'))
.replaceAll('{{testName}}', testName);
return shelf.Response.ok(processedContents,
headers: {'Content-Type': 'text/html'});
}
return shelf.Response.notFound('Not found.');
}
@override
ExecutableSettings parsePlatformSettings(YamlMap settings) =>
ExecutableSettings.parse(settings);
@override
ExecutableSettings mergePlatformSettings(
ExecutableSettings settings1, ExecutableSettings settings2) =>
settings1.merge(settings2);
@override
void customizePlatform(Runtime runtime, ExecutableSettings settings) {
var oldSettings =
_browserSettings[runtime] ?? _browserSettings[runtime.root];
if (oldSettings != null) settings = oldSettings.merge(settings);
_browserSettings[runtime] = settings;
}
/// 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, Map<String, Object?> message) async {
if (suiteConfig.precompiledPath != null) {
throw UnsupportedError(
'The wasm platform doesn\'t support precompiled suites');
}
var browser = platform.runtime;
assert(suiteConfig.runtimes.contains(browser.identifier));
if (!browser.isBrowser) {
throw ArgumentError('$browser is not a browser.');
}
// TODO: Support custom html?
Uri suiteUrl;
await _compileSuite(path, suiteConfig);
if (_closed) return null;
suiteUrl = url.resolveUri(
p.toUri('${p.withoutExtension(p.relative(path, from: _root))}.html'));
if (_closed) return null;
var browserManager = await _browserManagerFor(browser);
if (_closed || browserManager == null) return null;
var suite = await browserManager.load(path, suiteUrl, suiteConfig, message);
if (_closed) return null;
return suite;
}
/// Compile the test suite at [dartPath] to WASM.
///
/// Once the suite has been compiled, it's added to [_wasmHandler] so it can be
/// served.
Future<void> _compileSuite(String dartPath, SuiteConfiguration suiteConfig) {
return _compileFutures.putIfAbsent(dartPath, () async {
var dir = Directory(_compiledDir).createTempSync('test_').path;
var baseCompiledPath =
p.join(dir, '${p.basename(dartPath)}.browser_test.dart');
var baseUrl =
'${p.toUri(p.relative(dartPath, from: _root)).path}.browser_test.dart';
var wasmUrl = '$baseUrl.wasm';
var jsRuntimeWrapperUrl = '$baseUrl.js';
var jsRuntimeUrl = '$baseUrl.mjs';
var htmlUrl = '$baseUrl.html';
var bootstrapContent = '''
${suiteConfig.metadata.languageVersionComment ?? await rootPackageLanguageVersionComment}
import "package:test/src/bootstrap/browser.dart";
import "${p.toUri(p.absolute(dartPath))}" as test;
void main() {
internalBootstrapBrowserTest(() => test.main);
}
''';
await _compilers.compile(bootstrapContent, baseCompiledPath, suiteConfig);
if (_closed) return;
var wasmPath = '$baseCompiledPath.wasm';
_wasmHandler.add(wasmUrl, (request) {
return shelf.Response.ok(File(wasmPath).readAsBytesSync(),
headers: {'Content-Type': 'application/wasm'});
});
_wasmHandler.add(jsRuntimeWrapperUrl, (request) {
return shelf.Response.ok(File(_jsRuntimeWrapper).readAsBytesSync(),
headers: {'Content-Type': 'application/javascript'});
});
var jsRuntimePath = '$baseCompiledPath.mjs';
_wasmHandler.add(jsRuntimeUrl, (request) {
return shelf.Response.ok(File(jsRuntimePath).readAsBytesSync(),
headers: {'Content-Type': 'application/javascript'});
});
var htmlPath = '$baseCompiledPath.html';
_wasmHandler.add(htmlUrl, (request) {
return shelf.Response.ok(File(htmlPath).readAsBytesSync(),
headers: {'Content-Type': 'text/html'});
});
});
}
/// Returns the [BrowserManager] for [browser].
///
/// If no browser manager is running yet, starts one.
///
/// TODO: Share a browser manager with the regular browser platform.
Future<BrowserManager?> _browserManagerFor(Runtime browser) {
var managerFuture = _browserManagers[browser];
if (managerFuture != null) return managerFuture;
var completer = Completer<WebSocketChannel>.sync();
var path = _webSocketHandler.create(webSocketHandler(completer.complete));
var webSocketUrl = url.replace(scheme: 'ws').resolve(path);
var hostUrl = url
.resolve('packages/test/src/runner/browser/static/index.html')
.replace(queryParameters: {
'managerUrl': webSocketUrl.toString(),
'debug': _config.debug.toString()
});
var future = BrowserManager.start(browser, hostUrl, completer.future,
_browserSettings[browser]!, _config)
.onError((error, _) {
throw StateError('Unable to spawn Chrome Beta, which is required by the '
'experimental-chrome-wasm platform. You may also need to set the '
'executable path in your dart_test.yaml file, documented here: '
'https://github.com/dart-lang/test/blob/master/pkgs/test/doc/configuration.md#override_platforms'
'\n\n$error\n');
});
// Store null values for browsers that error out so we know not to load them
// again.
_browserManagers[browser] =
future.then<BrowserManager?>((value) => value).onError((_, __) => 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<List<void>> closeEphemeral() {
var managers = _browserManagers.values.toList();
_browserManagers.clear();
return Future.wait(managers.map((manager) async {
var result = await manager;
if (result == null) return;
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() async => _closeMemo.runOnce(() async {
await Future.wait([
for (var browser in _browserManagers.values)
browser.then((b) => b?.close()),
_server.close(),
_compilers.close(),
]);
Directory(_compiledDir).deleteSync(recursive: true);
});
final _closeMemo = AsyncMemoizer<void>();
}