Index: lib/src/runner/browser/dartium.dart |
diff --git a/lib/src/runner/browser/dartium.dart b/lib/src/runner/browser/dartium.dart |
index 0783f1005e43bebcff04656b0eb3a03752959e6f..8e6a8a30c6431d2b6f10ed80ca7e98a4332a8a72 100644 |
--- a/lib/src/runner/browser/dartium.dart |
+++ b/lib/src/runner/browser/dartium.dart |
@@ -5,13 +5,19 @@ |
library test.runner.browser.dartium; |
import 'dart:async'; |
+import 'dart:convert'; |
import 'dart:io'; |
+import 'package:async/async.dart'; |
import 'package:path/path.dart' as p; |
+import '../../util/cancelable_future.dart'; |
import '../../util/io.dart'; |
+import '../../utils.dart'; |
import 'browser.dart'; |
+final _observatoryRegExp = new RegExp(r"^Observatory listening on ([^ ]+)"); |
+ |
/// A class for running an instance of Dartium. |
/// |
/// Most of the communication with the browser is expected to happen via HTTP, |
@@ -22,35 +28,45 @@ import 'browser.dart'; |
class Dartium extends Browser { |
final name = "Dartium"; |
- Dartium(url, {String executable}) |
- : super(() => _startBrowser(url, executable)); |
+ final Future<Uri> observatoryUrl; |
+ |
+ factory Dartium(url, {String executable}) { |
+ var completer = new Completer.sync(); |
+ return new Dartium._(() async { |
+ if (executable == null) executable = _defaultExecutable(); |
+ |
+ var dir = createTempDir(); |
+ var process = await Process.start(executable, [ |
+ "--user-data-dir=$dir", |
+ url.toString(), |
+ "--disable-extensions", |
+ "--disable-popup-blocking", |
+ "--bwsi", |
+ "--no-first-run", |
+ "--no-default-browser-check", |
+ "--disable-default-apps", |
+ "--disable-translate" |
+ ], environment: {"DART_FLAGS": "--checked"}); |
+ |
+ // The first observatory URL emitted is for the empty start page; the |
+ // second is actually for the host page. |
+ completer.complete(_getObservatoryUrl(process.stdout)); |
+ |
+ process.exitCode |
+ .then((_) => new Directory(dir).deleteSync(recursive: true)); |
+ |
+ return process; |
+ }, completer.future); |
+ } |
+ |
+ Dartium._(Future<Process> startBrowser(), this.observatoryUrl) |
+ : super(startBrowser); |
/// Starts a new instance of Dartium open to the given [url], which may be a |
/// [Uri] or a [String]. |
/// |
/// If [executable] is passed, it's used as the Dartium executable. Otherwise |
/// the default executable name for the current OS will be used. |
- static Future<Process> _startBrowser(url, [String executable]) async { |
- if (executable == null) executable = _defaultExecutable(); |
- |
- var dir = createTempDir(); |
- var process = await Process.start(executable, [ |
- "--user-data-dir=$dir", |
- url.toString(), |
- "--disable-extensions", |
- "--disable-popup-blocking", |
- "--bwsi", |
- "--no-first-run", |
- "--no-default-browser-check", |
- "--disable-default-apps", |
- "--disable-translate" |
- ], environment: {"DART_FLAGS": "--checked"}); |
- |
- process.exitCode |
- .then((_) => new Directory(dir).deleteSync(recursive: true)); |
- |
- return process; |
- } |
/// Return the default executable for the current operating system. |
static String _defaultExecutable() { |
@@ -86,4 +102,150 @@ class Dartium extends Browser { |
var dartium = p.join(dir, "chromium", "chrome"); |
return new File(dartium).existsSync() ? dartium : null; |
} |
+ |
+ // TODO(nweiz): simplify this when sdk#23923 is fixed. |
+ /// Returns the Observatory URL for the Dartium executable with the given |
+ /// [stdout] stream, or `null` if the correct one couldn't be found. |
+ /// |
+ /// Dartium prints out three different Observatory URLs when it starts. Only |
+ /// one of them is connected to the VM instance running the host page, and the |
+ /// ordering isn't guaranteed, so we need to figure out which one is correct. |
+ /// We do so by connecting to the VM service via WebSockets and looking for |
+ /// the Observatory instance that actually contains an isolate, and returning |
+ /// the corresponding URI. |
+ static Future<Uri> _getObservatoryUrl(Stream<List<int>> stdout) async { |
+ var urlQueue = new StreamQueue(lineSplitter.bind(stdout).map((line) { |
+ var match = _observatoryRegExp.firstMatch(line); |
+ return match == null ? null : Uri.parse(match[1]); |
+ }).where((line) => line != null)); |
+ |
+ var futures = [ |
+ urlQueue.next, |
+ urlQueue.next, |
+ urlQueue.next |
+ ].map(_checkObservatoryUrl); |
+ |
+ urlQueue.cancel(); |
+ |
+ /// Dartium will print three possible observatory URLs. For each one, we |
+ /// check whether it's actually connected to an isolate, indicating that |
+ /// it's the observatory for the main page. Once we find the one that is, we |
+ /// cancel the other requests and return it. |
+ return inCompletionOrder(futures) |
+ .firstWhere((url) => url != null, defaultValue: () => null); |
+ } |
+ |
+ /// If the URL returned by [future] corresponds to the correct Observatory |
+ /// instance, returns it. Otherwise, returns `null`. |
+ /// |
+ /// If the returned future is canceled before it fires, the WebSocket |
+ /// connection with the given Observatory will be closed immediately. |
+ static CancelableFuture<Uri> _checkObservatoryUrl(Future<Uri> future) { |
+ var webSocket; |
+ var canceled = false; |
+ var completer = new CancelableCompleter(() { |
+ canceled = true; |
+ if (webSocket != null) webSocket.close(); |
+ }); |
+ |
+ // We've encountered a format we don't understand. Close the web socket and |
+ // complete to null. |
+ giveUp() { |
+ webSocket.close(); |
+ if (!completer.isCompleted) completer.complete(); |
+ } |
+ |
+ future.then((url) async { |
+ try { |
+ webSocket = await WebSocket.connect( |
+ url.replace(scheme: 'ws', path: '/ws').toString()); |
+ if (canceled) { |
+ webSocket.close(); |
+ return null; |
+ } |
+ |
+ webSocket.add(JSON.encode({ |
+ "jsonrpc": "2.0", |
+ "method": "streamListen", |
+ "params": {"streamId": "Isolate"}, |
+ "id": "0" |
+ })); |
+ |
+ webSocket.add(JSON.encode({ |
+ "jsonrpc": "2.0", |
+ "method": "getVM", |
+ "params": {}, |
+ "id": "1" |
+ })); |
+ |
+ webSocket.listen((response) { |
+ try { |
+ response = JSON.decode(response); |
+ } on FormatException catch (_) { |
+ giveUp(); |
+ return; |
+ } |
+ |
+ // If there's a "response" key, we're probably talking to the pre-1.0 |
+ // VM service protocol, in which case we should just give up. |
+ if (response is! Map || response.containsKey("response")) { |
+ giveUp(); |
+ return; |
+ } |
+ |
+ if (response["id"] == "0") return; |
+ |
+ if (response["id"] == "1") { |
+ var result = response["result"]; |
+ if (result is! Map) { |
+ giveUp(); |
+ return; |
+ } |
+ |
+ var isolates = result["isolates"]; |
+ if (isolates is! List) { |
+ giveUp(); |
+ return; |
+ } |
+ |
+ if (isolates.isNotEmpty) { |
+ webSocket.close(); |
+ if (!completer.isCompleted) completer.complete(url); |
+ } |
+ return; |
+ } |
+ |
+ // The 1.0 protocol used a raw "event" key, while the 2.0 protocol |
+ // wraps it in JSON-RPC method params. |
+ var event; |
+ if (response.containsKey("event")) { |
+ event = response["event"]; |
+ } else { |
+ var params = response["params"]; |
+ if (params is Map) event = params["event"]; |
+ } |
+ |
+ if (event is! Map) { |
+ giveUp(); |
+ return; |
+ } |
+ |
+ if (event["kind"] != "IsolateStart") return; |
+ webSocket.close(); |
+ if (completer.isCompleted) return; |
+ |
+ // TODO(nweiz): include the isolate ID in the URL? |
+ completer.complete(url); |
+ }); |
+ } on IOException catch (_) { |
+ // IO exceptions are probably caused by connecting to an |
+ // incorrect WebSocket that already closed. |
+ return null; |
+ } |
+ }).catchError((error, stackTrace) { |
+ if (!completer.isCompleted) completer.completeError(error, stackTrace); |
+ }); |
+ |
+ return completer.future; |
+ } |
} |