| // 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>(); |
| } |