blob: 912c119bfa235d6c9c134e0097acc2c321d9a51c [file] [log] [blame]
// 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),
]);
});
});
}