-
Notifications
You must be signed in to change notification settings - Fork 218
Add support for hybrid VM/browser tests. #509
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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]. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what is the benefit of having an untyped There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can be URI or String. We need union types. 😄 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. More specifically, in practice URLs are represented as Strings and Uri objects with similar frequency in Dart. I try to make my APIs handle either representation to avoid making my users write lots of annoying |
||
/// | ||
/// 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we need two ways to pass data? If there is initial data can we send it as the first value through the stream channel? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can, but the Stream API doesn't make it particularly easy to get the first event without also canceling the stream subscription. This also mirrors the Isolate spawn APIs more closely. |
||
/// 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}) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. DBC: This seems cool – but feel scary. Is there a strong case for this over just giving folks the URI-based method? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you elaborate on "scary"? This is useful for the common situation where the hybrid isolate is very simple. It lets users keep the full definition of their test in one file. |
||
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); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what happens if this throws? Should we be putting an error on the sink? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is called synchronously from the user's |
||
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()) | ||
}); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can this doc mention the 'print' functionality?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.