/// Widget tests for [ClaudePane] — the spawn/rebind lifecycle (T-269) plus the /// composer-driven command handling (/clear, /fork, send, mode cycle) and the /// status / prompt render paths. /// /// Harness: a fake [ClaudeSessionOrchestrator] (no real `claude` process) and a /// connected fake IPC that answers `files.root`. The pane awaits a real /// File(...).exists() transcript probe during spawn, so the spawn/respawn /// phases run inside tester.runAsync (fake-async would trap that I/O). library; import 'dart:async'; import 'dart:convert'; import 'package:clide/builtin/claude/src/claude_composer.dart'; import 'package:clide/builtin/claude/src/claude_pane.dart'; import 'package:clide/builtin/claude/src/conversation_view.dart'; import 'package:clide/builtin/claude/src/model_picker_card.dart'; import 'package:clide/builtin/claude/src/session_naming.dart'; import 'package:clide/builtin/claude/src/session_orchestrator.dart'; import 'package:clide/builtin/claude/src/session_picker.dart'; import 'package:clide/builtin/claude/src/stream_json_session.dart'; import 'package:clide/clide.dart'; import 'package:clide/kernel/kernel.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../helpers/kernel_fixture.dart'; class _FakeProc extends StreamJsonProcess { final _ctl = StreamController.broadcast(); final List writes = []; bool killed = false; @override Stream get lines => _ctl.stream; @override void writeLine(String line) => writes.add(line); @override Future kill() async { killed = true; if (!_ctl.isClosed) await _ctl.close(); } void feed(Map event) { if (!_ctl.isClosed) _ctl.add(jsonEncode(event)); } } void main() { late KernelFixture f; late ClaudeSessionOrchestrator orch; late String root; final created = <_FakeProc>[]; final spawnArgs = >[]; setUp(() async { f = await KernelFixture.create(); created.clear(); spawnArgs.clear(); root = '/repo-a'; orch = ClaudeSessionOrchestrator( processFactory: ({required sessionArgs, required cwd, env}) async { final p = _FakeProc(); created.add(p); spawnArgs.add(sessionArgs); return p; }, ); activeSessionOrchestrator = orch; f.ipc.setConnected(true); f.ipc.stub('files.root', (_) async => IpcResponse.ok(id: '', data: {'path': root})); }); tearDown(() async { activeSessionOrchestrator = null; orch.dispose(); await f.dispose(); }); Widget tree(ClaudePane pane) => Directionality( textDirection: TextDirection.ltr, child: ClideKernel( services: f.services, child: ClideTheme( controller: f.services.theme, child: MediaQuery( data: const MediaQueryData(), child: Align( alignment: Alignment.topLeft, child: SizedBox( width: 900, height: 700, child: DialogHost( router: f.services.dialog, child: Overlay(initialEntries: [OverlayEntry(builder: (_) => pane)]), ), ), ), ), ), ), ); // Pump the pane and release its project-wait gate so _spawn runs. The whole // chain (incl. the real transcript-probe I/O) runs in the real zone. Future mount(WidgetTester tester, ClaudePane pane, {String? openPath}) async { await tester.runAsync(() async { await tester.pumpWidget(tree(pane)); f.services.events.emit(ProjectOpened(path: openPath ?? root)); await Future.delayed(const Duration(milliseconds: 60)); }); await tester.pump(); } // Run [fn] (a stream feed or an async command) in the real zone, then settle. Future act(WidgetTester tester, FutureOr Function() fn) async { await tester.runAsync(() async { await fn(); await Future.delayed(const Duration(milliseconds: 60)); }); await tester.pump(); } ClaudeComposer composer(WidgetTester tester) => tester.widget(find.byType(ClaudeComposer)); testWidgets('spawns a session bound to the active repo and renders', (tester) async { await mount(tester, const ClaudePane(showChrome: false)); final primary = orch.byId('primary'); expect(primary, isNotNull); expect(primary!.cwd, '/repo-a'); expect(primary.sessionId, primarySessionId('/repo-a')); expect(find.byType(ConversationView), findsOneWidget); expect(find.byType(ClaudeComposer), findsOneWidget); }); testWidgets('switching the workspace in place rebinds to the new repo (T-269)', (tester) async { await mount(tester, const ClaudePane(showChrome: false)); expect(orch.byId('primary')!.cwd, '/repo-a'); final firstProc = created.single; root = '/repo-b'; await act(tester, () => f.services.events.emit(const ProjectOpened(path: '/repo-b'))); final primary = orch.byId('primary')!; expect(primary.cwd, '/repo-b'); expect(primary.sessionId, primarySessionId('/repo-b')); expect(firstProc.killed, isTrue); expect(created, hasLength(2)); }); testWidgets('a re-open of the same repo does not respawn', (tester) async { await mount(tester, const ClaudePane(showChrome: false)); expect(created, hasLength(1)); await act(tester, () => f.services.events.emit(ProjectOpened(path: root))); expect(created, hasLength(1), reason: 'same path → no rebind'); }); testWidgets('sending a message writes to the session', (tester) async { await mount(tester, const ClaudePane(showChrome: false)); final proc = created.single; composer(tester).onSubmit('hello there'); await tester.pump(); expect(proc.writes.any((w) => w.contains('hello there')), isTrue); }); testWidgets('a Workflow run renders its dedicated card in the conversation (T-416)', (tester) async { await mount(tester, const ClaudePane(showChrome: false)); final proc = created.single; final semantics = tester.ensureSemantics(); await act(tester, () { proc.feed({ 'type': 'assistant', 'uuid': 'a-wf', 'message': { 'role': 'assistant', 'content': [ { 'type': 'tool_use', 'id': 'toolu_wf', 'name': 'Workflow', 'input': {'script': 'await parallel([])'}, }, ], }, }); proc.feed({ 'type': 'system', 'subtype': 'task_progress', 'tool_use_id': 'toolu_wf', 'workflow_progress': [ {'type': 'workflow_agent', 'index': 1, 'label': 'first agent', 'state': 'done'}, {'type': 'workflow_agent', 'index': 2, 'label': 'second agent', 'state': 'start'}, ], }); }); // The dedicated workflow collapser, with its live done/total agent counter. expect(find.bySemanticsLabel('workflow, 1/2 agents, collapsed'), findsOneWidget); semantics.dispose(); }); testWidgets('/clear empties the deterministic session in place', (tester) async { await mount(tester, const ClaudePane(showChrome: false)); final firstProc = created.single; final id = orch.byId('primary')!.sessionId; await act(tester, () => composer(tester).onSubmit('/clear')); expect(firstProc.killed, isTrue); expect(created, hasLength(2)); // Re-bound to the SAME deterministic id (cleared in place, not a random id). expect(orch.byId('primary')!.sessionId, id); }); testWidgets('/clear in a fork pane clears instead of re-forking (T-375)', (tester) async { await mount(tester, const ClaudePane(showChrome: false, isPrimary: false, secondaryIndex: 1, forkSourceId: 'source-session-uuid')); // First bind forks from the source. expect(spawnArgs.single, containsAll(['--fork-session', 'source-session-uuid'])); await act(tester, () => composer(tester).onSubmit('/clear')); // The respawn must NOT fork the original again — the fork source is a // one-shot spawn parameter consumed by the first bind. expect(spawnArgs, hasLength(2)); expect(spawnArgs.last, isNot(contains('--fork-session'))); expect(spawnArgs.last, isNot(contains('source-session-uuid'))); }); testWidgets('/fork delegates to the onFork callback with the session id', (tester) async { String? forkedWith; await mount(tester, ClaudePane(showChrome: false, onFork: (sid) => forkedWith = sid)); composer(tester).onSubmit('/fork'); await tester.pump(); expect(forkedWith, primarySessionId('/repo-a')); }); testWidgets('/model with an argument sends set_model, never a user message (T-408)', (tester) async { await mount(tester, const ClaudePane(showChrome: false)); final proc = created.single; await act(tester, () => composer(tester).onSubmit('/model sonnet')); final sent = proc.writes.map((w) => jsonDecode(w) as Map).toList(); final setModel = sent.where((m) => (m['request'] as Map?)?['subtype'] == 'set_model').toList(); expect(setModel, hasLength(1)); expect((setModel.single['request'] as Map)['model'], 'sonnet'); expect(sent.any((m) => m['type'] == 'user'), isFalse, reason: '/model must not be forwarded as message text'); expect(find.byType(ModelPickerCard), findsNothing); }); testWidgets('bare /model swaps the picker in; picking sends set_model and restores the composer (T-408)', (tester) async { await mount(tester, const ClaudePane(showChrome: false)); final proc = created.single; await act(tester, () => composer(tester).onSubmit('/model')); expect(find.byType(ModelPickerCard), findsOneWidget); expect(find.byType(ClaudeComposer), findsNothing, reason: 'the picker takes the interaction zone (D-78)'); tester.widget(find.byType(ModelPickerCard)).onPick('opus'); await tester.pump(); expect(find.byType(ModelPickerCard), findsNothing); expect(find.byType(ClaudeComposer), findsOneWidget); final setModel = proc.writes.map((w) => jsonDecode(w) as Map).where((m) => (m['request'] as Map?)?['subtype'] == 'set_model').toList(); expect((setModel.single['request'] as Map)['model'], 'opus'); }); testWidgets('Esc cancels the /model picker without sending (T-408)', (tester) async { await mount(tester, const ClaudePane(showChrome: false)); final proc = created.single; await act(tester, () => composer(tester).onSubmit('/model')); expect(find.byType(ModelPickerCard), findsOneWidget); await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pump(); expect(find.byType(ModelPickerCard), findsNothing); expect(find.byType(ClaudeComposer), findsOneWidget); expect(proc.writes.any((w) => w.contains('set_model')), isFalse); }); testWidgets('cycling permission mode sends a control message', (tester) async { await mount(tester, const ClaudePane(showChrome: false)); final proc = created.single; composer(tester).onCycleMode!(); await tester.pump(); expect(proc.writes.any((w) => w.contains('permission')), isTrue); }); testWidgets('composer draft is retained then cleared', (tester) async { await mount(tester, const ClaudePane(showChrome: false)); final c = composer(tester); c.onDraftChanged!(const TextEditingValue(text: 'a draft')); c.onDraftChanged!(TextEditingValue.empty); await tester.pump(); // No throw / no crash; draft round-trips through the pane's per-session map. expect(find.byType(ClaudeComposer), findsOneWidget); }); testWidgets('an init event populates the status line', (tester) async { await mount(tester, const ClaudePane(showChrome: false)); final proc = created.single; await act( tester, () => proc.feed({'type': 'system', 'subtype': 'init', 'model': 'claude-opus-4-8', 'permissionMode': 'plan', 'session_id': primarySessionId('/repo-a')}), ); // The init event flows through the session into the pane's status path // (the rendered slot lives in the status bar, absent from this harness). expect(orch.byId('primary')!.session.status.permissionMode, 'plan'); expect(orch.byId('primary')!.session.status.model, 'claude-opus-4-8'); }); testWidgets('a can_use_tool request renders a prompt card in place of the composer', (tester) async { await mount(tester, const ClaudePane(showChrome: false)); final proc = created.single; await act( tester, () => proc.feed({ 'type': 'control_request', 'request_id': 'req-1', 'request': { 'subtype': 'can_use_tool', 'tool_name': 'Bash', 'tool_use_id': 'tu-1', 'input': {'command': 'ls'}, }, }), ); expect(find.byType(ClaudeComposer), findsNothing, reason: 'prompt takes the composer slot (D-78)'); }); testWidgets('a disconnected daemon surfaces an error instead of spawning', (tester) async { f.ipc.setConnected(false); await mount(tester, const ClaudePane(showChrome: false)); expect(orch.byId('primary'), isNull); expect(find.textContaining('not connected'), findsOneWidget); }); testWidgets('a secondary pane spawns a fresh session under its own key', (tester) async { await mount(tester, const ClaudePane(isPrimary: false, secondaryIndex: 1, showChrome: false)); final secondary = orch.byId('secondary-1'); expect(secondary, isNotNull); expect(secondary!.cwd, '/repo-a'); // Secondaries get a fresh random id, not the deterministic primary one. expect(secondary.sessionId, isNot(primarySessionId('/repo-a'))); // Disposing the secondary closes its session (the !isPrimary branch). // Pump a different widget type so the pane's State is torn down, not reused. await tester.pumpWidget(const SizedBox()); await tester.pump(); expect(orch.byId('secondary-1'), isNull); }); testWidgets('tapping the conversation area focuses the composer', (tester) async { await mount(tester, const ClaudePane(showChrome: false)); await tester.tap(find.byType(ConversationView)); await tester.pump(); expect(find.byType(ClaudeComposer), findsOneWidget); }); testWidgets('/resume opens the session picker; cancelling leaves the session', (tester) async { await mount(tester, const ClaudePane(showChrome: false)); final proc = created.single; await act(tester, () => composer(tester).onSubmit('/resume')); expect(find.byType(SessionPickerDialog), findsOneWidget); // Cancel the picker → no rebind, original session untouched. await act(tester, () => f.services.dialog.dismiss()); expect(find.byType(SessionPickerDialog), findsNothing); expect(created, hasLength(1)); expect(proc.killed, isFalse); }); // ---- T-410 epic: bus-driven owned commands (T-412/T-413/T-414) ---------- // The sidebar controls publish slash-command text on builtin.claude/command; // the primary pane executes it through _send — the same path as typing. group('command bus → owned slash commands', () { Future command(WidgetTester tester, String text) => act(tester, () => f.services.messages.publish('builtin.claude', 'command', {'text': text})); testWidgets('/effort respawns the session carrying --effort (T-412)', (tester) async { await mount(tester, const ClaudePane(showChrome: false)); expect(created, hasLength(1)); await command(tester, '/effort xhigh'); expect(created, hasLength(2), reason: 'effort change = respawn'); final args = spawnArgs.last; final i = args.indexOf('--effort'); expect(i, isNonNegative, reason: 'args: $args'); expect(args[i + 1], 'xhigh'); // The pane records the level on the session status (the wire never // reports effort). expect(orch.byId('primary')!.session.status.effort, 'xhigh'); }); testWidgets('/effort with an unknown level notices and does NOT respawn (T-412)', (tester) async { await mount(tester, const ClaudePane(showChrome: false)); await command(tester, '/effort warp9'); expect(created, hasLength(1)); expect(find.textContaining('unknown effort "warp9"'), findsOneWidget); }); testWidgets('bare /effort opens the effort picker in the interaction zone (T-412)', (tester) async { await mount(tester, const ClaudePane(showChrome: false)); await command(tester, '/effort'); expect(find.byType(ModelPickerCard), findsOneWidget); expect(find.text('effort'), findsOneWidget); // the picker title }); testWidgets('/permissions sends set_permission_mode (T-413)', (tester) async { await mount(tester, const ClaudePane(showChrome: false)); await command(tester, '/permissions plan'); final proc = created.single; expect(proc.writes.any((w) => w.contains('set_permission_mode') && w.contains('"plan"')), isTrue, reason: proc.writes.join('\n')); }); testWidgets('bare /permissions opens the mode picker (T-413)', (tester) async { await mount(tester, const ClaudePane(showChrome: false)); await command(tester, '/permissions'); expect(find.byType(ModelPickerCard), findsOneWidget); expect(find.text('permissions'), findsOneWidget); }); testWidgets('/status and /config navigate the Claude sidebar (T-413)', (tester) async { final tabs = []; final sub = f.services.messages.subscribe(publisher: 'builtin.claude', channel: 'meta.tab').listen((m) => tabs.add(m.data['tab'] as String?)); addTearDown(sub.cancel); await mount(tester, const ClaudePane(showChrome: false)); await command(tester, '/status'); await command(tester, '/config'); await command(tester, '/mcp'); expect(tabs, ['activity', 'config', 'config']); }); testWidgets('/memory opens CLAUDE.md in the editor (T-413)', (tester) async { final opened = []; f.ipc.stub('editor.open', (args) async { opened.add(args['path'] as String?); return IpcResponse.ok(id: '', data: const {}); }); await mount(tester, const ClaudePane(showChrome: false)); await command(tester, '/memory'); expect(opened, ['/repo-a/CLAUDE.md']); }); testWidgets('/help renders a local clide summary card (T-413)', (tester) async { await mount(tester, const ClaudePane(showChrome: false)); await command(tester, '/help'); expect(find.textContaining('clide commands:'), findsOneWidget); expect(find.text('clide'), findsOneWidget); // synthetic card attribution }); testWidgets('a TUI-only command becomes a notice card, never reaching the session (T-411)', (tester) async { await mount(tester, const ClaudePane(showChrome: false)); final before = created.single.writes.length; await command(tester, '/doctor'); expect(find.textContaining('/doctor is a Claude Code TUI command'), findsOneWidget); expect(created.single.writes.length, before, reason: 'nothing forwarded'); }); }); }