feat(log): live verbosity toggle — dock Level chip + clide log level (T-433)
The boot-time toggle (CLIDE_LOG / app.log.level) existed; this makes it adjustable at runtime, from the two surfaces that fit — D-6 parity — and NOT a Claude-composer slash command (log verbosity isn't a Claude-session concept). - Output dock Level chip: was a view-only filter; now also drives the kernel Logger.minLevel and persists app.log.level, so the choice is real (changes what's captured) and survives restart. Initialized from the logger's current level so it reflects a CLI change. A null callback keeps it a pure view filter (tests / no kernel). - `clide log level [<level>]` (lib/src/daemon/log_commands.dart): no arg reports the level + vocabulary; a valid level sets the live logger and persists; unknown → bad_arg (code 64), logger untouched. Wired in buildDispatcher with the kernel logger + settings (captured post-boot). Tested: the command (get/set/case-insensitive/bad-arg, live + persist) and the controller (initial level, callback fires, same-level no-op, filter-only when unwired). Coverage gate 95.05%. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import 'package:clide/builtin/output/src/output_controller.dart';
|
||||
import 'package:clide/kernel/src/log.dart';
|
||||
import 'package:clide/kernel/src/log_ring.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('OutputController Level chip (T-433)', () {
|
||||
test('initialLevel sets the starting level (reflects the kernel logger)', () {
|
||||
final c = OutputController(LogRing(), initialLevel: LogLevel.warn);
|
||||
expect(c.minLevel, LogLevel.warn);
|
||||
c.dispose();
|
||||
});
|
||||
|
||||
test('defaults to debug when no initial level is given', () {
|
||||
final c = OutputController(LogRing());
|
||||
expect(c.minLevel, LogLevel.debug);
|
||||
c.dispose();
|
||||
});
|
||||
|
||||
test('setMinLevel updates the level, fires onMinLevelChanged + notifies', () {
|
||||
final changes = <LogLevel>[];
|
||||
var notified = 0;
|
||||
final c = OutputController(LogRing(), initialLevel: LogLevel.info, onMinLevelChanged: changes.add)..addListener(() => notified++);
|
||||
|
||||
c.setMinLevel(LogLevel.warn);
|
||||
expect(c.minLevel, LogLevel.warn);
|
||||
expect(changes, [LogLevel.warn]); // the chip drove the kernel + persist hook
|
||||
expect(notified, 1);
|
||||
c.dispose();
|
||||
});
|
||||
|
||||
test('setting the same level is a no-op (no kernel write, no notify)', () {
|
||||
final changes = <LogLevel>[];
|
||||
var notified = 0;
|
||||
final c = OutputController(LogRing(), initialLevel: LogLevel.info, onMinLevelChanged: changes.add)..addListener(() => notified++);
|
||||
|
||||
c.setMinLevel(LogLevel.info);
|
||||
expect(changes, isEmpty);
|
||||
expect(notified, 0);
|
||||
c.dispose();
|
||||
});
|
||||
|
||||
test('with no callback the chip is a pure view filter (no throw)', () {
|
||||
final c = OutputController(LogRing(), initialLevel: LogLevel.info);
|
||||
expect(() => c.setMinLevel(LogLevel.error), returnsNormally);
|
||||
expect(c.minLevel, LogLevel.error);
|
||||
c.dispose();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:clide/kernel/src/log.dart';
|
||||
import 'package:clide/src/daemon/dispatcher.dart';
|
||||
import 'package:clide/src/daemon/log_commands.dart';
|
||||
import 'package:clide/src/ipc/envelope.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('log.level command (T-433)', () {
|
||||
test('no arg reports the current level + the vocabulary', () async {
|
||||
final log = Logger(minLevel: LogLevel.info);
|
||||
final d = DaemonDispatcher();
|
||||
registerLogCommands(d, log, (_) async {});
|
||||
|
||||
final r = await d.dispatch(IpcRequest(id: '1', cmd: 'log.level', args: const {}));
|
||||
expect(r.ok, isTrue);
|
||||
expect(r.data['level'], 'info');
|
||||
expect(r.data['levels'], containsAll(<String>['trace', 'debug', 'info', 'warn', 'error']));
|
||||
});
|
||||
|
||||
test('a valid level sets the running logger AND persists it', () async {
|
||||
final log = Logger(minLevel: LogLevel.info);
|
||||
String? persisted;
|
||||
final d = DaemonDispatcher();
|
||||
registerLogCommands(d, log, (name) async => persisted = name);
|
||||
|
||||
final r = await d.dispatch(IpcRequest(id: '1', cmd: 'log.level', args: {'level': 'warn'}));
|
||||
expect(r.ok, isTrue);
|
||||
expect(r.data['level'], 'warn');
|
||||
expect(log.minLevel, LogLevel.warn); // live
|
||||
expect(persisted, 'warn'); // durable
|
||||
});
|
||||
|
||||
test('level name is case-insensitive', () async {
|
||||
final log = Logger(minLevel: LogLevel.info);
|
||||
final d = DaemonDispatcher();
|
||||
registerLogCommands(d, log, (_) async {});
|
||||
await d.dispatch(IpcRequest(id: '1', cmd: 'log.level', args: {'level': 'ERROR'}));
|
||||
expect(log.minLevel, LogLevel.error);
|
||||
});
|
||||
|
||||
test('an unknown level errors (code 64), leaves the logger untouched, does not persist', () async {
|
||||
final log = Logger(minLevel: LogLevel.info);
|
||||
var persistCalls = 0;
|
||||
final d = DaemonDispatcher();
|
||||
registerLogCommands(d, log, (_) async => persistCalls++);
|
||||
|
||||
final r = await d.dispatch(IpcRequest(id: '1', cmd: 'log.level', args: {'level': 'loud'}));
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.code, 64);
|
||||
expect(r.error?.hint, contains('warn'));
|
||||
expect(log.minLevel, LogLevel.info);
|
||||
expect(persistCalls, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user