/// Tests for `env path …` (D-106, T-511; D-6 CLI parity). Verifies each verb's /// store effect, the published mutation payloads, the capture diff, and honest /// userErrors — against a fake PathPresetStore so the handler stays /// Flutter-free (runs under `dart test`). library; import 'package:clide/clide.dart'; import 'package:clide/src/daemon/env_path_commands.dart'; import 'package:test/test.dart'; class _FakeStore implements PathPresetStore { final Map> byCwd = {}; @override List dirsFor(String cwd) => List.of(byCwd[cwd] ?? const []); @override Future setFor(String cwd, List dirs) async { if (dirs.isEmpty) { byCwd.remove(cwd); } else { byCwd[cwd] = List.of(dirs); } } } void main() { late _FakeStore store; late List<({String publisher, String channel, Map data})> published; late DaemonDispatcher d; late Set existingDirs; String? loginPath; var processPath = '/usr/bin:/bin'; void wire({String? cwd = '/repo', bool withStore = true, String? home = '/home/u'}) { store = _FakeStore(); published = []; existingDirs = {}; loginPath = null; processPath = '/usr/bin:/bin'; d = DaemonDispatcher(); registerEnvPathCommands( d, () => withStore ? store : null, publisher: () => (p, c, data) => published.add((publisher: p, channel: c, data: data)), workspaceCwd: () => cwd, home: () => home, dirExists: (dir) => existingDirs.contains(dir), loginPath: () => loginPath, processPath: () => processPath, ); } Future run(List positional) => d.dispatch(IpcRequest(id: '1', cmd: 'env.path', args: {'positional': positional})); test('registered on the dispatcher → shows in capabilities', () async { wire(); final caps = await d.dispatch(IpcRequest(id: 'c', cmd: 'capabilities', args: const {})); expect((caps.data['commands'] as Map).containsKey('env.path'), isTrue); }); group('set', () { test('replaces the preset, de-duplicated, and publishes', () async { wire(); existingDirs.add('/opt/go/bin'); final r = await run(['set', '/opt/go/bin', '/x', '/opt/go/bin']); expect(r.ok, isTrue, reason: r.error?.message); expect(r.data['dirs'], ['/opt/go/bin', '/x']); expect(r.data['missing'], ['/x'], reason: 'nonexistent dirs warn, never error'); expect(store.byCwd['/repo'], ['/opt/go/bin', '/x']); expect(published.single.channel, envPathChannel); expect(published.single.data['action'], 'set'); expect(published.single.data['dirs'], ['/opt/go/bin', '/x']); }); test('expands ~/ against HOME and strips trailing slashes', () async { wire(); final r = await run(['set', '~/go/bin/', '~']); expect(r.ok, isTrue, reason: r.error?.message); expect(r.data['dirs'], ['/home/u/go/bin', '/home/u']); }); test('relative paths error; empty set points at clear', () async { wire(); final rel = await run(['set', 'go/bin']); expect(rel.ok, isFalse); expect(rel.error?.message, contains('not an absolute path')); final empty = await run(['set']); expect(empty.ok, isFalse); expect(empty.error?.hint, contains('clear')); }); test('add and remove apply the same guards as set (relative + separator)', () async { wire(); for (final action in ['set', 'add', 'remove']) { final rel = await run([action, 'go/bin']); expect(rel.ok, isFalse, reason: '$action relative'); expect(rel.error?.message, contains('not an absolute path')); final sep = await run([action, '/a:']); expect(sep.ok, isFalse, reason: '$action separator'); expect(sep.error?.message, contains('PATH separator')); } expect(store.byCwd, isEmpty, reason: 'nothing was written'); }); test('~ with no HOME errors instead of storing a broken entry', () async { wire(home: null); final r = await run(['set', '~/go/bin']); expect(r.ok, isFalse); expect(r.error?.message, contains('cannot expand')); }); test('a leading-dash entry is rejected by the schema (T-104 guard)', () async { wire(); final r = await d.dispatch( IpcRequest( id: '1', cmd: 'env.path', args: { 'positional': ['set'], 'flags': {'dirs': '--evil'}, }, ), ); expect(r.ok, isFalse); }); }); group('add / remove / clear', () { test('add appends without duplicating; remove drops; clear empties the key', () async { wire(); await run(['set', '/a']); published.clear(); var r = await run(['add', '/b', '/a']); expect(r.data['dirs'], ['/a', '/b']); r = await run(['remove', '/a']); expect(r.data['dirs'], ['/b']); r = await run(['clear']); expect(r.ok, isTrue); expect(r.data['dirs'], isEmpty); expect(store.byCwd.containsKey('/repo'), isFalse, reason: 'empty preset removes the key'); expect(published.map((p) => p.data['action']), ['add', 'remove', 'clear']); }); test('add and remove require a ; removing an absent entry errors', () async { wire(); expect((await run(['add'])).ok, isFalse); expect((await run(['remove'])).ok, isFalse); final r = await run(['remove', '/ghost']); expect(r.ok, isFalse); expect(r.error?.message, contains('no preset entry matches')); }); }); group('list', () { test('returns the preset, the missing subset, and the effective PATH preview', () async { wire(); existingDirs.add('/a'); await run(['set', '/a', '/gone']); final r = await run(['list']); expect(r.data['root'], '/repo'); expect(r.data['dirs'], ['/a', '/gone']); expect(r.data['missing'], ['/gone']); expect(r.data['effectivePath'], startsWith('/a:/gone:'), reason: 'preset prepends the resolved PATH'); }); }); group('capture', () { test('suggests login-shell dirs the process PATH lacks, minus the preset', () async { wire(); loginPath = '/brew/bin:/usr/bin:/opt/go/bin:/bin'; await run(['set', '/brew/bin']); final r = await run(['capture']); expect(r.ok, isTrue, reason: r.error?.message); expect(r.data['suggested'], ['/opt/go/bin']); expect(r.data['loginShellPath'], loginPath); expect(r.data['processPath'], '/usr/bin:/bin'); }); test('no login-shell probe → empty suggestions with an honest note', () async { wire(); final r = await run(['capture']); expect(r.data['suggested'], isEmpty); expect(r.data['note'], contains('unavailable')); }); }); test('un-injected production seams (HOME / dir probe / process PATH) work', () async { store = _FakeStore(); d = DaemonDispatcher(); registerEnvPathCommands(d, () => store, workspaceCwd: () => '/repo'); final set = await d.dispatch( IpcRequest( id: '1', cmd: 'env.path', args: const { 'positional': ['set', '/definitely-missing-dir'], }, ), ); expect(set.ok, isTrue, reason: set.error?.message); expect(set.data['missing'], ['/definitely-missing-dir'], reason: 'real Directory probe ran'); final cap = await d.dispatch( IpcRequest( id: '2', cmd: 'env.path', args: const { 'positional': ['capture'], }, ), ); expect(cap.ok, isTrue); expect(cap.data['processPath'], isA()); }); test('no workspace / no store / unknown action error clearly', () async { wire(cwd: null); expect((await run(['list'])).error?.message, contains('no workspace')); wire(withStore: false); expect((await run(['list'])).error?.message, contains('unavailable')); wire(); final r = await run(['frobnicate']); expect(r.ok, isFalse); expect(r.error?.hint, contains('list | set | add | remove | clear | capture')); }); }