Merge pull request #2 from sass/dummy

Add a very basic protocol dispatcher and a few tests
This commit is contained in:
Natalie Weizenbaum 2019-11-01 19:12:21 -07:00 committed by GitHub
commit 6ccc5c3892
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 553 additions and 0 deletions

View File

@ -9,6 +9,24 @@ cache:
directories:
- $HOME/.pub-cache
env:
global:
- PROTOBUF_VERSION=3.10.1
- PATH="$HOME/protoc/bin:$PATH"
before_install:
- if [[ "$TRAVIS_OS_NAME" = windows ]]; then
curl -Lo protoc.zip https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOBUF_VERSION}/protoc-${PROTOBUF_VERSION}-win64.zip;
else
curl -Lo protoc.zip https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOBUF_VERSION}/protoc-${PROTOBUF_VERSION}-${TRAVIS_OS_NAME}-x86_64.zip;
fi
- unzip -d "$HOME/protoc" protoc.zip
before_script:
- pub run grinder protobuf
# Format the generated code or else the formatter task will get upset.
- dartfmt -w --fix lib/src/embedded_sass.pb*
jobs:
include:

View File

@ -0,0 +1,31 @@
// Copyright 2019 Google Inc. Use of this source code is governed by an
// MIT-style license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.
import 'dart:io';
import 'package:stream_channel/stream_channel.dart';
import 'package:sass_embedded/src/dispatcher.dart';
import 'package:sass_embedded/src/embedded_sass.pb.dart';
import 'package:sass_embedded/src/util/length_delimited_transformer.dart';
void main(List<String> args) {
if (args.isNotEmpty) {
stderr.writeln(
"This executable is not intended to be executed with arguments.\n"
"See https://github.com/sass/embedded-protocol#readme for details.");
// USAGE error from https://bit.ly/2poTt90
exitCode = 64;
return;
}
var dispatcher = Dispatcher(
StreamChannel.withGuarantees(stdin, stdout, allowSinkErrors: false)
.transform(lengthDelimited));
dispatcher.listen((request) {
return OutboundMessage_CompileResponse()
..success = (OutboundMessage_CompileResponse_CompileSuccess()
..css = "a {\n b: c;\n}\n");
});
}

120
lib/src/dispatcher.dart Normal file
View File

@ -0,0 +1,120 @@
// Copyright 2019 Google Inc. Use of this source code is governed by an
// MIT-style license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:protobuf/protobuf.dart';
import 'package:stack_trace/stack_trace.dart';
import 'package:stream_channel/stream_channel.dart';
import 'embedded_sass.pb.dart';
/// A class that dispatches messages to and from the host.
class Dispatcher {
/// The channel of encoded protocol buffers, connected to the host.
final StreamChannel<Uint8List> _channel;
/// Creates a [Dispatcher] that sends and receives encoded protocol buffers
/// over [channel].
Dispatcher(this._channel);
/// Listens for incoming `CompileRequests` and passes them to [callback].
///
/// The callback must return a `CompileResponse` which is sent to the host. It
/// doesn't need to set [OutboundMessage_CompileResponse.id]; the [Dispatcher]
/// will take care of that.
///
/// This may only be called once.
void listen(
FutureOr<OutboundMessage_CompileResponse> callback(
InboundMessage_CompileRequest request)) {
_channel.stream.listen((binaryMessage) async {
InboundMessage message;
try {
try {
message = InboundMessage.fromBuffer(binaryMessage);
} on InvalidProtocolBufferException catch (error) {
throw _parseError(error.message);
}
switch (message.whichMessage()) {
case InboundMessage_Message.error:
var error = message.ensureError();
stderr
.write("Host reported ${error.type.name.toLowerCase()} error");
if (error.id != -1) stderr.write(" with request ${error.id}");
stderr.writeln(": ${error.message}");
// SOFTWARE error from https://bit.ly/2poTt90
exitCode = 70;
_channel.sink.close();
break;
case InboundMessage_Message.compileRequest:
var request = message.ensureCompileRequest();
var response = await callback(request);
response.id = request.id;
_send(OutboundMessage()..compileResponse = response);
break;
case InboundMessage_Message.notSet:
// PROTOCOL error from https://bit.ly/2poTt90
exitCode = 76;
throw _parseError("InboundMessage.message is not set.");
default:
// PROTOCOL error from https://bit.ly/2poTt90
exitCode = 76;
throw _parseError(
"Unknown message type: ${message.toDebugString()}");
}
} on ProtocolError catch (error) {
error.id = _messageId(message) ?? -1;
stderr.write("Host caused ${error.type.name.toLowerCase()} error");
if (error.id != -1) stderr.write(" with request ${error.id}");
stderr.writeln(": ${error.message}");
_send(OutboundMessage()..error = error);
} catch (error, stackTrace) {
var errorMessage = "$error\n${Chain.forTrace(stackTrace)}";
stderr.write("Internal compiler error: $errorMessage");
_send(OutboundMessage()
..error = (ProtocolError()
..type = ProtocolError_ErrorType.INTERNAL
..id = _messageId(message) ?? -1
..message = errorMessage));
_channel.sink.close();
}
});
}
/// Sends [message] to the host.
void _send(OutboundMessage message) =>
_channel.sink.add(message.writeToBuffer());
/// Returns a [ProtocolError] with type `PARSE` and the given [message].
ProtocolError _parseError(String message) => ProtocolError()
..type = ProtocolError_ErrorType.PARSE
..message = message;
/// Returns the id for [message] if it it's a request or response, or `null`
/// otherwise.
int _messageId(InboundMessage message) {
if (message == null) return null;
switch (message.whichMessage()) {
case InboundMessage_Message.compileRequest:
return message.ensureCompileRequest().id;
case InboundMessage_Message.canonicalizeResponse:
return message.ensureCanonicalizeResponse().id;
case InboundMessage_Message.importResponse:
return message.ensureImportResponse().id;
case InboundMessage_Message.functionCallRequest:
return message.ensureFunctionCallRequest().id;
case InboundMessage_Message.functionCallResponse:
return message.ensureFunctionCallResponse().id;
default:
return null;
}
}
}

