Unify gateway settings actions and harden persistence tests
This commit is contained in:
parent
83c73a4ceb
commit
936ddd665b
@ -134,9 +134,15 @@ mobile:
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile settings gateway tab
|
||||
ui_surface: settings_page
|
||||
gateway_setup_code:
|
||||
enabled: false
|
||||
release_tier: experimental
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile gateway setup code editor
|
||||
ui_surface: settings_page
|
||||
agents:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
enabled: false
|
||||
release_tier: experimental
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile settings multi-agent tab
|
||||
ui_surface: settings_page
|
||||
@ -307,9 +313,15 @@ desktop:
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop settings gateway tab
|
||||
ui_surface: settings_page
|
||||
gateway_setup_code:
|
||||
enabled: false
|
||||
release_tier: experimental
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop gateway setup code editor
|
||||
ui_surface: settings_page
|
||||
agents:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
enabled: false
|
||||
release_tier: experimental
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop settings multi-agent tab
|
||||
ui_surface: settings_page
|
||||
@ -420,6 +432,12 @@ web:
|
||||
build_modes: [debug, profile, release]
|
||||
description: Web settings gateway tab
|
||||
ui_surface: web_settings_page
|
||||
gateway_setup_code:
|
||||
enabled: false
|
||||
release_tier: experimental
|
||||
build_modes: []
|
||||
description: Web does not expose gateway setup code editor
|
||||
ui_surface: web_settings_page
|
||||
appearance:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
|
||||
@ -161,6 +161,19 @@ XWorkmate 统一维护两类状态:
|
||||
- 远程 Gateway 不允许静默降级为非 TLS
|
||||
- 协作事件与 metadata 不上传本地 secret 或本机绝对路径
|
||||
|
||||
## 8. 设置页统一动作语义(Gateway 家族)
|
||||
|
||||
`OpenClaw Gateway`、`Vault`、`AI Gateway`(以及后续外部扩展)统一遵循同一操作语义:
|
||||
|
||||
- `Test`:只使用当前草稿(含当前输入的临时 secret 覆盖)做连通性校验,不写入持久层。
|
||||
- `Save`:把草稿同步到本地持久存储(`SettingsStore` + `SecretStore`),不立即改变运行时会话行为。
|
||||
- `Apply`:在 `Save` 的基础上,立即让当前运行时按新配置生效。
|
||||
|
||||
实现约束:
|
||||
|
||||
- Gateway 集成页不再重复显示顶层全局 `Save / Apply`,避免与卡片内动作语义冲突。
|
||||
- `settings.gateway_setup_code` 与 `settings.agents` 当前均按 `experimental + enabled: false` 发布策略控制。
|
||||
|
||||
## 相关代码
|
||||
|
||||
- `lib/app/app_controller.dart`
|
||||
|
||||
@ -579,7 +579,20 @@ switchSession(sessionKey) must synchronize:
|
||||
- gatewayProfiles changes must not silently overwrite the current thread mode
|
||||
- platform capability filtering must not invent unsupported work modes
|
||||
|
||||
6.4 What task list must never do
|
||||
6.5 Integration cards share one action contract
|
||||
|
||||
Gateway-family cards (`OpenClaw Gateway` / `Vault` / `AI Gateway`) and future
|
||||
extensions must keep the same contract:
|
||||
|
||||
- Test = validate current draft only (including temporary secret overrides), no
|
||||
persistence side effects
|
||||
- Save = persist draft + secure secrets only
|
||||
- Apply = make saved draft effective in runtime/session state
|
||||
|
||||
To avoid semantic duplication, the Gateway settings tab uses local card actions
|
||||
and does not render another global Save/Apply action row.
|
||||
|
||||
6.6 What task list must never do
|
||||
|
||||
Task list must never:
|
||||
- own executionTarget
|
||||
|
||||
@ -65,6 +65,7 @@ abstract final class UiFeatureKeys {
|
||||
static const settingsGeneral = 'settings.general';
|
||||
static const settingsWorkspace = 'settings.workspace';
|
||||
static const settingsGateway = 'settings.gateway';
|
||||
static const settingsGatewaySetupCode = 'settings.gateway_setup_code';
|
||||
static const settingsAgents = 'settings.agents';
|
||||
static const settingsAppearance = 'settings.appearance';
|
||||
static const settingsDiagnostics = 'settings.diagnostics';
|
||||
@ -254,9 +255,15 @@ mobile:
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile settings gateway tab
|
||||
ui_surface: settings_page
|
||||
gateway_setup_code:
|
||||
enabled: false
|
||||
release_tier: experimental
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile gateway setup code editor
|
||||
ui_surface: settings_page
|
||||
agents:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
enabled: false
|
||||
release_tier: experimental
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile settings multi-agent tab
|
||||
ui_surface: settings_page
|
||||
@ -427,9 +434,15 @@ desktop:
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop settings gateway tab
|
||||
ui_surface: settings_page
|
||||
gateway_setup_code:
|
||||
enabled: false
|
||||
release_tier: experimental
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop gateway setup code editor
|
||||
ui_surface: settings_page
|
||||
agents:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
enabled: false
|
||||
release_tier: experimental
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop settings multi-agent tab
|
||||
ui_surface: settings_page
|
||||
@ -540,6 +553,12 @@ web:
|
||||
build_modes: [debug, profile, release]
|
||||
description: Web settings gateway tab
|
||||
ui_surface: web_settings_page
|
||||
gateway_setup_code:
|
||||
enabled: false
|
||||
release_tier: experimental
|
||||
build_modes: []
|
||||
description: Web does not expose gateway setup code editor
|
||||
ui_surface: web_settings_page
|
||||
appearance:
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
@ -910,6 +929,9 @@ class UiFeatureAccess {
|
||||
bool get supportsDiagnostics =>
|
||||
isEnabledPath(UiFeatureKeys.settingsDiagnostics);
|
||||
|
||||
bool get supportsGatewaySetupCode =>
|
||||
isEnabledPath(UiFeatureKeys.settingsGatewaySetupCode);
|
||||
|
||||
List<SettingsTab> get availableSettingsTabs {
|
||||
return SettingsTab.values
|
||||
.where(
|
||||
|
||||
@ -61,6 +61,8 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
String _gatewaySetupCodeSyncedValue = '';
|
||||
String _gatewayHostSyncedValue = '';
|
||||
String _gatewayPortSyncedValue = '';
|
||||
_SecretFieldUiState _gatewayTokenState = const _SecretFieldUiState();
|
||||
_SecretFieldUiState _gatewayPasswordState = const _SecretFieldUiState();
|
||||
bool _aiGatewayTesting = false;
|
||||
String _aiGatewayTestState = 'idle';
|
||||
String _aiGatewayTestMessage = '';
|
||||
@ -139,6 +141,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
_navigationContext = controller.settingsNavigationContext;
|
||||
final settings = controller.settingsDraft;
|
||||
final showingDetail = _detail != null;
|
||||
final showGlobalApplyBar = _tab != SettingsTab.gateway;
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(32, 32, 32, 8),
|
||||
child: Column(
|
||||
@ -184,8 +187,10 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildGlobalApplyBar(context, controller),
|
||||
const SizedBox(height: 16),
|
||||
if (showGlobalApplyBar) ...[
|
||||
_buildGlobalApplyBar(context, controller),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
if (!showingDetail) ...[
|
||||
SectionTabs(
|
||||
items: availableTabs.map((item) => item.label).toList(),
|
||||
@ -912,21 +917,19 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
selectedProfileIndex,
|
||||
gatewayProfile,
|
||||
);
|
||||
final uiFeatures = controller.featuresFor(
|
||||
resolveUiFeaturePlatformFromContext(context),
|
||||
);
|
||||
final setupCodeFeatureEnabled = uiFeatures.supportsGatewaySetupCode;
|
||||
final useSetupCode = selectedProfileIndex == kGatewayLocalProfileIndex
|
||||
? false
|
||||
: gatewayProfile.useSetupCode;
|
||||
: setupCodeFeatureEnabled && gatewayProfile.useSetupCode;
|
||||
final gatewayTls = gatewayMode == RuntimeConnectionMode.local
|
||||
? false
|
||||
: gatewayProfile.tls;
|
||||
final hasStoredGatewayToken = controller.hasStoredGatewayToken;
|
||||
final hasStoredGatewayPassword =
|
||||
controller.settingsController.secureRefs['gateway_password'] != null;
|
||||
final typedGatewayToken = _gatewayTokenController.text.trim();
|
||||
final willUseStoredGatewayToken =
|
||||
typedGatewayToken.isEmpty && hasStoredGatewayToken;
|
||||
final showSharedTokenStatusCard =
|
||||
gatewayMode != RuntimeConnectionMode.unconfigured &&
|
||||
(willUseStoredGatewayToken || typedGatewayToken.isNotEmpty);
|
||||
|
||||
return SurfaceCard(
|
||||
child: Column(
|
||||
@ -983,7 +986,8 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (selectedProfileIndex != kGatewayLocalProfileIndex) ...[
|
||||
if (selectedProfileIndex != kGatewayLocalProfileIndex &&
|
||||
setupCodeFeatureEnabled) ...[
|
||||
SectionTabs(
|
||||
items: [appText('配置码', 'Setup Code'), appText('手动配置', 'Manual')],
|
||||
value: useSetupCode
|
||||
@ -1083,65 +1087,45 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
key: const ValueKey('gateway-shared-token-field'),
|
||||
_buildSecureField(
|
||||
fieldKey: const ValueKey('gateway-shared-token-field'),
|
||||
controller: _gatewayTokenController,
|
||||
obscureText: true,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: appText('共享 Token', 'Shared Token'),
|
||||
hintText: appText(
|
||||
'可选:覆盖默认 Gateway Token',
|
||||
'Optional override for gateway token',
|
||||
),
|
||||
label: appText('共享 Token', 'Shared Token'),
|
||||
hasStoredValue: hasStoredGatewayToken,
|
||||
fieldState: _gatewayTokenState,
|
||||
onStateChanged: (value) =>
|
||||
setState(() => _gatewayTokenState = value),
|
||||
loadValue: controller.settingsController.loadGatewayToken,
|
||||
onSubmitted: (value) async =>
|
||||
controller.saveGatewayTokenDraft(value),
|
||||
storedHelperText: appText(
|
||||
'已安全保存,默认以 **** 显示;可直接测试,也可通过本区保存/应用提交。',
|
||||
'Stored securely. Test directly or submit with local Save / Apply actions.',
|
||||
),
|
||||
emptyHelperText: appText(
|
||||
'输入后先进入草稿;通过本区保存/应用提交。',
|
||||
'Values stage into draft first; submit with local Save / Apply actions.',
|
||||
),
|
||||
onChanged: (_) =>
|
||||
controller.saveGatewayTokenDraft(_gatewayTokenController.text),
|
||||
),
|
||||
if (showSharedTokenStatusCard) ...[
|
||||
const SizedBox(height: 10),
|
||||
_GatewaySecretStatusCard(
|
||||
message: willUseStoredGatewayToken
|
||||
? appText(
|
||||
'已安全保存 shared token(${controller.storedGatewayTokenMask})。留空时会直接使用它连接。',
|
||||
'A shared token is already stored securely (${controller.storedGatewayTokenMask}). Leave the field empty to connect with it.',
|
||||
)
|
||||
: appText(
|
||||
'本次输入会覆盖已安全保存的 shared token。',
|
||||
'This entry will overwrite the stored shared token.',
|
||||
),
|
||||
locked: hasStoredGatewayToken,
|
||||
onClear: hasStoredGatewayToken
|
||||
? () async {
|
||||
await controller.clearStoredGatewayToken();
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
: null,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
key: const ValueKey('gateway-password-field'),
|
||||
_buildSecureField(
|
||||
fieldKey: const ValueKey('gateway-password-field'),
|
||||
controller: _gatewayPasswordController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: appText('密码', 'Password'),
|
||||
hintText: appText('可选:共享密码', 'Optional shared password'),
|
||||
helperText: hasStoredGatewayPassword
|
||||
? appText(
|
||||
'已存在安全保存的密码;输入新值后会在保存时覆盖。',
|
||||
'A password is already stored securely; entering a new value replaces it on Save.',
|
||||
)
|
||||
: appText(
|
||||
'输入后先进入草稿;保存后才会写入安全存储。',
|
||||
'Values stage into draft first and only persist after Save.',
|
||||
),
|
||||
label: appText('密码', 'Password'),
|
||||
hasStoredValue: hasStoredGatewayPassword,
|
||||
fieldState: _gatewayPasswordState,
|
||||
onStateChanged: (value) =>
|
||||
setState(() => _gatewayPasswordState = value),
|
||||
loadValue: controller.settingsController.loadGatewayPassword,
|
||||
onSubmitted: (value) async =>
|
||||
controller.saveGatewayPasswordDraft(value),
|
||||
storedHelperText: appText(
|
||||
'已安全保存,默认以 **** 显示;可直接测试,也可通过本区保存/应用提交。',
|
||||
'Stored securely. Test directly or submit with local Save / Apply actions.',
|
||||
),
|
||||
onChanged: (_) => controller.saveGatewayPasswordDraft(
|
||||
_gatewayPasswordController.text,
|
||||
emptyHelperText: appText(
|
||||
'输入后先进入草稿;通过本区保存/应用提交。',
|
||||
'Values stage into draft first; submit with local Save / Apply actions.',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@ -1360,8 +1344,8 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
'Stored securely. Test directly or submit it with the local Save / Apply actions.',
|
||||
),
|
||||
emptyHelperText: appText(
|
||||
'输入后可直接测试,也可通过本区或顶部按钮统一保存/应用。',
|
||||
'Test it now, or use the local or top-level Save / Apply actions.',
|
||||
'输入后可直接测试,也可通过本区保存/应用提交。',
|
||||
'Test it now, or submit it with the local Save / Apply actions.',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@ -2434,11 +2418,17 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
}
|
||||
|
||||
Future<void> _captureVisibleSecretDrafts(AppController controller) async {
|
||||
final gatewayToken = _gatewayTokenController.text.trim();
|
||||
final gatewayToken = _secretOverride(
|
||||
_gatewayTokenController,
|
||||
_gatewayTokenState,
|
||||
);
|
||||
if (gatewayToken.isNotEmpty) {
|
||||
controller.saveGatewayTokenDraft(gatewayToken);
|
||||
}
|
||||
final gatewayPassword = _gatewayPasswordController.text.trim();
|
||||
final gatewayPassword = _secretOverride(
|
||||
_gatewayPasswordController,
|
||||
_gatewayPasswordState,
|
||||
);
|
||||
if (gatewayPassword.isNotEmpty) {
|
||||
controller.saveGatewayPasswordDraft(gatewayPassword);
|
||||
}
|
||||
@ -2463,6 +2453,10 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
}
|
||||
|
||||
void _resetSecureFieldUiAfterPersist(AppController controller) {
|
||||
final hasStoredGatewayToken =
|
||||
controller.settingsController.secureRefs['gateway_token'] != null;
|
||||
final hasStoredGatewayPassword =
|
||||
controller.settingsController.secureRefs['gateway_password'] != null;
|
||||
final hasStoredAiGatewayApiKey =
|
||||
controller.settingsController.secureRefs['ai_gateway_api_key'] != null;
|
||||
final hasStoredVaultToken =
|
||||
@ -2470,11 +2464,21 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
final hasStoredOllamaApiKey =
|
||||
controller.settingsController.secureRefs['ollama_cloud_api_key'] !=
|
||||
null;
|
||||
_gatewayTokenState = const _SecretFieldUiState();
|
||||
_gatewayPasswordState = const _SecretFieldUiState();
|
||||
_aiGatewayApiKeyState = const _SecretFieldUiState();
|
||||
_vaultTokenState = const _SecretFieldUiState();
|
||||
_ollamaApiKeyState = const _SecretFieldUiState();
|
||||
_gatewayTokenController.clear();
|
||||
_gatewayPasswordController.clear();
|
||||
_primeSecureFieldController(
|
||||
_gatewayTokenController,
|
||||
hasStoredValue: hasStoredGatewayToken,
|
||||
fieldState: _gatewayTokenState,
|
||||
);
|
||||
_primeSecureFieldController(
|
||||
_gatewayPasswordController,
|
||||
hasStoredValue: hasStoredGatewayPassword,
|
||||
fieldState: _gatewayPasswordState,
|
||||
);
|
||||
_primeSecureFieldController(
|
||||
_aiGatewayApiKeyController,
|
||||
hasStoredValue: hasStoredAiGatewayApiKey,
|
||||
@ -2782,8 +2786,17 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
RuntimeConnectionMode.remote => AssistantExecutionTarget.remote,
|
||||
RuntimeConnectionMode.unconfigured => AssistantExecutionTarget.remote,
|
||||
};
|
||||
final token = _gatewayTokenController.text.trim();
|
||||
final password = _gatewayPasswordController.text.trim();
|
||||
var token = _secretOverride(_gatewayTokenController, _gatewayTokenState);
|
||||
var password = _secretOverride(
|
||||
_gatewayPasswordController,
|
||||
_gatewayPasswordState,
|
||||
);
|
||||
if (token.isEmpty) {
|
||||
token = await controller.settingsController.loadGatewayToken();
|
||||
}
|
||||
if (password.isEmpty) {
|
||||
password = await controller.settingsController.loadGatewayPassword();
|
||||
}
|
||||
setState(() => _gatewayTesting = true);
|
||||
try {
|
||||
final result = await controller.testGatewayConnectionDraft(
|
||||
@ -3853,47 +3866,6 @@ class _InlineSwitchField extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _GatewaySecretStatusCard extends StatelessWidget {
|
||||
const _GatewaySecretStatusCard({
|
||||
required this.message,
|
||||
required this.locked,
|
||||
this.onClear,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final bool locked;
|
||||
final Future<void> Function()? onClear;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
locked ? Icons.lock_rounded : Icons.info_outline_rounded,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(message, style: theme.textTheme.bodySmall)),
|
||||
if (onClear != null)
|
||||
TextButton(
|
||||
onPressed: () => onClear!.call(),
|
||||
child: Text(appText('清除', 'Clear')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AiGatewayFeedbackTheme {
|
||||
const _AiGatewayFeedbackTheme({
|
||||
required this.background,
|
||||
|
||||
@ -110,6 +110,7 @@ const List<WorkspaceDestination> kAssistantNavigationDestinationDefaults =
|
||||
|
||||
const List<WorkspaceDestination> kAssistantNavigationDestinationCandidates =
|
||||
<WorkspaceDestination>[
|
||||
WorkspaceDestination.tasks,
|
||||
WorkspaceDestination.skills,
|
||||
WorkspaceDestination.nodes,
|
||||
WorkspaceDestination.agents,
|
||||
|
||||
@ -136,6 +136,14 @@ class SettingsController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<String> loadGatewayToken() async {
|
||||
return (await _store.loadGatewayToken())?.trim() ?? '';
|
||||
}
|
||||
|
||||
Future<String> loadGatewayPassword() async {
|
||||
return (await _store.loadGatewayPassword())?.trim() ?? '';
|
||||
}
|
||||
|
||||
Future<void> saveOllamaCloudApiKey(String value) async {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
|
||||
@ -8,6 +8,7 @@ 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/settings/settings_page.dart';
|
||||
import 'package:xworkmate/models/app_models.dart';
|
||||
import 'package:xworkmate/runtime/codex_runtime.dart';
|
||||
@ -59,6 +60,7 @@ class _AiGatewaySettingsShortcutTestController extends AppController {
|
||||
_AiGatewaySettingsShortcutTestController({
|
||||
required super.store,
|
||||
required super.runtimeCoordinator,
|
||||
super.uiFeatureManifest,
|
||||
});
|
||||
|
||||
@override
|
||||
@ -105,12 +107,20 @@ void main() {
|
||||
databasePathResolver: () async => '${testRoot.path}/settings.sqlite3',
|
||||
fallbackDirectoryPathResolver: () async => testRoot.path,
|
||||
);
|
||||
final manifest = UiFeatureManifest.fallback().copyWithFeature(
|
||||
platform: UiFeaturePlatform.desktop,
|
||||
module: 'settings',
|
||||
feature: 'agents',
|
||||
enabled: true,
|
||||
releaseTier: UiFeatureReleaseTier.stable,
|
||||
);
|
||||
controller = _AiGatewaySettingsShortcutTestController(
|
||||
store: store,
|
||||
runtimeCoordinator: RuntimeCoordinator(
|
||||
gateway: _FakeGatewayRuntime(),
|
||||
codex: _FakeCodexRuntime(),
|
||||
),
|
||||
uiFeatureManifest: manifest,
|
||||
);
|
||||
await _waitFor(() => !controller.initializing);
|
||||
});
|
||||
|
||||
@ -14,7 +14,7 @@ import '../test_support.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'SettingsPage AI Gateway draft/save/apply flow persists edited fields through the global actions',
|
||||
'SettingsPage AI Gateway draft/save/apply flow persists edited fields through local actions',
|
||||
(WidgetTester tester) async {
|
||||
late _AiGatewaySettingsTestController controller;
|
||||
await tester.runAsync(() async {
|
||||
@ -53,10 +53,7 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
await pumpPage(
|
||||
tester,
|
||||
child: SettingsPage(controller: controller),
|
||||
);
|
||||
await pumpPage(tester, child: SettingsPage(controller: controller));
|
||||
|
||||
await tester.tap(find.text('集成'));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
@ -83,18 +80,27 @@ void main() {
|
||||
.text,
|
||||
'https://api.svc.plus/v1',
|
||||
);
|
||||
expect(find.byKey(const ValueKey('ai-gateway-save-button')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('ai-gateway-apply-button')), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey('ai-gateway-save-button')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('ai-gateway-apply-button')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('settings-global-save-button')),
|
||||
findsOneWidget,
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('settings-global-apply-button')),
|
||||
findsOneWidget,
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
expect(controller.settingsDraft.aiGateway.baseUrl, 'https://api.svc.plus/v1');
|
||||
expect(
|
||||
controller.settingsDraft.aiGateway.baseUrl,
|
||||
'https://api.svc.plus/v1',
|
||||
);
|
||||
expect(controller.settings.aiGateway.baseUrl, isEmpty);
|
||||
|
||||
final saveButton = tester.widget<OutlinedButton>(
|
||||
|
||||
@ -165,11 +165,22 @@ void main() {
|
||||
testWidgets('SettingsPage multi-agent tab keeps header readable', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final controller = await createTestController(tester);
|
||||
final manifest = UiFeatureManifest.fallback().copyWithFeature(
|
||||
platform: UiFeaturePlatform.desktop,
|
||||
module: 'settings',
|
||||
feature: 'agents',
|
||||
enabled: true,
|
||||
releaseTier: UiFeatureReleaseTier.stable,
|
||||
);
|
||||
final controller = await createTestController(
|
||||
tester,
|
||||
uiFeatureManifest: manifest,
|
||||
);
|
||||
|
||||
await pumpPage(
|
||||
tester,
|
||||
child: const SizedBox(width: 1100, height: 900, child: Placeholder()),
|
||||
platform: TargetPlatform.macOS,
|
||||
);
|
||||
await pumpPage(
|
||||
tester,
|
||||
@ -178,6 +189,7 @@ void main() {
|
||||
height: 900,
|
||||
child: SettingsPage(controller: controller),
|
||||
),
|
||||
platform: TargetPlatform.macOS,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('多 Agent'));
|
||||
@ -193,6 +205,30 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('SettingsPage hides gateway setup code editor by default', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final controller = await createTestController(tester);
|
||||
|
||||
await pumpPage(
|
||||
tester,
|
||||
child: SettingsPage(controller: controller),
|
||||
platform: TargetPlatform.macOS,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('集成'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const ValueKey('gateway-profile-chip-1')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('配置码'), findsNothing);
|
||||
expect(
|
||||
find.byKey(const ValueKey('gateway-setup-code-field')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(find.byKey(const ValueKey('gateway-host-field')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('SettingsPage diagnostics tab filters and clears runtime logs', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
|
||||
@ -2,19 +2,27 @@
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:xworkmate/app/app_controller.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
import 'package:xworkmate/runtime/secure_config_store.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'AppController exposes selected AI Gateway models to the assistant',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final controller = AppController();
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-app-controller-models-',
|
||||
);
|
||||
addTearDown(() => tempDirectory.delete(recursive: true));
|
||||
final store = _createIsolatedStore(tempDirectory.path);
|
||||
final controller = AppController(store: store);
|
||||
addTearDown(controller.dispose);
|
||||
addTearDown(store.dispose);
|
||||
|
||||
await _waitFor(() => !controller.initializing);
|
||||
|
||||
@ -40,8 +48,14 @@ void main() {
|
||||
'AppController switches assistant model source with the execution mode',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final controller = AppController();
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-app-controller-models-',
|
||||
);
|
||||
addTearDown(() => tempDirectory.delete(recursive: true));
|
||||
final store = _createIsolatedStore(tempDirectory.path);
|
||||
final controller = AppController(store: store);
|
||||
addTearDown(controller.dispose);
|
||||
addTearDown(store.dispose);
|
||||
|
||||
await _waitFor(() => !controller.initializing);
|
||||
|
||||
@ -74,6 +88,15 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
SecureConfigStore _createIsolatedStore(String rootPath) {
|
||||
return SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async => '$rootPath/config-store.sqlite3',
|
||||
fallbackDirectoryPathResolver: () async => rootPath,
|
||||
defaultSupportDirectoryPathResolver: () async => rootPath,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _waitFor(
|
||||
bool Function() condition, {
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
|
||||
@ -10,7 +10,7 @@ import 'package:xworkmate/models/app_models.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'AppController omits fixed task entry from focused destinations',
|
||||
'AppController keeps tasks destination in focused destinations',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final controller = AppController();
|
||||
@ -23,6 +23,7 @@ void main() {
|
||||
assistantNavigationDestinations: const <WorkspaceDestination>[
|
||||
WorkspaceDestination.tasks,
|
||||
WorkspaceDestination.skills,
|
||||
WorkspaceDestination.tasks,
|
||||
WorkspaceDestination.aiGateway,
|
||||
],
|
||||
),
|
||||
@ -32,6 +33,7 @@ void main() {
|
||||
expect(
|
||||
controller.assistantNavigationDestinations,
|
||||
const <WorkspaceDestination>[
|
||||
WorkspaceDestination.tasks,
|
||||
WorkspaceDestination.skills,
|
||||
WorkspaceDestination.aiGateway,
|
||||
],
|
||||
|
||||
@ -19,7 +19,12 @@ void main() {
|
||||
final server = await _FakeAiGatewayServer.start();
|
||||
addTearDown(server.close);
|
||||
|
||||
final store = SecureConfigStore();
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-settings-ai-gateway-sync-',
|
||||
);
|
||||
addTearDown(() async => _deleteDirectoryBestEffort(tempDirectory));
|
||||
final store = _createIsolatedStore(tempDirectory.path);
|
||||
addTearDown(store.dispose);
|
||||
final controller = SettingsController(store);
|
||||
await controller.initialize();
|
||||
await controller.saveSnapshot(
|
||||
@ -63,7 +68,12 @@ void main() {
|
||||
final server = await _FakeAiGatewayServer.start();
|
||||
addTearDown(server.close);
|
||||
|
||||
final store = SecureConfigStore();
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-settings-ai-gateway-sync-',
|
||||
);
|
||||
addTearDown(() async => _deleteDirectoryBestEffort(tempDirectory));
|
||||
final store = _createIsolatedStore(tempDirectory.path);
|
||||
addTearDown(store.dispose);
|
||||
final controller = SettingsController(store);
|
||||
await controller.initialize();
|
||||
await controller.saveSnapshot(
|
||||
@ -88,7 +98,10 @@ void main() {
|
||||
'claude-3.7',
|
||||
]);
|
||||
expect(await store.loadAiGatewayApiKey(), 'stored-inline-key');
|
||||
expect(controller.snapshot.toJsonString(), isNot(contains('stored-inline-key')));
|
||||
expect(
|
||||
controller.snapshot.toJsonString(),
|
||||
isNot(contains('stored-inline-key')),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@ -99,7 +112,12 @@ void main() {
|
||||
final server = await _FakeAiGatewayServer.start(appendFooterJson: true);
|
||||
addTearDown(server.close);
|
||||
|
||||
final store = SecureConfigStore();
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-settings-ai-gateway-sync-',
|
||||
);
|
||||
addTearDown(() async => _deleteDirectoryBestEffort(tempDirectory));
|
||||
final store = _createIsolatedStore(tempDirectory.path);
|
||||
addTearDown(store.dispose);
|
||||
final controller = SettingsController(store);
|
||||
await controller.initialize();
|
||||
await controller.saveSnapshot(
|
||||
@ -131,7 +149,12 @@ void main() {
|
||||
);
|
||||
addTearDown(server.close);
|
||||
|
||||
final store = SecureConfigStore();
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-settings-ai-gateway-sync-',
|
||||
);
|
||||
addTearDown(() async => _deleteDirectoryBestEffort(tempDirectory));
|
||||
final store = _createIsolatedStore(tempDirectory.path);
|
||||
addTearDown(store.dispose);
|
||||
final controller = SettingsController(store);
|
||||
await controller.initialize();
|
||||
|
||||
@ -157,7 +180,12 @@ void main() {
|
||||
);
|
||||
addTearDown(server.close);
|
||||
|
||||
final store = SecureConfigStore();
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-settings-ai-gateway-sync-',
|
||||
);
|
||||
addTearDown(() async => _deleteDirectoryBestEffort(tempDirectory));
|
||||
final store = _createIsolatedStore(tempDirectory.path);
|
||||
addTearDown(store.dispose);
|
||||
final controller = SettingsController(store);
|
||||
await controller.initialize();
|
||||
|
||||
@ -173,6 +201,32 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
SecureConfigStore _createIsolatedStore(String rootPath) {
|
||||
return SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async => '$rootPath/config-store.sqlite3',
|
||||
fallbackDirectoryPathResolver: () async => rootPath,
|
||||
defaultSupportDirectoryPathResolver: () async => rootPath,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteDirectoryBestEffort(Directory directory) async {
|
||||
for (var attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
if (!await directory.exists()) {
|
||||
return;
|
||||
}
|
||||
await directory.delete(recursive: true);
|
||||
return;
|
||||
} on FileSystemException {
|
||||
if (attempt == 2) {
|
||||
return;
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 80));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeAiGatewayServer {
|
||||
_FakeAiGatewayServer._(
|
||||
this._server,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user