import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:nomadcode_app/src/app/nomadcode_client_app.dart'; import 'package:nomadcode_app/src/features/workspaces/domain/project_workspace.dart'; import 'package:nomadcode_app/src/features/workspaces/domain/workspace_task.dart'; import 'package:nomadcode_app/src/features/workspaces/presentation/workspace_home_page.dart'; import 'package:nomadcode_app/src/integrations/proto_socket/proto_socket_endpoint_config.dart'; import 'package:nomadcode_app/src/integrations/proto_socket/proto_socket_envelope.dart'; import 'package:nomadcode_app/src/integrations/proto_socket/proto_socket_lifecycle.dart'; const _enabledProtoSocketConfig = ProtoSocketEndpointConfig( host: 'core.example.com', port: 443, secure: true, enabled: true, ); const _disabledProtoSocketConfig = ProtoSocketEndpointConfig( host: 'core.example.com', port: 443, secure: true, ); class _FakeProtoSocketTransport implements ProtoSocketTransport { final StreamController controller = StreamController.broadcast(); final List> taskMaps = const [ { 'id': 'task-service-1', 'title': 'Service task from Core', 'status': 'running', 'source': 'core', }, ]; final List sent = []; @override Stream get events => controller.stream; @override Future sendRequest( ProtoSocketEnvelope envelope, { Duration timeout = const Duration(seconds: 30), }) async { sent.add(envelope); if (envelope.action != 'task.list') { throw StateError('unexpected action ${envelope.action}'); } return ProtoSocketEnvelope( id: 'response-${sent.length}', correlationId: envelope.id, type: 'response', channel: 'task', action: envelope.action, payload: {'tasks': taskMaps}, meta: const { 'connection_id': 'conn-widget', 'timestamp': '2026-05-30T12:02:00Z', }, ); } @override Future close() => controller.close(); } class _FakeProtoSocketConnector implements ProtoSocketConnector { final _FakeProtoSocketTransport transport; final Completer? pendingConnect; int connectCalls = 0; int disconnectCalls = 0; _FakeProtoSocketConnector({ _FakeProtoSocketTransport? transport, this.pendingConnect, }) : transport = transport ?? _FakeProtoSocketTransport(); @override Future connect(ProtoSocketEndpointConfig config) { connectCalls += 1; final pending = pendingConnect; if (pending != null) return pending.future; return Future.value(transport); } @override Future disconnect() async { disconnectCalls += 1; } } void main() { testWidgets('App renders console dashboard title', ( WidgetTester tester, ) async { await tester.pumpWidget(const NomadCodeClientApp()); expect(find.text('NomadCode Console'), findsOneWidget); expect(find.text('NomadCode Core'), findsNWidgets(2)); expect(find.text('IOP Bridge'), findsOneWidget); }); testWidgets('WorkspaceHomePage triggers launcher callback on button tap', ( WidgetTester tester, ) async { ProjectWorkspace? launchedProject; await tester.pumpWidget( MaterialApp( home: WorkspaceHomePage( onLaunchCodeServer: (project) async { launchedProject = project; return true; }, ), ), ); final buttonFinder = find.widgetWithText(ElevatedButton, 'Open Workspace'); expect(buttonFinder, findsOneWidget); await tester.tap(buttonFinder); await tester.pumpAndSettle(); expect(launchedProject, isNotNull); expect(launchedProject!.id, equals('nomadcode')); }); testWidgets('WorkspaceHomePage renders tasks from the loader', ( WidgetTester tester, ) async { await tester.pumpWidget( MaterialApp( home: WorkspaceHomePage( loadTasks: (project) async => const [ WorkspaceTask( id: 't1', title: 'Build pipeline', status: 'running', source: 'core', ), WorkspaceTask( id: 't2', title: 'Sync contracts', status: 'queued', source: 'core', ), ], ), ), ); await tester.pumpAndSettle(); expect(find.text('Build pipeline'), findsOneWidget); expect(find.text('Sync contracts'), findsOneWidget); expect(find.text('running'), findsOneWidget); expect(find.text('queued'), findsOneWidget); }); testWidgets('WorkspaceHomePage renders empty task state from the loader', ( WidgetTester tester, ) async { await tester.pumpWidget( MaterialApp( home: WorkspaceHomePage( loadTasks: (project) async => const [], ), ), ); await tester.pumpAndSettle(); expect(find.text('No active tasks. All tasks are idle.'), findsOneWidget); }); testWidgets('WorkspaceHomePage renders error state with retry on failure', ( WidgetTester tester, ) async { await tester.pumpWidget( MaterialApp( home: WorkspaceHomePage( loadTasks: (project) async => throw Exception('secret transport boom'), ), ), ); await tester.pumpAndSettle(); expect(find.text('Failed to load tasks.'), findsOneWidget); expect(find.widgetWithText(TextButton, 'Retry'), findsOneWidget); // The underlying exception detail must not leak into the UI. expect(find.textContaining('secret transport boom'), findsNothing); }); testWidgets('WorkspaceHomePage shows proto-socket diagnostics safely', ( WidgetTester tester, ) async { final connector = _FakeProtoSocketConnector(); final lifecycle = ProtoSocketLifecycle(connector: connector); await lifecycle.connect(_enabledProtoSocketConfig); await tester.pumpWidget( MaterialApp(home: WorkspaceHomePage(protoSocketLifecycle: lifecycle)), ); await tester.pumpAndSettle(); connector.transport.controller.add( const ProtoSocketEnvelope( id: 'event-secret', type: 'event', channel: 'task', action: 'task.status.changed', error: ProtoSocketEnvelopeError( code: 'task.conflict', message: 'secret server detail', retryable: false, ), payload: {'secret': 'do-not-render'}, meta: { 'connection_id': 'conn-debug', 'timestamp': '2026-05-30T12:03:00Z', }, ), ); await tester.pump(); expect(find.text('PROTO-SOCKET'), findsOneWidget); expect(find.text('connected'), findsOneWidget); expect(find.text('conn-debug'), findsOneWidget); expect(find.text(protoSocketProtocolVersion), findsOneWidget); expect(find.text('task'), findsOneWidget); expect(find.text('task.status.changed'), findsOneWidget); expect(find.text('task.conflict'), findsOneWidget); expect(find.text('2026-05-30T12:03:00Z'), findsOneWidget); expect(find.textContaining('do-not-render'), findsNothing); expect(find.textContaining('secret server detail'), findsNothing); await tester.pumpWidget(const SizedBox.shrink()); await lifecycle.disconnect(); await lifecycle.dispose(); }); testWidgets('reloads tasks when loader is attached after initial build', ( WidgetTester tester, ) async { await tester.pumpWidget( const MaterialApp(home: WorkspaceHomePage(loadTasks: null)), ); await tester.pumpAndSettle(); expect(find.text('Attached loader task'), findsNothing); await tester.pumpWidget( MaterialApp( home: WorkspaceHomePage( loadTasks: (project) async => const [ WorkspaceTask( id: 'attached-1', title: 'Attached loader task', status: 'queued', source: 'core', ), ], ), ), ); await tester.pumpAndSettle(); expect(find.text('Attached loader task'), findsOneWidget); expect(find.text('queued'), findsOneWidget); }); group('NomadCodeClientApp', () { testWidgets('does not connect when proto-socket config is absent', ( WidgetTester tester, ) async { final connector = _FakeProtoSocketConnector(); final lifecycle = ProtoSocketLifecycle(connector: connector); await tester.pumpWidget( NomadCodeClientApp(protoSocketLifecycle: lifecycle), ); await tester.pump(); expect(connector.connectCalls, equals(0)); await tester.pumpWidget(const SizedBox.shrink()); await lifecycle.dispose(); }); testWidgets('does not connect when proto-socket config is disabled', ( WidgetTester tester, ) async { final connector = _FakeProtoSocketConnector(); final lifecycle = ProtoSocketLifecycle(connector: connector); await tester.pumpWidget( NomadCodeClientApp( protoSocketLifecycle: lifecycle, protoSocketConfig: _disabledProtoSocketConfig, ), ); await tester.pump(); expect(connector.connectCalls, equals(0)); await tester.pumpWidget(const SizedBox.shrink()); await lifecycle.dispose(); }); testWidgets('connects once and renders service task data', ( WidgetTester tester, ) async { final connector = _FakeProtoSocketConnector(); final lifecycle = ProtoSocketLifecycle(connector: connector); await tester.pumpWidget( NomadCodeClientApp( protoSocketLifecycle: lifecycle, protoSocketConfig: _enabledProtoSocketConfig, ), ); await tester.pumpAndSettle(); expect(connector.connectCalls, equals(1)); expect(connector.transport.sent.single.action, equals('task.list')); expect(find.text('Service task from Core'), findsOneWidget); expect(find.text('running'), findsOneWidget); expect(find.text('conn-widget'), findsOneWidget); expect(find.text('task.list'), findsOneWidget); await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); await lifecycle.dispose(); }); testWidgets( 'uses already connected lifecycle without disconnecting on dispose', (WidgetTester tester) async { final connector = _FakeProtoSocketConnector(); final lifecycle = ProtoSocketLifecycle(connector: connector); await lifecycle.connect(_enabledProtoSocketConfig); await tester.pumpWidget( NomadCodeClientApp( protoSocketLifecycle: lifecycle, protoSocketConfig: _enabledProtoSocketConfig, ), ); await tester.pumpAndSettle(); expect(connector.connectCalls, equals(1)); expect(find.text('Service task from Core'), findsOneWidget); await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); expect(connector.disconnectCalls, equals(0)); await lifecycle.disconnect(); await lifecycle.dispose(); }, ); testWidgets('cleans up late app-started connects after dispose', ( WidgetTester tester, ) async { final pendingConnect = Completer(); final connector = _FakeProtoSocketConnector( pendingConnect: pendingConnect, ); final lifecycle = ProtoSocketLifecycle(connector: connector); await tester.pumpWidget( NomadCodeClientApp( protoSocketLifecycle: lifecycle, protoSocketConfig: _enabledProtoSocketConfig, ), ); await tester.pump(); expect(connector.connectCalls, equals(1)); expect(lifecycle.state, ProtoSocketConnectionState.connecting); await tester.pumpWidget(const SizedBox.shrink()); pendingConnect.complete(connector.transport); await tester.pump(); await tester.pump(); expect(connector.disconnectCalls, equals(1)); await lifecycle.dispose(); }); }); }