View File

@ -7,9 +7,17 @@ homepage: https://github.com/sass/dart-sass-embedded
environment:
sdk: '>=2.4.0 <3.0.0'
executables:
dart-sass-embedded: dart_sass_embedded
dependencies:
async: ">=1.13.0 <3.0.0"
protobuf: ^1.0.0
stack_trace: ^1.6.0
stream_channel: ">=1.6.0 <3.0.0"
dev_dependencies:
grinder: ^0.8.0
protoc_plugin: ^19.0.0
path: ^1.6.0
test: ^1.0.0

183
test/embedded_process.dart Normal file
View File

@ -0,0 +1,183 @@
// Copyright 2019 Google Inc. Use of this source code is governed by an
// MIT-style license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:async/async.dart';
import 'package:test/test.dart';
import 'package:sass_embedded/src/embedded_sass.pb.dart';
import 'package:sass_embedded/src/util/length_delimited_transformer.dart';
/// A wrapper for [Process] that provides a convenient API for testing the
/// embedded Sass process.
///
/// If the test fails, this will automatically print out any stderr and protocol
/// buffers from the process to aid debugging.
///
/// This API is based on the `test_process` package.
class EmbeddedProcess {
/// The underlying process.
final Process _process;
/// A [StreamQueue] that emits each outbound protocol buffer from the process.
StreamQueue<OutboundMessage> get outbound => _outbound;
StreamQueue<OutboundMessage> _outbound;
/// A [StreamQueue] that emits each line of stderr from the process.
StreamQueue<String> get stderr => _stderr;
StreamQueue<String> _stderr;
/// A splitter that can emit new copies of [outbound].
final StreamSplitter<OutboundMessage> _outboundSplitter;
/// A splitter that can emit new copies of [stderr].
final StreamSplitter<String> _stderrSplitter;
/// A sink into which inbound messages can be passed to the process.
final Sink<InboundMessage> inbound;
/// The raw standard input byte sink.
IOSink get stdin => _process.stdin;
/// A log that includes lines from [stderr] and human-friendly serializations
/// of protocol buffers from [outbound]
final _log = <String>[];
/// Whether [_log] has been passed to [printOnFailure] yet.
bool _loggedOutput = false;
/// Returns a [Future] which completes to the exit code of the process, once
/// it completes.
Future<int> get exitCode => _process.exitCode;
/// The process ID of the process.
int get pid => _process.pid;
/// Completes to [_process]'s exit code if it's exited, otherwise completes to
/// `null` immediately.
Future<int> get _exitCodeOrNull async =>
await exitCode.timeout(Duration.zero, onTimeout: () => null);
/// Starts a process.
///
/// [executable], [workingDirectory], [environment],
/// [includeParentEnvironment], and [runInShell] have the same meaning as for
/// [Process.start].
///
/// If [forwardOutput] is `true`, the process's [outbound] messages and
/// [stderr] will be printed to the console as they appear. This is only
/// intended to be set temporarily to help when debugging test failures.
static Future<EmbeddedProcess> start(
{String workingDirectory,
Map<String, String> environment,
bool includeParentEnvironment = true,
bool runInShell = false,
bool forwardOutput = false}) async {
// TODO(nweiz): Support running from a native executable.
var process = await Process.start(
Platform.executable, ["bin/dart_sass_embedded.dart"],
workingDirectory: workingDirectory,
environment: environment,
includeParentEnvironment: includeParentEnvironment,
runInShell: runInShell);
return EmbeddedProcess._(process, forwardOutput: forwardOutput);
}
/// Creates a [EmbeddedProcess] for [process].
///
/// The [forwardOutput] argument is the same as that to [start].
EmbeddedProcess._(Process process, {bool forwardOutput = false})
: _process = process,
_outboundSplitter = StreamSplitter(process.stdout
.transform(lengthDelimitedDecoder)
.map((message) => OutboundMessage.fromBuffer(message))),
_stderrSplitter = StreamSplitter(process.stderr
.transform(utf8.decoder)
.transform(const LineSplitter())),
inbound = StreamSinkTransformer<InboundMessage, List<int>>.fromHandlers(
handleData: (message, sink) =>
sink.add(message.writeToBuffer())).bind(
StreamSinkTransformer.fromStreamTransformer(lengthDelimitedEncoder)
.bind(process.stdin)) {
addTearDown(_tearDown);
expect(_process.exitCode.then((_) => _logOutput()), completes,
reason: "Process `dart_sass_embedded` never exited.");
_outbound = StreamQueue(_outboundSplitter.split());
_stderr = StreamQueue(_stderrSplitter.split());
_outboundSplitter.split().listen((message) {
for (var line in message.toDebugString().split("\n")) {
if (forwardOutput) print(line);
_log.add(" $line");
}
});
_stderrSplitter.split().listen((line) {
if (forwardOutput) print(line);
_log.add("[e] $line");
});
}
/// A callback that's run when the test completes.
Future _tearDown() async {
// If the process is already dead, do nothing.
if (await _exitCodeOrNull != null) return;
_process.kill(ProcessSignal.sigkill);
// Log output now rather than waiting for the exitCode callback so that
// it's visible even if we time out waiting for the process to die.
await _logOutput();
}
/// Formats the contents of [_log] and passes them to [printOnFailure].
Future _logOutput() async {
if (_loggedOutput) return;
_loggedOutput = true;
var exitCodeOrNull = await _exitCodeOrNull;
// Wait a timer tick to ensure that all available lines have been flushed to
// [_log].
await Future.delayed(Duration.zero);
var buffer = StringBuffer();
buffer.write("Process `dart_sass_embedded` ");
if (exitCodeOrNull == null) {
buffer.write("was killed with SIGKILL in a tear-down.");
} else {
buffer.write("exited with exitCode $exitCodeOrNull.");
}
buffer.writeln(" Output:");
buffer.writeln(_log.join("\n"));
printOnFailure(buffer.toString());
}
/// Kills the process (with SIGKILL on POSIX operating systems), and returns a
/// future that completes once it's dead.
///
/// If this is called after the process is already dead, it does nothing.
Future kill() async {
_process.kill(ProcessSignal.sigkill);
await exitCode;
}
/// Waits for the process to exit, and verifies that the exit code matches
/// [expectedExitCode] (if given).
///
/// If this is called after the process is already dead, it verifies its
/// existing exit code.
Future shouldExit([expectedExitCode]) async {
var exitCode = await this.exitCode;
if (expectedExitCode == null) return;
expect(exitCode, expectedExitCode,
reason: "Process `dart_sass_embedded` had an unexpected exit code.");
}
}

