From 936ddd665b80a560ef64b2b9115f7bd8d40fc1da Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 23 Mar 2026 07:28:33 +0800 Subject: [PATCH] Unify gateway settings actions and harden persistence tests --- config/feature_flags.yaml | 26 ++- docs/architecture/xworkmate-integrations.md | 13 ++ .../xworkmate-internal-state-architecture.md | 15 +- lib/app/ui_feature_manifest.dart | 30 ++- lib/features/settings/settings_page.dart | 196 ++++++++---------- lib/models/app_models.dart | 1 + lib/runtime/runtime_controllers.dart | 8 + test/features/ai_gateway_page_suite.dart | 10 + ...settings_ai_gateway_persistence_suite.dart | 26 ++- test/features/settings_page_suite.dart | 38 +++- ...pp_controller_ai_gateway_models_suite.dart | 27 ++- ...controller_navigation_favorites_suite.dart | 4 +- ...ings_controller_ai_gateway_sync_suite.dart | 66 +++++- 13 files changed, 319 insertions(+), 141 deletions(-) diff --git a/config/feature_flags.yaml b/config/feature_flags.yaml index 4fc29f42..21491816 100644 --- a/config/feature_flags.yaml +++ b/config/feature_flags.yaml @@ -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 diff --git a/docs/architecture/xworkmate-integrations.md b/docs/architecture/xworkmate-integrations.md index fd3136bd..42a00bd9 100644 --- a/docs/architecture/xworkmate-integrations.md +++ b/docs/architecture/xworkmate-integrations.md @@ -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` diff --git a/docs/architecture/xworkmate-internal-state-architecture.md b/docs/architecture/xworkmate-internal-state-architecture.md index 2d195e37..ee1d595b 100644 --- a/docs/architecture/xworkmate-internal-state-architecture.md +++ b/docs/architecture/xworkmate-internal-state-architecture.md @@ -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 diff --git a/lib/app/ui_feature_manifest.dart b/lib/app/ui_feature_manifest.dart index b56c911e..c3f8cbff 100644 --- a/lib/app/ui_feature_manifest.dart +++ b/lib/app/ui_feature_manifest.dart @@ -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 get availableSettingsTabs { return SettingsTab.values .where( diff --git a/lib/features/settings/settings_page.dart b/lib/features/settings/settings_page.dart index 489e7d34..95f688eb 100644 --- a/lib/features/settings/settings_page.dart +++ b/lib/features/settings/settings_page.dart @@ -61,6 +61,8 @@ class _SettingsPageState extends State { 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 { _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 { ), ), 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 { 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 { 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 { ), ], 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 { '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 { } Future _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 { } 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 { 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 { 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 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, diff --git a/lib/models/app_models.dart b/lib/models/app_models.dart index 388f8d78..7877d28e 100644 --- a/lib/models/app_models.dart +++ b/lib/models/app_models.dart @@ -110,6 +110,7 @@ const List kAssistantNavigationDestinationDefaults = const List kAssistantNavigationDestinationCandidates = [ + WorkspaceDestination.tasks, WorkspaceDestination.skills, WorkspaceDestination.nodes, WorkspaceDestination.agents, diff --git a/lib/runtime/runtime_controllers.dart b/lib/runtime/runtime_controllers.dart index 89a71b34..3a980403 100644 --- a/lib/runtime/runtime_controllers.dart +++ b/lib/runtime/runtime_controllers.dart @@ -136,6 +136,14 @@ class SettingsController extends ChangeNotifier { notifyListeners(); } + Future loadGatewayToken() async { + return (await _store.loadGatewayToken())?.trim() ?? ''; + } + + Future loadGatewayPassword() async { + return (await _store.loadGatewayPassword())?.trim() ?? ''; + } + Future saveOllamaCloudApiKey(String value) async { final trimmed = value.trim(); if (trimmed.isEmpty) { diff --git a/test/features/ai_gateway_page_suite.dart b/test/features/ai_gateway_page_suite.dart index bfc7af17..35f42d11 100644 --- a/test/features/ai_gateway_page_suite.dart +++ b/test/features/ai_gateway_page_suite.dart @@ -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); }); diff --git a/test/features/settings_ai_gateway_persistence_suite.dart b/test/features/settings_ai_gateway_persistence_suite.dart index 7542b075..9b9014e1 100644 --- a/test/features/settings_ai_gateway_persistence_suite.dart +++ b/test/features/settings_ai_gateway_persistence_suite.dart @@ -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( diff --git a/test/features/settings_page_suite.dart b/test/features/settings_page_suite.dart index 1f677773..7323f100 100644 --- a/test/features/settings_page_suite.dart +++ b/test/features/settings_page_suite.dart @@ -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 { diff --git a/test/runtime/app_controller_ai_gateway_models_suite.dart b/test/runtime/app_controller_ai_gateway_models_suite.dart index 25e03401..6fd84fc1 100644 --- a/test/runtime/app_controller_ai_gateway_models_suite.dart +++ b/test/runtime/app_controller_ai_gateway_models_suite.dart @@ -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({}); - 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({}); - 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 _waitFor( bool Function() condition, { Duration timeout = const Duration(seconds: 5), diff --git a/test/runtime/app_controller_navigation_favorites_suite.dart b/test/runtime/app_controller_navigation_favorites_suite.dart index 8574be82..6d815223 100644 --- a/test/runtime/app_controller_navigation_favorites_suite.dart +++ b/test/runtime/app_controller_navigation_favorites_suite.dart @@ -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({}); final controller = AppController(); @@ -23,6 +23,7 @@ void main() { assistantNavigationDestinations: const [ WorkspaceDestination.tasks, WorkspaceDestination.skills, + WorkspaceDestination.tasks, WorkspaceDestination.aiGateway, ], ), @@ -32,6 +33,7 @@ void main() { expect( controller.assistantNavigationDestinations, const [ + WorkspaceDestination.tasks, WorkspaceDestination.skills, WorkspaceDestination.aiGateway, ], diff --git a/test/runtime/settings_controller_ai_gateway_sync_suite.dart b/test/runtime/settings_controller_ai_gateway_sync_suite.dart index 056cc3ba..cb8658ee 100644 --- a/test/runtime/settings_controller_ai_gateway_sync_suite.dart +++ b/test/runtime/settings_controller_ai_gateway_sync_suite.dart @@ -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 _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.delayed(const Duration(milliseconds: 80)); + } + } +} + class _FakeAiGatewayServer { _FakeAiGatewayServer._( this._server,