Files
clide/test/panes/registry_test.dart
T
jpmschweitzerandClaude Fable 5 e1fea83868 feat(env): per-workspace PATH preset injected at spawn (T-511)
Implements D-106. The T-439 login-shell probe is a global heuristic
with a known hole — login-but-non-interactive shells skip ~/.bashrc,
so interactive-only additions (brew shellenv) never reach the agent's
Bash tool or terminal panes on a desktop launch. The preset is the
explicit per-repo layer on top: user-scope storage keyed by repo
identity (a linked worktree resolves through its gitdir pointer to the
main repo, so worktrees share the preset), prepended at spawn via the
PaneRegistry pathForSpawn hook and agentEnvDelta prependDirs — which
now exports PATH even when clide is already resolvable, closing the
gap where the hosted session inherited the sparse GUI PATH untouched.

CLI half: `clide env path list|set|add|remove|clear|capture` over an
injected Flutter-free store port; capture diffs the login-shell PATH
against the process PATH to suggest the dirs a desktop launch dropped.
Binary resolution (toolchain, supporter pins, bundled pql/git) stays
preset-blind per the D-92/T-98 fence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:40:48 +02:00

143 lines
5.2 KiB
Dart

/// 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<String>();
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');
});
test('pathForSpawn hook sets the child PATH per cwd (D-106)', tags: ['pty'], () async {
String? seenCwd;
final preset = PaneRegistry(
events: sink,
pathForSpawn: (cwd) {
seenCwd = cwd;
return '/preset-marker:/usr/bin:/bin';
},
);
addTearDown(preset.shutdown);
final buf = StringBuffer();
final got = Completer<void>();
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('/preset-marker') && !got.isCompleted) got.complete();
});
addTearDown(sub.cancel);
await preset.spawn(kind: PaneKind.terminal, argv: const ['/bin/sh', '-c', r'printf %s "$PATH"; sleep 0.25'], cwd: '/tmp');
await got.future.timeout(ioTimeout, onTimeout: () => fail('child PATH never carried the preset marker within ${ioTimeout.inSeconds}s'));
expect(seenCwd, '/tmp');
});
});
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);
});
});
}