Skip to content

Commit ca61e4a

Browse files
committed
Add support for hybrid VM/browser tests.
This adds `spawnHybridUri()` and `spawnHybridCode()` methods that spawn VM isolates even when called from the browser. Closes #109
1 parent a873b14 commit ca61e4a

14 files changed

+980
-24
lines changed

CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## 0.12.18
2+
3+
* Add the `spawnHybridUri()` and `spawnHybridCode()` functions, which allow
4+
browser tests to run code on the VM.
5+
16
## 0.12.17+3
27

38
* Internal changes only.

README.md

+76-1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
* [Whole-Package Configuration](#whole-package-configuration)
1515
* [Tagging Tests](#tagging-tests)
1616
* [Debugging](#debugging)
17+
* [Browser/VM Hybrid Tests](#browser-vm-hybrid-tests)
1718
* [Testing with `barback`](#testing-with-barback)
1819
* [Further Reading](#further-reading)
1920

@@ -126,7 +127,7 @@ void main() {
126127

127128
A single test file can be run just using `pub run test path/to/test.dart`.
128129

129-
![Single file being run via pub run"](https://raw.githubusercontent.com/dart-lang/test/master/image/test1.gif)
130+
![Single file being run via "pub run"](https://raw.githubusercontent.com/dart-lang/test/master/image/test1.gif)
130131

131132
Many tests can be run at a time using `pub run test path/to/dir`.
132133

@@ -605,6 +606,80 @@ can see and interact with any HTML it renders. Note that the Dart animation may
605606
still be visible behind the iframe; to hide it, just add a `background-color` to
606607
the page's HTML.
607608

609+
## Browser/VM Hybrid Tests
610+
611+
Code that's written for the browser often needs to talk to some kind of server.
612+
Maybe you're testing the HTML served by your app, or maybe you're writing a
613+
library that communicates over WebSockets. We call tests that run code on both
614+
the browser and the VM **hybrid tests**.
615+
616+
Hybrid tests use one of two functions: [`spawnHybridCode()`][spawnHybridUri] and
617+
[`spawnHybridUri()`][spawnHybridCode]. Both of these spawn Dart VM
618+
[isolates][dart:isolate] that can import `dart:io` and other VM-only libraries.
619+
The only difference is where the code from the isolate comes from:
620+
`spawnHybridCode()` takes a chunk of actual Dart code, whereas
621+
`spawnHybridUri()` takes a URL. They both return a
622+
[`StreamChannel`][StreamChannel] that communicates with the hybrid isolate. For
623+
example:
624+
625+
[spawnHybridUri]: http://www.dartdocs.org/documentation/test/latest/index.html#test/test@id_spawnHybridUri
626+
[spawnHybridCode]: http://www.dartdocs.org/documentation/test/latest/index.html#test/test@id_spawnHybridCode
627+
[dart:isolate]: https://api.dartlang.org/stable/latest/dart-isolate/dart-isolate-library.html
628+
[StreamChannel]: https://pub.dartlang.org/packages/stream_channel
629+
630+
```dart
631+
// ## test/web_socket_server.dart
632+
633+
// The library loaded by spawnHybridUri() can import any packages that your
634+
// package depends on, including those that only work on the VM.
635+
import "package:shelf/shelf_io.dart" as io;
636+
import "package:shelf_web_socket/shelf_web_socket.dart";
637+
import "package:stream_channel/stream_channel.dart";
638+
639+
// Once the hybrid isolate starts, it will call the special function
640+
// hybridMain() with a StreamChannel that's connected to the channel
641+
// returned spawnHybridCode().
642+
hybridMain(StreamChannel channel) async {
643+
// Start a WebSocket server that just sends "hello!" to its clients.
644+
var server = await io.serve(webSocketHandler((webSocket) {
645+
webSocket.sink.add("hello!");
646+
}), 'localhost', 0);
647+
648+
// Send the port number of the WebSocket server to the browser test, so
649+
// it knows what to connect to.
650+
channel.sink.add(server.port);
651+
}
652+
653+
654+
// ## test/web_socket_test.dart
655+
656+
@TestOn("browser")
657+
658+
import "dart:html";
659+
660+
import "package:test/test.dart";
661+
662+
void main() {
663+
test("connects to a server-side WebSocket", () async {
664+
// Each spawnHybrid function returns a StreamChannel that communicates with
665+
// the hybrid isolate. You can close this channel to kill the isolate.
666+
var channel = spawnHybridUri("web_socket_server.dart");
667+
668+
// Get the port for the WebSocket server from the hybrid isolate.
669+
var port = await channel.stream.first;
670+
671+
var socket = new WebSocket('ws://localhost:$port');
672+
var message = await socket.onMessage.first;
673+
expect(message.data, equals("hello!"));
674+
});
675+
}
676+
```
677+
678+
![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)
679+
680+
**Note**: If you write hybrid tests, be sure to add a dependency on the
681+
`stream_channel` package, since you're using its API!
682+
608683
## Testing With `barback`
609684

610685
Packages using the `barback` transformer system may need to test code that's

image/hybrid.png

59 KB
Loading

lib/src/frontend/spawn_hybrid.dart

+166
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// Copyright (c) 2016, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'dart:async';
6+
import 'dart:convert';
7+
8+
import 'package:async/async.dart';
9+
import 'package:path/path.dart' as p;
10+
import 'package:stream_channel/stream_channel.dart';
11+
12+
import '../backend/invoker.dart';
13+
import '../util/remote_exception.dart';
14+
import '../utils.dart';
15+
16+
/// A transformer that handles messages from the spawned isolate and ensures
17+
/// that messages sent to it are JSON-encodable.
18+
final _transformer = new StreamChannelTransformer(
19+
new StreamTransformer.fromHandlers(handleData: (message, sink) {
20+
switch (message['type']) {
21+
case 'data':
22+
sink.add(message['data']);
23+
break;
24+
25+
case 'print':
26+
print(message['line']);
27+
break;
28+
29+
case 'error':
30+
var error = RemoteException.deserialize(message['error']);
31+
sink.addError(error.error, error.stackTrace);
32+
break;
33+
}
34+
}), new StreamSinkTransformer.fromHandlers(handleData: (message, sink) {
35+
ensureJsonEncodable(message);
36+
sink.add(message);
37+
}));
38+
39+
/// Spawns a VM isolate for the given [uri], which may be a [Uri] or a [String].
40+
///
41+
/// This allows browser tests to spawn servers with which they can communicate
42+
/// to test client/server interactions. It can also be used by VM tests to
43+
/// easily spawn an isolate.
44+
///
45+
/// The Dart file at [uri] must define a top-level `hybridMain()` function that
46+
/// takes a `StreamChannel` argument and, optionally, an `Object` argument to
47+
/// which [message] will be passed. Note that [message] must be JSON-encodable.
48+
/// For example:
49+
///
50+
/// ```dart
51+
/// import "package:stream_channel/stream_channel.dart";
52+
///
53+
/// hybridMain(StreamChannel channel, Object message) {
54+
/// // ...
55+
/// }
56+
/// ```
57+
///
58+
/// If [uri] is relative, it will be interpreted relative to the `file:` URL for
59+
/// the test suite being executed. If it's a `package:` URL, it will be resolved
60+
/// using the current package's dependency constellation.
61+
///
62+
/// Returns a [StreamChannel] that's connected to the channel passed to
63+
/// `hybridMain()`. Only JSON-encodable objects may be sent through this
64+
/// channel. If the channel is closed, the hybrid isolate is killed. If the
65+
/// isolate is killed, the channel's stream will emit a "done" event.
66+
///
67+
/// Any unhandled errors loading or running the hybrid isolate will be emitted
68+
/// as errors over the channel's stream. Any calls to `print()` in the hybrid
69+
/// isolate will be printed as though they came from the test that created the
70+
/// isolate.
71+
///
72+
/// Code in the hybrid isolate is not considered to be running in a test
73+
/// context, so it can't access test functions like `expect()` and
74+
/// `expectAsync()`.
75+
///
76+
/// **Note**: If you use this API, be sure to add a dependency on the
77+
/// **`stream_channel` package, since you're using its API as well!
78+
StreamChannel spawnHybridUri(uri, {Object message}) {
79+
Uri parsedUrl;
80+
if (uri is Uri) {
81+
parsedUrl = uri;
82+
} else if (uri is String) {
83+
parsedUrl = Uri.parse(uri);
84+
} else {
85+
throw new ArgumentError.value(uri, "uri", "must be a Uri or a String.");
86+
}
87+
88+
String uriString;
89+
if (parsedUrl.scheme.isEmpty) {
90+
var suitePath = Invoker.current.liveTest.suite.path;
91+
uriString = p.url.join(
92+
p.url.dirname(p.toUri(p.absolute(suitePath)).toString()),
93+
parsedUrl.toString());
94+
} else {
95+
uriString = uri.toString();
96+
}
97+
98+
return _spawn(uriString, message);
99+
}
100+
101+
/// Spawns a VM isolate that runs the given [dartCode], which is loaded as the
102+
/// contents of a Dart library.
103+
///
104+
/// This allows browser tests to spawn servers with which they can communicate
105+
/// to test client/server interactions. It can also be used by VM tests to
106+
/// easily spawn an isolate.
107+
///
108+
/// The [dartCode] must define a top-level `hybridMain()` function that takes a
109+
/// `StreamChannel` argument and, optionally, an `Object` argument to which
110+
/// [message] will be passed. Note that [message] must be JSON-encodable. For
111+
/// example:
112+
///
113+
/// ```dart
114+
/// import "package:stream_channel/stream_channel.dart";
115+
///
116+
/// hybridMain(StreamChannel channel, Object message) {
117+
/// // ...
118+
/// }
119+
/// ```
120+
///
121+
/// Returns a [StreamChannel] that's connected to the channel passed to
122+
/// `hybridMain()`. Only JSON-encodable objects may be sent through this
123+
/// channel. If the channel is closed, the hybrid isolate is killed. If the
124+
/// isolate is killed, the channel's stream will emit a "done" event.
125+
///
126+
/// Any unhandled errors loading or running the hybrid isolate will be emitted
127+
/// as errors over the channel's stream. Any calls to `print()` in the hybrid
128+
/// isolate will be printed as though they came from the test that created the
129+
/// isolate.
130+
///
131+
/// Code in the hybrid isolate is not considered to be running in a test
132+
/// context, so it can't access test functions like `expect()` and
133+
/// `expectAsync()`.
134+
///
135+
/// **Note**: If you use this API, be sure to add a dependency on the
136+
/// **`stream_channel` package, since you're using its API as well!
137+
StreamChannel spawnHybridCode(String dartCode, {Object message}) {
138+
var uri = new Uri.dataFromString(dartCode,
139+
encoding: UTF8, mimeType: 'application/dart');
140+
return _spawn(uri.toString(), message);
141+
}
142+
143+
/// Like [spawnHybridUri], but doesn't take [Uri] objects and doesn't handle
144+
/// relative URLs.
145+
StreamChannel _spawn(String uri, Object message) {
146+
var channel = Zone.current[#test.runner.test_channel] as MultiChannel;
147+
if (channel == null) {
148+
// TODO(nweiz): Link to an issue tracking support when running the test file
149+
// directly.
150+
throw new UnsupportedError(
151+
"Can't connect to the test runner.\n"
152+
'spawnHybridUri() is currently only supported within "pub run test".');
153+
}
154+
155+
ensureJsonEncodable(message);
156+
157+
var isolateChannel = channel.virtualChannel();
158+
channel.sink.add({
159+
"type": "spawn-hybrid-uri",
160+
"url": uri,
161+
"message": message,
162+
"channel": isolateChannel.id
163+
});
164+
165+
return isolateChannel.transform(_transformer);
166+
}

lib/src/runner/hybrid_listener.dart

+88
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Copyright (c) 2016, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import "dart:async";
6+
import "dart:isolate";
7+
8+
import "package:async/async.dart";
9+
import "package:stack_trace/stack_trace.dart";
10+
import "package:stream_channel/stream_channel.dart";
11+
12+
import "../util/remote_exception.dart";
13+
import "../utils.dart";
14+
15+
/// A sink transformer that wraps data and error events so that errors can be
16+
/// decoded after being JSON-serialized.
17+
final _transformer = new StreamSinkTransformer.fromHandlers(
18+
handleData: (data, sink) {
19+
ensureJsonEncodable(data);
20+
sink.add({"type": "data", "data": data});
21+
}, handleError: (error, stackTrace, sink) {
22+
sink.add({
23+
"type": "error",
24+
"error": RemoteException.serialize(error, stackTrace)
25+
});
26+
});
27+
28+
/// Runs the body of a hybrid isolate and communicates its messages, errors, and
29+
/// prints to the main isolate.
30+
///
31+
/// The [getMain] function returns the `hybridMain()` method. It's wrapped in a
32+
/// closure so that, if the method undefined, we can catch the error and notify
33+
/// the caller of it.
34+
///
35+
/// The [data] argument contains two values: a [SendPort] that communicates with
36+
/// the main isolate, and a message to pass to `hybridMain()`.
37+
void listen(AsyncFunction getMain(), List data) {
38+
var channel = new IsolateChannel.connectSend(data.first as SendPort);
39+
var message = data.last;
40+
41+
Chain.capture(() {
42+
runZoned(() {
43+
var main;
44+
try {
45+
main = getMain();
46+
} on NoSuchMethodError catch (_) {
47+
_sendError(channel, "No top-level hybridMain() function defined.");
48+
return;
49+
} catch (error, stackTrace) {
50+
_sendError(channel, error, stackTrace);
51+
return;
52+
}
53+
54+
if (main is! Function) {
55+
_sendError(channel, "Top-level hybridMain getter is not a function.");
56+
return;
57+
} else if (main is! ZoneUnaryCallback && main is! ZoneBinaryCallback) {
58+
_sendError(channel,
59+
"Top-level hybridMain() function must take one or two arguments.");
60+
return;
61+
}
62+
63+
// Wrap [channel] before passing it to user code so that we can wrap
64+
// errors and distinguish user data events from control events sent by the
65+
// listener.
66+
var transformedChannel = channel.transformSink(_transformer);
67+
if (main is ZoneUnaryCallback) {
68+
main(transformedChannel);
69+
} else {
70+
main(transformedChannel, message);
71+
}
72+
}, zoneSpecification: new ZoneSpecification(print: (_, __, ___, line) {
73+
channel.sink.add({"type": "print", "line": line});
74+
}));
75+
}, onError: (error, stackTrace) async {
76+
_sendError(channel, error, stackTrace);
77+
await channel.sink.close();
78+
Zone.current.handleUncaughtError(error, stackTrace);
79+
});
80+
}
81+
82+
/// Sends a message over [channel] indicating an error from user code.
83+
void _sendError(StreamChannel channel, error, [StackTrace stackTrace]) {
84+
channel.sink.add({
85+
"type": "error",
86+
"error": RemoteException.serialize(error, stackTrace ?? new Chain.current())
87+
});
88+
}

lib/src/runner/plugin/platform_helpers.dart

+1
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ Future<RunnerSuiteController> deserializeSuite(String path,
4545
'platform': platform.identifier,
4646
'metadata': suiteConfig.metadata.serialize(),
4747
'os': platform == TestPlatform.vm ? currentOS.identifier : null,
48+
'path': path,
4849
'collectTraces': Configuration.current.reporter == 'json'
4950
});
5051

lib/src/runner/remote_listener.dart

+5-2
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,8 @@ class RemoteListener {
7878
? null
7979
: OperatingSystem.find(message['os']);
8080
var platform = TestPlatform.find(message['platform']);
81-
var suite = new Suite(declarer.build(), platform: platform, os: os);
81+
var suite = new Suite(declarer.build(),
82+
platform: platform, os: os, path: message['path']);
8283
new RemoteListener._(suite, printZone)._listen(channel);
8384
}, onError: (error, stackTrace) {
8485
_sendError(channel, error, stackTrace);
@@ -194,6 +195,8 @@ class RemoteListener {
194195
});
195196
});
196197

197-
liveTest.run().then((_) => channel.sink.add({"type": "complete"}));
198+
runZoned(() {
199+
liveTest.run().then((_) => channel.sink.add({"type": "complete"}));
200+
}, zoneValues: {#test.runner.test_channel: channel});
198201
}
199202
}

0 commit comments

Comments
 (0)