47
test/protocol_test.dart Normal file
View File

@ -0,0 +1,47 @@
// Copyright 2019 Google Inc. Use of this source code is governed by an
// MIT-style license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.
import 'package:test/test.dart';
import 'package:sass_embedded/src/embedded_sass.pb.dart';
import 'embedded_process.dart';
import 'utils.dart';
void main() {
EmbeddedProcess process;
setUp(() async {
process = await EmbeddedProcess.start();
});
group("gracefully handles a protocol error", () {
test("caused by an empty message", () async {
process.inbound.add(InboundMessage());
await expectParseError(process, "InboundMessage.message is not set.");
await process.kill();
});
test("caused by an invalid message", () async {
process.stdin.add([1, 0, 0, 0, 0]);
await expectParseError(
process, "Protocol message contained an invalid tag (zero).");
await process.kill();
});
test("without shutting down the compiler", () async {
process.inbound.add(InboundMessage());
await expectParseError(process, "InboundMessage.message is not set.");
process.inbound.add(compileString("a {b: c}"));
await expectLater(process.outbound, emits(isSuccess("a { b: c; }")));
await process.kill();
});
});
test("compiles a CSS from a string", () async {
process.inbound.add(compileString("a {b: c}"));
await expectLater(process.outbound, emits(isSuccess("a { b: c; }")));
await process.kill();
});
}

61
test/utils.dart Normal file
View File

