diff --git a/lib/src/runner/browser/chrome.dart b/lib/src/runner/browser/chrome.dart new file mode 100644 index 0000000000000000000000000000000000000000..2a198a15fc669925a1603f4478b86ff1f4aa4c7c --- /dev/null +++ b/lib/src/runner/browser/chrome.dart @@ -0,0 +1,88 @@ +// 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 unittest.runner.browser.chrome; + +import 'dart:async'; +import 'dart:io'; + +import '../../util/io.dart'; + +// TODO(nweiz): move this into its own package? +// TODO(nweiz): support other browsers. +/// A class for running an instance of Chrome. +/// +/// Most of the communication with the browser is expected to happen via HTTP, +/// so this exposes a bare-bones API. The browser starts as soon as the class is +/// constructed, and is killed when [close] is called. +/// +/// Any errors starting or running the process are reported through [onExit]. +class Chrome { + /// The underlying process. + Process _process; + + /// The temporary directory used as the browser's user data dir. + /// + /// A new data dir is created for each run to ensure that they're + /// well-isolated. + String _dir; + + /// A future that completes when the browser exits. + /// + /// If there's a problem starting or running the browser, this will complete + /// with an error. + Future get onExit => _onExitCompleter.future; + final _onExitCompleter = new Completer(); + + /// A future that completes when the browser process has started. + /// + /// This is used to ensure that [close] works regardless of when it's called. + Future get _onProcessStarted => _onProcessStartedCompleter.future; + final _onProcessStartedCompleter = new Completer(); + + /// Starts a new instance of Chrome open to the given [url], which may be a + /// [Uri] or a [String]. + /// + /// If [executable] is passed, it's used as the Chrome executable. Otherwise + /// `"google-chrome"` will be looked up on the system PATH. + Chrome(url, {String executable}) { + if (executable == null) executable = "google-chrome"; + + // Don't return a Future here because there's no need for the caller to wait + // for the process to actually start. They should just wait for the HTTP + // request instead. + withTempDir((dir) { + _dir = dir; + return Process.start(executable, [ + "--user-data-dir=$_dir", + url.toString(), + "--disable-extensions", + "--disable-popup-blocking", + "--bwsi", + "--no-first-run" + ]).then((process) { + _process = process; + _onProcessStartedCompleter.complete(); + + // TODO(nweiz): the browser's standard output is almost always useless + // noise, but we should allow the user to opt in to seeing it. + return _process.exitCode; + }); + }).then((exitCode) { + if (exitCode != 0) throw "Chrome failed with exit code $exitCode."; + }).then(_onExitCompleter.complete) + .catchError(_onExitCompleter.completeError); + } + + /// Kills the browser process. + /// + /// Returns the same [Future] as [onExit], except that it won't emit + /// exceptions. + Future close() { + _onProcessStarted.then((_) => _process.kill()); + + // Swallow exceptions. The user should explicitly use [onExit] for these. + return onExit.catchError((_) {}); + } +} diff --git a/lib/src/util/io.dart b/lib/src/util/io.dart index 181cb5c504b5e6c0280fb2a8b370d0e08d9141f9..733f1a9ecda3f62d10bda7ce6caf7bf7b75ae7db 100644 --- a/lib/src/util/io.dart +++ b/lib/src/util/io.dart @@ -57,3 +57,20 @@ Future withTempDir(Future fn(String path)) { .whenComplete(() => tempDir.deleteSync(recursive: true)); }); } + +/// Creates a URL string for [address]:[port]. +/// +/// Handles properly formatting IPv6 addresses. +Uri baseUrlForAddress(InternetAddress address, int port) { + if (address.isLoopback) { + return new Uri(scheme: "http", host: "localhost", port: port); + } + + // IPv6 addresses in URLs need to be enclosed in square brackets to avoid + // URL ambiguity with the ":" in the address. + if (address.type == InternetAddressType.IP_V6) { + return new Uri(scheme: "http", host: "[${address.address}]", port: port); + } + + return new Uri(scheme: "http", host: address.address, port: port); +} diff --git a/pubspec.yaml b/pubspec.yaml index 86cb82091812ddb8cbb977ac37dbe6e40d749a2e..7f2137b4f314c2e6fe04d98e65a3240c92e5c796 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,6 +8,8 @@ environment: dependencies: args: '^0.12.1' matcher: '^0.12.0-alpha.0' + shelf: '^0.5.3' + shelf_web_socket: '^0.0.1' stack_trace: '^1.2.0' dev_dependencies: fake_async: '^0.1.2' diff --git a/test/browser/chrome_test.dart b/test/browser/chrome_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..9393d9194115ace58486af6e6603c8f73ac68a30 --- /dev/null +++ b/test/browser/chrome_test.dart @@ -0,0 +1,131 @@ +// 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. + +import 'dart:async'; + +import 'package:unittest/unittest.dart'; +import 'package:unittest/src/runner/browser/chrome.dart'; +import 'package:unittest/src/util/io.dart'; +import 'package:shelf/shelf.dart' as shelf; +import 'package:shelf/shelf_io.dart' as shelf_io; +import 'package:shelf_web_socket/shelf_web_socket.dart'; + +void main() { + group("running JavaScript", () { + // The JavaScript to serve in the server. We use actual JavaScript here to + // avoid the pain of compiling to JS in a test + var javaScript; + + var servePage = (request) { + if (request.url.path == "/") { + return new shelf.Response.ok(""" +<!doctype html> +<html> +<head> + <script src="index.js"></script> +</head> +</html> +""", headers: {'content-type': 'text/html'}); + } else if (request.url.path == "/index.js") { + return new shelf.Response.ok(javaScript, + headers: {'content-type': 'application/javascript'}); + } else { + return new shelf.Response.notFound(null); + } + }; + + var server; + var webSockets; + setUp(() { + var webSocketsController = new StreamController(); + webSockets = webSocketsController.stream; + + return shelf_io.serve( + new shelf.Cascade() + .add(webSocketHandler(webSocketsController.add)) + .add(servePage).handler, + 'localhost', 0).then((server_) { + server = server_; + }); + }); + + tearDown(() { + if (server != null) server.close(); + + javaScript = null; + server = null; + webSockets = null; + }); + + test("starts Chrome with the given URL", () { + javaScript = ''' +var webSocket = new WebSocket(window.location.href.replace("http://", "ws://")); +webSocket.addEventListener("open", function() { + webSocket.send("loaded!"); +}); +'''; + var chrome = new Chrome(baseUrlForAddress(server.address, server.port)); + + return webSockets.first.then((webSocket) { + return webSocket.first.then( + (message) => expect(message, equals("loaded!"))); + }).whenComplete(chrome.close); + }); + + test("doesn't preserve state across runs", () { + javaScript = ''' +localStorage.setItem("data", "value"); + +var webSocket = new WebSocket(window.location.href.replace("http://", "ws://")); +webSocket.addEventListener("open", function() { + webSocket.send("done"); +}); +'''; + var chrome = new Chrome(baseUrlForAddress(server.address, server.port)); + + var first = true; + webSockets.listen(expectAsync((webSocket) { + if (first) { + // The first request will set local storage data. We can't kill the + // old chrome and start a new one until we're sure that that has + // finished. + webSocket.first.then((_) { + chrome.close(); + + javaScript = ''' +var webSocket = new WebSocket(window.location.href.replace("http://", "ws://")); +webSocket.addEventListener("open", function() { + webSocket.send(localStorage.getItem("data")); +}); +'''; + chrome = new Chrome(baseUrlForAddress(server.address, server.port)); + first = false; + }); + } else { + // The second request will return the local storage data. This should + // be null, indicating that no data was saved between runs. + expect( + webSocket.first + .then((message) => expect(message, equals('null'))) + .whenComplete(chrome.close), + completes); + } + }, count: 2)); + }); + }); + + test("a process can be killed synchronously after it's started", () { + return shelf_io.serve(expectAsync((_) {}, count: 0), 'localhost', 8080) + .then((server) { + var chrome = new Chrome(baseUrlForAddress(server.address, server.port)); + return chrome.close().whenComplete(server.close); + }); + }); + + test("reports an error in onExit", () { + var chrome = new Chrome("http://dart-lang.org", + executable: "_does_not_exist"); + expect(chrome.onExit, throwsA(new isInstanceOf<ProcessException>())); + }); +}