diff --git a/lib/runtime/gateway_runtime_core.dart b/lib/runtime/gateway_runtime_core.dart index 78e1ce3e..ec1e3570 100644 --- a/lib/runtime/gateway_runtime_core.dart +++ b/lib/runtime/gateway_runtime_core.dart @@ -308,7 +308,7 @@ class GatewayRuntime extends ChangeNotifier with GatewayRuntimeHelpersInternal { 'stored device token for role ${connectResult.auth['role']?.toString().trim().isNotEmpty == true ? connectResult.auth['role'].toString().trim() : 'operator'}', ); } - snapshotInternal = connectResult.snapshot; + snapshotInternal = connectResult.snapshot.normalizedForConnectedState(); notifyListeners(); return; } on GatewayRuntimeException catch (error) { @@ -658,7 +658,7 @@ class GatewayRuntime extends ChangeNotifier with GatewayRuntimeHelpersInternal { switch (update.type) { case GatewayRuntimeSessionUpdateType.snapshot: if (update.snapshot != null) { - snapshotInternal = update.snapshot!; + snapshotInternal = update.snapshot!.normalizedForConnectedState(); notifyListeners(); } return; diff --git a/lib/runtime/runtime_models_runtime_payloads.dart b/lib/runtime/runtime_models_runtime_payloads.dart index 05e46928..4d1dfa73 100644 --- a/lib/runtime/runtime_models_runtime_payloads.dart +++ b/lib/runtime/runtime_models_runtime_payloads.dart @@ -142,7 +142,26 @@ class GatewayConnectionSnapshot { ); } + GatewayConnectionSnapshot normalizedForConnectedState() { + if (status != RuntimeConnectionStatus.connected) { + return this; + } + if (lastError == null && + lastErrorCode == null && + lastErrorDetailCode == null) { + return this; + } + return copyWith( + clearLastError: true, + clearLastErrorCode: true, + clearLastErrorDetailCode: true, + ); + } + bool get pairingRequired { + if (status == RuntimeConnectionStatus.connected) { + return false; + } final detailCode = lastErrorDetailCode?.trim().toUpperCase(); final errorCode = lastErrorCode?.trim().toUpperCase(); final errorText = lastError?.toLowerCase() ?? ''; @@ -152,6 +171,9 @@ class GatewayConnectionSnapshot { } bool get gatewayTokenMissing { + if (status == RuntimeConnectionStatus.connected) { + return false; + } final detailCode = lastErrorDetailCode?.trim().toUpperCase(); final errorText = lastError?.toLowerCase() ?? ''; return detailCode == 'AUTH_TOKEN_MISSING' || diff --git a/lib/web/web_settings_page_gateway.dart b/lib/web/web_settings_page_gateway.dart index b5cd7c58..68610c25 100644 --- a/lib/web/web_settings_page_gateway.dart +++ b/lib/web/web_settings_page_gateway.dart @@ -382,7 +382,7 @@ extension WebSettingsPageGatewayMixinInternal on WebSettingsPageStateInternal { ), ), FilledButton( - key: ValueKey('web-external-acp-apply-${profile.providerKey}'), + key: ValueKey('web-external-acp-save-${profile.providerKey}'), onPressed: () => saveExternalAcpEndpointInternal( controller, profile.providerKey, diff --git a/test/features/settings_page_suite.dart b/test/features/settings_page_suite.dart index 8f947e91..99726dda 100644 --- a/test/features/settings_page_suite.dart +++ b/test/features/settings_page_suite.dart @@ -2,14 +2,17 @@ library; import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:xworkmate/app/app_controller.dart'; +import 'package:xworkmate/features/assistant/assistant_page_message_widgets.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/desktop_platform_service.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; import 'package:xworkmate/runtime/skill_directory_access.dart'; +import 'package:xworkmate/theme/app_theme.dart'; import 'package:xworkmate/widgets/section_tabs.dart'; import '../test_support.dart'; @@ -148,6 +151,29 @@ Future _pumpSettingsPage( ); } +Future _pumpWithoutSettling( + WidgetTester tester, { + required Widget child, +}) async { + 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(platform: TargetPlatform.macOS), + darkTheme: AppTheme.dark(platform: TargetPlatform.macOS), + home: Scaffold(body: child), + ), + ); + await tester.pump(); +} + Future _ensureVisible(WidgetTester tester, Finder finder) async { await tester.ensureVisible(finder.first); await tester.pumpAndSettle(); @@ -672,13 +698,13 @@ paths: final testButton = find.byKey( ValueKey('external-acp-test-${customProfile.providerKey}'), ); - final applyButton = find.byKey( + final saveButton = find.byKey( ValueKey('external-acp-save-${customProfile.providerKey}'), ); expect(labelField, findsOneWidget); expect(testButton, findsOneWidget); - expect(applyButton, findsOneWidget); + expect(saveButton, findsOneWidget); await tester.enterText(labelField, 'A'); await tester.pump(); @@ -824,6 +850,51 @@ paths: expect(controller.runtimeLogs, isEmpty); }); + testWidgets( + 'Assistant homepage chip and settings pairing card stay globally consistent for a connected gateway snapshot', + (WidgetTester tester) async { + final controller = await createTestController(tester); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.remote, + ); + final remoteProfile = controller.settings.primaryRemoteGatewayProfile; + setGatewaySnapshotForTest( + controller, + GatewayConnectionSnapshot.initial(mode: RuntimeConnectionMode.remote) + .copyWith( + status: RuntimeConnectionStatus.connected, + statusText: 'Connected', + remoteAddress: '${remoteProfile.host}:${remoteProfile.port}', + lastError: 'NOT_PAIRED: pairing required', + lastErrorCode: 'NOT_PAIRED', + lastErrorDetailCode: 'PAIRING_REQUIRED', + ), + ); + + await _pumpWithoutSettling( + tester, + child: ConnectionChipInternal(controller: controller), + ); + + expect(find.byKey(const Key('assistant-connection-chip')), findsOneWidget); + expect( + find.textContaining( + '已连接 · ${remoteProfile.host}:${remoteProfile.port}', + ), + findsOneWidget, + ); + + controller.setSettingsTab(SettingsTab.gateway); + await _pumpWithoutSettling( + tester, + child: SettingsPage(controller: controller), + ); + + expect(find.text('需要设备审批'), findsNothing); + expect(find.text('Pairing Required'), findsNothing); + }, + ); + testWidgets('SettingsPage hides tabs disabled by feature manifest', ( WidgetTester tester, ) async { diff --git a/test/features/web_settings_page_external_acp_suite.dart b/test/features/web_settings_page_external_acp_suite.dart index 8796c221..39d09061 100644 --- a/test/features/web_settings_page_external_acp_suite.dart +++ b/test/features/web_settings_page_external_acp_suite.dart @@ -51,13 +51,13 @@ void main() { final testButton = find.byKey( ValueKey('web-external-acp-test-${customProfile.providerKey}'), ); - final applyButton = find.byKey( - ValueKey('web-external-acp-apply-${customProfile.providerKey}'), + final saveButton = find.byKey( + ValueKey('web-external-acp-save-${customProfile.providerKey}'), ); expect(labelField, findsOneWidget); expect(testButton, findsOneWidget); - expect(applyButton, findsOneWidget); + expect(saveButton, findsOneWidget); await tester.enterText(labelField, 'A'); await tester.pump(); diff --git a/test/helpers/test_keys.dart b/test/helpers/test_keys.dart index 52fdf287..a4dd2ee7 100644 --- a/test/helpers/test_keys.dart +++ b/test/helpers/test_keys.dart @@ -14,7 +14,7 @@ class TestKeys { ); static const Key settingsExternalAcpAuth = Key('external-acp-auth-Codex'); static const Key settingsExternalAcpTest = Key('external-acp-test-Codex'); - static const Key settingsExternalAcpSave = Key('external-acp-apply-Codex'); + static const Key settingsExternalAcpSave = Key('external-acp-save-Codex'); static const Key assistantTaskRail = Key('assistant-task-rail'); static const Key assistantExecutionTargetButton = Key( diff --git a/test/runtime/gateway_runtime_suite.dart b/test/runtime/gateway_runtime_suite.dart index 1984364d..d31ab867 100644 --- a/test/runtime/gateway_runtime_suite.dart +++ b/test/runtime/gateway_runtime_suite.dart @@ -583,7 +583,7 @@ void main() { ); test( - 'GatewayConnectionSnapshot keeps pairing-required visible even when status remains connected', + 'GatewayConnectionSnapshot clears pairing-required and missing-token flags once connected', () { final snapshot = GatewayConnectionSnapshot.initial( mode: RuntimeConnectionMode.local, @@ -594,7 +594,56 @@ void main() { lastErrorDetailCode: 'PAIRING_REQUIRED', ); - expect(snapshot.pairingRequired, isTrue); + expect(snapshot.pairingRequired, isFalse); + expect(snapshot.gatewayTokenMissing, isFalse); + }, + ); + + test( + 'GatewayRuntime normalizes connected session snapshots before exposing them globally', + () async { + SharedPreferences.setMockInitialValues({}); + final store = createIsolatedTestStore(); + final sessionClient = _FakeGatewayRuntimeSessionClient( + connectResult: GatewayRuntimeSessionConnectResult( + snapshot: GatewayConnectionSnapshot.initial( + mode: RuntimeConnectionMode.remote, + ).copyWith( + status: RuntimeConnectionStatus.connected, + statusText: 'Connected', + remoteAddress: 'gateway.example.com:443', + lastError: 'NOT_PAIRED: pairing required', + lastErrorCode: 'NOT_PAIRED', + lastErrorDetailCode: 'PAIRING_REQUIRED', + ), + auth: const {'role': 'operator'}, + returnedDeviceToken: '', + raw: const {}, + ), + ); + final runtime = GatewayRuntime( + store: store, + identityStore: DeviceIdentityStore(store), + sessionClient: sessionClient, + ); + addTearDown(runtime.dispose); + + await runtime.connectProfile( + GatewayConnectionProfile.defaults().copyWith( + mode: RuntimeConnectionMode.remote, + host: 'gateway.example.com', + port: 443, + tls: true, + useSetupCode: false, + ), + authTokenOverride: 'shared-token-from-form', + ); + + expect(runtime.snapshot.status, RuntimeConnectionStatus.connected); + expect(runtime.snapshot.pairingRequired, isFalse); + expect(runtime.snapshot.lastError, isNull); + expect(runtime.snapshot.lastErrorCode, isNull); + expect(runtime.snapshot.lastErrorDetailCode, isNull); }, ); } diff --git a/test/test_support.dart b/test/test_support.dart index f321f8bb..7d29e293 100644 --- a/test/test_support.dart +++ b/test/test_support.dart @@ -86,6 +86,10 @@ class _TestFakeGatewayRuntime extends GatewayRuntime { : super(identityStore: DeviceIdentityStore(store)); GatewayConnectionSnapshot _snapshot = GatewayConnectionSnapshot.initial(); + GatewayDevicePairingList _pairingList = const GatewayDevicePairingList( + pending: [], + paired: [], + ); @override bool get isConnected => _snapshot.status == RuntimeConnectionStatus.connected; @@ -112,6 +116,16 @@ class _TestFakeGatewayRuntime extends GatewayRuntime { notifyListeners(); } + void setSnapshotForTest(GatewayConnectionSnapshot snapshot) { + _snapshot = snapshot.normalizedForConnectedState(); + notifyListeners(); + } + + void setDevicePairingForTest(GatewayDevicePairingList pairingList) { + _pairingList = pairingList; + notifyListeners(); + } + @override Future disconnect({bool clearDesiredProfile = true}) async { _snapshot = _snapshot.copyWith( @@ -157,8 +171,40 @@ class _TestFakeGatewayRuntime extends GatewayRuntime { return {'jobs': const []}; case 'device.pair.list': return { - 'pending': const [], - 'paired': const [], + 'pending': _pairingList.pending + .map((item) => { + 'requestId': item.requestId, + 'deviceId': item.deviceId, + 'label': item.label, + 'role': item.role, + 'scopes': item.scopes, + 'remoteIp': item.remoteIp, + 'requestedAtMs': item.requestedAtMs, + 'repair': item.isRepair, + }) + .toList(growable: false), + 'paired': _pairingList.paired + .map((item) => { + 'deviceId': item.deviceId, + 'displayName': item.displayName, + 'roles': item.roles, + 'scopes': item.scopes, + 'remoteIp': item.remoteIp, + 'tokens': item.tokens + .map((token) => { + 'role': token.role, + 'scopes': token.scopes, + 'createdAtMs': token.createdAtMs, + 'rotatedAtMs': token.rotatedAtMs, + 'revokedAtMs': token.revokedAtMs, + 'lastUsedAtMs': token.lastUsedAtMs, + }) + .toList(growable: false), + 'createdAtMs': item.createdAtMs, + 'approvedAtMs': item.approvedAtMs, + 'currentDevice': item.currentDevice, + }) + .toList(growable: false), }; case 'system-presence': return const []; @@ -168,6 +214,28 @@ class _TestFakeGatewayRuntime extends GatewayRuntime { } } +void setGatewaySnapshotForTest( + AppController controller, + GatewayConnectionSnapshot snapshot, +) { + final runtime = controller.runtime; + if (runtime is! _TestFakeGatewayRuntime) { + throw StateError('createTestController() runtime does not support mutation'); + } + runtime.setSnapshotForTest(snapshot); +} + +void setGatewayPairingListForTest( + AppController controller, + GatewayDevicePairingList pairingList, +) { + final runtime = controller.runtime; + if (runtime is! _TestFakeGatewayRuntime) { + throw StateError('createTestController() runtime does not support mutation'); + } + runtime.setDevicePairingForTest(pairingList); +} + class _TestFakeCodexRuntime extends CodexRuntime { @override Future findCodexBinary() async => null;