Skip to content

Commit c423dbe

Browse files
authored
Add end to end test for package:watcher. (#2227)
1 parent 89f07d2 commit c423dbe

File tree

7 files changed

+448
-0
lines changed

7 files changed

+448
-0
lines changed
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
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:async';
6+
import 'dart:io';
7+
8+
import 'package:test/test.dart';
9+
import 'package:watcher/watcher.dart';
10+
11+
/// Simulates a typical use case for `package:watcher`.
12+
///
13+
/// Tracks file lengths, updating based on watch events.
14+
///
15+
/// Call [verify] to verify whether the tracked lengths match the actual file
16+
/// lengths on disk.
17+
class ClientSimulator {
18+
final Watcher watcher;
19+
20+
/// Events and actions, for logging on failure.
21+
final List<String> messages = [];
22+
23+
final Map<String, int> _trackedFileLengths = {};
24+
25+
StreamSubscription<WatchEvent>? _subscription;
26+
DateTime _lastEventAt = DateTime.now();
27+
28+
ClientSimulator._(this.watcher);
29+
30+
/// Creates a `ClientSimulator` watching with [watcher].
31+
///
32+
/// When returned, it has already read the filesystem state and started
33+
/// tracking file lengths using watcher events.
34+
static Future<ClientSimulator> watch(Watcher watcher) async {
35+
final result = ClientSimulator._(watcher);
36+
result._initialRead();
37+
result._subscription = watcher.events.listen(result._handleEvent);
38+
await watcher.ready;
39+
return result;
40+
}
41+
42+
/// Waits for at least [duration], and for a span of that duration in which no
43+
/// events are received.
44+
Future<void> waitForNoEvents(Duration duration) async {
45+
_lastEventAt = DateTime.now();
46+
while (true) {
47+
final timeLeft = duration - DateTime.now().difference(_lastEventAt);
48+
if (timeLeft <= Duration.zero) return;
49+
await Future<void>.delayed(timeLeft + const Duration(milliseconds: 1));
50+
}
51+
}
52+
53+
/// Closes the watcher subscription.
54+
void close() {
55+
_subscription?.cancel();
56+
}
57+
58+
Directory get _directory => Directory(watcher.path);
59+
60+
/// Reads all files to get the start state.
61+
void _initialRead() {
62+
for (final file in _directory.listSync(recursive: true).whereType<File>()) {
63+
_readFile(file.path);
64+
}
65+
}
66+
67+
/// Reads the file at [path] and updates tracked state with its current
68+
/// length.
69+
///
70+
/// If the file cannot be read the size is set to -1, this can be corrected
71+
/// by a REMOVE event.
72+
void _readFile(String path) {
73+
try {
74+
_trackedFileLengths[path] = File(path).lengthSync();
75+
} catch (_) {
76+
_trackedFileLengths[path] = -1;
77+
}
78+
}
79+
80+
/// Updates tracked state for [event].
81+
///
82+
/// For add and modify events, reads the file to determine its length.
83+
///
84+
/// For remove events, removes tracking for that file.
85+
void _handleEvent(WatchEvent event) {
86+
_log(event.toString());
87+
_lastEventAt = DateTime.now();
88+
switch (event.type) {
89+
case ChangeType.ADD:
90+
if (_trackedFileLengths.containsKey(event.path)) {
91+
// This happens sometimes, so investigation+fix would be needed
92+
// if we want to make it an error.
93+
printOnFailure('Warning: ADD for tracked path,${event.path}');
94+
}
95+
_readFile(event.path);
96+
break;
97+
98+
case ChangeType.MODIFY:
99+
_readFile(event.path);
100+
break;
101+
102+
case ChangeType.REMOVE:
103+
if (!_trackedFileLengths.containsKey(event.path)) {
104+
// This happens sometimes, so investigation+fix would be needed
105+
// if we want to make it an error.
106+
printOnFailure('Warning: REMOVE untracked path: ${event.path}');
107+
}
108+
_trackedFileLengths.remove(event.path);
109+
break;
110+
}
111+
}
112+
113+
/// Reads current file lengths for verification.
114+
Map<String, int> _readFileLengths() {
115+
final result = <String, int>{};
116+
for (final file in _directory.listSync(recursive: true).whereType<File>()) {
117+
result[file.path] = file.lengthSync();
118+
}
119+
return result;
120+
}
121+
122+
/// Returns whether tracked state matches actual state on disk.
123+
///
124+
/// If not, and [log] is `true`, prints an explanation of the difference
125+
/// with `printOnFailure`.
126+
bool verify({required bool log}) {
127+
final fileLengths = _readFileLengths();
128+
129+
var result = true;
130+
131+
final unexpectedFiles =
132+
fileLengths.keys.toSet().difference(_trackedFileLengths.keys.toSet());
133+
if (unexpectedFiles.isNotEmpty) {
134+
result = false;
135+
136+
if (log) {
137+
printOnFailure('Failed, on disk but not tracked:');
138+
printOnFailure(
139+
unexpectedFiles.map((path) => path.padLeft(4)).join('\n'));
140+
}
141+
}
142+
143+
final missingExpectedFiles =
144+
_trackedFileLengths.keys.toSet().difference(fileLengths.keys.toSet());
145+
if (missingExpectedFiles.isNotEmpty) {
146+
result = false;
147+
if (log) {
148+
printOnFailure('Failed, tracked but not on disk:');
149+
printOnFailure(
150+
missingExpectedFiles.map((path) => path.padLeft(4)).join('\n'));
151+
}
152+
}
153+
154+
final differentFiles = <String>{};
155+
for (final path in fileLengths.keys) {
156+
if (_trackedFileLengths[path] == null) continue;
157+
if (fileLengths[path] != _trackedFileLengths[path]) {
158+
differentFiles.add(path);
159+
}
160+
}
161+
if (differentFiles.isNotEmpty) {
162+
result = false;
163+
if (log) {
164+
printOnFailure('Failed, tracking is out of date:');
165+
final output = StringBuffer();
166+
for (final path in differentFiles) {
167+
final tracked = _trackedFileLengths[path]!;
168+
final actual = fileLengths[path]!;
169+
output.write(' $path tracked=$tracked actual=$actual\n');
170+
}
171+
printOnFailure(output.toString());
172+
}
173+
}
174+
175+
return result;
176+
}
177+
178+
void _log(String message) {
179+
// Remove the tmp folder from the message.
180+
message =
181+
message.replaceAll('${watcher.path}${Platform.pathSeparator}', '');
182+
messages.add(message);
183+
}
184+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
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:path/path.dart' as p;
8+
import 'package:test/test.dart';
9+
10+
import '../utils.dart';
11+
import 'client_simulator.dart';
12+
import 'file_changer.dart';
13+
14+
/// End to end test using a [FileChanger] that randomly changes files, then a
15+
/// [ClientSimulator] that tracks state using a Watcher.
16+
///
17+
/// The test passes if the [ClientSimulator] tracking matches what's actually on
18+
/// disk.
19+
///
20+
/// Fails on Linux due to https://github.com/dart-lang/tools/issues/2228.
21+
///
22+
/// Fails sometimes on Windows due to
23+
/// https://github.com/dart-lang/tools/issues/2234.
24+
void endToEndTests({required bool isNative}) {
25+
test('end to end test', timeout: const Timeout(Duration(minutes: 5)),
26+
() async {
27+
final temp = Directory.systemTemp.createTempSync();
28+
addTearDown(() => temp.deleteSync(recursive: true));
29+
30+
// Start with some files.
31+
final changer = FileChanger(temp.path);
32+
await changer.changeFiles(times: 100);
33+
34+
// Create the watcher and [ClientSimulator].
35+
final watcher = createWatcher(path: temp.path);
36+
final client = await ClientSimulator.watch(watcher);
37+
addTearDown(client.close);
38+
39+
// 20 iterations of making changes, waiting for events to settle, and
40+
// checking for consistency.
41+
for (var i = 0; i != 20; ++i) {
42+
// File changes.
43+
final messages = await changer.changeFiles(times: 100);
44+
45+
// Give time for events to arrive. To allow tests to run quickly when the
46+
// events are handled quickly, poll and continue if verification passes.
47+
for (var waits = 0; waits != 20; ++waits) {
48+
if (client.verify(log: false)) {
49+
break;
50+
}
51+
await client.waitForNoEvents(const Duration(milliseconds: 100));
52+
}
53+
54+
// Verify for real and fail the test if still not consistent.
55+
if (!client.verify(log: true)) {
56+
if (Platform.isLinux && isNative) {
57+
print('Ignoring expected failure for Linux native watcher.');
58+
return;
59+
}
60+
if (Platform.isWindows && isNative) {
61+
print('Ignoring expected failure for Windows native watcher.');
62+
return;
63+
}
64+
65+
// Write the file operations before the failure to a log, fail the test.
66+
final logTemp = Directory.systemTemp.createTempSync();
67+
final fileChangesLogPath = p.join(logTemp.path, 'changes.txt');
68+
File(fileChangesLogPath)
69+
.writeAsStringSync(messages.map((m) => '$m\n').join(''));
70+
final clientLogPath = p.join(logTemp.path, 'client.txt');
71+
File(clientLogPath)
72+
.writeAsStringSync(client.messages.map((m) => '$m\n').join(''));
73+
fail('''
74+
Failed on run $i.
75+
Files changes: $fileChangesLogPath
76+
Client log: $clientLogPath''');
77+
}
78+
}
79+
80+
if (Platform.isLinux && isNative) {
81+
fail('Expected Linux native watcher failure, but test passed!');
82+
}
83+
// Can't expect the Windows failure as it does sometimes succeed.
84+
});
85+
}

0 commit comments

Comments
 (0)