diff --git a/pkgs/watcher/test/directory_watcher/client_simulator.dart b/pkgs/watcher/test/directory_watcher/client_simulator.dart new file mode 100644 index 000000000..0592affd3 --- /dev/null +++ b/pkgs/watcher/test/directory_watcher/client_simulator.dart @@ -0,0 +1,184 @@ +// 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:async'; +import 'dart:io'; + +import 'package:test/test.dart'; +import 'package:watcher/watcher.dart'; + +/// Simulates a typical use case for `package:watcher`. +/// +/// Tracks file lengths, updating based on watch events. +/// +/// Call [verify] to verify whether the tracked lengths match the actual file +/// lengths on disk. +class ClientSimulator { + final Watcher watcher; + + /// Events and actions, for logging on failure. + final List messages = []; + + final Map _trackedFileLengths = {}; + + StreamSubscription? _subscription; + DateTime _lastEventAt = DateTime.now(); + + ClientSimulator._(this.watcher); + + /// Creates a `ClientSimulator` watching with [watcher]. + /// + /// When returned, it has already read the filesystem state and started + /// tracking file lengths using watcher events. + static Future watch(Watcher watcher) async { + final result = ClientSimulator._(watcher); + result._initialRead(); + result._subscription = watcher.events.listen(result._handleEvent); + await watcher.ready; + return result; + } + + /// Waits for at least [duration], and for a span of that duration in which no + /// events are received. + Future waitForNoEvents(Duration duration) async { + _lastEventAt = DateTime.now(); + while (true) { + final timeLeft = duration - DateTime.now().difference(_lastEventAt); + if (timeLeft <= Duration.zero) return; + await Future.delayed(timeLeft + const Duration(milliseconds: 1)); + } + } + + /// Closes the watcher subscription. + void close() { + _subscription?.cancel(); + } + + Directory get _directory => Directory(watcher.path); + + /// Reads all files to get the start state. + void _initialRead() { + for (final file in _directory.listSync(recursive: true).whereType()) { + _readFile(file.path); + } + } + + /// Reads the file at [path] and updates tracked state with its current + /// length. + /// + /// If the file cannot be read the size is set to -1, this can be corrected + /// by a REMOVE event. + void _readFile(String path) { + try { + _trackedFileLengths[path] = File(path).lengthSync(); + } catch (_) { + _trackedFileLengths[path] = -1; + } + } + + /// Updates tracked state for [event]. + /// + /// For add and modify events, reads the file to determine its length. + /// + /// For remove events, removes tracking for that file. + void _handleEvent(WatchEvent event) { + _log(event.toString()); + _lastEventAt = DateTime.now(); + switch (event.type) { + case ChangeType.ADD: + if (_trackedFileLengths.containsKey(event.path)) { + // This happens sometimes, so investigation+fix would be needed + // if we want to make it an error. + printOnFailure('Warning: ADD for tracked path,${event.path}'); + } + _readFile(event.path); + break; + + case ChangeType.MODIFY: + _readFile(event.path); + break; + + case ChangeType.REMOVE: + if (!_trackedFileLengths.containsKey(event.path)) { + // This happens sometimes, so investigation+fix would be needed + // if we want to make it an error. + printOnFailure('Warning: REMOVE untracked path: ${event.path}'); + } + _trackedFileLengths.remove(event.path); + break; + } + } + + /// Reads current file lengths for verification. + Map _readFileLengths() { + final result = {}; + for (final file in _directory.listSync(recursive: true).whereType()) { + result[file.path] = file.lengthSync(); + } + return result; + } + + /// Returns whether tracked state matches actual state on disk. + /// + /// If not, and [log] is `true`, prints an explanation of the difference + /// with `printOnFailure`. + bool verify({required bool log}) { + final fileLengths = _readFileLengths(); + + var result = true; + + final unexpectedFiles = + fileLengths.keys.toSet().difference(_trackedFileLengths.keys.toSet()); + if (unexpectedFiles.isNotEmpty) { + result = false; + + if (log) { + printOnFailure('Failed, on disk but not tracked:'); + printOnFailure( + unexpectedFiles.map((path) => path.padLeft(4)).join('\n')); + } + } + + final missingExpectedFiles = + _trackedFileLengths.keys.toSet().difference(fileLengths.keys.toSet()); + if (missingExpectedFiles.isNotEmpty) { + result = false; + if (log) { + printOnFailure('Failed, tracked but not on disk:'); + printOnFailure( + missingExpectedFiles.map((path) => path.padLeft(4)).join('\n')); + } + } + + final differentFiles = {}; + for (final path in fileLengths.keys) { + if (_trackedFileLengths[path] == null) continue; + if (fileLengths[path] != _trackedFileLengths[path]) { + differentFiles.add(path); + } + } + if (differentFiles.isNotEmpty) { + result = false; + if (log) { + printOnFailure('Failed, tracking is out of date:'); + final output = StringBuffer(); + for (final path in differentFiles) { + final tracked = _trackedFileLengths[path]!; + final actual = fileLengths[path]!; + output.write(' $path tracked=$tracked actual=$actual\n'); + } + printOnFailure(output.toString()); + } + } + + return result; + } + + void _log(String message) { + // Remove the tmp folder from the message. + message = + message.replaceAll('${watcher.path}${Platform.pathSeparator}', ''); + messages.add(message); + } +} diff --git a/pkgs/watcher/test/directory_watcher/end_to_end_tests.dart b/pkgs/watcher/test/directory_watcher/end_to_end_tests.dart new file mode 100644 index 000000000..086a4f6aa --- /dev/null +++ b/pkgs/watcher/test/directory_watcher/end_to_end_tests.dart @@ -0,0 +1,85 @@ +// 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:path/path.dart' as p; +import 'package:test/test.dart'; + +import '../utils.dart'; +import 'client_simulator.dart'; +import 'file_changer.dart'; + +/// End to end test using a [FileChanger] that randomly changes files, then a +/// [ClientSimulator] that tracks state using a Watcher. +/// +/// The test passes if the [ClientSimulator] tracking matches what's actually on +/// disk. +/// +/// Fails on Linux due to https://github.com/dart-lang/tools/issues/2228. +/// +/// Fails sometimes on Windows due to +/// https://github.com/dart-lang/tools/issues/2234. +void endToEndTests({required bool isNative}) { + test('end to end test', timeout: const Timeout(Duration(minutes: 5)), + () async { + final temp = Directory.systemTemp.createTempSync(); + addTearDown(() => temp.deleteSync(recursive: true)); + + // Start with some files. + final changer = FileChanger(temp.path); + await changer.changeFiles(times: 100); + + // Create the watcher and [ClientSimulator]. + final watcher = createWatcher(path: temp.path); + final client = await ClientSimulator.watch(watcher); + addTearDown(client.close); + + // 20 iterations of making changes, waiting for events to settle, and + // checking for consistency. + for (var i = 0; i != 20; ++i) { + // File changes. + final messages = await changer.changeFiles(times: 100); + + // Give time for events to arrive. To allow tests to run quickly when the + // events are handled quickly, poll and continue if verification passes. + for (var waits = 0; waits != 20; ++waits) { + if (client.verify(log: false)) { + break; + } + await client.waitForNoEvents(const Duration(milliseconds: 100)); + } + + // Verify for real and fail the test if still not consistent. + if (!client.verify(log: true)) { + if (Platform.isLinux && isNative) { + print('Ignoring expected failure for Linux native watcher.'); + return; + } + if (Platform.isWindows && isNative) { + print('Ignoring expected failure for Windows native watcher.'); + return; + } + + // Write the file operations before the failure to a log, fail the test. + final logTemp = Directory.systemTemp.createTempSync(); + final fileChangesLogPath = p.join(logTemp.path, 'changes.txt'); + File(fileChangesLogPath) + .writeAsStringSync(messages.map((m) => '$m\n').join('')); + final clientLogPath = p.join(logTemp.path, 'client.txt'); + File(clientLogPath) + .writeAsStringSync(client.messages.map((m) => '$m\n').join('')); + fail(''' +Failed on run $i. +Files changes: $fileChangesLogPath +Client log: $clientLogPath'''); + } + } + + if (Platform.isLinux && isNative) { + fail('Expected Linux native watcher failure, but test passed!'); + } + // Can't expect the Windows failure as it does sometimes succeed. + }); +} diff --git a/pkgs/watcher/test/directory_watcher/file_changer.dart b/pkgs/watcher/test/directory_watcher/file_changer.dart new file mode 100644 index 000000000..775d89a65 --- /dev/null +++ b/pkgs/watcher/test/directory_watcher/file_changer.dart @@ -0,0 +1,171 @@ +// 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 'dart:isolate'; +import 'dart:math'; + +import 'package:path/path.dart' as p; + +/// Changes files randomly. +/// +/// Writes are done in an isolate so as to not block the watcher code being +/// tested. Content is modified, files are moved, directories are moved. +/// Directories include nested directories. +/// +/// Most file operations as as fast as they can be, consecutive `sync` +/// operations, but one of the possible operations is "wait" which waits for +/// one millisecond. +/// +/// A fixed random seed is used so a new `FileChanged` will always perform +/// the same sequence of operations. +class FileChanger { + final String path; + + final Random _random = Random(0); + final List _messages = []; + + FileChanger(this.path); + + /// Changes files under [path], [times] times. + /// + /// Returns a log of the changes made. + Future> changeFiles({required int times}) async => + await Isolate.run(() => _changeFiles(times: times)); + + Future> _changeFiles({required int times}) async { + _messages.clear(); + for (var i = 0; i != times; ++i) { + await _changeFilesOnce(); + } + return _messages.toList(); + } + + Future _changeFilesOnce() async { + switch (_random.nextInt(9)) { + // "Create" is three times more likely than "delete" so that the set of + // files grows over time. + case 0: + case 1: + case 2: + final filePath = _randomFilePath(); + _ensureParent(filePath); + final content = _randomContent(); + _log('create,$filePath,${content.length}'); + // `flush` seems to make flaky failures more likely on Windows, + // presumably by ensuring that different states actually reach the + // filesystem. + File(filePath).writeAsStringSync(content, flush: true); + + case 3: + final existingPath = _randomExistingFilePath(); + if (existingPath == null) return; + final content = _randomContent(); + _log('modify,$existingPath,${content.length}'); + // `flush` seems to make flaky failures more likely on Windows, + // presumably by ensuring that different states actually reach the + // filesystem. + File(existingPath).writeAsStringSync(content, flush: true); + + case 4: + final existingPath = _randomExistingFilePath(); + if (existingPath == null) return; + final filePath = _randomFilePath(); + _ensureParent(filePath); + _log('move file to new,$existingPath,$filePath'); + File(existingPath).renameSync(filePath); + + case 5: + final existingPath = _randomExistingFilePath(); + if (existingPath == null) return; + final existingPath2 = _randomExistingFilePath()!; + _log('move file over file,$existingPath,$existingPath2'); + // Fails sometimes on Windows, so guard+retry. + _retryForPathAccessException( + () => File(existingPath).renameSync(existingPath2)); + + case 6: + final existingDirectory = _randomExistingDirectoryPath(); + if (existingDirectory == null) return; + final newDirectory = _randomDirectoryPath(); + if (Directory(newDirectory).existsSync()) return; + if (newDirectory.startsWith(existingDirectory)) return; + _ensureParent(newDirectory); + _log('move directory to new,$existingDirectory,$newDirectory'); + // Fails sometimes on Windows, so guard+retry. + _retryForPathAccessException( + () => Directory(existingDirectory).renameSync(newDirectory)); + + case 7: + final existingPath = _randomExistingFilePath(); + if (existingPath == null) return; + _log('delete,$existingPath'); + File(existingPath).deleteSync(); + + case 8: + _log('wait'); + await Future.delayed(const Duration(milliseconds: 1)); + } + } + + /// Returns 0-999 spaces. + String _randomContent() => ' ' * _random.nextInt(1000); + + /// Returns a file in a random path from [_randomDirectoryPath]. + String _randomFilePath() { + return p.join(_randomDirectoryPath(), _random.nextInt(100000).toString()); + } + + /// Returns a random directory with 0-2 levels of subdirectories. + String _randomDirectoryPath() { + var result = path; + final subdirectoryDepth = _random.nextInt(3); + for (var i = 0; i != subdirectoryDepth; ++i) { + // Name path segments as single characters a-j so there is a good chance + // of collisions that will cause multiple files to be created in one + // directory. + result = p.join(result, String.fromCharCode(97 + _random.nextInt(10))); + } + return result; + } + + /// Returns the path to an already-created file, or `null` if none exists. + String? _randomExistingFilePath() => + (Directory(path).listSync(recursive: true).whereType().toList() + ..shuffle(_random)) + .firstOrNull + ?.path; + + /// Returns the path to an already-created directory, or `null` if none + /// exists. + String? _randomExistingDirectoryPath() => (Directory( + path, + ).listSync(recursive: true).whereType().toList() + ..shuffle(_random)) + .firstOrNull + ?.path; + + void _ensureParent(String path) { + final directory = Directory(p.dirname(path)); + if (!directory.existsSync()) directory.createSync(recursive: true); + } + + void _log(String message) { + // Remove the tmp folder from the message. + message = message.replaceAll(',$path${Platform.pathSeparator}', ','); + _messages.add(message); + } + + /// Retries [action] until it does not throw [PathAccessException]. + void _retryForPathAccessException(void Function() action) { + while (true) { + try { + action(); + return; + } on PathAccessException catch (e) { + print('Temporary failure, retrying: $e'); + } + } + } +} diff --git a/pkgs/watcher/test/directory_watcher/linux_test.dart b/pkgs/watcher/test/directory_watcher/linux_test.dart index 948d565f9..c1aa93c59 100644 --- a/pkgs/watcher/test/directory_watcher/linux_test.dart +++ b/pkgs/watcher/test/directory_watcher/linux_test.dart @@ -10,6 +10,7 @@ import 'package:watcher/src/directory_watcher/linux.dart'; import 'package:watcher/watcher.dart'; import '../utils.dart'; +import 'end_to_end_tests.dart'; import 'file_tests.dart'; import 'link_tests.dart'; @@ -18,6 +19,7 @@ void main() { fileTests(isNative: true); linkTests(isNative: true); + endToEndTests(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 1306cc4b3..f9c8e9779 100644 --- a/pkgs/watcher/test/directory_watcher/mac_os_test.dart +++ b/pkgs/watcher/test/directory_watcher/mac_os_test.dart @@ -10,6 +10,7 @@ import 'package:watcher/src/directory_watcher/mac_os.dart'; import 'package:watcher/watcher.dart'; import '../utils.dart'; +import 'end_to_end_tests.dart'; import 'file_tests.dart'; import 'link_tests.dart'; @@ -18,6 +19,7 @@ void main() { fileTests(isNative: true); linkTests(isNative: true); + endToEndTests(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 78f6b78e5..5a5dfd951 100644 --- a/pkgs/watcher/test/directory_watcher/polling_test.dart +++ b/pkgs/watcher/test/directory_watcher/polling_test.dart @@ -9,6 +9,7 @@ import 'package:test/test.dart'; import 'package:watcher/watcher.dart'; import '../utils.dart'; +import 'end_to_end_tests.dart'; import 'file_tests.dart'; import 'link_tests.dart'; @@ -23,6 +24,7 @@ void main() { fileTests(isNative: false); linkTests(isNative: false); + endToEndTests(isNative: false); // A poll does an async directory list that runs "stat" on each file. Check // handling of a file that is deleted between the two. diff --git a/pkgs/watcher/test/directory_watcher/windows_test.dart b/pkgs/watcher/test/directory_watcher/windows_test.dart index c4f6d44a0..4bf0d4bde 100644 --- a/pkgs/watcher/test/directory_watcher/windows_test.dart +++ b/pkgs/watcher/test/directory_watcher/windows_test.dart @@ -15,6 +15,7 @@ import 'package:watcher/src/directory_watcher/windows.dart'; import 'package:watcher/watcher.dart'; import '../utils.dart'; +import 'end_to_end_tests.dart'; import 'file_tests.dart'; import 'link_tests.dart'; @@ -23,6 +24,7 @@ void main() { fileTests(isNative: true); linkTests(isNative: true); + endToEndTests(isNative: true); test('DirectoryWatcher creates a WindowsDirectoryWatcher on Windows', () { expect(DirectoryWatcher('.'), const TypeMatcher());