From f77983baa8eb3e0042491590c9dd5bcd49cd53ea Mon Sep 17 00:00:00 2001 From: Adam Barth Date: Wed, 19 Aug 2015 23:12:00 -0700 Subject: [PATCH] Adds experimental `pub run sky_tools:sky_test` command This command uses package:test to run Dart tests with sky_shell. For this to work, we need https://github.com/dart-lang/test/tree/hacky-loader-hook to land. We're also not smart enough to find sky_shell ourselves yet. Instead, we take the path as input using an environment variable. Eventually, we'll be able to get the sky_shell executable from package:sky_engine, but we don't yet ship that executable. --- packages/flutter_tools/bin/sky_test.dart | 11 ++ .../lib/src/test/json_socket.dart | 19 +++ .../flutter_tools/lib/src/test/loader.dart | 131 +++++++++++++++ .../lib/src/test/remote_listener.dart | 153 ++++++++++++++++++ .../lib/src/test/remote_test.dart | 67 ++++++++ packages/flutter_tools/pubspec.yaml | 2 - 6 files changed, 381 insertions(+), 2 deletions(-) create mode 100644 packages/flutter_tools/bin/sky_test.dart create mode 100644 packages/flutter_tools/lib/src/test/json_socket.dart create mode 100644 packages/flutter_tools/lib/src/test/loader.dart create mode 100644 packages/flutter_tools/lib/src/test/remote_listener.dart create mode 100644 packages/flutter_tools/lib/src/test/remote_test.dart diff --git a/packages/flutter_tools/bin/sky_test.dart b/packages/flutter_tools/bin/sky_test.dart new file mode 100644 index 00000000000..30082368d6f --- /dev/null +++ b/packages/flutter_tools/bin/sky_test.dart @@ -0,0 +1,11 @@ +// Copyright 2015 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:test/src/executable.dart' as executable; +import 'package:sky_tools/src/test/loader.dart' as loader; + +main(List args) { + loader.installHook(); + return executable.main(args); +} diff --git a/packages/flutter_tools/lib/src/test/json_socket.dart b/packages/flutter_tools/lib/src/test/json_socket.dart new file mode 100644 index 00000000000..56ee44f61f1 --- /dev/null +++ b/packages/flutter_tools/lib/src/test/json_socket.dart @@ -0,0 +1,19 @@ +// Copyright 2015 The Chromium Authors. 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 'dart:io'; + +class JSONSocket { + JSONSocket(WebSocket socket) + : _socket = socket, stream = socket.map(JSON.decode).asBroadcastStream(); + + final WebSocket _socket; + final Stream stream; + + void send(dynamic data) { + _socket.add(JSON.encode(data)); + } +} diff --git a/packages/flutter_tools/lib/src/test/loader.dart b/packages/flutter_tools/lib/src/test/loader.dart new file mode 100644 index 00000000000..793e9ef4390 --- /dev/null +++ b/packages/flutter_tools/lib/src/test/loader.dart @@ -0,0 +1,131 @@ +// Copyright 2015 The Chromium Authors. 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 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:sky_tools/src/test/json_socket.dart'; +import 'package:sky_tools/src/test/remote_test.dart'; +import 'package:stack_trace/stack_trace.dart'; +import 'package:test/src/backend/metadata.dart'; +import 'package:test/src/backend/test_platform.dart'; +import 'package:test/src/runner/configuration.dart'; +import 'package:test/src/runner/load_exception.dart'; +import 'package:test/src/runner/loader.dart'; +import 'package:test/src/runner/runner_suite.dart'; +import 'package:test/src/runner/vm/environment.dart'; +import 'package:test/src/util/io.dart'; +import 'package:test/src/util/remote_exception.dart'; + +void installHook() { + Loader.loadVMFileHook = _loadVMFile; +} + +final String _kSkyShell = Platform.environment['SKY_SHELL']; +const String _kHost = '127.0.0.1'; +const String _kPath = '/runner'; + +class _ServerInfo { + final String url; + final Future socket; + final HttpServer server; + + _ServerInfo(this.server, this.url, this.socket); +} + +Future<_ServerInfo> _createServer() async { + HttpServer server = await HttpServer.bind(_kHost, 0); + Completer socket = new Completer(); + server.listen((HttpRequest request) { + if (request.uri.path == _kPath) + socket.complete(WebSocketTransformer.upgrade(request)); + }); + return new _ServerInfo(server, 'ws://$_kHost:${server.port}$_kPath', socket.future); +} + +Future _startProcess(String path, { String packageRoot }) { + assert(_kSkyShell != null); // Please provide the path to the shell in the SKY_SHELL environment variable. + return Process.start(_kSkyShell, [ + '--enable-checked-mode', + '--non-interactive', + '--package-root=$packageRoot', + path, + ]); +} + +Future _loadVMFile(String path, + Metadata metadata, + Configuration config) async { + String encodedMetadata = Uri.encodeComponent(JSON.encode( + metadata.serialize())); + _ServerInfo info = await _createServer(); + Directory tempDir = await Directory.systemTemp.createTemp( + 'dart_test_listener'); + File listenerFile = new File('${tempDir.path}/listener.dart'); + await listenerFile.create(); + await listenerFile.writeAsString(''' +import 'dart:convert'; + +import 'package:test/src/backend/metadata.dart'; +import 'package:sky_tools/src/test/remote_listener.dart'; + +import '${p.toUri(p.absolute(path))}' as test; + +void main() { + String server = Uri.decodeComponent('${Uri.encodeComponent(info.url)}'); + Metadata metadata = new Metadata.deserialize( + JSON.decode(Uri.decodeComponent('$encodedMetadata'))); + RemoteListener.start(server, metadata, () => test.main); +} +'''); + + Process process = await _startProcess(listenerFile.path, + packageRoot: p.absolute(config.packageRoot)); + + JSONSocket socket = new JSONSocket(await info.socket); + + await tempDir.delete(recursive: true); + + void shutdown() { + process.kill(); + info.server.close(force: true); + } + + var completer = new Completer(); + + StreamSubscription subscription; + subscription = socket.stream.listen((response) { + if (response["type"] == "print") { + print(response["line"]); + } else if (response["type"] == "loadException") { + shutdown(); + completer.completeError( + new LoadException(path, response["message"]), + new Trace.current()); + } else if (response["type"] == "error") { + shutdown(); + var asyncError = RemoteException.deserialize(response["error"]); + completer.completeError( + new LoadException(path, asyncError.error), + asyncError.stackTrace); + } else { + assert(response["type"] == "success"); + subscription.cancel(); + completer.complete(response["tests"]); + } + }); + + return new RunnerSuite(const VMEnvironment(), + (await completer.future).map((test) { + var testMetadata = new Metadata.deserialize(test['metadata']); + return new RemoteTest(test['name'], testMetadata, socket, test['index']); + }), + metadata: metadata, + path: path, + platform: TestPlatform.vm, + os: currentOS, + onClose: shutdown); +} diff --git a/packages/flutter_tools/lib/src/test/remote_listener.dart b/packages/flutter_tools/lib/src/test/remote_listener.dart new file mode 100644 index 00000000000..838e21d5eb8 --- /dev/null +++ b/packages/flutter_tools/lib/src/test/remote_listener.dart @@ -0,0 +1,153 @@ +// Copyright 2015 The Chromium Authors. 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 'dart:io'; +import 'dart:isolate'; + +import 'package:stack_trace/stack_trace.dart'; +import 'package:test/src/backend/declarer.dart'; +import 'package:test/src/backend/live_test.dart'; +import 'package:test/src/backend/metadata.dart'; +import 'package:test/src/backend/operating_system.dart'; +import 'package:test/src/backend/suite.dart'; +import 'package:test/src/backend/test_platform.dart'; +import 'package:test/src/backend/test.dart'; +import 'package:test/src/util/remote_exception.dart'; + +final OperatingSystem currentOS = (() { + var name = Platform.operatingSystem; + var os = OperatingSystem.findByIoName(name); + if (os != null) return os; + + throw new UnsupportedError('Unsupported operating system "$name".'); +})(); + +typedef AsyncFunction(); + +class RemoteListener { + final Suite _suite; + final WebSocket _socket; + LiveTest _liveTest; + + static Future start(String server, Metadata metadata, Function getMain()) async { + WebSocket socket = await WebSocket.connect(server); + // Capture any top-level errors (mostly lazy syntax errors, since other are + // caught below) and report them to the parent isolate. We set errors + // non-fatal because otherwise they'll be double-printed. + var errorPort = new ReceivePort(); + Isolate.current.setErrorsFatal(false); + Isolate.current.addErrorListener(errorPort.sendPort); + errorPort.listen((message) { + // Masquerade as an IsoalteSpawnException because that's what this would + // be if the error had been detected statically. + var error = new IsolateSpawnException(message[0]); + var stackTrace = + message[1] == null ? new Trace([]) : new Trace.parse(message[1]); + socket.add(JSON.encode({ + "type": "error", + "error": RemoteException.serialize(error, stackTrace) + })); + }); + + var main; + try { + main = getMain(); + } on NoSuchMethodError catch (_) { + _sendLoadException(socket, "No top-level main() function defined."); + return; + } + + if (main is! Function) { + _sendLoadException(socket, "Top-level main getter is not a function."); + return; + } else if (main is! AsyncFunction) { + _sendLoadException( + socket, "Top-level main() function takes arguments."); + return; + } + + var declarer = new Declarer(); + try { + await runZoned(() => new Future.sync(main), zoneValues: { + #test.declarer: declarer + }, zoneSpecification: new ZoneSpecification(print: (_, __, ___, line) { + socket.add(JSON.encode({"type": "print", "line": line})); + })); + } catch (error, stackTrace) { + socket.add(JSON.encode({ + "type": "error", + "error": RemoteException.serialize(error, stackTrace) + })); + return; + } + + Suite suite = new Suite(declarer.tests, + platform: TestPlatform.vm, os: currentOS, metadata: metadata); + new RemoteListener._(suite, socket)._listen(); + } + + static void _sendLoadException(WebSocket socket, String message) { + socket.add(JSON.encode({"type": "loadException", "message": message})); + } + + RemoteListener._(this._suite, this._socket); + + void _send(data) { + _socket.add(JSON.encode(data)); + } + + void _listen() { + List tests = []; + for (var i = 0; i < _suite.tests.length; i++) { + Test test = _suite.tests[i]; + tests.add({ + "name": test.name, + "metadata": test.metadata.serialize(), + "index": i, + }); + } + + _send({"type": "success", "tests": tests}); + _socket.listen(_handleCommand); + } + + void _handleCommand(String data) { + var message = JSON.decode(data); + if (message['command'] == 'run') { + assert(_liveTest == null); + Test test = _suite.tests[message['index']]; + _liveTest = test.load(_suite); + + _liveTest.onStateChange.listen((state) { + _send({ + "type": "state-change", + "status": state.status.name, + "result": state.result.name + }); + }); + + _liveTest.onError.listen((asyncError) { + _send({ + "type": "error", + "error": RemoteException.serialize( + asyncError.error, asyncError.stackTrace) + }); + }); + + _liveTest.onPrint.listen((line) { + _send({"type": "print", "line": line}); + }); + + _liveTest.run().then((_) { + _send({"type": "complete"}); + _liveTest = null; + }); + } else if (message['command'] == 'close') { + _liveTest.close(); + _liveTest = null; + } + } +} diff --git a/packages/flutter_tools/lib/src/test/remote_test.dart b/packages/flutter_tools/lib/src/test/remote_test.dart new file mode 100644 index 00000000000..152c14375cd --- /dev/null +++ b/packages/flutter_tools/lib/src/test/remote_test.dart @@ -0,0 +1,67 @@ +// Copyright 2015 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:test/src/backend/live_test.dart'; +import 'package:test/src/backend/live_test_controller.dart'; +import 'package:test/src/backend/metadata.dart'; +import 'package:test/src/backend/state.dart'; +import 'package:test/src/backend/suite.dart'; +import 'package:test/src/backend/test.dart'; +import 'package:test/src/util/remote_exception.dart'; + +import 'package:sky_tools/src/test/json_socket.dart'; + +class RemoteTest implements Test { + final String name; + final Metadata metadata; + + final JSONSocket _socket; + final int _index; + + RemoteTest(this.name, this.metadata, this._socket, this._index); + + LiveTest load(Suite suite) { + var controller; + var subscription; + + controller = new LiveTestController(suite, this, () { + controller.setState(const State(Status.running, Result.success)); + + _socket.send({'command': 'run', 'index': _index}); + + subscription = _socket.stream.listen((message) { + if (message['type'] == 'error') { + var asyncError = RemoteException.deserialize(message['error']); + controller.addError(asyncError.error, asyncError.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'] == 'print') { + controller.print(message['line']); + } else { + assert(message['type'] == 'complete'); + subscription.cancel(); + subscription = null; + controller.completer.complete(); + } + }); + }, () { + _socket.send({'command': 'close'}); + if (subscription != null) { + subscription.cancel(); + subscription = null; + } + }); + return controller.liveTest; + } + + Test change({String name, Metadata metadata}) { + if (name == name && metadata == this.metadata) return this; + if (name == null) name = this.name; + if (metadata == null) metadata = this.metadata; + return new RemoteTest(name, metadata, _socket, _index); + } +} diff --git a/packages/flutter_tools/pubspec.yaml b/packages/flutter_tools/pubspec.yaml index aa57a6bdd47..75c3fed5932 100644 --- a/packages/flutter_tools/pubspec.yaml +++ b/packages/flutter_tools/pubspec.yaml @@ -14,8 +14,6 @@ dependencies: shelf: ^0.6.2 shelf_route: ^0.13.4 shelf_static: ^0.2.3 - -dev_dependencies: test: ^0.12.0 # Add the bin/sky_tools.dart script to the scripts pub installs.