diff --git a/CHANGELOG.md b/CHANGELOG.md index adeab179b..a94df6776 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.12.18 + +* Add the `spawnHybridUri()` and `spawnHybridCode()` functions, which allow + browser tests to run code on the VM. + ## 0.12.17+3 * Internal changes only. diff --git a/README.md b/README.md index f2570ebf2..7c585371c 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ * [Whole-Package Configuration](#whole-package-configuration) * [Tagging Tests](#tagging-tests) * [Debugging](#debugging) +* [Browser/VM Hybrid Tests](#browser-vm-hybrid-tests) * [Testing with `barback`](#testing-with-barback) * [Further Reading](#further-reading) @@ -126,7 +127,7 @@ void main() { A single test file can be run just using `pub run test path/to/test.dart`. -![Single file being run via pub run"](https://raw.githubusercontent.com/dart-lang/test/master/image/test1.gif) +![Single file being run via "pub run"](https://raw.githubusercontent.com/dart-lang/test/master/image/test1.gif) Many tests can be run at a time using `pub run test path/to/dir`. @@ -605,6 +606,80 @@ can see and interact with any HTML it renders. Note that the Dart animation may still be visible behind the iframe; to hide it, just add a `background-color` to the page's HTML. +## Browser/VM Hybrid Tests + +Code that's written for the browser often needs to talk to some kind of server. +Maybe you're testing the HTML served by your app, or maybe you're writing a +library that communicates over WebSockets. We call tests that run code on both +the browser and the VM **hybrid tests**. + +Hybrid tests use one of two functions: [`spawnHybridCode()`][spawnHybridCode] and +[`spawnHybridUri()`][spawnHybridUri]. Both of these spawn Dart VM +[isolates][dart:isolate] that can import `dart:io` and other VM-only libraries. +The only difference is where the code from the isolate comes from: +`spawnHybridCode()` takes a chunk of actual Dart code, whereas +`spawnHybridUri()` takes a URL. They both return a +[`StreamChannel`][StreamChannel] that communicates with the hybrid isolate. For +example: + +[spawnHybridCode]: http://www.dartdocs.org/documentation/test/latest/index.html#test/test@id_spawnHybridCode +[spawnHybridUri]: http://www.dartdocs.org/documentation/test/latest/index.html#test/test@id_spawnHybridUri +[dart:isolate]: https://api.dartlang.org/stable/latest/dart-isolate/dart-isolate-library.html +[StreamChannel]: https://pub.dartlang.org/packages/stream_channel + +```dart +// ## test/web_socket_server.dart + +// The library loaded by spawnHybridUri() can import any packages that your +// package depends on, including those that only work on the VM. +import "package:shelf/shelf_io.dart" as io; +import "package:shelf_web_socket/shelf_web_socket.dart"; +import "package:stream_channel/stream_channel.dart"; + +// Once the hybrid isolate starts, it will call the special function +// hybridMain() with a StreamChannel that's connected to the channel +// returned spawnHybridCode(). +hybridMain(StreamChannel channel) async { + // Start a WebSocket server that just sends "hello!" to its clients. + var server = await io.serve(webSocketHandler((webSocket) { + webSocket.sink.add("hello!"); + }), 'localhost', 0); + + // Send the port number of the WebSocket server to the browser test, so + // it knows what to connect to. + channel.sink.add(server.port); +} + + +// ## test/web_socket_test.dart + +@TestOn("browser") + +import "dart:html"; + +import "package:test/test.dart"; + +void main() { + test("connects to a server-side WebSocket", () async { + // Each spawnHybrid function returns a StreamChannel that communicates with + // the hybrid isolate. You can close this channel to kill the isolate. + var channel = spawnHybridUri("web_socket_server.dart"); + + // Get the port for the WebSocket server from the hybrid isolate. + var port = await channel.stream.first; + + var socket = new WebSocket('ws://localhost:$port'); + var message = await socket.onMessage.first; + expect(message.data, equals("hello!")); + }); +} +``` + +![A diagram showing a test in a browser communicating with a Dart VM isolate outside the browser.](https://raw.githubusercontent.com/dart-lang/test/master/image/hybrid.png) + +**Note**: If you write hybrid tests, be sure to add a dependency on the +`stream_channel` package, since you're using its API! + ## Testing With `barback` Packages using the `barback` transformer system may need to test code that's diff --git a/image/hybrid.png b/image/hybrid.png new file mode 100644 index 000000000..554a28c7d Binary files /dev/null and b/image/hybrid.png differ diff --git a/lib/src/frontend/spawn_hybrid.dart b/lib/src/frontend/spawn_hybrid.dart new file mode 100644 index 000000000..d5dae60e7 --- /dev/null +++ b/lib/src/frontend/spawn_hybrid.dart @@ -0,0 +1,172 @@ +// 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 'package:async/async.dart'; +import 'package:path/path.dart' as p; +import 'package:stream_channel/stream_channel.dart'; + +import '../backend/invoker.dart'; +import '../util/remote_exception.dart'; +import '../utils.dart'; + +/// A transformer that handles messages from the spawned isolate and ensures +/// that messages sent to it are JSON-encodable. +/// +/// The spawned isolate sends three kinds of messages. Data messages are emitted +/// as data events, error messages are emitted as error events, and print +/// messages are printed using `print()`. +final _transformer = new StreamChannelTransformer( + new StreamTransformer.fromHandlers(handleData: (message, sink) { + switch (message["type"]) { + case "data": + sink.add(message["data"]); + break; + + case "print": + print(message["line"]); + break; + + case "error": + var error = RemoteException.deserialize(message["error"]); + sink.addError(error.error, error.stackTrace); + break; + } + }), new StreamSinkTransformer.fromHandlers(handleData: (message, sink) { + // This is called synchronously from the user's `Sink.add()` call, so if + // [ensureJsonEncodable] throws here they'll get a helpful stack trace. + ensureJsonEncodable(message); + sink.add(message); + })); + +/// Spawns a VM isolate for the given [uri], which may be a [Uri] or a [String]. +/// +/// This allows browser tests to spawn servers with which they can communicate +/// to test client/server interactions. It can also be used by VM tests to +/// easily spawn an isolate. +/// +/// The Dart file at [uri] must define a top-level `hybridMain()` function that +/// takes a `StreamChannel` argument and, optionally, an `Object` argument to +/// which [message] will be passed. Note that [message] must be JSON-encodable. +/// For example: +/// +/// ```dart +/// import "package:stream_channel/stream_channel.dart"; +/// +/// hybridMain(StreamChannel channel, Object message) { +/// // ... +/// } +/// ``` +/// +/// If [uri] is relative, it will be interpreted relative to the `file:` URL for +/// the test suite being executed. If it's a `package:` URL, it will be resolved +/// using the current package's dependency constellation. +/// +/// Returns a [StreamChannel] that's connected to the channel passed to +/// `hybridMain()`. Only JSON-encodable objects may be sent through this +/// channel. If the channel is closed, the hybrid isolate is killed. If the +/// isolate is killed, the channel's stream will emit a "done" event. +/// +/// Any unhandled errors loading or running the hybrid isolate will be emitted +/// as errors over the channel's stream. Any calls to `print()` in the hybrid +/// isolate will be printed as though they came from the test that created the +/// isolate. +/// +/// Code in the hybrid isolate is not considered to be running in a test +/// context, so it can't access test functions like `expect()` and +/// `expectAsync()`. +/// +/// **Note**: If you use this API, be sure to add a dependency on the +/// **`stream_channel` package, since you're using its API as well! +StreamChannel spawnHybridUri(uri, {Object message}) { + Uri parsedUrl; + if (uri is Uri) { + parsedUrl = uri; + } else if (uri is String) { + parsedUrl = Uri.parse(uri); + } else { + throw new ArgumentError.value(uri, "uri", "must be a Uri or a String."); + } + + String absoluteUri; + if (parsedUrl.scheme.isEmpty) { + var suitePath = Invoker.current.liveTest.suite.path; + absoluteUri = p.url.join( + p.url.dirname(p.toUri(p.absolute(suitePath)).toString()), + parsedUrl.toString()); + } else { + absoluteUri = uri.toString(); + } + + return _spawn(absoluteUri, message); +} + +/// Spawns a VM isolate that runs the given [dartCode], which is loaded as the +/// contents of a Dart library. +/// +/// This allows browser tests to spawn servers with which they can communicate +/// to test client/server interactions. It can also be used by VM tests to +/// easily spawn an isolate. +/// +/// The [dartCode] must define a top-level `hybridMain()` function that takes a +/// `StreamChannel` argument and, optionally, an `Object` argument to which +/// [message] will be passed. Note that [message] must be JSON-encodable. For +/// example: +/// +/// ```dart +/// import "package:stream_channel/stream_channel.dart"; +/// +/// hybridMain(StreamChannel channel, Object message) { +/// // ... +/// } +/// ``` +/// +/// Returns a [StreamChannel] that's connected to the channel passed to +/// `hybridMain()`. Only JSON-encodable objects may be sent through this +/// channel. If the channel is closed, the hybrid isolate is killed. If the +/// isolate is killed, the channel's stream will emit a "done" event. +/// +/// Any unhandled errors loading or running the hybrid isolate will be emitted +/// as errors over the channel's stream. Any calls to `print()` in the hybrid +/// isolate will be printed as though they came from the test that created the +/// isolate. +/// +/// Code in the hybrid isolate is not considered to be running in a test +/// context, so it can't access test functions like `expect()` and +/// `expectAsync()`. +/// +/// **Note**: If you use this API, be sure to add a dependency on the +/// **`stream_channel` package, since you're using its API as well! +StreamChannel spawnHybridCode(String dartCode, {Object message}) { + var uri = new Uri.dataFromString(dartCode, + encoding: UTF8, mimeType: 'application/dart'); + return _spawn(uri.toString(), message); +} + +/// Like [spawnHybridUri], but doesn't take [Uri] objects and doesn't handle +/// relative URLs. +StreamChannel _spawn(String uri, Object message) { + var channel = Zone.current[#test.runner.test_channel] as MultiChannel; + if (channel == null) { + // TODO(nweiz): Link to an issue tracking support when running the test file + // directly. + throw new UnsupportedError( + "Can't connect to the test runner.\n" + 'spawnHybridUri() is currently only supported within "pub run test".'); + } + + ensureJsonEncodable(message); + + var isolateChannel = channel.virtualChannel(); + channel.sink.add({ + "type": "spawn-hybrid-uri", + "url": uri, + "message": message, + "channel": isolateChannel.id + }); + + return isolateChannel.transform(_transformer); +} diff --git a/lib/src/runner/hybrid_listener.dart b/lib/src/runner/hybrid_listener.dart new file mode 100644 index 000000000..df3b75d6f --- /dev/null +++ b/lib/src/runner/hybrid_listener.dart @@ -0,0 +1,88 @@ +// 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:isolate"; + +import "package:async/async.dart"; +import "package:stack_trace/stack_trace.dart"; +import "package:stream_channel/stream_channel.dart"; + +import "../util/remote_exception.dart"; +import "../utils.dart"; + +/// A sink transformer that wraps data and error events so that errors can be +/// decoded after being JSON-serialized. +final _transformer = new StreamSinkTransformer.fromHandlers( + handleData: (data, sink) { + ensureJsonEncodable(data); + sink.add({"type": "data", "data": data}); + }, handleError: (error, stackTrace, sink) { + sink.add({ + "type": "error", + "error": RemoteException.serialize(error, stackTrace) + }); + }); + +/// Runs the body of a hybrid isolate and communicates its messages, errors, and +/// prints to the main isolate. +/// +/// The [getMain] function returns the `hybridMain()` method. It's wrapped in a +/// closure so that, if the method undefined, we can catch the error and notify +/// the caller of it. +/// +/// The [data] argument contains two values: a [SendPort] that communicates with +/// the main isolate, and a message to pass to `hybridMain()`. +void listen(AsyncFunction getMain(), List data) { + var channel = new IsolateChannel.connectSend(data.first as SendPort); + var message = data.last; + + Chain.capture(() { + runZoned(() { + var main; + try { + main = getMain(); + } on NoSuchMethodError catch (_) { + _sendError(channel, "No top-level hybridMain() function defined."); + return; + } catch (error, stackTrace) { + _sendError(channel, error, stackTrace); + return; + } + + if (main is! Function) { + _sendError(channel, "Top-level hybridMain is not a function."); + return; + } else if (main is! ZoneUnaryCallback && main is! ZoneBinaryCallback) { + _sendError(channel, + "Top-level hybridMain() function must take one or two arguments."); + return; + } + + // Wrap [channel] before passing it to user code so that we can wrap + // errors and distinguish user data events from control events sent by the + // listener. + var transformedChannel = channel.transformSink(_transformer); + if (main is ZoneUnaryCallback) { + main(transformedChannel); + } else { + main(transformedChannel, message); + } + }, zoneSpecification: new ZoneSpecification(print: (_, __, ___, line) { + channel.sink.add({"type": "print", "line": line}); + })); + }, onError: (error, stackTrace) async { + _sendError(channel, error, stackTrace); + await channel.sink.close(); + Zone.current.handleUncaughtError(error, stackTrace); + }); +} + +/// Sends a message over [channel] indicating an error from user code. +void _sendError(StreamChannel channel, error, [StackTrace stackTrace]) { + channel.sink.add({ + "type": "error", + "error": RemoteException.serialize(error, stackTrace ?? new Chain.current()) + }); +} diff --git a/lib/src/runner/plugin/platform_helpers.dart b/lib/src/runner/plugin/platform_helpers.dart index b961f5cc7..9a37a9003 100644 --- a/lib/src/runner/plugin/platform_helpers.dart +++ b/lib/src/runner/plugin/platform_helpers.dart @@ -45,6 +45,7 @@ Future deserializeSuite(String path, 'platform': platform.identifier, 'metadata': suiteConfig.metadata.serialize(), 'os': platform == TestPlatform.vm ? currentOS.identifier : null, + 'path': path, 'collectTraces': Configuration.current.reporter == 'json' }); diff --git a/lib/src/runner/remote_listener.dart b/lib/src/runner/remote_listener.dart index 8b67f7c5d..e76dd44c0 100644 --- a/lib/src/runner/remote_listener.dart +++ b/lib/src/runner/remote_listener.dart @@ -78,7 +78,8 @@ class RemoteListener { ? null : OperatingSystem.find(message['os']); var platform = TestPlatform.find(message['platform']); - var suite = new Suite(declarer.build(), platform: platform, os: os); + var suite = new Suite(declarer.build(), + platform: platform, os: os, path: message['path']); new RemoteListener._(suite, printZone)._listen(channel); }, onError: (error, stackTrace) { _sendError(channel, error, stackTrace); @@ -194,6 +195,8 @@ class RemoteListener { }); }); - liveTest.run().then((_) => channel.sink.add({"type": "complete"})); + runZoned(() { + liveTest.run().then((_) => channel.sink.add({"type": "complete"})); + }, zoneValues: {#test.runner.test_channel: channel}); } } diff --git a/lib/src/runner/runner_test.dart b/lib/src/runner/runner_test.dart index 5e5672e90..3ae83d61e 100644 --- a/lib/src/runner/runner_test.dart +++ b/lib/src/runner/runner_test.dart @@ -17,6 +17,7 @@ import '../backend/test.dart'; import '../backend/test_platform.dart'; import '../util/remote_exception.dart'; import '../utils.dart'; +import 'spawn_hybrid.dart'; typedef StackTrace _MapTrace(StackTrace trace); @@ -53,23 +54,37 @@ class RunnerTest extends Test { }); testChannel.stream.listen((message) { - if (message['type'] == 'error') { - var asyncError = RemoteException.deserialize(message['error']); - - var stackTrace = _mapTrace(asyncError.stackTrace); - controller.addError(asyncError.error, stackTrace); - } else if (message['type'] == 'state-change') { - controller.setState( - new State( - new Status.parse(message['status']), - new Result.parse(message['result']))); - } else if (message['type'] == 'message') { - controller.message(new Message( - new MessageType.parse(message['message-type']), - message['text'])); - } else { - assert(message['type'] == 'complete'); - controller.completer.complete(); + switch (message['type']) { + case 'error': + var asyncError = RemoteException.deserialize(message['error']); + var stackTrace = _mapTrace(asyncError.stackTrace); + controller.addError(asyncError.error, stackTrace); + break; + + case 'state-change': + controller.setState( + new State( + new Status.parse(message['status']), + new Result.parse(message['result']))); + break; + + case 'message': + controller.message(new Message( + new MessageType.parse(message['message-type']), + message['text'])); + break; + + case 'complete': + controller.completer.complete(); + break; + + case 'spawn-hybrid-uri': + // When we kill the isolate that the test lives in, that will close + // this virtual channel and cause the spawned isolate to close as + // well. + spawnHybridUri(message['url'], message['message']) + .pipe(testChannel.virtualChannel(message['channel'])); + break; } }, onDone: () { // When the test channel closes—presumably becuase the browser diff --git a/lib/src/runner/spawn_hybrid.dart b/lib/src/runner/spawn_hybrid.dart new file mode 100644 index 000000000..228ff9807 --- /dev/null +++ b/lib/src/runner/spawn_hybrid.dart @@ -0,0 +1,65 @@ +// 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:isolate'; + +import 'package:async/async.dart'; +import 'package:stream_channel/stream_channel.dart'; + +import '../util/dart.dart' as dart; +import "../util/remote_exception.dart"; + +/// Spawns a hybrid isolate from [url] with the given [message], and returns a +/// [StreamChannel] that communicates with it. +/// +/// This connects the main isolate to the hybrid isolate, whereas +/// `lib/src/frontend/spawn_hybrid.dart` connects the test isolate to the main +/// isolate. +StreamChannel spawnHybridUri(String url, Object message) { + return StreamChannelCompleter.fromFuture(() async { + var port = new ReceivePort(); + var onExitPort = new ReceivePort(); + try { + var code = """ + import "package:test/src/runner/hybrid_listener.dart"; + + import "${url.replaceAll(r'$', '%24')}" as lib; + + void main(_, List data) => listen(() => lib.hybridMain, data); + """; + + var isolate = await dart.runInIsolate(code, [port.sendPort, message], + onExit: onExitPort.sendPort); + + // Ensure that we close [port] and [channel] when the isolate exits. + var disconnector = new Disconnector(); + onExitPort.listen((_) { + disconnector.disconnect(); + onExitPort.close(); + }); + + return new IsolateChannel.connectReceive(port) + .transform(disconnector) + .transformSink(new StreamSinkTransformer.fromHandlers( + handleDone: (sink) { + // If the user closes the stream channel, kill the isolate. + isolate.kill(); + onExitPort.close(); + sink.close(); + })); + } catch (error, stackTrace) { + port.close(); + onExitPort.close(); + + // Make sure any errors in spawning the isolate are forwarded to the test. + return new StreamChannel( + new Stream.fromFuture(new Future.value({ + "type": "error", + "error": RemoteException.serialize(error, stackTrace) + })), + new NullStreamSink()); + } + }()); +} diff --git a/lib/src/util/dart.dart b/lib/src/util/dart.dart index e18b50d03..c8cac8ccf 100644 --- a/lib/src/util/dart.dart +++ b/lib/src/util/dart.dart @@ -3,6 +3,7 @@ // BSD-style license that can be found in the LICENSE file. import 'dart:async'; +import 'dart:convert'; import 'dart:isolate'; import 'package:analyzer/analyzer.dart'; @@ -21,15 +22,17 @@ import 'string_literal_iterator.dart'; /// If [resolver] is passed, its package resolution strategy is used to resolve /// code in the spawned isolate. It defaults to [PackageResolver.current]. Future runInIsolate(String code, message, {PackageResolver resolver, - bool checked}) async { + bool checked, SendPort onExit}) async { resolver ??= PackageResolver.current; return await Isolate.spawnUri( - Uri.parse('data:application/dart;charset=utf-8,' + Uri.encodeFull(code)), + new Uri.dataFromString(code, + mimeType: 'application/dart', encoding: UTF8), [], message, packageRoot: await resolver.packageRoot, packageConfig: await resolver.packageConfigUri, - checked: checked); + checked: checked, + onExit: onExit); } // TODO(nweiz): Move this into the analyzer once it starts using SourceSpan diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 1a3ccac0a..dbf9e6357 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -358,3 +358,25 @@ String randomBase64(int bytes, {int seed}) { } return BASE64.encode(data); } + +/// Throws an [ArgumentError] if [message] isn't recursively JSON-safe. +void ensureJsonEncodable(Object message) { + if (message == null || message is String || message is num || + message is bool) { + // JSON-encodable, hooray! + } else if (message is List) { + for (var element in message) { + ensureJsonEncodable(element); + } + } else if (message is Map) { + message.forEach((key, value) { + if (key is! String) { + throw new ArgumentError("$message can't be JSON-encoded."); + } + + ensureJsonEncodable(value); + }); + } else { + throw new ArgumentError.value("$message can't be JSON-encoded."); + } +} diff --git a/lib/test.dart b/lib/test.dart index bb6ebd016..c2040bd97 100644 --- a/lib/test.dart +++ b/lib/test.dart @@ -24,6 +24,7 @@ export 'src/frontend/future_matchers.dart'; export 'src/frontend/on_platform.dart'; export 'src/frontend/prints_matcher.dart'; export 'src/frontend/skip.dart'; +export 'src/frontend/spawn_hybrid.dart'; export 'src/frontend/tags.dart'; export 'src/frontend/test_on.dart'; export 'src/frontend/throws_matcher.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index df8f9fa62..0f8b41e49 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: test -version: 0.12.17+3 +version: 0.12.18-dev author: Dart Team description: A library for writing dart unit tests. homepage: https://github.com/dart-lang/test @@ -36,5 +36,6 @@ dependencies: matcher: '>=0.12.0 <0.12.1' dev_dependencies: fake_async: '^0.1.2' + http: '^0.11.0' js: '^0.6.0' scheduled_test: '^0.12.5' diff --git a/test/runner/hybrid_test.dart b/test/runner/hybrid_test.dart new file mode 100644 index 000000000..0821582f1 --- /dev/null +++ b/test/runner/hybrid_test.dart @@ -0,0 +1,511 @@ +// 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. + +@TestOn("vm") + +import 'dart:async'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:async/async.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as p; +import 'package:scheduled_test/descriptor.dart' as d; +import 'package:scheduled_test/scheduled_process.dart'; +import 'package:scheduled_test/scheduled_stream.dart'; +import 'package:scheduled_test/scheduled_test.dart'; + +import 'package:test/src/util/io.dart'; + +import '../io.dart'; + +void main() { + useSandbox(); + + group("spawnHybridUri():", () { + test("loads a file in a separate isolate connected via StreamChannel", () { + d.file("test.dart", """ + import "package:test/test.dart"; + + void main() { + test("hybrid emits numbers", () { + expect(spawnHybridUri("hybrid.dart").stream.toList(), + completion(equals([1, 2, 3]))); + }); + } + """).create(); + + d.file("hybrid.dart", """ + import "package:stream_channel/stream_channel.dart"; + + void hybridMain(StreamChannel channel) { + channel.sink..add(1)..add(2)..add(3)..close(); + } + """).create(); + + var test = runTest(["test.dart"]); + test.stdout.expect(containsInOrder([ + "+0: hybrid emits numbers", + "+1: All tests passed!" + ])); + test.shouldExit(0); + }); + + test("resolves URIs relative to the test file", () { + d.dir("test/dir/subdir", [ + d.file("test.dart", """ + import "package:test/test.dart"; + + void main() { + test("hybrid emits numbers", () { + expect(spawnHybridUri("hybrid.dart").stream.toList(), + completion(equals([1, 2, 3]))); + }); + } + """), + + d.file("hybrid.dart", """ + import "package:stream_channel/stream_channel.dart"; + + void hybridMain(StreamChannel channel) { + channel.sink..add(1)..add(2)..add(3)..close(); + } + """), + ]).create(); + + var test = runTest(["test/dir/subdir/test.dart"]); + test.stdout.expect(containsInOrder([ + "+0: hybrid emits numbers", + "+1: All tests passed!" + ])); + test.shouldExit(0); + }); + + test("supports absolute file: URIs", () { + var url = p.toUri(p.absolute(p.join(sandbox, 'hybrid.dart'))); + d.file("test.dart", """ + import "package:test/test.dart"; + + void main() { + test("hybrid emits numbers", () { + expect(spawnHybridUri("$url").stream.toList(), + completion(equals([1, 2, 3]))); + }); + } + """).create(); + + d.file("hybrid.dart", """ + import "package:stream_channel/stream_channel.dart"; + + void hybridMain(StreamChannel channel) { + channel.sink..add(1)..add(2)..add(3)..close(); + } + """).create(); + + var test = runTest(["test.dart"]); + test.stdout.expect(containsInOrder([ + "+0: hybrid emits numbers", + "+1: All tests passed!" + ])); + test.shouldExit(0); + }); + + test("supports Uri objects", () { + d.file("test.dart", """ + import "package:test/test.dart"; + + void main() { + test("hybrid emits numbers", () { + expect(spawnHybridUri(Uri.parse("hybrid.dart")).stream.toList(), + completion(equals([1, 2, 3]))); + }); + } + """).create(); + + d.file("hybrid.dart", """ + import "package:stream_channel/stream_channel.dart"; + + void hybridMain(StreamChannel channel) { + channel.sink..add(1)..add(2)..add(3)..close(); + } + """).create(); + + var test = runTest(["test.dart"]); + test.stdout.expect(containsInOrder([ + "+0: hybrid emits numbers", + "+1: All tests passed!" + ])); + test.shouldExit(0); + }); + + test("rejects non-String, non-Uri objects", () { + expect(() => spawnHybridUri(123), throwsArgumentError); + }); + + test("passes a message to the hybrid isolate", () { + d.file("test.dart", """ + import "package:test/test.dart"; + + void main() { + test("hybrid echoes message", () { + expect( + spawnHybridUri(Uri.parse("hybrid.dart"), message: 123) + .stream.first, + completion(equals(123))); + + expect( + spawnHybridUri(Uri.parse("hybrid.dart"), message: "wow") + .stream.first, + completion(equals("wow"))); + }); + } + """).create(); + + d.file("hybrid.dart", """ + import "package:stream_channel/stream_channel.dart"; + + void hybridMain(StreamChannel channel, Object message) { + channel.sink..add(message)..close(); + } + """).create(); + + var test = runTest(["test.dart"]); + test.stdout.expect(containsInOrder([ + "+0: hybrid echoes message", + "+1: All tests passed!" + ])); + test.shouldExit(0); + }); + + test("emits an error from the stream channel if the isolate fails to load", + () { + expect(spawnHybridUri("non existent file").stream.first, + throwsA(new isInstanceOf())); + }); + }); + + group("spawnHybridCode()", () { + test("loads the code in a separate isolate connected via StreamChannel", + () { + expect(spawnHybridCode(""" + import "package:stream_channel/stream_channel.dart"; + + void hybridMain(StreamChannel channel) { + channel.sink..add(1)..add(2)..add(3)..close(); + } + """).stream.toList(), completion(equals([1, 2, 3]))); + }); + + test("can use dart:io even when run from a browser", () { + var path = p.join(sandbox, "test.dart"); + d.file("test.dart", """ + import "package:test/test.dart"; + + void main() { + test("hybrid loads dart:io", () { + expect(spawnHybridCode(''' + import 'dart:io'; + + import 'package:stream_channel/stream_channel.dart'; + + void hybridMain(StreamChannel channel) { + channel.sink + ..add(new File("$path").readAsStringSync()) + ..close(); + } + ''').stream.first, completion(contains("hybrid emits numbers"))); + }); + } + """).create(); + + var test = runTest(["-p", "content-shell", "test.dart"]); + test.stdout.expect(containsInOrder([ + "+0: hybrid loads dart:io", + "+1: All tests passed!" + ])); + test.shouldExit(0); + }, tags: ["content-shell"]); + + test("forwards data from the test to the hybrid isolate", () { + var channel = spawnHybridCode(""" + import "package:stream_channel/stream_channel.dart"; + + void hybridMain(StreamChannel channel) { + channel.stream.listen((num) { + channel.sink.add(num + 1); + }); + } + """); + channel.sink..add(1)..add(2)..add(3); + expect(channel.stream.take(3).toList(), completion(equals([2, 3, 4]))); + }); + + test("passes an initial message to the hybrid isolate", () { + var code = """ + import "package:stream_channel/stream_channel.dart"; + + void hybridMain(StreamChannel channel, Object message) { + channel.sink..add(message)..close(); + } + """; + + expect(spawnHybridCode(code, message: [1, 2, 3]).stream.first, + completion(equals([1, 2, 3]))); + expect(spawnHybridCode(code, message: {"a": "b"}).stream.first, + completion(equals({"a": "b"}))); + }); + + test("persists across multiple tests", () { + d.file("test.dart", """ + import "dart:async"; + + import "package:async/async.dart"; + import "package:stream_channel/stream_channel.dart"; + + import "package:test/test.dart"; + + void main() { + StreamQueue queue; + StreamSink sink; + setUpAll(() { + var channel = spawnHybridCode(''' + import "package:stream_channel/stream_channel.dart"; + + void hybridMain(StreamChannel channel) { + channel.stream.listen((message) { + channel.sink.add(message); + }); + } + '''); + queue = new StreamQueue(channel.stream); + sink = channel.sink; + }); + + test("echoes a number", () { + expect(queue.next, completion(equals(123))); + sink.add(123); + }); + + test("echoes a string", () { + expect(queue.next, completion(equals("wow"))); + sink.add("wow"); + }); + } + """).create(); + + var test = runTest(["-p", "content-shell", "test.dart"]); + test.stdout.expect(containsInOrder([ + "+0: echoes a number", + "+1: echoes a string", + "+2: All tests passed!" + ])); + test.shouldExit(0); + }); + + test("allows the hybrid isolate to send errors across the stream channel", + () { + var channel = spawnHybridCode(""" + import "package:stack_trace/stack_trace.dart"; + import "package:stream_channel/stream_channel.dart"; + + void hybridMain(StreamChannel channel) { + channel.sink.addError("oh no!", new Trace.current()); + } + """); + + channel.stream.listen(null, onError: expectAsync2((error, stackTrace) { + expect(error.toString(), equals("oh no!")); + expect(stackTrace.toString(), contains("hybridMain")); + })); + }); + + test("sends an unhandled synchronous error across the stream channel", () { + var channel = spawnHybridCode(""" + import "package:stream_channel/stream_channel.dart"; + + void hybridMain(StreamChannel channel) { + throw "oh no!"; + } + """); + + channel.stream.listen(null, onError: expectAsync2((error, stackTrace) { + expect(error.toString(), equals("oh no!")); + expect(stackTrace.toString(), contains("hybridMain")); + })); + }); + + test("sends an unhandled asynchronous error across the stream channel", () { + var channel = spawnHybridCode(""" + import 'dart:async'; + + import "package:stream_channel/stream_channel.dart"; + + void hybridMain(StreamChannel channel) { + scheduleMicrotask(() { + throw "oh no!"; + }); + } + """); + + channel.stream.listen(null, onError: expectAsync2((error, stackTrace) { + expect(error.toString(), equals("oh no!")); + expect(stackTrace.toString(), contains("hybridMain")); + })); + }); + + test("deserializes TestFailures as TestFailures", () { + var channel = spawnHybridCode(""" + import "package:stream_channel/stream_channel.dart"; + + import "package:test/test.dart"; + + void hybridMain(StreamChannel channel) { + throw new TestFailure("oh no!"); + } + """); + + expect(channel.stream.first, throwsA(new isInstanceOf())); + }); + + test("gracefully handles an unserializable message in the VM", () { + var channel = spawnHybridCode(""" + import "package:stream_channel/stream_channel.dart"; + + void hybridMain(StreamChannel channel) {} + """); + + expect(() => channel.sink.add([].iterator), throwsArgumentError); + }); + + test("gracefully handles an unserializable message in the browser", () { + var path = p.join(sandbox, "test.dart"); + d.file("test.dart", """ + import "package:test/test.dart"; + + void main() { + test("invalid message to hybrid", () { + var channel = spawnHybridCode(''' + import "package:stream_channel/stream_channel.dart"; + + void hybridMain(StreamChannel channel) {} + '''); + + expect(() => channel.sink.add([].iterator), throwsArgumentError); + }); + } + """).create(); + + var test = runTest(["-p", "content-shell", "test.dart"]); + test.stdout.expect(containsInOrder([ + "+0: invalid message to hybrid", + "+1: All tests passed!" + ])); + test.shouldExit(0); + }, tags: ['content-shell']); + + test("gracefully handles an unserializable message in the hybrid isolate", + () { + var channel = spawnHybridCode(""" + import "package:stream_channel/stream_channel.dart"; + + void hybridMain(StreamChannel channel) { + channel.sink.add([].iterator); + } + """); + + channel.stream.listen(null, onError: expectAsync1((error) { + expect(error.toString(), contains("can't be JSON-encoded.")); + })); + }); + + test("forwards prints from the hybrid isolate", () { + expect(() async { + var channel = spawnHybridCode(""" + import "package:stream_channel/stream_channel.dart"; + + void hybridMain(StreamChannel channel) { + print("hi!"); + channel.sink.add(null); + } + """); + await channel.stream.first; + }, prints("hi!\n")); + }); + + // This takes special handling, since the code is packed into a data: URI + // that's imported, URIs don't escape $ by default, and $ isn't allowed in + // imports. + test("supports a dollar character in the hybrid code", () { + expect(spawnHybridCode(r""" + import "package:stream_channel/stream_channel.dart"; + + void hybridMain(StreamChannel channel) { + var value = "bar"; + channel.sink.add("foo${value}baz"); + } + """).stream.first, completion("foobarbaz")); + }); + + test("kills the isolate when the test closes the channel", () async { + var channel = spawnHybridCode(""" + import "dart:async"; + import "dart:io"; + + import "package:shelf/shelf.dart" as shelf; + import "package:shelf/shelf_io.dart" as io; + import "package:stream_channel/stream_channel.dart"; + + hybridMain(StreamChannel channel) async { + var server = await ServerSocket.bind("localhost", 0); + server.listen(null); + channel.sink.add(server.port); + } + """); + + // Expect that the socket disconnects at some point (presumably when the + // isolate closes). + var port = await channel.stream.first; + var socket = await Socket.connect("localhost", port); + expect(socket.listen(null).asFuture(), completes); + + await channel.sink.close(); + }, skip: "Enable when sdk#28081 is fixed."); + + test("kills the isolate when the hybrid isolate closes the channel", () async { + var channel = spawnHybridCode(""" + import "dart:async"; + import "dart:io"; + + import "package:stream_channel/stream_channel.dart"; + + hybridMain(StreamChannel channel) async { + var server = await ServerSocket.bind("localhost", 0); + server.listen(null); + channel.sink.add(server.port); + await channel.stream.first; + channel.sink.close(); + } + """); + + // Expect that the socket disconnects at some point (presumably when the + // isolate closes). + var port = await channel.stream.first; + var socket = await Socket.connect("localhost", port); + expect(socket.listen(null).asFuture(), completes); + channel.sink.add(null); + }, skip: "Enable when sdk#28081 is fixed."); + + test("closes the channel when the hybrid isolate exits", () { + var channel = spawnHybridCode(""" + import "dart:isolate"; + + hybridMain(_) { + Isolate.current.kill(); + } + """); + + expect(channel.stream.toList(), completion(isEmpty)); + }); + }); +}