Add a --watch command

Partially addresses #264
This commit is contained in:
Natalie Weizenbaum 2018-05-22 18:06:07 -04:00
parent a2c0f15d67
commit 6bbb961675
18 changed files with 956 additions and 16 deletions

View File

@ -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].
///

View File

@ -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<String> 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.

View File

@ -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);
}

View File

@ -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<String, String> get sourcesToDestinations {
if (_sourcesToDestinations != null) return _sourcesToDestinations;
_ensureSources();
return _sourcesToDestinations;
}
Map<String, String> _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<String, String> get sourceDirectoriesToDestinations {
_ensureSources();
return _sourceDirectoriesToDestinations;
}
Map<String, String> _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) {
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<String>();
var sourcesToDestinations = newPathMap<String>();
var sourceDirectoriesToDestinations = newPathMap<String>();
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<String, String> _sourcesToDestinations;
/// Returns whether [string] contains an absolute Windows path at [index].
bool _isWindowsPath(String string, int index) =>
string.length > index + 2 &&

View File

@ -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 = <String>[]
..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<StylesheetNode> nodes) async {
var seen = new Set<StylesheetNode>();
var toRecompile = new Queue<StylesheetNode>.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 = <StylesheetNode>[];
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;
}
}

View File

@ -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].
///

View File

@ -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<Stream<WatchEvent>> watchDir(String path) => null;

View File

@ -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<Stream<WatchEvent>> 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<WatchEvent> 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<Stream<WatchEvent>>();
watcher.on('ready', allowInterop(() {
controller = new StreamController<WatchEvent>();
completer.complete(controller.stream);
}));
return completer.future;
}

View File

@ -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<Stream<WatchEvent>> 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<WatchEvent>(watcher.events
.transform(const SingleSubscriptionTransformer<WatchEvent, WatchEvent>())
.listen((e) => print(e)));
await watcher.ready;
return stream;
}

View File

@ -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");

View File

@ -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

View File

@ -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 = <String, Stream<WatchEvent>>{};
/// The stream of events from all directories that are being watched.
Stream<WatchEvent> get events => _group.stream;
final _group = new StreamGroup<WatchEvent>();
/// Watches [directory] for changes.
///
/// Returns a [Future] that completes when [events] is ready to emit events
/// from [directory].
Future<void> 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;
}
}

View File

@ -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"
}

View File

@ -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"]

View File

@ -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"

View File

@ -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);
}

View File

@ -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);
}

457
test/cli/shared/watch.dart Normal file
View File

@ -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<TestProcess> runSass(Iterable<String> arguments)) {
Future<TestProcess> watch(Iterable<String> 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.", ""]);