// 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/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; } /// 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. /// Or, pass [exactPath] to watch an exact relative or absolute path without /// modifying it to add the sandbox path. Watcher createWatcher({String? exactPath, String? path}) { if (exactPath != null) { path = exactPath; } else 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 _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 [sleepUntilNewModificationTime]. /// /// Resets at the end of the test. /// /// IMPORTANT NOTE about "polling" watchers and tests: /// /// The polling watchers can notice changes if the "last modified" time of a /// file changed or if its size changed. /// /// Most tests that make changes also change the size of a file, so they do not /// have to care about whether the timing works out for the modification time to /// be different. /// /// Tests that intentionally write to a file _without_ changing its contents /// must also call [sleepUntilNewModificationTime] if testing a polling /// watcher. That ensures that the write causes a new modification time. /// /// At the time of writing in 2025, Windows "last modified" time is much coarser /// than other platforms, at 1s. void enableSleepUntilNewModificationTime() { if (_waitForModificationTimesDirectory != null) return; _waitForModificationTimesDirectory = Directory.systemTemp.createTempSync('dart_test_'); addTearDown(() { _waitForModificationTimesDirectory!.deleteSync(recursive: true); _waitForModificationTimesDirectory = null; }); } /// Sleeps the until a new modification time is available from the platform. /// /// This ensures that the next write can be detected by polling watches based on /// its "last modified" time, whatever the contents. void sleepUntilNewModificationTime() { _waitForModificationTimesDirectory ??= Directory.systemTemp.createTempSync(); 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)); } } /// 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. /// /// Or, pass [exactPath] to watch an exact path irrespective of the sandbox. Future startWatcher({String? exactPath, String? path}) async { // We want to wait until we're ready *after* we subscribe to the watcher's // events. var watcher = createWatcher(exactPath: exactPath, 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, {bool ignoreCase = false}) { var normalizedPath = p.join(d.sandbox, p.normalize(path)); if (ignoreCase) normalizedPath = normalizedPath.toLowerCase(); return predicate((e) { if (e is! WatchEvent) return false; var eventPath = e.path; if (ignoreCase) eventPath = eventPath.toLowerCase(); return e.type == type && eventPath == normalizedPath; }, 'is $type $path'); } /// Returns a [Matcher] that matches a [WatchEvent] for an add event for [path]. Matcher isAddEvent(String path, {bool ignoreCase = false}) => isWatchEvent(ChangeType.ADD, path, ignoreCase: ignoreCase); /// Returns a [Matcher] that matches a [WatchEvent] for a modification event for /// [path]. Matcher isModifyEvent(String path, {bool ignoreCase = false}) => isWatchEvent(ChangeType.MODIFY, path, ignoreCase: ignoreCase); /// Returns a [Matcher] that matches a [WatchEvent] for a removal event for /// [path]. Matcher isRemoveEvent(String path, {bool ignoreCase = false}) => isWatchEvent(ChangeType.REMOVE, path, ignoreCase: ignoreCase); /// Takes the first event emitted during [duration], or returns `null` if there /// is none. Future waitForEvent({ Duration duration = const Duration(seconds: 1), }) async { final result = await _watcherEvents.peek .then((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> takeEvents({required Duration duration}) async { final result = []; final stopwatch = Stopwatch()..start(); while (stopwatch.elapsed < duration) { final event = await waitForEvent(duration: duration - stopwatch.elapsed); if (event != null) { result.add(event); } } return result; } /// Returns a copy of [events] without events about paths that have a REMOVE /// event as the last event. /// /// For example, drops all events for a path with an ADD then a MODIFY then a /// REMOVE; keeps all events for a path with ADD, REMOVE, ADD. /// /// This allows tests to avoid flakes due to equivalent but different events /// about transient files. List foldDeletes(List events) { final lastEventByPath = {for (final event in events) event.path: event}; return events .where((event) => lastEventByPath[event.path]!.type != ChangeType.REMOVE) .toList(); } /// 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)); /// 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. void writeFile(String path, {String? contents}) { contents ??= ''; 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); } /// Writes a file in the sandbox at [link] pointing to [target]. /// /// [target] is relative to the sandbox, not to [link]. /// /// If [unawaitedAsync], the link is written asynchronously and not awaited. /// Otherwise, it's synchronous. See the note in `windows.dart` for issue 61797 /// for why this is needed for testing on Windows. void writeLink({ required String link, required String target, bool unawaitedAsync = false, }) { var fullPath = p.join(d.sandbox, link); // Create any needed subdirectories. var dir = Directory(p.dirname(fullPath)); if (!dir.existsSync()) { dir.createSync(recursive: true); } if (unawaitedAsync) { unawaited(Link(fullPath).create(p.join(d.sandbox, target))); } else { Link(fullPath).createSync(p.join(d.sandbox, target)); } } /// 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(); } /// 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(); } /// Renames a file in the sandbox from [from] to [to]. void renameFile(String from, String to) { var absoluteTo = p.join(d.sandbox, to); File(p.join(d.sandbox, from)).renameSync(absoluteTo); expect(FileSystemEntity.typeSync(absoluteTo, followLinks: false), FileSystemEntityType.file); } /// 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) { var absoluteTo = p.join(d.sandbox, to); Link(p.join(d.sandbox, from)).renameSync(absoluteTo); expect(FileSystemEntity.typeSync(absoluteTo, followLinks: false), FileSystemEntityType.link); } /// 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); // Fails sometimes on Windows, so guard+retry. retryForPathAccessException( () => Directory(p.join(d.sandbox, from)).renameSync(absoluteTo)); expect(FileSystemEntity.typeSync(absoluteTo, followLinks: false), FileSystemEntityType.directory); } /// 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 withPermutations(S Function(int, int, int) callback, {int? limit}) { limit ??= 3; var results = {}; 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; } /// Retries [action] until it does not throw [PathAccessException]. void retryForPathAccessException(void Function() action) { while (true) { try { action(); return; } on PathAccessException catch (e) { stderr.writeln('Temporary failure, retrying: $e'); } } } /// Returns the union of all elements in each set in [sets]. Set unionAll(Iterable> sets) => sets.fold({}, (union, set) => union.union(set));