diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53ec775e9..5e285a68e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -281,7 +281,7 @@ jobs: github-token: ${{ github.token }} node-version: ${{ matrix.node-version }} - - run: dart run grinder before-test + - run: dart run grinder pkg-npm-dev - name: Run tests run: dart run test -t node -j 2 @@ -302,7 +302,7 @@ jobs: dart-sdk: ${{ matrix.dart_channel }} github-token: ${{ github.token }} - - run: dart run grinder before-test + - run: dart run grinder pkg-npm-dev - name: Run tests run: dart run test -p chrome -j 2 env: diff --git a/lib/src/embedded/dispatcher.dart b/lib/src/embedded/dispatcher.dart index a895b9f03..0e9c6899f 100644 --- a/lib/src/embedded/dispatcher.dart +++ b/lib/src/embedded/dispatcher.dart @@ -3,102 +3,123 @@ // https://opensource.org/licenses/MIT. import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; +import 'package:path/path.dart' as p; import 'package:protobuf/protobuf.dart'; +import 'package:sass/sass.dart' as sass; import 'package:stack_trace/stack_trace.dart'; import 'package:stream_channel/stream_channel.dart'; import 'embedded_sass.pb.dart'; +import 'function_registry.dart'; +import 'host_callable.dart'; +import 'importer/file.dart'; +import 'importer/host.dart'; +import 'logger.dart'; +import 'util/proto_extensions.dart'; import 'utils.dart'; -/// A class that dispatches messages to and from the host. +/// The request ID used for all outbound requests. +/// +/// Since the dispatcher runs a single-threaded compilation, it will only ever +/// have one active request at a time, so there's no need to vary the ID. +final _outboundRequestId = 0; + +/// A class that dispatches messages to and from the host for a single +/// compilation. class Dispatcher { /// The channel of encoded protocol buffers, connected to the host. final StreamChannel _channel; - /// Completers awaiting responses to outbound requests. + /// The compilation ID for which this dispatcher is running. + /// + /// This is added to outgoing messages but is _not_ parsed from incoming + /// messages, since that's already handled by the [IsolateDispatcher]. + final int _compilationId; + + /// [_compilationId], serialized as a varint. + final Uint8List _compilationIdVarint; + + /// Whether this dispatcher has received its compile request. + var _compiling = false; + + /// A completer awaiting a response to an outbound request. /// - /// The completers are located at indexes in this list matching the request - /// IDs. `null` elements indicate IDs whose requests have been responded to, - /// and which are therefore free to re-use. - final _outstandingRequests = ?>[]; + /// Since each [Dispatcher] is only running a single-threaded compilation, it + /// can only ever have one request outstanding. + Completer? _outstandingRequest; /// Creates a [Dispatcher] that sends and receives encoded protocol buffers /// over [channel]. - Dispatcher(this._channel); + Dispatcher(this._channel, this._compilationId) + : _compilationIdVarint = serializeVarint(_compilationId); - /// Listens for incoming `CompileRequests` and passes them to [callback]. + /// Listens for incoming `CompileRequests` and runs their compilations. /// - /// The callback must return a `CompileResponse` which is sent to the host. - /// The callback may throw [ProtocolError]s, which will be sent back to the - /// host. Neither `CompileResponse`s nor [ProtocolError]s need to set their - /// `id` fields; the [Dispatcher] will take care of that. - /// - /// This may only be called once. - void listen( - FutureOr callback( - InboundMessage_CompileRequest request)) { - _channel.stream.listen((binaryMessage) async { + /// This may only be called once. Returns whether or not the compilation + /// succeeded. + Future listen() async { + var success = false; + await _channel.stream.listen((binaryMessage) async { // Wait a single microtask tick so that we're running in a separate // microtask from the initial request dispatch. Otherwise, [waitFor] will // deadlock the event loop fiber that would otherwise be checking stdin // for new input. await Future.value(); - InboundMessage? message; try { + InboundMessage? message; try { message = InboundMessage.fromBuffer(binaryMessage); } on InvalidProtocolBufferException catch (error) { - throw _parseError(error.message); + throw parseError(error.message); } switch (message.whichMessage()) { case InboundMessage_Message.versionRequest: - var request = message.versionRequest; - var response = versionResponse(); - response.id = request.id; - _send(OutboundMessage()..versionResponse = response); - break; + throw paramsError("VersionRequest must have compilation ID 0."); case InboundMessage_Message.compileRequest: + if (_compiling) { + throw paramsError( + "A CompileRequest with compilation ID $_compilationId is " + "already active."); + } + _compiling = true; + var request = message.compileRequest; - var response = await callback(request); - response.id = request.id; + var response = await _compile(request); _send(OutboundMessage()..compileResponse = response); - break; + success = true; + // Each Dispatcher runs a single compilation and then closes. + _channel.sink.close(); case InboundMessage_Message.canonicalizeResponse: - var response = message.canonicalizeResponse; - _dispatchResponse(response.id, response); - break; + _dispatchResponse(message.id, message.canonicalizeResponse); case InboundMessage_Message.importResponse: - var response = message.importResponse; - _dispatchResponse(response.id, response); - break; + _dispatchResponse(message.id, message.importResponse); case InboundMessage_Message.fileImportResponse: - var response = message.fileImportResponse; - _dispatchResponse(response.id, response); - break; + _dispatchResponse(message.id, message.fileImportResponse); case InboundMessage_Message.functionCallResponse: - var response = message.functionCallResponse; - _dispatchResponse(response.id, response); - break; + _dispatchResponse(message.id, message.functionCallResponse); case InboundMessage_Message.notSet: - throw _parseError("InboundMessage.message is not set."); + throw parseError("InboundMessage.message is not set."); default: - throw _parseError( + throw parseError( "Unknown message type: ${message.toDebugString()}"); } } on ProtocolError catch (error) { - error.id = _inboundId(message) ?? errorId; + // Always set the ID to [errorId] because we're only ever reporting + // errors for responses or for [CompileRequest] which has no ID. + error.id = errorId; stderr.write("Host caused ${error.type.name.toLowerCase()} error"); if (error.id != errorId) stderr.write(" with request ${error.id}"); stderr.writeln(": ${error.message}"); @@ -111,11 +132,126 @@ class Dispatcher { stderr.write("Internal compiler error: $errorMessage"); sendError(ProtocolError() ..type = ProtocolErrorType.INTERNAL - ..id = _inboundId(message) ?? errorId + ..id = errorId ..message = errorMessage); _channel.sink.close(); } - }); + }).asFuture(); + return success; + } + + Future _compile( + InboundMessage_CompileRequest request) async { + var functions = FunctionRegistry(); + + var style = request.style == OutputStyle.COMPRESSED + ? sass.OutputStyle.compressed + : sass.OutputStyle.expanded; + var logger = EmbeddedLogger(this, + color: request.alertColor, ascii: request.alertAscii); + + try { + var importers = request.importers.map((importer) => + _decodeImporter(request, importer) ?? + (throw mandatoryError("Importer.importer"))); + + var globalFunctions = request.globalFunctions + .map((signature) => hostCallable(this, functions, signature)); + + late sass.CompileResult result; + switch (request.whichInput()) { + case InboundMessage_CompileRequest_Input.string: + var input = request.string; + result = sass.compileStringToResult(input.source, + color: request.alertColor, + logger: logger, + importers: importers, + importer: _decodeImporter(request, input.importer) ?? + (input.url.startsWith("file:") ? null : sass.Importer.noOp), + functions: globalFunctions, + syntax: syntaxToSyntax(input.syntax), + style: style, + url: input.url.isEmpty ? null : input.url, + quietDeps: request.quietDeps, + verbose: request.verbose, + sourceMap: request.sourceMap, + charset: request.charset); + break; + + case InboundMessage_CompileRequest_Input.path: + if (request.path.isEmpty) { + throw mandatoryError("CompileRequest.Input.path"); + } + + try { + result = sass.compileToResult(request.path, + color: request.alertColor, + logger: logger, + importers: importers, + functions: globalFunctions, + style: style, + quietDeps: request.quietDeps, + verbose: request.verbose, + sourceMap: request.sourceMap, + charset: request.charset); + } on FileSystemException catch (error) { + return OutboundMessage_CompileResponse() + ..failure = (OutboundMessage_CompileResponse_CompileFailure() + ..message = error.path == null + ? error.message + : "${error.message}: ${error.path}" + ..span = (SourceSpan() + ..start = SourceSpan_SourceLocation() + ..end = SourceSpan_SourceLocation() + ..url = p.toUri(request.path).toString())); + } + break; + + case InboundMessage_CompileRequest_Input.notSet: + throw mandatoryError("CompileRequest.input"); + } + + var success = OutboundMessage_CompileResponse_CompileSuccess() + ..css = result.css; + + var sourceMap = result.sourceMap; + if (sourceMap != null) { + success.sourceMap = json.encode(sourceMap.toJson( + includeSourceContents: request.sourceMapIncludeSources)); + } + return OutboundMessage_CompileResponse() + ..success = success + ..loadedUrls.addAll(result.loadedUrls.map((url) => url.toString())); + } on sass.SassException catch (error) { + var formatted = withGlyphs( + () => error.toString(color: request.alertColor), + ascii: request.alertAscii); + return OutboundMessage_CompileResponse() + ..failure = (OutboundMessage_CompileResponse_CompileFailure() + ..message = error.message + ..span = protofySpan(error.span) + ..stackTrace = error.trace.toString() + ..formatted = formatted) + ..loadedUrls.addAll(error.loadedUrls.map((url) => url.toString())); + } + } + + /// Converts [importer] into a [sass.Importer]. + sass.Importer? _decodeImporter(InboundMessage_CompileRequest request, + InboundMessage_CompileRequest_Importer importer) { + switch (importer.whichImporter()) { + case InboundMessage_CompileRequest_Importer_Importer.path: + return sass.FilesystemImporter(importer.path); + + case InboundMessage_CompileRequest_Importer_Importer.importerId: + return HostImporter(this, importer.importerId); + + case InboundMessage_CompileRequest_Importer_Importer.fileImporterId: + return FileImporter(this, importer.fileImporterId); + + case InboundMessage_CompileRequest_Importer_Importer.notSet: + return null; + } } /// Sends [event] to the host. @@ -149,104 +285,47 @@ class Dispatcher { /// Sends [request] to the host and returns the message sent in response. Future _sendRequest( OutboundMessage request) async { - var id = _nextRequestId(); - _setOutboundId(request, id); + request.id = _outboundRequestId; _send(request); - var completer = Completer(); - _outstandingRequests[id] = completer; - return completer.future; - } - - /// Returns an available request ID, and guarantees that its slot is available - /// in [_outstandingRequests]. - int _nextRequestId() { - for (var i = 0; i < _outstandingRequests.length; i++) { - if (_outstandingRequests[i] == null) return i; + if (_outstandingRequest != null) { + throw StateError( + "Dispatcher.sendRequest() can't be called when another request is " + "active."); } - // If there are no empty slots, add another one. - _outstandingRequests.add(null); - return _outstandingRequests.length - 1; + return (_outstandingRequest = Completer()).future; } /// Dispatches [response] to the appropriate outstanding request. /// /// Throws an error if there's no outstanding request with the given [id] or /// if that request is expecting a different type of response. - void _dispatchResponse(int id, T response) { - Completer? completer; - if (id < _outstandingRequests.length) { - completer = _outstandingRequests[id]; - _outstandingRequests[id] = null; - } - - if (completer == null) { + void _dispatchResponse(int? id, T response) { + var completer = _outstandingRequest; + _outstandingRequest = null; + if (completer == null || id != _outboundRequestId) { throw paramsError( - "Response ID $id doesn't match any outstanding requests."); + "Response ID $id doesn't match any outstanding requests in " + "compilation $_compilationId."); } else if (completer is! Completer) { - throw paramsError("Request ID $id doesn't match response type " - "${response.runtimeType}."); + throw paramsError( + "Request ID $id doesn't match response type ${response.runtimeType} " + "in compilation $_compilationId."); } completer.complete(response); } - /// 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 = ProtocolErrorType.PARSE - ..message = message; - - /// Returns the id for [message] if it it's a request, or `null` - /// otherwise. - int? _inboundId(InboundMessage? message) { - if (message == null) return null; - switch (message.whichMessage()) { - case InboundMessage_Message.compileRequest: - return message.compileRequest.id; - default: - return null; - } - } - - /// Sets the id for [message] to [id]. - /// - /// Throws an [ArgumentError] if [message] doesn't have an id field. - void _setOutboundId(OutboundMessage message, int id) { - switch (message.whichMessage()) { - case OutboundMessage_Message.compileResponse: - message.compileResponse.id = id; - break; - case OutboundMessage_Message.canonicalizeRequest: - message.canonicalizeRequest.id = id; - break; - case OutboundMessage_Message.importRequest: - message.importRequest.id = id; - break; - case OutboundMessage_Message.fileImportRequest: - message.fileImportRequest.id = id; - break; - case OutboundMessage_Message.functionCallRequest: - message.functionCallRequest.id = id; - break; - case OutboundMessage_Message.versionResponse: - message.versionResponse.id = id; - break; - default: - throw ArgumentError("Unknown message type: ${message.toDebugString()}"); - } - } + /// Sends [message] to the host with the given [wireId]. + void _send(OutboundMessage message) { + var protobufWriter = CodedBufferWriter(); + message.writeToCodedBufferWriter(protobufWriter); - /// Creates a [OutboundMessage_VersionResponse] - static OutboundMessage_VersionResponse versionResponse() { - return OutboundMessage_VersionResponse() - ..protocolVersion = const String.fromEnvironment("protocol-version") - ..compilerVersion = const String.fromEnvironment("compiler-version") - ..implementationVersion = const String.fromEnvironment("compiler-version") - ..implementationName = "Dart Sass"; + var packet = + Uint8List(_compilationIdVarint.length + protobufWriter.lengthInBytes); + packet.setAll(0, _compilationIdVarint); + protobufWriter.writeTo(packet, _compilationIdVarint.length); + _channel.sink.add(packet); } } diff --git a/lib/src/embedded/executable.dart b/lib/src/embedded/executable.dart index 3ee7eaf5a..11dec3454 100644 --- a/lib/src/embedded/executable.dart +++ b/lib/src/embedded/executable.dart @@ -5,25 +5,15 @@ import 'dart:io'; import 'dart:convert'; -import 'package:path/path.dart' as p; import 'package:stream_channel/stream_channel.dart'; -import '../../sass.dart'; -import 'dispatcher.dart'; -import 'embedded_sass.pb.dart' as proto; -import 'embedded_sass.pb.dart' hide OutputStyle; -import 'function_registry.dart'; -import 'host_callable.dart'; -import 'importer/file.dart'; -import 'importer/host.dart'; -import 'logger.dart'; +import 'isolate_dispatcher.dart'; import 'util/length_delimited_transformer.dart'; -import 'utils.dart'; void main(List args) { if (args.isNotEmpty) { if (args.first == "--version") { - var response = Dispatcher.versionResponse(); + var response = IsolateDispatcher.versionResponse(); response.id = 0; stdout.writeln( JsonEncoder.withIndent(" ").convert(response.toProto3Json())); @@ -40,120 +30,8 @@ void main(List args) { return; } - var dispatcher = Dispatcher( - StreamChannel.withGuarantees(stdin, stdout, allowSinkErrors: false) - .transform(lengthDelimited)); - - dispatcher.listen((request) async { - var functions = FunctionRegistry(); - - var style = request.style == proto.OutputStyle.COMPRESSED - ? OutputStyle.compressed - : OutputStyle.expanded; - var logger = EmbeddedLogger(dispatcher, request.id, - color: request.alertColor, ascii: request.alertAscii); - - try { - var importers = request.importers.map((importer) => - _decodeImporter(dispatcher, request, importer) ?? - (throw mandatoryError("Importer.importer"))); - - var globalFunctions = request.globalFunctions.map((signature) => - hostCallable(dispatcher, functions, request.id, signature)); - - late CompileResult result; - switch (request.whichInput()) { - case InboundMessage_CompileRequest_Input.string: - var input = request.string; - result = compileStringToResult(input.source, - color: request.alertColor, - logger: logger, - importers: importers, - importer: _decodeImporter(dispatcher, request, input.importer) ?? - (input.url.startsWith("file:") ? null : Importer.noOp), - functions: globalFunctions, - syntax: syntaxToSyntax(input.syntax), - style: style, - url: input.url.isEmpty ? null : input.url, - quietDeps: request.quietDeps, - verbose: request.verbose, - sourceMap: request.sourceMap, - charset: request.charset); - break; - - case InboundMessage_CompileRequest_Input.path: - if (request.path.isEmpty) { - throw mandatoryError("CompileRequest.Input.path"); - } - - try { - result = compileToResult(request.path, - color: request.alertColor, - logger: logger, - importers: importers, - functions: globalFunctions, - style: style, - quietDeps: request.quietDeps, - verbose: request.verbose, - sourceMap: request.sourceMap, - charset: request.charset); - } on FileSystemException catch (error) { - return OutboundMessage_CompileResponse() - ..failure = (OutboundMessage_CompileResponse_CompileFailure() - ..message = error.path == null - ? error.message - : "${error.message}: ${error.path}" - ..span = (SourceSpan() - ..start = SourceSpan_SourceLocation() - ..end = SourceSpan_SourceLocation() - ..url = p.toUri(request.path).toString())); - } - break; - - case InboundMessage_CompileRequest_Input.notSet: - throw mandatoryError("CompileRequest.input"); - } - - var success = OutboundMessage_CompileResponse_CompileSuccess() - ..css = result.css - ..loadedUrls.addAll(result.loadedUrls.map((url) => url.toString())); - - var sourceMap = result.sourceMap; - if (sourceMap != null) { - success.sourceMap = json.encode(sourceMap.toJson( - includeSourceContents: request.sourceMapIncludeSources)); - } - return OutboundMessage_CompileResponse()..success = success; - } on SassException catch (error) { - var formatted = withGlyphs( - () => error.toString(color: request.alertColor), - ascii: request.alertAscii); - return OutboundMessage_CompileResponse() - ..failure = (OutboundMessage_CompileResponse_CompileFailure() - ..message = error.message - ..span = protofySpan(error.span) - ..stackTrace = error.trace.toString() - ..formatted = formatted); - } - }); -} - -/// Converts [importer] into a [Importer]. -Importer? _decodeImporter( - Dispatcher dispatcher, - InboundMessage_CompileRequest request, - InboundMessage_CompileRequest_Importer importer) { - switch (importer.whichImporter()) { - case InboundMessage_CompileRequest_Importer_Importer.path: - return FilesystemImporter(importer.path); - - case InboundMessage_CompileRequest_Importer_Importer.importerId: - return HostImporter(dispatcher, request.id, importer.importerId); - - case InboundMessage_CompileRequest_Importer_Importer.fileImporterId: - return FileImporter(dispatcher, request.id, importer.fileImporterId); - - case InboundMessage_CompileRequest_Importer_Importer.notSet: - return null; - } + IsolateDispatcher( + StreamChannel.withGuarantees(stdin, stdout, allowSinkErrors: false) + .transform(lengthDelimited)) + .listen(); } diff --git a/lib/src/embedded/host_callable.dart b/lib/src/embedded/host_callable.dart index e9f20036e..6d65823a9 100644 --- a/lib/src/embedded/host_callable.dart +++ b/lib/src/embedded/host_callable.dart @@ -22,14 +22,13 @@ import 'utils.dart'; /// the name defined in the [signature]. /// /// Throws a [SassException] if [signature] is invalid. -Callable hostCallable(Dispatcher dispatcher, FunctionRegistry functions, - int compilationId, String signature, +Callable hostCallable( + Dispatcher dispatcher, FunctionRegistry functions, String signature, {int? id}) { late Callable callable; callable = Callable.fromSignature(signature, (arguments) { - var protofier = Protofier(dispatcher, functions, compilationId); + var protofier = Protofier(dispatcher, functions); var request = OutboundMessage_FunctionCallRequest() - ..compilationId = compilationId ..arguments.addAll( [for (var argument in arguments) protofier.protofy(argument)]); diff --git a/lib/src/embedded/importer/base.dart b/lib/src/embedded/importer/base.dart index 0fac89ef9..9fad62360 100644 --- a/lib/src/embedded/importer/base.dart +++ b/lib/src/embedded/importer/base.dart @@ -10,7 +10,7 @@ import '../dispatcher.dart'; /// An abstract base class for importers that communicate with the host in some /// way. abstract class ImporterBase extends Importer { - /// The [Dispatcher] to which to send requests. + /// The [CompileDispatcher] to which to send requests. @protected final Dispatcher dispatcher; diff --git a/lib/src/embedded/importer/file.dart b/lib/src/embedded/importer/file.dart index 13de17f7a..e2b2bad40 100644 --- a/lib/src/embedded/importer/file.dart +++ b/lib/src/embedded/importer/file.dart @@ -19,14 +19,10 @@ final _filesystemImporter = FilesystemImporter('.'); /// An importer that asks the host to resolve imports in a simplified, /// file-system-centric way. class FileImporter extends ImporterBase { - /// The ID of the compilation in which this importer is used. - final int _compilationId; - /// The host-provided ID of the importer to invoke. final int _importerId; - FileImporter(Dispatcher dispatcher, this._compilationId, this._importerId) - : super(dispatcher); + FileImporter(Dispatcher dispatcher, this._importerId) : super(dispatcher); Uri? canonicalize(Uri url) { if (url.scheme == 'file') return _filesystemImporter.canonicalize(url); @@ -35,7 +31,6 @@ class FileImporter extends ImporterBase { return waitFor(() async { var response = await dispatcher .sendFileImportRequest(OutboundMessage_FileImportRequest() - ..compilationId = _compilationId ..importerId = _importerId ..url = url.toString() ..fromImport = fromImport); diff --git a/lib/src/embedded/importer/host.dart b/lib/src/embedded/importer/host.dart index 705ed0258..743cdda95 100644 --- a/lib/src/embedded/importer/host.dart +++ b/lib/src/embedded/importer/host.dart @@ -13,21 +13,16 @@ import 'base.dart'; /// An importer that asks the host to resolve imports. class HostImporter extends ImporterBase { - /// The ID of the compilation in which this importer is used. - final int _compilationId; - /// The host-provided ID of the importer to invoke. final int _importerId; - HostImporter(Dispatcher dispatcher, this._compilationId, this._importerId) - : super(dispatcher); + HostImporter(Dispatcher dispatcher, this._importerId) : super(dispatcher); Uri? canonicalize(Uri url) { // ignore: deprecated_member_use return waitFor(() async { var response = await dispatcher .sendCanonicalizeRequest(OutboundMessage_CanonicalizeRequest() - ..compilationId = _compilationId ..importerId = _importerId ..url = url.toString() ..fromImport = fromImport); @@ -50,7 +45,6 @@ class HostImporter extends ImporterBase { return waitFor(() async { var response = await dispatcher.sendImportRequest(OutboundMessage_ImportRequest() - ..compilationId = _compilationId ..importerId = _importerId ..url = url.toString()); diff --git a/lib/src/embedded/isolate_dispatcher.dart b/lib/src/embedded/isolate_dispatcher.dart new file mode 100644 index 000000000..6951a77f5 --- /dev/null +++ b/lib/src/embedded/isolate_dispatcher.dart @@ -0,0 +1,232 @@ +// Copyright 2023 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:isolate'; +import 'dart:typed_data'; + +import 'package:pool/pool.dart'; +import 'package:protobuf/protobuf.dart'; +import 'package:stack_trace/stack_trace.dart'; +import 'package:stream_channel/isolate_channel.dart'; +import 'package:stream_channel/stream_channel.dart'; + +import 'dispatcher.dart'; +import 'embedded_sass.pb.dart'; +import 'util/explicit_close_transformer.dart'; +import 'util/proto_extensions.dart'; +import 'utils.dart'; + +/// The message sent to a previously-inactive isolate to initiate a new +/// compilation session. +/// +/// The [SendPort] is used to establish a dedicated [IsolateChannel] for this +/// compilation and the [int] is the compilation ID to use on the wire. +/// +/// We apply the compilation ID in the isolate for efficiency reasons: it allows +/// us to write the protobuf directly to the same buffer as the wire ID, which +/// saves a copy for each message. +typedef _InitialMessage = (SendPort, int); + +/// A class that dispatches messages between the host and various isolates that +/// are each running an individual compilation. +class IsolateDispatcher { + /// The channel of encoded protocol buffers, connected to the host. + final StreamChannel _channel; + + /// A map from compilation IDs to the sinks for isolates running those + /// compilations. + final _activeIsolates = >{}; + + /// A set of isolates that are _not_ actively running compilations. + final _inactiveIsolates = >{}; + + /// The actual isolate objects that have been spawned. + /// + /// Only used for cleaning up the process when the underlying channel closes. + final _allIsolates = []; + + /// A pool controlling how many isolates (and thus concurrent compilations) + /// may be live at once. + /// + /// More than 15 concurrent `waitFor()` calls seems to deadlock the Dart VM, + /// even across isolates. See sass/dart-sass#1959. + final _isolatePool = Pool(15); + + /// Whether the underlying channel has closed and the dispatcher is shutting + /// down. + var _closed = false; + + IsolateDispatcher(this._channel); + + void listen() { + _channel.stream.listen((packet) async { + int? compilationId; + InboundMessage? message; + try { + Uint8List messageBuffer; + (compilationId, messageBuffer) = parsePacket(packet); + + if (compilationId != 0) { + // TODO(nweiz): Consider using the techniques described in + // https://github.com/dart-lang/language/issues/124#issuecomment-988718728 + // or https://github.com/dart-lang/language/issues/3118 for low-cost + // inter-isolate transfers. + (_activeIsolates[compilationId] ?? await _getIsolate(compilationId)) + .add(messageBuffer); + return; + } + + try { + message = InboundMessage.fromBuffer(messageBuffer); + } on InvalidProtocolBufferException catch (error) { + throw parseError(error.message); + } + + if (message.whichMessage() case var type + when type != InboundMessage_Message.versionRequest) { + throw paramsError( + "Only VersionRequest may have wire ID 0, was $type."); + } + + var request = message.versionRequest; + var response = versionResponse(); + response.id = request.id; + _send(0, OutboundMessage()..versionResponse = response); + } catch (error, stackTrace) { + _handleError(error, stackTrace, + compilationId: compilationId, messageId: message?.id); + } + }, onError: (Object error, StackTrace stackTrace) { + _handleError(error, stackTrace); + }, onDone: () { + _closed = true; + for (var isolate in _allIsolates) { + isolate.kill(); + } + + // Killing isolates isn't sufficient to make sure the process closes; we + // also have to close all the [ReceivePort]s we've constructed (by closing + // the [IsolateChannel]s). + for (var sink in _activeIsolates.values) { + sink.close(); + } + for (var channel in _inactiveIsolates) { + channel.sink.close(); + } + }); + } + + /// Returns an isolate that's ready to run a new compilation. + /// + /// This re-uses an existing isolate if possible, and spawns a new one + /// otherwise. + Future> _getIsolate(int compilationId) async { + var resource = await _isolatePool.request(); + if (_inactiveIsolates.isNotEmpty) { + return _activate(_inactiveIsolates.first, compilationId, resource); + } + + var receivePort = ReceivePort(); + _allIsolates.add(await Isolate.spawn(_isolateMain, receivePort.sendPort)); + + var channel = IsolateChannel<_InitialMessage?>.connectReceive(receivePort) + .transform(const ExplicitCloseTransformer()); + channel.stream.listen(null, + onError: (Object error, StackTrace stackTrace) => + _handleError(error, stackTrace), + onDone: _channel.sink.close); + return _activate(channel, compilationId, resource); + } + + /// Activates [isolate] for a new individual compilation. + /// + /// This pipes all the outputs from the given isolate through to [_channel]. + /// The [resource] is released once the isolate is no longer active. + StreamSink _activate(StreamChannel<_InitialMessage> isolate, + int compilationId, PoolResource resource) { + _inactiveIsolates.remove(isolate); + + // Each individual compilation has its own dedicated [IsolateChannel], which + // closes once the compilation is finished. + var receivePort = ReceivePort(); + isolate.sink.add((receivePort.sendPort, compilationId)); + + var channel = IsolateChannel.connectReceive(receivePort) + .transform(const ExplicitCloseTransformer()); + channel.stream.listen(_channel.sink.add, + onError: (Object error, StackTrace stackTrace) => + _handleError(error, stackTrace), + onDone: () { + _activeIsolates.remove(compilationId); + if (_closed) { + isolate.sink.close(); + } else { + _inactiveIsolates.add(isolate); + } + resource.release(); + }); + _activeIsolates[compilationId] = channel.sink; + return channel.sink; + } + + /// Creates a [OutboundMessage_VersionResponse] + static OutboundMessage_VersionResponse versionResponse() { + return OutboundMessage_VersionResponse() + ..protocolVersion = const String.fromEnvironment("protocol-version") + ..compilerVersion = const String.fromEnvironment("compiler-version") + ..implementationVersion = const String.fromEnvironment("compiler-version") + ..implementationName = "Dart Sass"; + } + + /// Handles an error thrown by the dispatcher or code it dispatches to. + /// + /// The [compilationId] and [messageId] indicate the IDs of the message being + /// responded to, if available. + void _handleError(Object error, StackTrace stackTrace, + {int? compilationId, int? messageId}) { + if (error is ProtocolError) { + error.id = messageId ?? errorId; + stderr.write("Host caused ${error.type.name.toLowerCase()} error"); + if (error.id != errorId) stderr.write(" with request ${error.id}"); + stderr.writeln(": ${error.message}"); + sendError(compilationId ?? errorId, error); + // PROTOCOL error from https://bit.ly/2poTt90 + exitCode = 76; + _channel.sink.close(); + } else { + var errorMessage = "$error\n${Chain.forTrace(stackTrace)}"; + stderr.write("Internal compiler error: $errorMessage"); + sendError( + compilationId ?? errorId, + ProtocolError() + ..type = ProtocolErrorType.INTERNAL + ..id = messageId ?? errorId + ..message = errorMessage); + _channel.sink.close(); + } + } + + /// Sends [message] to the host. + void _send(int compilationId, OutboundMessage message) => + _channel.sink.add(serializePacket(compilationId, message)); + + /// Sends [error] to the host. + void sendError(int compilationId, ProtocolError error) => + _send(compilationId, OutboundMessage()..error = error); +} + +void _isolateMain(SendPort sendPort) { + var channel = IsolateChannel<_InitialMessage?>.connectSend(sendPort) + .transform(const ExplicitCloseTransformer()); + channel.stream.listen((initialMessage) async { + var (compilationSendPort, compilationId) = initialMessage; + var compilationChannel = + IsolateChannel.connectSend(compilationSendPort) + .transform(const ExplicitCloseTransformer()); + var success = await Dispatcher(compilationChannel, compilationId).listen(); + if (!success) channel.sink.close(); + }); +} diff --git a/lib/src/embedded/logger.dart b/lib/src/embedded/logger.dart index f84b31fb9..c94e52dc1 100644 --- a/lib/src/embedded/logger.dart +++ b/lib/src/embedded/logger.dart @@ -17,17 +17,13 @@ class EmbeddedLogger implements Logger { /// The [Dispatcher] to which to send events. final Dispatcher _dispatcher; - /// The ID of the compilation to which this logger is passed. - final int _compilationId; - /// Whether the formatted message should contain terminal colors. final bool _color; /// Whether the formatted message should use ASCII encoding. final bool _ascii; - EmbeddedLogger(this._dispatcher, this._compilationId, - {bool color = false, bool ascii = false}) + EmbeddedLogger(this._dispatcher, {bool color = false, bool ascii = false}) : _color = color, _ascii = ascii; @@ -40,7 +36,6 @@ class EmbeddedLogger implements Logger { ..writeln(': $message'); _dispatcher.sendLog(OutboundMessage_LogEvent() - ..compilationId = _compilationId ..type = LogEventType.DEBUG ..message = message ..span = protofySpan(span) @@ -73,7 +68,6 @@ class EmbeddedLogger implements Logger { }, ascii: _ascii); var event = OutboundMessage_LogEvent() - ..compilationId = _compilationId ..type = deprecation ? LogEventType.DEPRECATION_WARNING : LogEventType.WARNING ..message = message diff --git a/lib/src/embedded/protofier.dart b/lib/src/embedded/protofier.dart index 796fb7f6f..9096c8b9b 100644 --- a/lib/src/embedded/protofier.dart +++ b/lib/src/embedded/protofier.dart @@ -21,9 +21,6 @@ class Protofier { /// The IDs of first-class functions. final FunctionRegistry _functions; - /// The ID of the current compilation. - final int _compilationId; - /// Any argument lists transitively contained in [value]. /// /// The IDs of the [Value_ArgumentList] protobufs are always one greater than @@ -36,7 +33,7 @@ class Protofier { /// /// The [functions] tracks the IDs of first-class functions so that the host /// can pass them back to the compiler. - Protofier(this._dispatcher, this._functions, this._compilationId); + Protofier(this._dispatcher, this._functions); /// Converts [value] to its protocol buffer representation. proto.Value protofy(Value value) { @@ -265,8 +262,8 @@ class Protofier { return function; case Value_Value.hostFunction: - return SassFunction(hostCallable(_dispatcher, _functions, - _compilationId, value.hostFunction.signature, + return SassFunction(hostCallable( + _dispatcher, _functions, value.hostFunction.signature, id: value.hostFunction.id)); case Value_Value.calculation: diff --git a/lib/src/embedded/util/explicit_close_transformer.dart b/lib/src/embedded/util/explicit_close_transformer.dart new file mode 100644 index 000000000..43a5334dd --- /dev/null +++ b/lib/src/embedded/util/explicit_close_transformer.dart @@ -0,0 +1,38 @@ +// Copyright 2023 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 'package:async/async.dart'; +import 'package:stream_channel/stream_channel.dart'; + +/// A [StreamChannelTransformer] that explicitly ensures that when one endpoint +/// closes its sink, the other endpoint will close as well. +/// +/// This must be applied to both ends of the channel, and the underlying channel +/// must reserve `null` for a close event. +class ExplicitCloseTransformer + implements StreamChannelTransformer { + const ExplicitCloseTransformer(); + + StreamChannel bind(StreamChannel channel) { + var closedUnderlyingSink = false; + return StreamChannel.withCloseGuarantee(channel.stream + .transform(StreamTransformer.fromHandlers(handleData: (data, sink) { + if (data == null) { + channel.sink.close(); + closedUnderlyingSink = true; + } else { + sink.add(data); + } + })), channel.sink + .transform(StreamSinkTransformer.fromHandlers(handleDone: (sink) { + if (!closedUnderlyingSink) { + closedUnderlyingSink = true; + sink.add(null); + sink.close(); + } + }))); + } +} diff --git a/lib/src/embedded/util/length_delimited_transformer.dart b/lib/src/embedded/util/length_delimited_transformer.dart index 20bc33c98..8e8b782e9 100644 --- a/lib/src/embedded/util/length_delimited_transformer.dart +++ b/lib/src/embedded/util/length_delimited_transformer.dart @@ -8,7 +8,9 @@ import 'dart:typed_data'; import 'package:async/async.dart'; import 'package:stream_channel/stream_channel.dart'; -import 'package:typed_data/typed_data.dart'; + +import '../utils.dart'; +import 'varint_builder.dart'; /// A [StreamChannelTransformer] that converts a channel that sends and receives /// arbitrarily-chunked binary data to one that sends and receives packets of @@ -22,16 +24,10 @@ final StreamChannelTransformer> lengthDelimited = /// into a stream of packet contents. final lengthDelimitedDecoder = StreamTransformer, Uint8List>.fromBind((stream) { - // The number of bits we've consumed so far to fill out [nextMessageLength]. - var nextMessageLengthBits = 0; - - // The length of the next message, in bytes. + // The builder for the varint indicating the length of the next message. // - // This is built up from a [varint]. Once it's fully consumed, [buffer] is - // initialized. - // - // [varint]: https://developers.google.com/protocol-buffers/docs/encoding#varints - var nextMessageLength = 0; + // Once this is fully built up, [buffer] is initialized and this is reset. + final nextMessageLengthBuilder = VarintBuilder(53, 'packet length'); // The buffer into which the packet data itself is written. Initialized once // [nextMessageLength] is known. @@ -66,22 +62,13 @@ final lengthDelimitedDecoder = // have [nextMessageLength] bytes in it before we send it to // [queue.local.sink] and start waiting for the next message. if (buffer_ == null) { - var byte = chunk[i]; - - // Varints encode data in the 7 lower bits of each byte, which we access - // by masking with 0x7f = 0b01111111. - nextMessageLength += (byte & 0x7f) << nextMessageLengthBits; - nextMessageLengthBits += 7; + var length = nextMessageLengthBuilder.add(chunk[i]); i++; - - // If the byte is higher than 0x7f = 0b01111111, that means its high bit - // is set which and so there are more bytes to consume before we know - // the full message length. - if (byte > 0x7f) continue; + if (length == null) continue; // Otherwise, [nextMessageLength] is now finalized and we can allocate // the data buffer. - buffer_ = buffer = Uint8List(nextMessageLength); + buffer_ = buffer = Uint8List(length); bufferIndex = 0; } @@ -94,12 +81,11 @@ final lengthDelimitedDecoder = buffer_.setRange(bufferIndex, bufferIndex + bytesToWrite, chunk, i); i += bytesToWrite; bufferIndex += bytesToWrite; - if (bufferIndex < nextMessageLength) return; + if (bufferIndex < buffer_.length) return; // Once we've filled the buffer, emit it and reset our state. sink.add(buffer_); - nextMessageLength = 0; - nextMessageLengthBits = 0; + nextMessageLengthBuilder.reset(); buffer = null; } })); @@ -117,16 +103,6 @@ final lengthDelimitedEncoder = return; } - // Write the length in varint format, 7 bits at a time from least to most - // significant. - var lengthBuffer = Uint8Buffer(); - while (length > 0) { - // The highest-order bit indicates whether more bytes are necessary to fully - // express the number. The lower 7 bits indicate the number's value. - lengthBuffer.add((length > 0x7f ? 0x80 : 0) | (length & 0x7f)); - length >>= 7; - } - - sink.add(Uint8List.view(lengthBuffer.buffer, 0, lengthBuffer.length)); + sink.add(serializeVarint(length)); sink.add(message); }); diff --git a/lib/src/embedded/util/proto_extensions.dart b/lib/src/embedded/util/proto_extensions.dart new file mode 100644 index 000000000..43eea2dc9 --- /dev/null +++ b/lib/src/embedded/util/proto_extensions.dart @@ -0,0 +1,53 @@ +// Copyright 2023 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 '../embedded_sass.pb.dart'; + +extension InboundMessageExtensions on InboundMessage { + /// Returns the ID of this message, regardless of its type. + /// + /// Returns null if [message] doesn't have an id field. + int? get id => switch (whichMessage()) { + InboundMessage_Message.versionRequest => versionRequest.id, + InboundMessage_Message.canonicalizeResponse => canonicalizeResponse.id, + InboundMessage_Message.importResponse => importResponse.id, + InboundMessage_Message.fileImportResponse => fileImportResponse.id, + InboundMessage_Message.functionCallResponse => functionCallResponse.id, + _ => null + }; +} + +extension OutboundMessageExtensions on OutboundMessage { + /// Returns the outbound ID of this message, regardless of its type. + /// + /// Throws an [ArgumentError] if [message] doesn't have an id field. + int get id => switch (whichMessage()) { + OutboundMessage_Message.canonicalizeRequest => canonicalizeRequest.id, + OutboundMessage_Message.importRequest => importRequest.id, + OutboundMessage_Message.fileImportRequest => fileImportRequest.id, + OutboundMessage_Message.functionCallRequest => functionCallRequest.id, + OutboundMessage_Message.versionResponse => versionResponse.id, + _ => throw ArgumentError("Unknown message type: ${toDebugString()}") + }; + + /// Sets the outbound ID of this message, regardless of its type. + /// + /// Throws an [ArgumentError] if [message] doesn't have an id field. + set id(int id) { + switch (whichMessage()) { + case OutboundMessage_Message.canonicalizeRequest: + canonicalizeRequest.id = id; + case OutboundMessage_Message.importRequest: + importRequest.id = id; + case OutboundMessage_Message.fileImportRequest: + fileImportRequest.id = id; + case OutboundMessage_Message.functionCallRequest: + functionCallRequest.id = id; + case OutboundMessage_Message.versionResponse: + versionResponse.id = id; + default: + throw ArgumentError("Unknown message type: ${toDebugString()}"); + } + } +} diff --git a/lib/src/embedded/util/varint_builder.dart b/lib/src/embedded/util/varint_builder.dart new file mode 100644 index 000000000..78610bc9d --- /dev/null +++ b/lib/src/embedded/util/varint_builder.dart @@ -0,0 +1,82 @@ +// Copyright 2023 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 '../embedded_sass.pb.dart'; +import '../utils.dart'; + +/// A class that builds up unsigned varints byte-by-byte. +class VarintBuilder { + /// The maximum length in bits of the varint being parsed. + final int _maxLength; + + /// The name of the value being parsed, used for error reporting. + final String? _name; + + /// The value of the varint so far. + int _value = 0; + + /// The total number of bits parsed so far. + int _bits = 0; + + /// Whether we've finished parsing the varint. + var _done = false; + + /// Creates a builder with [maxLength] as the maximum number of bits allowed + /// for the integer. + /// + /// If [name] is passed, it's used in error reporting. + VarintBuilder(this._maxLength, [this._name]); + + /// Parses [byte] as a continuation of the varint. + /// + /// If this byte completes the varint, returns the parsed int. Otherwise, + /// returns null. + /// + /// Throws a [ProtocolError] if [byte] causes the length of the varint to + /// exceed [_maxLength]. Throws a [StateError] if called after [add] has + /// already returned a value. + int? add(int byte) { + if (_done) { + throw StateError("VarintBuilder.add() has already returned a value."); + } + + // Varints encode data in the 7 lower bits of each byte, which we access by + // masking with 0x7f = 0b01111111. + _value += (byte & 0x7f) << _bits; + _bits += 7; + + // If the byte is higher than 0x7f = 0b01111111, that means its high bit is + // set which and so there are more bytes to consume before we know the full + // value. + if (byte > 0x7f) { + if (_bits >= _maxLength) { + _done = true; + throw _tooLong(); + } else { + return null; + } + } else { + _done = true; + if (_bits > _maxLength && _value >= 1 << _maxLength) { + // [_maxLength] is probably not divisible by 7, so we need to check that + // the highest bits of the final byte aren't set. + throw _tooLong(); + } else { + return _value; + } + } + } + + /// Resets this to its initial state so it can build another varint. + void reset() { + _value = 0; + _bits = 0; + _done = false; + } + + /// Returns a [ProtocolError] indicating that the varint exceeded [_maxLength]. + ProtocolError _tooLong() => + parseError("Varint ${_name == null ? '' : '$_name '}was longer than " + "$_maxLength bits."); +} diff --git a/lib/src/embedded/utils.dart b/lib/src/embedded/utils.dart index 123c8d23c..2fe90bc3d 100644 --- a/lib/src/embedded/utils.dart +++ b/lib/src/embedded/utils.dart @@ -2,12 +2,16 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'dart:typed_data'; + +import 'package:protobuf/protobuf.dart'; import 'package:source_span/source_span.dart'; import 'package:term_glyph/term_glyph.dart' as term_glyph; import '../syntax.dart'; import 'embedded_sass.pb.dart' as proto; import 'embedded_sass.pb.dart' hide SourceSpan, Syntax; +import 'util/varint_builder.dart'; /// The special ID that indicates an error that's not associated with a specific /// inbound request ID. @@ -27,6 +31,11 @@ ProtocolError paramsError(String message) => ProtocolError() ..type = ProtocolErrorType.PARAMS ..message = message; +/// Returns a [ProtocolError] with type `PARSE` and the given [message]. +ProtocolError parseError(String message) => ProtocolError() + ..type = ProtocolErrorType.PARSE + ..message = message; + /// Converts a Dart source span to a protocol buffer source span. proto.SourceSpan protofySpan(SourceSpan span) { var protoSpan = proto.SourceSpan() @@ -68,3 +77,60 @@ T withGlyphs(T callback(), {required bool ascii}) { term_glyph.ascii = currentConfig; return result; } + +/// Serializes [value] to an unsigned varint. +Uint8List serializeVarint(int value) { + if (value == 0) return Uint8List.fromList([0]); + RangeError.checkNotNegative(value); + + // Essentially `(value.bitLength / 7).ceil()`, but without getting floats + // involved. + var lengthInBytes = (value.bitLength + 6) ~/ 7; + var list = Uint8List(lengthInBytes); + for (var i = 0; i < lengthInBytes; i++) { + // The highest-order bit indicates whether more bytes are necessary to fully + // express the number. The lower 7 bits indicate the number's value. + list[i] = (value > 0x7f ? 0x80 : 0) | (value & 0x7f); + value >>= 7; + } + return list; +} + +/// Serializes a compilation ID and protobuf message into a packet buffer as +/// specified in the embedded protocol. +Uint8List serializePacket(int compilationId, GeneratedMessage message) { + var varint = serializeVarint(compilationId); + var protobufWriter = CodedBufferWriter(); + message.writeToCodedBufferWriter(protobufWriter); + + var packet = Uint8List(varint.length + protobufWriter.lengthInBytes); + packet.setAll(0, varint); + protobufWriter.writeTo(packet, varint.length); + return packet; +} + +/// A [VarintBuilder] that's shared across invocations of [parsePacket] to avoid +/// unnecessary allocations. +final _compilationIdBuilder = VarintBuilder(32, 'compilation ID'); + +/// Parses a compilation ID and encoded protobuf message from a packet buffer as +/// specified in the embedded protocol. +(int, Uint8List) parsePacket(Uint8List packet) { + try { + var i = 0; + while (true) { + if (i == packet.length) { + throw parseError( + "Invalid compilation ID: continuation bit always set."); + } + + var compilationId = _compilationIdBuilder.add(packet[i]); + i++; + if (compilationId != null) { + return (compilationId, Uint8List.sublistView(packet, i)); + } + } + } finally { + _compilationIdBuilder.reset(); + } +} diff --git a/lib/src/embedded/value.dart b/lib/src/embedded/value.dart index fcbe99d93..1b08f7b83 100644 --- a/lib/src/embedded/value.dart +++ b/lib/src/embedded/value.dart @@ -83,12 +83,11 @@ proto.ListSeparator _protofySeparator(ListSeparator separator) { /// /// The [functions] tracks the IDs of first-class functions so that they can be /// deserialized to their original references. -Value deprotofyValue(Dispatcher dispatcher, FunctionRegistry functions, - int compilationId, proto.Value value) { +Value deprotofyValue( + Dispatcher dispatcher, FunctionRegistry functions, proto.Value value) { // Curry recursive calls to this function so we don't have to keep repeating // ourselves. - deprotofy(proto.Value value) => - deprotofyValue(dispatcher, functions, compilationId, value); + deprotofy(proto.Value value) => deprotofyValue(dispatcher, functions, value); try { switch (value.whichValue()) { @@ -148,7 +147,7 @@ Value deprotofyValue(Dispatcher dispatcher, FunctionRegistry functions, case Value_Value.hostFunction: return SassFunction(hostCallable( - dispatcher, functions, compilationId, value.hostFunction.signature, + dispatcher, functions, value.hostFunction.signature, id: value.hostFunction.id)); case Value_Value.singleton: diff --git a/lib/src/exception.dart b/lib/src/exception.dart index 388ae9054..38a1a057e 100644 --- a/lib/src/exception.dart +++ b/lib/src/exception.dart @@ -24,7 +24,14 @@ class SassException extends SourceSpanException { FileSpan get span => super.span as FileSpan; - SassException(String message, FileSpan span) : super(message, span); + /// The set of canonical stylesheet URLs that were loaded in the course of the + /// compilation, before it failed. + final Set loadedUrls; + + SassException(String message, FileSpan span, [Iterable? loadedUrls]) + : loadedUrls = + loadedUrls == null ? const {} : Set.unmodifiable(loadedUrls), + super(message, span); /// Converts this to a [MultiSpanSassException] with the additional [span] and /// [label]. @@ -32,7 +39,7 @@ class SassException extends SourceSpanException { /// @nodoc @internal MultiSpanSassException withAdditionalSpan(FileSpan span, String label) => - MultiSpanSassException(message, this.span, "", {span: label}); + MultiSpanSassException(message, this.span, "", {span: label}, loadedUrls); /// Returns a copy of this as a [SassRuntimeException] with [trace] as its /// Sass stack trace. @@ -40,7 +47,12 @@ class SassException extends SourceSpanException { /// @nodoc @internal SassRuntimeException withTrace(Trace trace) => - SassRuntimeException(message, span, trace); + SassRuntimeException(message, span, trace, loadedUrls); + + /// Returns a copy of this with [loadedUrls] set to the given value. + @internal + SassException withLoadedUrls(Iterable loadedUrls) => + SassException(message, span, loadedUrls); String toString({Object? color}) { var buffer = StringBuffer() @@ -109,17 +121,22 @@ class MultiSpanSassException extends SassException final Map secondarySpans; MultiSpanSassException(String message, FileSpan span, this.primaryLabel, - Map secondarySpans) + Map secondarySpans, + [Iterable? loadedUrls]) : secondarySpans = Map.unmodifiable(secondarySpans), - super(message, span); + super(message, span, loadedUrls); MultiSpanSassException withAdditionalSpan(FileSpan span, String label) => - MultiSpanSassException( - message, this.span, primaryLabel, {...secondarySpans, span: label}); + MultiSpanSassException(message, this.span, primaryLabel, + {...secondarySpans, span: label}, loadedUrls); MultiSpanSassRuntimeException withTrace(Trace trace) => MultiSpanSassRuntimeException( - message, span, primaryLabel, secondarySpans, trace); + message, span, primaryLabel, secondarySpans, trace, loadedUrls); + + MultiSpanSassException withLoadedUrls(Iterable loadedUrls) => + MultiSpanSassException( + message, span, primaryLabel, secondarySpans, loadedUrls); String toString({Object? color, String? secondaryColor}) { var useColor = false; @@ -156,10 +173,14 @@ class SassRuntimeException extends SassException { MultiSpanSassRuntimeException withAdditionalSpan( FileSpan span, String label) => MultiSpanSassRuntimeException( - message, this.span, "", {span: label}, trace); + message, this.span, "", {span: label}, trace, loadedUrls); - SassRuntimeException(String message, FileSpan span, this.trace) - : super(message, span); + SassRuntimeException withLoadedUrls(Iterable loadedUrls) => + SassRuntimeException(message, span, trace, loadedUrls); + + SassRuntimeException(String message, FileSpan span, this.trace, + [Iterable? loadedUrls]) + : super(message, span, loadedUrls); } /// A [SassRuntimeException] that's also a [MultiSpanSassException]. @@ -168,13 +189,18 @@ class MultiSpanSassRuntimeException extends MultiSpanSassException final Trace trace; MultiSpanSassRuntimeException(String message, FileSpan span, - String primaryLabel, Map secondarySpans, this.trace) - : super(message, span, primaryLabel, secondarySpans); + String primaryLabel, Map secondarySpans, this.trace, + [Iterable? loadedUrls]) + : super(message, span, primaryLabel, secondarySpans, loadedUrls); MultiSpanSassRuntimeException withAdditionalSpan( FileSpan span, String label) => MultiSpanSassRuntimeException(message, this.span, primaryLabel, - {...secondarySpans, span: label}, trace); + {...secondarySpans, span: label}, trace, loadedUrls); + + MultiSpanSassRuntimeException withLoadedUrls(Iterable loadedUrls) => + MultiSpanSassRuntimeException( + message, span, primaryLabel, secondarySpans, trace, loadedUrls); } /// An exception thrown when Sass parsing has failed. @@ -191,9 +217,16 @@ class SassFormatException extends SassException @internal MultiSpanSassFormatException withAdditionalSpan( FileSpan span, String label) => - MultiSpanSassFormatException(message, this.span, "", {span: label}); + MultiSpanSassFormatException( + message, this.span, "", {span: label}, loadedUrls); - SassFormatException(String message, FileSpan span) : super(message, span); + /// @nodoc + SassFormatException withLoadedUrls(Iterable loadedUrls) => + SassFormatException(message, span, loadedUrls); + + SassFormatException(String message, FileSpan span, + [Iterable? loadedUrls]) + : super(message, span, loadedUrls); } /// A [SassFormatException] that's also a [MultiSpanFormatException]. @@ -208,12 +241,17 @@ class MultiSpanSassFormatException extends MultiSpanSassException MultiSpanSassFormatException withAdditionalSpan( FileSpan span, String label) => + MultiSpanSassFormatException(message, this.span, primaryLabel, + {...secondarySpans, span: label}, loadedUrls); + + MultiSpanSassFormatException withLoadedUrls(Iterable loadedUrls) => MultiSpanSassFormatException( - message, this.span, primaryLabel, {...secondarySpans, span: label}); + message, span, primaryLabel, secondarySpans, loadedUrls); MultiSpanSassFormatException(String message, FileSpan span, - String primaryLabel, Map secondarySpans) - : super(message, span, primaryLabel, secondarySpans); + String primaryLabel, Map secondarySpans, + [Iterable? loadedUrls]) + : super(message, span, primaryLabel, secondarySpans, loadedUrls); } /// An exception thrown by SassScript. diff --git a/lib/src/visitor/async_evaluate.dart b/lib/src/visitor/async_evaluate.dart index e16be1342..1f2e1e1f2 100644 --- a/lib/src/visitor/async_evaluate.dart +++ b/lib/src/visitor/async_evaluate.dart @@ -521,17 +521,22 @@ class _EvaluateVisitor } Future run(AsyncImporter? importer, Stylesheet node) async { - return withEvaluationContext(_EvaluationContext(this, node), () async { - var url = node.span.sourceUrl; - if (url != null) { - _activeModules[url] = null; - if (!(_asNodeSass && url.toString() == 'stdin')) _loadedUrls.add(url); - } + try { + return await withEvaluationContext(_EvaluationContext(this, node), + () async { + var url = node.span.sourceUrl; + if (url != null) { + _activeModules[url] = null; + if (!(_asNodeSass && url.toString() == 'stdin')) _loadedUrls.add(url); + } - var module = await _addExceptionTrace(() => _execute(importer, node)); + var module = await _addExceptionTrace(() => _execute(importer, node)); - return EvaluateResult(_combineCss(module), _loadedUrls); - }); + return EvaluateResult(_combineCss(module), _loadedUrls); + }); + } on SassException catch (error, stackTrace) { + throwWithTrace(error.withLoadedUrls(_loadedUrls), stackTrace); + } } Future runExpression(AsyncImporter? importer, Expression expression) => @@ -1602,17 +1607,22 @@ class _EvaluateVisitor var importCache = _importCache; if (importCache != null) { + var parsedUrl = Uri.parse(url); baseUrl ??= _stylesheet.span.sourceUrl; - var tuple = await importCache.canonicalize(Uri.parse(url), + var tuple = await importCache.canonicalize(parsedUrl, baseImporter: _importer, baseUrl: baseUrl, forImport: forImport); if (tuple != null) { + // Make sure we record the canonical URL as "loaded" even if the + // actual load fails, because watchers should watch it to see if it + // changes in a way that allows the load to succeed. + _loadedUrls.add(tuple.item2); + var isDependency = _inDependency || tuple.item1 != _importer; var stylesheet = await importCache.importCanonical( tuple.item1, tuple.item2, originalUrl: tuple.item3, quiet: _quietDeps && isDependency); if (stylesheet != null) { - _loadedUrls.add(tuple.item2); return _LoadedStylesheet(stylesheet, importer: tuple.item1, isDependency: isDependency); } diff --git a/lib/src/visitor/evaluate.dart b/lib/src/visitor/evaluate.dart index 0b4e78125..0226cb01e 100644 --- a/lib/src/visitor/evaluate.dart +++ b/lib/src/visitor/evaluate.dart @@ -5,7 +5,7 @@ // DO NOT EDIT. This file was generated from async_evaluate.dart. // See tool/grind/synchronize.dart for details. // -// Checksum: 3e5e09dec7a8bcc6bc017103c67f463843b7fed7 +// Checksum: fa9a95be772a0bb1e769db23a3d794c6c55148d4 // // ignore_for_file: unused_import @@ -526,17 +526,21 @@ class _EvaluateVisitor } EvaluateResult run(Importer? importer, Stylesheet node) { - return withEvaluationContext(_EvaluationContext(this, node), () { - var url = node.span.sourceUrl; - if (url != null) { - _activeModules[url] = null; - if (!(_asNodeSass && url.toString() == 'stdin')) _loadedUrls.add(url); - } + try { + return withEvaluationContext(_EvaluationContext(this, node), () { + var url = node.span.sourceUrl; + if (url != null) { + _activeModules[url] = null; + if (!(_asNodeSass && url.toString() == 'stdin')) _loadedUrls.add(url); + } - var module = _addExceptionTrace(() => _execute(importer, node)); + var module = _addExceptionTrace(() => _execute(importer, node)); - return EvaluateResult(_combineCss(module), _loadedUrls); - }); + return EvaluateResult(_combineCss(module), _loadedUrls); + }); + } on SassException catch (error, stackTrace) { + throwWithTrace(error.withLoadedUrls(_loadedUrls), stackTrace); + } } Value runExpression(Importer? importer, Expression expression) => @@ -1602,16 +1606,21 @@ class _EvaluateVisitor var importCache = _importCache; if (importCache != null) { + var parsedUrl = Uri.parse(url); baseUrl ??= _stylesheet.span.sourceUrl; - var tuple = importCache.canonicalize(Uri.parse(url), + var tuple = importCache.canonicalize(parsedUrl, baseImporter: _importer, baseUrl: baseUrl, forImport: forImport); if (tuple != null) { + // Make sure we record the canonical URL as "loaded" even if the + // actual load fails, because watchers should watch it to see if it + // changes in a way that allows the load to succeed. + _loadedUrls.add(tuple.item2); + var isDependency = _inDependency || tuple.item1 != _importer; var stylesheet = importCache.importCanonical(tuple.item1, tuple.item2, originalUrl: tuple.item3, quiet: _quietDeps && isDependency); if (stylesheet != null) { - _loadedUrls.add(tuple.item2); return _LoadedStylesheet(stylesheet, importer: tuple.item1, isDependency: isDependency); } diff --git a/pkg/sass_api/pubspec.yaml b/pkg/sass_api/pubspec.yaml index ac10b5e3b..b9147e6bf 100644 --- a/pkg/sass_api/pubspec.yaml +++ b/pkg/sass_api/pubspec.yaml @@ -7,7 +7,7 @@ description: Additional APIs for Dart Sass. homepage: https://github.com/sass/dart-sass environment: - sdk: ">=2.17.0 <3.0.0" + sdk: ">=3.0.0 <4.0.0" dependencies: sass: 1.63.0 diff --git a/pubspec.yaml b/pubspec.yaml index d9f0312f8..5083fc106 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,7 +8,7 @@ executables: sass: sass environment: - sdk: ">=2.17.0 <3.0.0" + sdk: ">=3.0.0 <4.0.0" dependencies: args: ^2.0.0 @@ -22,6 +22,7 @@ dependencies: node_interop: ^2.1.0 package_config: ^2.0.0 path: ^1.8.0 + pool: ^1.5.1 protobuf: ^2.0.0 pub_semver: ^2.0.0 source_maps: ^0.10.10 diff --git a/test/embedded/embedded_process.dart b/test/embedded/embedded_process.dart index 68aade450..30279b0ba 100644 --- a/test/embedded/embedded_process.dart +++ b/test/embedded/embedded_process.dart @@ -13,8 +13,11 @@ import 'package:cli_pkg/testing.dart' as pkg; import 'package:test/test.dart'; import 'package:sass/src/embedded/embedded_sass.pb.dart'; +import 'package:sass/src/embedded/utils.dart'; import 'package:sass/src/embedded/util/length_delimited_transformer.dart'; +import 'utils.dart'; + /// A wrapper for [Process] that provides a convenient API for testing the /// embedded Sass process. /// @@ -27,21 +30,25 @@ class EmbeddedProcess { final Process _process; /// A [StreamQueue] that emits each outbound protocol buffer from the process. - StreamQueue get outbound => _outbound; - late StreamQueue _outbound; + /// + /// The initial int is the compilation ID. + StreamQueue<(int, OutboundMessage)> get outbound => _outbound; + late StreamQueue<(int, OutboundMessage)> _outbound; /// A [StreamQueue] that emits each line of stderr from the process. StreamQueue get stderr => _stderr; late StreamQueue _stderr; /// A splitter that can emit new copies of [outbound]. - final StreamSplitter _outboundSplitter; + final StreamSplitter<(int, OutboundMessage)> _outboundSplitter; /// A splitter that can emit new copies of [stderr]. final StreamSplitter _stderrSplitter; /// A sink into which inbound messages can be passed to the process. - final Sink inbound; + /// + /// The initial int is the compilation ID. + final Sink<(int, InboundMessage)> inbound; /// The raw standard input byte sink. IOSink get stdin => _process.stdin; @@ -98,15 +105,19 @@ class EmbeddedProcess { /// 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))), + _outboundSplitter = StreamSplitter( + process.stdout.transform(lengthDelimitedDecoder).map((packet) { + var (compilationId, buffer) = parsePacket(packet); + return (compilationId, OutboundMessage.fromBuffer(buffer)); + })), _stderrSplitter = StreamSplitter(process.stderr .transform(utf8.decoder) .transform(const LineSplitter())), - inbound = StreamSinkTransformer>.fromHandlers( - handleData: (message, sink) => - sink.add(message.writeToBuffer())).bind( + inbound = StreamSinkTransformer<(int, InboundMessage), + List>.fromHandlers(handleData: (pair, sink) { + var (compilationId, message) = pair; + sink.add(serializePacket(compilationId, message)); + }).bind( StreamSinkTransformer.fromStreamTransformer(lengthDelimitedEncoder) .bind(process.stdin)) { addTearDown(_tearDown); @@ -116,8 +127,8 @@ class EmbeddedProcess { _outbound = StreamQueue(_outboundSplitter.split()); _stderr = StreamQueue(_stderrSplitter.split()); - _outboundSplitter.split().listen((message) { - for (var line in message.toDebugString().split("\n")) { + _outboundSplitter.split().listen((pair) { + for (var line in pair.$2.toDebugString().split("\n")) { if (forwardOutput) print(line); _log.add(" $line"); } @@ -165,6 +176,25 @@ class EmbeddedProcess { printOnFailure(buffer.toString()); } + /// Sends [message] to the process with the default compilation ID. + void send(InboundMessage message) => + inbound.add((defaultCompilationId, message)); + + /// Fetches the next message from [outbound] and asserts that it has the + /// default compilation ID. + Future receive() async { + var (actualCompilationId, message) = await outbound.next; + expect(actualCompilationId, equals(defaultCompilationId), + reason: "Expected default compilation ID"); + return message; + } + + /// Closes the process's stdin and waits for it to exit gracefully. + Future close() async { + stdin.close(); + await shouldExit(0); + } + /// Kills the process (with SIGKILL on POSIX operating systems), and returns a /// future that completes once it's dead. /// diff --git a/test/embedded/file_importer_test.dart b/test/embedded/file_importer_test.dart index fb750ebb7..8d5bf4ec2 100644 --- a/test/embedded/file_importer_test.dart +++ b/test/embedded/file_importer_test.dart @@ -24,15 +24,15 @@ void main() { late OutboundMessage_FileImportRequest request; setUp(() async { - process.inbound.add(compileString("@import 'other'", importers: [ + process.send(compileString("@import 'other'", importers: [ InboundMessage_CompileRequest_Importer()..fileImporterId = 1 ])); - request = getFileImportRequest(await process.outbound.next); + request = await getFileImportRequest(process); }); test("for a response without a corresponding request ID", () async { - process.inbound.add(InboundMessage() + process.send(InboundMessage() ..fileImportResponse = (InboundMessage_FileImportResponse()..id = request.id + 1)); @@ -40,12 +40,12 @@ void main() { process, errorId, "Response ID ${request.id + 1} doesn't match any outstanding " - "requests."); - await process.kill(); + "requests in compilation $defaultCompilationId."); + await process.shouldExit(76); }); test("for a response that doesn't match the request type", () async { - process.inbound.add(InboundMessage() + process.send(InboundMessage() ..canonicalizeResponse = (InboundMessage_CanonicalizeResponse()..id = request.id)); @@ -53,8 +53,9 @@ void main() { process, errorId, "Request ID ${request.id} doesn't match response type " - "InboundMessage_CanonicalizeResponse."); - await process.kill(); + "InboundMessage_CanonicalizeResponse in compilation " + "$defaultCompilationId."); + await process.shouldExit(76); }); }); @@ -62,45 +63,45 @@ void main() { late OutboundMessage_FileImportRequest request; setUp(() async { - process.inbound.add(compileString("@import 'other'", importers: [ + process.send(compileString("@import 'other'", importers: [ InboundMessage_CompileRequest_Importer()..fileImporterId = 1 ])); - request = getFileImportRequest(await process.outbound.next); + request = await getFileImportRequest(process); }); group("for a FileImportResponse with a URL", () { test("that's empty", () async { - process.inbound.add(InboundMessage() + process.send(InboundMessage() ..fileImportResponse = (InboundMessage_FileImportResponse() ..id = request.id ..fileUrl = "")); await _expectImportError( process, 'The file importer must return an absolute URL, was ""'); - await process.kill(); + await process.close(); }); test("that's relative", () async { - process.inbound.add(InboundMessage() + process.send(InboundMessage() ..fileImportResponse = (InboundMessage_FileImportResponse() ..id = request.id ..fileUrl = "foo")); await _expectImportError(process, 'The file importer must return an absolute URL, was "foo"'); - await process.kill(); + await process.close(); }); test("that's not file:", () async { - process.inbound.add(InboundMessage() + process.send(InboundMessage() ..fileImportResponse = (InboundMessage_FileImportResponse() ..id = request.id ..fileUrl = "other:foo")); await _expectImportError(process, 'The file importer must return a file: URL, was "other:foo"'); - await process.kill(); + await process.close(); }); }); }); @@ -110,16 +111,11 @@ void main() { var importerId = 5679; late OutboundMessage_FileImportRequest request; setUp(() async { - process.inbound.add( + process.send( compileString("@import 'other'", id: compilationId, importers: [ InboundMessage_CompileRequest_Importer()..fileImporterId = importerId ])); - request = getFileImportRequest(await process.outbound.next); - }); - - test("the same compilationId as the compilation", () async { - expect(request.compilationId, equals(compilationId)); - await process.kill(); + request = await getFileImportRequest(process); }); test("a known importerId", () async { @@ -139,50 +135,50 @@ void main() { }); test("errors cause compilation to fail", () async { - process.inbound.add(compileString("@import 'other'", importers: [ + process.send(compileString("@import 'other'", importers: [ InboundMessage_CompileRequest_Importer()..fileImporterId = 1 ])); - var request = getFileImportRequest(await process.outbound.next); - process.inbound.add(InboundMessage() + var request = await getFileImportRequest(process); + process.send(InboundMessage() ..fileImportResponse = (InboundMessage_FileImportResponse() ..id = request.id ..error = "oh no")); - var failure = getCompileFailure(await process.outbound.next); + var failure = await getCompileFailure(process); expect(failure.message, equals('oh no')); expect(failure.span.text, equals("'other'")); expect(failure.stackTrace, equals('- 1:9 root stylesheet\n')); - await process.kill(); + await process.close(); }); test("null results count as not found", () async { - process.inbound.add(compileString("@import 'other'", importers: [ + process.send(compileString("@import 'other'", importers: [ InboundMessage_CompileRequest_Importer()..fileImporterId = 1 ])); - var request = getFileImportRequest(await process.outbound.next); - process.inbound.add(InboundMessage() + var request = await getFileImportRequest(process); + process.send(InboundMessage() ..fileImportResponse = (InboundMessage_FileImportResponse()..id = request.id)); - var failure = getCompileFailure(await process.outbound.next); + var failure = await getCompileFailure(process); expect(failure.message, equals("Can't find stylesheet to import.")); expect(failure.span.text, equals("'other'")); - await process.kill(); + await process.close(); }); group("attempts importers in order", () { test("with multiple file importers", () async { - process.inbound.add(compileString("@import 'other'", importers: [ + process.send(compileString("@import 'other'", importers: [ for (var i = 0; i < 10; i++) InboundMessage_CompileRequest_Importer()..fileImporterId = i ])); for (var i = 0; i < 10; i++) { - var request = getFileImportRequest(await process.outbound.next); + var request = await getFileImportRequest(process); expect(request.importerId, equals(i)); - process.inbound.add(InboundMessage() + process.send(InboundMessage() ..fileImportResponse = (InboundMessage_FileImportResponse()..id = request.id)); } @@ -191,7 +187,7 @@ void main() { }); test("with a mixture of file and normal importers", () async { - process.inbound.add(compileString("@import 'other'", importers: [ + process.send(compileString("@import 'other'", importers: [ for (var i = 0; i < 10; i++) if (i % 2 == 0) InboundMessage_CompileRequest_Importer()..fileImporterId = i @@ -201,15 +197,15 @@ void main() { for (var i = 0; i < 10; i++) { if (i % 2 == 0) { - var request = getFileImportRequest(await process.outbound.next); + var request = await getFileImportRequest(process); expect(request.importerId, equals(i)); - process.inbound.add(InboundMessage() + process.send(InboundMessage() ..fileImportResponse = (InboundMessage_FileImportResponse()..id = request.id)); } else { - var request = getCanonicalizeRequest(await process.outbound.next); + var request = await getCanonicalizeRequest(process); expect(request.importerId, equals(i)); - process.inbound.add(InboundMessage() + process.send(InboundMessage() ..canonicalizeResponse = (InboundMessage_CanonicalizeResponse()..id = request.id)); } @@ -223,29 +219,29 @@ void main() { await d.file("upstream.scss", "a {b: c}").create(); await d.file("midstream.scss", "@import 'upstream';").create(); - process.inbound.add(compileString("@import 'midstream'", importers: [ + process.send(compileString("@import 'midstream'", importers: [ for (var i = 0; i < 10; i++) InboundMessage_CompileRequest_Importer()..fileImporterId = i ])); for (var i = 0; i < 5; i++) { - var request = getFileImportRequest(await process.outbound.next); + var request = await getFileImportRequest(process); expect(request.url, equals("midstream")); expect(request.importerId, equals(i)); - process.inbound.add(InboundMessage() + process.send(InboundMessage() ..fileImportResponse = (InboundMessage_FileImportResponse()..id = request.id)); } - var request = getFileImportRequest(await process.outbound.next); + var request = await getFileImportRequest(process); expect(request.importerId, equals(5)); - process.inbound.add(InboundMessage() + process.send(InboundMessage() ..fileImportResponse = (InboundMessage_FileImportResponse() ..id = request.id ..fileUrl = p.toUri(d.path("midstream")).toString())); - await expectLater(process.outbound, emits(isSuccess("a { b: c; }"))); - await process.kill(); + await expectSuccess(process, "a { b: c; }"); + await process.close(); }); group("handles an importer for a string compile request", () { @@ -254,30 +250,30 @@ void main() { }); test("without a base URL", () async { - process.inbound.add(compileString("@import 'other'", + process.send(compileString("@import 'other'", importer: InboundMessage_CompileRequest_Importer() ..fileImporterId = 1)); - var request = getFileImportRequest(await process.outbound.next); + var request = await getFileImportRequest(process); expect(request.url, equals("other")); - process.inbound.add(InboundMessage() + process.send(InboundMessage() ..fileImportResponse = (InboundMessage_FileImportResponse() ..id = request.id ..fileUrl = p.toUri(d.path("other")).toString())); - await expectLater(process.outbound, emits(isSuccess("a { b: c; }"))); - await process.kill(); + await expectSuccess(process, "a { b: c; }"); + await process.close(); }); test("with a base URL", () async { - process.inbound.add(compileString("@import 'other'", + process.send(compileString("@import 'other'", url: p.toUri(d.path("input")).toString(), importer: InboundMessage_CompileRequest_Importer() ..fileImporterId = 1)); - await expectLater(process.outbound, emits(isSuccess("a { b: c; }"))); - await process.kill(); + await expectSuccess(process, "a { b: c; }"); + await process.close(); }); }); } @@ -285,7 +281,7 @@ void main() { /// Asserts that [process] emits a [CompileFailure] result with the given /// [message] on its protobuf stream and causes the compilation to fail. Future _expectImportError(EmbeddedProcess process, Object message) async { - var failure = getCompileFailure(await process.outbound.next); + var failure = await getCompileFailure(process); expect(failure.message, equals(message)); expect(failure.span.text, equals("'other'")); } diff --git a/test/embedded/function_test.dart b/test/embedded/function_test.dart index 1e0984f42..a8cb01512 100644 --- a/test/embedded/function_test.dart +++ b/test/embedded/function_test.dart @@ -25,99 +25,87 @@ void main() { group("emits a compile failure for a custom function with a signature", () { test("that's empty", () async { - _process.inbound.add(compileString("a {b: c}", functions: [r""])); + _process.send(compileString("a {b: c}", functions: [r""])); await _expectFunctionError( _process, r'Invalid signature "": Expected identifier.'); - await _process.kill(); + await _process.close(); }); test("that's just a name", () async { - _process.inbound.add(compileString("a {b: c}", functions: [r"foo"])); + _process.send(compileString("a {b: c}", functions: [r"foo"])); await _expectFunctionError( _process, r'Invalid signature "foo": expected "(".'); - await _process.kill(); + await _process.close(); }); test("without a closing paren", () async { - _process.inbound.add(compileString("a {b: c}", functions: [r"foo($bar"])); + _process.send(compileString("a {b: c}", functions: [r"foo($bar"])); await _expectFunctionError( _process, r'Invalid signature "foo($bar": expected ")".'); - await _process.kill(); + await _process.close(); }); test("with text after the closing paren", () async { - _process.inbound.add(compileString("a {b: c}", functions: [r"foo() "])); + _process.send(compileString("a {b: c}", functions: [r"foo() "])); await _expectFunctionError( _process, r'Invalid signature "foo() ": expected no more input.'); - await _process.kill(); + await _process.close(); }); test("with invalid arguments", () async { - _process.inbound.add(compileString("a {b: c}", functions: [r"foo($)"])); + _process.send(compileString("a {b: c}", functions: [r"foo($)"])); await _expectFunctionError( _process, r'Invalid signature "foo($)": Expected identifier.'); - await _process.kill(); + await _process.close(); }); }); group("includes in FunctionCallRequest", () { - var compilationId = 1234; - late OutboundMessage_FunctionCallRequest request; - setUp(() async { - _process.inbound.add(compileString("a {b: foo()}", - id: compilationId, functions: ["foo()"])); - request = getFunctionCallRequest(await _process.outbound.next); - }); - - test("the same compilationId as the compilation", () async { - expect(request.compilationId, equals(compilationId)); - await _process.kill(); - }); - test("the function name", () async { + _process.send(compileString("a {b: foo()}", functions: ["foo()"])); + var request = await getFunctionCallRequest(_process); expect(request.name, equals("foo")); await _process.kill(); }); group("arguments", () { test("that are empty", () async { - _process.inbound - .add(compileString("a {b: foo()}", functions: ["foo()"])); - var request = getFunctionCallRequest(await _process.outbound.next); + _process.send(compileString("a {b: foo()}", functions: ["foo()"])); + var request = await getFunctionCallRequest(_process); expect(request.arguments, isEmpty); await _process.kill(); }); test("by position", () async { - _process.inbound.add(compileString("a {b: foo(true, null, false)}", + _process.send(compileString("a {b: foo(true, null, false)}", functions: [r"foo($arg1, $arg2, $arg3)"])); - var request = getFunctionCallRequest(await _process.outbound.next); + var request = await getFunctionCallRequest(_process); expect(request.arguments, equals([_true, _null, _false])); await _process.kill(); }); test("by name", () async { - _process.inbound.add(compileString( + _process.send(compileString( r"a {b: foo($arg3: true, $arg1: null, $arg2: false)}", functions: [r"foo($arg1, $arg2, $arg3)"])); - var request = getFunctionCallRequest(await _process.outbound.next); + var request = await getFunctionCallRequest(_process); expect(request.arguments, equals([_null, _false, _true])); await _process.kill(); }); test("by position and name", () async { - _process.inbound.add(compileString( + _process.send(compileString( r"a {b: foo(true, $arg3: null, $arg2: false)}", functions: [r"foo($arg1, $arg2, $arg3)"])); - var request = getFunctionCallRequest(await _process.outbound.next); + var request = await getFunctionCallRequest(_process); expect(request.arguments, equals([_true, _false, _null])); await _process.kill(); }); test("from defaults", () async { - _process.inbound.add(compileString(r"a {b: foo(1, $arg3: 2)}", + _process.send(compileString(r"a {b: foo(1, $arg3: 2)}", functions: [r"foo($arg1: null, $arg2: true, $arg3: false)"])); - var request = getFunctionCallRequest(await _process.outbound.next); + var request = await getFunctionCallRequest(_process); expect( request.arguments, equals([ @@ -130,9 +118,9 @@ void main() { group("from argument lists", () { test("with no named arguments", () async { - _process.inbound.add(compileString("a {b: foo(true, false, null)}", + _process.send(compileString("a {b: foo(true, false, null)}", functions: [r"foo($arg, $args...)"])); - var request = getFunctionCallRequest(await _process.outbound.next); + var request = await getFunctionCallRequest(_process); expect( request.arguments, @@ -148,9 +136,9 @@ void main() { }); test("with named arguments", () async { - _process.inbound.add(compileString(r"a {b: foo(true, $arg: false)}", + _process.send(compileString(r"a {b: foo(true, $arg: false)}", functions: [r"foo($args...)"])); - var request = getFunctionCallRequest(await _process.outbound.next); + var request = await getFunctionCallRequest(_process); expect( request.arguments, @@ -166,46 +154,44 @@ void main() { }); test("throws if named arguments are unused", () async { - _process.inbound.add(compileString(r"a {b: foo($arg: false)}", + _process.send(compileString(r"a {b: foo($arg: false)}", functions: [r"foo($args...)"])); - var request = getFunctionCallRequest(await _process.outbound.next); + var request = await getFunctionCallRequest(_process); - _process.inbound.add(InboundMessage() + _process.send(InboundMessage() ..functionCallResponse = (InboundMessage_FunctionCallResponse() ..id = request.id ..success = _true)); - var failure = getCompileFailure(await _process.outbound.next); + var failure = await getCompileFailure(_process); expect(failure.message, equals(r"No argument named $arg.")); - await _process.kill(); + await _process.close(); }); test("doesn't throw if named arguments are used", () async { - _process.inbound.add(compileString(r"a {b: foo($arg: false)}", + _process.send(compileString(r"a {b: foo($arg: false)}", functions: [r"foo($args...)"])); - var request = getFunctionCallRequest(await _process.outbound.next); + var request = await getFunctionCallRequest(_process); - _process.inbound.add(InboundMessage() + _process.send(InboundMessage() ..functionCallResponse = (InboundMessage_FunctionCallResponse() ..id = request.id ..accessedArgumentLists .add(request.arguments.first.argumentList.id) ..success = _true)); - await expectLater(_process.outbound, - emits(isSuccess(equals("a {\n b: true;\n}")))); - await _process.kill(); + await expectSuccess(_process, equals("a {\n b: true;\n}")); + await _process.close(); }); }); }); }); test("returns the result as a SassScript value", () async { - _process.inbound - .add(compileString("a {b: foo() + 2px}", functions: [r"foo()"])); - var request = getFunctionCallRequest(await _process.outbound.next); + _process.send(compileString("a {b: foo() + 2px}", functions: [r"foo()"])); + var request = await getFunctionCallRequest(_process); - _process.inbound.add(InboundMessage() + _process.send(InboundMessage() ..functionCallResponse = (InboundMessage_FunctionCallResponse() ..id = request.id ..success = (Value() @@ -213,41 +199,38 @@ void main() { ..value = 1 ..numerators.add("px"))))); - await expectLater( - _process.outbound, emits(isSuccess(equals("a {\n b: 3px;\n}")))); - await _process.kill(); + await expectSuccess(_process, equals("a {\n b: 3px;\n}")); + await _process.close(); }); group("calls a first-class function", () { test("defined in the compiler and passed to and from the host", () async { - _process.inbound.add(compileString(r""" + _process.send(compileString(r""" @use "sass:math"; @use "sass:meta"; a {b: call(foo(meta.get-function("abs", $module: "math")), -1)} """, functions: [r"foo($arg)"])); - var request = getFunctionCallRequest(await _process.outbound.next); + var request = await getFunctionCallRequest(_process); var value = request.arguments.single; expect(value.hasCompilerFunction(), isTrue); - _process.inbound.add(InboundMessage() + _process.send(InboundMessage() ..functionCallResponse = (InboundMessage_FunctionCallResponse() ..id = request.id ..success = value)); - await expectLater( - _process.outbound, emits(isSuccess(equals("a {\n b: 1;\n}")))); - await _process.kill(); + await expectSuccess(_process, equals("a {\n b: 1;\n}")); + await _process.close(); }); test("defined in the host", () async { - var compilationId = 1234; - _process.inbound.add(compileString("a {b: call(foo(), true)}", - id: compilationId, functions: [r"foo()"])); + _process.send( + compileString("a {b: call(foo(), true)}", functions: [r"foo()"])); var hostFunctionId = 5678; - var request = getFunctionCallRequest(await _process.outbound.next); - _process.inbound.add(InboundMessage() + var request = await getFunctionCallRequest(_process); + _process.send(InboundMessage() ..functionCallResponse = (InboundMessage_FunctionCallResponse() ..id = request.id ..success = (Value() @@ -255,36 +238,30 @@ void main() { ..id = hostFunctionId ..signature = r"bar($arg)")))); - request = getFunctionCallRequest(await _process.outbound.next); - expect(request.compilationId, equals(compilationId)); + request = await getFunctionCallRequest(_process); expect(request.functionId, equals(hostFunctionId)); expect(request.arguments, equals([_true])); - _process.inbound.add(InboundMessage() + _process.send(InboundMessage() ..functionCallResponse = (InboundMessage_FunctionCallResponse() ..id = request.id ..success = _false)); - await expectLater( - _process.outbound, emits(isSuccess(equals("a {\n b: false;\n}")))); - await _process.kill(); + await expectSuccess(_process, equals("a {\n b: false;\n}")); + await _process.close(); }); test("defined in the host and passed to and from the host", () async { - var compilationId = 1234; - _process.inbound.add(compileString( - r""" + _process.send(compileString(r""" $function: get-host-function(); $function: round-trip($function); a {b: call($function, true)} - """, - id: compilationId, - functions: [r"get-host-function()", r"round-trip($function)"])); + """, functions: [r"get-host-function()", r"round-trip($function)"])); var hostFunctionId = 5678; - var request = getFunctionCallRequest(await _process.outbound.next); + var request = await getFunctionCallRequest(_process); expect(request.name, equals("get-host-function")); - _process.inbound.add(InboundMessage() + _process.send(InboundMessage() ..functionCallResponse = (InboundMessage_FunctionCallResponse() ..id = request.id ..success = (Value() @@ -292,28 +269,26 @@ void main() { ..id = hostFunctionId ..signature = r"bar($arg)")))); - request = getFunctionCallRequest(await _process.outbound.next); + request = await getFunctionCallRequest(_process); expect(request.name, equals("round-trip")); var value = request.arguments.single; expect(value.hasCompilerFunction(), isTrue); - _process.inbound.add(InboundMessage() + _process.send(InboundMessage() ..functionCallResponse = (InboundMessage_FunctionCallResponse() ..id = request.id ..success = value)); - request = getFunctionCallRequest(await _process.outbound.next); - expect(request.compilationId, equals(compilationId)); + request = await getFunctionCallRequest(_process); expect(request.functionId, equals(hostFunctionId)); expect(request.arguments, equals([_true])); - _process.inbound.add(InboundMessage() + _process.send(InboundMessage() ..functionCallResponse = (InboundMessage_FunctionCallResponse() ..id = request.id ..success = _false)); - await expectLater( - _process.outbound, emits(isSuccess(equals("a {\n b: false;\n}")))); - await _process.kill(); + await expectSuccess(_process, equals("a {\n b: false;\n}")); + await _process.close(); }); }); @@ -1767,12 +1742,11 @@ void main() { test("reports a compilation failure when simplification fails", () async { - _process.inbound - .add(compileString("a {b: foo()}", functions: [r"foo()"])); + _process.send(compileString("a {b: foo()}", functions: [r"foo()"])); - var request = getFunctionCallRequest(await _process.outbound.next); + var request = await getFunctionCallRequest(_process); expect(request.arguments, isEmpty); - _process.inbound.add(InboundMessage() + _process.send(InboundMessage() ..functionCallResponse = (InboundMessage_FunctionCallResponse() ..id = request.id ..success = (Value() @@ -1787,21 +1761,21 @@ void main() { ..value = 2.0 ..numerators.add("s"))))))); - var failure = getCompileFailure(await _process.outbound.next); + var failure = await getCompileFailure(_process); expect(failure.message, equals("1px and 2s are incompatible.")); - expect(_process.kill(), completes); + expect(_process.close(), completes); }); }); group("reports a compilation error for a function with a signature", () { Future expectSignatureError( String signature, Object message) async { - _process.inbound.add( + _process.send( compileString("a {b: inspect(foo())}", functions: [r"foo()"])); - var request = getFunctionCallRequest(await _process.outbound.next); + var request = await getFunctionCallRequest(_process); expect(request.arguments, isEmpty); - _process.inbound.add(InboundMessage() + _process.send(InboundMessage() ..functionCallResponse = (InboundMessage_FunctionCallResponse() ..id = request.id ..success = (Value() @@ -1809,9 +1783,9 @@ void main() { ..id = 1234 ..signature = signature)))); - var failure = getCompileFailure(await _process.outbound.next); + var failure = await getCompileFailure(_process); expect(failure.message, message); - expect(_process.kill(), completes); + expect(_process.close(), completes); } test("that's empty", () async { @@ -1846,7 +1820,7 @@ void main() { /// Evaluates [sassScript] in the compiler, passes it to a custom function, and /// returns the protocol buffer result. Future _protofy(String sassScript) async { - _process.inbound.add(compileString(""" + _process.send(compileString(""" @use 'sass:list'; @use 'sass:map'; @use 'sass:math'; @@ -1859,7 +1833,7 @@ Future _protofy(String sassScript) async { \$_: foo(($sassScript)); """, functions: [r"foo($arg)"])); - var request = getFunctionCallRequest(await _process.outbound.next); + var request = await getFunctionCallRequest(_process); expect(_process.kill(), completes); return request.arguments.single; } @@ -1883,30 +1857,30 @@ void _testSerializationAndRoundTrip(Value value, String expected, /// If [inspect] is true, this returns the value as serialized by the /// `meta.inspect()` function. Future _deprotofy(Value value, {bool inspect = false}) async { - _process.inbound.add(compileString( + _process.send(compileString( inspect ? "a {b: inspect(foo())}" : "a {b: foo()}", functions: [r"foo()"])); - var request = getFunctionCallRequest(await _process.outbound.next); + var request = await getFunctionCallRequest(_process); expect(request.arguments, isEmpty); - _process.inbound.add(InboundMessage() + _process.send(InboundMessage() ..functionCallResponse = (InboundMessage_FunctionCallResponse() ..id = request.id ..success = value)); - var success = getCompileSuccess(await _process.outbound.next); - expect(_process.kill(), completes); + var success = await getCompileSuccess(_process); + expect(_process.close(), completes); return RegExp(r" b: (.*);").firstMatch(success.css)![1]!; } /// Asserts that [value] causes a parameter error with a message matching /// [message] when deserializing it from a protocol buffer. Future _expectDeprotofyError(Value value, Object message) async { - _process.inbound.add(compileString("a {b: foo()}", functions: [r"foo()"])); + _process.send(compileString("a {b: foo()}", functions: [r"foo()"])); - var request = getFunctionCallRequest(await _process.outbound.next); + var request = await getFunctionCallRequest(_process); expect(request.arguments, isEmpty); - _process.inbound.add(InboundMessage() + _process.send(InboundMessage() ..functionCallResponse = (InboundMessage_FunctionCallResponse() ..id = request.id ..success = value)); @@ -1928,18 +1902,18 @@ Future _assertRoundTrips(Value value) async => /// Sends [value] to the compiler to convert to a native Sass value, then sends /// it back out to the host as a protocol buffer and returns the result. Future _roundTrip(Value value) async { - _process.inbound.add(compileString(""" + _process.send(compileString(""" \$_: outbound(inbound()); """, functions: ["inbound()", r"outbound($arg)"])); - var request = getFunctionCallRequest(await _process.outbound.next); + var request = await getFunctionCallRequest(_process); expect(request.arguments, isEmpty); - _process.inbound.add(InboundMessage() + _process.send(InboundMessage() ..functionCallResponse = (InboundMessage_FunctionCallResponse() ..id = request.id ..success = value)); - request = getFunctionCallRequest(await _process.outbound.next); + request = await getFunctionCallRequest(_process); expect(_process.kill(), completes); return request.arguments.single; } @@ -1964,6 +1938,6 @@ Value _hsl(num hue, num saturation, num lightness, double alpha) => Value() /// [message] on its protobuf stream and causes the compilation to fail. Future _expectFunctionError( EmbeddedProcess process, Object message) async { - var failure = getCompileFailure(await process.outbound.next); + var failure = await getCompileFailure(process); expect(failure.message, equals(message)); } diff --git a/test/embedded/importer_test.dart b/test/embedded/importer_test.dart index 858867883..71574cb4e 100644 --- a/test/embedded/importer_test.dart +++ b/test/embedded/importer_test.dart @@ -22,12 +22,12 @@ void main() { group("emits a protocol error", () { test("for a response without a corresponding request ID", () async { - process.inbound.add(compileString("@import 'other'", importers: [ + process.send(compileString("@import 'other'", importers: [ InboundMessage_CompileRequest_Importer()..importerId = 1 ])); - var request = getCanonicalizeRequest(await process.outbound.next); - process.inbound.add(InboundMessage() + var request = await getCanonicalizeRequest(process); + process.send(InboundMessage() ..canonicalizeResponse = (InboundMessage_CanonicalizeResponse()..id = request.id + 1)); @@ -35,87 +35,80 @@ void main() { process, errorId, "Response ID ${request.id + 1} doesn't match any outstanding " - "requests."); - await process.kill(); + "requests in compilation $defaultCompilationId."); + await process.shouldExit(76); }); test("for a response that doesn't match the request type", () async { - process.inbound.add(compileString("@import 'other'", importers: [ + process.send(compileString("@import 'other'", importers: [ InboundMessage_CompileRequest_Importer()..importerId = 1 ])); - var request = getCanonicalizeRequest(await process.outbound.next); - process.inbound.add(InboundMessage() + var request = await getCanonicalizeRequest(process); + process.send(InboundMessage() ..importResponse = (InboundMessage_ImportResponse()..id = request.id)); await expectParamsError( process, errorId, "Request ID ${request.id} doesn't match response type " - "InboundMessage_ImportResponse."); - await process.kill(); + "InboundMessage_ImportResponse in compilation " + "$defaultCompilationId."); + await process.shouldExit(76); }); test("for an unset importer", () async { - process.inbound.add(compileString("a {b: c}", + process.send(compileString("a {b: c}", importers: [InboundMessage_CompileRequest_Importer()])); await expectParamsError( - process, 0, "Missing mandatory field Importer.importer"); - await process.kill(); + process, errorId, "Missing mandatory field Importer.importer"); + await process.shouldExit(76); }); }); group("canonicalization", () { group("emits a compile failure", () { test("for a canonicalize response with an empty URL", () async { - process.inbound.add(compileString("@import 'other'", importers: [ + process.send(compileString("@import 'other'", importers: [ InboundMessage_CompileRequest_Importer()..importerId = 1 ])); - var request = getCanonicalizeRequest(await process.outbound.next); - process.inbound.add(InboundMessage() + var request = await getCanonicalizeRequest(process); + process.send(InboundMessage() ..canonicalizeResponse = (InboundMessage_CanonicalizeResponse() ..id = request.id ..url = "")); await _expectImportError( process, 'The importer must return an absolute URL, was ""'); - await process.kill(); + await process.close(); }); test("for a canonicalize response with a relative URL", () async { - process.inbound.add(compileString("@import 'other'", importers: [ + process.send(compileString("@import 'other'", importers: [ InboundMessage_CompileRequest_Importer()..importerId = 1 ])); - var request = getCanonicalizeRequest(await process.outbound.next); - process.inbound.add(InboundMessage() + var request = await getCanonicalizeRequest(process); + process.send(InboundMessage() ..canonicalizeResponse = (InboundMessage_CanonicalizeResponse() ..id = request.id ..url = "relative")); await _expectImportError(process, 'The importer must return an absolute URL, was "relative"'); - await process.kill(); + await process.close(); }); }); group("includes in CanonicalizeRequest", () { - var compilationId = 1234; var importerId = 5679; late OutboundMessage_CanonicalizeRequest request; setUp(() async { - process.inbound.add(compileString("@import 'other'", - id: compilationId, - importers: [ - InboundMessage_CompileRequest_Importer()..importerId = importerId - ])); - request = getCanonicalizeRequest(await process.outbound.next); - }); - - test("the same compilationId as the compilation", () async { - expect(request.compilationId, equals(compilationId)); - await process.kill(); + process.send(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..importerId = importerId + ])); + request = await getCanonicalizeRequest(process); }); test("a known importerId", () async { @@ -130,86 +123,86 @@ void main() { }); test("errors cause compilation to fail", () async { - process.inbound.add(compileString("@import 'other'", importers: [ + process.send(compileString("@import 'other'", importers: [ InboundMessage_CompileRequest_Importer()..importerId = 1 ])); - var request = getCanonicalizeRequest(await process.outbound.next); - process.inbound.add(InboundMessage() + var request = await getCanonicalizeRequest(process); + process.send(InboundMessage() ..canonicalizeResponse = (InboundMessage_CanonicalizeResponse() ..id = request.id ..error = "oh no")); - var failure = getCompileFailure(await process.outbound.next); + var failure = await getCompileFailure(process); expect(failure.message, equals('oh no')); expect(failure.span.text, equals("'other'")); expect(failure.stackTrace, equals('- 1:9 root stylesheet\n')); - await process.kill(); + await process.close(); }); test("null results count as not found", () async { - process.inbound.add(compileString("@import 'other'", importers: [ + process.send(compileString("@import 'other'", importers: [ InboundMessage_CompileRequest_Importer()..importerId = 1 ])); - var request = getCanonicalizeRequest(await process.outbound.next); - process.inbound.add(InboundMessage() + var request = await getCanonicalizeRequest(process); + process.send(InboundMessage() ..canonicalizeResponse = (InboundMessage_CanonicalizeResponse()..id = request.id)); - var failure = getCompileFailure(await process.outbound.next); + var failure = await getCompileFailure(process); expect(failure.message, equals("Can't find stylesheet to import.")); expect(failure.span.text, equals("'other'")); - await process.kill(); + await process.close(); }); test("attempts importers in order", () async { - process.inbound.add(compileString("@import 'other'", importers: [ + process.send(compileString("@import 'other'", importers: [ for (var i = 0; i < 10; i++) InboundMessage_CompileRequest_Importer()..importerId = i ])); for (var i = 0; i < 10; i++) { - var request = getCanonicalizeRequest(await process.outbound.next); + var request = await getCanonicalizeRequest(process); expect(request.importerId, equals(i)); - process.inbound.add(InboundMessage() + process.send(InboundMessage() ..canonicalizeResponse = (InboundMessage_CanonicalizeResponse()..id = request.id)); } - await process.kill(); + await process.close(); }); test("tries resolved URL using the original importer first", () async { - process.inbound.add(compileString("@import 'midstream'", importers: [ + process.send(compileString("@import 'midstream'", importers: [ for (var i = 0; i < 10; i++) InboundMessage_CompileRequest_Importer()..importerId = i ])); for (var i = 0; i < 5; i++) { - var request = getCanonicalizeRequest(await process.outbound.next); + var request = await getCanonicalizeRequest(process); expect(request.url, equals("midstream")); expect(request.importerId, equals(i)); - process.inbound.add(InboundMessage() + process.send(InboundMessage() ..canonicalizeResponse = (InboundMessage_CanonicalizeResponse()..id = request.id)); } - var canonicalize = getCanonicalizeRequest(await process.outbound.next); + var canonicalize = await getCanonicalizeRequest(process); expect(canonicalize.importerId, equals(5)); - process.inbound.add(InboundMessage() + process.send(InboundMessage() ..canonicalizeResponse = (InboundMessage_CanonicalizeResponse() ..id = canonicalize.id ..url = "custom:foo/bar")); - var import = getImportRequest(await process.outbound.next); - process.inbound.add(InboundMessage() + var import = await getImportRequest(process); + process.send(InboundMessage() ..importResponse = (InboundMessage_ImportResponse() ..id = import.id ..success = (InboundMessage_ImportResponse_ImportSuccess() ..contents = "@import 'upstream'"))); - canonicalize = getCanonicalizeRequest(await process.outbound.next); + canonicalize = await getCanonicalizeRequest(process); expect(canonicalize.importerId, equals(5)); expect(canonicalize.url, equals("custom:foo/upstream")); @@ -220,13 +213,13 @@ void main() { group("importing", () { group("emits a compile failure", () { test("for an import result with a relative sourceMapUrl", () async { - process.inbound.add(compileString("@import 'other'", importers: [ + process.send(compileString("@import 'other'", importers: [ InboundMessage_CompileRequest_Importer()..importerId = 1 ])); await _canonicalize(process); - var import = getImportRequest(await process.outbound.next); - process.inbound.add(InboundMessage() + var import = await getImportRequest(process); + process.send(InboundMessage() ..importResponse = (InboundMessage_ImportResponse() ..id = import.id ..success = (InboundMessage_ImportResponse_ImportSuccess() @@ -234,33 +227,25 @@ void main() { await _expectImportError(process, 'The importer must return an absolute URL, was "relative"'); - await process.kill(); + await process.close(); }); }); group("includes in ImportRequest", () { - var compilationId = 1234; var importerId = 5678; late OutboundMessage_ImportRequest request; setUp(() async { - process.inbound.add(compileString("@import 'other'", - id: compilationId, - importers: [ - InboundMessage_CompileRequest_Importer()..importerId = importerId - ])); - - var canonicalize = getCanonicalizeRequest(await process.outbound.next); - process.inbound.add(InboundMessage() + process.send(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..importerId = importerId + ])); + + var canonicalize = await getCanonicalizeRequest(process); + process.send(InboundMessage() ..canonicalizeResponse = (InboundMessage_CanonicalizeResponse() ..id = canonicalize.id ..url = "custom:foo")); - request = getImportRequest(await process.outbound.next); - }); - - test("the same compilationId as the compilation", () async { - expect(request.compilationId, equals(compilationId)); - await process.kill(); + request = await getImportRequest(process); }); test("a known importerId", () async { @@ -275,177 +260,172 @@ void main() { }); test("null results count as not found", () async { - process.inbound.add(compileString("@import 'other'", importers: [ + process.send(compileString("@import 'other'", importers: [ InboundMessage_CompileRequest_Importer()..importerId = 1 ])); - var canonicalizeRequest = - getCanonicalizeRequest(await process.outbound.next); - process.inbound.add(InboundMessage() + var canonicalizeRequest = await getCanonicalizeRequest(process); + process.send(InboundMessage() ..canonicalizeResponse = (InboundMessage_CanonicalizeResponse() ..id = canonicalizeRequest.id ..url = "o:other")); - var importRequest = getImportRequest(await process.outbound.next); - process.inbound.add(InboundMessage() + var importRequest = await getImportRequest(process); + process.send(InboundMessage() ..importResponse = (InboundMessage_ImportResponse()..id = importRequest.id)); - var failure = getCompileFailure(await process.outbound.next); + var failure = await getCompileFailure(process); expect(failure.message, equals("Can't find stylesheet to import.")); expect(failure.span.text, equals("'other'")); - await process.kill(); + await process.close(); }); test("errors cause compilation to fail", () async { - process.inbound.add(compileString("@import 'other'", importers: [ + process.send(compileString("@import 'other'", importers: [ InboundMessage_CompileRequest_Importer()..importerId = 1 ])); await _canonicalize(process); - var request = getImportRequest(await process.outbound.next); - process.inbound.add(InboundMessage() + var request = await getImportRequest(process); + process.send(InboundMessage() ..importResponse = (InboundMessage_ImportResponse() ..id = request.id ..error = "oh no")); - var failure = getCompileFailure(await process.outbound.next); + var failure = await getCompileFailure(process); expect(failure.message, equals('oh no')); expect(failure.span.text, equals("'other'")); expect(failure.stackTrace, equals('- 1:9 root stylesheet\n')); - await process.kill(); + await process.close(); }); test("can return an SCSS file", () async { - process.inbound.add(compileString("@import 'other'", importers: [ + process.send(compileString("@import 'other'", importers: [ InboundMessage_CompileRequest_Importer()..importerId = 1 ])); await _canonicalize(process); - var request = getImportRequest(await process.outbound.next); - process.inbound.add(InboundMessage() + var request = await getImportRequest(process); + process.send(InboundMessage() ..importResponse = (InboundMessage_ImportResponse() ..id = request.id ..success = (InboundMessage_ImportResponse_ImportSuccess() ..contents = "a {b: 1px + 2px}"))); - await expectLater(process.outbound, emits(isSuccess("a { b: 3px; }"))); - await process.kill(); + await expectSuccess(process, "a { b: 3px; }"); + await process.close(); }); test("can return an indented syntax file", () async { - process.inbound.add(compileString("@import 'other'", importers: [ + process.send(compileString("@import 'other'", importers: [ InboundMessage_CompileRequest_Importer()..importerId = 1 ])); await _canonicalize(process); - var request = getImportRequest(await process.outbound.next); - process.inbound.add(InboundMessage() + var request = await getImportRequest(process); + process.send(InboundMessage() ..importResponse = (InboundMessage_ImportResponse() ..id = request.id ..success = (InboundMessage_ImportResponse_ImportSuccess() ..contents = "a\n b: 1px + 2px" ..syntax = Syntax.INDENTED))); - await expectLater(process.outbound, emits(isSuccess("a { b: 3px; }"))); - await process.kill(); + await expectSuccess(process, "a { b: 3px; }"); + await process.close(); }); test("can return a plain CSS file", () async { - process.inbound.add(compileString("@import 'other'", importers: [ + process.send(compileString("@import 'other'", importers: [ InboundMessage_CompileRequest_Importer()..importerId = 1 ])); await _canonicalize(process); - var request = getImportRequest(await process.outbound.next); - process.inbound.add(InboundMessage() + var request = await getImportRequest(process); + process.send(InboundMessage() ..importResponse = (InboundMessage_ImportResponse() ..id = request.id ..success = (InboundMessage_ImportResponse_ImportSuccess() ..contents = "a {b: c}" ..syntax = Syntax.CSS))); - await expectLater(process.outbound, emits(isSuccess("a { b: c; }"))); - await process.kill(); + await expectSuccess(process, "a { b: c; }"); + await process.close(); }); test("uses a data: URL rather than an empty source map URL", () async { - process.inbound.add(compileString("@import 'other'", + process.send(compileString("@import 'other'", sourceMap: true, importers: [ InboundMessage_CompileRequest_Importer()..importerId = 1 ])); await _canonicalize(process); - var request = getImportRequest(await process.outbound.next); - process.inbound.add(InboundMessage() + var request = await getImportRequest(process); + process.send(InboundMessage() ..importResponse = (InboundMessage_ImportResponse() ..id = request.id ..success = (InboundMessage_ImportResponse_ImportSuccess() ..contents = "a {b: c}" ..sourceMapUrl = ""))); - await expectLater( - process.outbound, - emits(isSuccess("a { b: c; }", sourceMap: (String map) { - var mapping = source_maps.parse(map) as source_maps.SingleMapping; - expect(mapping.urls, [startsWith("data:")]); - }))); - await process.kill(); + await expectSuccess(process, "a { b: c; }", sourceMap: (String map) { + var mapping = source_maps.parse(map) as source_maps.SingleMapping; + expect(mapping.urls, [startsWith("data:")]); + }); + await process.close(); }); test("uses a non-empty source map URL", () async { - process.inbound.add(compileString("@import 'other'", + process.send(compileString("@import 'other'", sourceMap: true, importers: [ InboundMessage_CompileRequest_Importer()..importerId = 1 ])); await _canonicalize(process); - var request = getImportRequest(await process.outbound.next); - process.inbound.add(InboundMessage() + var request = await getImportRequest(process); + process.send(InboundMessage() ..importResponse = (InboundMessage_ImportResponse() ..id = request.id ..success = (InboundMessage_ImportResponse_ImportSuccess() ..contents = "a {b: c}" ..sourceMapUrl = "file:///asdf"))); - await expectLater( - process.outbound, - emits(isSuccess("a { b: c; }", sourceMap: (String map) { - var mapping = source_maps.parse(map) as source_maps.SingleMapping; - expect(mapping.urls, equals(["file:///asdf"])); - }))); - await process.kill(); + await expectSuccess(process, "a { b: c; }", sourceMap: (String map) { + var mapping = source_maps.parse(map) as source_maps.SingleMapping; + expect(mapping.urls, equals(["file:///asdf"])); + }); + await process.close(); }); }); test("handles an importer for a string compile request", () async { - process.inbound.add(compileString("@import 'other'", + process.send(compileString("@import 'other'", importer: InboundMessage_CompileRequest_Importer()..importerId = 1)); await _canonicalize(process); - var request = getImportRequest(await process.outbound.next); - process.inbound.add(InboundMessage() + var request = await getImportRequest(process); + process.send(InboundMessage() ..importResponse = (InboundMessage_ImportResponse() ..id = request.id ..success = (InboundMessage_ImportResponse_ImportSuccess() ..contents = "a {b: 1px + 2px}"))); - await expectLater(process.outbound, emits(isSuccess("a { b: 3px; }"))); - await process.kill(); + await expectSuccess(process, "a { b: 3px; }"); + await process.close(); }); group("load paths", () { test("are used to load imports", () async { await d.dir("dir", [d.file("other.scss", "a {b: c}")]).create(); - process.inbound.add(compileString("@import 'other'", importers: [ + process.send(compileString("@import 'other'", importers: [ InboundMessage_CompileRequest_Importer()..path = d.path("dir") ])); - await expectLater(process.outbound, emits(isSuccess("a { b: c; }"))); - await process.kill(); + await expectSuccess(process, "a { b: c; }"); + await process.close(); }); test("are accessed in order", () async { @@ -453,45 +433,45 @@ void main() { await d.dir("dir$i", [d.file("other$i.scss", "a {b: $i}")]).create(); } - process.inbound.add(compileString("@import 'other2'", importers: [ + process.send(compileString("@import 'other2'", importers: [ for (var i = 0; i < 3; i++) InboundMessage_CompileRequest_Importer()..path = d.path("dir$i") ])); - await expectLater(process.outbound, emits(isSuccess("a { b: 2; }"))); - await process.kill(); + await expectSuccess(process, "a { b: 2; }"); + await process.close(); }); test("take precedence over later importers", () async { await d.dir("dir", [d.file("other.scss", "a {b: c}")]).create(); - process.inbound.add(compileString("@import 'other'", importers: [ + process.send(compileString("@import 'other'", importers: [ InboundMessage_CompileRequest_Importer()..path = d.path("dir"), InboundMessage_CompileRequest_Importer()..importerId = 1 ])); - await expectLater(process.outbound, emits(isSuccess("a { b: c; }"))); - await process.kill(); + await expectSuccess(process, "a { b: c; }"); + await process.close(); }); test("yield precedence to earlier importers", () async { await d.dir("dir", [d.file("other.scss", "a {b: c}")]).create(); - process.inbound.add(compileString("@import 'other'", importers: [ + process.send(compileString("@import 'other'", importers: [ InboundMessage_CompileRequest_Importer()..importerId = 1, InboundMessage_CompileRequest_Importer()..path = d.path("dir") ])); await _canonicalize(process); - var request = getImportRequest(await process.outbound.next); - process.inbound.add(InboundMessage() + var request = await getImportRequest(process); + process.send(InboundMessage() ..importResponse = (InboundMessage_ImportResponse() ..id = request.id ..success = (InboundMessage_ImportResponse_ImportSuccess() ..contents = "x {y: z}"))); - await expectLater(process.outbound, emits(isSuccess("x { y: z; }"))); - await process.kill(); + await expectSuccess(process, "x { y: z; }"); + await process.close(); }); }); } @@ -503,8 +483,8 @@ void main() { /// generic code for canonicalization. It shouldn't be used for testing /// canonicalization itself. Future _canonicalize(EmbeddedProcess process) async { - var request = getCanonicalizeRequest(await process.outbound.next); - process.inbound.add(InboundMessage() + var request = await getCanonicalizeRequest(process); + process.send(InboundMessage() ..canonicalizeResponse = (InboundMessage_CanonicalizeResponse() ..id = request.id ..url = "custom:other")); @@ -513,7 +493,7 @@ Future _canonicalize(EmbeddedProcess process) async { /// Asserts that [process] emits a [CompileFailure] result with the given /// [message] on its protobuf stream and causes the compilation to fail. Future _expectImportError(EmbeddedProcess process, Object message) async { - var failure = getCompileFailure(await process.outbound.next); + var failure = await getCompileFailure(process); expect(failure.message, equals(message)); expect(failure.span.text, equals("'other'")); } diff --git a/test/embedded/protocol_test.dart b/test/embedded/protocol_test.dart index 830ac5ad5..663c28a5e 100644 --- a/test/embedded/protocol_test.dart +++ b/test/embedded/protocol_test.dart @@ -11,6 +11,7 @@ import 'package:test/test.dart'; import 'package:test_descriptor/test_descriptor.dart' as d; import 'package:sass/src/embedded/embedded_sass.pb.dart'; +import 'package:sass/src/embedded/utils.dart'; import 'embedded_process.dart'; import 'utils.dart'; @@ -23,175 +24,275 @@ void main() { group("exits upon protocol error", () { test("caused by an empty message", () async { - process.inbound.add(InboundMessage()); + process.send(InboundMessage()); await expectParseError(process, "InboundMessage.message is not set."); - expect(await process.exitCode, 76); + await process.shouldExit(76); }); - test("caused by an invalid message", () async { - process.stdin.add([1, 0]); + test("caused by an unterminated compilation ID varint", () async { + process.stdin.add([1, 0x81]); await expectParseError( - process, "Protocol message contained an invalid tag (zero)."); - expect(await process.exitCode, 76); + process, "Invalid compilation ID: continuation bit always set.", + compilationId: errorId); + await process.shouldExit(76); + }); + + test("caused by a 33-bit compilation ID varint", () async { + var varint = serializeVarint(0x100000000); + process.stdin.add([...serializeVarint(varint.length), ...varint]); + await expectParseError( + process, "Varint compilation ID was longer than 32 bits.", + compilationId: errorId); + await process.shouldExit(76); + }); + + test("caused by an invalid protobuf", () async { + process.stdin.add([2, 1, 0]); + await expectParseError( + process, "Protocol message contained an invalid tag (zero).", + compilationId: 1); + await process.shouldExit(76); + }); + + test("caused by a response to an inactive compilation", () async { + process.send(InboundMessage() + ..canonicalizeResponse = + (InboundMessage_CanonicalizeResponse()..id = 1)); + await expectParamsError( + process, + errorId, + "Response ID 1 doesn't match any outstanding requests in " + "compilation $defaultCompilationId."); + await process.shouldExit(76); + }); + + test("caused by duplicate compilation IDs", () async { + process.send(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); + await getCanonicalizeRequest(process); + + process.send(compileString("a {b: c}")); + await expectParamsError( + process, + errorId, + "A CompileRequest with compilation ID $defaultCompilationId is " + "already active."); + await process.shouldExit(76); }); }); test("a version response is valid", () async { - process.inbound.add(InboundMessage() - ..versionRequest = (InboundMessage_VersionRequest()..id = 123)); - var response = (await process.outbound.next).versionResponse; + process.inbound.add(( + 0, + InboundMessage() + ..versionRequest = (InboundMessage_VersionRequest()..id = 123) + )); + var (compilationId, OutboundMessage(versionResponse: response)) = + await process.outbound.next; + expect(compilationId, equals(0)); expect(response.id, equals(123)); Version.parse(response.protocolVersion); // shouldn't throw Version.parse(response.compilerVersion); // shouldn't throw Version.parse(response.implementationVersion); // shouldn't throw expect(response.implementationName, equals("Dart Sass")); - await process.kill(); + await process.close(); }); group("compiles CSS from", () { test("an SCSS string by default", () async { - process.inbound.add(compileString("a {b: 1px + 2px}")); - await expectLater(process.outbound, emits(isSuccess("a { b: 3px; }"))); - await process.kill(); + process.send(compileString("a {b: 1px + 2px}")); + await expectSuccess(process, "a { b: 3px; }"); + await process.close(); }); test("an SCSS string explicitly", () async { - process.inbound - .add(compileString("a {b: 1px + 2px}", syntax: Syntax.SCSS)); - await expectLater(process.outbound, emits(isSuccess("a { b: 3px; }"))); - await process.kill(); + process.send(compileString("a {b: 1px + 2px}", syntax: Syntax.SCSS)); + await expectSuccess(process, "a { b: 3px; }"); + await process.close(); }); test("an indented syntax string", () async { - process.inbound - .add(compileString("a\n b: 1px + 2px", syntax: Syntax.INDENTED)); - await expectLater(process.outbound, emits(isSuccess("a { b: 3px; }"))); - await process.kill(); + process.send(compileString("a\n b: 1px + 2px", syntax: Syntax.INDENTED)); + await expectSuccess(process, "a { b: 3px; }"); + await process.close(); }); test("a plain CSS string", () async { - process.inbound.add(compileString("a {b: c}", syntax: Syntax.CSS)); - await expectLater(process.outbound, emits(isSuccess("a { b: c; }"))); - await process.kill(); + process.send(compileString("a {b: c}", syntax: Syntax.CSS)); + await expectSuccess(process, "a { b: c; }"); + await process.close(); }); test("an absolute path", () async { await d.file("test.scss", "a {b: 1px + 2px}").create(); - process.inbound.add(InboundMessage() + process.send(InboundMessage() ..compileRequest = (InboundMessage_CompileRequest() ..path = p.absolute(d.path("test.scss")))); - await expectLater(process.outbound, emits(isSuccess("a { b: 3px; }"))); - await process.kill(); + await expectSuccess(process, "a { b: 3px; }"); + await process.close(); }); test("a relative path", () async { await d.file("test.scss", "a {b: 1px + 2px}").create(); - process.inbound.add(InboundMessage() + process.send(InboundMessage() ..compileRequest = (InboundMessage_CompileRequest() ..path = p.relative(d.path("test.scss")))); - await expectLater(process.outbound, emits(isSuccess("a { b: 3px; }"))); - await process.kill(); + await expectSuccess(process, "a { b: 3px; }"); + await process.close(); }); }); group("compiles CSS in", () { test("expanded mode", () async { - process.inbound - .add(compileString("a {b: 1px + 2px}", style: OutputStyle.EXPANDED)); - await expectLater( - process.outbound, emits(isSuccess(equals("a {\n b: 3px;\n}")))); - await process.kill(); + process + .send(compileString("a {b: 1px + 2px}", style: OutputStyle.EXPANDED)); + await expectSuccess(process, equals("a {\n b: 3px;\n}")); + await process.close(); }); test("compressed mode", () async { - process.inbound.add( + process.send( compileString("a {b: 1px + 2px}", style: OutputStyle.COMPRESSED)); - await expectLater(process.outbound, emits(isSuccess(equals("a{b:3px}")))); - await process.kill(); + await expectSuccess(process, equals("a{b:3px}")); + await process.close(); + }); + }); + + group("exits when stdin is closed", () { + test("immediately", () async { + process.stdin.close(); + await process.shouldExit(0); + }); + + test("after compiling CSS", () async { + process.send(compileString("a {b: 1px + 2px}")); + await expectSuccess(process, equals("a {\n b: 3px;\n}")); + process.stdin.close(); + await process.shouldExit(0); }); + + test("while compiling CSS", () async { + process.send(compileString("a {b: foo() + 2px}", functions: [r"foo()"])); + await getFunctionCallRequest(process); + process.stdin.close(); + await process.shouldExit(0); + }); + }); + + test("handles many concurrent compilation requests", () async { + var totalRequests = 1000; + for (var i = 1; i <= totalRequests; i++) { + process.inbound + .add((i, compileString("a {b: foo() + 2px}", functions: [r"foo()"]))); + } + + var successes = 0; + process.outbound.rest.listen((pair) { + var (compilationId, message) = pair; + expect(compilationId, + allOf(greaterThan(0), lessThanOrEqualTo(totalRequests))); + + if (message.hasFunctionCallRequest()) { + process.inbound.add(( + compilationId, + InboundMessage() + ..functionCallResponse = (InboundMessage_FunctionCallResponse() + ..id = message.functionCallRequest.id + ..success = (Value() + ..number = (Value_Number() + ..value = 1 + ..numerators.add("px")))) + )); + } else if (message.hasCompileResponse()) { + var response = message.compileResponse; + expect(response.hasSuccess(), isTrue); + expect(response.success.css, equalsIgnoringWhitespace("a { b: 3px; }")); + + successes++; + if (successes == totalRequests) { + process.stdin.close(); + } + } else { + fail("Unexpected message ${message.toDebugString()}"); + } + }); + + await process.shouldExit(0); }); test("doesn't include a source map by default", () async { - process.inbound.add(compileString("a {b: 1px + 2px}")); - await expectLater(process.outbound, - emits(isSuccess("a { b: 3px; }", sourceMap: isEmpty))); - await process.kill(); + process.send(compileString("a {b: 1px + 2px}")); + await expectSuccess(process, "a { b: 3px; }", sourceMap: isEmpty); + await process.close(); }); test("doesn't include a source map with source_map: false", () async { - process.inbound.add(compileString("a {b: 1px + 2px}", sourceMap: false)); - await expectLater(process.outbound, - emits(isSuccess("a { b: 3px; }", sourceMap: isEmpty))); - await process.kill(); + process.send(compileString("a {b: 1px + 2px}", sourceMap: false)); + await expectSuccess(process, "a { b: 3px; }", sourceMap: isEmpty); + await process.close(); }); test("includes a source map if source_map is true", () async { - process.inbound.add(compileString("a {b: 1px + 2px}", sourceMap: true)); - await expectLater( - process.outbound, - emits(isSuccess("a { b: 3px; }", sourceMap: (String map) { - var mapping = source_maps.parse(map); - var span = mapping.spanFor(2, 5)!; - expect(span.start.line, equals(0)); - expect(span.start.column, equals(3)); - expect(span.end, equals(span.start)); - expect(mapping, isA()); - expect((mapping as source_maps.SingleMapping).files[0], isNull); - return true; - }))); - await process.kill(); + process.send(compileString("a {b: 1px + 2px}", sourceMap: true)); + await expectSuccess(process, "a { b: 3px; }", sourceMap: (String map) { + var mapping = source_maps.parse(map); + var span = mapping.spanFor(2, 5)!; + expect(span.start.line, equals(0)); + expect(span.start.column, equals(3)); + expect(span.end, equals(span.start)); + expect(mapping, isA()); + expect((mapping as source_maps.SingleMapping).files[0], isNull); + return true; + }); + await process.close(); }); test( "includes a source map without content if source_map is true and source_map_include_sources is false", () async { - process.inbound.add(compileString("a {b: 1px + 2px}", + process.send(compileString("a {b: 1px + 2px}", sourceMap: true, sourceMapIncludeSources: false)); - await expectLater( - process.outbound, - emits(isSuccess("a { b: 3px; }", sourceMap: (String map) { - var mapping = source_maps.parse(map); - var span = mapping.spanFor(2, 5)!; - expect(span.start.line, equals(0)); - expect(span.start.column, equals(3)); - expect(span.end, equals(span.start)); - expect(mapping, isA()); - expect((mapping as source_maps.SingleMapping).files[0], isNull); - return true; - }))); - await process.kill(); + await expectSuccess(process, "a { b: 3px; }", sourceMap: (String map) { + var mapping = source_maps.parse(map); + var span = mapping.spanFor(2, 5)!; + expect(span.start.line, equals(0)); + expect(span.start.column, equals(3)); + expect(span.end, equals(span.start)); + expect(mapping, isA()); + expect((mapping as source_maps.SingleMapping).files[0], isNull); + return true; + }); + await process.close(); }); test( "includes a source map with content if source_map is true and source_map_include_sources is true", () async { - process.inbound.add(compileString("a {b: 1px + 2px}", + process.send(compileString("a {b: 1px + 2px}", sourceMap: true, sourceMapIncludeSources: true)); - await expectLater( - process.outbound, - emits(isSuccess("a { b: 3px; }", sourceMap: (String map) { - var mapping = source_maps.parse(map); - var span = mapping.spanFor(2, 5)!; - expect(span.start.line, equals(0)); - expect(span.start.column, equals(3)); - expect(span.end, equals(span.start)); - expect(mapping, isA()); - expect((mapping as source_maps.SingleMapping).files[0], isNotNull); - return true; - }))); - await process.kill(); + await expectSuccess(process, "a { b: 3px; }", sourceMap: (String map) { + var mapping = source_maps.parse(map); + var span = mapping.spanFor(2, 5)!; + expect(span.start.line, equals(0)); + expect(span.start.column, equals(3)); + expect(span.end, equals(span.start)); + expect(mapping, isA()); + expect((mapping as source_maps.SingleMapping).files[0], isNotNull); + return true; + }); + await process.close(); }); group("emits a log event", () { group("for a @debug rule", () { test("with correct fields", () async { - process.inbound.add(compileString("a {@debug hello}")); + process.send(compileString("a {@debug hello}")); - var logEvent = getLogEvent(await process.outbound.next); - expect(logEvent.compilationId, equals(0)); + var logEvent = await getLogEvent(process); expect(logEvent.type, equals(LogEventType.DEBUG)); expect(logEvent.message, equals("hello")); expect(logEvent.span.text, equals("@debug hello")); @@ -204,9 +305,8 @@ void main() { }); test("formatted with terminal colors", () async { - process.inbound - .add(compileString("a {@debug hello}", alertColor: true)); - var logEvent = getLogEvent(await process.outbound.next); + process.send(compileString("a {@debug hello}", alertColor: true)); + var logEvent = await getLogEvent(process); expect( logEvent.formatted, equals('-:1 \u001b[1mDebug\u001b[0m: hello\n')); await process.kill(); @@ -215,10 +315,9 @@ void main() { group("for a @warn rule", () { test("with correct fields", () async { - process.inbound.add(compileString("a {@warn hello}")); + process.send(compileString("a {@warn hello}")); - var logEvent = getLogEvent(await process.outbound.next); - expect(logEvent.compilationId, equals(0)); + var logEvent = await getLogEvent(process); expect(logEvent.type, equals(LogEventType.WARNING)); expect(logEvent.message, equals("hello")); expect(logEvent.span, equals(SourceSpan())); @@ -231,8 +330,8 @@ void main() { }); test("formatted with terminal colors", () async { - process.inbound.add(compileString("a {@warn hello}", alertColor: true)); - var logEvent = getLogEvent(await process.outbound.next); + process.send(compileString("a {@warn hello}", alertColor: true)); + var logEvent = await getLogEvent(process); expect( logEvent.formatted, equals('\x1B[33m\x1B[1mWarning\x1B[0m: hello\n' @@ -241,9 +340,8 @@ void main() { }); test("encoded in ASCII", () async { - process.inbound - .add(compileString("a {@debug a && b}", alertAscii: true)); - var logEvent = getLogEvent(await process.outbound.next); + process.send(compileString("a {@debug a && b}", alertAscii: true)); + var logEvent = await getLogEvent(process); expect( logEvent.formatted, equals('WARNING on line 1, column 13: \n' @@ -257,10 +355,9 @@ void main() { }); test("for a parse-time deprecation warning", () async { - process.inbound.add(compileString("@if true {} @elseif true {}")); + process.send(compileString("@if true {} @elseif true {}")); - var logEvent = getLogEvent(await process.outbound.next); - expect(logEvent.compilationId, equals(0)); + var logEvent = await getLogEvent(process); expect(logEvent.type, equals(LogEventType.DEPRECATION_WARNING)); expect( logEvent.message, @@ -278,10 +375,9 @@ void main() { }); test("for a runtime deprecation warning", () async { - process.inbound.add(compileString("a {\$var: value !global}")); + process.send(compileString("a {\$var: value !global}")); - var logEvent = getLogEvent(await process.outbound.next); - expect(logEvent.compilationId, equals(0)); + var logEvent = await getLogEvent(process); expect(logEvent.type, equals(LogEventType.DEPRECATION_WARNING)); expect( logEvent.message, @@ -296,21 +392,13 @@ void main() { expect(logEvent.stackTrace, "- 1:4 root stylesheet\n"); await process.kill(); }); - - test("with the same ID as the CompileRequest", () async { - process.inbound.add(compileString("@debug hello", id: 12345)); - - var logEvent = getLogEvent(await process.outbound.next); - expect(logEvent.compilationId, equals(12345)); - await process.kill(); - }); }); group("gracefully handles an error", () { test("from invalid syntax", () async { - process.inbound.add(compileString("a {b: }")); + process.send(compileString("a {b: }")); - var failure = getCompileFailure(await process.outbound.next); + var failure = await getCompileFailure(process); expect(failure.message, equals("Expected expression.")); expect(failure.span.text, isEmpty); expect(failure.span.start, equals(location(6, 0, 6))); @@ -318,13 +406,13 @@ void main() { expect(failure.span.url, isEmpty); expect(failure.span.context, equals("a {b: }")); expect(failure.stackTrace, equals("- 1:7 root stylesheet\n")); - await process.kill(); + await process.close(); }); test("from the runtime", () async { - process.inbound.add(compileString("a {b: 1px + 1em}")); + process.send(compileString("a {b: 1px + 1em}")); - var failure = getCompileFailure(await process.outbound.next); + var failure = await getCompileFailure(process); expect(failure.message, equals("1px and 1em have incompatible units.")); expect(failure.span.text, "1px + 1em"); expect(failure.span.start, equals(location(6, 0, 6))); @@ -332,15 +420,15 @@ void main() { expect(failure.span.url, isEmpty); expect(failure.span.context, equals("a {b: 1px + 1em}")); expect(failure.stackTrace, equals("- 1:7 root stylesheet\n")); - await process.kill(); + await process.close(); }); test("from a missing file", () async { - process.inbound.add(InboundMessage() + process.send(InboundMessage() ..compileRequest = (InboundMessage_CompileRequest()..path = d.path("test.scss"))); - var failure = getCompileFailure(await process.outbound.next); + var failure = await getCompileFailure(process); expect(failure.message, startsWith("Cannot open file: ")); expect(failure.message.replaceFirst("Cannot open file: ", "").trim(), equalsPath(d.path('test.scss'))); @@ -350,29 +438,29 @@ void main() { expect(failure.span.end, equals(SourceSpan_SourceLocation())); expect(failure.span.url, equals(p.toUri(d.path('test.scss')).toString())); expect(failure.stackTrace, isEmpty); - await process.kill(); + await process.close(); }); test("with a multi-line source span", () async { - process.inbound.add(compileString(""" + process.send(compileString(""" a { b: 1px + 1em; } """)); - var failure = getCompileFailure(await process.outbound.next); + var failure = await getCompileFailure(process); expect(failure.span.text, "1px +\n 1em"); expect(failure.span.start, equals(location(9, 1, 5))); expect(failure.span.end, equals(location(23, 2, 8))); expect(failure.span.url, isEmpty); expect(failure.span.context, equals(" b: 1px +\n 1em;\n")); expect(failure.stackTrace, equals("- 2:6 root stylesheet\n")); - await process.kill(); + await process.close(); }); test("with multiple stack trace entries", () async { - process.inbound.add(compileString(""" + process.send(compileString(""" @function fail() { @return 1px + 1em; } @@ -382,45 +470,43 @@ a { } """)); - var failure = getCompileFailure(await process.outbound.next); + var failure = await getCompileFailure(process); expect( failure.stackTrace, equals("- 2:11 fail()\n" "- 6:6 root stylesheet\n")); - await process.kill(); + await process.close(); }); group("and includes the URL from", () { test("a string input", () async { - process.inbound - .add(compileString("a {b: 1px + 1em}", url: "foo://bar/baz")); + process.send(compileString("a {b: 1px + 1em}", url: "foo://bar/baz")); - var failure = getCompileFailure(await process.outbound.next); + var failure = await getCompileFailure(process); expect(failure.span.url, equals("foo://bar/baz")); expect( failure.stackTrace, equals("foo://bar/baz 1:7 root stylesheet\n")); - await process.kill(); + await process.close(); }); test("a path input", () async { await d.file("test.scss", "a {b: 1px + 1em}").create(); var path = d.path("test.scss"); - process.inbound.add(InboundMessage() + process.send(InboundMessage() ..compileRequest = (InboundMessage_CompileRequest()..path = path)); - var failure = getCompileFailure(await process.outbound.next); + var failure = await getCompileFailure(process); expect(p.fromUri(failure.span.url), equalsPath(path)); expect(failure.stackTrace, endsWith(" 1:7 root stylesheet\n")); expect(failure.stackTrace.split(" ").first, equalsPath(path)); - await process.kill(); + await process.close(); }); }); test("caused by using Sass features in CSS", () async { - process.inbound - .add(compileString("a {b: 1px + 2px}", syntax: Syntax.CSS)); + process.send(compileString("a {b: 1px + 2px}", syntax: Syntax.CSS)); - var failure = getCompileFailure(await process.outbound.next); + var failure = await getCompileFailure(process); expect(failure.message, equals("Operators aren't allowed in plain CSS.")); expect(failure.span.text, "+"); expect(failure.span.start, equals(location(10, 0, 10))); @@ -428,14 +514,14 @@ a { expect(failure.span.url, isEmpty); expect(failure.span.context, equals("a {b: 1px + 2px}")); expect(failure.stackTrace, equals("- 1:11 root stylesheet\n")); - await process.kill(); + await process.close(); }); group("and provides a formatted", () { test("message", () async { - process.inbound.add(compileString("a {b: 1px + 1em}")); + process.send(compileString("a {b: 1px + 1em}")); - var failure = getCompileFailure(await process.outbound.next); + var failure = await getCompileFailure(process); expect( failure.formatted, equals('Error: 1px and 1em have incompatible units.\n' @@ -444,14 +530,13 @@ a { ' │ ^^^^^^^^^\n' ' ╵\n' ' - 1:7 root stylesheet')); - await process.kill(); + await process.close(); }); test("message with terminal colors", () async { - process.inbound - .add(compileString("a {b: 1px + 1em}", alertColor: true)); + process.send(compileString("a {b: 1px + 1em}", alertColor: true)); - var failure = getCompileFailure(await process.outbound.next); + var failure = await getCompileFailure(process); expect( failure.formatted, equals('Error: 1px and 1em have incompatible units.\n' @@ -460,14 +545,13 @@ a { '\x1B[34m │\x1B[0m \x1B[31m ^^^^^^^^^\x1B[0m\n' '\x1B[34m ╵\x1B[0m\n' ' - 1:7 root stylesheet')); - await process.kill(); + await process.close(); }); test("message with ASCII encoding", () async { - process.inbound - .add(compileString("a {b: 1px + 1em}", alertAscii: true)); + process.send(compileString("a {b: 1px + 1em}", alertAscii: true)); - var failure = getCompileFailure(await process.outbound.next); + var failure = await getCompileFailure(process); expect( failure.formatted, equals('Error: 1px and 1em have incompatible units.\n' @@ -476,7 +560,7 @@ a { ' | ^^^^^^^^^\n' ' \'\n' ' - 1:7 root stylesheet')); - await process.kill(); + await process.close(); }); }); }); diff --git a/test/embedded/utils.dart b/test/embedded/utils.dart index 35eb2220b..68c1e2f83 100644 --- a/test/embedded/utils.dart +++ b/test/embedded/utils.dart @@ -10,8 +10,12 @@ import 'package:sass/src/embedded/utils.dart'; import 'embedded_process.dart'; -/// Returns a [InboundMessage] that compiles the given plain CSS -/// string. +/// An arbitrary compilation ID to use for tests where the specific ID doesn't +/// matter. +const defaultCompilationId = 4321; + +/// Returns a (compilation ID, [InboundMessage]) pair that compiles the given +/// plain CSS string. InboundMessage compileString(String css, {int? id, bool? alertColor, @@ -30,7 +34,6 @@ InboundMessage compileString(String css, if (importer != null) input.importer = importer; var request = InboundMessage_CompileRequest()..string = input; - if (id != null) request.id = id; if (importers != null) request.importers.addAll(importers); if (style != null) request.style = style; if (sourceMap != null) request.sourceMap = sourceMap; @@ -46,9 +49,12 @@ InboundMessage compileString(String css, /// Asserts that [process] emits a [ProtocolError] parse error with the given /// [message] on its protobuf stream and prints a notice on stderr. -Future expectParseError(EmbeddedProcess process, Object message) async { - await expectLater(process.outbound, - emits(isProtocolError(errorId, ProtocolErrorType.PARSE, message))); +Future expectParseError(EmbeddedProcess process, Object message, + {int compilationId = defaultCompilationId}) async { + var (actualCompilationId, actualMessage) = await process.outbound.next; + expect(actualCompilationId, equals(compilationId)); + expect(actualMessage, + isProtocolError(errorId, ProtocolErrorType.PARSE, message)); var stderrPrefix = "Host caused parse error: "; await expectLater( @@ -60,10 +66,11 @@ Future expectParseError(EmbeddedProcess process, Object message) async { /// Asserts that [process] emits a [ProtocolError] params error with the given /// [message] on its protobuf stream and prints a notice on stderr. -Future expectParamsError( - EmbeddedProcess process, int id, Object message) async { - await expectLater(process.outbound, - emits(isProtocolError(id, ProtocolErrorType.PARAMS, message))); +Future expectParamsError(EmbeddedProcess process, int id, Object message, + {int compilationId = defaultCompilationId}) async { + var (actualCompilationId, actualMessage) = await process.outbound.next; + expect(actualCompilationId, equals(compilationId)); + expect(actualMessage, isProtocolError(id, ProtocolErrorType.PARAMS, message)); var stderrPrefix = "Host caused params error" "${id == errorId ? '' : " with request $id"}: "; @@ -88,104 +95,103 @@ Matcher isProtocolError(int id, ProtocolErrorType type, [Object? message]) => return true; }); -/// Asserts that [message] is an [OutboundMessage] with a -/// `CanonicalizeRequest` and returns it. -OutboundMessage_CanonicalizeRequest getCanonicalizeRequest(Object? value) { - expect(value, isA()); - var message = value as OutboundMessage; +/// Asserts [process] emits a `CanonicalizeRequest` with the default compilation +/// ID and returns it. +Future getCanonicalizeRequest( + EmbeddedProcess process) async { + var message = await process.receive(); expect(message.hasCanonicalizeRequest(), isTrue, reason: "Expected $message to have a CanonicalizeRequest"); return message.canonicalizeRequest; } -/// Asserts that [message] is an [OutboundMessage] with a `ImportRequest` and -/// returns it. -OutboundMessage_ImportRequest getImportRequest(Object? value) { - expect(value, isA()); - var message = value as OutboundMessage; +/// Asserts [process] emits an `ImportRequest` with the default compilation ID +/// and returns it. +Future getImportRequest( + EmbeddedProcess process) async { + var message = await process.receive(); expect(message.hasImportRequest(), isTrue, reason: "Expected $message to have a ImportRequest"); return message.importRequest; } -/// Asserts that [message] is an [OutboundMessage] with a `FileImportRequest` -/// and returns it. -OutboundMessage_FileImportRequest getFileImportRequest(Object? value) { - expect(value, isA()); - var message = value as OutboundMessage; +/// Asserts that [process] emits a `FileImportRequest` with the default +/// compilation ID and returns it. +Future getFileImportRequest( + EmbeddedProcess process) async { + var message = await process.receive(); expect(message.hasFileImportRequest(), isTrue, reason: "Expected $message to have a FileImportRequest"); return message.fileImportRequest; } -/// Asserts that [message] is an [OutboundMessage] with a -/// `FunctionCallRequest` and returns it. -OutboundMessage_FunctionCallRequest getFunctionCallRequest(Object? value) { - expect(value, isA()); - var message = value as OutboundMessage; +/// Asserts that [process] emits a `FunctionCallRequest` with the default +/// compilation ID and returns it. +Future getFunctionCallRequest( + EmbeddedProcess process) async { + var message = await process.receive(); expect(message.hasFunctionCallRequest(), isTrue, reason: "Expected $message to have a FunctionCallRequest"); return message.functionCallRequest; } -/// Asserts that [message] is an [OutboundMessage] with a +/// Asserts that [process] emits a with the default compilation ID /// `CompileResponse.Failure` and returns it. -OutboundMessage_CompileResponse_CompileFailure getCompileFailure( - Object? value) { - var response = getCompileResponse(value); +Future getCompileFailure( + EmbeddedProcess process) async { + var response = await getCompileResponse(process); expect(response.hasFailure(), isTrue, reason: "Expected $response to be a failure"); return response.failure; } -/// Asserts that [message] is an [OutboundMessage] with a +/// Asserts that [process] emits a with the default compilation ID /// `CompileResponse.Success` and returns it. -OutboundMessage_CompileResponse_CompileSuccess getCompileSuccess( - Object? value) { - var response = getCompileResponse(value); +Future getCompileSuccess( + EmbeddedProcess process) async { + var response = await getCompileResponse(process); expect(response.hasSuccess(), isTrue, reason: "Expected $response to be a success"); return response.success; } -/// Asserts that [message] is an [OutboundMessage] with a `CompileResponse` and -/// returns it. -OutboundMessage_CompileResponse getCompileResponse(Object? value) { - expect(value, isA()); - var message = value as OutboundMessage; +/// Asserts that [process] emits a `CompileResponse` and with the default +/// compilation ID returns it. +Future getCompileResponse( + EmbeddedProcess process) async { + var message = await process.receive(); expect(message.hasCompileResponse(), isTrue, reason: "Expected $message to have a CompileResponse"); return message.compileResponse; } -/// Asserts that [message] is an [OutboundMessage] with a `LogEvent` and -/// returns it. -OutboundMessage_LogEvent getLogEvent(Object? value) { - expect(value, isA()); - var message = value as OutboundMessage; +/// Asserts that [process] emits a `LogEvent` and returns with the default +/// compilation ID it. +Future getLogEvent(EmbeddedProcess process) async { + var message = await process.receive(); expect(message.hasLogEvent(), isTrue, reason: "Expected $message to have a LogEvent"); return message.logEvent; } -/// Asserts that an [OutboundMessage] is a `CompileResponse` with CSS that -/// matches [css], with a source map that matches [sourceMap] (if passed). +/// Asserts that [process] emits a `CompileResponse` with CSS that matches +/// [css], with a source map that matches [sourceMap] (if passed). /// /// If [css] is a [String], this automatically wraps it in /// [equalsIgnoringWhitespace]. /// /// If [sourceMap] is a function, `response.success.sourceMap` is passed to it. /// Otherwise, it's treated as a matcher for `response.success.sourceMap`. -Matcher isSuccess(Object css, {Object? sourceMap}) => predicate((value) { - var success = getCompileSuccess(value); - expect(success.css, css is String ? equalsIgnoringWhitespace(css) : css); - if (sourceMap is void Function(String)) { - sourceMap(success.sourceMap); - } else if (sourceMap != null) { - expect(success.sourceMap, sourceMap); - } - return true; - }); +Future expectSuccess(EmbeddedProcess process, Object css, + {Object? sourceMap}) async { + var success = await getCompileSuccess(process); + expect(success.css, css is String ? equalsIgnoringWhitespace(css) : css); + if (sourceMap is void Function(String)) { + sourceMap(success.sourceMap); + } else if (sourceMap != null) { + expect(success.sourceMap, sourceMap); + } +} /// Returns a [SourceSpan_SourceLocation] with the given [offset], [line], and /// [column].