xworkmate-app/test/features/assistant_page_suite.dart

1429 lines
44 KiB
Dart

@TestOn('vm')
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:xworkmate/app/app_controller.dart';
import 'package:xworkmate/app/ui_feature_manifest.dart';
import 'package:xworkmate/features/assistant/assistant_page.dart';
import 'package:xworkmate/models/app_models.dart';
import 'package:xworkmate/runtime/codex_runtime.dart';
import 'package:xworkmate/runtime/device_identity_store.dart';
import 'package:xworkmate/runtime/gateway_runtime.dart';
import 'package:xworkmate/runtime/runtime_coordinator.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
import 'package:xworkmate/runtime/secure_config_store.dart';
import 'package:xworkmate/theme/app_theme.dart';
import 'package:xworkmate/widgets/pane_resize_handle.dart';
import '../test_support.dart';
void main() {
testWidgets(
'AssistantPage desktop hides conversation header text and shows thread rail',
(WidgetTester tester) async {
final controller = await createTestController(tester);
await pumpPage(
tester,
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
platform: TargetPlatform.macOS,
);
expect(find.byKey(const Key('assistant-task-rail')), findsOneWidget);
expect(
find.byKey(const Key('assistant-conversation-title')),
findsNothing,
);
expect(controller.currentSessionKey, 'main');
},
skip: true,
);
testWidgets('AssistantPage keeps draft task visible until archived', (
WidgetTester tester,
) async {
final controller = await createTestController(tester);
await pumpPage(
tester,
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
platform: TargetPlatform.macOS,
);
await tester.tap(
find.byKey(const ValueKey<String>('assistant-task-group-local')),
);
await tester.pumpAndSettle();
expect(
find.byWidgetPredicate(
(widget) =>
widget.key is ValueKey<String> &&
(widget.key as ValueKey<String>).value.startsWith(
'assistant-task-item-',
),
),
findsOneWidget,
);
await tester.tap(find.byKey(const Key('assistant-new-task-button')));
await tester.pumpAndSettle();
await controller.refreshSessions();
await tester.pumpAndSettle();
expect(
find.byWidgetPredicate(
(widget) =>
widget.key is ValueKey<String> &&
(widget.key as ValueKey<String>).value.startsWith(
'assistant-task-item-',
),
),
findsNWidgets(2),
);
final archiveButton = find.byWidgetPredicate(
(widget) =>
widget.key is ValueKey<String> &&
(widget.key as ValueKey<String>).value.startsWith(
'assistant-task-archive-draft:',
),
);
expect(archiveButton, findsOneWidget);
await tester.tap(archiveButton);
await tester.pumpAndSettle();
expect(
controller.settings.assistantArchivedTaskKeys.any(
(item) => item.startsWith('draft:'),
),
isTrue,
);
expect(
find.byWidgetPredicate(
(widget) =>
widget.key is ValueKey<String> &&
(widget.key as ValueKey<String>).value.startsWith(
'assistant-task-item-',
),
),
findsOneWidget,
);
await pumpPage(
tester,
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
platform: TargetPlatform.macOS,
);
expect(find.text('当前 0'), findsOneWidget);
controller.dispose();
await tester.pump();
});
testWidgets('AssistantPage lets users rename task titles', (
WidgetTester tester,
) async {
final controller = await createTestController(tester);
await pumpPage(
tester,
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
);
await tester.tap(
find.byKey(const ValueKey<String>('assistant-task-group-local')),
);
await tester.pumpAndSettle();
await tester.longPress(
find.byKey(const ValueKey<String>('assistant-task-item-main')),
);
await tester.pumpAndSettle();
expect(
find.byKey(const Key('assistant-task-rename-input')),
findsOneWidget,
);
await tester.enterText(
find.byKey(const Key('assistant-task-rename-input')),
'研发任务',
);
await tester.tap(find.text('保存'));
await tester.pumpAndSettle();
expect(find.text('研发任务'), findsWidgets);
expect(controller.settings.assistantCustomTaskTitles['main'], '研发任务');
await pumpPage(
tester,
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
);
expect(find.text('研发任务'), findsWidgets);
});
testWidgets('AssistantPage groups task rows by execution target', (
WidgetTester tester,
) async {
final controller = await createTestController(tester);
await pumpPage(
tester,
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
);
await tester.tap(find.byKey(const Key('assistant-new-task-button')));
await _pumpForUiSync(tester);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.singleAgent,
);
await _pumpForUiSync(tester);
await tester.tap(find.byKey(const Key('assistant-new-task-button')));
await _pumpForUiSync(tester);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.remote,
);
await _pumpForUiSync(tester);
await tester.tap(find.byKey(const Key('assistant-new-task-button')));
await _pumpForUiSync(tester);
final aiGroup = find.byKey(
const ValueKey<String>('assistant-task-group-singleAgent'),
);
final localGroup = find.byKey(
const ValueKey<String>('assistant-task-group-local'),
);
final remoteGroup = find.byKey(
const ValueKey<String>('assistant-task-group-remote'),
);
expect(aiGroup, findsOneWidget);
expect(localGroup, findsOneWidget);
expect(remoteGroup, findsOneWidget);
expect(
tester.getTopLeft(aiGroup).dy,
lessThan(tester.getTopLeft(localGroup).dy),
);
expect(
tester.getTopLeft(localGroup).dy,
lessThan(tester.getTopLeft(remoteGroup).dy),
);
}, skip: true);
testWidgets('AssistantPage keeps the artifact pane collapsed until opened', (
WidgetTester tester,
) async {
final controller = await createTestController(tester);
await pumpPage(
tester,
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
platform: TargetPlatform.macOS,
);
expect(find.byKey(const Key('assistant-artifact-pane')), findsNothing);
expect(
find.byKey(const Key('assistant-artifact-pane-toggle')),
findsOneWidget,
);
await tester.tap(find.byKey(const Key('assistant-artifact-pane-toggle')));
await _pumpForUiSync(tester);
expect(find.byKey(const Key('assistant-artifact-pane')), findsOneWidget);
final beforeWidth = tester
.getSize(find.byKey(const Key('assistant-artifact-pane')))
.width;
await tester.drag(
find.byKey(const Key('assistant-artifact-pane-resize-handle')),
const Offset(-120, 0),
);
await _pumpForUiSync(tester);
final afterWidth = tester
.getSize(find.byKey(const Key('assistant-artifact-pane')))
.width;
expect(afterWidth, greaterThan(beforeWidth));
await tester.tap(find.byKey(const Key('assistant-artifact-pane-collapse')));
await _pumpForUiSync(tester);
expect(find.byKey(const Key('assistant-artifact-pane')), findsNothing);
});
testWidgets(
'AssistantPage keeps the collapsed artifact toggle clear of top toolbar controls',
(WidgetTester tester) async {
final controller = await createTestController(tester);
await pumpPage(
tester,
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
platform: TargetPlatform.macOS,
);
final toggle = find.byKey(const Key('assistant-artifact-pane-toggle'));
final viewMode = find.byKey(
const Key('assistant-message-view-mode-button'),
);
final connectionChip = find.byKey(const Key('assistant-connection-chip'));
expect(toggle, findsOneWidget);
expect(viewMode, findsOneWidget);
expect(connectionChip, findsOneWidget);
final toggleRect = tester.getRect(toggle);
final viewModeRect = tester.getRect(viewMode);
final connectionRect = tester.getRect(connectionChip);
expect(toggleRect.overlaps(viewModeRect), isFalse);
expect(toggleRect.overlaps(connectionRect), isFalse);
},
);
testWidgets('AssistantPage uses a compact collapsed artifact toggle', (
WidgetTester tester,
) async {
final controller = await createTestController(tester);
await pumpPage(
tester,
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
platform: TargetPlatform.macOS,
);
final toggle = find.byKey(const Key('assistant-artifact-pane-toggle'));
final decoratedBody = find.descendant(
of: toggle,
matching: find.byWidgetPredicate(
(widget) => widget is Container && widget.decoration is BoxDecoration,
),
);
expect(toggle, findsOneWidget);
expect(tester.getSize(toggle), const Size(32, 36));
final body = tester.widget<Container>(decoratedBody);
final decoration = body.decoration! as BoxDecoration;
expect(decoration.borderRadius, BorderRadius.circular(8));
});
testWidgets(
'AssistantPage shows Single Agent provider selector on the right',
(WidgetTester tester) async {},
skip: true,
);
testWidgets('AssistantPage shows three collapsed task groups by default', (
WidgetTester tester,
) async {
final controller = await createTestController(tester);
await pumpPage(
tester,
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
);
expect(
find.byKey(const ValueKey<String>('assistant-task-group-singleAgent')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey<String>('assistant-task-group-local')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey<String>('assistant-task-group-remote')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey<String>('assistant-task-item-main')),
findsNothing,
);
await tester.tap(
find.byKey(const ValueKey<String>('assistant-task-group-local')),
);
await _pumpForUiSync(tester);
expect(
find.byKey(const ValueKey<String>('assistant-task-item-main')),
findsOneWidget,
);
});
testWidgets('AssistantPage can switch unified side pane tabs and collapse', (
WidgetTester tester,
) async {
final controller = await createTestController(tester);
await pumpPage(
tester,
child: AssistantPage(
controller: controller,
onOpenDetail: (_) {},
navigationPanelBuilder: (_) => const ColoredBox(
key: Key('assistant-nav-panel-probe'),
color: Colors.red,
),
showStandaloneTaskRail: false,
),
);
expect(find.byKey(const Key('assistant-side-pane')), findsOneWidget);
expect(find.byKey(const Key('assistant-task-rail')), findsOneWidget);
expect(find.byKey(const Key('assistant-nav-panel-probe')), findsNothing);
await tester.tap(
find.byKey(const Key('assistant-side-pane-tab-navigation')),
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('assistant-nav-panel-probe')), findsOneWidget);
await tester.tap(find.byKey(const Key('assistant-side-pane-toggle')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('assistant-nav-panel-probe')), findsNothing);
expect(find.byKey(const Key('assistant-side-pane')), findsOneWidget);
});
testWidgets(
'AssistantPage shows ARIS chip when multi-agent ARIS is enabled',
(WidgetTester tester) async {
final controller = await createTestController(tester);
final multiAgentConfig = controller.settings.multiAgent.copyWith(
enabled: true,
framework: MultiAgentFramework.aris,
arisEnabled: true,
);
await controller.settingsController.saveSnapshot(
controller.settings.copyWith(multiAgent: multiAgentConfig),
);
controller.multiAgentOrchestrator.updateConfig(multiAgentConfig);
await tester.pumpWidget(
MaterialApp(
locale: const Locale('zh'),
supportedLocales: const [Locale('zh'), Locale('en')],
localizationsDelegates: GlobalMaterialLocalizations.delegates,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
home: Scaffold(
body: AssistantPage(controller: controller, onOpenDetail: (_) {}),
),
),
);
await tester.pump();
expect(find.text('ARIS'), findsWidgets);
},
skip: true,
);
testWidgets('AssistantPage narrow layout keeps existing single-pane flow', (
WidgetTester tester,
) async {
final controller = await createTestController(tester);
await pumpPage(
tester,
size: const Size(820, 900),
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
);
expect(find.byKey(const Key('assistant-task-rail')), findsNothing);
expect(
find.byKey(const Key('assistant-conversation-shell')),
findsOneWidget,
);
});
testWidgets('AssistantPage offline edit action opens gateway settings', (
WidgetTester tester,
) async {
final controller = await createTestController(tester);
await pumpPage(
tester,
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
);
await tester.tap(find.text('编辑连接'));
await tester.pumpAndSettle();
expect(controller.destination, WorkspaceDestination.settings);
expect(controller.settingsDetail, SettingsDetailPage.gatewayConnection);
});
testWidgets(
'AssistantPage empty state stays above the composer instead of centering over the workspace',
(WidgetTester tester) async {
final controller = await createTestController(tester);
await pumpPage(
tester,
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
);
final emptyState = find.byKey(const Key('assistant-empty-state-card'));
final composerShell = find.byKey(const Key('assistant-composer-shell'));
expect(emptyState, findsOneWidget);
expect(composerShell, findsOneWidget);
expect(
tester.getRect(emptyState).bottom,
lessThan(tester.getRect(composerShell).top),
);
},
);
testWidgets('AssistantPage keeps a minimal composer action menu', (
WidgetTester tester,
) async {
final controller = await createTestController(tester);
await pumpPage(
tester,
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
);
expect(find.text('幻灯片'), findsNothing);
expect(find.text('视频生成'), findsNothing);
expect(find.text('深度研究'), findsNothing);
expect(find.text('自动化'), findsNothing);
expect(find.textContaining('输入需求、补充上下文'), findsOneWidget);
expect(
find.byKey(const Key('assistant-attachment-menu-button')),
findsOneWidget,
);
expect(
find.byKey(const Key('assistant-execution-target-button')),
findsOneWidget,
);
expect(
find.byKey(const Key('assistant-skill-picker-button')),
findsOneWidget,
);
expect(
find.byKey(const Key('assistant-permission-button')),
findsOneWidget,
);
expect(find.byKey(const Key('assistant-model-button')), findsOneWidget);
expect(find.byKey(const Key('assistant-thinking-button')), findsOneWidget);
expect(find.byTooltip('模式'), findsNothing);
await tester.tap(find.byKey(const Key('assistant-attachment-menu-button')));
await _pumpForUiSync(tester);
expect(find.text('添加照片和文件'), findsOneWidget);
expect(find.text('计划模式'), findsNothing);
expect(find.text('连接网关'), findsNothing);
expect(find.text('浏览器 / 编码 / 研究'), findsNothing);
await tester.tapAt(const Offset(24, 24));
await _pumpForUiSync(tester);
await tester.tap(
find.byKey(const Key('assistant-execution-target-button')),
);
await _pumpForUiSync(tester);
expect(find.text('单机智能体'), findsWidgets);
expect(find.text('本地 OpenClaw Gateway'), findsWidgets);
expect(find.text('远程 OpenClaw Gateway'), findsWidgets);
});
testWidgets(
'AssistantPage shows a persistent skill popover in single-agent mode and keeps thread selections isolated',
(WidgetTester tester) async {
late final Directory tempDirectory;
late final AppController controller;
await tester.runAsync(() async {
tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-assistant-skills-ui-',
);
final agentsRoot = Directory('${tempDirectory.path}/agents-skills');
final customRootA = Directory('${tempDirectory.path}/custom-skills-a');
final customRootB = Directory('${tempDirectory.path}/custom-skills-b');
await _writeSkill(
agentsRoot,
'browser',
skillName: 'Browser Automation',
description: 'Browse websites',
);
await _writeSkill(
customRootA,
'ppt',
skillName: 'PPT',
description: 'Presentation skill',
);
await _writeSkill(
customRootB,
'wordx',
skillName: 'WordX',
description: 'Document skill',
);
controller = await _createControllerWithThreadRecords(
records: const <AssistantThreadRecord>[],
useFakeGatewayRuntime: true,
singleAgentSharedSkillScanRootOverrides: <String>[
agentsRoot.path,
customRootA.path,
customRootB.path,
],
);
});
addTearDown(() async {
if (await tempDirectory.exists()) {
try {
await tempDirectory.delete(recursive: true);
} catch (_) {}
}
});
addTearDown(controller.dispose);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(1600, 1000);
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
await tester.pumpWidget(
MaterialApp(
locale: const Locale('zh'),
supportedLocales: const [Locale('zh'), Locale('en')],
localizationsDelegates: GlobalMaterialLocalizations.delegates,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
home: Scaffold(
body: AssistantPage(controller: controller, onOpenDetail: (_) {}),
),
),
);
await _pumpForUiSync(tester);
await tester.runAsync(() async {
await _waitForCondition(
() =>
controller
.assistantImportedSkillsForSession(
controller.currentSessionKey,
)
.length ==
3,
);
});
await _pumpForUiSync(tester);
await tester.tap(find.byKey(const Key('assistant-skill-picker-button')));
await _pumpForUiSync(tester);
expect(
find.byKey(const Key('assistant-skill-picker-popover')),
findsOneWidget,
);
expect(
find.byKey(const Key('assistant-skill-picker-dialog')),
findsNothing,
);
await tester.enterText(
find.byKey(const Key('assistant-skill-picker-search')),
'browser',
);
await _pumpForUiSync(tester);
expect(find.text('Browser Automation'), findsOneWidget);
expect(find.text('PPT'), findsNothing);
final browserSkill = controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.firstWhere((skill) => skill.label == 'Browser Automation');
final pptSkill = controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.firstWhere((skill) => skill.label == 'PPT');
final wordxSkill = controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.firstWhere((skill) => skill.label == 'WordX');
await tester.tap(
find.byKey(
ValueKey<String>('assistant-skill-option-${browserSkill.key}'),
),
);
await _pumpForUiSync(tester);
expect(
find.byKey(const Key('assistant-skill-picker-popover')),
findsOneWidget,
);
expect(
find.byKey(
ValueKey<String>('assistant-selected-skill-${browserSkill.key}'),
),
findsOneWidget,
);
await tester.enterText(
find.byKey(const Key('assistant-skill-picker-search')),
'',
);
await _pumpForUiSync(tester);
await tester.tap(
find.byKey(ValueKey<String>('assistant-skill-option-${pptSkill.key}')),
);
await _pumpForUiSync(tester);
expect(
find.byKey(const Key('assistant-skill-picker-popover')),
findsOneWidget,
);
expect(
find.byKey(
ValueKey<String>('assistant-selected-skill-${pptSkill.key}'),
),
findsOneWidget,
);
await tester.tapAt(const Offset(24, 24));
await _pumpForUiSync(tester);
expect(
find.byKey(const Key('assistant-skill-picker-popover')),
findsNothing,
);
controller.initializeAssistantThreadContext(
'draft:task-b',
title: 'Task B',
executionTarget: AssistantExecutionTarget.singleAgent,
messageViewMode: AssistantMessageViewMode.rendered,
);
await tester.runAsync(() async {
await controller.switchSession('draft:task-b');
});
await _pumpForUiSync(tester);
expect(
find.byKey(
ValueKey<String>('assistant-selected-skill-${browserSkill.key}'),
),
findsNothing,
);
expect(
find.byKey(
ValueKey<String>('assistant-selected-skill-${pptSkill.key}'),
),
findsNothing,
);
await tester.tap(find.byKey(const Key('assistant-skill-picker-button')));
await _pumpForUiSync(tester);
await tester.tap(
find.byKey(
ValueKey<String>('assistant-skill-option-${wordxSkill.key}'),
),
);
await _pumpForUiSync(tester);
expect(
find.byKey(
ValueKey<String>('assistant-selected-skill-${wordxSkill.key}'),
),
findsOneWidget,
);
await tester.runAsync(() async {
await controller.switchSession('main');
});
await _pumpForUiSync(tester);
expect(
find.byKey(
ValueKey<String>('assistant-selected-skill-${browserSkill.key}'),
),
findsOneWidget,
);
expect(
find.byKey(
ValueKey<String>('assistant-selected-skill-${pptSkill.key}'),
),
findsOneWidget,
);
expect(
find.byKey(
ValueKey<String>('assistant-selected-skill-${wordxSkill.key}'),
),
findsNothing,
);
},
);
testWidgets('AssistantPage hides gated attachment and multi-agent actions', (
WidgetTester tester,
) async {
final manifest = UiFeatureManifest.fallback()
.copyWithFeature(
platform: UiFeaturePlatform.desktop,
module: 'assistant',
feature: 'file_attachments',
enabled: false,
)
.copyWithFeature(
platform: UiFeaturePlatform.desktop,
module: 'assistant',
feature: 'multi_agent',
enabled: false,
);
final controller = await createTestController(
tester,
uiFeatureManifest: manifest,
);
await pumpPage(
tester,
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
platform: TargetPlatform.macOS,
);
expect(
find.byKey(const Key('assistant-attachment-menu-button')),
findsNothing,
);
expect(
find.byKey(const Key('assistant-collaboration-toggle')),
findsNothing,
);
});
testWidgets('AssistantPage composer input area can be resized vertically', (
WidgetTester tester,
) async {
final controller = await createTestController(tester);
await pumpPage(
tester,
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
);
final inputArea = find.byKey(const Key('assistant-composer-input-area'));
final resizeHandle = find.byKey(
const Key('assistant-composer-resize-handle'),
);
final conversationShell = find.byKey(
const Key('assistant-conversation-shell'),
);
final composerShell = find.byKey(const Key('assistant-composer-shell'));
expect(inputArea, findsOneWidget);
expect(resizeHandle, findsOneWidget);
expect(conversationShell, findsOneWidget);
expect(composerShell, findsOneWidget);
final initialHeight = tester.getSize(inputArea).height;
final initialComposerHeight = tester.getRect(composerShell).height;
final initialConversationHeight = tester.getRect(conversationShell).height;
await tester.drag(resizeHandle, const Offset(0, 40));
await tester.pumpAndSettle();
final expandedHeight = tester.getSize(inputArea).height;
final expandedComposerHeight = tester.getRect(composerShell).height;
final expandedConversationHeight = tester.getRect(conversationShell).height;
expect(expandedHeight, greaterThan(initialHeight));
expect(expandedComposerHeight, greaterThan(initialComposerHeight));
expect(expandedConversationHeight, lessThan(initialConversationHeight));
});
testWidgets('AssistantPage workspace split can be resized vertically', (
WidgetTester tester,
) async {
final controller = await createTestController(tester);
await pumpPage(
tester,
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
);
final resizeHandle = find.byKey(
const Key('assistant-workspace-resize-handle'),
);
final conversationShell = find.byKey(
const Key('assistant-conversation-shell'),
);
final composerShell = find.byKey(const Key('assistant-composer-shell'));
expect(resizeHandle, findsOneWidget);
expect(conversationShell, findsOneWidget);
expect(composerShell, findsOneWidget);
final initialComposerHeight = tester.getRect(composerShell).height;
final initialConversationHeight = tester.getRect(conversationShell).height;
await tester.drag(resizeHandle, const Offset(0, 40));
await tester.pumpAndSettle();
final shrunkComposerHeight = tester.getRect(composerShell).height;
final expandedConversationHeight = tester.getRect(conversationShell).height;
expect(shrunkComposerHeight, lessThan(initialComposerHeight));
expect(expandedConversationHeight, greaterThan(initialConversationHeight));
});
testWidgets(
'AssistantPage keeps all three panes tightly packed after resize',
(WidgetTester tester) async {
final controller = await createTestController(tester);
await pumpPage(
tester,
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
platform: TargetPlatform.macOS,
);
final pageRect = tester.getRect(find.byType(AssistantPage));
final taskRail = find.byKey(const Key('assistant-task-rail'));
final horizontalHandle = find.byType(PaneResizeHandle).first;
final verticalHandle = find.byKey(
const Key('assistant-workspace-resize-handle'),
);
final conversationShell = find.byKey(
const Key('assistant-conversation-shell'),
);
final composerShell = find.byKey(const Key('assistant-composer-shell'));
await tester.drag(horizontalHandle, const Offset(360, 0));
await tester.pumpAndSettle();
await tester.drag(verticalHandle, const Offset(0, 260));
await tester.pumpAndSettle();
final taskRailRect = tester.getRect(taskRail);
final horizontalHandleRect = tester.getRect(horizontalHandle);
final conversationRect = tester.getRect(conversationShell);
final verticalHandleRect = tester.getRect(verticalHandle);
final composerRect = tester.getRect(composerShell);
expect(taskRailRect.left, moreOrLessEquals(pageRect.left, epsilon: 0.01));
expect(
taskRailRect.right,
moreOrLessEquals(horizontalHandleRect.left, epsilon: 0.01),
);
expect(
horizontalHandleRect.right,
moreOrLessEquals(conversationRect.left, epsilon: 2.01),
);
expect(
conversationRect.top,
moreOrLessEquals(pageRect.top, epsilon: 0.01),
);
expect(
conversationRect.bottom,
moreOrLessEquals(verticalHandleRect.top, epsilon: 0.01),
);
expect(
verticalHandleRect.bottom,
moreOrLessEquals(composerRect.top, epsilon: 0.01),
);
expect(
composerRect.bottom,
moreOrLessEquals(pageRect.bottom, epsilon: 0.01),
);
expect(
composerRect.right,
moreOrLessEquals(pageRect.right, epsilon: 0.01),
);
expect(conversationRect.width, greaterThan(620));
expect(conversationRect.height, greaterThanOrEqualTo(180));
expect(composerRect.height, greaterThanOrEqualTo(124));
},
);
// Known flutter_tester host-exit hang in this widget scenario.
testWidgets(
'AssistantPage syncs task selection with execution target menu and connection chip',
(WidgetTester tester) async {
final controller = await _createControllerWithThreadRecords(
records: const <AssistantThreadRecord>[],
useFakeGatewayRuntime: true,
);
addTearDown(controller.dispose);
await pumpPage(
tester,
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
);
await tester.tap(find.byKey(const Key('assistant-new-task-button')));
await _pumpForUiSync(tester);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.singleAgent,
);
await _pumpForUiSync(tester);
await tester.tap(
find.byKey(const ValueKey<String>('assistant-task-item-main')),
);
await _pumpForUiSync(tester);
expect(
find.descendant(
of: find.byKey(const Key('assistant-execution-target-button')),
matching: find.text('本地 OpenClaw Gateway'),
),
findsOneWidget,
);
expect(find.textContaining('离线 · 未连接目标'), findsOneWidget);
final aiThreadItem = find.byWidgetPredicate(
(widget) =>
widget.key is ValueKey<String> &&
(widget.key as ValueKey<String>).value.startsWith(
'assistant-task-item-draft:',
),
);
expect(aiThreadItem, findsOneWidget);
await tester.tap(aiThreadItem);
await _pumpForUiSync(tester);
expect(
find.descendant(
of: find.byKey(const Key('assistant-execution-target-button')),
matching: find.text('单机智能体'),
),
findsOneWidget,
);
expect(find.textContaining('单机智能体'), findsWidgets);
},
skip: true,
);
testWidgets('AssistantPage shows thread-level message view chip', (
WidgetTester tester,
) async {
final controller = await createTestController(tester);
await pumpPage(
tester,
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
);
expect(
find.byKey(const Key('assistant-message-view-mode-button')),
findsOneWidget,
);
expect(find.text('渲染'), findsOneWidget);
});
testWidgets(
'AssistantPage keeps attached files and execution context collapsed by default',
(WidgetTester tester) async {
final controller = await _createControllerWithThreadRecords(
records: const <AssistantThreadRecord>[
AssistantThreadRecord(
sessionKey: 'main',
title: '研发任务',
archived: false,
executionTarget: AssistantExecutionTarget.singleAgent,
messageViewMode: AssistantMessageViewMode.raw,
updatedAtMs: 1700000000000,
messages: <GatewayChatMessage>[
GatewayChatMessage(
id: 'user-1',
role: 'user',
text:
'Attached files:\n'
'- clipboard-image-1.png\n\n'
'Execution context:\n'
'- target: single-agent\n'
'- provider: codex\n'
'- workspace_root: /opt/data/workspace\n'
'- permission: full-access\n\n'
'结合项目代码制作一份用户手册',
timestampMs: 1700000000000,
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: false,
),
],
),
],
useFakeGatewayRuntime: true,
);
addTearDown(controller.dispose);
await pumpPage(
tester,
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
);
expect(find.text('结合项目代码制作一份用户手册'), findsOneWidget);
expect(
find.byKey(const Key('assistant-user-meta-attachments-toggle')),
findsOneWidget,
);
expect(
find.byKey(const Key('assistant-user-meta-context-toggle')),
findsOneWidget,
);
expect(
find.byKey(const Key('assistant-user-meta-attachments-block')),
findsNothing,
);
expect(
find.byKey(const Key('assistant-user-meta-context-block')),
findsNothing,
);
await tester.tap(
find.byKey(const Key('assistant-user-meta-attachments-toggle')),
);
await _pumpForUiSync(tester);
expect(
find.byKey(const Key('assistant-user-meta-attachments-block')),
findsOneWidget,
);
expect(find.text('Attached files:'), findsOneWidget);
await tester.tap(
find.byKey(const Key('assistant-user-meta-context-toggle')),
);
await _pumpForUiSync(tester);
expect(
find.byKey(const Key('assistant-user-meta-context-block')),
findsOneWidget,
);
expect(find.text('Execution context:'), findsOneWidget);
},
// Known flutter_tester host-exit hang in this widget scenario.
skip: true,
);
// Known flutter_tester host-exit hang in this widget scenario.
testWidgets('AssistantPage toggles Markdown Rendered and RAW per thread', (
WidgetTester tester,
) async {
final controller = await _createControllerWithThreadRecords(
records: const <AssistantThreadRecord>[
AssistantThreadRecord(
sessionKey: 'main',
title: '研发任务',
archived: false,
executionTarget: AssistantExecutionTarget.singleAgent,
messageViewMode: AssistantMessageViewMode.rendered,
updatedAtMs: 1700000000000,
messages: <GatewayChatMessage>[
GatewayChatMessage(
id: 'user-1',
role: 'user',
text: '请看这个清单',
timestampMs: 1700000000000,
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: false,
),
GatewayChatMessage(
id: 'assistant-1',
role: 'assistant',
text: '## 标题\\n\\n- 第一项\\n- 第二项',
timestampMs: 1700000001000,
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: false,
),
],
),
],
useFakeGatewayRuntime: true,
);
addTearDown(controller.dispose);
await pumpPage(
tester,
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
);
expect(find.byType(MarkdownBody), findsOneWidget);
await tester.tap(
find.byKey(const Key('assistant-message-view-mode-button')),
);
await _pumpForUiSync(tester);
await tester.tap(find.text('RAW').last);
await _pumpForUiSync(tester);
expect(
controller.currentAssistantMessageViewMode,
AssistantMessageViewMode.raw,
);
expect(find.byType(MarkdownBody), findsNothing);
}, skip: true);
// Known flutter_tester host-exit hang in this widget scenario.
testWidgets(
'AssistantPage shows Single Agent chip and keeps task rows minimal',
(WidgetTester tester) async {
final controller = await createTestController(tester);
await controller.settingsController.saveAiGatewayApiKey('live-key');
await controller.saveSettings(
controller.settings.copyWith(
aiGateway: controller.settings.aiGateway.copyWith(
baseUrl: 'http://127.0.0.1:11434/v1',
availableModels: const <String>['qwen2.5-coder:latest'],
selectedModels: const <String>['qwen2.5-coder:latest'],
),
defaultModel: 'qwen2.5-coder:latest',
assistantExecutionTarget: AssistantExecutionTarget.singleAgent,
),
refreshAfterSave: false,
);
await pumpPage(
tester,
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
);
expect(
find.byKey(const Key('assistant-connection-chip')),
findsOneWidget,
);
expect(
find.text('Auto · qwen2.5-coder:latest · 127.0.0.1:11434'),
findsOneWidget,
);
expect(find.text('等待描述这个任务的第一条消息'), findsNothing);
await tester.tap(find.byKey(const Key('assistant-new-task-button')));
await tester.pumpAndSettle();
expect(find.text('等待描述这个任务的第一条消息'), findsNothing);
},
skip: true,
);
}
Future<AppController> _createControllerWithThreadRecords({
WidgetTester? tester,
required List<AssistantThreadRecord> records,
bool useFakeGatewayRuntime = false,
List<String>? singleAgentSharedSkillScanRootOverrides,
}) async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-assistant-page-tests-',
);
final store = SecureConfigStore(
enableSecureStorage: false,
databasePathResolver: () async => '${tempDirectory.path}/settings.db',
fallbackDirectoryPathResolver: () async => tempDirectory.path,
defaultSupportDirectoryPathResolver: () async => tempDirectory.path,
);
addTearDown(() async {
if (await tempDirectory.exists()) {
try {
await tempDirectory.delete(recursive: true);
} catch (_) {}
}
});
final defaults = SettingsSnapshot.defaults();
await store.saveSettingsSnapshot(
defaults.copyWith(
gatewayProfiles: replaceGatewayProfileAt(
replaceGatewayProfileAt(
defaults.gatewayProfiles,
kGatewayLocalProfileIndex,
defaults.primaryLocalGatewayProfile.copyWith(
host: '127.0.0.1',
port: 9,
tls: false,
),
),
kGatewayRemoteProfileIndex,
defaults.primaryRemoteGatewayProfile.copyWith(
host: '127.0.0.1',
port: 9,
tls: false,
),
),
aiGateway: defaults.aiGateway.copyWith(
baseUrl: 'http://127.0.0.1:11434/v1',
availableModels: const <String>['qwen2.5-coder:latest'],
selectedModels: const <String>['qwen2.5-coder:latest'],
),
assistantExecutionTarget: AssistantExecutionTarget.singleAgent,
defaultModel: 'qwen2.5-coder:latest',
workspacePath: tempDirectory.path,
),
);
await store.saveAssistantThreadRecords(records);
final controller = AppController(
store: store,
runtimeCoordinator: useFakeGatewayRuntime
? RuntimeCoordinator(
gateway: _FakeGatewayRuntime(store: store),
codex: _FakeCodexRuntime(),
)
: null,
singleAgentSharedSkillScanRootOverrides:
singleAgentSharedSkillScanRootOverrides,
);
final stopwatch = Stopwatch()..start();
while (controller.initializing) {
if (stopwatch.elapsed > const Duration(seconds: 10)) {
fail('controller did not finish initializing before timeout');
}
if (tester != null) {
await tester.pump(const Duration(milliseconds: 20));
} else {
await Future<void>.delayed(const Duration(milliseconds: 20));
}
}
return controller;
}
Future<void> _writeSkill(
Directory root,
String folderName, {
required String skillName,
required String description,
}) async {
final directory = Directory('${root.path}/$folderName');
await directory.create(recursive: true);
await File(
'${directory.path}/SKILL.md',
).writeAsString('---\nname: $skillName\ndescription: $description\n---\n');
}
Future<void> _pumpForUiSync(WidgetTester tester) async {
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
}
Future<void> _waitForCondition(bool Function() predicate) async {
final deadline = DateTime.now().add(const Duration(seconds: 20));
while (!predicate()) {
if (DateTime.now().isAfter(deadline)) {
fail('Timed out waiting for condition');
}
await Future<void>.delayed(const Duration(milliseconds: 20));
}
}
class _FakeGatewayRuntime extends GatewayRuntime {
_FakeGatewayRuntime({required super.store})
: super(identityStore: DeviceIdentityStore(store));
GatewayConnectionSnapshot _snapshot = GatewayConnectionSnapshot.initial();
@override
bool get isConnected => _snapshot.status == RuntimeConnectionStatus.connected;
@override
GatewayConnectionSnapshot get snapshot => _snapshot;
@override
Stream<GatewayPushEvent> get events => const Stream<GatewayPushEvent>.empty();
@override
Future<void> connectProfile(
GatewayConnectionProfile profile, {
int? profileIndex,
String authTokenOverride = '',
String authPasswordOverride = '',
}) async {
_snapshot = GatewayConnectionSnapshot.initial(mode: profile.mode).copyWith(
status: RuntimeConnectionStatus.connected,
statusText: 'Connected',
remoteAddress: '${profile.host}:${profile.port}',
connectAuthMode: 'none',
);
notifyListeners();
}
@override
Future<void> disconnect({bool clearDesiredProfile = true}) async {
_snapshot = _snapshot.copyWith(
status: RuntimeConnectionStatus.offline,
statusText: 'Offline',
remoteAddress: null,
clearLastError: true,
clearLastErrorCode: true,
clearLastErrorDetailCode: true,
);
notifyListeners();
}
@override
Future<dynamic> request(
String method, {
Map<String, dynamic>? params,
Duration timeout = const Duration(seconds: 30),
}) async {
switch (method) {
case 'health':
case 'status':
return <String, dynamic>{'ok': true};
case 'agents.list':
return <String, dynamic>{'agents': const <Object>[], 'mainKey': 'main'};
case 'sessions.list':
return <String, dynamic>{'sessions': const <Object>[]};
case 'chat.history':
return <String, dynamic>{'messages': const <Object>[]};
case 'skills.status':
return <String, dynamic>{'skills': const <Object>[]};
case 'channels.status':
return <String, dynamic>{
'channelMeta': const <Object>[],
'channelLabels': const <String, dynamic>{},
'channelDetailLabels': const <String, dynamic>{},
'channelAccounts': const <String, dynamic>{},
'channelOrder': const <Object>[],
};
case 'models.list':
return <String, dynamic>{'models': const <Object>[]};
case 'cron.list':
return <String, dynamic>{'jobs': const <Object>[]};
case 'device.pair.list':
return <String, dynamic>{
'pending': const <Object>[],
'paired': const <Object>[],
};
case 'system-presence':
return const <Object>[];
default:
return <String, dynamic>{};
}
}
}
class _FakeCodexRuntime extends CodexRuntime {
@override
Future<String?> findCodexBinary() async => null;
@override
Future<void> stop() async {}
}