// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. library watcher.test.utils; import 'dart:async'; import 'dart:collection'; import 'dart:io'; import 'package:path/path.dart' as p; import 'package:scheduled_test/scheduled_test.dart'; import 'package:unittest/compact_vm_config.dart'; import 'package:watcher/watcher.dart'; import 'package:watcher/src/stat.dart'; import 'package:watcher/src/utils.dart'; // TODO(nweiz): remove this when issue 15042 is fixed. import 'package:watcher/src/directory_watcher/mac_os.dart'; /// The path to the temporary sandbox created for each test. All file /// operations are implicitly relative to this directory. String _sandboxDir; /// The [DirectoryWatcher] being used for the current scheduled test. DirectoryWatcher _watcher; /// The index in [_watcher]'s event stream for the next event. When event /// expectations are set using [expectEvent] (et. al.), they use this to /// expect a series of events in order. var _nextEvent = 0; /// The mock modification times (in milliseconds since epoch) for each file. /// /// The actual file system has pretty coarse granularity for file modification /// times. This means using the real file system requires us to put delays in /// the tests to ensure we wait long enough between operations for the mod time /// to be different. /// /// Instead, we'll just mock that out. Each time a file is written, we manually /// increment the mod time for that file instantly. Map _mockFileModificationTimes; typedef DirectoryWatcher WatcherFactory(String directory); /// Sets the function used to create the directory watcher. set watcherFactory(WatcherFactory factory) { _watcherFactory = factory; } WatcherFactory _watcherFactory; void initConfig() { useCompactVMConfiguration(); filterStacks = true; } /// Creates the sandbox directory the other functions in this library use and /// ensures it's deleted when the test ends. /// /// This should usually be called by [setUp]. void createSandbox() { var dir = Directory.systemTemp.createTempSync('watcher_test_'); _sandboxDir = dir.path; _mockFileModificationTimes = new Map(); mockGetModificationTime((path) { path = p.normalize(p.relative(path, from: _sandboxDir)); // Make sure we got a path in the sandbox. assert(p.isRelative(path) && !path.startsWith("..")); var mtime = _mockFileModificationTimes[path]; return new DateTime.fromMillisecondsSinceEpoch(mtime == null ? 0 : mtime); }); // Delete the sandbox when done. currentSchedule.onComplete.schedule(() { if (_sandboxDir != null) { new Directory(_sandboxDir).deleteSync(recursive: true); _sandboxDir = null; } _mockFileModificationTimes = null; mockGetModificationTime(null); }, "delete sandbox"); } /// Creates a new [DirectoryWatcher] that watches a temporary directory. /// /// Normally, this will pause the schedule until the watcher is done scanning /// and is polling for changes. If you pass `false` for [waitForReady], it will /// not schedule this delay. /// /// If [dir] is provided, watches a subdirectory in the sandbox with that name. DirectoryWatcher createWatcher({String dir, bool waitForReady}) { if (dir == null) { dir = _sandboxDir; } else { dir = p.join(_sandboxDir, dir); } var watcher = _watcherFactory(dir); // Wait until the scan is finished so that we don't miss changes to files // that could occur before the scan completes. if (waitForReady != false) { schedule(() => watcher.ready, "wait for watcher to be ready"); } return watcher; } /// The stream of events from the watcher started with [startWatcher]. Stream _watcherEvents; /// Creates a new [DirectoryWatcher] that watches a temporary directory and /// starts monitoring it for events. /// /// If [dir] is provided, watches a subdirectory in the sandbox with that name. void startWatcher({String dir}) { var testCase = currentTestCase.description; if (MacOSDirectoryWatcher.logDebugInfo) { print("starting watcher for $testCase"); } // We want to wait until we're ready *after* we subscribe to the watcher's // events. _watcher = createWatcher(dir: dir, waitForReady: false); // Schedule [_watcher.events.listen] so that the watcher doesn't start // watching [dir] before it exists. Expose [_watcherEvents] immediately so // that it can be accessed synchronously after this. _watcherEvents = futureStream(schedule(() { var allEvents = new Queue(); var subscription = _watcher.events.listen(allEvents.add, onError: currentSchedule.signalError); currentSchedule.onComplete.schedule(() { if (MacOSDirectoryWatcher.logDebugInfo) { print("stopping watcher for $testCase"); } var numEvents = _nextEvent; subscription.cancel(); _nextEvent = 0; _watcher = null; // If there are already errors, don't add this to the output and make // people think it might be the root cause. if (currentSchedule.errors.isEmpty) { expect(allEvents, hasLength(numEvents)); } else { currentSchedule.addDebugInfo("Events fired:\n${allEvents.join('\n')}"); } }, "reset watcher"); return _watcher.events; }, "create watcher"), broadcast: true); schedule(() => _watcher.ready, "wait for watcher to be ready"); } /// A future set by [inAnyOrder] that will complete to the set of events that /// occur in the [inAnyOrder] block. Future> _unorderedEventFuture; /// Runs [block] and allows multiple [expectEvent] calls in that block to match /// events in any order. void inAnyOrder(block()) { var oldFuture = _unorderedEventFuture; try { var firstEvent = _nextEvent; var completer = new Completer(); _unorderedEventFuture = completer.future; block(); _watcherEvents.skip(firstEvent).take(_nextEvent - firstEvent).toSet() .then(completer.complete, onError: completer.completeError); currentSchedule.wrapFuture(_unorderedEventFuture, "waiting for ${_nextEvent - firstEvent} events"); } finally { _unorderedEventFuture = oldFuture; } } /// Expects that the next set of event will be a change of [type] on [path]. /// /// Multiple calls to [expectEvent] require that the events are received in that /// order unless they're called in an [inAnyOrder] block. void expectEvent(ChangeType type, String path) { if (_unorderedEventFuture != null) { // Assign this to a local variable since it will be un-assigned by the time // the scheduled callback runs. var future = _unorderedEventFuture; expect( schedule(() => future, "should fire $type event on $path"), completion(contains(isWatchEvent(type, path)))); } else { var future = currentSchedule.wrapFuture( _watcherEvents.elementAt(_nextEvent), "waiting for $type event on $path"); expect( schedule(() => future, "should fire $type event on $path"), completion(isWatchEvent(type, path))); } _nextEvent++; } /// Returns a matcher that matches a [WatchEvent] with the given [type] and /// [path]. Matcher isWatchEvent(ChangeType type, String path) { return predicate((e) { return e is WatchEvent && e.type == type && e.path == p.join(_sandboxDir, p.normalize(path)); }, "is $type $path"); } void expectAddEvent(String path) => expectEvent(ChangeType.ADD, path); void expectModifyEvent(String path) => expectEvent(ChangeType.MODIFY, path); void expectRemoveEvent(String path) => expectEvent(ChangeType.REMOVE, path); /// Schedules writing a file in the sandbox at [path] with [contents]. /// /// If [contents] is omitted, creates an empty file. If [updatedModified] is /// `false`, the mock file modification time is not changed. void writeFile(String path, {String contents, bool updateModified}) { if (contents == null) contents = ""; if (updateModified == null) updateModified = true; schedule(() { var fullPath = p.join(_sandboxDir, path); // Create any needed subdirectories. var dir = new Directory(p.dirname(fullPath)); if (!dir.existsSync()) { dir.createSync(recursive: true); } new File(fullPath).writeAsStringSync(contents); // Manually update the mock modification time for the file. if (updateModified) { // Make sure we always use the same separator on Windows. path = p.normalize(path); var milliseconds = _mockFileModificationTimes.putIfAbsent(path, () => 0); _mockFileModificationTimes[path]++; } }, "write file $path"); } /// Schedules deleting a file in the sandbox at [path]. void deleteFile(String path) { schedule(() { new File(p.join(_sandboxDir, path)).deleteSync(); }, "delete file $path"); } /// Schedules renaming a file in the sandbox from [from] to [to]. /// /// If [contents] is omitted, creates an empty file. void renameFile(String from, String to) { schedule(() { new File(p.join(_sandboxDir, from)).renameSync(p.join(_sandboxDir, to)); // Make sure we always use the same separator on Windows. to = p.normalize(to); // Manually update the mock modification time for the file. var milliseconds = _mockFileModificationTimes.putIfAbsent(to, () => 0); _mockFileModificationTimes[to]++; }, "rename file $from to $to"); } /// Schedules creating a directory in the sandbox at [path]. void createDir(String path) { schedule(() { new Directory(p.join(_sandboxDir, path)).createSync(); }, "create directory $path"); } /// Schedules renaming a directory in the sandbox from [from] to [to]. void renameDir(String from, String to) { schedule(() { new Directory(p.join(_sandboxDir, from)) .renameSync(p.join(_sandboxDir, to)); }, "rename directory $from to $to"); } /// Schedules deleting a directory in the sandbox at [path]. void deleteDir(String path) { schedule(() { new Directory(p.join(_sandboxDir, path)).deleteSync(recursive: true); }, "delete directory $path"); } /// Runs [callback] with every permutation of non-negative [i], [j], and [k] /// less than [limit]. /// /// [limit] defaults to 3. void withPermutations(callback(int i, int j, int k), {int limit}) { if (limit == null) limit = 3; for (var i = 0; i < limit; i++) { for (var j = 0; j < limit; j++) { for (var k = 0; k < limit; k++) { callback(i, j, k); } } } }