diff --git a/pkgs/watcher/lib/src/stat.dart b/pkgs/watcher/lib/src/stat.dart index fe0f15578..25019ef3b 100644 --- a/pkgs/watcher/lib/src/stat.dart +++ b/pkgs/watcher/lib/src/stat.dart @@ -16,7 +16,7 @@ MockTimeCallback? _mockTimeCallback; /// The OS file modification time has pretty rough granularity (like a few /// seconds) which can make for slow tests that rely on modtime. This lets you /// replace it with something you control. -void mockGetModificationTime(MockTimeCallback callback) { +void mockGetModificationTime(MockTimeCallback? callback) { _mockTimeCallback = callback; } diff --git a/pkgs/watcher/test/directory_watcher/polling_test.dart b/pkgs/watcher/test/directory_watcher/polling_test.dart index af10c21ed..5b16c13d7 100644 --- a/pkgs/watcher/test/directory_watcher/polling_test.dart +++ b/pkgs/watcher/test/directory_watcher/polling_test.dart @@ -16,14 +16,26 @@ void main() { watcherFactory = (dir) => PollingDirectoryWatcher(dir, pollingDelay: const Duration(milliseconds: 100)); - sharedTests(); - - test('does not notify if the modification time did not change', () async { - writeFile('a.txt', contents: 'before'); - writeFile('b.txt', contents: 'before'); - await startWatcher(); - writeFile('a.txt', contents: 'after', updateModified: false); - writeFile('b.txt', contents: 'after'); - await expectModifyEvent('b.txt'); + // Filesystem modification times can be low resolution, mock them. + group('with mock mtime', () { + setUp(enableMockModificationTimes); + + sharedTests(); + + test('does not notify if the modification time did not change', () async { + writeFile('a.txt', contents: 'before'); + writeFile('b.txt', contents: 'before'); + await startWatcher(); + writeFile('a.txt', contents: 'after', updateModified: false); + writeFile('b.txt', contents: 'after'); + await expectModifyEvent('b.txt'); + }); + }); + + // Also test with delayed writes and real mtimes. + group('with real mtime', () { + setUp(enableWaitingForDifferentModificationTimes); + + sharedTests(); }); } diff --git a/pkgs/watcher/test/file_watcher/link_tests.dart b/pkgs/watcher/test/file_watcher/link_tests.dart index 6b86cc236..a9de2cf0a 100644 --- a/pkgs/watcher/test/file_watcher/link_tests.dart +++ b/pkgs/watcher/test/file_watcher/link_tests.dart @@ -20,13 +20,25 @@ void linkTests({required bool isNative}) { test('notifies when a link is overwritten with an identical file', () async { await startWatcher(path: 'link.txt'); writeFile('link.txt'); - await expectModifyEvent('link.txt'); + + // TODO(davidmorgan): reconcile differences. + if (isNative) { + await expectNoEvents(); + } else { + await expectModifyEvent('link.txt'); + } }); test('notifies when a link is overwritten with a different file', () async { await startWatcher(path: 'link.txt'); writeFile('link.txt', contents: 'modified'); - await expectModifyEvent('link.txt'); + + // TODO(davidmorgan): reconcile differences. + if (isNative) { + await expectNoEvents(); + } else { + await expectModifyEvent('link.txt'); + } }); test( @@ -35,12 +47,7 @@ void linkTests({required bool isNative}) { await startWatcher(path: 'link.txt'); writeFile('target.txt'); - // TODO(davidmorgan): reconcile differences. - if (isNative) { - await expectModifyEvent('link.txt'); - } else { - await expectNoEvents(); - } + await expectModifyEvent('link.txt'); }, ); @@ -48,12 +55,7 @@ void linkTests({required bool isNative}) { await startWatcher(path: 'link.txt'); writeFile('target.txt', contents: 'modified'); - // TODO(davidmorgan): reconcile differences. - if (isNative) { - await expectModifyEvent('link.txt'); - } else { - await expectNoEvents(); - } + await expectModifyEvent('link.txt'); }); test('notifies when a link is removed', () async { @@ -79,21 +81,11 @@ void linkTests({required bool isNative}) { writeFile('target.txt', contents: 'modified'); - // TODO(davidmorgan): reconcile differences. - if (isNative) { - await expectModifyEvent('link.txt'); - } else { - await expectNoEvents(); - } + await expectModifyEvent('link.txt'); writeFile('target.txt', contents: 'modified again'); - // TODO(davidmorgan): reconcile differences. - if (isNative) { - await expectModifyEvent('link.txt'); - } else { - await expectNoEvents(); - } + await expectModifyEvent('link.txt'); }); test('notifies when a link is moved away', () async { @@ -145,12 +137,7 @@ void linkTests({required bool isNative}) { writeFile('old.txt'); renameFile('old.txt', 'target.txt'); - // TODO(davidmorgan): reconcile differences. - if (isNative) { - await expectModifyEvent('link.txt'); - } else { - await expectNoEvents(); - } + await expectModifyEvent('link.txt'); }); test('notifies when a different file is moved over the target', () async { @@ -158,11 +145,6 @@ void linkTests({required bool isNative}) { writeFile('old.txt', contents: 'modified'); renameFile('old.txt', 'target.txt'); - // TODO(davidmorgan): reconcile differences. - if (isNative) { - await expectModifyEvent('link.txt'); - } else { - await expectNoEvents(); - } + await expectModifyEvent('link.txt'); }); } diff --git a/pkgs/watcher/test/file_watcher/polling_test.dart b/pkgs/watcher/test/file_watcher/polling_test.dart index c1590e783..3acb8a8ff 100644 --- a/pkgs/watcher/test/file_watcher/polling_test.dart +++ b/pkgs/watcher/test/file_watcher/polling_test.dart @@ -2,6 +2,7 @@ // 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 'package:test/test.dart'; import 'package:watcher/watcher.dart'; import '../utils.dart'; @@ -13,7 +14,21 @@ void main() { watcherFactory = (file) => PollingFileWatcher(file, pollingDelay: const Duration(milliseconds: 100)); - fileTests(isNative: false); - linkTests(isNative: false); - startupRaceTests(isNative: false); + // Filesystem modification times can be low resolution, mock them. + group('with mock mtime', () { + setUp(enableMockModificationTimes); + + fileTests(isNative: false); + linkTests(isNative: false); + startupRaceTests(isNative: false); + }); + +// Also test with delayed writes and real mtimes. + group('with real mtime', () { + setUp(enableWaitingForDifferentModificationTimes); + fileTests(isNative: false); + linkTests(isNative: false); + // Don't run `startupRaceTests`, polling can't have a race and the test is + // too slow on Windows when waiting for modification times. + }); } diff --git a/pkgs/watcher/test/utils.dart b/pkgs/watcher/test/utils.dart index 6f61f9a8b..db16e3f8c 100644 --- a/pkgs/watcher/test/utils.dart +++ b/pkgs/watcher/test/utils.dart @@ -28,7 +28,11 @@ set watcherFactory(WatcherFactory factory) { /// /// Instead, we'll just mock that out. Each time a file is written, we manually /// increment the mod time for that file instantly. -final _mockFileModificationTimes = {}; +Map? _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; @@ -54,13 +58,57 @@ late StreamQueue _watcherEvents; /// be done automatically via [addTearDown] in [startWatcher]. var _hasClosedStream = true; -/// Creates a new [Watcher] that watches a temporary file or directory and -/// starts monitoring it for events. +/// Enables waiting before writes to ensure a different modification time. /// -/// If [path] is provided, watches a path in the sandbox with that name. -Future startWatcher({String? path}) async { +/// 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) { - final normalized = p.normalize(p.relative(path, from: d.sandbox)); + // Resolve symbolic links before looking up mtime to match documented + // behavior of `FileSystemEntity.stat`. + final link = Link(path); + if (link.existsSync()) { + path = link.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('..')) { @@ -70,10 +118,21 @@ Future startWatcher({String? path}) async { 'Path is not in the sandbox: $path not in ${d.sandbox}', ); } - var mtime = _mockFileModificationTimes[normalized]; + 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 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); @@ -221,11 +280,18 @@ Future allowModifyEvent(String path) => /// set back to a previously used value. int _nextTimestamp = 1; -/// Schedules writing a file in the sandbox at [path] with [contents]. +/// 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`, the mock file modification time is not changed. +/// 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; @@ -237,24 +303,36 @@ void writeFile(String path, {String? contents, bool? updateModified}) { dir.createSync(recursive: true); } - File(fullPath).writeAsStringSync(contents); + 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); - if (updateModified) { + final mockFileModificationTimes = _mockFileModificationTimes; + if (mockFileModificationTimes != null && updateModified) { path = p.normalize(path); - _mockFileModificationTimes[path] = _nextTimestamp++; + mockFileModificationTimes[path] = _nextTimestamp++; } } -/// Schedules writing a file in the sandbox at [link] pointing to [target]. +/// Writes a file in the sandbox at [link] pointing to [target]. /// -/// If [updateModified] is `false`, the mock file modification time is not -/// changed. +/// 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); @@ -270,63 +348,90 @@ void writeLink({ if (updateModified) { link = p.normalize(link); - _mockFileModificationTimes[link] = _nextTimestamp++; + final mockFileModificationTimes = _mockFileModificationTimes; + + if (mockFileModificationTimes != null) { + mockFileModificationTimes[link] = _nextTimestamp++; + } } } -/// Schedules deleting a file in the sandbox at [path]. +/// Deletes a file in the sandbox at [path]. void deleteFile(String path) { File(p.join(d.sandbox, path)).deleteSync(); - _mockFileModificationTimes.remove(path); + final mockFileModificationTimes = _mockFileModificationTimes; + if (mockFileModificationTimes != null) { + mockFileModificationTimes.remove(path); + } } -/// Schedules renaming a file in the sandbox from [from] to [to]. +/// Renames a file in the sandbox from [from] to [to]. void renameFile(String from, String to) { - File(p.join(d.sandbox, from)).renameSync(p.join(d.sandbox, to)); - - // Make sure we always use the same separator on Windows. - to = p.normalize(to); - - _mockFileModificationTimes.update(to, (value) => value + 1, - ifAbsent: () => 1); + _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); + } } -/// Schedules renaming a link in the sandbox from [from] to [to]. +/// 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) { - Link(p.join(d.sandbox, from)).renameSync(p.join(d.sandbox, to)); - - // Make sure we always use the same separator on Windows. - to = p.normalize(to); - - _mockFileModificationTimes.update(to, (value) => value + 1, - ifAbsent: () => 1); + _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); + } } -/// Schedules creating a directory in the sandbox at [path]. +/// Creates a directory in the sandbox at [path]. void createDir(String path) { Directory(p.join(d.sandbox, path)).createSync(); } -/// Schedules renaming a directory in the sandbox from [from] to [to]. +/// Renames a directory in the sandbox from [from] to [to]. void renameDir(String from, String to) { - Directory(p.join(d.sandbox, from)).renameSync(p.join(d.sandbox, to)); - - // Migrate timestamps for any files in this folder. - final knownFilePaths = _mockFileModificationTimes.keys.toList(); - for (final filePath in knownFilePaths) { - if (p.isWithin(from, filePath)) { - _mockFileModificationTimes[filePath.replaceAll(from, to)] = - _mockFileModificationTimes[filePath]!; - _mockFileModificationTimes.remove(filePath); + 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)) { + mockFileModificationTimes[filePath.replaceAll(from, to)] = + mockFileModificationTimes[filePath]!; + mockFileModificationTimes.remove(filePath); + } } } } -/// Schedules deleting a directory in the sandbox at [path]. +/// Deletes a directory in the sandbox at [path]. void deleteDir(String path) { Directory(p.join(d.sandbox, path)).deleteSync(recursive: true); }