blob: c7b69716a2cbe231636bffa6416712a15d23cef8 [file] [log] [blame]
// Copyright (c) 2016, 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 'dart:isolate';
import 'package:async/async.dart';
import 'package:http_multi_server/http_multi_server.dart';
import 'package:package_resolver/package_resolver.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_static/shelf_static.dart';
import 'package:shelf_web_socket/shelf_web_socket.dart';
import 'package:shelf_packages_handler/shelf_packages_handler.dart';
import 'package:stream_channel/stream_channel.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:yaml/yaml.dart';
import '../../backend/compiler.dart';
import '../../backend/test_platform.dart';
import '../../util/io.dart';
import '../../util/one_off_handler.dart';
import '../../util/path_handler.dart';
import '../../util/stack_trace_mapper.dart';
import '../../utils.dart';
import '../build.dart' as build;
import '../compiler_pool.dart';
import '../configuration.dart';
import '../configuration/suite.dart';
import '../executable_settings.dart';
import '../load_exception.dart';
import '../plugin/customizable_platform.dart';
import '../plugin/platform.dart';
import '../runner_suite.dart';
import 'browser_manager.dart';
import 'default_settings.dart';
import 'polymer.dart';
class BrowserPlatform 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<BrowserPlatform> start({String root}) async {
var server = new shelf_io.IOServer(await HttpMultiServer.loopback(0));
return new BrowserPlatform._(
server,
Configuration.current,
p.fromUri(await Isolate.resolvePackageUri(
Uri.parse('package:test/src/runner/browser/static/favicon.ico'))),
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 = Uri.encodeComponent(randomBase64(24));
/// 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 = new OneOffHandler();
/// A [PathHandler] used to serve compiled JS.
final _jsHandler = new PathHandler();
/// The [CompilerPool] managing active instances of `dart2js`.
final _compilers = new CompilerPool();
/// The temporary directory in which compiled JS is emitted.
final String _compiledDir;
/// The root directory served statically by this server.
final String _root;
/// The pool of active `pub serve` compilations.
///
/// Pub itself ensures that only one compilation runs at a time; we just use
/// this pool to make sure that the output is nice and linear.
final _pubServePool = new Pool(1);
/// The HTTP client to use when caching JS files in `pub serve`.
final HttpClient _http;
/// 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 = <TestPlatform, Future<BrowserManager>>{};
/// Settings for invoking each browser.
///
/// This starts out with the default settings, which may be overridden by user settings.
final _browserSettings =
new Map<TestPlatform, ExecutableSettings>.from(defaultSettings);
/// A cascade of handlers for suites' precompiled paths.
///
/// This is `null` if there are no precompiled suites yet.
shelf.Cascade _precompiledCascade;
/// The precompiled paths that have handlers in [_precompiledHandler].
final _precompiledPaths = new Set<String>();
/// 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 = new Map<String, Future>();
/// Mappers for Dartifying stack traces, indexed by test path.
final _mappers = new Map<String, StackTraceMapper>();
BrowserPlatform._(this._server, Configuration config, String faviconPath,
{String root})
: _config = config,
_root = root == null ? p.current : root,
_compiledDir = config.pubServeUrl == null ? createTempDir() : null,
_http = config.pubServeUrl == null ? null : new HttpClient() {
var cascade = new shelf.Cascade().add(_webSocketHandler.handler);
if (_config.pubServeUrl == null) {
cascade = cascade
.add(packagesDirHandler())
.add(_jsHandler.handler)
.add(createStaticHandler(_root))
// Add this before the wrapper handler so that its HTML takes
// precedence over the test runner's.
.add((request) =>
_precompiledCascade?.handler(request) ??
new shelf.Response.notFound(null))
.add(_wrapperHandler);
if (build.isInUse) {
cascade = cascade.add(_createBuildHandler()).add(PathHandler.nestedIn(
"packages/\$sdk")(createStaticHandler(p.join(sdkDir, 'lib'))));
}
}
var pipeline = new shelf.Pipeline()
.addMiddleware(PathHandler.nestedIn(_secret))
.addHandler(cascade.handler);
_server.mount(new shelf.Cascade()
.add(createFileHandler(faviconPath))
.add(pipeline)
.handler);
}
/// Returns a handler that serves files from .dart_tool/build/generated.
///
/// This rewrites URLs like so:
///
/// * `packages/foo/...` -> `foo/lib/...`
/// * `foo/...` -> `${rootPackageName}/foo/...`
shelf.Handler _createBuildHandler() {
var inner = createStaticHandler(build.generatedDir);
return (request) {
if (request.method != 'GET' || !request.url.path.contains(".ddc.js")) {
return new shelf.Response.notFound('Not found.');
}
String newPath;
if (request.url.path.startsWith("packages/")) {
var components = p.url.split(request.url.path);
var package = components[1];
newPath = p.url.joinAll([package, 'lib']..addAll(components.skip(2)));
} else {
newPath = p.url.join(rootPackageName, request.url.path);
}
return inner(new shelf.Request(
request.method,
request.requestedUri
.replace(path: p.url.join(request.handlerPath, newPath)),
headers: request.headers,
handlerPath: request.handlerPath,
context: request.context));
};
}
/// 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(".browser_test.dart")) {
var testPath = p.basename(trimSuffix(path, ".browser_test.dart"));
return new shelf.Response.ok('''
import "package:stream_channel/stream_channel.dart";
import "package:test/src/runner/plugin/remote_platform_helpers.dart";
import "package:test/src/runner/browser/post_message_channel.dart";
import "$testPath" as test;
void main() {
var channel = serializeSuite(() => test.main, hidePrints: false);
postMessageChannel().pipe(channel);
}
''', headers: {'Content-Type': 'application/dart'});
}
if (path.endsWith(".ddc.dart.browser_test.dart.js")) {
var modulePaths = {
"dart_sdk": "packages/\$sdk/dev_compiler/amd/dart_sdk"
};
for (var path in build.ddcModules) {
var components = p.split(p.relative(path, from: build.generatedDir));
Uri url;
if (components[1] == 'lib') {
var package = components.first;
var pathInLib = p.joinAll(components.skip(2));
url = p.toUri(p.join('packages', package, pathInLib));
} else {
assert(components.first == rootPackageName);
url = p.toUri(p.joinAll(components.skip(1)));
}
modulePaths[url.toString()] = "$url.ddc";
}
var moduleName =
trimSuffix(request.url.path, ".ddc.dart.browser_test.dart.js");
var requires = [
moduleName,
"packages/test/src/bootstrap/browser",
"dart_sdk"
];
var moduleIdentifier =
p.url.split(moduleName).skip(1).join('__').replaceAll('.', '\$46');
return new shelf.Response.ok('''
(function() {
let oldOnError = requirejs.onError;
requirejs.onError = function(e) {
if (e.originalError && e.originalError.srcElement) {
let xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200) {
console.error(this.responseText);
window.parent.postMessage({
"href": window.location.href,
"data": [
0,
{"type": "loadException", "message": this.responseText}
]
}, window.location.origin);
}
};
xhr.open("GET", e.originalError.srcElement.src + ".errors", true);
xhr.send();
}
// Also handle errors the normal way.
if (oldOnError) oldOnError(e);
};
require.config({
baseUrl: ${JSON.encode(url.toString())},
waitSeconds: 0,
paths: ${JSON.encode(modulePaths)}
});
require(${JSON.encode(requires)}, function(app, bootstrap, dart_sdk) {
dart_sdk._isolate_helper.startRootIsolate(() => {}, []);
dart_sdk._debugger.registerDevtoolsFormatter();
window.\$dartTestGetSourceMap = dart_sdk.dart.getSourceMap;
window.postMessage({type: "DDC_STATE_CHANGE", state: "start"}, "*");
bootstrap.src__bootstrap__browser.internalBootstrapBrowserTest(
function() {
return app.$moduleIdentifier.main;
});
});
})();
''', headers: {'Content-Type': 'application/javascript'});
}
if (path.endsWith(".html")) {
var test = p.withoutExtension(path) + ".dart";
var scriptBase =
"${HTML_ESCAPE.convert(p.basename(test))}.browser_test.dart";
String scriptTags;
if (path.endsWith(".ddc.html")) {
var requireUrl = "/$_secret/packages/\$sdk/dev_compiler/amd/require.js";
scriptTags =
'<script data-main="$scriptBase" src="$requireUrl" defer></script>';
} else {
// Link to the Dart wrapper on Dartium and the compiled JS version
// elsewhere.
var script = request.headers['user-agent'].contains('(Dart)')
? 'type="application/dart" src="$scriptBase"'
: 'src="$scriptBase.js"';
scriptTags = "<script $script></script>";
}
return new shelf.Response.ok('''
<!DOCTYPE html>
<html>
<head>
<title>${HTML_ESCAPE.convert(test)} Test</title>
$scriptTags
</head>
</html>
''', headers: {'Content-Type': 'text/html'});
}
return new shelf.Response.notFound('Not found.');
}
ExecutableSettings parsePlatformSettings(YamlMap settings) =>
new ExecutableSettings.parse(settings);
ExecutableSettings mergePlatformSettings(
ExecutableSettings settings1, ExecutableSettings settings2) =>
settings1.merge(settings2);
void customizePlatform(TestPlatform platform, ExecutableSettings settings) {
var oldSettings =
_browserSettings[platform] ?? _browserSettings[platform.root];
if (oldSettings != null) settings = oldSettings.merge(settings);
_browserSettings[platform] = settings;
}
/// Loads the test suite at [path] on the browser [browser].
///
/// This will start a browser to load the suite if one isn't already running.
/// Throws an [ArgumentError] if [browser] isn't a browser platform.
Future<RunnerSuite> load(String path, TestPlatform browser,
SuiteConfiguration suiteConfig, Object message) async {
assert(suiteConfig.platforms.contains(browser.identifier));
// TODO(nweiz): Find a more principled way of passing CLI
// configuration to plugins.
var compiler = Compiler.find((message as Map)['compiler'] as String);
if (!browser.isBrowser) {
throw new ArgumentError("$browser is not a browser.");
}
if (compiler == Compiler.build && !build.isInUse) {
throw "The build package's output directory doesn't exist.\n"
"Make sure the build_runner package is installed, then run `pub run "
"build_runner build`.\n"
"See https://github.com/dart-lang/test/blob/master/README.md"
"#build_runner";
}
var htmlPath = p.withoutExtension(path) + '.html';
if (new File(htmlPath).existsSync() &&
!new File(htmlPath)
.readAsStringSync()
.contains('packages/test/dart.js')) {
throw new LoadException(
path,
'"${htmlPath}" must contain <script src="packages/test/dart.js">'
'</script>.');
}
var suiteUrl;
if (_config.pubServeUrl != null) {
var suitePrefix = p
.toUri(
p.withoutExtension(p.relative(path, from: p.join(_root, 'test'))))
.path;
var dartUrl;
// Polymer generates a bootstrap entrypoint that wraps the entrypoint we
// see on disk, and modifies the HTML file to point to the bootstrap
// instead. To make sure we get the right source maps and wait for the
// right file to compile, we have some Polymer-specific logic here to load
// the boostrap instead of the unwrapped file.
if (isPolymerEntrypoint(path)) {
dartUrl = _config.pubServeUrl.resolve(
"$suitePrefix.html.polymer.bootstrap.dart.browser_test.dart");
} else {
dartUrl =
_config.pubServeUrl.resolve('$suitePrefix.dart.browser_test.dart');
}
await _pubServeSuite(path, dartUrl, browser, suiteConfig);
suiteUrl = _config.pubServeUrl.resolveUri(p.toUri('$suitePrefix.html'));
} else {
var extension = ".html";
if (browser.isJS) {
if (_precompiled(suiteConfig, path)) {
if (_precompiledPaths.add(suiteConfig.precompiledPath)) {
if (!suiteConfig.jsTrace) {
var jsPath = p.join(suiteConfig.precompiledPath,
p.relative(path + ".browser_test.dart.js", from: _root));
var sourceMapPath = '${jsPath}.map';
if (new File(sourceMapPath).existsSync()) {
_mappers[path] = new StackTraceMapper(
new File(sourceMapPath).readAsStringSync(),
mapUrl: p.toUri(sourceMapPath),
packageResolver: await PackageResolver.current.asSync,
sdkRoot: p.toUri(sdkDir));
}
}
_precompiledCascade ??= new shelf.Cascade();
_precompiledCascade = _precompiledCascade
.add(createStaticHandler(suiteConfig.precompiledPath));
}
} else {
assert(compiler != Compiler.none);
if (compiler == Compiler.dart2js) {
await _compileSuite(path, suiteConfig);
} else {
await _compileBootstrap();
extension = ".ddc.html";
}
}
}
if (_closed) return null;
suiteUrl = url.resolveUri(p.toUri(
p.withoutExtension(p.relative(path, from: _root)) + extension));
}
if (_closed) return null;
// TODO(nweiz): Don't start the browser until all the suites are compiled.
var browserManager = await _browserManagerFor(browser);
if (_closed || browserManager == null) return null;
var suite = await browserManager.load(path, suiteUrl, suiteConfig, message,
mapper: browser.isJS ? _mappers[path] : null);
if (_closed) return null;
return suite;
}
/// Returns whether the test at [path] has precompiled HTML available
/// underneath [suiteConfig.precompiledPath].
bool _precompiled(SuiteConfiguration suiteConfig, String path) {
if (suiteConfig.precompiledPath == null) return false;
var htmlPath = p.join(suiteConfig.precompiledPath,
p.relative(p.withoutExtension(path) + ".html", from: _root));
return new File(htmlPath).existsSync();
}
StreamChannel loadChannel(String path, TestPlatform platform) =>
throw new UnimplementedError();
/// Loads a test suite at [path] from the `pub serve` URL [dartUrl].
///
/// This ensures that only one suite is loaded at a time, and that any errors
/// are exposed as [LoadException]s.
Future _pubServeSuite(String path, Uri dartUrl, TestPlatform browser,
SuiteConfiguration suiteConfig) {
return _pubServePool.withResource(() async {
var timer = new Timer(new Duration(seconds: 1), () {
print('"pub serve" is compiling $path...');
});
// For browsers that run Dart compiled to JavaScript, get the source map
// instead of the Dart code for two reasons. We want to verify that the
// server's dart2js compiler is running on the Dart code, and also load
// the StackTraceMapper.
var getSourceMap = browser.isJS;
var url = getSourceMap
? dartUrl.replace(path: dartUrl.path + '.js.map')
: dartUrl;
HttpClientResponse response;
try {
var request = await _http.getUrl(url);
response = await request.close();
if (response.statusCode != 200) {
// We don't care about the response body, but we have to drain it or
// else the process can't exit.
response.listen((_) {});
throw new LoadException(
path,
"Error getting $url: ${response.statusCode} "
"${response.reasonPhrase}\n"
'Make sure "pub serve" is serving the test/ directory.');
}
if (getSourceMap && !suiteConfig.jsTrace) {
_mappers[path] = new StackTraceMapper(
await UTF8.decodeStream(response),
mapUrl: url,
packageResolver: new SyncPackageResolver.root('packages'),
sdkRoot: p.toUri('packages/\$sdk'));
return;
}
// Drain the response stream.
response.listen((_) {});
} on IOException catch (error) {
var message = getErrorMessage(error);
if (error is SocketException) {
message = "${error.osError.message} "
"(errno ${error.osError.errorCode})";
}
throw new LoadException(
path,
"Error getting $url: $message\n"
'Make sure "pub serve" is running.');
} finally {
timer.cancel();
}
});
}
/// Compile the test suite at [dartPath] to JavaScript with dart2js.
///
/// Once the suite has been compiled, it's added to [_jsHandler] so it can be
/// served.
Future _compileSuite(String dartPath, SuiteConfiguration suiteConfig) {
return _compileFutures.putIfAbsent(dartPath, () async {
var dir = new Directory(_compiledDir).createTempSync('test_').path;
var jsPath = p.join(dir, p.basename(dartPath) + ".browser_test.dart.js");
await _compilers.compile('''
import "package:test/src/bootstrap/browser.dart";
import "${p.toUri(p.absolute(dartPath))}" as test;
void main() {
internalBootstrapBrowserTest(() => test.main);
}
''', jsPath, suiteConfig);
if (_closed) return;
var jsUrl = p.toUri(p.relative(dartPath, from: _root)).path +
'.browser_test.dart.js';
_jsHandler.add(jsUrl, (request) {
return new shelf.Response.ok(new File(jsPath).readAsStringSync(),
headers: {'Content-Type': 'application/javascript'});
});
var mapUrl = p.toUri(p.relative(dartPath, from: _root)).path +
'.browser_test.dart.js.map';
_jsHandler.add(mapUrl, (request) {
return new shelf.Response.ok(
new File(jsPath + '.map').readAsStringSync(),
headers: {'Content-Type': 'application/json'});
});
if (suiteConfig.jsTrace) return;
var mapPath = jsPath + '.map';
_mappers[dartPath] = new StackTraceMapper(
new File(mapPath).readAsStringSync(),
mapUrl: p.toUri(mapPath),
packageResolver: await PackageResolver.current.asSync,
sdkRoot: p.toUri(sdkDir));
});
}
/// Compiles "package:test/src/bootstrap/browser.dart" to JS using DDC and
/// adds the compiled output to the server.
Future _compileBootstrap() async {
// Pass the error through a result to avoid cross-zone issues.
var result = await _compileBootstrapMemo.runOnce(() {
return Result.capture(new Future(() async {
var js = await build.compile("package:test/src/bootstrap/browser.dart");
_jsHandler.add(
"packages/test/src/bootstrap/browser.js",
(request) => new shelf.Response.ok(js,
headers: {'Content-Type': 'application/javascript'}));
}));
});
await result.asFuture;
}
final _compileBootstrapMemo = new AsyncMemoizer<Result>();
/// Returns the [BrowserManager] for [platform], which should be a browser.
///
/// If no browser manager is running yet, starts one.
Future<BrowserManager> _browserManagerFor(TestPlatform platform) {
var managerFuture = _browserManagers[platform];
if (managerFuture != null) return managerFuture;
var completer = new Completer<WebSocketChannel>.sync();
var path = _webSocketHandler.create(webSocketHandler(completer.complete));
var webSocketUrl = url.replace(scheme: 'ws').resolve(path);
var hostUrl = (_config.pubServeUrl == null ? url : _config.pubServeUrl)
.resolve('packages/test/src/runner/browser/static/index.html')
.replace(queryParameters: {
'managerUrl': webSocketUrl.toString(),
'debug': _config.pauseAfterLoad.toString()
});
var future = BrowserManager.start(
platform, hostUrl, completer.future, _browserSettings[platform],
debug: _config.pauseAfterLoad);
// Store null values for browsers that error out so we know not to load them
// again.
_browserManagers[platform] = future.catchError((_) => 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.
Future 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.
Future close() => _closeMemo.runOnce(() async {
var futures =
_browserManagers.values.map<Future<dynamic>>((future) async {
var result = await future;
if (result == null) return;
await result.close();
}).toList();
futures.add(_server.close());
futures.add(_compilers.close());
await Future.wait(futures);
if (_config.pubServeUrl == null) {
//new Directory(_compiledDir).deleteSync(recursive: true);
} else {
_http.close();
}
});
final _closeMemo = new AsyncMemoizer();
}