Files
clide/test/builtin/claude/conversation_view_test.dart
T
jpmschweitzerandClaude Opus 4.7 c30a707b41 hide prompted tool-use payloads from the conversation log
A permission-gated tool or AskUserQuestion already surfaces as a prompt
in the composer zone, so its raw tool-use card was redundant noise. The
session now tracks which tool_use_ids surfaced as a prompt; the
conversation view hides those tool-use cards. AskUserQuestion also hides
its result (the chosen answer is logged separately); permission-tool
results are kept — that's the useful outcome. The pane rebuilds the
view on each prompt change so the payload vanishes the moment its prompt
appears.

T-176, T-177, D-78.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:50:39 +02:00

270 lines
11 KiB
Dart

/// Widget tests for the native Claude ConversationView + controller
/// (T-137): renders each transcript item kind as a card, and text
/// selects + copies across cards via ClideSelectionArea (the terminal
/// affordance we keep, T-135).
library;
import 'dart:async';
import 'package:clide/builtin/claude/src/claude_banner.dart';
import 'package:clide/builtin/claude/src/conversation_controller.dart';
import 'package:clide/builtin/claude/src/conversation_view.dart';
import 'package:clide/builtin/claude/src/transcript_publisher.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart';
import 'package:clide/kernel/src/events/message_bus.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
final _t = DateTime.utc(2026, 1, 1);
UserMessage _user(String text) => UserMessage(uuid: 'u', timestamp: _t, isSidechain: false, text: text);
AssistantTextMessage _asst(String text) => AssistantTextMessage(uuid: 'a', timestamp: _t, isSidechain: false, text: text);
AssistantThinkingMessage _think(String text) => AssistantThinkingMessage(uuid: 't', timestamp: _t, isSidechain: false, thinking: text);
AssistantToolUse _tool(String name, Map<String, dynamic> input) =>
AssistantToolUse(uuid: 'tu', timestamp: _t, isSidechain: false, toolUseId: 'x1', name: name, input: input);
ToolResultMessage _result(String content, {bool isError = false}) =>
ToolResultMessage(uuid: 'r', timestamp: _t, isSidechain: false, toolUseId: 'x1', content: content, isError: isError);
class _MockClipboard {
Map<String, dynamic> _data = {'text': null};
Future<Object?> handleMethodCall(MethodCall call) async {
switch (call.method) {
case 'Clipboard.setData':
_data = Map<String, dynamic>.from(call.arguments as Map);
case 'Clipboard.getData':
return _data;
case 'Clipboard.hasStrings':
final t = _data['text'] as String?;
return {'value': t != null && t.isNotEmpty};
}
return null;
}
String? get text => _data['text'] as String?;
}
void main() {
group('ConversationController', () {
test('accumulates items from the stream and notifies', () async {
final ctrl = StreamController<ConversationItem>();
final c = ConversationController(stream: ctrl.stream);
addTearDown(c.dispose);
var notifications = 0;
c.addListener(() => notifications++);
expect(c.isEmpty, isTrue);
ctrl.add(_user('hi'));
ctrl.add(_asst('hello'));
// Wait past the coalescing timer (zero-duration, fires after the
// microtask queue drains).
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(c.items, hasLength(2));
expect(c.items.first, isA<UserMessage>());
// Notifications are coalesced: a burst of items collapses to a
// single notify so the view rebuilds once, not per item.
expect(notifications, 1);
await ctrl.close();
});
test('onDispose is invoked on dispose', () async {
final ctrl = StreamController<ConversationItem>();
var disposed = false;
final c = ConversationController(stream: ctrl.stream, onDispose: () async => disposed = true);
c.dispose();
expect(disposed, isTrue);
await ctrl.close();
});
});
group('ConversationController.fromBus', () {
test('consumes items published on its publisher/channel', () async {
final bus = MessageBus();
addTearDown(bus.dispose);
final c = ConversationController.fromBus(messages: bus);
addTearDown(c.dispose);
bus.publish(ClaudeConversation.publisher, ClaudeConversation.leadChannel, {ClaudeConversation.itemKey: _user('hi')});
bus.publish(ClaudeConversation.publisher, ClaudeConversation.leadChannel, {ClaudeConversation.itemKey: _asst('hello')});
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(c.items, hasLength(2));
expect(c.items.first, isA<UserMessage>());
});
test('ignores other publishers and channels', () async {
final bus = MessageBus();
addTearDown(bus.dispose);
final c = ConversationController.fromBus(messages: bus);
addTearDown(c.dispose);
bus.publish('someone.else', ClaudeConversation.leadChannel, {ClaudeConversation.itemKey: _user('nope')});
bus.publish(ClaudeConversation.publisher, 'conversation/other', {ClaudeConversation.itemKey: _user('nope')});
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(c.items, isEmpty);
});
});
group('ConversationView', () {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
Future<ConversationController> pumpWith(WidgetTester tester, List<ConversationItem> items, {Set<String> hiddenToolUseIds = const {}}) async {
tester.view.physicalSize = const Size(900, 700);
tester.view.devicePixelRatio = 1.0;
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
final stream = StreamController<ConversationItem>.broadcast();
final c = ConversationController(stream: stream.stream);
addTearDown(c.dispose);
await tester.pumpWidget(harness(f, ConversationView(controller: c, hiddenToolUseIds: hiddenToolUseIds)));
for (final it in items) {
stream.add(it);
}
await tester.pumpAndSettle();
return c;
}
testWidgets('empty controller shows the waiting hint', (tester) async {
await pumpWith(tester, const []);
expect(find.text('Waiting for Claude…'), findsOneWidget);
});
testWidgets('empty controller shows the provided emptyState instead', (tester) async {
final stream = StreamController<ConversationItem>.broadcast();
final c = ConversationController(stream: stream.stream);
addTearDown(c.dispose);
await tester.pumpWidget(harness(
f,
ConversationView(
controller: c,
emptyState: const ClideText('CUSTOM EMPTY'),
),
));
expect(find.text('CUSTOM EMPTY'), findsOneWidget);
expect(find.text('Waiting for Claude…'), findsNothing);
});
testWidgets('renders a card per item kind with role/tool labels', (tester) async {
await pumpWith(tester, [
_user('a question'),
_asst('an answer'),
_think('hmm'),
_tool('Bash', {'command': 'ls'}),
_result('ok'),
_result('boom', isError: true),
]);
expect(find.text('you'), findsOneWidget);
expect(find.text('claude'), findsOneWidget);
expect(find.text('thinking'), findsOneWidget);
expect(find.text('Bash'), findsOneWidget);
expect(find.text('result'), findsOneWidget);
expect(find.text('error'), findsOneWidget);
});
testWidgets('AskUserQuestion tool-use and its result are hidden (it shows as a prompt)', (tester) async {
await pumpWith(tester, [
_asst('let me ask'),
AssistantToolUse(uuid: 'au', timestamp: _t, isSidechain: false, toolUseId: 'auq1', name: 'AskUserQuestion', input: const {'questions': []}),
ToolResultMessage(uuid: 'ar', timestamp: _t, isSidechain: false, toolUseId: 'auq1', content: 'answered', isError: false),
_asst('thanks'),
]);
expect(find.text('AskUserQuestion'), findsNothing);
expect(find.text('let me ask'), findsOneWidget);
expect(find.text('thanks'), findsOneWidget);
});
testWidgets('a permission-prompted tool-use is hidden but its result is kept', (tester) async {
await pumpWith(
tester,
[
_tool('Write', {'file_path': '/tmp/x'}),
_result('done')
],
hiddenToolUseIds: {'x1'}, // _tool + _result both use toolUseId 'x1'
);
expect(find.text('Write'), findsNothing); // payload hidden
expect(find.text('done'), findsOneWidget); // result kept
expect(find.text('result'), findsOneWidget);
});
testWidgets('a one-line tool result renders inline (no collapse caret)', (tester) async {
await pumpWith(tester, [_result('hello-from-spike')]);
expect(find.text('hello-from-spike'), findsOneWidget);
expect(find.byType(ClideIcon), findsNothing); // not collapsible → no caret
});
testWidgets('a multi-line tool result starts collapsed with a first-line summary', (tester) async {
await pumpWith(tester, [_result('first line\nsecond line\nthird line')]);
// Collapsed: caret present, summary (first line) shown, full body hidden.
expect(find.byType(ClideIcon), findsOneWidget);
expect(find.text('first line'), findsOneWidget);
expect(find.text('first line\nsecond line\nthird line'), findsNothing);
await tester.tap(find.byType(ClideIcon));
await tester.pump();
expect(find.text('first line\nsecond line\nthird line'), findsOneWidget);
});
testWidgets('select-all + copy spans multiple cards', (tester) async {
final clipboard = _MockClipboard();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, clipboard.handleMethodCall);
addTearDown(() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null));
await pumpWith(tester, [_user('question text'), _asst('answer text')]);
// Focus the selection region, select all, copy.
final region = find.byType(ClideSelectionArea);
expect(region, findsOneWidget);
await tester.tap(region);
await tester.pump();
Future<void> keys(LogicalKeyboardKey k) async {
await tester.sendKeyDownEvent(LogicalKeyboardKey.control);
await tester.sendKeyDownEvent(k);
await tester.sendKeyUpEvent(k);
await tester.sendKeyUpEvent(LogicalKeyboardKey.control);
await tester.pump();
}
await keys(LogicalKeyboardKey.keyA);
await keys(LogicalKeyboardKey.keyC);
await tester.pump();
final copied = clipboard.text ?? '';
expect(copied, contains('question text'));
expect(copied, contains('answer text'));
});
});
group('ClaudeBanner', () {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
testWidgets('shows role, workspace, status, and a hint', (tester) async {
await tester.pumpWidget(harness(
f,
const ClaudeBanner(
role: 'primary',
workspace: '/work/space',
statusLine: 'tmux · clide-claude-x',
),
));
await tester.pump();
expect(find.text('Claude'), findsOneWidget);
expect(find.text('primary'), findsOneWidget);
expect(find.text('/work/space'), findsOneWidget);
expect(find.text('tmux · clide-claude-x'), findsOneWidget);
expect(find.textContaining('Warming up'), findsOneWidget);
});
});
}