add ClaudePane + session-host widget-test harness (T-269 coverage)
claude_pane.dart had no widget-test harness, so the T-269 wiring (primary rebind on workspace switch, host tab-reset) shipped uncovered — and the moment any test mounts ClaudePane its 230 lines enter the coverage denominator, which is what sank the gate earlier. Add a real harness: a fake ClaudeSessionOrchestrator (no real `claude` process) plus a connected fake IPC answering files.root, with spawn/respawn driven inside tester.runAsync so the transcript-probe File I/O resolves. Covers spawn-binds-to-repo, in-place rebind, same-repo no-op, send, /clear (in place), /fork, /resume (picker open + cancel), mode cycle, draft round-trip, init-event status, can_use_tool prompt swap, secondary spawn + dispose-close, tap-to-focus, and the disconnected-daemon error path. claude_pane.dart 25% -> 84%; session-host tab-reset covered too. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -77,6 +77,11 @@ class ClaudeSessionHostState extends State<ClaudeSessionHost> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Ids of the open tabs, oldest-first. Exposed for tests that assert the
|
||||
/// host resets to a lone primary on an in-place workspace switch (T-269).
|
||||
@visibleForTesting
|
||||
List<String> get tabIds => _controller.entries.map((e) => e.id).toList();
|
||||
|
||||
/// Public entry point used by the `claude.new-secondary` command.
|
||||
void addSecondary() {
|
||||
final index = _nextSecondary++;
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
/// 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/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/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
|
||||
class _FakeProc implements StreamJsonProcess {
|
||||
final _ctl = StreamController<String>.broadcast();
|
||||
final List<String> writes = [];
|
||||
bool killed = false;
|
||||
|
||||
@override
|
||||
Stream<String> get lines => _ctl.stream;
|
||||
|
||||
@override
|
||||
void writeLine(String line) => writes.add(line);
|
||||
|
||||
@override
|
||||
Future<void> kill() async {
|
||||
killed = true;
|
||||
if (!_ctl.isClosed) await _ctl.close();
|
||||
}
|
||||
|
||||
void feed(Map<String, Object?> event) {
|
||||
if (!_ctl.isClosed) _ctl.add(jsonEncode(event));
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
late ClaudeSessionOrchestrator orch;
|
||||
late String root;
|
||||
final created = <_FakeProc>[];
|
||||
|
||||
setUp(() async {
|
||||
f = await KernelFixture.create();
|
||||
created.clear();
|
||||
root = '/repo-a';
|
||||
orch = ClaudeSessionOrchestrator(
|
||||
processFactory: ({required sessionArgs, required cwd, env}) async {
|
||||
final p = _FakeProc();
|
||||
created.add(p);
|
||||
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<void> 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<void>.delayed(const Duration(milliseconds: 60));
|
||||
});
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
// Run [fn] (a stream feed or an async command) in the real zone, then settle.
|
||||
Future<void> act(WidgetTester tester, FutureOr<void> Function() fn) async {
|
||||
await tester.runAsync(() async {
|
||||
await fn();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 60));
|
||||
});
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
ClaudeComposer composer(WidgetTester tester) => tester.widget<ClaudeComposer>(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('/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('/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('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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/// Host-level behaviour for the Claude session tabs (T-269): an in-place
|
||||
/// workspace switch (Open Project/Folder) must reset to a lone primary tab —
|
||||
/// the previous repo's secondaries/forks don't belong in the new workspace.
|
||||
///
|
||||
/// The embedded [ClaudePane]s take the "daemon not connected" path under the
|
||||
/// fake IPC, so no real `claude` process is spawned; this exercises the host's
|
||||
/// tab bookkeeping in isolation.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/claude_session_host.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
|
||||
Widget _host(KernelFixture f, Key key) => 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(
|
||||
// Bounded size so the reorderable tab strip's Draggable has a
|
||||
// real width and the Overlay below isn't asked to self-size.
|
||||
width: 800,
|
||||
height: 600,
|
||||
child: Overlay(
|
||||
initialEntries: [OverlayEntry(builder: (_) => ClaudeSessionHost(key: key))],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
testWidgets('in-place workspace switch drops secondaries, keeps primary (T-269)', (tester) async {
|
||||
final key = GlobalKey<ClaudeSessionHostState>();
|
||||
await tester.pumpWidget(_host(f, key));
|
||||
await tester.pump();
|
||||
final state = key.currentState!;
|
||||
|
||||
state.addSecondary();
|
||||
state.addSecondary();
|
||||
await tester.pump();
|
||||
expect(state.tabIds, ['primary', 'secondary-1', 'secondary-2']);
|
||||
|
||||
// Establish the baseline workspace; the first ProjectOpened is the initial
|
||||
// open, not a switch, so it must NOT reset anything. (It also resolves each
|
||||
// pane's "wait for project" so no spawn timer outlives the widget tree.)
|
||||
f.services.events.emit(const ProjectOpened(path: '/repo-a'));
|
||||
await tester.pump();
|
||||
expect(state.tabIds, ['primary', 'secondary-1', 'secondary-2']);
|
||||
|
||||
// Switch to a different repo in place → back to just the primary.
|
||||
f.services.events.emit(const ProjectOpened(path: '/repo-b'));
|
||||
await tester.pump();
|
||||
expect(state.tabIds, ['primary']);
|
||||
|
||||
// The secondary counter resets, so the next secondary is index 1 again.
|
||||
// The redundant /repo-b emit resolves the new pane's wait without a reset.
|
||||
state.addSecondary();
|
||||
f.services.events.emit(const ProjectOpened(path: '/repo-b'));
|
||||
await tester.pump();
|
||||
expect(state.tabIds, ['primary', 'secondary-1']);
|
||||
});
|
||||
|
||||
testWidgets('re-opening the same workspace is not a switch (no reset) (T-269)', (tester) async {
|
||||
final key = GlobalKey<ClaudeSessionHostState>();
|
||||
await tester.pumpWidget(_host(f, key));
|
||||
await tester.pump();
|
||||
final state = key.currentState!;
|
||||
|
||||
f.services.events.emit(const ProjectOpened(path: '/repo-a'));
|
||||
await tester.pump();
|
||||
state.addSecondary();
|
||||
await tester.pump();
|
||||
|
||||
// Same path again — a redundant re-open must keep the secondary.
|
||||
f.services.events.emit(const ProjectOpened(path: '/repo-a'));
|
||||
await tester.pump();
|
||||
expect(state.tabIds, ['primary', 'secondary-1']);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user