| // 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' as io; |
| import 'dart:io'; |
| import 'dart:isolate'; |
| |
| 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 '../utils.dart'; |
| |
| void fileTests({required bool isNative}) { |
| for (var i = 0; i != runsPerTest; ++i) { |
| _fileTests(isNative: isNative); |
| } |
| } |
| |
| void _fileTests({required bool isNative}) { |
| test('error reported if directory does not exist', () async { |
| await startWatcher(path: 'missing_path'); |
| |
| // TODO(davidmorgan): reconcile differences. |
| if (isNative && !Platform.isMacOS) { |
| expect(expectNoEvents, throwsA(isA<PathNotFoundException>())); |
| } else { |
| // The polling watcher and the MacOS watcher do not throw on missing file |
| // on watch. |
| await expectNoEvents(); |
| writeFile('missing_path/file.txt'); |
| await expectAddEvent('missing_path/file.txt'); |
| } |
| }); |
| |
| // ResubscribableWatcher wraps all the directory watchers to add handling of |
| // multiple subscribers. The underlying watcher is created when there is at |
| // least one subscriber and closed when there are zero subscribers. So, |
| // exercise that behavior in various ways. |
| test('ResubscribableWatcher handles multiple subscriptions ', () async { |
| final watcher = createWatcher(); |
| |
| // One subscription, one event, close the subscription. |
| final queue1 = StreamQueue(watcher.events); |
| final event1 = queue1.next; |
| await watcher.ready; |
| writeFile('a.txt'); |
| expect(await event1, isAddEvent('a.txt')); |
| await queue1.cancel(immediate: true); |
| |
| // Open before "ready", cancel before event. |
| final queue2a = StreamQueue(watcher.events); |
| // Open before "ready", cancel after one event. |
| final queue2b = StreamQueue(watcher.events); |
| // Open before "ready", cancel after two events. |
| final queue2c = StreamQueue(watcher.events); |
| |
| final queue2aHasNext = queue2a.hasNext; |
| unawaited(queue2a.cancel(immediate: true)); |
| expect(await queue2aHasNext, false); |
| |
| await watcher.ready; |
| |
| // Open after "ready", cancel before event. |
| final queue2d = StreamQueue(watcher.events); |
| |
| // Open after "ready", cancel after one event. |
| final queue2e = StreamQueue(watcher.events); |
| |
| // Open after "ready", cancel after two events. |
| final queue2f = StreamQueue(watcher.events); |
| |
| final queue2dHasNext = queue2d.hasNext; |
| unawaited(queue2d.cancel(immediate: true)); |
| expect(await queue2dHasNext, false); |
| |
| writeFile('b.txt'); |
| |
| expect(await queue2b.next, isAddEvent('b.txt')); |
| expect(await queue2c.next, isAddEvent('b.txt')); |
| expect(await queue2e.next, isAddEvent('b.txt')); |
| expect(await queue2f.next, isAddEvent('b.txt')); |
| final queue2bHasNext = queue2b.hasNext; |
| await queue2b.cancel(immediate: true); |
| expect(await queue2bHasNext, false); |
| final queue2eHasNext = queue2e.hasNext; |
| await queue2e.cancel(immediate: true); |
| expect(await queue2eHasNext, false); |
| |
| // Remaining subscriptions still get events. |
| writeFile('c.txt'); |
| expect(await queue2c.next, isAddEvent('c.txt')); |
| expect(await queue2f.next, isAddEvent('c.txt')); |
| final queue2cHasNext = queue2c.hasNext; |
| await queue2c.cancel(immediate: true); |
| expect(await queue2cHasNext, false); |
| final queue2fHasNext = queue2f.hasNext; |
| await queue2f.cancel(immediate: true); |
| expect(await queue2fHasNext, false); |
| |
| // Repeat the first simple test: one subscription, one event, close the |
| // subscription. |
| final queue3 = StreamQueue(watcher.events); |
| await watcher.ready; |
| writeFile('d.txt'); |
| expect(await queue3.next, isAddEvent('d.txt')); |
| final queue3HasNext = queue3.hasNext; |
| await queue3.cancel(immediate: true); |
| expect(await queue3HasNext, false); |
| }); |
| |
| test('does not notify for files that already exist when started', () async { |
| // Make some pre-existing files. |
| writeFile('a.txt'); |
| writeFile('b.txt'); |
| |
| await startWatcher(); |
| |
| // Change one after the watcher is running. |
| writeFile('b.txt', contents: 'modified'); |
| |
| // We should get a modify event for the changed file, but no add events |
| // for them before this. |
| await expectModifyEvent('b.txt'); |
| }); |
| |
| test('notifies when a file is added', () async { |
| await startWatcher(); |
| writeFile('file.txt'); |
| await expectAddEvent('file.txt'); |
| }); |
| |
| test('notifies when a file is modified', () async { |
| writeFile('file.txt'); |
| await startWatcher(); |
| writeFile('file.txt', contents: 'modified'); |
| await expectModifyEvent('file.txt'); |
| }); |
| |
| test('notifies when a file is removed', () async { |
| writeFile('file.txt'); |
| await startWatcher(); |
| deleteFile('file.txt'); |
| await expectRemoveEvent('file.txt'); |
| }); |
| |
| test('notifies when a file is modified multiple times', () async { |
| writeFile('file.txt'); |
| await startWatcher(); |
| writeFile('file.txt', contents: 'modified'); |
| await expectModifyEvent('file.txt'); |
| writeFile('file.txt', contents: 'modified again'); |
| await expectModifyEvent('file.txt'); |
| }); |
| |
| test('notifies even if the file contents are unchanged', () async { |
| writeFile('a.txt', contents: 'same'); |
| writeFile('b.txt', contents: 'before'); |
| await startWatcher(); |
| |
| if (!isNative) sleepUntilNewModificationTime(); |
| writeFile('a.txt', contents: 'same'); |
| writeFile('b.txt', contents: 'after'); |
| await inAnyOrder([isModifyEvent('a.txt'), isModifyEvent('b.txt')]); |
| }); |
| |
| test('when the watched directory is deleted, removes all files', () async { |
| writeFile('dir/a.txt'); |
| writeFile('dir/b.txt'); |
| |
| await startWatcher(path: 'dir'); |
| |
| deleteDir('dir'); |
| await inAnyOrder([isRemoveEvent('dir/a.txt'), isRemoveEvent('dir/b.txt')]); |
| }); |
| |
| test('when the watched directory is moved, removes all files', () async { |
| writeFile('dir/a.txt'); |
| writeFile('dir/b.txt'); |
| |
| await startWatcher(path: 'dir'); |
| |
| renameDir('dir', 'moved_dir'); |
| createDir('dir'); |
| await inAnyOrder([isRemoveEvent('dir/a.txt'), isRemoveEvent('dir/b.txt')]); |
| }); |
| |
| // Regression test for b/30768513. |
| test( |
| "doesn't crash when the directory is moved immediately after a subdir " |
| 'is added', () async { |
| writeFile('dir/a.txt'); |
| writeFile('dir/b.txt'); |
| |
| await startWatcher(path: 'dir'); |
| |
| createDir('dir/subdir'); |
| renameDir('dir', 'moved_dir'); |
| createDir('dir'); |
| await inAnyOrder([isRemoveEvent('dir/a.txt'), isRemoveEvent('dir/b.txt')]); |
| }); |
| |
| group('moves', () { |
| test('notifies when a file is moved within the watched directory', |
| () async { |
| writeFile('old.txt'); |
| await startWatcher(); |
| renameFile('old.txt', 'new.txt'); |
| |
| await inAnyOrder([isAddEvent('new.txt'), isRemoveEvent('old.txt')]); |
| }); |
| |
| test('notifies when a file is moved from outside the watched directory', |
| () async { |
| writeFile('old.txt'); |
| createDir('dir'); |
| await startWatcher(path: 'dir'); |
| |
| renameFile('old.txt', 'dir/new.txt'); |
| await expectAddEvent('dir/new.txt'); |
| }); |
| |
| test('notifies when a file is moved outside the watched directory', |
| () async { |
| writeFile('dir/old.txt'); |
| await startWatcher(path: 'dir'); |
| |
| renameFile('dir/old.txt', 'new.txt'); |
| await expectRemoveEvent('dir/old.txt'); |
| }); |
| |
| test('notifies when a file is moved onto an existing one', () async { |
| writeFile('from.txt'); |
| writeFile('to.txt', contents: 'different'); |
| await startWatcher(); |
| |
| renameFile('from.txt', 'to.txt'); |
| await inAnyOrder([isRemoveEvent('from.txt'), isModifyEvent('to.txt')]); |
| }); |
| }); |
| |
| group('clustered changes', () { |
| test("doesn't notify when a file is created and then immediately removed", |
| () async { |
| writeFile('test.txt'); |
| await startWatcher(); |
| writeFile('file.txt'); |
| deleteFile('file.txt'); |
| }); |
| |
| test('reports when a file is moved between directories then deleted', |
| () async { |
| writeFile('a/test.txt'); |
| createDir('b'); |
| await startWatcher(path: 'b'); |
| |
| renameFile('a/test.txt', 'b/test.txt'); |
| deleteFile('b/test.txt'); |
| |
| final events = |
| await takeEvents(duration: const Duration(milliseconds: 500)); |
| |
| // It's correct to report either nothing or an add+remove. |
| expect( |
| events, |
| anyOf([ |
| isEmpty, |
| containsAll([ |
| isAddEvent('b/test.txt'), |
| isRemoveEvent('b/test.txt'), |
| ]), |
| ])); |
| expect(events, isNot(contains(isModifyEvent('b/test.txt')))); |
| }); |
| |
| test( |
| 'reports a modification when a file is deleted and then immediately ' |
| 'recreated', () async { |
| writeFile('file.txt'); |
| await startWatcher(); |
| |
| deleteFile('file.txt'); |
| writeFile('file.txt', contents: 're-created'); |
| |
| await expectModifyEvent('file.txt'); |
| }); |
| |
| test( |
| 'reports a modification when a file is moved and then immediately ' |
| 'recreated', () async { |
| writeFile('old.txt'); |
| await startWatcher(); |
| |
| renameFile('old.txt', 'new.txt'); |
| writeFile('old.txt', contents: 're-created'); |
| |
| await inAnyOrder([isModifyEvent('old.txt'), isAddEvent('new.txt')]); |
| }); |
| |
| test( |
| 'reports a removal when a file is modified and then immediately ' |
| 'removed', () async { |
| writeFile('file.txt'); |
| await startWatcher(); |
| |
| writeFile('file.txt', contents: 'modified'); |
| deleteFile('file.txt'); |
| |
| await expectRemoveEvent('file.txt'); |
| }); |
| |
| test('reports an add when a file is added and then immediately modified', |
| () async { |
| await startWatcher(); |
| |
| writeFile('file.txt'); |
| writeFile('file.txt', contents: 'modified'); |
| |
| await expectAddEvent('file.txt'); |
| }); |
| }); |
| |
| group('subdirectories', () { |
| test('watches files in subdirectories', () async { |
| await startWatcher(); |
| writeFile('a/b/c/d/file.txt'); |
| await expectAddEvent('a/b/c/d/file.txt'); |
| }); |
| |
| test( |
| 'notifies when a subdirectory is moved within the watched directory ' |
| 'and then its contents are modified', () async { |
| writeFile('old/file.txt'); |
| await startWatcher(); |
| |
| renameDir('old', 'new'); |
| await inAnyOrder( |
| [isRemoveEvent('old/file.txt'), isAddEvent('new/file.txt')]); |
| |
| writeFile('new/file.txt', contents: 'modified'); |
| await expectModifyEvent('new/file.txt'); |
| }); |
| |
| test('notifies when a file is replaced by a subdirectory', () async { |
| writeFile('new'); |
| writeFile('old/file.txt'); |
| await startWatcher(); |
| |
| deleteFile('new'); |
| renameDir('old', 'new'); |
| await inAnyOrder([ |
| isRemoveEvent('new'), |
| isRemoveEvent('old/file.txt'), |
| isAddEvent('new/file.txt') |
| ]); |
| }); |
| |
| test('notifies when a subdirectory is replaced by a file', () async { |
| writeFile('old'); |
| writeFile('new/file.txt'); |
| await startWatcher(); |
| |
| renameDir('new', 'newer'); |
| renameFile('old', 'new'); |
| await inAnyOrder([ |
| isRemoveEvent('new/file.txt'), |
| isAddEvent('newer/file.txt'), |
| isRemoveEvent('old'), |
| isAddEvent('new') |
| ]); |
| }); |
| |
| test('emits events for many nested files added at once', () async { |
| withPermutations((i, j, k) => writeFile('sub/sub-$i/sub-$j/file-$k.txt')); |
| |
| createDir('dir'); |
| await startWatcher(path: 'dir'); |
| renameDir('sub', 'dir/sub'); |
| |
| await inAnyOrder(withPermutations( |
| (i, j, k) => isAddEvent('dir/sub/sub-$i/sub-$j/file-$k.txt'))); |
| }); |
| |
| test('emits events for many nested files removed at once', () async { |
| withPermutations( |
| (i, j, k) => writeFile('dir/sub/sub-$i/sub-$j/file-$k.txt')); |
| |
| createDir('dir'); |
| await startWatcher(path: 'dir'); |
| |
| // Rename the directory rather than deleting it because native watchers |
| // report a rename as a single DELETE event for the directory, whereas |
| // they report recursive deletion with DELETE events for every file in the |
| // directory. |
| renameDir('dir/sub', 'sub'); |
| |
| await inAnyOrder(withPermutations( |
| (i, j, k) => isRemoveEvent('dir/sub/sub-$i/sub-$j/file-$k.txt'))); |
| }); |
| |
| test('emits events for many nested files moved at once', () async { |
| withPermutations( |
| (i, j, k) => writeFile('dir/old/sub-$i/sub-$j/file-$k.txt')); |
| |
| createDir('dir'); |
| await startWatcher(path: 'dir'); |
| renameDir('dir/old', 'dir/new'); |
| |
| await inAnyOrder(unionAll(withPermutations((i, j, k) { |
| return { |
| isRemoveEvent('dir/old/sub-$i/sub-$j/file-$k.txt'), |
| isAddEvent('dir/new/sub-$i/sub-$j/file-$k.txt') |
| }; |
| }))); |
| }); |
| |
| test( |
| 'emits events for many nested files moved out then immediately back in', |
| () async { |
| withPermutations( |
| (i, j, k) => writeFile('dir/sub/sub-$i/sub-$j/file-$k.txt')); |
| |
| await startWatcher(path: 'dir'); |
| |
| renameDir('dir/sub', 'sub'); |
| renameDir('sub', 'dir/sub'); |
| |
| if (isNative) { |
| if (Platform.isMacOS || Platform.isWindows) { |
| // MacOS/Windows watcher reports as "modify" instead of remove then add. |
| await inAnyOrder(withPermutations( |
| (i, j, k) => isModifyEvent('dir/sub/sub-$i/sub-$j/file-$k.txt'))); |
| } else { |
| await inAnyOrder(withPermutations( |
| (i, j, k) => isRemoveEvent('dir/sub/sub-$i/sub-$j/file-$k.txt'))); |
| await inAnyOrder(withPermutations( |
| (i, j, k) => isAddEvent('dir/sub/sub-$i/sub-$j/file-$k.txt'))); |
| } |
| } else { |
| // Polling watchers can't detect this as directory contents mtimes |
| // aren't updated when the directory is moved. |
| await expectNoEvents(); |
| } |
| }); |
| |
| test( |
| 'emits events for many files added at once in a subdirectory with the ' |
| 'same name as a removed file', () async { |
| writeFile('dir/sub'); |
| withPermutations((i, j, k) => writeFile('old/sub-$i/sub-$j/file-$k.txt')); |
| await startWatcher(path: 'dir'); |
| |
| deleteFile('dir/sub'); |
| renameDir('old', 'dir/sub'); |
| |
| var events = withPermutations( |
| (i, j, k) => isAddEvent('dir/sub/sub-$i/sub-$j/file-$k.txt')); |
| events.add(isRemoveEvent('dir/sub')); |
| await inAnyOrder(events); |
| }); |
| |
| test('are still watched after move', () async { |
| await startWatcher(); |
| |
| writeFile('a/b/file.txt'); |
| await expectAddEvent('a/b/file.txt'); |
| |
| renameDir('a', 'c'); |
| await inAnyOrder( |
| [isRemoveEvent('a/b/file.txt'), isAddEvent('c/b/file.txt')]); |
| |
| writeFile('c/b/file2.txt'); |
| await expectAddEvent('c/b/file2.txt'); |
| await expectNoEvents(); |
| }); |
| |
| test('multiple deletes order is respected', () async { |
| createDir('watched'); |
| writeFile('a/1'); |
| writeFile('b/1'); |
| |
| await startWatcher(path: 'watched'); |
| |
| renameDir('a', 'watched/x'); |
| renameDir('watched/x', 'a'); |
| renameDir('b', 'watched/x'); |
| writeFile('watched/x/1', contents: 'updated'); |
| // This is a "duplicate" delete of x, but it's not the same delete and the |
| // watcher needs to notice that it happens after the update to x/1 so |
| // there is no file left behind. |
| renameDir('watched/x', 'b'); |
| |
| await expectNoEvents(); |
| }); |
| |
| test('subdirectory watching is robust against races', () async { |
| // Make sandboxPath accessible to child isolates created by Isolate.run. |
| final sandboxPath = d.sandbox; |
| final dirNames = [for (var i = 0; i < 500; i++) 'dir$i']; |
| await startWatcher(); |
| |
| // Repeatedly create and delete subdirectories in attempt to trigger |
| // a race. |
| for (var i = 0; i < 10; i++) { |
| for (var dir in dirNames) { |
| createDir(dir); |
| } |
| await Isolate.run(() async { |
| await Future.wait([ |
| for (var dir in dirNames) |
| io.Directory('$sandboxPath/$dir').delete(), |
| ]); |
| }); |
| } |
| }); |
| }); |
| |
| test( |
| 'does not notify about the watched directory being deleted and ' |
| 'recreated immediately before watching', () async { |
| createDir('dir'); |
| writeFile('dir/old.txt'); |
| deleteDir('dir'); |
| createDir('dir'); |
| |
| await startWatcher(path: 'dir'); |
| writeFile('dir/newer.txt'); |
| await expectAddEvent('dir/newer.txt'); |
| }); |
| |
| test('does not suppress files with the same prefix as a directory', () async { |
| // Regression test for https://github.com/dart-lang/watcher/issues/83 |
| writeFile('some_name.txt'); |
| |
| await startWatcher(); |
| |
| writeFile('some_name/some_name.txt'); |
| deleteFile('some_name.txt'); |
| |
| await inAnyOrder([ |
| isAddEvent('some_name/some_name.txt'), |
| isRemoveEvent('some_name.txt') |
| ]); |
| }); |
| |
| bool filesystemIsCaseSensitive() { |
| final directory = Directory.systemTemp.createTempSync(); |
| final filePath = p.join(directory.path, 'a'); |
| final file = File(filePath)..createSync(); |
| final result = !File(filePath.toUpperCase()).existsSync(); |
| file.deleteSync(); |
| return result; |
| } |
| |
| group('on case-insensitive filesystem', skip: filesystemIsCaseSensitive(), |
| () { |
| test('events with case-only changes', () async { |
| if (filesystemIsCaseSensitive()) return; |
| |
| writeFile('A.txt'); |
| writeFile('B.txt'); |
| writeFile('C.txt'); |
| |
| await startWatcher(); |
| |
| writeFile('A.TXT', contents: 'modified'); |
| deleteFile('B.TXT'); |
| renameFile('C.txt', 'C.TXT'); |
| |
| if (isNative && Platform.isWindows) { |
| // On Windows events arrive with case the files were created with, not |
| // the case that was used when modifying them. So the delete of `B.txt` |
| // as `B.TXT` is picked up. But, the watcher does not correctly handle |
| // the "remove" of `C.txt` from the rename, and sends an incorrect |
| // "modify". TODO(davidmorgan): fix it. |
| // See: https://github.com/dart-lang/tools/issues/2271. |
| await inAnyOrder([ |
| isModifyEvent('A.txt'), |
| isRemoveEvent('B.txt'), |
| isModifyEvent('C.txt'), |
| isAddEvent('C.TXT'), |
| ]); |
| } else if (isNative && Platform.isMacOS) { |
| // On MacOS the delete event arrives with case used to operate on the |
| // file, so the delete of `B.txt` as `B.TXT` is not picked up. It has |
| // the same problem as Windows with the move of `C.txt`. |
| // See: https://github.com/dart-lang/tools/issues/2271. |
| await inAnyOrder([ |
| isModifyEvent('A.txt'), |
| isModifyEvent('C.txt'), |
| isAddEvent('C.TXT'), |
| ]); |
| } else { |
| await inAnyOrder([ |
| isModifyEvent('A.txt'), |
| isRemoveEvent('B.txt'), |
| isRemoveEvent('C.txt'), |
| isAddEvent('C.TXT'), |
| ]); |
| } |
| |
| await expectNoEvents(); |
| }); |
| |
| test('works when watch root is specified with case-only changes', () async { |
| if (filesystemIsCaseSensitive()) return; |
| |
| writeFile('a'); |
| writeFile('b'); |
| writeFile('c'); |
| |
| final sandboxPathWithDifferentCase = d.sandbox.toUpperCase(); |
| expect(sandboxPathWithDifferentCase, isNot(d.sandbox)); |
| await startWatcher(exactPath: sandboxPathWithDifferentCase); |
| |
| writeFile('a', contents: 'modified'); |
| deleteFile('b'); |
| renameFile('c', 'e'); |
| writeFile('d'); |
| |
| await inAnyOrder([ |
| isModifyEvent('a', ignoreCase: true), |
| isRemoveEvent('b', ignoreCase: true), |
| isRemoveEvent('c', ignoreCase: true), |
| isAddEvent('e', ignoreCase: true), |
| isAddEvent('d', ignoreCase: true), |
| ]); |
| }); |
| }); |
| } |