Unify gateway settings actions and harden persistence tests

This commit is contained in:
Haitao Pan 2026-03-23 07:28:33 +08:00
parent 83c73a4ceb
commit 936ddd665b
13 changed files with 319 additions and 141 deletions

View File

@ -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

View File

@ -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`

View File

@ -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

View File

@ -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(

View File

@ -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,

View File

@ -110,6 +110,7 @@ const List<WorkspaceDestination> kAssistantNavigationDestinationDefaults =
const List<WorkspaceDestination> kAssistantNavigationDestinationCandidates =
<WorkspaceDestination>[
WorkspaceDestination.tasks,
WorkspaceDestination.skills,
WorkspaceDestination.nodes,
WorkspaceDestination.agents,

View File

@ -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) {

View File

@ -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);
});

View File

@ -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>(

View File

@ -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 {

View File

@ -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),

View File

@ -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,
],

View File

@ -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,