Files
clide/test/daemon/claude_account_commands_test.dart
T
jpmschweitzerandClaude Opus 4.8 34b9690005 feat(claude): respawn panes on account set/unset + safe --purge (T-480 part 2)
The extension consumer for the per-repo account verbs, making set/unset
fully functional. The Claude extension subscribes to accountActionChannel:

- set / unset → ClaudeSessionOrchestrator.respawnForWorkspace(cwd): closes the
  workspace's solo sessions (awaiting real process death, T-437) and re-spawns
  each on the same id with --resume, so the conversation continues under the
  newly-bound CLAUDE_CONFIG_DIR (resolved at spawn by agentBootstrap). Team and
  forked sessions are skipped — re-joining the broker / re-forking on an account
  swap is out of scope; they adopt the account on their next natural spawn.
- remove --purge → deletes the config dir behind isPurgeableAccountDir, a strict
  guard that only ever removes a ~/.claude-* directory that is a direct child of
  $HOME. The purge payload now carries the dir (the account is gone from the
  registry by publish time).

login still only publishes its action — spawning the `claude login` terminal
pane needs argv+env terminal-pane support and is split to T-485.

Covered: respawnForWorkspace (respawn solo, skip fork/other-repo) and the purge
guard's accept/reject matrix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 21:18:49 +02:00

157 lines
5.8 KiB
Dart

/// Tests for `claude account …` (T-480, epic T-476; D-6 CLI parity). Verifies
/// each verb's registry effect, the published action payloads, and honest
/// userErrors — against a fake AccountStore so the handler stays Flutter-free
/// (runs under `dart test`).
library;
import 'package:clide/clide.dart';
import 'package:clide/src/daemon/claude_account_commands.dart';
import 'package:test/test.dart';
class _FakeStore implements AccountStore {
final _accounts = <({String name, String dir})>[];
final _bindings = <String, String>{}; // cwd → account name
List<String> detected = const [];
@override
List<({String name, String dir})> get accounts => List.of(_accounts);
@override
String? boundAccountName(String cwd) => _bindings[cwd];
@override
Set<String> boundAccountNames() => _bindings.values.toSet();
@override
String defaultDirFor(String name) => '/home/u/.claude-$name';
@override
List<String> detectedDirs() => detected;
@override
Future<void> add(String name, String dir) async => _accounts.add((name: name, dir: dir));
@override
Future<void> remove(String name) async => _accounts.removeWhere((a) => a.name == name);
@override
Future<void> bind(String cwd, String name) async => _bindings[cwd] = name;
@override
Future<void> unbind(String cwd) async => _bindings.remove(cwd);
}
void main() {
late _FakeStore store;
late List<({String publisher, String channel, Map<String, Object?> data})> published;
late DaemonDispatcher d;
void wire({String? cwd = '/repo', bool withStore = true}) {
store = _FakeStore();
published = [];
d = DaemonDispatcher();
registerClaudeAccountCommands(
d,
() => withStore ? store : null,
publisher: () =>
(p, c, data) => published.add((publisher: p, channel: c, data: data)),
workspaceCwd: () => cwd,
);
}
Future<IpcResponse> run(List<String> positional, {Map<String, Object?>? flags}) =>
d.dispatch(IpcRequest(id: '1', cmd: 'claude.account', args: {'positional': positional, 'flags': ?flags}));
test('registered on the dispatcher → shows in capabilities (T-480 #1)', () async {
wire();
final caps = await d.dispatch(IpcRequest(id: 'c', cmd: 'capabilities', args: const {}));
expect((caps.data['commands'] as Map).containsKey('claude.account'), isTrue);
});
group('add', () {
test('registers with the default dir, then is idempotent, then conflicts', () async {
wire();
var r = await run(['add', 'work']);
expect(r.ok, isTrue, reason: r.error?.message);
expect(r.data, {'name': 'work', 'dir': '/home/u/.claude-work', 'created': true});
r = await run(['add', 'work']); // same args → no-op
expect(r.data['created'], isFalse);
r = await run(['add', 'work'], flags: {'dir': '/other'}); // conflicting dir
expect(r.ok, isFalse);
expect(r.error?.message, contains('already exists'));
});
test('--dir uses the explicit path; missing name errors', () async {
wire();
expect((await run(['add', 'work'], flags: {'dir': '/custom'})).data['dir'], '/custom');
expect((await run(['add'])).ok, isFalse);
});
});
test('list returns accounts + this workspace binding + detected dirs', () async {
wire();
store.detected = ['/home/u/.claude-old'];
await run(['add', 'work']);
await run(['set', 'work']);
final r = await run(['list']);
expect(r.data['accounts'], [
{'name': 'work', 'dir': '/home/u/.claude-work'},
]);
expect(r.data['boundAccount'], 'work');
expect(r.data['detected'], ['/home/u/.claude-old']);
});
test('set binds + publishes; unknown account errors', () async {
wire();
expect((await run(['set', 'nope'])).ok, isFalse);
await run(['add', 'work']);
final r = await run(['set', 'work']);
expect(r.ok, isTrue, reason: r.error?.message);
expect(store.boundAccountName('/repo'), 'work');
expect(published.single.channel, accountActionChannel);
expect(published.single.data, {'action': 'set', 'name': 'work', 'cwd': '/repo'});
});
test('unset clears the binding + publishes the previous account', () async {
wire();
await run(['add', 'work']);
await run(['set', 'work']);
published.clear();
final r = await run(['unset']);
expect(r.ok, isTrue);
expect(store.boundAccountName('/repo'), isNull);
expect(published.single.data, {'action': 'unset', 'cwd': '/repo', 'previous': 'work'});
});
test('remove refuses while bound, succeeds once unset; --purge publishes', () async {
wire();
await run(['add', 'work']);
await run(['set', 'work']);
expect((await run(['remove', 'work'])).ok, isFalse, reason: 'bound → refused');
await run(['unset']);
published.clear();
final r = await run(['remove', 'work'], flags: {'purge': true});
expect(r.ok, isTrue, reason: r.error?.message);
expect(store.accounts, isEmpty);
expect(published.single.data, {'action': 'purge', 'name': 'work', 'dir': '/home/u/.claude-work'});
});
test('login publishes a login action with the dir; unknown account errors', () async {
wire();
expect((await run(['login', 'work'])).ok, isFalse);
await run(['add', 'work']);
published.clear();
final r = await run(['login', 'work']);
expect(r.ok, isTrue, reason: r.error?.message);
expect(published.single.data, {'action': 'login', 'name': 'work', 'dir': '/home/u/.claude-work'});
});
test('unknown action → userError listing the verbs', () async {
wire();
final r = await run(['frobnicate']);
expect(r.ok, isFalse);
expect(r.error?.message, contains('unknown account action'));
});
test('degrades to a clear error when no registry is wired', () async {
wire(withStore: false);
final r = await run(['list']);
expect(r.ok, isFalse);
expect(r.error?.message, contains('unavailable'));
});
}