@TestOn('vm') library; import 'dart:async'; import 'dart:convert'; 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 completes the minimal assistant flow against a gateway', () async { SharedPreferences.setMockInitialValues({}); final gateway = await _FakeGatewayServer.start(); final tempDirectory = await Directory.systemTemp.createTemp( 'xworkmate-assistant-flow-', ); addTearDown(() async { await _deleteDirectoryWithRetry(tempDirectory); }); final store = SecureConfigStore( enableSecureStorage: false, databasePathResolver: () async => '${tempDirectory.path}/settings.db', fallbackDirectoryPathResolver: () async => tempDirectory.path, ); final controller = AppController(store: store); addTearDown(() async { controller.dispose(); }); addTearDown(gateway.close); await _waitFor(() => !controller.initializing); await controller.connectManual( host: '127.0.0.1', port: gateway.port, tls: false, mode: RuntimeConnectionMode.local, token: _FakeGatewayServer.sharedToken, ); expect(controller.connection.status, RuntimeConnectionStatus.connected); expect(gateway.connectAuthToken, _FakeGatewayServer.sharedToken); await controller.selectAgent('main'); await controller.sendChatMessage('请只回复一行:XWORKMATE_OK', thinking: 'low'); await _waitFor( () => controller.chatMessages.any( (message) => message.role == 'assistant' && message.text.contains('XWORKMATE_OK'), ), ); await _waitFor(() => controller.tasksController.history.isNotEmpty); expect( controller.chatMessages.any( (message) => message.role == 'assistant' && message.text.contains('XWORKMATE_OK'), ), isTrue, ); expect( controller.tasksController.history.any( (task) => task.summary.contains('XWORKMATE_OK'), ), isTrue, ); expect(gateway.lastChatSendParams?['agentId'], 'main'); expect( ((gateway.lastChatSendParams?['metadata'] as Map?)?['node'] as Map?)?['kind'], 'app-mediated-cooperative-node', ); expect( ((gateway.lastChatSendParams?['metadata'] as Map?)?['dispatch'] as Map?)?['mode'], 'gateway-only', ); }, ); test( 'AppController connects directly from a setup code and persists gateway auth', () async { SharedPreferences.setMockInitialValues({}); final gateway = await _FakeGatewayServer.start(); final tempDirectory = await Directory.systemTemp.createTemp( 'xworkmate-setup-code-flow-', ); addTearDown(() async { await _deleteDirectoryWithRetry(tempDirectory); }); final store = SecureConfigStore( enableSecureStorage: false, databasePathResolver: () async => '${tempDirectory.path}/settings.db', fallbackDirectoryPathResolver: () async => tempDirectory.path, ); final controller = AppController(store: store); addTearDown(() async { controller.dispose(); }); addTearDown(gateway.close); await _waitFor(() => !controller.initializing); final setupCode = jsonEncode({ 'url': 'ws://127.0.0.1:${gateway.port}', 'token': _FakeGatewayServer.sharedToken, }); await controller.connectWithSetupCode(setupCode: setupCode); expect(controller.connection.status, RuntimeConnectionStatus.connected); expect(controller.connection.mode, RuntimeConnectionMode.local); expect(gateway.connectAuthToken, _FakeGatewayServer.sharedToken); expect(controller.settings.primaryLocalGatewayProfile.host, '127.0.0.1'); expect(controller.settings.primaryLocalGatewayProfile.port, gateway.port); expect( await controller.settingsController.loadGatewayToken( profileIndex: kGatewayLocalProfileIndex, ), _FakeGatewayServer.sharedToken, ); }, ); test( 'AppController hides gateway transcript after switching the thread to single-agent', () async { SharedPreferences.setMockInitialValues({}); final gateway = await _FakeGatewayServer.start(); final tempDirectory = await Directory.systemTemp.createTemp( 'xworkmate-single-agent-history-hide-', ); addTearDown(() async { await _deleteDirectoryWithRetry(tempDirectory); }); final store = SecureConfigStore( enableSecureStorage: false, databasePathResolver: () async => '${tempDirectory.path}/settings.db', fallbackDirectoryPathResolver: () async => tempDirectory.path, ); final controller = AppController(store: store); addTearDown(controller.dispose); addTearDown(gateway.close); await _waitFor(() => !controller.initializing); await controller.connectManual( host: '127.0.0.1', port: gateway.port, tls: false, mode: RuntimeConnectionMode.local, token: _FakeGatewayServer.sharedToken, ); await controller.selectAgent('main'); await controller.sendChatMessage('请只回复一行:XWORKMATE_OK', thinking: 'low'); await _waitFor( () => controller.chatMessages.any( (message) => message.role == 'assistant' && message.text.contains('XWORKMATE_OK'), ), ); await controller.setAssistantExecutionTarget( AssistantExecutionTarget.singleAgent, ); expect( controller.assistantExecutionTarget, AssistantExecutionTarget.singleAgent, ); expect( controller.chatMessages.any( (message) => message.text.contains('XWORKMATE_OK'), ), isFalse, ); }, ); } class _FakeGatewayServer { _FakeGatewayServer._(this._server); static const sharedToken = 'shared-token-from-test'; final HttpServer _server; WebSocket? _socket; String? connectAuthToken; Map? lastChatSendParams; final List> _history = >[]; String _lastMessagePreview = ''; double _updatedAtMs = DateTime.now().millisecondsSinceEpoch.toDouble(); int get port => _server.port; static Future<_FakeGatewayServer> start() async { final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); final fake = _FakeGatewayServer._(server); unawaited(fake._serve()); return fake; } Future close() async { await _socket?.close(); await _server.close(force: true); } Future _serve() async { await for (final request in _server) { if (request.uri.path == '/acp/rpc' && request.method == 'POST') { await _serveAcpRpc(request); continue; } if (request.uri.path == '/acp' && WebSocketTransformer.isUpgradeRequest(request)) { final acpSocket = await WebSocketTransformer.upgrade(request); await acpSocket.close( WebSocketStatus.normalClosure, 'test gateway runtime only', ); continue; } if (!WebSocketTransformer.isUpgradeRequest(request)) { request.response.statusCode = HttpStatus.notFound; await request.response.close(); continue; } final socket = await WebSocketTransformer.upgrade(request); _socket = socket; _send(socket, { 'type': 'event', 'event': 'connect.challenge', 'payload': {'nonce': 'nonce-1'}, }); await for (final raw in socket) { final frame = jsonDecode(raw as String) as Map; if (frame['type'] != 'req') { continue; } final method = frame['method'] as String? ?? ''; final id = frame['id'] as String? ?? 'unknown'; final params = (frame['params'] as Map?)?.cast() ?? const {}; switch (method) { case 'connect': connectAuthToken = ((params['auth'] as Map?)?['token'] as String?) ?.trim(); _send(socket, { 'type': 'res', 'id': id, 'ok': true, 'payload': { 'server': {'host': '127.0.0.1'}, 'snapshot': { 'sessionDefaults': { 'mainSessionKey': 'agent:main:main', }, }, }, }); break; case 'health': case 'status': _send(socket, { 'type': 'res', 'id': id, 'ok': true, 'payload': {'ok': true}, }); break; case 'agents.list': _send(socket, { 'type': 'res', 'id': id, 'ok': true, 'payload': { 'agents': >[ {'id': 'main', 'name': 'Main'}, ], 'mainKey': 'main', }, }); break; case 'sessions.list': _send(socket, { 'type': 'res', 'id': id, 'ok': true, 'payload': { 'sessions': >[ { 'key': 'agent:main:main', 'displayName': 'main', 'surface': 'assistant', 'updatedAt': _updatedAtMs, 'derivedTitle': 'main', 'lastMessagePreview': _lastMessagePreview, 'sessionId': 'sess-main', }, ], }, }); break; case 'chat.history': _send(socket, { 'type': 'res', 'id': id, 'ok': true, 'payload': {'messages': _history}, }); break; case 'skills.status': _send(socket, { 'type': 'res', 'id': id, 'ok': true, 'payload': {'skills': const []}, }); break; case 'channels.status': _send(socket, { 'type': 'res', 'id': id, 'ok': true, 'payload': { 'channelMeta': const [], 'channelLabels': const {}, 'channelDetailLabels': const {}, 'channelAccounts': const {}, 'channelOrder': const [], }, }); break; case 'models.list': _send(socket, { 'type': 'res', 'id': id, 'ok': true, 'payload': { 'models': >[ { 'id': 'gpt-5.4', 'name': 'gpt-5.4', 'provider': 'test', }, ], }, }); break; case 'cron.list': _send(socket, { 'type': 'res', 'id': id, 'ok': true, 'payload': {'jobs': const []}, }); break; case 'system-presence': _send(socket, { 'type': 'res', 'id': id, 'ok': true, 'payload': const [], }); break; case 'chat.send': lastChatSendParams = params; final sessionKey = params['sessionKey'] as String? ?? 'agent:main:main'; final runId = params['idempotencyKey'] as String? ?? 'run-1'; final userText = params['message'] as String? ?? ''; _appendMessage(role: 'user', text: userText); _send(socket, { 'type': 'res', 'id': id, 'ok': true, 'payload': {'runId': runId, 'status': 'started'}, }); unawaited( _emitAssistantResult( socket, runId: runId, sessionKey: sessionKey, ), ); break; default: _send(socket, { 'type': 'res', 'id': id, 'ok': true, 'payload': const {}, }); break; } } } } Future _serveAcpRpc(HttpRequest request) async { final body = await utf8.decodeStream(request); final envelope = (jsonDecode(body) as Map).cast(); final id = envelope['id']; final method = envelope['method']?.toString() ?? ''; final response = { 'jsonrpc': '2.0', 'id': id, 'result': method == 'acp.capabilities' ? { 'singleAgent': true, 'multiAgent': true, 'providers': ['claude', 'codex', 'gemini', 'opencode'], 'capabilities': { 'single_agent': true, 'multi_agent': true, 'providers': ['claude', 'codex', 'gemini', 'opencode'], }, } : const {}, }; request.response.headers.set( HttpHeaders.contentTypeHeader, 'text/event-stream; charset=utf-8', ); request.response.write('data: ${jsonEncode(response)}\n\n'); await request.response.close(); } Future _emitAssistantResult( WebSocket socket, { required String runId, required String sessionKey, }) async { await Future.delayed(const Duration(milliseconds: 20)); const reply = 'XWORKMATE_OK'; _appendMessage(role: 'assistant', text: reply); _send(socket, { 'type': 'event', 'event': 'chat', 'payload': { 'runId': runId, 'sessionKey': sessionKey, 'state': 'delta', 'message': { 'role': 'assistant', 'content': >[ {'type': 'text', 'text': reply}, ], 'timestamp': _updatedAtMs.toInt(), }, }, }); _send(socket, { 'type': 'event', 'event': 'chat', 'payload': { 'runId': runId, 'sessionKey': sessionKey, 'state': 'final', 'message': { 'role': 'assistant', 'content': >[ {'type': 'text', 'text': reply}, ], 'timestamp': _updatedAtMs.toInt(), }, }, }); } void _appendMessage({required String role, required String text}) { _updatedAtMs = DateTime.now().millisecondsSinceEpoch.toDouble(); _lastMessagePreview = text; _history.add({ 'role': role, 'content': >[ {'type': 'text', 'text': text}, ], 'timestamp': _updatedAtMs.toInt(), }); } void _send(WebSocket socket, Map frame) { socket.add(jsonEncode(frame)); } } Future _deleteDirectoryWithRetry(Directory directory) async { if (!await directory.exists()) { return; } for (var attempt = 0; attempt < 3; attempt += 1) { try { await directory.delete(recursive: true); return; } on FileSystemException { if (attempt == 2) { rethrow; } await Future.delayed(const Duration(milliseconds: 100)); } } } Future _waitFor( bool Function() predicate, { Duration timeout = const Duration(seconds: 5), }) async { final deadline = DateTime.now().add(timeout); while (DateTime.now().isBefore(deadline)) { if (predicate()) { return; } await Future.delayed(const Duration(milliseconds: 20)); } throw TimeoutException('Condition not met before timeout.'); }