@ -0,0 +1,61 @@
// Copyright 2019 Google Inc. Use of this source code is governed by an
// MIT-style license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.
import 'package:test/test.dart';
import 'package:sass_embedded/src/embedded_sass.pb.dart';
import 'embedded_process.dart';
/// Returns a [InboundMessage] that compiles the given plain CSS
/// string.
InboundMessage compileString(String css) => InboundMessage()
..compileRequest = (InboundMessage_CompileRequest()
..string = (InboundMessage_CompileRequest_StringInput()..source = css));
/// Asserts that [process] emits a [ProtocolError] parse error with the given
/// [message] on its protobuf stream and prints a notice on stderr.
Future<void> expectParseError(EmbeddedProcess process, message) async {
await expectLater(process.outbound,
emits(isProtocolError(-1, ProtocolError_ErrorType.PARSE, message)));
await expectLater(process.stderr, emits("Host caused parse error: $message"));
}
/// Asserts that an [OutboundMessage] is a [ProtocolError] with the given [id],
/// [type], and optionally [message].
Matcher isProtocolError(int id, ProtocolError_ErrorType type, [message]) =>
predicate((value) {
expect(value, isA<OutboundMessage>());
var outboundMessage = value as OutboundMessage;
expect(outboundMessage.hasError(), isTrue,
reason: "Expected $message to be a ProtocolError");
expect(outboundMessage.error.id, equals(id));
expect(outboundMessage.error.type, equals(type));
if (message != null) expect(outboundMessage.error.message, message);
return true;
});
/// Asserts that [message] is an [OutboundMessage] with a [CompileResponse] and
/// returns it.
OutboundMessage_CompileResponse getCompileResponse(value) {
expect(value, isA<OutboundMessage>());
var message = value as OutboundMessage;
expect(message.hasCompileResponse(), isTrue,
reason: "Expected $message to have a CompileResponse");
return message.compileResponse;
}
/// Asserts that an [OutboundMessage] is a [CompileResponse] with CSS that
/// matches [css].
///
/// If [css] is a [String], this automatically wraps it in
/// [equalsIgnoringWhitespace].
Matcher isSuccess(css) => predicate((value) {
var response = getCompileResponse(value);
expect(response.hasSuccess(), isTrue,
reason: "Expected $response to be successful");
expect(response.success.css,
css is String ? equalsIgnoringWhitespace(css) : css);
return true;
});

43
tool/grind.dart Normal file
View File

@ -0,0 +1,43 @@
// Copyright 2016 Google Inc. Use of this source code is governed by an
// MIT-style license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.
import 'dart:io';
import 'package:grinder/grinder.dart';
import 'utils.dart';
main(List<String> args) => grind(args);
@Task('Compile the protocol buffer definition to a Dart library.')
protobuf() async {
Directory('build').createSync(recursive: true);
// Make sure we use the version of protoc_plugin defined by our pubspec,
// rather than whatever version the developer might have globally installed.
log("Writing protoc-gen-dart");
if (Platform.isWindows) {
File('build/protoc-gen-dart.bat').writeAsStringSync('''
@echo off
pub run protoc_plugin %*
''');
} else {
File('build/protoc-gen-dart')
.writeAsStringSync('pub run protoc_plugin "\$@"');
runProcess('chmod', arguments: ['a+x', 'build/protoc-gen-dart']);
}
await cloneOrPull("git://github.com/sass/embedded-protocol");
await runAsync("protoc",
arguments: [
"-Ibuild/embedded-protocol",
"embedded_sass.proto",
"--dart_out=lib/src/"
],
runOptions: RunOptions(environment: {
"PATH": 'build' +
(Platform.isWindows ? ";" : ":") +
Platform.environment["PATH"]
}));
}

42
tool/utils.dart Normal file
View File

@ -0,0 +1,42 @@
// Copyright 2019 Google Inc. Use of this source code is governed by an
// MIT-style license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.
import 'dart:io';
import 'package:grinder/grinder.dart';
import 'package:path/path.dart' as p;
/// Ensure that the repository at [url] is cloned into the build directory and
/// pointing to the latest master revision.
///
/// Returns the path to the repository.
Future<String> cloneOrPull(String url) async =>
cloneOrCheckout(url, "origin/master");
/// Ensure that the repository at [url] is cloned into the build directory and
/// pointing to [ref].
///
/// Returns the path to the repository.
Future<String> cloneOrCheckout(String url, String ref) async {
var name = p.url.basename(url);
if (p.url.extension(name) == ".git") name = p.url.withoutExtension(name);
var path = p.join("build", name);
if (Directory(p.join(path, '.git')).existsSync()) {
log("Updating $url");
await runAsync("git",
arguments: ["fetch", "origin"], workingDirectory: path);
} else {
delete(Directory(path));
await runAsync("git", arguments: ["clone", url, path]);
await runAsync("git",
arguments: ["config", "advice.detachedHead", "false"],
workingDirectory: path);
}
await runAsync("git", arguments: ["checkout", ref], workingDirectory: path);
log("");
return path;
}