Files
clide/test/builtin/claude/extension_commands_test.dart
jpmschweitzerandClaude Opus 4.8 1d9000988d test(draw): cover the 2.9.0 drawing-card additions to clear the coverage floor
The drawing-card feature batch (icon/image/compare/graph/d2 cards, --stdin,
tool resolution) added widget + wiring code that dipped total coverage under
the 95% floor — surfaced by `make release` (push-check skips coverage). Cover
the gaps: ProblemsController.refresh, every SVG shape-type bbox + the style
vocabulary, _spawnD2 via a real /bin/cat, the icon-show bus path + error
branches, and quad/arc/close marker paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 22:00:56 +02:00

195 lines
9.4 KiB
Dart

/// T-391: the claude builtin's command handlers must honor the D-6
/// exit-code contract — a failure is an ERROR envelope (non-zero CLI
/// exit), never `ok` with an `error` field a script can't detect.
/// `clide claude.agent.set-permission-mode bogus` exited 0 before this.
/// Plus the activation lifecycle + command success paths.
library;
import 'package:clide/builtin/claude/src/activity_cluster.dart' show kActivityFoldLevelKey;
import 'package:clide/builtin/claude/src/session_defaults.dart' show kDefaultEffortKey, kDefaultModelKey, kDefaultPermissionModeKey;
import 'package:clide/builtin/claude/src/claude_config.dart' show activeClaudeConfig;
import 'package:clide/builtin/claude/src/extension.dart';
import 'package:clide/builtin/claude/src/session_orchestrator.dart' show activeSessionOrchestrator;
import 'package:clide/clide.dart';
import 'package:clide/extension/extension.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/src/daemon/icon_commands.dart' show iconShowChannel;
import 'package:clide/src/daemon/image_commands.dart' show imageShowChannel;
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized(); // GlobalKey lookups in handlers
final ext = ClaudeExtension();
CommandContribution cmd(String id) => ext.contributions.whereType<CommandContribution>().firstWhere((c) => c.id == id);
group('failure paths return error envelopes (T-391, D-6)', () {
// (command id, args, expected error kind)
final cases = <(String, List<String>, String)>[
('claude.agent.show', [], IpcErrorKind.userError),
('claude.agent.hide', [], IpcErrorKind.userError),
('claude.agent.close', [], IpcErrorKind.userError),
('claude.agent.mute', [], IpcErrorKind.userError),
('claude.agent.unmute', [], IpcErrorKind.userError),
('claude.agent.inject-message', [], IpcErrorKind.userError),
('claude.agent.inject-message', ['some-id'], IpcErrorKind.userError),
('claude.agent.set-permission-mode', [], IpcErrorKind.userError),
('claude.agent.set-permission-mode', ['some-id'], IpcErrorKind.userError),
('claude.agent.set-permission-mode', ['some-id', 'bogus'], IpcErrorKind.userError),
('claude.mode.cycle', [], IpcErrorKind.notFound),
('claude.task.reassign', [], IpcErrorKind.userError),
('claude.team-chat.post', [], IpcErrorKind.userError),
('claude.agent.fork', [], IpcErrorKind.userError),
// No orchestrator is wired in this test (extension not activated),
// so a fork with a source id fails as unavailable tooling.
('claude.agent.fork', ['some-id'], IpcErrorKind.toolError),
];
for (final (id, args, kind) in cases) {
test('$id ${args.isEmpty ? '(no args)' : args.join(' ')}$kind', () async {
final r = await cmd(id).run(args);
expect(r.ok, isFalse, reason: 'a failure must not report ok');
expect(r.error!.kind, kind);
expect(r.error!.code, isNot(0), reason: 'the CLI must exit non-zero');
});
}
});
group('activated lifecycle + success paths', () {
late KernelFixture f;
setUp(() async {
f = await KernelFixture.create();
f.services.extensions.register(ClaudeExtension());
await f.services.extensions.activate('builtin.claude');
expect(f.services.extensions.isActivated('builtin.claude'), isTrue, reason: f.services.extensions.failedExtensions.toString());
});
tearDown(() async {
await f.services.extensions.deactivate('builtin.claude');
await f.dispose();
});
Future<IpcResponse> run(String command, [List<String> args = const []]) {
final c = f.services.commands.get(command);
expect(c, isNotNull, reason: '$command should be registered after activation');
return c!.run(args);
}
test('roster verbs succeed once the orchestrator is wired (no-op on unknown ids)', () async {
for (final verb in ['claude.agent.show', 'claude.agent.hide', 'claude.agent.close', 'claude.agent.mute', 'claude.agent.unmute']) {
final r = await run(verb, ['no-such-session']);
expect(r.ok, isTrue, reason: '$verb is idempotent on unknown ids');
}
final inject = await run('claude.agent.inject-message', ['no-such-session', 'hello']);
expect(inject.ok, isTrue);
final mode = await run('claude.agent.set-permission-mode', ['no-such-session', 'plan']);
expect(mode.ok, isTrue);
expect(mode.data['mode'], 'plan');
});
test('claude.new-secondary and kill-all-sessions succeed with no live panes', () async {
expect((await run('claude.new-secondary')).ok, isTrue);
final killed = await run('claude.kill-all-sessions');
expect(killed.ok, isTrue);
expect(killed.data['status'], 'killed');
});
test('claude.activity.fold-level cycles and persists the setting (T-235)', () async {
final r1 = await run('claude.activity.fold-level');
expect(r1.ok, isTrue);
final first = r1.data['foldLevel'] as String;
expect(f.services.settings.get<String>(kActivityFoldLevelKey), first);
final r2 = await run('claude.activity.fold-level');
expect(r2.data['foldLevel'], isNot(first), reason: 'the level advances each call');
});
test('claude.team-chat.open and .post succeed', () async {
expect((await run('claude.team-chat.open')).ok, isTrue);
final broadcast = await run('claude.team-chat.post', ['hello', 'team']);
expect(broadcast.ok, isTrue);
final directed = await run('claude.team-chat.post', ['@tyre', 'hello', 'you']);
expect(directed.ok, isTrue);
expect(directed.data['to'], 'tyre');
});
test('claude.session-storage degrades cleanly when files.root is unavailable', () async {
// The fixture IPC has no files.root stub → the handler bails out ok
// without opening the dialog.
final r = await run('claude.session-storage');
expect(r.ok, isTrue);
});
test('an image-show message with no live session is dropped silently (T-249)', () async {
f.services.messages.publish('test', imageShowChannel, {'path': '/tmp/x.png'});
f.services.messages.publish('test', imageShowChannel, {'path': ''});
await pumpEventQueue();
// Nothing to assert beyond "no throw" — there is no conversation to
// receive the card and the CLI already acked at publish time.
});
test('an icon-show message parses entries, dropped silently with no session (T-313)', () async {
f.services.messages.publish('test', iconShowChannel, {
'entries': [
{'codepoint': 0xe2a4, 'name': 'gear', 'label': 'Settings', 'description': 'd', 'color': '#fff'},
{'name': 'noCodepoint'}, // skipped — no int codepoint
],
'color': '#888',
});
f.services.messages.publish('test', iconShowChannel, {'entries': const []}); // empty → early return
f.services.messages.publish('test', iconShowChannel, {'entries': 'notalist'}); // not a list → early return
await pumpEventQueue();
// No live session — the entry parse ran; nothing to inject into.
});
test('a project switch closes sessions that belong to the old root (T-269)', () async {
f.services.events.emit(const ProjectOpened(path: '/repo-one'));
await pumpEventQueue();
f.services.events.emit(const ProjectOpened(path: '/repo-one'));
await pumpEventQueue();
f.services.events.emit(const ProjectOpened(path: '/repo-two'));
await pumpEventQueue();
// No live sessions in this fixture — the sweep runs over an empty set.
expect(activeSessionOrchestrator!.sessions, isEmpty);
});
test('deactivate clears the builtin-owned singletons', () async {
expect(activeSessionOrchestrator, isNotNull);
await f.services.extensions.deactivate('builtin.claude');
expect(activeSessionOrchestrator, isNull);
expect(activeClaudeConfig, isNull);
});
});
group('Activity settings category (T-453)', () {
final category = ext.contributions.whereType<SettingsCategoryContribution>().firstWhere((c) => c.id == 'activity').category;
test('contributes an Activity category with the fold-level field', () {
expect(category.title, 'Activity');
final field = category.sections.expand((s) => s.fields).firstWhere((f) => f.key == kActivityFoldLevelKey);
expect(field.kind, SettingsFieldKind.select);
expect(field.defaultValue, 'tools');
expect(field.options.map((o) => o.value), containsAll(['none', 'tools', 'thinking', 'everything']));
});
});
group('Claude settings category (T-457)', () {
final category = ext.contributions.whereType<SettingsCategoryContribution>().firstWhere((c) => c.id == 'claude').category;
test('contributes new-session default fields for model, effort, permission', () {
expect(category.title, 'Claude');
final fields = category.sections.expand((s) => s.fields).toList();
final keys = fields.map((f) => f.key).toSet();
expect(keys, containsAll([kDefaultModelKey, kDefaultEffortKey, kDefaultPermissionModeKey]));
// The new-session-default fields are selects with options; other sections
// (e.g. the Account custom controls, T-482) have their own field kinds.
final defaults = fields.where((f) => {kDefaultModelKey, kDefaultEffortKey, kDefaultPermissionModeKey}.contains(f.key));
for (final f in defaults) {
expect(f.kind, SettingsFieldKind.select);
expect(f.options, isNotEmpty);
}
});
});
}