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; } } /// Pumps a bounded number of frames so async connect/load work settles without /// `pumpAndSettle`, which never returns while the loading /// `CircularProgressIndicator` animation is on screen. Future _pumpFrames( WidgetTester tester, { int frames = 8, Duration step = const Duration(milliseconds: 50), }) async { for (var i = 0; i < frames; i++) { await tester.pump(step); } } 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('Workbench opens agent dock and IOP section from right rail', ( WidgetTester tester, ) async { await tester.pumpWidget(const NomadCodeClientApp()); await tester.tap(find.byIcon(Icons.smart_toy_outlined)); await tester.pump(); expect(find.text('Ask the workspace agent'), findsOneWidget); expect(find.textContaining('NomadCode agent is docked'), findsOneWidget); await tester.tap(find.byIcon(Icons.hub_outlined)); await tester.pump(); expect(find.text('IOP'), findsOneWidget); expect(find.textContaining('Optional IOP console package'), findsOneWidget); }); testWidgets('Workbench separates titlebar, right rail, and content', ( WidgetTester tester, ) async { addTearDown(() { tester.view.resetPhysicalSize(); tester.view.resetDevicePixelRatio(); }); for (final size in const [Size(390, 800), Size(1280, 900)]) { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; await tester.pumpWidget(const NomadCodeClientApp()); await tester.pump(); final titleRect = tester.getRect(find.text('NomadCode Console')); final workflowRailRect = tester.getRect(find.byTooltip('Workflow')); expect(titleRect.bottom, lessThanOrEqualTo(workflowRailRect.top)); await tester.tap(find.byTooltip('Workflow')); await tester.pump(); final workflowContentRect = tester.getRect( find.text('Workflow management surface will mount here.'), ); expect(workflowContentRect.top, greaterThan(titleRect.bottom)); expect( workflowContentRect.right, lessThanOrEqualTo(workflowRailRect.left), ); await tester.tap(find.byTooltip('Agent')); await tester.pump(); final agentRailRect = tester.getRect(find.byTooltip('Agent')); final agentContentRect = tester.getRect( find.text('Ask the workspace agent'), ); expect(agentContentRect.top, greaterThan(titleRect.bottom)); expect(agentContentRect.right, lessThanOrEqualTo(agentRailRect.left)); await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); } }); 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.pump(); 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', }, ), ); // The diagnostics event crosses two broadcast streams (transport -> // lifecycle -> StreamBuilder); pump twice so the delivered event also gets // a frame to rebuild the debug surface. await tester.pump(); 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); addTearDown(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); // Dispose via addTearDown so it runs after the tester has quiesced; a // manual dispose() in the body races in-flight diagnostics broadcast // delivery and hangs isolate shutdown. addTearDown(lifecycle.dispose); await tester.pumpWidget( NomadCodeClientApp( protoSocketLifecycle: lifecycle, protoSocketConfig: _enabledProtoSocketConfig, ), ); // Cannot use pumpAndSettle: the loading FutureBuilder shows a // CircularProgressIndicator whose animation never settles. Pump bounded // frames so connect -> task load -> render completes deterministically. await _pumpFrames(tester); 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); }); testWidgets( 'uses already connected lifecycle without disconnecting on dispose', (WidgetTester tester) async { final connector = _FakeProtoSocketConnector(); final lifecycle = ProtoSocketLifecycle(connector: connector); addTearDown(lifecycle.dispose); await lifecycle.connect(_enabledProtoSocketConfig); await tester.pumpWidget( NomadCodeClientApp( protoSocketLifecycle: lifecycle, protoSocketConfig: _enabledProtoSocketConfig, ), ); await _pumpFrames(tester); expect(connector.connectCalls, equals(1)); expect(find.text('Service task from Core'), findsOneWidget); // Unmounting the widget must NOT disconnect a lifecycle it did not // start (the lifecycle was already connected before mount). await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); expect(connector.disconnectCalls, equals(0)); }, ); 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); addTearDown(lifecycle.dispose); 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)); }); }); }