Skip to content

Commit 467df8c

Browse files
authored
Add test coverage around startup race and missing file on startup. (#2174)
1 parent 08515a4 commit 467df8c

File tree

5 files changed

+101
-20
lines changed

5 files changed

+101
-20
lines changed

pkgs/watcher/test/file_watcher/shared.dart renamed to pkgs/watcher/test/file_watcher/file_tests.dart

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,24 @@
22
// for details. All rights reserved. Use of this source code is governed by a
33
// BSD-style license that can be found in the LICENSE file.
44

5+
import 'dart:io';
6+
57
import 'package:test/test.dart';
68

79
import '../utils.dart';
810

9-
void sharedTests() {
11+
void fileTests({required bool isNative}) {
12+
setUp(() {
13+
writeFile('file.txt');
14+
});
15+
1016
test("doesn't notify if the file isn't modified", () async {
17+
// TODO(davidmorgan): fix startup race on MacOS.
18+
if (isNative && Platform.isMacOS) {
19+
await Future<void>.delayed(const Duration(milliseconds: 100));
20+
}
1121
await startWatcher(path: 'file.txt');
12-
await pumpEventQueue();
13-
deleteFile('file.txt');
14-
await expectRemoveEvent('file.txt');
22+
await expectNoEvents();
1523
});
1624

1725
test('notifies when a file is modified', () async {
@@ -70,4 +78,22 @@ void sharedTests() {
7078
// startWatcher awaits 'ready'
7179
await startWatcher(path: 'foo/bar/baz');
7280
});
81+
82+
test('throws if file does not exist', () async {
83+
await startWatcher(path: 'other_file.txt');
84+
85+
// TODO(davidmorgan): reconcile differences.
86+
if (isNative && Platform.isLinux) {
87+
expect(expectNoEvents, throwsA(isA<PathNotFoundException>()));
88+
} else {
89+
// The polling watcher and the MacOS watcher do not throw on missing file
90+
// on watch. Instead, they report both creating and modification as
91+
// modifications.
92+
await expectNoEvents();
93+
writeFile('other_file.txt');
94+
await expectModifyEvent('other_file.txt');
95+
writeFile('other_file.txt');
96+
await expectModifyEvent('other_file.txt');
97+
}
98+
});
7399
}

pkgs/watcher/test/file_watcher/native_test.dart

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,12 @@ import 'package:test/test.dart';
99
import 'package:watcher/src/file_watcher/native.dart';
1010

1111
import '../utils.dart';
12-
import 'shared.dart';
12+
import 'file_tests.dart';
13+
import 'startup_race_tests.dart';
1314

1415
void main() {
1516
watcherFactory = NativeFileWatcher.new;
1617

17-
setUp(() {
18-
writeFile('file.txt');
19-
});
20-
21-
sharedTests();
18+
fileTests(isNative: true);
19+
startupRaceTests(isNative: true);
2220
}

pkgs/watcher/test/file_watcher/polling_test.dart

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,16 @@
22
// for details. All rights reserved. Use of this source code is governed by a
33
// BSD-style license that can be found in the LICENSE file.
44

5-
import 'package:test/test.dart';
65
import 'package:watcher/watcher.dart';
76

87
import '../utils.dart';
9-
import 'shared.dart';
8+
import 'file_tests.dart';
9+
import 'startup_race_tests.dart';
1010

1111
void main() {
1212
watcherFactory = (file) =>
1313
PollingFileWatcher(file, pollingDelay: const Duration(milliseconds: 100));
1414

15-
setUp(() {
16-
writeFile('file.txt');
17-
});
18-
19-
sharedTests();
15+
fileTests(isNative: false);
16+
startupRaceTests(isNative: false);
2017
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'dart:io';
6+
7+
import 'package:test/test.dart';
8+
9+
import '../utils.dart';
10+
11+
/// Tests for a startup race that affects MacOS.
12+
///
13+
/// As documented in `File.watch`, changes from shortly _before_ the `watch`
14+
/// method is called might be reported on MacOS. They should be ignored.
15+
void startupRaceTests({required bool isNative}) {
16+
test('ignores events from before watch starts', () async {
17+
// Write then immediately watch 100 times and count the events received.
18+
var events = 0;
19+
final futures = <Future<void>>[];
20+
for (var i = 0; i != 100; ++i) {
21+
writeFile('file$i.txt');
22+
await startWatcher(path: 'file$i.txt');
23+
futures.add(
24+
waitForEvent().then((event) {
25+
if (event != null) ++events;
26+
}),
27+
);
28+
}
29+
await Future.wait(futures);
30+
31+
// TODO(davidmorgan): the MacOS watcher currently does get unwanted events,
32+
// fix it.
33+
if (isNative && Platform.isMacOS) {
34+
expect(events, greaterThan(10));
35+
} else {
36+
expect(events, 0);
37+
}
38+
});
39+
}

pkgs/watcher/test/utils.dart

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,13 @@ Future<void> startWatcher({String? path}) async {
6363
final normalized = p.normalize(p.relative(path, from: d.sandbox));
6464

6565
// Make sure we got a path in the sandbox.
66-
assert(p.isRelative(normalized) && !normalized.startsWith('..'),
67-
'Path is not in the sandbox: $path not in ${d.sandbox}');
68-
66+
if (!p.isRelative(normalized) || normalized.startsWith('..')) {
67+
// The polling watcher can poll during test teardown, signal using an
68+
// exception that it will ignore.
69+
throw FileSystemException(
70+
'Path is not in the sandbox: $path not in ${d.sandbox}',
71+
);
72+
}
6973
var mtime = _mockFileModificationTimes[normalized];
7074
return mtime != null ? DateTime.fromMillisecondsSinceEpoch(mtime) : null;
7175
});
@@ -174,6 +178,23 @@ Matcher isModifyEvent(String path) => isWatchEvent(ChangeType.MODIFY, path);
174178
/// [path].
175179
Matcher isRemoveEvent(String path) => isWatchEvent(ChangeType.REMOVE, path);
176180

181+
/// Takes the first event emitted during [duration], or returns `null` if there
182+
/// is none.
183+
Future<WatchEvent?> waitForEvent({
184+
Duration duration = const Duration(seconds: 1),
185+
}) async {
186+
final result = await _watcherEvents.peek
187+
.then<WatchEvent?>((e) => e)
188+
.timeout(duration, onTimeout: () => null);
189+
if (result != null) _watcherEvents.take(1).ignore();
190+
return result;
191+
}
192+
193+
/// Expects that no events are omitted for [duration].
194+
Future expectNoEvents({Duration duration = const Duration(seconds: 1)}) async {
195+
expect(await waitForEvent(duration: duration), isNull);
196+
}
197+
177198
/// Expects that the next event emitted will be for an add event for [path].
178199
Future expectAddEvent(String path) =>
179200
_expectOrCollect(isWatchEvent(ChangeType.ADD, path));

0 commit comments

Comments
 (0)