From 72a3dce4a354698fca1668a529186d2ec5eceae1 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 14 May 2026 10:19:07 +0200 Subject: [PATCH] =?UTF-8?q?test=20sweep:=20foundational=20systems=20?= =?UTF-8?q?=E2=80=94=20files=20/=20ipc=20errno=20/=20panes=20(T-91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small foundational test additions: - test/files/watcher_test.dart (new, 8 tests): FileChangeKind fromEvent across every FileSystemEvent.type + wire getter, FileChange.toJson, FileWatcher end-to-end (created event, ignored-path filtering, idempotent start, stop teardown). - ignore_test: trailing /**, bare **, ? glob-pattern branches in the regex compiler. - path_safety_test: PathOutsideRoot.toString embeds the three fields. - ipc/errno_mapping_test: ENOTDIR / ENOMEM / EAGAIN branches in errnoToIpcError that weren't previously hit. - panes/registry_test: RecordingEventSink.ofSubsystem filter. Coverage: src/files/watcher.dart 10/36 -> ~ all; ignore.dart + path_safety.dart residuals closed; ipc/errno_mapping.dart 3 added branches; panes/event_sink.dart 100%. Total coverage 90.53% -> 90.99%. Co-Authored-By: Claude Opus 4.7 (1M context) --- test/files/ignore_test.dart | 18 ++++++ test/files/path_safety_test.dart | 5 ++ test/files/watcher_test.dart | 98 ++++++++++++++++++++++++++++++++ test/ipc/errno_mapping_test.dart | 19 +++++++ test/panes/registry_test.dart | 13 +++++ 5 files changed, 153 insertions(+) create mode 100644 test/files/watcher_test.dart diff --git a/test/files/ignore_test.dart b/test/files/ignore_test.dart index 442393ce..55f04dac 100644 --- a/test/files/ignore_test.dart +++ b/test/files/ignore_test.dart @@ -76,5 +76,23 @@ void main() { expect(s.isIgnored('docs/a.draft.md', isDirectory: false), isTrue); expect(s.isIgnored('other/a.draft.md', isDirectory: false), isFalse); }); + + test('trailing /** matches every descendant', () { + final s = IgnoreSet.parse(['build/**']); + expect(s.isIgnored('build/a/b/c.txt', isDirectory: false), isTrue); + expect(s.isIgnored('src/a.txt', isDirectory: false), isFalse); + }); + + test('bare ** (not adjacent to /) matches across path separators', () { + final s = IgnoreSet.parse(['a**z']); + expect(s.isIgnored('axyz', isDirectory: false), isTrue); + expect(s.isIgnored('amiddlez', isDirectory: false), isTrue); + }); + + test('? matches exactly one non-slash character', () { + final s = IgnoreSet.parse(['a?c']); + expect(s.isIgnored('abc', isDirectory: false), isTrue); + expect(s.isIgnored('a/c', isDirectory: false), isFalse); + }); }); } diff --git a/test/files/path_safety_test.dart b/test/files/path_safety_test.dart index 28aa48b4..b4a7ade2 100644 --- a/test/files/path_safety_test.dart +++ b/test/files/path_safety_test.dart @@ -58,5 +58,10 @@ void main() { if (twin.existsSync()) twin.deleteSync(recursive: true); } }); + + test('PathOutsideRoot.toString embeds requested + resolved + root', () { + final e = PathOutsideRoot('r', '/abs', '/root'); + expect(e.toString(), allOf(contains('r'), contains('/abs'), contains('/root'))); + }); }); } diff --git a/test/files/watcher_test.dart b/test/files/watcher_test.dart new file mode 100644 index 00000000..6e03f1ae --- /dev/null +++ b/test/files/watcher_test.dart @@ -0,0 +1,98 @@ +/// Tests for `lib/src/files/watcher.dart`. Drives a real +/// Directory.watch against a tempdir. +library; + +import 'dart:async'; +import 'dart:io'; + +import 'package:clide/src/files/ignore.dart'; +import 'package:clide/src/files/watcher.dart'; +import 'package:test/test.dart'; + +void main() { + group('FileChangeKind', () { + test('fromEvent maps every FileSystemEvent.type to a wire kind', () { + final dir = Directory.systemTemp; + FileSystemEvent ev(int type) => switch (type) { + FileSystemEvent.create => FileSystemCreateEvent('${dir.path}/f', false), + FileSystemEvent.delete => FileSystemDeleteEvent('${dir.path}/f', false), + FileSystemEvent.modify => FileSystemModifyEvent('${dir.path}/f', false, false), + FileSystemEvent.move => FileSystemMoveEvent('${dir.path}/f', false, '${dir.path}/g'), + _ => FileSystemModifyEvent('${dir.path}/f', false, false), + }; + expect(FileChangeKind.fromEvent(ev(FileSystemEvent.create)), FileChangeKind.created); + expect(FileChangeKind.fromEvent(ev(FileSystemEvent.delete)), FileChangeKind.deleted); + expect(FileChangeKind.fromEvent(ev(FileSystemEvent.modify)), FileChangeKind.modified); + expect(FileChangeKind.fromEvent(ev(FileSystemEvent.move)), FileChangeKind.renamed); + }); + + test('wire getter is the enum name', () { + expect(FileChangeKind.created.wire, 'created'); + expect(FileChangeKind.deleted.wire, 'deleted'); + expect(FileChangeKind.modified.wire, 'modified'); + expect(FileChangeKind.renamed.wire, 'renamed'); + }); + }); + + group('FileChange', () { + test('toJson encodes kind / path / isDirectory', () { + const c = FileChange(kind: FileChangeKind.modified, path: 'a/b.txt', isDirectory: false); + final j = c.toJson(); + expect(j['kind'], 'modified'); + expect(j['path'], 'a/b.txt'); + expect(j['isDirectory'], isFalse); + }); + }); + + group('FileWatcher', () { + late Directory sandbox; + late FileWatcher watcher; + + setUp(() async { + sandbox = await Directory.systemTemp.createTemp('clide-watcher-'); + watcher = FileWatcher(root: sandbox, ignore: IgnoreSet.builtin()); + }); + + tearDown(() async { + await watcher.stop(); + if (sandbox.existsSync()) sandbox.deleteSync(recursive: true); + }); + + test('emits a created event when a file is added under root', () async { + await watcher.start(); + final received = []; + final sub = watcher.stream.listen(received.add); + addTearDown(sub.cancel); + // Give inotify a moment to settle, then create a file. + await Future.delayed(const Duration(milliseconds: 50)); + await File('${sandbox.path}/new.txt').writeAsString('hi'); + await Future.delayed(const Duration(milliseconds: 200)); + expect(received.any((c) => c.path == 'new.txt'), isTrue); + }); + + test('filters ignored paths', () async { + await watcher.start(); + final received = []; + final sub = watcher.stream.listen(received.add); + addTearDown(sub.cancel); + // .dart_tool/ is in the builtin ignore set. + await Future.delayed(const Duration(milliseconds: 50)); + final dt = Directory('${sandbox.path}/.dart_tool')..createSync(); + await File('${dt.path}/hidden').writeAsString('x'); + await Future.delayed(const Duration(milliseconds: 200)); + expect(received.any((c) => c.path.startsWith('.dart_tool')), isFalse); + }); + + test('second start() is a no-op (idempotent)', () async { + await watcher.start(); + await watcher.start(); + }); + + test('stop() cancels the subscription and closes the stream', () async { + await watcher.start(); + await watcher.stop(); + // A second stop is also safe. + await watcher.stop(); + }); + }); +} diff --git a/test/ipc/errno_mapping_test.dart b/test/ipc/errno_mapping_test.dart index cc90f25d..77bac0b0 100644 --- a/test/ipc/errno_mapping_test.dart +++ b/test/ipc/errno_mapping_test.dart @@ -51,5 +51,24 @@ void main() { expect(err.message, contains('kernel exploded')); expect(err.message, isNot(contains('errno=999'))); }); + + test('ENOTDIR → user_error', () { + final e = errnoToIpcError(errno: PosixErrno.enotdir, op: 'files.ls', target: '/x'); + expect(e.kind, IpcErrorKind.userError); + expect(e.message, contains('not a directory')); + }); + + test('ENOMEM → tool_error', () { + final e = errnoToIpcError(errno: PosixErrno.enomem, op: 'pty.spawn'); + expect(e.kind, IpcErrorKind.toolError); + expect(e.message, contains('out of memory')); + }); + + test('EAGAIN → tool_error with retry hint', () { + final e = errnoToIpcError(errno: PosixErrno.eagain, op: 'pty.read'); + expect(e.kind, IpcErrorKind.toolError); + expect(e.message, contains('temporarily unavailable')); + expect(e.hint, contains('retry')); + }); }); } diff --git a/test/panes/registry_test.dart b/test/panes/registry_test.dart index 119c5f8f..354c3b6d 100644 --- a/test/panes/registry_test.dart +++ b/test/panes/registry_test.dart @@ -103,4 +103,17 @@ void main() { expect(pane.toJson()['kind'], 'claude'); }); }); + + group('RecordingEventSink filters', () { + test('ofSubsystem narrows events to a single subsystem', () { + final s = RecordingEventSink(); + final ts = DateTime.now().toUtc(); + s.emit(IpcEvent(subsystem: 'pane', kind: 'spawned', timestamp: ts, data: const {})); + s.emit(IpcEvent(subsystem: 'git', kind: 'changed', timestamp: ts, data: const {})); + s.emit(IpcEvent(subsystem: 'pane', kind: 'closed', timestamp: ts, data: const {})); + expect(s.ofSubsystem('pane'), hasLength(2)); + expect(s.ofSubsystem('git'), hasLength(1)); + expect(s.ofSubsystem('files'), isEmpty); + }); + }); }