xworkmate-app/test/runtime/app_controller_codex_bridge_suite.dart
2026-03-27 17:38:15 +08:00

310 lines
9.7 KiB
Dart

@TestOn('vm')
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/codex_runtime.dart';
import 'package:xworkmate/runtime/device_identity_store.dart';
import 'package:xworkmate/runtime/gateway_runtime.dart';
import 'package:xworkmate/runtime/runtime_coordinator.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
import 'package:xworkmate/runtime/secure_config_store.dart';
import '../test_support.dart';
const String _manualCodexBridgeSkipReason =
'Disabled by default: reserved for manual validation with a dedicated Codex environment only.';
class _FakeGatewayRuntime extends GatewayRuntime {
factory _FakeGatewayRuntime({required bool connected}) {
final store = createIsolatedTestStore();
return _FakeGatewayRuntime._(store, connected: connected);
}
_FakeGatewayRuntime._(SecureConfigStore store, {required bool connected})
: super(store: store, identityStore: DeviceIdentityStore(store)) {
setConnected(connected);
}
final List<Map<String, dynamic>> requests = <Map<String, dynamic>>[];
GatewayConnectionSnapshot _snapshot = GatewayConnectionSnapshot.initial();
void setConnected(bool connected) {
_snapshot =
GatewayConnectionSnapshot.initial(
mode: GatewayConnectionProfile.defaults().mode,
).copyWith(
status: connected
? RuntimeConnectionStatus.connected
: RuntimeConnectionStatus.offline,
statusText: connected ? 'Connected' : 'Offline',
);
}
@override
bool get isConnected => _snapshot.status == RuntimeConnectionStatus.connected;
@override
GatewayConnectionSnapshot get snapshot => _snapshot;
@override
Stream<GatewayPushEvent> get events => const Stream<GatewayPushEvent>.empty();
@override
Future<void> connectProfile(
GatewayConnectionProfile profile, {
int? profileIndex,
String authTokenOverride = '',
String authPasswordOverride = '',
}) async {
setConnected(true);
}
@override
Future<void> disconnect({bool clearDesiredProfile = true}) async {
setConnected(false);
}
@override
Future<dynamic> request(
String method, {
Map<String, dynamic>? params,
Duration timeout = const Duration(seconds: 30),
}) async {
final resolvedParams = params ?? const <String, dynamic>{};
requests.add(<String, dynamic>{'method': method, 'params': resolvedParams});
if (method == 'agent/register') {
return <String, dynamic>{
'agentId': 'bridge-1',
'agentType': resolvedParams['agentType'],
'name': resolvedParams['name'],
'version': resolvedParams['version'],
'token': 'registration-token',
'registeredAt': '2026-03-14T10:00:00Z',
};
}
return <String, dynamic>{};
}
}
class _FakeCodexRuntime extends CodexRuntime {
_FakeCodexRuntime();
bool startCalled = false;
bool stopCalled = false;
bool findCalled = false;
String? startedCodexPath;
String? startedCwd;
bool _connected = false;
@override
bool get isConnected => _connected;
@override
Future<String?> findCodexBinary() async {
findCalled = true;
return null;
}
@override
Future<void> startStdio({
required String codexPath,
String? cwd,
CodexSandboxMode sandbox = CodexSandboxMode.workspaceWrite,
CodexApprovalPolicy approval = CodexApprovalPolicy.suggest,
List<String> extraArgs = const <String>[],
}) async {
startCalled = true;
startedCodexPath = codexPath;
startedCwd = cwd;
_connected = true;
}
@override
Future<void> stop() async {
stopCalled = true;
_connected = false;
}
}
void main() {
group('Manual Codex bridge validation', () {
test(
'AppController enables external Codex bridge and registers to gateway',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = createIsolatedTestStore();
final gateway = _FakeGatewayRuntime(connected: true);
final codex = _FakeCodexRuntime();
final coordinator = RuntimeCoordinator(gateway: gateway, codex: codex);
final controller = AppController(
store: store,
runtimeCoordinator: coordinator,
);
addTearDown(controller.dispose);
await _waitFor(() => !controller.initializing);
final tempDir = await Directory.systemTemp.createTemp('codex-bridge-');
addTearDown(() async {
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
});
final codexBinary = File('${tempDir.path}/codex');
await codexBinary.writeAsString('#!/bin/sh\nexit 0\n');
await controller.settingsController.saveAiGatewayApiKey(
'bridge-secret',
);
await controller.saveSettings(
controller.settings.copyWith(
workspacePath: tempDir.path,
codeAgentRuntimeMode: CodeAgentRuntimeMode.externalCli,
codexCliPath: codexBinary.path,
aiGateway: controller.settings.aiGateway.copyWith(
baseUrl: 'https://gateway.example.com',
),
),
);
await controller.enableCodexBridge();
expect(controller.isCodexBridgeEnabled, isTrue);
expect(
controller.codexCooperationState,
CodexCooperationState.registered,
);
expect(codex.startCalled, isTrue);
expect(codex.startedCodexPath, codexBinary.path);
expect(codex.startedCwd, tempDir.path);
final registrationCall = gateway.requests.firstWhere(
(request) => request['method'] == 'agent/register',
);
final params = registrationCall['params'] as Map<String, dynamic>;
expect(params['transport'], 'stdio-bridge');
expect(params['metadata'], containsPair('providerId', 'codex'));
expect(params['metadata'], containsPair('runtimeMode', 'externalCli'));
expect(
(params['metadata']['node'] as Map<String, dynamic>)['kind'],
'app-mediated-cooperative-node',
);
},
);
test(
'AppController keeps bridge running when gateway registration is unavailable',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = createIsolatedTestStore();
final gateway = _FakeGatewayRuntime(connected: false);
final codex = _FakeCodexRuntime();
final coordinator = RuntimeCoordinator(gateway: gateway, codex: codex);
final controller = AppController(
store: store,
runtimeCoordinator: coordinator,
);
addTearDown(controller.dispose);
await _waitFor(() => !controller.initializing);
final tempDir = await Directory.systemTemp.createTemp(
'codex-bridge-offline-',
);
addTearDown(() async {
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
});
final codexBinary = File('${tempDir.path}/codex');
await codexBinary.writeAsString('#!/bin/sh\nexit 0\n');
await controller.saveSettings(
controller.settings.copyWith(
codeAgentRuntimeMode: CodeAgentRuntimeMode.externalCli,
codexCliPath: codexBinary.path,
aiGateway: controller.settings.aiGateway.copyWith(
baseUrl: 'https://gateway.example.com',
),
),
);
await controller.enableCodexBridge();
expect(controller.isCodexBridgeEnabled, isTrue);
expect(
controller.codexCooperationState,
CodexCooperationState.bridgeOnly,
);
expect(codex.startCalled, isTrue);
expect(
gateway.requests.where(
(request) => request['method'] == 'agent/register',
),
isEmpty,
);
},
);
test(
'AppController sanitizes legacy built-in mode back to external cli',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = createIsolatedTestStore();
final gateway = _FakeGatewayRuntime(connected: false);
final codex = _FakeCodexRuntime();
final coordinator = RuntimeCoordinator(gateway: gateway, codex: codex);
final controller = AppController(
store: store,
runtimeCoordinator: coordinator,
);
addTearDown(controller.dispose);
await _waitFor(() => !controller.initializing);
await controller.saveSettings(
controller.settings.copyWith(
codeAgentRuntimeMode: CodeAgentRuntimeMode.builtIn,
aiGateway: controller.settings.aiGateway.copyWith(
baseUrl: 'https://gateway.example.com',
),
),
);
expect(
controller.settings.codeAgentRuntimeMode,
CodeAgentRuntimeMode.externalCli,
);
expect(controller.codexRuntimeWarning, isNotNull);
await controller.enableCodexBridge();
expect(controller.isCodexBridgeEnabled, isTrue);
expect(
controller.codexCooperationState,
CodexCooperationState.bridgeOnly,
);
expect(codex.startCalled, isFalse);
expect(coordinator.runtimeMode, CodeAgentRuntimeMode.externalCli);
},
);
}, skip: _manualCodexBridgeSkipReason);
}
Future<void> _waitFor(
bool Function() predicate, {
Duration timeout = const Duration(seconds: 5),
}) async {
final deadline = DateTime.now().add(timeout);
while (!predicate()) {
if (DateTime.now().isAfter(deadline)) {
fail('condition not met before timeout');
}
await Future<void>.delayed(const Duration(milliseconds: 20));
}
}