diff --git a/pkgs/watcher/test/directory_watcher/shared.dart b/pkgs/watcher/test/directory_watcher/file_tests.dart similarity index 99% rename from pkgs/watcher/test/directory_watcher/shared.dart rename to pkgs/watcher/test/directory_watcher/file_tests.dart index 9a26a47a1..9b3fcd6c2 100644 --- a/pkgs/watcher/test/directory_watcher/shared.dart +++ b/pkgs/watcher/test/directory_watcher/file_tests.dart @@ -1,6 +1,7 @@ // 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:io' as io; import 'dart:isolate'; @@ -10,7 +11,7 @@ import 'package:watcher/src/utils.dart'; import '../utils.dart'; -void sharedTests() { +void fileTests() { test('does not notify for files that already exist when started', () async { // Make some pre-existing files. writeFile('a.txt'); diff --git a/pkgs/watcher/test/directory_watcher/link_tests.dart b/pkgs/watcher/test/directory_watcher/link_tests.dart new file mode 100644 index 000000000..f4919d1fb --- /dev/null +++ b/pkgs/watcher/test/directory_watcher/link_tests.dart @@ -0,0 +1,268 @@ +// Copyright (c) 2025, 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:io'; + +import 'package:test/test.dart'; + +import '../utils.dart'; + +void linkTests({required bool isNative}) { + test('notifies when a link is added', () async { + createDir('targets'); + createDir('links'); + writeFile('targets/a.target'); + await startWatcher(path: 'links'); + + writeLink(link: 'links/a.link', target: 'targets/a.target'); + + await expectAddEvent('links/a.link'); + }); + + test( + 'notifies when a link is replaced with a link to a different target ' + 'with the same contents', () async { + createDir('targets'); + createDir('links'); + writeFile('targets/a.target'); + writeFile('targets/b.target'); + writeLink(link: 'links/a.link', target: 'targets/a.target'); + await startWatcher(path: 'links'); + + deleteLink('links/a.link'); + writeLink(link: 'links/a.link', target: 'targets/b.target'); + + await expectModifyEvent('links/a.link'); + }); + + test( + 'notifies when a link is replaced with a link to a different target ' + 'with different contents', () async { + writeFile('targets/a.target', contents: 'a'); + writeFile('targets/b.target', contents: 'b'); + writeLink(link: 'links/a.link', target: 'targets/a.target'); + await startWatcher(path: 'links'); + + deleteLink('links/a.link'); + writeLink(link: 'links/a.link', target: 'targets/b.target'); + + await expectModifyEvent('links/a.link'); + }); + + test('does not notify when a link target is modified', () async { + createDir('targets'); + createDir('links'); + writeFile('targets/a.target'); + writeLink(link: 'links/a.link', target: 'targets/a.target'); + await startWatcher(path: 'links'); + writeFile('targets/a.target', contents: 'modified'); + + // TODO(davidmorgan): reconcile differences. + if (isNative) { + await expectNoEvents(); + } else { + await expectModifyEvent('links/a.link'); + } + }); + + test('does not notify when a link target is removed', () async { + createDir('targets'); + createDir('links'); + writeFile('targets/a.target'); + writeLink(link: 'links/a.link', target: 'targets/a.target'); + await startWatcher(path: 'links'); + + deleteFile('targets/a.target'); + + // TODO(davidmorgan): reconcile differences. + if (isNative) { + await expectNoEvents(); + } else { + await expectRemoveEvent('links/a.link'); + } + }); + + test('notifies when a link is moved within the watched directory', () async { + createDir('targets'); + createDir('links'); + writeFile('targets/a.target'); + writeLink(link: 'links/a.link', target: 'targets/a.target'); + await startWatcher(path: 'links'); + + renameLink('links/a.link', 'links/b.link'); + + await inAnyOrder( + [isAddEvent('links/b.link'), isRemoveEvent('links/a.link')]); + }); + + test('notifies when a link to an empty directory is added', () async { + createDir('targets'); + createDir('links'); + createDir('targets/a.targetdir'); + await startWatcher(path: 'links'); + + writeLink(link: 'links/a.link', target: 'targets/a.targetdir'); + + // TODO(davidmorgan): reconcile differences. + if (isNative) { + await expectAddEvent('links/a.link'); + } else { + await expectNoEvents(); + } + }); + + test( + 'does not notify about directory contents ' + 'when a link to a directory is added', () async { + createDir('targets'); + createDir('links'); + createDir('targets/a.targetdir'); + writeFile('targets/a.targetdir/a.target'); + await startWatcher(path: 'links'); + + writeLink(link: 'links/a.link', target: 'targets/a.targetdir'); + + // TODO(davidmorgan): reconcile differences. + if (isNative) { + await expectAddEvent('links/a.link'); + } else { + await expectAddEvent('links/a.link/a.target'); + } + }); + + test('notifies when a file is added to a linked directory', () async { + createDir('targets'); + createDir('links'); + createDir('targets/a.targetdir'); + writeLink(link: 'links/a.link', target: 'targets/a.targetdir'); + await startWatcher(path: 'links'); + + writeFile('targets/a.targetdir/a.txt'); + + // TODO(davidmorgan): reconcile differences. + if (!isNative || Platform.isLinux) { + await expectAddEvent('links/a.link/a.txt'); + } else { + await expectNoEvents(); + } + }); + + test( + 'notifies about linked directory contents when a directory with a linked ' + 'subdirectory is moved in', () async { + createDir('targets'); + createDir('links'); + createDir('targets/a.targetdir'); + createDir('watched'); + writeFile('targets/a.targetdir/a.txt'); + writeLink(link: 'links/a.link', target: 'targets/a.targetdir'); + await startWatcher(path: 'watched'); + + renameDir('links', 'watched/links'); + + await expectAddEvent('watched/links/a.link/a.txt'); + }); + + test( + 'notifies about linked directory contents when a directory with a linked ' + 'subdirectory containing a link loop is moved in', () async { + createDir('targets'); + createDir('links'); + createDir('targets/a.targetdir'); + createDir('watched'); + writeFile('targets/a.targetdir/a.txt'); + writeLink(link: 'links/a.link', target: 'targets/a.targetdir'); + writeLink( + link: 'targets/a.targetdir/cycle.link', target: 'targets/a.targetdir'); + await startWatcher(path: 'watched'); + + renameDir('links', 'watched/links'); + + // TODO(davidmorgan): reconcile differences. + if (isNative && (Platform.isLinux || Platform.isMacOS)) { + await inAnyOrder([ + isAddEvent('watched/links/a.link/a.txt'), + isAddEvent('watched/links/a.link/cycle.link/a.txt'), + isAddEvent('watched/links/a.link/cycle.link/cycle.link'), + ]); + await expectNoEvents(); + } else if (isNative && Platform.isWindows) { + await inAnyOrder([ + isAddEvent('watched/links/a.link/a.txt'), + isAddEvent('watched/links/a.link/cycle.link'), + ]); + await expectNoEvents(); + } else if (!isNative && Platform.isWindows) { + await inAnyOrder([ + isAddEvent('watched/links/a.link/a.txt'), + ]); + await expectNoEvents(); + } else { + assert(!isNative); + await inAnyOrder([ + isAddEvent('watched/links/a.link/a.txt'), + isAddEvent('watched/links/a.link/cycle.link/a.txt'), + ]); + await expectNoEvents(); + } + }); + + test( + 'notifies about linked directory contents when a directory with a linked ' + 'subdirectory containing two link loops is moved in', () async { + createDir('targets'); + createDir('links'); + createDir('targets/a.targetdir'); + createDir('watched'); + writeFile('targets/a.targetdir/a.txt'); + writeLink(link: 'links/a.link', target: 'targets/a.targetdir'); + writeLink( + link: 'targets/a.targetdir/cycle1.link', target: 'targets/a.targetdir'); + writeLink( + link: 'targets/a.targetdir/cycle2.link', target: 'targets/a.targetdir'); + await startWatcher(path: 'watched'); + + renameDir('links', 'watched/links'); + + // TODO(davidmorgan): reconcile differences. + if (isNative && (Platform.isLinux || Platform.isMacOS)) { + await inAnyOrder([ + isAddEvent('watched/links/a.link/a.txt'), + isAddEvent('watched/links/a.link/cycle1.link/a.txt'), + isAddEvent('watched/links/a.link/cycle1.link/cycle1.link'), + isAddEvent('watched/links/a.link/cycle1.link/cycle2.link/a.txt'), + isAddEvent('watched/links/a.link/cycle1.link/cycle2.link/cycle1.link'), + isAddEvent('watched/links/a.link/cycle1.link/cycle2.link/cycle2.link'), + isAddEvent('watched/links/a.link/cycle2.link/a.txt'), + isAddEvent('watched/links/a.link/cycle2.link/cycle1.link/a.txt'), + isAddEvent('watched/links/a.link/cycle2.link/cycle1.link/cycle1.link'), + isAddEvent('watched/links/a.link/cycle2.link/cycle1.link/cycle2.link'), + isAddEvent('watched/links/a.link/cycle2.link/cycle2.link'), + ]); + await expectNoEvents(); + } else if (isNative && Platform.isWindows) { + await inAnyOrder([ + isAddEvent('watched/links/a.link/a.txt'), + isAddEvent('watched/links/a.link/cycle1.link'), + isAddEvent('watched/links/a.link/cycle2.link'), + ]); + await expectNoEvents(); + } else if (!isNative && Platform.isWindows) { + await inAnyOrder([ + isAddEvent('watched/links/a.link/a.txt'), + ]); + await expectNoEvents(); + } else { + assert(!isNative); + await inAnyOrder([ + isAddEvent('watched/links/a.link/a.txt'), + isAddEvent('watched/links/a.link/cycle1.link/a.txt'), + isAddEvent('watched/links/a.link/cycle1.link/cycle2.link/a.txt'), + isAddEvent('watched/links/a.link/cycle2.link/a.txt'), + isAddEvent('watched/links/a.link/cycle2.link/cycle1.link/a.txt'), + ]); + await expectNoEvents(); + } + }); +} diff --git a/pkgs/watcher/test/directory_watcher/linux_test.dart b/pkgs/watcher/test/directory_watcher/linux_test.dart index a10a72c33..a47779466 100644 --- a/pkgs/watcher/test/directory_watcher/linux_test.dart +++ b/pkgs/watcher/test/directory_watcher/linux_test.dart @@ -10,12 +10,14 @@ import 'package:watcher/src/directory_watcher/linux.dart'; import 'package:watcher/watcher.dart'; import '../utils.dart'; -import 'shared.dart'; +import 'file_tests.dart'; +import 'link_tests.dart'; void main() { watcherFactory = LinuxDirectoryWatcher.new; - sharedTests(); + fileTests(); + linkTests(isNative: true); test('DirectoryWatcher creates a LinuxDirectoryWatcher on Linux', () { expect(DirectoryWatcher('.'), const TypeMatcher()); diff --git a/pkgs/watcher/test/directory_watcher/mac_os_test.dart b/pkgs/watcher/test/directory_watcher/mac_os_test.dart index 337662646..ba52d1958 100644 --- a/pkgs/watcher/test/directory_watcher/mac_os_test.dart +++ b/pkgs/watcher/test/directory_watcher/mac_os_test.dart @@ -10,12 +10,14 @@ import 'package:watcher/src/directory_watcher/mac_os.dart'; import 'package:watcher/watcher.dart'; import '../utils.dart'; -import 'shared.dart'; +import 'file_tests.dart'; +import 'link_tests.dart'; void main() { watcherFactory = MacOSDirectoryWatcher.new; - sharedTests(); + fileTests(); + linkTests(isNative: true); test('DirectoryWatcher creates a MacOSDirectoryWatcher on Mac OS', () { expect(DirectoryWatcher('.'), const TypeMatcher()); diff --git a/pkgs/watcher/test/directory_watcher/polling_test.dart b/pkgs/watcher/test/directory_watcher/polling_test.dart index 5b16c13d7..9fd2a0afd 100644 --- a/pkgs/watcher/test/directory_watcher/polling_test.dart +++ b/pkgs/watcher/test/directory_watcher/polling_test.dart @@ -9,7 +9,8 @@ import 'package:test/test.dart'; import 'package:watcher/watcher.dart'; import '../utils.dart'; -import 'shared.dart'; +import 'file_tests.dart'; +import 'link_tests.dart'; void main() { // Use a short delay to make the tests run quickly. @@ -20,7 +21,8 @@ void main() { group('with mock mtime', () { setUp(enableMockModificationTimes); - sharedTests(); + fileTests(); + linkTests(isNative: false); test('does not notify if the modification time did not change', () async { writeFile('a.txt', contents: 'before'); @@ -36,6 +38,7 @@ void main() { group('with real mtime', () { setUp(enableWaitingForDifferentModificationTimes); - sharedTests(); + fileTests(); + linkTests(isNative: false); }); } diff --git a/pkgs/watcher/test/directory_watcher/windows_test.dart b/pkgs/watcher/test/directory_watcher/windows_test.dart index 709bcb59b..406a0755e 100644 --- a/pkgs/watcher/test/directory_watcher/windows_test.dart +++ b/pkgs/watcher/test/directory_watcher/windows_test.dart @@ -15,12 +15,14 @@ import 'package:watcher/src/directory_watcher/windows.dart'; import 'package:watcher/watcher.dart'; import '../utils.dart'; -import 'shared.dart'; +import 'file_tests.dart'; +import 'link_tests.dart'; void main() { watcherFactory = WindowsDirectoryWatcher.new; - group('Shared Tests:', sharedTests); + fileTests(); + linkTests(isNative: true); test('DirectoryWatcher creates a WindowsDirectoryWatcher on Windows', () { expect(DirectoryWatcher('.'), const TypeMatcher()); diff --git a/pkgs/watcher/test/file_watcher/link_tests.dart b/pkgs/watcher/test/file_watcher/link_tests.dart index a9de2cf0a..8cb5e102d 100644 --- a/pkgs/watcher/test/file_watcher/link_tests.dart +++ b/pkgs/watcher/test/file_watcher/link_tests.dart @@ -60,7 +60,7 @@ void linkTests({required bool isNative}) { test('notifies when a link is removed', () async { await startWatcher(path: 'link.txt'); - deleteFile('link.txt'); + deleteLink('link.txt'); // TODO(davidmorgan): reconcile differences. if (isNative) { diff --git a/pkgs/watcher/test/utils.dart b/pkgs/watcher/test/utils.dart index db16e3f8c..bcc905232 100644 --- a/pkgs/watcher/test/utils.dart +++ b/pkgs/watcher/test/utils.dart @@ -107,6 +107,11 @@ void enableMockModificationTimes() { 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)); @@ -324,6 +329,8 @@ void writeFile(String path, {String? contents, bool? updateModified}) { /// 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({ @@ -343,13 +350,12 @@ void writeLink({ dir.createSync(recursive: true); } - Link(fullPath).createSync(target); + Link(fullPath).createSync(p.join(d.sandbox, target)); if (updateModified) { link = p.normalize(link); final mockFileModificationTimes = _mockFileModificationTimes; - if (mockFileModificationTimes != null) { mockFileModificationTimes[link] = _nextTimestamp++; } @@ -358,7 +364,23 @@ void writeLink({ /// Deletes a file in the sandbox at [path]. void deleteFile(String path) { - File(p.join(d.sandbox, path)).deleteSync(); + 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) { @@ -433,7 +455,10 @@ void renameDir(String from, String to) { /// Deletes a directory in the sandbox at [path]. void deleteDir(String path) { - Directory(p.join(d.sandbox, path)).deleteSync(recursive: true); + 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