Files
clide/test/builtin/claude/account_registry_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

153 lines
7.0 KiB
Dart

/// T-483: AccountRegistry — user-scope storage + typed reader/writer for the
/// per-repo Claude account epic (T-476). Persistence flows through a real
/// SettingsStore over a temp appDir, so these cover the round-trip (incl. a
/// reload from disk) without mocking the store.
library;
import 'dart:io';
import 'package:clide/builtin/claude/src/account_registry.dart';
import 'package:clide/kernel/src/settings.dart';
import 'package:clide/src/ipc/paths.dart' show canonicalWorkspaceKey, fnv1a64Hex;
import 'package:flutter_test/flutter_test.dart';
void main() {
late Directory appDir;
late SettingsStore store;
late AccountRegistry reg;
setUp(() async {
appDir = await Directory.systemTemp.createTemp('clide-accounts-');
store = SettingsStore(appDir: appDir);
await store.load();
reg = AccountRegistry(store);
});
tearDown(() {
store.dispose();
if (appDir.existsSync()) appDir.deleteSync(recursive: true);
});
group('account registry CRUD', () {
test('register + read back; replace by name; remove', () async {
expect(reg.accounts, isEmpty);
await reg.registerAccount('personal', '/home/u/.claude-personal');
await reg.registerAccount('work', '/home/u/.claude-work');
expect(reg.accounts, [const Account(name: 'personal', dir: '/home/u/.claude-personal'), const Account(name: 'work', dir: '/home/u/.claude-work')]);
expect(reg.accountByName('work')?.dir, '/home/u/.claude-work');
expect(reg.accountByName('nope'), isNull);
// Re-register by the same name replaces (no duplicate).
await reg.registerAccount('personal', '/home/u/.claude-personal2');
expect(reg.accounts.where((a) => a.name == 'personal'), hasLength(1));
expect(reg.accountByName('personal')?.dir, '/home/u/.claude-personal2');
await reg.removeAccount('personal');
expect(reg.accountByName('personal'), isNull);
expect(reg.accounts, hasLength(1));
});
test('the accounts list survives a reload from disk', () async {
await reg.registerAccount('work', '/home/u/.claude-work');
// Fresh store over the same appDir, loaded from the YAML on disk.
final store2 = SettingsStore(appDir: appDir);
await store2.load();
addTearDown(store2.dispose);
final reg2 = AccountRegistry(store2);
expect(reg2.accounts, [const Account(name: 'work', dir: '/home/u/.claude-work')]);
});
});
group('workspace binding', () {
test('bind resolves an account; unbind clears it; unknown name → null', () async {
await reg.registerAccount('work', '/home/u/.claude-work');
expect(reg.accountForWorkspace('/repo/a'), isNull);
await reg.bindWorkspace('/repo/a', 'work');
expect(reg.accountForWorkspace('/repo/a')?.dir, '/home/u/.claude-work');
// A binding to a removed account degrades to null (Claude default).
await reg.removeAccount('work');
expect(reg.accountForWorkspace('/repo/a'), isNull);
await reg.registerAccount('work', '/home/u/.claude-work');
expect(reg.accountForWorkspace('/repo/a')?.dir, '/home/u/.claude-work');
await reg.unbindWorkspace('/repo/a');
expect(reg.accountForWorkspace('/repo/a'), isNull);
});
test('workspace hash is the D-70 socket hash and ignores trailing slashes', () {
expect(AccountRegistry.workspaceHash('/repo/a'), AccountRegistry.workspaceHash('/repo/a/'));
expect(AccountRegistry.workspaceHash('/repo/a'), AccountRegistry.workspaceHash('/repo/a///'));
expect(AccountRegistry.workspaceHash('/repo/a'), isNot(AccountRegistry.workspaceHash('/repo/b')));
// Same algorithm/input as the socket path (single source of truth, D-70).
expect(AccountRegistry.workspaceHash('/repo/a'), fnv1a64Hex(canonicalWorkspaceKey('/repo/a')));
// The binding key is namespaced under app.claude.account.<hash>.
expect(AccountRegistry.bindingKey('/repo/a'), 'app.claude.account.${AccountRegistry.workspaceHash('/repo/a')}');
});
test('two workspaces bind independently', () async {
await reg.registerAccount('personal', '/p');
await reg.registerAccount('work', '/w');
await reg.bindWorkspace('/repo/a', 'personal');
await reg.bindWorkspace('/repo/b', 'work');
expect(reg.accountForWorkspace('/repo/a')?.name, 'personal');
expect(reg.accountForWorkspace('/repo/b')?.name, 'work');
});
test('boundName is the raw binding (survives account removal); boundAccountNames lists in-use names', () async {
await reg.registerAccount('personal', '/p');
await reg.registerAccount('work', '/w');
expect(reg.boundName('/repo/a'), isNull);
expect(reg.boundAccountNames(), isEmpty);
await reg.bindWorkspace('/repo/a', 'personal');
await reg.bindWorkspace('/repo/b', 'work');
expect(reg.boundName('/repo/a'), 'personal');
expect(reg.boundAccountNames(), {'personal', 'work'});
// Removing the account leaves the raw binding; only resolution degrades.
await reg.removeAccount('personal');
expect(reg.boundName('/repo/a'), 'personal');
expect(reg.accountForWorkspace('/repo/a'), isNull);
});
});
group('bootstrap probe', () {
test('returns ~/.claude-* dirs that look like config dirs, skipping the rest', () async {
final home = await Directory.systemTemp.createTemp('clide-home-');
addTearDown(() => home.deleteSync(recursive: true));
// A config dir marked by .claude.json.
Directory('${home.path}/.claude-personal').createSync();
File('${home.path}/.claude-personal/.claude.json').writeAsStringSync('{}');
// A config dir marked by sessions/.
Directory('${home.path}/.claude-work/sessions').createSync(recursive: true);
// A .claude-* dir that is NOT a config dir (no marker) — skipped.
Directory('${home.path}/.claude-empty').createSync();
// The real ~/.claude (no `-suffix`) — not a candidate.
Directory('${home.path}/.claude').createSync();
// An unrelated dir — skipped.
Directory('${home.path}/projects').createSync();
final found = probeExistingAccountDirs(home.path);
expect(found.map((d) => d.name), ['personal', 'work']);
expect(found.first.dir, '${home.path}/.claude-personal');
});
test('missing home returns empty, mutates nothing', () {
expect(probeExistingAccountDirs('/no/such/home/${DateTime.now().microsecondsSinceEpoch}'), isEmpty);
});
});
group('purge guard (T-480)', () {
const home = '/home/u';
test('accepts ~/.claude-* direct children only, rejects everything else', () {
expect(isPurgeableAccountDir('/home/u/.claude-work', home), isTrue);
expect(isPurgeableAccountDir('/home/u/.claude', home), isFalse); // the real config dir
expect(isPurgeableAccountDir('/home/u/projects', home), isFalse); // not .claude-*
expect(isPurgeableAccountDir('/home/u/sub/.claude-work', home), isFalse); // nested, not a direct child
expect(isPurgeableAccountDir('/etc/.claude-work', home), isFalse); // elsewhere on disk
expect(isPurgeableAccountDir('/home/u/.claude-work', ''), isFalse); // no home → never delete
});
});
}