blob: fc570d15b6a9dbe490fbd2a0c35074a1177b6cb2 [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.browser_manager;
import 'dart:async';
import 'dart:convert';
import 'package:async/async.dart';
import 'package:http_parser/http_parser.dart';
import 'package:pool/pool.dart';
import '../../backend/group.dart';
import '../../backend/metadata.dart';
import '../../backend/test.dart';
import '../../backend/test_platform.dart';
import '../../util/cancelable_future.dart';
import '../../util/multi_channel.dart';
import '../../util/remote_exception.dart';
import '../../util/stack_trace_mapper.dart';
import '../../utils.dart';
import '../application_exception.dart';
import '../environment.dart';
import '../load_exception.dart';
import '../runner_suite.dart';
import 'browser.dart';
import 'chrome.dart';
import 'content_shell.dart';
import 'dartium.dart';
import 'firefox.dart';
import 'iframe_test.dart';
import 'internet_explorer.dart';
import 'phantom_js.dart';
import 'safari.dart';
/// A class that 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 {
/// The browser instance that this is connected to via [_channel].
final Browser _browser;
// TODO(nweiz): Consider removing the duplication between this and
// [_browser.name].
/// The [TestPlatform] for [_browser].
final TestPlatform _platform;
/// The channel used to communicate with the browser.
///
/// This is connected to a page running `static/host.dart`.
final MultiChannel _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 = new 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 _pauseCompleter;
/// The environment to attach to each suite.
Future<_BrowserEnvironment> _environment;
/// Starts the browser identified by [platform] 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.
///
/// Returns the browser manager, or throws an [ApplicationException] if a
/// connection fails to be established.
static Future<BrowserManager> start(TestPlatform platform, Uri url,
Future<CompatibleWebSocket> future, {bool debug: false}) {
var browser = _newBrowser(url, platform, debug: debug);
var completer = new Completer();
// TODO(nweiz): Gracefully handle the browser being killed before the
// tests complete.
browser.onExit.then((_) {
throw new ApplicationException(
"${platform.name} exited before connecting.");
}).catchError((error, stackTrace) {
if (completer.isCompleted) return;
completer.completeError(error, stackTrace);
});
future.then((webSocket) {
if (completer.isCompleted) return;
completer.complete(new BrowserManager._(browser, platform, webSocket));
}).catchError((error, stackTrace) {
browser.close();
if (completer.isCompleted) return;
completer.completeError(error, stackTrace);
});
return completer.future.timeout(new Duration(seconds: 30), onTimeout: () {
browser.close();
throw new ApplicationException(
"Timed out waiting for ${platform.name} to connect.");
});
}
/// Starts the browser identified by [browser] and has it load [url].
///
/// If [debug] is true, starts the browser in debug mode.
static Browser _newBrowser(Uri url, TestPlatform browser,
{bool debug: false}) {
switch (browser) {
case TestPlatform.dartium: return new Dartium(url, debug: debug);
case TestPlatform.contentShell:
return new ContentShell(url, debug: debug);
case TestPlatform.chrome: return new Chrome(url);
case TestPlatform.phantomJS: return new PhantomJS(url, debug: debug);
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.");
}
}
/// Creates a new BrowserManager that communicates with [browser] over
/// [webSocket].
BrowserManager._(this._browser, this._platform, CompatibleWebSocket webSocket)
: _channel = new MultiChannel(
webSocket.map(JSON.decode),
mapSink(webSocket, JSON.encode)) {
_environment = _loadBrowserEnvironment();
_channel.stream.listen(_onMessage, onDone: close);
}
/// Loads [_BrowserEnvironment].
Future<_BrowserEnvironment> _loadBrowserEnvironment() async {
var observatoryUrl;
if (_platform.isDartVM) observatoryUrl = await _browser.observatoryUrl;
var remoteDebuggerUrl;
if (_platform.isHeadless) {
remoteDebuggerUrl = await _browser.remoteDebuggerUrl;
}
return new _BrowserEnvironment(this, observatoryUrl, remoteDebuggerUrl);
}
/// 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. [metadata] is the parsed metadata for the test suite.
///
/// If [mapper] is passed, it's used to map stack traces for errors coming
/// from this test suite.
Future<RunnerSuite> loadSuite(String path, Uri url, Metadata metadata,
{StackTraceMapper mapper}) async {
url = url.replace(fragment: Uri.encodeFull(JSON.encode({
"metadata": metadata.serialize(),
"browser": _platform.identifier
})));
// The stream may close before emitting a value if the browser is killed
// prematurely (e.g. via Control-C).
var suiteVirtualChannel = _channel.virtualChannel();
var suiteId = _suiteId++;
var suiteChannel;
closeIframe() {
if (_closed) return;
suiteChannel.sink.close();
_channel.sink.add({
"command": "closeSuite",
"id": suiteId
});
}
var response = await _pool.withResource(() {
_channel.sink.add({
"command": "loadSuite",
"url": url.toString(),
"id": suiteId,
"channel": suiteVirtualChannel.id
});
// Create a nested MultiChannel because the iframe will be using a channel
// wrapped within the host's channel.
suiteChannel = new MultiChannel(
suiteVirtualChannel.stream, suiteVirtualChannel.sink);
var completer = new Completer();
suiteChannel.stream.listen((response) {
if (response["type"] == "print") {
print(response["line"]);
} else {
completer.complete(response);
}
}, onDone: () {
if (!completer.isCompleted) completer.complete();
});
return completer.future.timeout(new Duration(minutes: 1), onTimeout: () {
throw new LoadException(
path,
"Timed out waiting for the test suite to connect on "
"${_platform.name}.");
});
});
if (response == null) {
closeIframe();
throw new LoadException(
path, "Connection closed before test suite loaded.");
}
if (response["type"] == "loadException") {
closeIframe();
throw new LoadException(path, response["message"]);
}
if (response["type"] == "error") {
closeIframe();
var asyncError = RemoteException.deserialize(response["error"]);
await new Future.error(
new LoadException(path, asyncError.error),
asyncError.stackTrace);
}
return new RunnerSuite(
await _environment,
_deserializeGroup(suiteChannel, mapper, response["root"]),
platform: _platform,
path: path,
onClose: () => closeIframe());
}
/// Deserializes [group] into a concrete [Group] class.
Group _deserializeGroup(MultiChannel suiteChannel,
StackTraceMapper mapper, Map group) {
var metadata = new Metadata.deserialize(group['metadata']);
return new Group(group['name'], group['entries'].map((entry) {
if (entry['type'] == 'group') {
return _deserializeGroup(suiteChannel, mapper, entry);
}
return _deserializeTest(suiteChannel, mapper, entry);
}),
metadata: metadata,
setUpAll: _deserializeTest(suiteChannel, mapper, group['setUpAll']),
tearDownAll:
_deserializeTest(suiteChannel, mapper, group['tearDownAll']));
}
/// Deserializes [test] into a concrete [Test] class.
///
/// Returns `null` if [test] is `null`.
Test _deserializeTest(MultiChannel suiteChannel, StackTraceMapper mapper,
Map test) {
if (test == null) return null;
var metadata = new Metadata.deserialize(test['metadata']);
var testChannel = suiteChannel.virtualChannel(test['channel']);
return new IframeTest(test['name'], metadata, testChannel,
mapper: mapper);
}
/// An implementation of [Environment.displayPause].
CancelableFuture _displayPause() {
if (_pauseCompleter != null) return _pauseCompleter.future;
_pauseCompleter = new CancelableCompleter(() {
_channel.sink.add({"command": "resume"});
_pauseCompleter = null;
});
_channel.sink.add({"command": "displayPause"});
return _pauseCompleter.future.whenComplete(() {
_pauseCompleter = null;
});
}
/// The callback for handling messages received from the host page.
void _onMessage(Map message) {
assert(message["command"] == "resume");
if (_pauseCompleter == null) return;
_pauseCompleter.complete();
}
/// Closes the manager and releases any resources it owns, including closing
/// the browser.
Future close() => _closeMemoizer.runOnce(() {
_closed = true;
if (_pauseCompleter != null) _pauseCompleter.complete();
_pauseCompleter = null;
return _browser.close();
});
final _closeMemoizer = new AsyncMemoizer();
}
/// An implementation of [Environment] for the browser.
///
/// All methods forward directly to [BrowserManager].
class _BrowserEnvironment implements Environment {
final BrowserManager _manager;
final Uri observatoryUrl;
final Uri remoteDebuggerUrl;
_BrowserEnvironment(this._manager, this.observatoryUrl,
this.remoteDebuggerUrl);
CancelableFuture displayPause() => _manager._displayPause();
}