/// Unit tests for [PaneRegistry]. /// /// Exercises spawn / list / write / resize / close against the real /// NativePty (posix_spawn via FFI). Events are captured via /// [RecordingEventSink]. library; import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:clide/clide.dart'; import 'package:clide/src/panes/registry.dart'; import 'package:test/test.dart'; import '../helpers/timeouts.dart'; void main() { if (!Platform.isLinux && !Platform.isMacOS) return; group('PaneRegistry', () { late RecordingEventSink sink; late PaneRegistry registry; setUp(() { sink = RecordingEventSink(); registry = PaneRegistry(events: sink); }); tearDown(() => registry.shutdown()); test('spawn → emits pane.spawned and lists the pane', () async { final pane = await registry.spawn(kind: PaneKind.terminal, argv: const ['/bin/echo', 'hi']); expect(pane.id, startsWith('p_')); expect(pane.kind, PaneKind.terminal); expect(registry.panes, contains(pane)); expect(sink.ofKind('pane.spawned'), hasLength(1)); final evt = sink.ofKind('pane.spawned').first; expect(evt.data['id'], pane.id); }); test('output events base64-encode the child bytes', tags: ['pty'], () async { // Subscribe to the sink stream BEFORE spawn so we don't miss // any pane.output events that arrive between spawn and listen. final buf = StringBuffer(); final got = Completer(); final sub = sink.stream.listen((e) { if (e.kind != 'pane.output') return; buf.write(utf8.decode(base64Decode(e.data['bytes_b64']! as String))); if (buf.toString().contains('hello-panes') && !got.isCompleted) { got.complete(buf.toString()); } }); addTearDown(sub.cancel); await registry.spawn( kind: PaneKind.terminal, // Child writes then lingers so the reader's poll has a wide // window to see POLLIN before HUP. argv: const ['/bin/sh', '-c', 'printf hello-panes; sleep 0.25'], ); final decoded = await got.future.timeout(ioTimeout, onTimeout: () => fail('pane.output never carried "hello-panes" within ${ioTimeout.inSeconds}s')); expect(decoded, contains('hello-panes')); expect(sink.ofKind('pane.output'), isNotEmpty); }); test('write + resize emit no spurious events, update state', () async { final pane = await registry.spawn(kind: PaneKind.terminal, argv: const ['/bin/cat']); final writeCount = registry.write(pane.id, utf8.encode('abc')); expect(writeCount, greaterThan(0)); registry.resize(pane.id, cols: 120, rows: 40); final resized = sink.ofKind('pane.resized').toList(); expect(resized, hasLength(1)); expect(resized.single.data['cols'], 120); expect(resized.single.data['rows'], 40); }); test('close is idempotent + emits pane.closed once', () async { final pane = await registry.spawn(kind: PaneKind.terminal, argv: const ['/bin/cat']); await registry.close(pane.id); await registry.close(pane.id); // second call: no-op expect(registry.get(pane.id), isNull); expect(sink.ofKind('pane.closed'), hasLength(1)); }); test('close on unknown id does nothing', () async { await registry.close('p_nonexistent'); expect(sink.ofKind('pane.closed'), isEmpty); }); test('claude kind round-trips on the wire', () async { final pane = await registry.spawn(kind: PaneKind.claude, argv: const ['/bin/sh', '-c', 'exit 0']); expect(pane.kind, PaneKind.claude); 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); }); }); }