dispose-safe teardown for KeymapService + ClaudeConfig; cover the
claude extension lifecycle make test-integration failed at widget-tree finalization: the palette's dispose() clears its scope flag, which during app teardown runs AFTER KernelServices.dispose() has disposed the KeymapService — notifyListeners asserted. Scope-flag mutations now use the same fire-and-forget guard SettingsStore established. Same family in ClaudeConfig: activation's unawaited load() could notify (and start watchers on) a disposed notifier when a teardown raced it. The claude extension's activation lifecycle and command success paths are now exercised end-to-end through the kernel fixture — the file entered the coverage denominator with the T-391 failure-path tests, so per the ratchet discipline the rest of it gets covered too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -253,6 +253,7 @@ class ClaudeConfig extends ChangeNotifier {
|
|||||||
_version = _parseVersion(await _guard(_versionRunner));
|
_version = _parseVersion(await _guard(_versionRunner));
|
||||||
await _readProbeCache();
|
await _readProbeCache();
|
||||||
await _loadDiskConfig();
|
await _loadDiskConfig();
|
||||||
|
if (_disposed) return; // activation fired-and-forgot; teardown won
|
||||||
_startWatchers();
|
_startWatchers();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -296,8 +297,14 @@ class ClaudeConfig extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set when [dispose] runs. The fire-and-forget [load] from extension
|
||||||
|
/// activation checks this so a teardown racing an in-flight load can't
|
||||||
|
/// notify (or start watchers on) a disposed notifier.
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
_stopWatching();
|
_stopWatching();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,20 +167,37 @@ class KeymapService extends ChangeNotifier {
|
|||||||
return km.match(sequence, _scope).exact;
|
return km.match(sequence, _scope).exact;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Scope-flag producers clear their flags from widget dispose() — which
|
||||||
|
/// during app teardown runs AFTER KernelServices.dispose() has disposed
|
||||||
|
/// this notifier. Tolerate that ordering instead of asserting (the same
|
||||||
|
/// fire-and-forget pattern SettingsStore uses).
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _safeNotify() {
|
||||||
|
if (_disposed) return;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// Set a named scope flag. Producers should call this when their
|
/// Set a named scope flag. Producers should call this when their
|
||||||
/// state changes so when-clauses re-evaluate correctly. Notifies
|
/// state changes so when-clauses re-evaluate correctly. Notifies
|
||||||
/// listeners when the value actually changes.
|
/// listeners when the value actually changes.
|
||||||
void setScopeFlag(String name, bool value) {
|
void setScopeFlag(String name, bool value) {
|
||||||
if (_scope[name] == value) return;
|
if (_scope[name] == value) return;
|
||||||
_scope[name] = value;
|
_scope[name] = value;
|
||||||
notifyListeners();
|
_safeNotify();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear a named scope flag.
|
/// Clear a named scope flag.
|
||||||
void clearScopeFlag(String name) {
|
void clearScopeFlag(String name) {
|
||||||
if (!_scope.containsKey(name)) return;
|
if (!_scope.containsKey(name)) return;
|
||||||
_scope.remove(name);
|
_scope.remove(name);
|
||||||
notifyListeners();
|
_safeNotify();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Switch presets. Persists the new preset name to settings and
|
/// Switch presets. Persists the new preset name to settings and
|
||||||
|
|||||||
@@ -2,14 +2,23 @@
|
|||||||
/// exit-code contract — a failure is an ERROR envelope (non-zero CLI
|
/// exit-code contract — a failure is an ERROR envelope (non-zero CLI
|
||||||
/// exit), never `ok` with an `error` field a script can't detect.
|
/// exit), never `ok` with an `error` field a script can't detect.
|
||||||
/// `clide claude.agent.set-permission-mode bogus` exited 0 before this.
|
/// `clide claude.agent.set-permission-mode bogus` exited 0 before this.
|
||||||
|
/// Plus the activation lifecycle + command success paths.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'package:clide/builtin/claude/src/activity_cluster.dart' show kActivityFoldLevelKey;
|
||||||
|
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/extension.dart';
|
||||||
|
import 'package:clide/builtin/claude/src/session_orchestrator.dart' show activeSessionOrchestrator;
|
||||||
import 'package:clide/clide.dart';
|
import 'package:clide/clide.dart';
|
||||||
import 'package:clide/extension/extension.dart';
|
import 'package:clide/extension/extension.dart';
|
||||||
|
import 'package:clide/kernel/kernel.dart';
|
||||||
|
import 'package:clide/src/daemon/image_commands.dart' show imageShowChannel;
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import '../../helpers/kernel_fixture.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized(); // GlobalKey lookups in handlers
|
||||||
final ext = ClaudeExtension();
|
final ext = ClaudeExtension();
|
||||||
CommandContribution cmd(String id) => ext.contributions.whereType<CommandContribution>().firstWhere((c) => c.id == id);
|
CommandContribution cmd(String id) => ext.contributions.whereType<CommandContribution>().firstWhere((c) => c.id == id);
|
||||||
|
|
||||||
@@ -44,4 +53,96 @@ void main() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 Future<void>.delayed(Duration.zero);
|
||||||
|
// Nothing to assert beyond "no throw" — there is no conversation to
|
||||||
|
// receive the card and the CLI already acked at publish time.
|
||||||
|
});
|
||||||
|
|
||||||
|
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 Future<void>.delayed(Duration.zero);
|
||||||
|
f.services.events.emit(const ProjectOpened(path: '/repo-one'));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
f.services.events.emit(const ProjectOpened(path: '/repo-two'));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user