merge: pairing status sync

# Conflicts:
#	test/features/settings_page_suite.dart
This commit is contained in:
Haitao Pan 2026-04-08 18:48:44 +08:00
commit 132b1340b9
8 changed files with 223 additions and 13 deletions

View File

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

View File

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

View File

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

View File

@ -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<void> _pumpSettingsPage(
);
}
Future<void> _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<void> _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 {

View File

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

View File

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

View File

@ -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(<String, Object>{});
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 <String, dynamic>{'role': 'operator'},
returnedDeviceToken: '',
raw: const <String, dynamic>{},
),
);
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);
},
);
}

View File

@ -86,6 +86,10 @@ class _TestFakeGatewayRuntime extends GatewayRuntime {
: super(identityStore: DeviceIdentityStore(store));
GatewayConnectionSnapshot _snapshot = GatewayConnectionSnapshot.initial();
GatewayDevicePairingList _pairingList = const GatewayDevicePairingList(
pending: <GatewayPendingDevice>[],
paired: <GatewayPairedDevice>[],
);
@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<void> disconnect({bool clearDesiredProfile = true}) async {
_snapshot = _snapshot.copyWith(
@ -157,8 +171,40 @@ class _TestFakeGatewayRuntime extends GatewayRuntime {
return <String, dynamic>{'jobs': const <Object>[]};
case 'device.pair.list':
return <String, dynamic>{
'pending': const <Object>[],
'paired': const <Object>[],
'pending': _pairingList.pending
.map((item) => <String, dynamic>{
'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) => <String, dynamic>{
'deviceId': item.deviceId,
'displayName': item.displayName,
'roles': item.roles,
'scopes': item.scopes,
'remoteIp': item.remoteIp,
'tokens': item.tokens
.map((token) => <String, dynamic>{
'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 <Object>[];
@ -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<String?> findCodexBinary() async => null;