| // 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. |
| |
| import 'dart:async'; |
| import 'dart:io'; |
| |
| import 'package:async/async.dart'; |
| import 'package:path/path.dart' as p; |
| import 'package:test/test.dart'; |
| import 'package:test_descriptor/test_descriptor.dart' as d; |
| import 'package:watcher/src/stat.dart'; |
| import 'package:watcher/watcher.dart'; |
| |
| /// Edit this to run fast-running tests many times. |
| int runsPerTest = 1; |
| |
| typedef WatcherFactory = Watcher Function(String directory); |
| |
| /// Sets the function used to create the watcher. |
| set watcherFactory(WatcherFactory factory) { |
| _watcherFactory = factory; |
| } |
| |
| /// 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<String, int>? _mockFileModificationTimes; |
| |
| /// If real modification times are used, a directory where a test file will be |
| /// updated to wait for a new modification time. |
| Directory? _waitForModificationTimesDirectory; |
| |
| late WatcherFactory _watcherFactory; |
| |
| /// Creates a new [Watcher] that watches a temporary file or directory. |
| /// |
| /// If [path] is provided, watches a subdirectory in the sandbox with that name. |
| Watcher createWatcher({String? path}) { |
| if (path == null) { |
| path = d.sandbox; |
| } else { |
| path = p.join(d.sandbox, path); |
| } |
| |
| return _watcherFactory(path); |
| } |
| |
| /// The stream of events from the watcher started with [startWatcher]. |
| late StreamQueue<WatchEvent> _watcherEvents; |
| |
| /// Whether the event stream has been closed. |
| /// |
| /// If this is not done by a test (by calling [startClosingEventStream]) it will |
| /// be done automatically via [addTearDown] in [startWatcher]. |
| var _hasClosedStream = true; |
| |
| /// Enables waiting before writes to ensure a different modification time. |
| /// |
| /// This will allow polling watchers to notice all writes. |
| /// |
| /// Resets at the end of the test. |
| void enableWaitingForDifferentModificationTimes() { |
| if (_waitForModificationTimesDirectory != null) return; |
| _waitForModificationTimesDirectory = |
| Directory.systemTemp.createTempSync('dart_test_'); |
| addTearDown(() { |
| _waitForModificationTimesDirectory!.deleteSync(recursive: true); |
| _waitForModificationTimesDirectory = null; |
| }); |
| } |
| |
| /// If [enableWaitingForDifferentModificationTimes] was called, sleeps until a |
| /// modified file has a new modified timestamp. |
| void _maybeWaitForDifferentModificationTime() { |
| if (_waitForModificationTimesDirectory == null) return; |
| var file = File(p.join(_waitForModificationTimesDirectory!.path, 'file')); |
| if (file.existsSync()) file.deleteSync(); |
| file.createSync(); |
| final time = file.statSync().modified; |
| while (true) { |
| file.deleteSync(); |
| file.createSync(); |
| final updatedTime = file.statSync().modified; |
| if (time != updatedTime) { |
| return; |
| } |
| sleep(const Duration(milliseconds: 1)); |
| } |
| } |
| |
| /// Enables mock modification times so that all writes set a different |
| /// modification time. |
| /// |
| /// This will allow polling watchers to notice all writes. |
| /// |
| /// Resets at the end of the test. |
| void enableMockModificationTimes() { |
| _mockFileModificationTimes = {}; |
| mockGetModificationTime((path) { |
| // Resolve symbolic links before looking up mtime to match documented |
| // behavior of `FileSystemEntity.stat`. |
| final link = Link(path); |
| if (link.existsSync()) { |
| path = link.resolveSymbolicLinksSync(); |
| } |
| // Also resolve symbolic links in the enclosing directory. |
| final file = File(path); |
| if (file.existsSync()) { |
| path = file.resolveSymbolicLinksSync(); |
| } |
| |
| var normalized = p.normalize(p.relative(path, from: d.sandbox)); |
| |
| // Make sure we got a path in the sandbox. |
| if (!p.isRelative(normalized) || normalized.startsWith('..')) { |
| // The polling watcher can poll during test teardown, signal using an |
| // exception that it will ignore. |
| throw FileSystemException( |
| 'Path is not in the sandbox: $path not in ${d.sandbox}', |
| ); |
| } |
| final mockFileModificationTimes = _mockFileModificationTimes!; |
| var mtime = mockFileModificationTimes[normalized]; |
| return mtime != null ? DateTime.fromMillisecondsSinceEpoch(mtime) : null; |
| }); |
| addTearDown(() { |
| _mockFileModificationTimes = null; |
| mockGetModificationTime(null); |
| }); |
| } |
| |
| /// Creates a new [Watcher] that watches a temporary file or directory and |
| /// starts monitoring it for events. |
| /// |
| /// If [path] is provided, watches a path in the sandbox with that name. |
| Future<void> startWatcher({String? path}) async { |
| // We want to wait until we're ready *after* we subscribe to the watcher's |
| // events. |
| var watcher = createWatcher(path: path); |
| _watcherEvents = StreamQueue(watcher.events); |
| // Forces a subscription to the underlying stream. |
| unawaited(_watcherEvents.hasNext); |
| |
| _hasClosedStream = false; |
| addTearDown(startClosingEventStream); |
| |
| await watcher.ready; |
| } |
| |
| /// Schedule closing the watcher stream after the event queue has been pumped. |
| /// |
| /// This is necessary when events are allowed to occur, but don't have to occur, |
| /// at the end of a test. Otherwise, if they don't occur, the test will wait |
| /// indefinitely because they might in the future and because the watcher is |
| /// normally only closed after the test completes. |
| void startClosingEventStream() async { |
| if (_hasClosedStream) return; |
| _hasClosedStream = true; |
| await pumpEventQueue(); |
| await _watcherEvents.cancel(immediate: true); |
| } |
| |
| /// Add [streamMatcher] as an expectation to [_watcherEvents]. |
| /// |
| /// [streamMatcher] can be a [StreamMatcher], a [Matcher], or a value. |
| Future _expect(Matcher streamMatcher) { |
| return expectLater(_watcherEvents, emits(streamMatcher)); |
| } |
| |
| /// Expects that [matchers] will match emitted events in any order. |
| /// |
| /// [matchers] may be [Matcher]s or values, but not [StreamMatcher]s. |
| Future inAnyOrder(Iterable matchers) => |
| _expect(emitsInAnyOrder(matchers.toSet())); |
| |
| /// Returns a StreamMatcher 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(d.sandbox, p.normalize(path)); |
| }, 'is $type $path'); |
| } |
| |
| /// Returns a [Matcher] that matches a [WatchEvent] for an add event for [path]. |
| Matcher isAddEvent(String path) => isWatchEvent(ChangeType.ADD, path); |
| |
| /// Returns a [Matcher] that matches a [WatchEvent] for a modification event for |
| /// [path]. |
| Matcher isModifyEvent(String path) => isWatchEvent(ChangeType.MODIFY, path); |
| |
| /// Returns a [Matcher] that matches a [WatchEvent] for a removal event for |
| /// [path]. |
| Matcher isRemoveEvent(String path) => isWatchEvent(ChangeType.REMOVE, path); |
| |
| /// Takes the first event emitted during [duration], or returns `null` if there |
| /// is none. |
| Future<WatchEvent?> waitForEvent({ |
| Duration duration = const Duration(seconds: 1), |
| }) async { |
| final result = await _watcherEvents.peek |
| .then<WatchEvent?>((e) => e) |
| .timeout(duration, onTimeout: () => null); |
| if (result != null) _watcherEvents.take(1).ignore(); |
| return result; |
| } |
| |
| /// Expects that no events are emitted for [duration]. |
| Future expectNoEvents({Duration duration = const Duration(seconds: 1)}) async { |
| expect(await waitForEvent(duration: duration), isNull); |
| } |
| |
| /// Takes all events emitted for [duration]. |
| Future<List<WatchEvent>> takeEvents({required Duration duration}) async { |
| final result = <WatchEvent>[]; |
| final stopwatch = Stopwatch()..start(); |
| while (stopwatch.elapsed < duration) { |
| final event = await waitForEvent(duration: duration - stopwatch.elapsed); |
| if (event != null) { |
| result.add(event); |
| } |
| } |
| return result; |
| } |
| |
| /// Expects that the next event emitted will be for an add event for [path]. |
| Future expectAddEvent(String path) => |
| _expect(isWatchEvent(ChangeType.ADD, path)); |
| |
| /// Expects that the next event emitted will be for a modification event for |
| /// [path]. |
| Future expectModifyEvent(String path) => |
| _expect(isWatchEvent(ChangeType.MODIFY, path)); |
| |
| /// Expects that the next event emitted will be for a removal event for [path]. |
| Future expectRemoveEvent(String path) => |
| _expect(isWatchEvent(ChangeType.REMOVE, path)); |
| |
| /// Track a fake timestamp to be used when writing files. This always increases |
| /// so that files that are deleted and re-created do not have their timestamp |
| /// set back to a previously used value. |
| int _nextTimestamp = 1; |
| |
| /// Writes a file in the sandbox at [path] with [contents]. |
| /// |
| /// If [path] is currently a link it is deleted and a file is written in its |
| /// place. |
| /// |
| /// If [contents] is omitted, creates an empty file. |
| /// |
| /// If [updateModified] is `false` and mock modification times are in use, the |
| /// mock file modification time is not changed. |
| void writeFile(String path, {String? contents, bool? updateModified}) { |
| _maybeWaitForDifferentModificationTime(); |
| |
| contents ??= ''; |
| updateModified ??= true; |
| |
| var fullPath = p.join(d.sandbox, path); |
| |
| // Create any needed subdirectories. |
| var dir = Directory(p.dirname(fullPath)); |
| if (!dir.existsSync()) { |
| dir.createSync(recursive: true); |
| } |
| |
| var file = File(fullPath); |
| // `File.writeAsStringSync` would write through the link, so if there is a |
| // link then start by deleting it. |
| if (FileSystemEntity.typeSync(fullPath, followLinks: false) == |
| FileSystemEntityType.link) { |
| file.deleteSync(); |
| } |
| file.writeAsStringSync(contents); |
| // Check that `fullPath` now refers to a file, not a link. |
| expect(FileSystemEntity.typeSync(fullPath), FileSystemEntityType.file); |
| |
| final mockFileModificationTimes = _mockFileModificationTimes; |
| if (mockFileModificationTimes != null && updateModified) { |
| path = p.normalize(path); |
| |
| mockFileModificationTimes[path] = _nextTimestamp++; |
| } |
| } |
| |
| /// Writes a file in the sandbox at [link] pointing to [target]. |
| /// |
| /// [target] is relative to the sandbox, not to [link]. |
| /// |
| /// If [updateModified] is `false` and mock modification times are in use, the |
| /// mock file modification time is not changed. |
| void writeLink({ |
| required String link, |
| required String target, |
| bool? updateModified, |
| }) { |
| _maybeWaitForDifferentModificationTime(); |
| |
| updateModified ??= true; |
| |
| var fullPath = p.join(d.sandbox, link); |
| |
| // Create any needed subdirectories. |
| var dir = Directory(p.dirname(fullPath)); |
| if (!dir.existsSync()) { |
| dir.createSync(recursive: true); |
| } |
| |
| Link(fullPath).createSync(p.join(d.sandbox, target)); |
| |
| if (updateModified) { |
| link = p.normalize(link); |
| |
| final mockFileModificationTimes = _mockFileModificationTimes; |
| if (mockFileModificationTimes != null) { |
| mockFileModificationTimes[link] = _nextTimestamp++; |
| } |
| } |
| } |
| |
| /// Deletes a file in the sandbox at [path]. |
| void deleteFile(String path) { |
| final fullPath = p.join(d.sandbox, path); |
| expect(FileSystemEntity.typeSync(fullPath, followLinks: false), |
| FileSystemEntityType.file); |
| File(fullPath).deleteSync(); |
| |
| final mockFileModificationTimes = _mockFileModificationTimes; |
| if (mockFileModificationTimes != null) { |
| mockFileModificationTimes.remove(path); |
| } |
| } |
| |
| /// Deletes a link in the sandbox at [path]. |
| void deleteLink(String path) { |
| final fullPath = p.join(d.sandbox, path); |
| expect(FileSystemEntity.typeSync(fullPath, followLinks: false), |
| FileSystemEntityType.link); |
| Link(fullPath).deleteSync(); |
| |
| final mockFileModificationTimes = _mockFileModificationTimes; |
| if (mockFileModificationTimes != null) { |
| mockFileModificationTimes.remove(path); |
| } |
| } |
| |
| /// Renames a file in the sandbox from [from] to [to]. |
| void renameFile(String from, String to) { |
| _maybeWaitForDifferentModificationTime(); |
| |
| var absoluteTo = p.join(d.sandbox, to); |
| File(p.join(d.sandbox, from)).renameSync(absoluteTo); |
| expect(FileSystemEntity.typeSync(absoluteTo, followLinks: false), |
| FileSystemEntityType.file); |
| |
| final mockFileModificationTimes = _mockFileModificationTimes; |
| if (mockFileModificationTimes != null) { |
| // Make sure we always use the same separator on Windows. |
| to = p.normalize(to); |
| mockFileModificationTimes.update(to, (value) => value + 1, |
| ifAbsent: () => 1); |
| } |
| } |
| |
| /// Renames a link in the sandbox from [from] to [to]. |
| /// |
| /// On MacOS and Linux links can also be named with `renameFile`. On Windows, |
| /// however, a link must be renamed with this method. |
| void renameLink(String from, String to) { |
| _maybeWaitForDifferentModificationTime(); |
| |
| var absoluteTo = p.join(d.sandbox, to); |
| Link(p.join(d.sandbox, from)).renameSync(absoluteTo); |
| expect(FileSystemEntity.typeSync(absoluteTo, followLinks: false), |
| FileSystemEntityType.link); |
| |
| final mockFileModificationTimes = _mockFileModificationTimes; |
| if (mockFileModificationTimes != null) { |
| // Make sure we always use the same separator on Windows. |
| to = p.normalize(to); |
| mockFileModificationTimes.update(to, (value) => value + 1, |
| ifAbsent: () => 1); |
| } |
| } |
| |
| /// Creates a directory in the sandbox at [path]. |
| void createDir(String path) { |
| Directory(p.join(d.sandbox, path)).createSync(); |
| } |
| |
| /// Renames a directory in the sandbox from [from] to [to]. |
| void renameDir(String from, String to) { |
| var absoluteTo = p.join(d.sandbox, to); |
| Directory(p.join(d.sandbox, from)).renameSync(absoluteTo); |
| expect(FileSystemEntity.typeSync(absoluteTo, followLinks: false), |
| FileSystemEntityType.directory); |
| |
| final mockFileModificationTimes = _mockFileModificationTimes; |
| if (mockFileModificationTimes != null) { |
| // Migrate timestamps for any files in this folder. |
| final knownFilePaths = mockFileModificationTimes.keys.toList(); |
| for (final filePath in knownFilePaths) { |
| if (p.isWithin(from, filePath)) { |
| final movedPath = |
| p.normalize(p.join(to, filePath.substring(from.length + 1))); |
| mockFileModificationTimes[movedPath] = |
| mockFileModificationTimes[filePath]!; |
| mockFileModificationTimes.remove(filePath); |
| } |
| } |
| } |
| } |
| |
| /// Deletes a directory in the sandbox at [path]. |
| void deleteDir(String path) { |
| final fullPath = p.join(d.sandbox, path); |
| expect(FileSystemEntity.typeSync(fullPath, followLinks: false), |
| FileSystemEntityType.directory); |
| Directory(fullPath).deleteSync(recursive: true); |
| } |
| |
| /// Runs [callback] with every permutation of non-negative numbers for each |
| /// argument less than [limit]. |
| /// |
| /// Returns a set of all values returns by [callback]. |
| /// |
| /// [limit] defaults to 3. |
| Set<S> withPermutations<S>(S Function(int, int, int) callback, {int? limit}) { |
| limit ??= 3; |
| var results = <S>{}; |
| for (var i = 0; i < limit; i++) { |
| for (var j = 0; j < limit; j++) { |
| for (var k = 0; k < limit; k++) { |
| results.add(callback(i, j, k)); |
| } |
| } |
| } |
| return results; |
| } |