blob: 1383bb3bc9e3423854a40b8b8d122639c22b8651 [file] [log] [blame]
// Copyright (c) 2015, 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.
library test.runner.browser.server;
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: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 '../../backend/metadata.dart';
import '../../backend/suite.dart';
import '../../backend/test_platform.dart';
import '../../util/async_thunk.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 '../application_exception.dart';
import '../load_exception.dart';
import 'browser.dart';
import 'browser_manager.dart';
import 'compiler_pool.dart';
import 'chrome.dart';
import 'content_shell.dart';
import 'dartium.dart';
import 'firefox.dart';
import 'internet_explorer.dart';
import 'phantom_js.dart';
import 'polymer.dart';
import 'safari.dart';
/// A server that serves JS-compiled tests to browsers.
///
/// A test suite may be loaded for a given file using [loadSuite].
class BrowserServer {
/// Starts the server.
///
/// [root] is the root directory that the server should serve. It defaults to
/// the working directory.
///
/// If [packageRoot] is passed, it's used for all package imports when
/// compiling tests to JS. Otherwise, the package root is inferred from
/// [root].
///
/// If [pubServeUrl] is passed, tests will be loaded from the `pub serve`
/// instance at that URL rather than from the filesystem.
///
/// If [color] is true, console colors will be used when compiling Dart.
///
/// If [jsTrace] is true, raw JavaScript stack traces will be used for tests
/// that are compiled to JavaScript.
///
/// If the package root doesn't exist, throws an [ApplicationException].
static Future<BrowserServer> start({String root, String packageRoot,
Uri pubServeUrl, bool color: false, bool jsTrace: false}) async {
var server = new BrowserServer._(
root, packageRoot, pubServeUrl, color, jsTrace);
await server._load();
return server;
}
/// The underlying HTTP server.
HttpServer _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 = randomBase64(24, urlSafe: true);
/// The URL for this server.
Uri get url => baseUrlForAddress(_server.address, _server.port)
.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`.
///
/// This is `null` if tests are loaded from `pub serve`.
final CompilerPool _compilers;
/// The temporary directory in which compiled JS is emitted.
final String _compiledDir;
/// The root directory served statically by this server.
final String _root;
/// The package root.
final String _packageRoot;
final bool _jsTrace;
/// The URL for the `pub serve` instance to use to load tests.
///
/// This is `null` if tests should be compiled manually.
final Uri _pubServeUrl;
/// 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 => _closeThunk.hasRun;
/// The thunk for running [close] exactly once.
final _closeThunk = new AsyncThunk();
/// All currently-running browsers.
///
/// These are controlled by [_browserManager]s.
final _browsers = new Map<TestPlatform, Browser>();
/// A map from browser identifiers to futures that will complete to the
/// [BrowserManager]s for those browsers, or the errors that occurred when
/// trying to load those managers.
///
/// This should only be accessed through [_browserManagerFor].
final _browserManagers =
new Map<TestPlatform, Future<Result<BrowserManager>>>();
/// 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>();
final _mappers = new Map<String, StackTraceMapper>();
BrowserServer._(String root, String packageRoot, Uri pubServeUrl, bool color,
this._jsTrace)
: _root = root == null ? p.current : root,
_packageRoot = packageRootFor(root, packageRoot),
_pubServeUrl = pubServeUrl,
_compiledDir = pubServeUrl == null ? createTempDir() : null,
_http = pubServeUrl == null ? null : new HttpClient(),
_compilers = new CompilerPool(color: color);
/// Starts the underlying server.
Future _load() async {
var cascade = new shelf.Cascade()
.add(_webSocketHandler.handler);
if (_pubServeUrl == null) {
cascade = cascade
.add(_createPackagesHandler())
.add(_jsHandler.handler)
.add(createStaticHandler(_root))
.add(_wrapperHandler);
}
var pipeline = new shelf.Pipeline()
.addMiddleware(nestingMiddleware(_secret))
.addHandler(cascade.handler);
_server = await HttpMultiServer.loopback(0);
shelf_io.serveRequests(_server, pipeline);
}
/// Returns a handler that serves the contents of the "packages/" directory
/// for any URL that contains "packages/".
///
/// This is a factory so it can wrap a static handler.
shelf.Handler _createPackagesHandler() {
var staticHandler =
createStaticHandler(_packageRoot, serveFilesOutsidePath: true);
return (request) {
var segments = p.url.split(shelfUrl(request).path);
for (var i = 0; i < segments.length; i++) {
if (segments[i] != "packages") continue;
return staticHandler(
shelfChange(request, path: p.url.joinAll(segments.take(i + 1))));
}
return new shelf.Response.notFound("Not found.");
};
}
/// A handler that serves wrapper files used to bootstrap tests.
shelf.Response _wrapperHandler(shelf.Request request) {
var path = p.fromUri(shelfUrl(request));
if (path.endsWith(".browser_test.dart")) {
return new shelf.Response.ok('''
import "package:test/src/runner/browser/iframe_listener.dart";
import "${p.basename(p.withoutExtension(p.withoutExtension(path)))}" as test;
void main() {
IframeListener.start(() => test.main);
}
''', headers: {'Content-Type': 'application/dart'});
}
if (path.endsWith(".html")) {
var test = p.withoutExtension(path) + ".dart";
// Link to the Dart wrapper on Dartium and the compiled JS version
// elsewhere.
var scriptBase =
"${HTML_ESCAPE.convert(p.basename(test))}.browser_test.dart";
var script = request.headers['user-agent'].contains('(Dart)')
? 'type="application/dart" src="$scriptBase"'
: 'src="$scriptBase.js"';
return new shelf.Response.ok('''
<!DOCTYPE html>
<html>
<head>
<title>${HTML_ESCAPE.convert(test)} Test</title>
<script $script></script>
</head>
</html>
''', headers: {'Content-Type': 'text/html'});
}
return new shelf.Response.notFound('Not found.');
}
/// 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<Suite> loadSuite(String path, TestPlatform browser,
Metadata metadata) async {
if (!browser.isBrowser) {
throw new ArgumentError("$browser is not a browser.");
}
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 (_pubServeUrl != null) {
var suitePrefix = p.withoutExtension(
p.relative(path, from: p.join(_root, 'test')));
var jsUrl;
// 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)) {
jsUrl = _pubServeUrl.resolve(
"$suitePrefix.html.polymer.bootstrap.dart.browser_test.dart.js");
} else {
jsUrl = _pubServeUrl.resolve(
'$suitePrefix.dart.browser_test.dart.js');
}
await _pubServeSuite(path, jsUrl);
suiteUrl = _pubServeUrl.resolveUri(p.toUri('$suitePrefix.html'));
} else {
if (browser.isJS) await _compileSuite(path);
if (_closed) return null;
suiteUrl = url.resolveUri(p.toUri(
p.withoutExtension(p.relative(path, from: _root)) + ".html"));
}
if (_closed) return null;
// TODO(nweiz): Don't start the browser until all the suites are compiled.
var browserManager = await _browserManagerFor(browser);
if (_closed) return null;
var suite;
if (browserManager != null) {
suite = await browserManager.loadSuite(path, suiteUrl, metadata,
mapper: browser.isJS ? _mappers[path] : null);
if (_closed) return null;
}
if (suite != null) return suite.change(platform: browser.name);
// If the browser manager fails to load a suite and the server isn't
// closed, it's probably because the browser failed. We emit the failure
// here to ensure that it gets surfaced.
return _browsers[browser].onExit;
}
/// Loads a test suite at [path] from the `pub serve` URL [jsUrl].
///
/// 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 jsUrl) {
return _pubServePool.withResource(() async {
var timer = new Timer(new Duration(seconds: 1), () {
print('"pub serve" is compiling $path...');
});
var mapUrl = jsUrl.replace(path: jsUrl.path + '.map');
var response;
try {
// Get the source map here 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 request = await _http.getUrl(mapUrl);
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 $mapUrl: ${response.statusCode} "
"${response.reasonPhrase}\n"
'Make sure "pub serve" is serving the test/ directory.');
}
if (_jsTrace) {
// Drain the response stream.
response.listen((_) {});
return;
}
_mappers[path] = new StackTraceMapper(
await UTF8.decodeStream(response),
mapUrl: mapUrl,
packageRoot: _pubServeUrl.resolve('packages'),
sdkRoot: _pubServeUrl.resolve('packages/\$sdk'));
} 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 $mapUrl: $message\n"
'Make sure "pub serve" is running.');
} finally {
timer.cancel();
}
});
}
/// Compile the test suite at [dartPath] to JavaScript.
///
/// Once the suite has been compiled, it's added to [_jsHandler] so it can be
/// served.
Future _compileSuite(String dartPath) {
return _compileFutures.putIfAbsent(dartPath, () async {
var dir = new Directory(_compiledDir).createTempSync('test_').path;
var jsPath = p.join(dir, p.basename(dartPath) + ".js");
await _compilers.compile(dartPath, jsPath, packageRoot: _packageRoot);
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 (_jsTrace) return;
var mapPath = jsPath + '.map';
_mappers[dartPath] = new StackTraceMapper(
new File(mapPath).readAsStringSync(),
mapUrl: p.toUri(mapPath),
packageRoot: p.toUri(_packageRoot),
sdkRoot: p.toUri(sdkDir));
});
}
/// 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 manager = _browserManagers[platform];
if (manager != null) return Result.release(manager);
var completer = new Completer();
// Capture errors and release them later to avoid Zone issues. This call to
// [_browserManagerFor] is running in a different [LoadSuite] than future
// calls, which means they're also running in different error zones so
// errors can't be freely passed between them. Storing the error or value as
// an explicit [Result] fixes that.
_browserManagers[platform] = Result.capture(completer.future);
var path = _webSocketHandler.create(webSocketHandler((webSocket) {
completer.complete(new BrowserManager(platform, webSocket));
}));
var webSocketUrl = url.replace(scheme: 'ws').resolve(path);
var hostUrl = (_pubServeUrl == null ? url : _pubServeUrl)
.resolve('packages/test/src/runner/browser/static/index.html');
var browser = _newBrowser(hostUrl.replace(queryParameters: {
'managerUrl': webSocketUrl.toString()
}), platform);
_browsers[platform] = browser;
// TODO(nweiz): Gracefully handle the browser being killed before the
// tests complete.
browser.onExit.then((_) {
if (completer.isCompleted) return;
if (!_closed) return;
completer.complete(null);
}).catchError((error, stackTrace) {
if (completer.isCompleted) return;
completer.completeError(error, stackTrace);
});
return completer.future.timeout(new Duration(seconds: 30), onTimeout: () {
throw new ApplicationException(
"Timed out waiting for ${platform.name} to connect.");
});
}
/// Starts the browser identified by [browser] and has it load [url].
Browser _newBrowser(Uri url, TestPlatform browser) {
switch (browser) {
case TestPlatform.dartium: return new Dartium(url);
case TestPlatform.contentShell: return new ContentShell(url);
case TestPlatform.chrome: return new Chrome(url);
case TestPlatform.phantomJS: return new PhantomJS(url);
case TestPlatform.firefox: return new Firefox(url);
case TestPlatform.safari: return new Safari(url);
case TestPlatform.internetExplorer: return new InternetExplorer(url);
default:
throw new ArgumentError("$browser is not a browser.");
}
}
/// 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() {
return _closeThunk.run(() async {
var futures = _browsers.values.map((browser) => browser.close()).toList();
futures.add(_server.close());
futures.add(_compilers.close());
await Future.wait(futures);
if (_pubServeUrl == null) {
new Directory(_compiledDir).deleteSync(recursive: true);
} else {
_http.close();
}
});
}
}