The session observed its child only via stdout. Two failure modes: with --verbose the CLI chats on stderr, and an undrained 64KB pipe blocks the child mid-turn with zero diagnostics; and nothing watched the exit code, so a crashed process just looked thoughtful forever. ClaudeStreamJsonProcess now drains stderr from construction into a bounded tail buffer, and StreamJsonSession watches exitCode: on death it flips busy off, clears any unanswerable pending prompt, and emits a SessionEnd (exit code + stderr tail) — replayed via session.end for late binders. The pane reports the exit in its status line and logs the stderr tail; a deliberate dispose suppresses the watch so /clear and teardown don't read as crashes. Test fakes extend the process base instead of implementing it, so its defaults carry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1047 lines
39 KiB
Dart
1047 lines
39 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
|
||
import 'package:clide/builtin/claude/src/claude_config.dart';
|
||
import 'package:clide/builtin/claude/src/claude_meta_sidebar.dart';
|
||
import 'package:clide/builtin/claude/src/claude_stats.dart';
|
||
import 'package:clide/builtin/claude/src/session_orchestrator.dart';
|
||
import 'package:clide/builtin/claude/src/stream_json_session.dart';
|
||
import 'package:clide/builtin/claude/src/transcript_publisher.dart';
|
||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||
import 'package:clide/kernel/kernel.dart';
|
||
import 'package:flutter/services.dart' show LogicalKeyboardKey;
|
||
import 'package:flutter/widgets.dart' show ActivateIntent, Actions, EditableText, SizedBox, Semantics;
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
|
||
import '../../helpers/kernel_fixture.dart';
|
||
import '../../helpers/widget_harness.dart';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Minimal fake process so orchestrator tests don't need a real `claude` binary.
|
||
// ---------------------------------------------------------------------------
|
||
class _FakeProc extends StreamJsonProcess {
|
||
final _ctl = StreamController<String>.broadcast();
|
||
final List<String> writes = [];
|
||
bool killed = false;
|
||
|
||
/// Optional callback fired on every [writeLine] — used by T-181 tests to
|
||
/// mirror writes into a shared list across sessions.
|
||
void Function(String)? onWrite;
|
||
|
||
@override
|
||
Stream<String> get lines => _ctl.stream;
|
||
|
||
@override
|
||
void writeLine(String line) {
|
||
writes.add(line);
|
||
onWrite?.call(line);
|
||
}
|
||
|
||
@override
|
||
Future<void> kill() async => killed = true;
|
||
}
|
||
|
||
ClaudeSessionOrchestrator _fakeOrchestrator() {
|
||
return ClaudeSessionOrchestrator(processFactory: ({required sessionArgs, required cwd, env}) async => _FakeProc());
|
||
}
|
||
|
||
void main() {
|
||
late KernelFixture f;
|
||
setUp(() async => f = await KernelFixture.create());
|
||
tearDown(() => f.dispose());
|
||
|
||
const stats = ClaudeStats(
|
||
lastComputed: '2026-05-22',
|
||
daily: [
|
||
DailyActivity(date: '2026-05-01', messageCount: 100, sessionCount: 2, toolCallCount: 30),
|
||
DailyActivity(date: '2026-05-22', messageCount: 250, sessionCount: 5, toolCallCount: 80),
|
||
],
|
||
);
|
||
|
||
ClaudeMetaSidebar sidebar({
|
||
ClaudeStats stats = const ClaudeStats(),
|
||
ClaudeConfig? config,
|
||
SidebarTab initialTab = SidebarTab.activity,
|
||
ClaudeSessionOrchestrator? orchestrator,
|
||
}) => ClaudeMetaSidebar(statsLoader: () async => stats, pollInterval: Duration.zero, config: config, orchestrator: orchestrator, initialTab: initialTab);
|
||
|
||
testWidgets('Activity tab shows today + lifetime stats on the table', (tester) async {
|
||
await tester.pumpWidget(harness(f, sidebar(stats: stats)));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('TODAY'), findsOneWidget);
|
||
expect(find.text('LIFETIME'), findsOneWidget);
|
||
expect(find.text('250'), findsOneWidget); // today messages
|
||
expect(find.text('80'), findsOneWidget); // today tool calls
|
||
expect(find.text('350'), findsOneWidget); // lifetime messages
|
||
expect(find.text('messages'), findsNWidgets(2)); // today + lifetime rows
|
||
});
|
||
|
||
testWidgets('defaults to Activity, not the roster', (tester) async {
|
||
await tester.pumpWidget(harness(f, sidebar(stats: stats)));
|
||
await tester.pumpAndSettle();
|
||
expect(find.text('No team active.'), findsNothing);
|
||
expect(find.text('TODAY'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('tapping a sub-tab switches the body', (tester) async {
|
||
await tester.pumpWidget(harness(f, sidebar(stats: stats)));
|
||
await tester.pumpAndSettle();
|
||
|
||
await tester.tap(find.text('Team'));
|
||
await tester.pump();
|
||
expect(find.text('No team active.'), findsOneWidget);
|
||
expect(find.text('TODAY'), findsNothing);
|
||
|
||
await tester.tap(find.text('Activity'));
|
||
await tester.pump();
|
||
expect(find.text('TODAY'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('a sub-tab is keyboard-activatable, not pointer-only (T-182)', (tester) async {
|
||
await tester.pumpWidget(harness(f, sidebar(stats: stats)));
|
||
await tester.pumpAndSettle();
|
||
|
||
// The tab wraps its label in a ClideTappable, so the keymap's Enter/Space
|
||
// → ActivateIntent reaches it. Dispatch ActivateIntent through the real
|
||
// Actions path (what KeymapService does) — no pointer tap — and the body
|
||
// switches. Mirrors test/widgets/src/clide_tappable_test.dart.
|
||
Actions.invoke(tester.element(find.text('Team')), const ActivateIntent());
|
||
await tester.pump();
|
||
expect(find.text('No team active.'), findsOneWidget);
|
||
expect(find.text('TODAY'), findsNothing);
|
||
});
|
||
|
||
testWidgets('Config tab renders the settings table over ClaudeConfig', (tester) async {
|
||
final dir = Directory.systemTemp.createTempSync('cfg');
|
||
addTearDown(() => dir.deleteSync(recursive: true));
|
||
final config = ClaudeConfig(globalDir: dir, cacheDir: dir);
|
||
|
||
await tester.pumpWidget(harness(f, sidebar(config: config, initialTab: SidebarTab.config)));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('SETTINGS'), findsOneWidget);
|
||
expect(find.text('output style'), findsOneWidget);
|
||
expect(find.text('permission mode'), findsOneWidget);
|
||
expect(find.text('~/.claude + .claude'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('Config tab degrades when no environment is loaded', (tester) async {
|
||
await tester.pumpWidget(harness(f, sidebar(initialTab: SidebarTab.config)));
|
||
await tester.pumpAndSettle();
|
||
expect(find.text('Claude environment not loaded.'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('a team spawn auto-fronts the Team tab', (tester) async {
|
||
await tester.pumpWidget(harness(f, sidebar(stats: stats)));
|
||
await tester.pumpAndSettle();
|
||
expect(find.text('TODAY'), findsOneWidget); // starts on Activity
|
||
|
||
f.services.events.emit(
|
||
const TeamMemberJoined(team: 't', agentId: 'a1', name: 'Scout', agentType: 'explorer', paneId: '%1', model: 'claude-opus-4-7', color: 'blue'),
|
||
);
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
// Fronted to Team without a tap.
|
||
expect(find.text('Scout'), findsOneWidget);
|
||
expect(find.text('explorer · opus 4.7'), findsOneWidget);
|
||
expect(find.text('TODAY'), findsNothing);
|
||
});
|
||
|
||
testWidgets('removing the last member leaves the Team tab empty', (tester) async {
|
||
await tester.pumpWidget(harness(f, sidebar()));
|
||
await tester.pumpAndSettle();
|
||
|
||
f.services.events.emit(const TeamMemberJoined(team: 't', agentId: 'a1', name: 'Scout', agentType: 'explorer', paneId: '%1', color: 'blue'));
|
||
await tester.pump();
|
||
await tester.pump();
|
||
expect(find.text('Scout'), findsOneWidget);
|
||
|
||
f.services.events.emit(const TeamMemberLeft(team: 't', agentId: 'a1', paneId: '%1'));
|
||
await tester.pump();
|
||
await tester.pump();
|
||
expect(find.text('Scout'), findsNothing);
|
||
expect(find.text('No team active.'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('folds live per-member status into the roster row', (tester) async {
|
||
await tester.pumpWidget(harness(f, sidebar()));
|
||
await tester.pumpAndSettle();
|
||
|
||
f.services.events.emit(const TeamMemberJoined(team: 't', agentId: 'a1', name: 'Scout', agentType: 'explorer', paneId: '%1', color: 'blue'));
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
f.services.messages.publish(
|
||
ClaudeConversation.publisher,
|
||
ClaudeConversation.memberStatusChannel,
|
||
ClaudeConversation.memberStatusData('a1', const SessionStatus(model: 'claude-opus-4-7', permissionMode: 'acceptEdits', contextTokens: 21000)),
|
||
);
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
expect(find.text('explorer · opus 4.7 · accept-edits · 21k ctx'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('Activity degrades to a no-activity message with empty stats', (tester) async {
|
||
await tester.pumpWidget(harness(f, sidebar()));
|
||
await tester.pumpAndSettle();
|
||
expect(find.text('No activity recorded yet.'), findsOneWidget);
|
||
});
|
||
|
||
// T-171: roster controls + task list ----------------------------------------
|
||
|
||
group('T-171 roster controls', () {
|
||
Future<ClaudeSessionOrchestrator> orchWithMember(WidgetTester tester, {String name = 'Scout', String agentId = 'a1'}) async {
|
||
final orch = _fakeOrchestrator();
|
||
await orch.spawn(SpawnSpec(id: 'teammate:$name', role: 'teammate', sessionId: '$name-uuid', cwd: '/repo', team: true, memberName: name));
|
||
|
||
await tester.pumpWidget(harness(f, sidebar(orchestrator: orch, initialTab: SidebarTab.team)));
|
||
|
||
f.services.events.emit(TeamMemberJoined(team: 't', agentId: agentId, name: name, agentType: 'coder', paneId: '%1', color: 'blue'));
|
||
await tester.pump();
|
||
await tester.pump();
|
||
return orch;
|
||
}
|
||
|
||
testWidgets('team tab shows MESSAGES placeholder seam', (tester) async {
|
||
await orchWithMember(tester);
|
||
expect(find.text('MESSAGES'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('show/hide toggle changes managed session visibility', (tester) async {
|
||
final semantics = tester.ensureSemantics();
|
||
final orch = await orchWithMember(tester);
|
||
final managed = orch.byId('teammate:Scout')!;
|
||
expect(managed.visible, isTrue);
|
||
|
||
// The eye icon tooltips are "Hide pane" and "Show pane".
|
||
// We can find the first ClideTappable for hide (the eye icon).
|
||
// Tap by tooltip text (via Semantics).
|
||
final hideTap = find.bySemanticsLabel('Hide pane').first;
|
||
await tester.tap(hideTap);
|
||
await tester.pump();
|
||
expect(managed.visible, isFalse);
|
||
|
||
final showTap = find.bySemanticsLabel('Show pane').first;
|
||
await tester.tap(showTap);
|
||
await tester.pump();
|
||
expect(managed.visible, isTrue);
|
||
|
||
semantics.dispose();
|
||
orch.dispose();
|
||
});
|
||
|
||
testWidgets('mute toggle gates broker delivery', (tester) async {
|
||
final semantics = tester.ensureSemantics();
|
||
final orch = await orchWithMember(tester);
|
||
final managed = orch.byId('teammate:Scout')!;
|
||
expect(managed.muted, isFalse);
|
||
|
||
final muteTap = find.bySemanticsLabel('Mute messages').first;
|
||
await tester.tap(muteTap);
|
||
await tester.pump();
|
||
expect(managed.muted, isTrue);
|
||
expect(orch.broker.isMuted('teammate:Scout'), isTrue);
|
||
|
||
final unmuteTap = find.bySemanticsLabel('Unmute messages').first;
|
||
await tester.tap(unmuteTap);
|
||
await tester.pump();
|
||
expect(managed.muted, isFalse);
|
||
|
||
semantics.dispose();
|
||
orch.dispose();
|
||
});
|
||
|
||
testWidgets('close button kills the session', (tester) async {
|
||
final semantics = tester.ensureSemantics();
|
||
final orch = await orchWithMember(tester);
|
||
expect(orch.byId('teammate:Scout'), isNotNull);
|
||
|
||
final closeTap = find.bySemanticsLabel('Close session').first;
|
||
await tester.tap(closeTap);
|
||
await tester.pump();
|
||
await tester.pump(); // allow async close to complete
|
||
expect(orch.byId('teammate:Scout'), isNull);
|
||
|
||
semantics.dispose();
|
||
orch.dispose();
|
||
});
|
||
|
||
testWidgets('inject-message affordance toggles the text field', (tester) async {
|
||
final semantics = tester.ensureSemantics();
|
||
final orch = await orchWithMember(tester);
|
||
|
||
// Before tap: only the chat-composer field is visible (T-180).
|
||
// find by focusNode debugLabel to count only the inject field (not the chat composer).
|
||
final injectFinder = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel?.startsWith('inject-') == true);
|
||
expect(injectFinder, findsNothing);
|
||
|
||
final injectTap = find.bySemanticsLabel('Inject message').first;
|
||
await tester.tap(injectTap);
|
||
await tester.pump();
|
||
|
||
// After tap: inject field appears.
|
||
expect(injectFinder, findsOneWidget);
|
||
|
||
// Tapping the cancel (×) icon dismisses it.
|
||
final cancelTap = find.bySemanticsLabel('Cancel').first;
|
||
await tester.tap(cancelTap);
|
||
await tester.pump();
|
||
expect(injectFinder, findsNothing);
|
||
|
||
semantics.dispose();
|
||
orch.dispose();
|
||
});
|
||
|
||
testWidgets('submitting inject field sends the text to the session', (tester) async {
|
||
final semantics = tester.ensureSemantics();
|
||
final orch = _fakeOrchestrator();
|
||
// We cannot intercept the proc easily through the public API — verify
|
||
// injectMessage wired up by checking the managed session is the right one.
|
||
await orch.spawn(SpawnSpec(id: 'teammate:Alpha', role: 'teammate', sessionId: 'alpha-uuid', cwd: '/repo', team: true, memberName: 'Alpha'));
|
||
|
||
await tester.pumpWidget(harness(f, sidebar(orchestrator: orch, initialTab: SidebarTab.team)));
|
||
f.services.events.emit(const TeamMemberJoined(team: 't', agentId: 'a2', name: 'Alpha', agentType: 'coder', paneId: '%2', color: 'green'));
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
// Open inject field.
|
||
await tester.tap(find.bySemanticsLabel('Inject message').first);
|
||
await tester.pump();
|
||
final injectFinder = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel?.startsWith('inject-') == true);
|
||
expect(injectFinder, findsOneWidget);
|
||
|
||
// Type and submit.
|
||
await tester.enterText(injectFinder, 'hello agent');
|
||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||
await tester.pump();
|
||
|
||
// Inject field dismissed after submit (chat composer remains).
|
||
expect(injectFinder, findsNothing);
|
||
|
||
semantics.dispose();
|
||
orch.dispose();
|
||
// Note: we cannot assert on _FakeProc.writes here because the process
|
||
// factory closed over the outer list; the session's injectMessage call
|
||
// is verified by the orchestrator unit test in session_orchestrator_test.
|
||
});
|
||
});
|
||
|
||
group('T-171 task list', () {
|
||
testWidgets('task list renders live from broker on changes stream', (tester) async {
|
||
final orch = _fakeOrchestrator();
|
||
await orch.spawn(SpawnSpec(id: 'primary', role: 'primary', sessionId: 'primary-uuid', cwd: '/repo', team: true, memberName: 'lead'));
|
||
await orch.spawn(SpawnSpec(id: 'teammate:tyre', role: 'teammate', sessionId: 'tyre-uuid', cwd: '/repo', team: true, memberName: 'tyre'));
|
||
|
||
await tester.pumpWidget(harness(f, sidebar(orchestrator: orch, initialTab: SidebarTab.team)));
|
||
f.services.events.emit(const TeamMemberJoined(team: 't', agentId: 'a1', name: 'lead', agentType: 'lead', paneId: '%1', color: 'blue'));
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
// No tasks yet.
|
||
expect(find.text('TASKS'), findsNothing);
|
||
|
||
// Add a task via the broker.
|
||
orch.broker.claimTask('primary', title: 'wire-the-sidebar');
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
expect(find.text('TASKS'), findsOneWidget);
|
||
expect(find.text('wire-the-sidebar'), findsOneWidget);
|
||
|
||
orch.dispose();
|
||
});
|
||
|
||
testWidgets('reassign button cycles task owner', (tester) async {
|
||
final orch = _fakeOrchestrator();
|
||
await orch.spawn(SpawnSpec(id: 'primary', role: 'primary', sessionId: 'p-uuid', cwd: '/repo', team: true, memberName: 'lead'));
|
||
await orch.spawn(SpawnSpec(id: 'teammate:tyre', role: 'teammate', sessionId: 't-uuid', cwd: '/repo', team: true, memberName: 'tyre'));
|
||
|
||
// Sized box so the ListView gets a real (tall) viewport — under the
|
||
// shared canSizeOverlay harness the sidebar's scrollable otherwise gets a
|
||
// degenerate viewport and clips the task section out of the semantics
|
||
// tree, so the reassign button below the roster isn't findable by label.
|
||
await tester.pumpWidget(
|
||
harness(
|
||
f,
|
||
SizedBox(
|
||
width: 320,
|
||
height: 700,
|
||
child: sidebar(orchestrator: orch, initialTab: SidebarTab.team),
|
||
),
|
||
),
|
||
);
|
||
f.services.events.emit(const TeamMemberJoined(team: 't', agentId: 'a1', name: 'lead', agentType: 'lead', paneId: '%1', color: 'cyan'));
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
orch.broker.claimTask('primary', title: 'the-task');
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
final originalOwner = orch.broker.tasks.first.owner;
|
||
|
||
// Find the reassign button at the widget level (its Semantics carries the
|
||
// label). We don't use find.bySemanticsLabel here because the shared
|
||
// canSizeOverlay test harness gives the sidebar's ListView a degenerate
|
||
// viewport that clips the lower task section out of the semantics *tree*
|
||
// (the widget is built and tappable; only the semantics node is dropped).
|
||
final reassign = find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Reassign task');
|
||
await tester.tap(reassign.first);
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
expect(orch.broker.tasks.first.owner, isNot(originalOwner));
|
||
|
||
orch.dispose();
|
||
});
|
||
});
|
||
|
||
// T-181: permission-mode badge -----------------------------------------------
|
||
|
||
group('T-181 permission-mode badge', () {
|
||
/// Build an orchestrator that captures all writes from the spawned session's
|
||
/// stdin. The factory captures the fake proc and mirrors its writeLine calls
|
||
/// into [writes] so tests can assert on control_requests sent.
|
||
(ClaudeSessionOrchestrator, List<String>) orchCapturing() {
|
||
final writes = <String>[];
|
||
final orch = ClaudeSessionOrchestrator(
|
||
processFactory: ({required sessionArgs, required cwd, env}) async {
|
||
final p = _FakeProc();
|
||
p.onWrite = writes.add;
|
||
return p;
|
||
},
|
||
);
|
||
return (orch, writes);
|
||
}
|
||
|
||
Future<(ClaudeSessionOrchestrator, List<String>)> spawnAndShow(WidgetTester tester, {String name = 'Scout', String agentId = 'b1'}) async {
|
||
final (orch, writes) = orchCapturing();
|
||
await orch.spawn(SpawnSpec(id: 'teammate:$name', role: 'teammate', sessionId: '$name-uuid', cwd: '/repo', team: true, memberName: name));
|
||
await tester.pumpWidget(harness(f, sidebar(orchestrator: orch, initialTab: SidebarTab.team)));
|
||
f.services.events.emit(TeamMemberJoined(team: 't', agentId: agentId, name: name, agentType: 'coder', paneId: '%1', color: 'blue'));
|
||
await tester.pump();
|
||
await tester.pump();
|
||
return (orch, writes);
|
||
}
|
||
|
||
testWidgets('badge renders with label D when permissionMode is null/default', (tester) async {
|
||
final semantics = tester.ensureSemantics();
|
||
final (orch, _) = await spawnAndShow(tester);
|
||
|
||
// The badge Semantics label is 'Permission mode: D' for the default mode.
|
||
expect(find.bySemanticsLabel('Permission mode: D'), findsOneWidget);
|
||
|
||
semantics.dispose();
|
||
orch.dispose();
|
||
});
|
||
|
||
testWidgets('badge label reflects live permissionMode from status (A for acceptEdits)', (tester) async {
|
||
final semantics = tester.ensureSemantics();
|
||
final (orch, _) = await spawnAndShow(tester);
|
||
|
||
// Push a live status update with acceptEdits.
|
||
f.services.messages.publish(
|
||
ClaudeConversation.publisher,
|
||
ClaudeConversation.memberStatusChannel,
|
||
ClaudeConversation.memberStatusData('b1', const SessionStatus(permissionMode: 'acceptEdits')),
|
||
);
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
expect(find.bySemanticsLabel('Permission mode: A'), findsOneWidget);
|
||
|
||
semantics.dispose();
|
||
orch.dispose();
|
||
});
|
||
|
||
testWidgets('plain click cycles default → acceptEdits and writes set_permission_mode', (tester) async {
|
||
final semantics = tester.ensureSemantics();
|
||
final (orch, writes) = await spawnAndShow(tester);
|
||
|
||
final preCount = writes.length;
|
||
|
||
await tester.tap(find.bySemanticsLabel('Permission mode: D').first);
|
||
await tester.pump();
|
||
|
||
// One new write for the set_permission_mode control_request.
|
||
expect(writes.length, preCount + 1);
|
||
final sent = jsonDecode(writes.last) as Map<String, dynamic>;
|
||
expect(sent['type'], 'control_request');
|
||
expect((sent['request'] as Map)['subtype'], 'set_permission_mode');
|
||
expect((sent['request'] as Map)['mode'], 'acceptEdits');
|
||
|
||
semantics.dispose();
|
||
orch.dispose();
|
||
});
|
||
|
||
testWidgets('shift-click shows the bypass confirm inline', (tester) async {
|
||
final semantics = tester.ensureSemantics();
|
||
final (orch, _) = await spawnAndShow(tester);
|
||
|
||
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
|
||
await tester.tap(find.bySemanticsLabel('Permission mode: D').first);
|
||
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
|
||
await tester.pump();
|
||
|
||
// The inline confirm prompt should be visible.
|
||
expect(find.text('Enable bypassPermissions? All tool calls will be auto-allowed.'), findsOneWidget);
|
||
expect(find.bySemanticsLabel('Confirm bypass'), findsOneWidget);
|
||
expect(find.bySemanticsLabel('Cancel bypass'), findsOneWidget);
|
||
|
||
semantics.dispose();
|
||
orch.dispose();
|
||
});
|
||
|
||
testWidgets('bypass confirm OK sends bypassPermissions and dismisses the prompt', (tester) async {
|
||
final semantics = tester.ensureSemantics();
|
||
final (orch, writes) = await spawnAndShow(tester);
|
||
|
||
final preCount = writes.length;
|
||
|
||
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
|
||
await tester.tap(find.bySemanticsLabel('Permission mode: D').first);
|
||
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
|
||
await tester.pump();
|
||
|
||
await tester.tap(find.bySemanticsLabel('Confirm bypass').first);
|
||
await tester.pump();
|
||
|
||
// Prompt is gone.
|
||
expect(find.text('Enable bypassPermissions? All tool calls will be auto-allowed.'), findsNothing);
|
||
|
||
// bypassPermissions was sent to the session.
|
||
expect(writes.length, preCount + 1);
|
||
final sent = jsonDecode(writes.last) as Map<String, dynamic>;
|
||
expect((sent['request'] as Map)['subtype'], 'set_permission_mode');
|
||
expect((sent['request'] as Map)['mode'], 'bypassPermissions');
|
||
|
||
semantics.dispose();
|
||
orch.dispose();
|
||
});
|
||
|
||
testWidgets('bypass confirm Cancel dismisses without sending', (tester) async {
|
||
final semantics = tester.ensureSemantics();
|
||
final (orch, writes) = await spawnAndShow(tester);
|
||
|
||
final preCount = writes.length;
|
||
|
||
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
|
||
await tester.tap(find.bySemanticsLabel('Permission mode: D').first);
|
||
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
|
||
await tester.pump();
|
||
|
||
await tester.tap(find.bySemanticsLabel('Cancel bypass').first);
|
||
await tester.pump();
|
||
|
||
// Prompt dismissed, no extra write.
|
||
expect(find.text('Enable bypassPermissions? All tool calls will be auto-allowed.'), findsNothing);
|
||
expect(writes.length, preCount);
|
||
|
||
semantics.dispose();
|
||
orch.dispose();
|
||
});
|
||
});
|
||
|
||
// T-172: fork button in the roster -------------------------------------------
|
||
|
||
group('T-172 fork session button', () {
|
||
Future<ClaudeSessionOrchestrator> orchWithMember(WidgetTester tester, {String name = 'Forker', String agentId = 'f1'}) async {
|
||
final orch = _fakeOrchestrator();
|
||
await orch.spawn(SpawnSpec(id: 'teammate:$name', role: 'teammate', sessionId: '$name-uuid', cwd: '/repo', team: true, memberName: name));
|
||
|
||
await tester.pumpWidget(harness(f, sidebar(orchestrator: orch, initialTab: SidebarTab.team)));
|
||
|
||
f.services.events.emit(TeamMemberJoined(team: 't', agentId: agentId, name: name, agentType: 'coder', paneId: '%1', color: 'teal'));
|
||
await tester.pump();
|
||
await tester.pump();
|
||
return orch;
|
||
}
|
||
|
||
testWidgets('fork button appears in the roster controls', (tester) async {
|
||
final semantics = tester.ensureSemantics();
|
||
final orch = await orchWithMember(tester);
|
||
|
||
// The fork button is an _IconButton with tooltip 'Fork session'.
|
||
expect(find.bySemanticsLabel('Fork session'), findsOneWidget);
|
||
|
||
semantics.dispose();
|
||
orch.dispose();
|
||
});
|
||
|
||
testWidgets('tapping fork button spawns a new fork session in the orchestrator', (tester) async {
|
||
final semantics = tester.ensureSemantics();
|
||
final orch = await orchWithMember(tester);
|
||
|
||
expect(orch.sessions, hasLength(1)); // just the source
|
||
|
||
await tester.tap(find.bySemanticsLabel('Fork session').first);
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
// A second (fork) session is now registered.
|
||
expect(orch.sessions, hasLength(2));
|
||
final forkSession = orch.sessions.last;
|
||
expect(forkSession.isFork, isTrue);
|
||
expect(forkSession.forkSourceSessionId, 'Forker-uuid');
|
||
|
||
semantics.dispose();
|
||
orch.dispose();
|
||
});
|
||
|
||
testWidgets('fork leaves the source session untouched', (tester) async {
|
||
final semantics = tester.ensureSemantics();
|
||
final orch = await orchWithMember(tester);
|
||
final source = orch.byId('teammate:Forker')!;
|
||
|
||
await tester.tap(find.bySemanticsLabel('Fork session').first);
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
// Source session is still present and unchanged.
|
||
expect(orch.byId('teammate:Forker'), same(source));
|
||
expect(source.visible, isTrue);
|
||
|
||
semantics.dispose();
|
||
orch.dispose();
|
||
});
|
||
});
|
||
|
||
// T-183: Config tab accordion browser ----------------------------------------
|
||
|
||
group('T-183 Config accordion browser', () {
|
||
/// Build a fully-loaded [ClaudeConfig] via [tester.runAsync] so real I/O
|
||
/// and async work run outside FakeAsync. Returns a config whose load() has
|
||
/// completed; caller must call dispose() (via addTearDown).
|
||
Future<ClaudeConfig> loadedConfig(
|
||
WidgetTester tester,
|
||
Directory root, {
|
||
List<({String name, String dir})> skills = const [],
|
||
List<String> commands = const [],
|
||
List<String> agents = const [],
|
||
Map<String, Object?> settings = const {},
|
||
}) async {
|
||
final result = await tester.runAsync(() async {
|
||
final globalDir = Directory('${root.path}/global')..createSync(recursive: true);
|
||
final cacheDir = Directory('${root.path}/cache')..createSync(recursive: true);
|
||
|
||
for (final s in skills) {
|
||
final d = Directory('${globalDir.path}/skills/${s.dir}')..createSync(recursive: true);
|
||
await File('${d.path}/SKILL.md').writeAsString('---\nname: ${s.name}\n---\nbody');
|
||
}
|
||
for (final cmd in commands) {
|
||
final d = Directory('${globalDir.path}/commands')..createSync(recursive: true);
|
||
await File('${d.path}/$cmd.md').writeAsString('# $cmd');
|
||
}
|
||
for (final agent in agents) {
|
||
final d = Directory('${globalDir.path}/agents')..createSync(recursive: true);
|
||
await File('${d.path}/$agent.md').writeAsString('# $agent');
|
||
}
|
||
if (settings.isNotEmpty) {
|
||
await File('${globalDir.path}/settings.json').writeAsString(jsonEncode(settings));
|
||
}
|
||
|
||
final c = ClaudeConfig(
|
||
globalDir: globalDir,
|
||
cacheDir: cacheDir,
|
||
versionRunner: () async => '2.1.0 (Claude Code)\n',
|
||
initProbe: () async => null,
|
||
watch: (_) => const Stream<void>.empty(),
|
||
debounce: Duration.zero,
|
||
);
|
||
await c.load();
|
||
return c;
|
||
});
|
||
// tester.runAsync returns T? — the result is non-null here because the
|
||
// body always returns successfully.
|
||
return result!;
|
||
}
|
||
|
||
testWidgets('pinned SETTINGS table renders with model / output style / permission mode / source', (tester) async {
|
||
final dir = Directory.systemTemp.createTempSync('t183_settings');
|
||
addTearDown(() => dir.deleteSync(recursive: true));
|
||
final config = await loadedConfig(tester, dir);
|
||
addTearDown(config.dispose);
|
||
|
||
await tester.pumpWidget(harness(f, sidebar(config: config, initialTab: SidebarTab.config)));
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
expect(find.text('SETTINGS'), findsOneWidget);
|
||
expect(find.text('model'), findsOneWidget);
|
||
expect(find.text('output style'), findsOneWidget);
|
||
expect(find.text('permission mode'), findsOneWidget);
|
||
expect(find.text('source'), findsOneWidget);
|
||
expect(find.text('~/.claude + .claude'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('accordion sections appear for each kind with correct counts', (tester) async {
|
||
final dir = Directory.systemTemp.createTempSync('t183_counts');
|
||
addTearDown(() => dir.deleteSync(recursive: true));
|
||
final config = await loadedConfig(
|
||
tester,
|
||
dir,
|
||
skills: [(name: 'git-commit', dir: 'git-commit'), (name: 'pql', dir: 'pql')],
|
||
commands: ['deploy', 'test'],
|
||
agents: ['planner'],
|
||
settings: {
|
||
'mcpServers': {'brave': {}, 'clide': {}},
|
||
'permissions': {
|
||
'allow': ['Bash(git *)'],
|
||
'deny': ['Bash(rm -rf *)'],
|
||
},
|
||
},
|
||
);
|
||
addTearDown(config.dispose);
|
||
|
||
await tester.pumpWidget(
|
||
harness(
|
||
f,
|
||
SizedBox(
|
||
width: 320,
|
||
height: 700,
|
||
child: sidebar(config: config, initialTab: SidebarTab.config),
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
// Section headers with counts are rendered by ClideAccordion as "$label · $count".
|
||
expect(find.text('SKILLS · 2'), findsOneWidget);
|
||
expect(find.text('AGENTS · 1'), findsOneWidget);
|
||
expect(find.text('COMMANDS · 2'), findsOneWidget);
|
||
expect(find.text('PERMISSIONS · 2'), findsOneWidget);
|
||
expect(find.text('MCP SERVERS · 2'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('expanding SKILLS section shows all skill names (no ellipsis)', (tester) async {
|
||
final dir = Directory.systemTemp.createTempSync('t183_skills');
|
||
addTearDown(() => dir.deleteSync(recursive: true));
|
||
final config = await loadedConfig(
|
||
tester,
|
||
dir,
|
||
skills: [(name: 'git-commit', dir: 'git-commit'), (name: 'pql', dir: 'pql'), (name: 'deep-research', dir: 'deep-research')],
|
||
);
|
||
addTearDown(config.dispose);
|
||
|
||
await tester.pumpWidget(
|
||
harness(
|
||
f,
|
||
SizedBox(
|
||
width: 320,
|
||
height: 700,
|
||
child: sidebar(config: config, initialTab: SidebarTab.config),
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
// Skills are hidden until expanded.
|
||
expect(find.text('git-commit'), findsNothing);
|
||
|
||
// Tap the SKILLS accordion header.
|
||
await tester.tap(find.text('SKILLS · 3'));
|
||
await tester.pump();
|
||
|
||
// All three skills render — no truncation.
|
||
expect(find.text('git-commit'), findsOneWidget);
|
||
expect(find.text('pql'), findsOneWidget);
|
||
expect(find.text('deep-research'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('expanding AGENTS section shows all agent names', (tester) async {
|
||
final dir = Directory.systemTemp.createTempSync('t183_agents');
|
||
addTearDown(() => dir.deleteSync(recursive: true));
|
||
final config = await loadedConfig(tester, dir, agents: ['planner', 'coder']);
|
||
addTearDown(config.dispose);
|
||
|
||
await tester.pumpWidget(
|
||
harness(
|
||
f,
|
||
SizedBox(
|
||
width: 320,
|
||
height: 700,
|
||
child: sidebar(config: config, initialTab: SidebarTab.config),
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
await tester.tap(find.text('AGENTS · 2'));
|
||
await tester.pump();
|
||
|
||
expect(find.text('planner'), findsOneWidget);
|
||
expect(find.text('coder'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('expanding COMMANDS section shows all command names', (tester) async {
|
||
final dir = Directory.systemTemp.createTempSync('t183_cmds');
|
||
addTearDown(() => dir.deleteSync(recursive: true));
|
||
final config = await loadedConfig(tester, dir, commands: ['deploy', 'test', 'lint']);
|
||
addTearDown(config.dispose);
|
||
|
||
await tester.pumpWidget(
|
||
harness(
|
||
f,
|
||
SizedBox(
|
||
width: 320,
|
||
height: 700,
|
||
child: sidebar(config: config, initialTab: SidebarTab.config),
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
await tester.tap(find.text('COMMANDS · 3'));
|
||
await tester.pump();
|
||
|
||
expect(find.text('deploy'), findsOneWidget);
|
||
expect(find.text('test'), findsOneWidget);
|
||
expect(find.text('lint'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('expanding PERMISSIONS shows rules grouped by allow / ask / deny', (tester) async {
|
||
final dir = Directory.systemTemp.createTempSync('t183_perms');
|
||
addTearDown(() => dir.deleteSync(recursive: true));
|
||
final config = await loadedConfig(
|
||
tester,
|
||
dir,
|
||
settings: {
|
||
'permissions': {
|
||
'allow': ['Bash(git *)'],
|
||
'ask': ['Write'],
|
||
'deny': ['Bash(rm -rf *)'],
|
||
},
|
||
},
|
||
);
|
||
addTearDown(config.dispose);
|
||
|
||
await tester.pumpWidget(
|
||
harness(
|
||
f,
|
||
SizedBox(
|
||
width: 320,
|
||
height: 700,
|
||
child: sidebar(config: config, initialTab: SidebarTab.config),
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
await tester.tap(find.text('PERMISSIONS · 3'));
|
||
await tester.pump();
|
||
|
||
// Group labels and rules should all appear.
|
||
expect(find.text('allow'), findsOneWidget);
|
||
expect(find.text('ask'), findsOneWidget);
|
||
expect(find.text('deny'), findsOneWidget);
|
||
expect(find.text('Bash(git *)'), findsOneWidget);
|
||
expect(find.text('Write'), findsOneWidget);
|
||
expect(find.text('Bash(rm -rf *)'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('expanding MCP SERVERS shows all server names', (tester) async {
|
||
final dir = Directory.systemTemp.createTempSync('t183_mcp');
|
||
addTearDown(() => dir.deleteSync(recursive: true));
|
||
final config = await loadedConfig(
|
||
tester,
|
||
dir,
|
||
settings: {
|
||
'mcpServers': {'brave': {}, 'clide': {}, 'github': {}},
|
||
},
|
||
);
|
||
addTearDown(config.dispose);
|
||
|
||
await tester.pumpWidget(
|
||
harness(
|
||
f,
|
||
SizedBox(
|
||
width: 320,
|
||
height: 700,
|
||
child: sidebar(config: config, initialTab: SidebarTab.config),
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
await tester.tap(find.text('MCP SERVERS · 3'));
|
||
await tester.pump();
|
||
|
||
expect(find.text('brave'), findsOneWidget);
|
||
expect(find.text('clide'), findsOneWidget);
|
||
expect(find.text('github'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('tapping a file-backed skill publishes to builtin.markdown selection (T-187)', (tester) async {
|
||
final dir = Directory.systemTemp.createTempSync('t183_click');
|
||
addTearDown(() => dir.deleteSync(recursive: true));
|
||
final config = await loadedConfig(tester, dir, skills: [(name: 'my-skill', dir: 'my-skill')]);
|
||
addTearDown(config.dispose);
|
||
|
||
// Capture markdown selection messages from the kernel MessageBus.
|
||
final published = <Message>[];
|
||
final sub = f.services.messages.subscribe(publisher: 'builtin.markdown', channel: 'selection').listen(published.add);
|
||
addTearDown(sub.cancel);
|
||
|
||
await tester.pumpWidget(
|
||
harness(
|
||
f,
|
||
SizedBox(
|
||
width: 320,
|
||
height: 700,
|
||
child: sidebar(config: config, initialTab: SidebarTab.config),
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
// Expand SKILLS, then tap the skill row.
|
||
await tester.tap(find.text('SKILLS · 1'));
|
||
await tester.pump();
|
||
|
||
// The skill name is the tappable label.
|
||
await tester.tap(find.text('my-skill'));
|
||
await tester.pump();
|
||
await pumpAsync(tester);
|
||
|
||
expect(published, hasLength(1));
|
||
expect(published.first.data['path'] as String?, endsWith('my-skill/SKILL.md'));
|
||
});
|
||
|
||
testWidgets('tapping a file-backed command publishes to builtin.markdown selection (T-187)', (tester) async {
|
||
final dir = Directory.systemTemp.createTempSync('t183_cmd_click');
|
||
addTearDown(() => dir.deleteSync(recursive: true));
|
||
final config = await loadedConfig(tester, dir, commands: ['deploy']);
|
||
addTearDown(config.dispose);
|
||
|
||
final published = <Message>[];
|
||
final sub = f.services.messages.subscribe(publisher: 'builtin.markdown', channel: 'selection').listen(published.add);
|
||
addTearDown(sub.cancel);
|
||
|
||
await tester.pumpWidget(
|
||
harness(
|
||
f,
|
||
SizedBox(
|
||
width: 320,
|
||
height: 700,
|
||
child: sidebar(config: config, initialTab: SidebarTab.config),
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
await tester.tap(find.text('COMMANDS · 1'));
|
||
await tester.pump();
|
||
|
||
await tester.tap(find.text('deploy'));
|
||
await tester.pump();
|
||
await pumpAsync(tester);
|
||
|
||
expect(published, hasLength(1));
|
||
expect(published.first.data['path'] as String?, endsWith('deploy.md'));
|
||
});
|
||
|
||
testWidgets('tapping a file-backed agent publishes to builtin.markdown selection (T-187)', (tester) async {
|
||
final dir = Directory.systemTemp.createTempSync('t183_agent_click');
|
||
addTearDown(() => dir.deleteSync(recursive: true));
|
||
final config = await loadedConfig(tester, dir, agents: ['planner']);
|
||
addTearDown(config.dispose);
|
||
|
||
final published = <Message>[];
|
||
final sub = f.services.messages.subscribe(publisher: 'builtin.markdown', channel: 'selection').listen(published.add);
|
||
addTearDown(sub.cancel);
|
||
|
||
await tester.pumpWidget(
|
||
harness(
|
||
f,
|
||
SizedBox(
|
||
width: 320,
|
||
height: 700,
|
||
child: sidebar(config: config, initialTab: SidebarTab.config),
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
await tester.tap(find.text('AGENTS · 1'));
|
||
await tester.pump();
|
||
|
||
await tester.tap(find.text('planner'));
|
||
await tester.pump();
|
||
await pumpAsync(tester);
|
||
|
||
expect(published, hasLength(1));
|
||
expect(published.first.data['path'] as String?, endsWith('planner.md'));
|
||
});
|
||
|
||
testWidgets('accordion collapses when toggled a second time', (tester) async {
|
||
final dir = Directory.systemTemp.createTempSync('t183_collapse');
|
||
addTearDown(() => dir.deleteSync(recursive: true));
|
||
final config = await loadedConfig(tester, dir, skills: [(name: 'git-commit', dir: 'git-commit')]);
|
||
addTearDown(config.dispose);
|
||
|
||
await tester.pumpWidget(
|
||
harness(
|
||
f,
|
||
SizedBox(
|
||
width: 320,
|
||
height: 700,
|
||
child: sidebar(config: config, initialTab: SidebarTab.config),
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
// Expand.
|
||
await tester.tap(find.text('SKILLS · 1'));
|
||
await tester.pump();
|
||
expect(find.text('git-commit'), findsOneWidget);
|
||
|
||
// Collapse.
|
||
await tester.tap(find.text('SKILLS · 1'));
|
||
await tester.pump();
|
||
expect(find.text('git-commit'), findsNothing);
|
||
});
|
||
|
||
testWidgets('Config tab with empty sections shows zero counts on all headers', (tester) async {
|
||
final dir = Directory.systemTemp.createTempSync('t183_empty');
|
||
addTearDown(() => dir.deleteSync(recursive: true));
|
||
final config = await loadedConfig(tester, dir);
|
||
addTearDown(config.dispose);
|
||
|
||
await tester.pumpWidget(
|
||
harness(
|
||
f,
|
||
SizedBox(
|
||
width: 320,
|
||
height: 700,
|
||
child: sidebar(config: config, initialTab: SidebarTab.config),
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
expect(find.text('SKILLS · 0'), findsOneWidget);
|
||
expect(find.text('AGENTS · 0'), findsOneWidget);
|
||
expect(find.text('COMMANDS · 0'), findsOneWidget);
|
||
expect(find.text('HOOKS · 0'), findsOneWidget);
|
||
expect(find.text('PERMISSIONS · 0'), findsOneWidget);
|
||
expect(find.text('MCP SERVERS · 0'), findsOneWidget);
|
||
});
|
||
});
|
||
}
|