diff --git a/lib/src/async_import_cache.dart b/lib/src/async_import_cache.dart index dc50aeaa..8b639ad4 100644 --- a/lib/src/async_import_cache.dart +++ b/lib/src/async_import_cache.dart @@ -151,6 +151,13 @@ class AsyncImportCache { }); } + /// Clears the cached canonical version of the given [url]. + /// + /// Has no effect if the canonical version of [url] has not been cached. + void clearCanonicalize(Uri url) { + _canonicalizeCache.remove(url); + } + /// Clears the cached parse tree for the stylesheet with the given /// [canonicalUrl]. /// diff --git a/lib/src/executable.dart b/lib/src/executable.dart index 1b0f9df7..9fd65d38 100644 --- a/lib/src/executable.dart +++ b/lib/src/executable.dart @@ -11,6 +11,7 @@ import 'exception.dart'; import 'executable/compile_stylesheet.dart'; import 'executable/options.dart'; import 'executable/repl.dart'; +import 'executable/watch.dart'; import 'import_cache.dart'; import 'io.dart'; import 'stylesheet_graph.dart'; @@ -50,10 +51,16 @@ main(List args) async { var graph = new StylesheetGraph(new ImportCache([], loadPaths: options.loadPaths, logger: options.logger)); + if (options.watch) { + await watch(options, graph); + return; + } + for (var source in options.sourcesToDestinations.keys) { var destination = options.sourcesToDestinations[source]; try { - await compileStylesheet(options, graph, source, destination); + await compileStylesheet(options, graph, source, destination, + ifModified: options.update); } on SassException catch (error, stackTrace) { // This is an immediately-invoked function expression to work around // dart-lang/sdk#33400. diff --git a/lib/src/executable/compile_stylesheet.dart b/lib/src/executable/compile_stylesheet.dart index 5bd42d8b..c388f9d3 100644 --- a/lib/src/executable/compile_stylesheet.dart +++ b/lib/src/executable/compile_stylesheet.dart @@ -26,10 +26,15 @@ import 'options.dart'; /// If [source] is `null`, that indicates that the stylesheet should be read /// from stdin. If [destination] is `null`, that indicates that the stylesheet /// should be emitted to stdout. +/// +/// If [ifModified] is `true`, only recompiles if [source]'s modification time +/// or that of a file it imports is more recent than [destination]'s +/// modification time. Note that these modification times are cached by [graph]. Future compileStylesheet(ExecutableOptions options, StylesheetGraph graph, - String source, String destination) async { + String source, String destination, + {bool ifModified: false}) async { var importer = new FilesystemImporter('.'); - if (options.update) { + if (ifModified) { try { if (source != null && destination != null && @@ -50,7 +55,7 @@ Future compileStylesheet(ExecutableOptions options, StylesheetGraph graph, importer: importer, logger: options.logger, sourceMap: options.emitSourceMap) - : await evaluate(stylesheet, + : evaluate(stylesheet, importCache: graph.importCache, importer: importer, logger: options.logger, @@ -68,10 +73,12 @@ Future compileStylesheet(ExecutableOptions options, StylesheetGraph graph, writeFile(destination, css + "\n"); } - if (!options.update || options.quiet) return; + if (options.quiet || (!options.update && !options.watch)) return; var buffer = new StringBuffer(); if (options.color) buffer.write('\u001b[32m'); - buffer.write('Compiled ${source ?? 'stdin'} to $destination.'); + + var sourceName = source == null ? 'stdin' : p.prettyUri(p.toUri(source)); + buffer.write('Compiled $sourceName to $destination.'); if (options.color) buffer.write('\u001b[0m'); print(buffer); } diff --git a/lib/src/executable/options.dart b/lib/src/executable/options.dart index 08de155d..e1eb02b8 100644 --- a/lib/src/executable/options.dart +++ b/lib/src/executable/options.dart @@ -6,6 +6,7 @@ import 'dart:collection'; import 'package:args/args.dart'; import 'package:charcode/charcode.dart'; +import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; import '../../sass.dart'; @@ -71,6 +72,9 @@ class ExecutableOptions { parser ..addSeparator(_separator('Other')) + ..addFlag('watch', + help: 'Watch stylesheets and recompile when they change.', + negatable: false) ..addFlag('interactive', abbr: 'i', help: 'Run an interactive SassScript shell.', @@ -163,6 +167,9 @@ class ExecutableOptions { /// Whether to update only files that have changed since the last compilation. bool get update => _options['update'] as bool; + /// Whether to continuously watch the filesystem for changes. + bool get watch => _options['watch'] as bool; + /// A map from source paths to the destination paths where the compiled CSS /// should be written. /// @@ -172,7 +179,27 @@ class ExecutableOptions { /// input. A `null` destination indicates that a stylesheet should be written /// to standard output. Map get sourcesToDestinations { - if (_sourcesToDestinations != null) return _sourcesToDestinations; + _ensureSources(); + return _sourcesToDestinations; + } + + Map _sourcesToDestinations; + + /// A map from source directories to the destination directories where the + /// compiled CSS for stylesheets in the source directories should be written. + /// + /// Considers keys to be the same if they represent the same path on disk. + Map get sourceDirectoriesToDestinations { + _ensureSources(); + return _sourceDirectoriesToDestinations; + } + + Map _sourceDirectoriesToDestinations; + + /// Ensure that both [sourcesToDestinations] and [sourceDirectories] have been + /// computed. + void _ensureSources() { + if (_sourcesToDestinations != null) return; var stdin = _options['stdin'] as bool; if (_options.rest.isEmpty && !stdin) _fail("Compile Sass to CSS."); @@ -203,6 +230,8 @@ class ExecutableOptions { _fail("Only one argument is allowed with --stdin."); } else if (update) { _fail("--update is not allowed with --stdin."); + } else if (watch) { + _fail("--watch is not allowed with --stdin."); } _sourcesToDestinations = new Map.unmodifiable( {null: _options.rest.isEmpty ? null : _options.rest.first}); @@ -211,13 +240,18 @@ class ExecutableOptions { } else { var source = _options.rest.first == '-' ? null : _options.rest.first; var destination = _options.rest.length == 1 ? null : _options.rest.last; - if (update && destination == null) { - _fail("--update is not allowed when printing to stdout."); + if (destination == null) { + if (update) { + _fail("--update is not allowed when printing to stdout."); + } else if (watch) { + _fail("--watch is not allowed when printing to stdout."); + } } _sourcesToDestinations = new UnmodifiableMapView(newPathMap({source: destination})); } - return _sourcesToDestinations; + _sourceDirectoriesToDestinations = const {}; + return; } if (stdin) _fail('--stdin may not be used with ":" arguments.'); @@ -227,6 +261,7 @@ class ExecutableOptions { // directories have been resolved. var seen = new Set(); var sourcesToDestinations = newPathMap(); + var sourceDirectoriesToDestinations = newPathMap(); for (var argument in _options.rest) { String source; String destination; @@ -255,17 +290,17 @@ class ExecutableOptions { if (source == '-') { sourcesToDestinations[null] = destination; } else if (dirExists(source)) { + sourceDirectoriesToDestinations[source] = destination; sourcesToDestinations.addAll(_listSourceDirectory(source, destination)); } else { sourcesToDestinations[source] = destination; } } _sourcesToDestinations = new UnmodifiableMapView(sourcesToDestinations); - return _sourcesToDestinations; + _sourceDirectoriesToDestinations = + new UnmodifiableMapView(sourceDirectoriesToDestinations); } - Map _sourcesToDestinations; - /// Returns whether [string] contains an absolute Windows path at [index]. bool _isWindowsPath(String string, int index) => string.length > index + 2 && diff --git a/lib/src/executable/watch.dart b/lib/src/executable/watch.dart new file mode 100644 index 00000000..4105d095 --- /dev/null +++ b/lib/src/executable/watch.dart @@ -0,0 +1,230 @@ +// Copyright 2018 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:collection'; + +import 'package:stack_trace/stack_trace.dart'; +import 'package:watcher/watcher.dart'; + +import '../exception.dart'; +import '../importer/filesystem.dart'; +import '../io.dart'; +import '../stylesheet_graph.dart'; +import '../util/multi_dir_watcher.dart'; +import '../util/path.dart'; +import 'compile_stylesheet.dart'; +import 'options.dart'; + +/// Watches all the files in [graph] for changes and updates them as necessary. +Future watch(ExecutableOptions options, StylesheetGraph graph) async { + var directoriesToWatch = [] + ..addAll(options.sourceDirectoriesToDestinations.keys) + ..addAll(options.sourcesToDestinations.keys.map((path) => p.dirname(path))) + ..addAll(options.loadPaths); + + var dirWatcher = new MultiDirWatcher(); + await Future.wait(directoriesToWatch.map((dir) { + // If a directory doesn't exist, watch its parent directory so that we're + // notified once it starts existing. + while (!dirExists(dir)) dir = p.dirname(dir); + return dirWatcher.watch(dir); + })); + + // Before we start paying attention to changes, compile all the stylesheets as + // they currently exist. This ensures that changes that come in update a + // known-good state. + var watcher = new _Watcher(options, graph); + for (var source in options.sourcesToDestinations.keys) { + var destination = options.sourcesToDestinations[source]; + graph.addCanonical(new FilesystemImporter('.'), + p.toUri(p.canonicalize(source)), p.toUri(source)); + await watcher.compile(source, destination, ifModified: true); + } + + print("Sass is watching for changes. Press Ctrl-C to stop.\n"); + await watcher.watch(dirWatcher); +} + +/// Holds state that's shared across functions that react to changes on the +/// filesystem. +class _Watcher { + final ExecutableOptions _options; + + final StylesheetGraph _graph; + + _Watcher(this._options, this._graph); + + /// Compiles the stylesheet at [source] to [destination], and prints any + /// errors that occur. + Future compile(String source, String destination, + {bool ifModified: false}) async { + try { + await compileStylesheet(_options, _graph, source, destination, + ifModified: ifModified); + } on SassException catch (error, stackTrace) { + _printError(error.toString(color: _options.color), stackTrace); + _delete(destination); + } on FileSystemException catch (error, stackTrace) { + _printError("Error reading ${p.relative(error.path)}: ${error.message}.", + stackTrace); + } + } + + /// Deletes the file at [path] and prints a message about it. + void _delete(String path) { + try { + deleteFile(path); + var buffer = new StringBuffer(); + if (_options.color) buffer.write("\u001b[33m"); + buffer.write("Deleted $path."); + if (_options.color) buffer.write("\u001b[0m"); + print(buffer); + } on FileSystemException { + // If the file doesn't exist, that's fine. + } + } + + /// Prints [message] to standard error, with [stackTrace] if [_options.trace] + /// is set. + void _printError(String message, StackTrace stackTrace) { + stderr.writeln(message); + + if (_options.trace) { + stderr.writeln(); + stderr.writeln(new Trace.from(stackTrace).terse.toString().trimRight()); + } + + stderr.writeln(); + } + + /// Listens to `watcher.events` and updates the filesystem accordingly. + /// + /// Returns a future that will only complete if an unexpected error occurs. + Future watch(MultiDirWatcher watcher) async { + loop: + await for (var event in watcher.events) { + var extension = p.extension(event.path); + if (extension != '.sass' && extension != '.scss') continue; + var url = p.toUri(p.canonicalize(event.path)); + + switch (event.type) { + case ChangeType.MODIFY: + if (!_graph.nodes.containsKey(url)) continue loop; + + _graph.reload(url); + await _recompileDownstream([_graph.nodes[url]]); + break; + + case ChangeType.ADD: + await _retryPotentialImports(event.path); + + var destination = _destinationFor(event.path); + if (destination == null) continue loop; + + _graph.addCanonical( + new FilesystemImporter('.'), url, p.toUri(event.path)); + + await compile(event.path, destination); + break; + + case ChangeType.REMOVE: + await _retryPotentialImports(event.path); + if (!_graph.nodes.containsKey(url)) continue loop; + + var destination = _destinationFor(event.path); + if (destination != null) _delete(destination); + + _graph.remove(url); + await _recompileDownstream([_graph.nodes[url]]); + break; + } + } + } + + /// Recompiles [nodes] and everything that transitively imports them, if + /// necessary. + Future _recompileDownstream(Iterable nodes) async { + var seen = new Set(); + var toRecompile = new Queue.from(nodes); + + while (!toRecompile.isEmpty) { + var node = toRecompile.removeFirst(); + if (!seen.add(node)) continue; + + await _compileIfEntrypoint(node.canonicalUrl); + toRecompile.addAll(node.downstream); + } + } + + /// Compiles the stylesheet at [url] to CSS if it's an entrypoint that's being + /// watched. + Future _compileIfEntrypoint(Uri url) async { + if (url.scheme != 'file') return; + + var source = p.fromUri(url); + var destination = _destinationFor(source); + if (destination == null) return; + + await compile(source, destination); + } + + /// If a Sass file at [source] should be compiled to CSS, returns the path to + /// the CSS file it should be compiled to. + /// + /// Otherwise, returns `null`. + String _destinationFor(String source) { + var destination = _options.sourcesToDestinations[source]; + if (destination != null) return destination; + if (p.basename(source).startsWith('_')) return null; + + for (var sourceDir in _options.sourceDirectoriesToDestinations.keys) { + if (p.isWithin(sourceDir, source)) { + return p.join(_options.sourceDirectoriesToDestinations[sourceDir], + p.setExtension(p.relative(source, from: sourceDir), '.css')); + } + } + + return null; + } + + /// Re-runs all imports in [_graph] that might refer to [path], and recompiles + /// the files that contain those imports if they end up importing new + /// stylesheets. + Future _retryPotentialImports(String path) async { + var name = _name(p.basename(path)); + var changed = []; + for (var node in _graph.nodes.values) { + var importChanged = false; + for (var url in node.upstream.keys) { + if (_name(pUrl.basename(url.path)) != name) continue; + _graph.clearCanonicalize(url); + + // If the import produces a different canonicalized URL than it did + // before, it changed and the stylesheet needs to be recompiled. + if (!importChanged) { + Uri newCanonicalUrl; + try { + newCanonicalUrl = _graph.importCache + .canonicalize(url, node.importer, node.canonicalUrl) + ?.item2; + } catch (_) { + // If the call to canonicalize failed, do nothing. We'll surface the + // error more nicely when we try to recompile the file. + } + importChanged = newCanonicalUrl != node.upstream[url]?.canonicalUrl; + } + } + if (importChanged) changed.add(node); + } + + await _recompileDownstream(changed); + } + + /// Removes an extension from [extension], and a leading underscore if it has one. + String _name(String basename) { + basename = p.withoutExtension(basename); + return basename.startsWith("_") ? basename.substring(1) : basename; + } +} diff --git a/lib/src/import_cache.dart b/lib/src/import_cache.dart index 4e16090c..86dbc070 100644 --- a/lib/src/import_cache.dart +++ b/lib/src/import_cache.dart @@ -5,7 +5,7 @@ // DO NOT EDIT. This file was generated from async_import_cache.dart. // See tool/synchronize.dart for details. // -// Checksum: 90302aba01c574ffbc07e75a34606406e48df7fc +// Checksum: 411ad1e666d5a138e69b51d9bdb58e9b22e88632 import 'package:tuple/tuple.dart'; @@ -154,6 +154,13 @@ class ImportCache { }); } + /// Clears the cached canonical version of the given [url]. + /// + /// Has no effect if the canonical version of [url] has not been cached. + void clearCanonicalize(Uri url) { + _canonicalizeCache.remove(url); + } + /// Clears the cached parse tree for the stylesheet with the given /// [canonicalUrl]. /// diff --git a/lib/src/io/interface.dart b/lib/src/io/interface.dart index 04f83139..1f50ac57 100644 --- a/lib/src/io/interface.dart +++ b/lib/src/io/interface.dart @@ -4,6 +4,8 @@ import 'dart:async'; +import 'package:watcher/watcher.dart'; + /// An output sink that writes to this process's standard error. class Stderr { /// Writes the string representation of [object] to standard error. @@ -79,3 +81,10 @@ DateTime modificationTime(String path) => null; /// Gets and sets the exit code that the process will use when it exits. int exitCode; + +/// Recursively watches the directory at [path] for modifications. +/// +/// Returns a future that completes with a single-subscription stream once the +/// directory has been scanned initially. The watch is canceled when the stream +/// is closed. +Future> watchDir(String path) => null; diff --git a/lib/src/io/node.dart b/lib/src/io/node.dart index 1c4ad6a5..56427dda 100644 --- a/lib/src/io/node.dart +++ b/lib/src/io/node.dart @@ -8,8 +8,10 @@ import 'dart:convert'; import 'package:dart2_constant/convert.dart' as convert; import 'package:js/js.dart'; import 'package:source_span/source_span.dart'; +import 'package:watcher/watcher.dart'; import '../exception.dart'; +import '../node/chokidar.dart'; import '../util/path.dart'; @JS() @@ -237,3 +239,34 @@ external int get exitCode; @JS("process.exitCode") external set exitCode(int code); + +Future> watchDir(String path) { + var watcher = + chokidar.watch(path, new ChokidarOptions(disableGlobbing: true)); + + // Don't assign the controller until after the ready event fires. Otherwise, + // Chokidar will give us a bunch of add events for files that already exist. + StreamController controller; + watcher + ..on( + 'add', + allowInterop((String path, _) => + controller?.add(new WatchEvent(ChangeType.ADD, path)))) + ..on( + 'change', + allowInterop((String path, _) => + controller?.add(new WatchEvent(ChangeType.MODIFY, path)))) + ..on( + 'unlink', + allowInterop((String path) => + controller?.add(new WatchEvent(ChangeType.REMOVE, path)))) + ..on('error', allowInterop((error) => controller?.addError(error))); + + var completer = new Completer>(); + watcher.on('ready', allowInterop(() { + controller = new StreamController(); + completer.complete(controller.stream); + })); + + return completer.future; +} diff --git a/lib/src/io/vm.dart b/lib/src/io/vm.dart index cb5969cc..afc15f6b 100644 --- a/lib/src/io/vm.dart +++ b/lib/src/io/vm.dart @@ -5,9 +5,11 @@ import 'dart:async'; import 'dart:io' as io; +import 'package:async/async.dart'; import 'package:dart2_constant/convert.dart' as convert; import 'package:path/path.dart' as p; import 'package:source_span/source_span.dart'; +import 'package:watcher/watcher.dart'; import '../exception.dart'; @@ -71,3 +73,16 @@ DateTime modificationTime(String path) { } return stat.modified; } + +Future> watchDir(String path) async { + var watcher = new DirectoryWatcher(path); + + // Wrap [stream] in a [SubscriptionStream] so that its `onListen` event + // triggers but the caller can still listen at their leisure. + var stream = new SubscriptionStream(watcher.events + .transform(const SingleSubscriptionTransformer()) + .listen((e) => print(e))); + await watcher.ready; + + return stream; +} diff --git a/lib/src/node/chokidar.dart b/lib/src/node/chokidar.dart new file mode 100644 index 00000000..3cd49fbe --- /dev/null +++ b/lib/src/node/chokidar.dart @@ -0,0 +1,32 @@ +// Copyright 2018 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'package:js/js.dart'; + +@JS("require") +external Chokidar _require(String name); + +@JS() +class Chokidar { + external ChokidarWatcher watch(String path, ChokidarOptions options); +} + +@JS() +@anonymous +class ChokidarOptions { + external bool get disableGlobbing; + + external factory ChokidarOptions({bool disableGlobbing}); +} + +@JS() +class ChokidarWatcher { + external void on(String event, Function callback); + external void close(); +} + +/// The Chokidar module. +/// +/// See [the docs on npm](https://www.npmjs.com/package/chokidar). +final chokidar = _require("chokidar"); diff --git a/lib/src/stylesheet_graph.dart b/lib/src/stylesheet_graph.dart index ee41cba9..643b4f3c 100644 --- a/lib/src/stylesheet_graph.dart +++ b/lib/src/stylesheet_graph.dart @@ -130,6 +130,7 @@ class StylesheetGraph { remove(canonicalUrl); return null; } + node._stylesheet = stylesheet; node._stylesheet = stylesheet; node._replaceUpstream( @@ -191,6 +192,17 @@ class StylesheetGraph { return node; } + /// Clears the cached canonical version of the given [url] in [importCache]. + /// + /// Also resets the cached modification times for stylesheets in the graph. + void clearCanonicalize(Uri url) { + // Rather than spending time computing exactly which modification times + // should be updated, just clear the cache and let it be computed again + // later. + _transitiveModificationTimes.clear(); + importCache.clearCanonicalize(url); + } + /// Runs [callback] and returns its result. /// /// If [callback] throws any errors, ignores them and returns `null`. This is diff --git a/lib/src/util/multi_dir_watcher.dart b/lib/src/util/multi_dir_watcher.dart new file mode 100644 index 00000000..c8331c8a --- /dev/null +++ b/lib/src/util/multi_dir_watcher.dart @@ -0,0 +1,53 @@ +// Copyright 2018 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:watcher/watcher.dart'; + +import '../io.dart'; +import 'path.dart'; + +/// Watches multiple directories which may change over time recursively for changes. +/// +/// This ensures that each directory is only watched once, even if one is a +/// parent of another. +class MultiDirWatcher { + /// A map from paths to the event streams for those paths. + /// + /// No key in this map is a parent directories of any other key in this map. + final _watchers = >{}; + + /// The stream of events from all directories that are being watched. + Stream get events => _group.stream; + final _group = new StreamGroup(); + + /// Watches [directory] for changes. + /// + /// Returns a [Future] that completes when [events] is ready to emit events + /// from [directory]. + Future watch(String directory) { + var isParentOfExistingDir = false; + for (var existingDir in _watchers.keys.toList()) { + if (!isParentOfExistingDir && + (p.equals(existingDir, directory) || + p.isWithin(existingDir, directory))) { + return new Future.value(); + } + + if (p.isWithin(directory, existingDir)) { + _group.remove(_watchers.remove(existingDir)); + isParentOfExistingDir = true; + } + } + + var future = watchDir(directory); + var stream = StreamCompleter.fromFuture(future); + _watchers[directory] = stream; + _group.add(stream); + + return future; + } +} diff --git a/package.json b/package.json index 63e2596e..c95d61a4 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "install dependencies used for testing the Node API." ], "devDependencies": { + "chokidar": "^2.0.0", "fibers": ">=1.0.0 <3.0.0", "intercept-stdout": "^0.1.2" } diff --git a/package/package.json b/package/package.json index 5e3db1ad..d5719fec 100644 --- a/package/package.json +++ b/package/package.json @@ -16,6 +16,9 @@ "engines": { "node": ">=0.11.8" }, + "dependencies": { + "chokidar": "^2.0.0" + }, "main": "sass.dart.js", "bin": "sass.js", "keywords": ["style", "scss", "sass", "preprocessor", "css"] diff --git a/pubspec.yaml b/pubspec.yaml index 06350b21..91671260 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,13 +19,14 @@ dependencies: convert: "^2.0.1" dart2_constant: "^1.0.0" meta: "^1.1.0" + package_resolver: "^1.0.0" path: "^1.6.0" source_maps: "^0.10.5" source_span: "^1.4.0" - string_scanner: ">=0.1.5 <2.0.0" stack_trace: ">=0.9.0 <2.0.0" + string_scanner: ">=0.1.5 <2.0.0" tuple: "^1.0.0" - package_resolver: ^1.0.0 + watcher: "^0.9.6" dev_dependencies: archive: ">=1.0.0 <3.0.0" diff --git a/test/cli/dart/watch_test.dart b/test/cli/dart/watch_test.dart new file mode 100644 index 00000000..418aea78 --- /dev/null +++ b/test/cli/dart/watch_test.dart @@ -0,0 +1,14 @@ +// Copyright 2018 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. + +@TestOn('vm') + +import 'package:test/test.dart'; + +import '../dart_test.dart'; +import '../shared/watch.dart'; + +void main() { + sharedTests(runSass); +} diff --git a/test/cli/node/watch_test.dart b/test/cli/node/watch_test.dart new file mode 100644 index 00000000..9c02e763 --- /dev/null +++ b/test/cli/node/watch_test.dart @@ -0,0 +1,17 @@ +// Copyright 2018 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. + +@TestOn('vm') +@Tags(const ['node']) + +import 'package:test/test.dart'; + +import '../../ensure_npm_package.dart'; +import '../node_test.dart'; +import '../shared/watch.dart'; + +void main() { + setUpAll(ensureNpmPackage); + sharedTests(runSass); +} diff --git a/test/cli/shared/watch.dart b/test/cli/shared/watch.dart new file mode 100644 index 00000000..f6bfad1c --- /dev/null +++ b/test/cli/shared/watch.dart @@ -0,0 +1,457 @@ +// Copyright 2018 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 'package:test/test.dart'; +import 'package:test_descriptor/test_descriptor.dart' as d; +import 'package:test_process/test_process.dart'; + +import 'package:sass/src/util/path.dart'; + +import '../../utils.dart'; + +/// Defines test that are shared between the Dart and Node.js CLI test suites. +void sharedTests(Future runSass(Iterable arguments)) { + Future watch(Iterable arguments) => + runSass(["--no-source-map", "--watch"]..addAll(arguments)); + + group("when started", () { + test("updates a CSS file whose source was modified", () async { + await d.file("out.css", "x {y: z}").create(); + await new Future.delayed(new Duration(milliseconds: 10)); + await d.file("test.scss", "a {b: c}").create(); + + var sass = await watch(["test.scss:out.css"]); + await expectLater(sass.stdout, emits('Compiled test.scss to out.css.')); + await expectLater(sass.stdout, _watchingForChanges); + await sass.kill(); + + await d + .file("out.css", equalsIgnoringWhitespace("a { b: c; }")) + .validate(); + }); + + test("doesn't update a CSS file that wasn't modified", () async { + await d.file("test.scss", "a {b: c}").create(); + await d.file("out.css", "x {y: z}").create(); + + var sass = await watch(["test.scss:out.css"]); + await expectLater(sass.stdout, _watchingForChanges); + await sass.kill(); + + await d.file("out.css", "x {y: z}").validate(); + }); + }); + + group("recompiles a watched file", () { + test("when it's modified", () async { + await d.file("test.scss", "a {b: c}").create(); + + var sass = await watch(["test.scss:out.css"]); + await expectLater(sass.stdout, emits('Compiled test.scss to out.css.')); + await expectLater(sass.stdout, _watchingForChanges); + + await d.file("test.scss", "x {y: z}").create(); + await expectLater(sass.stdout, emits('Compiled test.scss to out.css.')); + await sass.kill(); + + await d + .file("out.css", equalsIgnoringWhitespace("x { y: z; }")) + .validate(); + }); + + test("when it's modified when watched from a directory", () async { + await d.dir("dir", [d.file("test.scss", "a {b: c}")]).create(); + + var sass = await watch(["dir:out"]); + await expectLater( + sass.stdout, emits('Compiled dir/test.scss to out/test.css.')); + await expectLater(sass.stdout, _watchingForChanges); + + await d.dir("dir", [d.file("test.scss", "x {y: z}")]).create(); + await expectLater( + sass.stdout, emits('Compiled dir/test.scss to out/test.css.')); + await sass.kill(); + + await d.dir("out", [ + d.file("test.css", equalsIgnoringWhitespace("x { y: z; }")) + ]).validate(); + }); + + test("when its dependency is modified", () async { + await d.file("_other.scss", "a {b: c}").create(); + await d.file("test.scss", "@import 'other'").create(); + + var sass = await watch(["test.scss:out.css"]); + await expectLater(sass.stdout, emits('Compiled test.scss to out.css.')); + await expectLater(sass.stdout, _watchingForChanges); + + await d.file("_other.scss", "x {y: z}").create(); + await expectLater(sass.stdout, emits('Compiled test.scss to out.css.')); + await sass.kill(); + + await d + .file("out.css", equalsIgnoringWhitespace("x { y: z; }")) + .validate(); + }); + + test("when it's deleted and re-added", () async { + await d.file("test.scss", "a {b: c}").create(); + + var sass = await watch(["test.scss:out.css"]); + await expectLater(sass.stdout, emits('Compiled test.scss to out.css.')); + await expectLater(sass.stdout, _watchingForChanges); + + new File(p.join(d.sandbox, "test.scss")).deleteSync(); + await expectLater(sass.stdout, emits('Deleted out.css.')); + + await d.file("test.scss", "x {y: z}").create(); + await expectLater(sass.stdout, emits('Compiled test.scss to out.css.')); + await sass.kill(); + + await d + .file("out.css", equalsIgnoringWhitespace("x { y: z; }")) + .validate(); + }); + + group("when its dependency is deleted", () { + test("and removes the output", () async { + await d.file("_other.scss", "a {b: c}").create(); + await d.file("test.scss", "@import 'other'").create(); + + var sass = await watch(["test.scss:out.css"]); + await expectLater(sass.stdout, emits('Compiled test.scss to out.css.')); + await expectLater(sass.stdout, _watchingForChanges); + + new File(p.join(d.sandbox, "_other.scss")).deleteSync(); + await expectLater( + sass.stderr, emits("Error: Can't find stylesheet to import.")); + await expectLater(sass.stderr, emitsThrough(contains('test.scss 1:9'))); + await sass.kill(); + + await d.nothing("out.css").validate(); + }); + + test("but another is available", () async { + await d.file("_other.scss", "a {b: c}").create(); + await d.file("test.scss", "@import 'other'").create(); + await d.dir("dir", [d.file("_other.scss", "x {y: z}")]).create(); + + var sass = await watch(["-I", "dir", "test.scss:out.css"]); + await expectLater(sass.stdout, emits('Compiled test.scss to out.css.')); + await expectLater(sass.stdout, _watchingForChanges); + + new File(p.join(d.sandbox, "_other.scss")).deleteSync(); + await expectLater(sass.stdout, emits('Compiled test.scss to out.css.')); + await sass.kill(); + + await d + .file("out.css", equalsIgnoringWhitespace("x { y: z; }")) + .validate(); + }); + + test("which resolves a conflict", () async { + await d.file("_other.scss", "a {b: c}").create(); + await d.file("_other.sass", "x\n y: z").create(); + await d.file("test.scss", "@import 'other'").create(); + + var sass = await watch(["test.scss:out.css"]); + await expectLater(sass.stderr, + emits("Error: It's not clear which file to import. Found:")); + await expectLater(sass.stdout, _watchingForChanges); + + new File(p.join(d.sandbox, "_other.sass")).deleteSync(); + await expectLater(sass.stdout, emits('Compiled test.scss to out.css.')); + await sass.kill(); + + await d + .file("out.css", equalsIgnoringWhitespace("a { b: c; }")) + .validate(); + }); + }); + + group("when a dependency is added", () { + group("that was missing", () { + test("relative to the file", () async { + await d.file("test.scss", "@import 'other'").create(); + + var sass = await watch(["test.scss:out.css"]); + await expectLater( + sass.stderr, emits("Error: Can't find stylesheet to import.")); + await expectLater( + sass.stderr, emitsThrough(contains("test.scss 1:9"))); + await expectLater(sass.stdout, _watchingForChanges); + + await d.file("_other.scss", "a {b: c}").create(); + await expectLater( + sass.stdout, emits('Compiled test.scss to out.css.')); + await sass.kill(); + + await d + .file("out.css", equalsIgnoringWhitespace("a { b: c; }")) + .validate(); + }); + + test("on a load path", () async { + await d.file("test.scss", "@import 'other'").create(); + await d.dir("dir").create(); + + var sass = await watch(["-I", "dir", "test.scss:out.css"]); + await expectLater( + sass.stderr, emits("Error: Can't find stylesheet to import.")); + await expectLater( + sass.stderr, emitsThrough(contains("test.scss 1:9"))); + await expectLater(sass.stdout, _watchingForChanges); + + await d.dir("dir", [d.file("_other.scss", "a {b: c}")]).create(); + await expectLater( + sass.stdout, emits('Compiled test.scss to out.css.')); + await sass.kill(); + + await d + .file("out.css", equalsIgnoringWhitespace("a { b: c; }")) + .validate(); + }); + + test("on a load path that was created", () async { + await d + .dir("dir1", [d.file("test.scss", "@import 'other'")]).create(); + + var sass = await watch(["-I", "dir2", "dir1:out"]); + await expectLater( + sass.stderr, emits("Error: Can't find stylesheet to import.")); + await expectLater( + sass.stderr, emitsThrough(contains("dir1/test.scss 1:9"))); + await expectLater(sass.stdout, _watchingForChanges); + + await d.dir("dir2", [d.file("_other.scss", "a {b: c}")]).create(); + await expectLater( + sass.stdout, emits('Compiled dir1/test.scss to out/test.css.')); + await sass.kill(); + + await d + .file("out/test.css", equalsIgnoringWhitespace("a { b: c; }")) + .validate(); + }); + }); + + test("that conflicts with the previous dependency", () async { + await d.file("_other.scss", "a {b: c}").create(); + await d.file("test.scss", "@import 'other'").create(); + + var sass = await watch(["test.scss:out.css"]); + await expectLater(sass.stdout, emits('Compiled test.scss to out.css.')); + await expectLater(sass.stdout, _watchingForChanges); + + await d.file("_other.sass", "x\n y: z").create(); + await expectLater(sass.stderr, + emits("Error: It's not clear which file to import. Found:")); + await expectLater(sass.stdout, emits("Deleted out.css.")); + await sass.kill(); + + await d.nothing("out.css").validate(); + }); + + group("that overrides the previous dependency", () { + test("on an import path", () async { + await d.file("test.scss", "@import 'other'").create(); + await d.dir("dir2", [d.file("_other.scss", "a {b: c}")]).create(); + await d.dir("dir1").create(); + + var sass = + await watch(["-I", "dir1", "-I", "dir2", "test.scss:out.css"]); + await expectLater( + sass.stdout, emits('Compiled test.scss to out.css.')); + await expectLater(sass.stdout, _watchingForChanges); + + await d.dir("dir1", [d.file("_other.scss", "x {y: z}")]).create(); + await expectLater( + sass.stdout, emits('Compiled test.scss to out.css.')); + await sass.kill(); + + await d + .file("out.css", equalsIgnoringWhitespace("x { y: z; }")) + .validate(); + }); + + test("because it's relative", () async { + await d.file("test.scss", "@import 'other'").create(); + await d.dir("dir", [d.file("_other.scss", "a {b: c}")]).create(); + + var sass = await watch(["-I", "dir", "test.scss:out.css"]); + await expectLater( + sass.stdout, emits('Compiled test.scss to out.css.')); + await expectLater(sass.stdout, _watchingForChanges); + + await d.file("_other.scss", "x {y: z}").create(); + await expectLater( + sass.stdout, emits('Compiled test.scss to out.css.')); + await sass.kill(); + + await d + .file("out.css", equalsIgnoringWhitespace("x { y: z; }")) + .validate(); + }); + + test("because it's not an index", () async { + await d.file("test.scss", "@import 'other'").create(); + await d.dir("other", [d.file("_index.scss", "a {b: c}")]).create(); + + var sass = await watch(["test.scss:out.css"]); + await expectLater( + sass.stdout, emits('Compiled test.scss to out.css.')); + await expectLater(sass.stdout, _watchingForChanges); + + await d.file("_other.scss", "x {y: z}").create(); + await expectLater( + sass.stdout, emits('Compiled test.scss to out.css.')); + await sass.kill(); + + await d + .file("out.css", equalsIgnoringWhitespace("x { y: z; }")) + .validate(); + }); + }); + + test("gracefully handles a parse error", () async { + await d.dir("dir").create(); + + var sass = await watch(["dir:out"]); + await expectLater(sass.stdout, _watchingForChanges); + + await d.dir("dir", [d.file("test.scss", "a {b: }")]).create(); + await expectLater(sass.stderr, emits('Error: Expected expression.')); + await sass.kill(); + }); + }); + }); + + group("doesn't recompile the watched file", () { + test("when an unrelated file is modified", () async { + await d.dir("dir", [ + d.file("test1.scss", "a {b: c}"), + d.file("test2.scss", "a {b: c}") + ]).create(); + + var sass = await watch(["dir:out"]); + await expectLater( + sass.stdout, + emitsInAnyOrder([ + 'Compiled dir/test1.scss to out/test1.css.', + 'Compiled dir/test2.scss to out/test2.css.' + ])); + await expectLater(sass.stdout, _watchingForChanges); + + await d.dir("dir", [d.file("test2.scss", "x {y: z}")]).create(); + await expectLater( + sass.stdout, emits('Compiled dir/test2.scss to out/test2.css.')); + expect( + sass.stdout, neverEmits('Compiled dir/test1.scss to out/test1.css.')); + await tick; + await sass.kill(); + }); + + test("when a potential dependency that's not actually imported is added", + () async { + await d.file("test.scss", "@import 'other'").create(); + await d.file("_other.scss", "a {b: c}").create(); + await d.dir("dir").create(); + + var sass = await watch(["-I", "dir", "test.scss:out.css"]); + await expectLater(sass.stdout, emits('Compiled test.scss to out.css.')); + await expectLater(sass.stdout, _watchingForChanges); + + await d.dir("dir", [d.file("_other.scss", "a {b: c}")]).create(); + expect(sass.stdout, neverEmits('Compiled test.scss to out.css.')); + await tick; + await sass.kill(); + + await d + .file("out.css", equalsIgnoringWhitespace("a { b: c; }")) + .validate(); + }); + }); + + group("deletes the CSS", () { + test("when a file is deleted", () async { + await d.file("test.scss", "a {b: c}").create(); + + var sass = await watch(["test.scss:out.css"]); + await expectLater(sass.stdout, emits('Compiled test.scss to out.css.')); + await expectLater(sass.stdout, _watchingForChanges); + + new File(p.join(d.sandbox, "test.scss")).deleteSync(); + await expectLater(sass.stdout, emits('Deleted out.css.')); + await sass.kill(); + + await d.nothing("out.css").validate(); + }); + + test("when a file is deleted within a directory", () async { + await d.dir("dir", [d.file("test.scss", "a {b: c}")]).create(); + + var sass = await watch(["dir:out"]); + await expectLater( + sass.stdout, emits('Compiled dir/test.scss to out/test.css.')); + await expectLater(sass.stdout, _watchingForChanges); + + new File(p.join(d.sandbox, "dir", "test.scss")).deleteSync(); + await expectLater(sass.stdout, emits('Deleted out/test.css.')); + await sass.kill(); + + await d.dir("dir", [d.nothing("out.css")]).validate(); + }); + }); + + test("creates a new CSS file when a Sass file is added", () async { + await d.dir("dir").create(); + + var sass = await watch(["dir:out"]); + await expectLater(sass.stdout, _watchingForChanges); + + await d.dir("dir", [d.file("test.scss", "a {b: c}")]).create(); + await expectLater( + sass.stdout, emits('Compiled dir/test.scss to out/test.css.')); + await sass.kill(); + + await d.dir("out", [ + d.file("test.css", equalsIgnoringWhitespace("a { b: c; }")) + ]).validate(); + }); + + test("doesn't create a new CSS file when a partial is added", () async { + await d.dir("dir").create(); + + var sass = await watch(["dir:out"]); + await expectLater(sass.stdout, _watchingForChanges); + + await d.dir("dir", [d.file("_test.scss", "a {b: c}")]).create(); + expect(sass.stdout, neverEmits('Compiled dir/test.scss to out/test.css.')); + await tick; + await sass.kill(); + + await d.nothing("out/test.scss").validate(); + }); + + group("doesn't allow", () { + test("--stdin", () async { + var sass = await watch(["--stdin", "test.scss"]); + expect(sass.stdout, emits('--watch is not allowed with --stdin.')); + await sass.shouldExit(64); + }); + + test("printing to stderr", () async { + var sass = await watch(["test.scss"]); + expect(sass.stdout, + emits('--watch is not allowed when printing to stdout.')); + await sass.shouldExit(64); + }); + }); +} + +/// Matches the output that indicates that Sass is watching for changes. +final _watchingForChanges = + emitsInOrder(["Sass is watching for changes. Press Ctrl-C to stop.", ""]);