import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:iop_client/main.dart'; import 'package:iop_client/client_config.dart'; import 'package:iop_client/iop_wire/client_wire_client.dart'; import 'package:iop_client/gen/proto/iop/control.pb.dart'; import 'package:iop_client/control_plane_status_client.dart'; import 'package:iop_client/widgets/nodes_panel.dart'; import 'package:protobuf/protobuf.dart'; import 'package:fixnum/fixnum.dart'; import 'package:iop_client/src/integrations/nexo/nexo_notification_client.dart'; import 'package:iop_client/src/integrations/nexo/nexo_notification_host_integration.dart'; class FakeWebSocket implements WebSocket { final _controller = StreamController(); @override StreamSubscription listen( void Function(dynamic event)? onData, { Function? onError, void Function()? onDone, bool? cancelOnError, }) { return _controller.stream.listen( onData, onError: onError, onDone: onDone, cancelOnError: cancelOnError, ); } @override dynamic noSuchMethod(Invocation invocation) { return null; } } class FakeClientWireClient extends ClientWireClient { final bool shouldSuccess; final String mockMessage; FakeClientWireClient({ this.shouldSuccess = true, this.mockMessage = 'Welcome to Toki CP!', }) : super(FakeWebSocket(), heartbeatIntervalTime: 0); @override bool get isAlive => true; @override Future sendRequest< Req extends GeneratedMessage, Res extends GeneratedMessage >(Req data, {Duration timeout = const Duration(seconds: 30)}) async { if (data is ClientHelloRequest) { final response = ClientHelloResponse() ..ready = shouldSuccess ..protocol = 'iop-wire-v1' ..serverTimeUnixNano = Int64(1716584400) ..message = mockMessage; return response as Res; } throw UnimplementedError(); } } class FakeControlPlaneStatusRepository implements ControlPlaneStatusRepository { String nextCommandStatus = 'accepted'; String nextCommandError = ''; String nextCommandSummary = 'Command accepted by control plane'; String? lastCommandOperation; String? lastCommandTargetSelector; Map? lastCommandParameters; String? lastCommandEdgeId; Completer? edgeBOperationsCompleter; List mockEdges = [ EdgeRegistryView( edgeId: 'edge-a', edgeName: 'Edge Alpha', version: '1.2.0', capabilities: ['Serving', 'Automation'], protocol: 'iop-wire-v1', connected: true, lastSeen: DateTime.now(), ), EdgeRegistryView( edgeId: 'edge-b', edgeName: 'Edge Beta', version: '1.2.1', capabilities: ['Serving'], protocol: 'iop-wire-v1', connected: false, lastSeen: DateTime.now().subtract(const Duration(minutes: 5)), ), ]; @override Future> fetchEdges() async { return mockEdges; } @override Future fetchEdgeStatus(String edgeId) async { return EdgeStatusResponseView( requestId: 'req-123', edgeId: edgeId, edgeName: edgeId == 'edge-a' ? 'Edge Alpha' : 'Edge Beta', observedTimeUnixNano: DateTime.now().microsecondsSinceEpoch * 1000, nodes: [ EdgeNodeSnapshotView( nodeId: 'node-1', alias: 'Node One', label: 'GPU-T4', connected: true, config: NodeConfigSummaryView( adapters: [ AdapterSummaryView(type: 'ollama', enabled: true), AdapterSummaryView(type: 'custom', enabled: false), ], concurrency: 4, ), providerSnapshots: [ ProviderSnapshotView( id: 'provider-ollama', adapter: 'ollama', type: 'llm', category: 'inference', status: 'active', health: 'healthy', capacity: 10, inFlight: 3, queued: 1, loadRatio: 0.3, servedModels: ['llama-3.1', 'mistral'], lifecycleCapabilities: ['start', 'stop', 'restart'], ), ProviderSnapshotView( id: 'provider-vllm', adapter: 'vllm', type: 'llm', category: 'inference', status: 'active', health: 'healthy', capacity: 8, inFlight: 2, queued: 0, loadRatio: 0.25, servedModels: ['vicuna'], lifecycleCapabilities: ['start', 'stop'], ), ], ), EdgeNodeSnapshotView( nodeId: 'node-2', alias: 'Node Two', label: 'CPU-only', connected: false, config: NodeConfigSummaryView( adapters: [AdapterSummaryView(type: 'python-cli', enabled: true)], concurrency: 2, ), providerSnapshots: [ ProviderSnapshotView( id: 'provider-openai', adapter: 'openai', type: 'llm', category: 'external', status: 'active', health: 'degraded', capacity: 100, inFlight: 50, queued: 5, loadRatio: 0.5, servedModels: ['gpt-4', 'gpt-3.5-turbo'], lifecycleCapabilities: [], ), ], ), ], capabilities: [ EdgeCapabilitySummaryView( kind: 'Serving', available: true, status: 'ready', summary: 'Active', ), EdgeCapabilitySummaryView( kind: 'Automation', available: true, status: 'ready', summary: 'Active', ), ], domainAgents: edgeId == 'edge-a' ? [ EdgeDomainAgentSummaryView( agentKind: 'deployer', available: true, lifecycleState: 'ready', activeCommandId: 'cmd-alpha-active', summary: 'Active', ), EdgeDomainAgentSummaryView( agentKind: 'build-deploy', available: true, lifecycleState: 'ready', activeCommandId: '', summary: 'Idle', ), ] : [ EdgeDomainAgentSummaryView( agentKind: 'deployer', available: true, lifecycleState: 'busy', activeCommandId: 'cmd-beta-active', summary: 'Processing', ), EdgeDomainAgentSummaryView( agentKind: 'build-deploy', available: true, lifecycleState: 'ready', activeCommandId: '', summary: 'Idle', ), ], metadata: {'region': 'us-west'}, error: '', ); } @override Future> fetchEdgeEvents(String edgeId) async { return [ EdgeNodeEventView( edgeId: edgeId, eventId: 'evt-1', type: 'online', source: 'node-register', nodeId: 'node-1', alias: 'Node One', reason: 'Node connected and registered successfully', timestamp: DateTime.now().subtract(const Duration(minutes: 2)), receivedAt: DateTime.now().subtract(const Duration(minutes: 2)), metadata: {}, ), EdgeNodeEventView( edgeId: edgeId, eventId: 'evt-2', type: 'warn', source: 'runtime-dispatch', nodeId: 'node-2', alias: 'Node Two', reason: 'Execution concurrency limit reached', timestamp: DateTime.now().subtract(const Duration(minutes: 1)), receivedAt: DateTime.now().subtract(const Duration(minutes: 1)), metadata: {}, ), ]; } @override Future fetchFleetStatus() async { return FleetStatusResponseView( generatedAt: DateTime.now(), edges: mockEdges .map( (e) => FleetEdgeView( edgeId: e.edgeId, edgeName: e.edgeName, version: e.version, protocol: e.protocol, connected: e.connected, health: e.connected ? 'healthy' : 'unhealthy', lastSeen: e.lastSeen, nodeCount: 2, capabilities: e.capabilities .map( (c) => EdgeCapabilitySummaryView( kind: c, available: true, status: 'ready', summary: '', ), ) .toList(), domainAgents: e.edgeId == 'edge-a' ? [ EdgeDomainAgentSummaryView( agentKind: 'deployer', available: true, lifecycleState: 'ready', activeCommandId: 'cmd-alpha-active', summary: 'Active', ), EdgeDomainAgentSummaryView( agentKind: 'build-deploy', available: true, lifecycleState: 'ready', activeCommandId: '', summary: 'Idle', ), ] : [ EdgeDomainAgentSummaryView( agentKind: 'deployer', available: true, lifecycleState: 'busy', activeCommandId: 'cmd-beta-active', summary: 'Processing', ), EdgeDomainAgentSummaryView( agentKind: 'build-deploy', available: true, lifecycleState: 'ready', activeCommandId: '', summary: 'Idle', ), ], error: '', ), ) .toList(), ); } @override Future fetchEdgeOperations(String edgeId) async { if (edgeId == 'edge-a') { return EdgeOperationsResponseView( edgeId: edgeId, operations: [ EdgeCommandRecordView( edgeId: edgeId, commandId: 'cmd-alpha-active', operation: 'deployer.sync', targetSelector: '', status: 'success', summary: 'Synced 5 models', error: '', ), ], ); } else { if (edgeBOperationsCompleter != null) { return edgeBOperationsCompleter!.future; } return EdgeOperationsResponseView( edgeId: edgeId, operations: [ EdgeCommandRecordView( edgeId: edgeId, commandId: 'cmd-beta-active', operation: 'build-deploy.deploy', targetSelector: '', status: 'success', summary: 'Deploy queued for edge beta', error: '', ), ], ); } } @override Future sendEdgeCommand( String edgeId, String operation, { String? targetSelector, Map? parameters, }) async { lastCommandOperation = operation; lastCommandTargetSelector = targetSelector; lastCommandParameters = parameters; lastCommandEdgeId = edgeId; if (operation == 'agent.status' && (targetSelector == null || targetSelector.isEmpty)) { return EdgeCommandResponseView( requestId: 'req-111', commandId: 'cmd-failed', edgeId: edgeId, status: 'error', summary: 'Failed: agent.status requires target_selector', error: 'target_selector is required', ); } if (operation == 'agent.command') { final cmd = parameters?['command']; if (targetSelector == null || targetSelector.isEmpty || cmd == null || cmd.isEmpty) { return EdgeCommandResponseView( requestId: 'req-111', commandId: 'cmd-failed', edgeId: edgeId, status: 'error', summary: 'Failed: agent.command requires target_selector and parameters.command', error: 'target_selector and parameters.command are required', ); } } return EdgeCommandResponseView( requestId: 'req-111', commandId: 'cmd-new', edgeId: edgeId, status: nextCommandStatus, summary: nextCommandSummary, error: nextCommandError, ); } } void main() { testWidgets('Client App basic rendering and success handshake test', ( WidgetTester tester, ) async { final fakeClient = FakeClientWireClient(shouldSuccess: true); final fakeStatusRepo = FakeControlPlaneStatusRepository(); await tester.pumpWidget( IopClientApp(testClient: fakeClient, statusRepository: fakeStatusRepo), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); expect(find.text('IOP CONTROL PLANE'), findsOneWidget); expect(find.text('Operations Overview'), findsOneWidget); expect( find.text(ClientConfig.controlPlaneHttpUrl, findRichText: true), findsOneWidget, ); expect( find.text(ClientConfig.controlPlaneWireUrl, findRichText: true), findsOneWidget, ); expect(find.text('CONNECTED'), findsOneWidget); }); testWidgets('Client App connection error state test', ( WidgetTester tester, ) async { final fakeClient = FakeClientWireClient( shouldSuccess: false, mockMessage: 'Invalid Version', ); final fakeStatusRepo = FakeControlPlaneStatusRepository(); await tester.pumpWidget( IopClientApp(testClient: fakeClient, statusRepository: fakeStatusRepo), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); expect(find.text('ERROR'), findsOneWidget); }); testWidgets('Client App opens IOP agent panel from the left rail', ( WidgetTester tester, ) async { final fakeClient = FakeClientWireClient(shouldSuccess: true); final fakeStatusRepo = FakeControlPlaneStatusRepository(); await tester.pumpWidget( IopClientApp(testClient: fakeClient, statusRepository: fakeStatusRepo), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); await tester.tap(find.byTooltip('Agent')); await tester.pump(); expect(find.text('Ask about IOP operations'), findsOneWidget); expect(find.textContaining('IOP agent surface is ready'), findsOneWidget); expect(find.textContaining('Edge Control'), findsOneWidget); expect(find.textContaining('Node Management'), findsOneWidget); }); testWidgets('Client App opens Edges panel and displays Edge details', ( WidgetTester tester, ) async { final fakeClient = FakeClientWireClient(shouldSuccess: true); final fakeStatusRepo = FakeControlPlaneStatusRepository(); await tester.pumpWidget( IopClientApp(testClient: fakeClient, statusRepository: fakeStatusRepo), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); await tester.tap(find.byTooltip('Edges')); await tester.pump(); // Verify list of edges expect(find.text('Edge Alpha'), findsNWidgets(2)); expect(find.text('Edge Beta'), findsOneWidget); // Verify detail pane for the first auto-selected edge (Edge Alpha) expect(find.text('Edge ID'), findsOneWidget); expect(find.text('edge-a'), findsOneWidget); expect(find.text('1.2.0'), findsOneWidget); expect(find.text('Serving'), findsOneWidget); expect(find.text('Automation'), findsOneWidget); // Verify selecting another Edge switches the detail pane instead of // reusing the first Edge's status view. await tester.tap(find.text('Edge Beta')); await tester.pump(); expect(find.text('Edge Alpha'), findsOneWidget); expect(find.text('Edge Beta'), findsNWidgets(2)); expect(find.text('edge-b'), findsOneWidget); expect(find.text('1.2.1'), findsOneWidget); expect(find.text('DISCONNECTED'), findsOneWidget); expect(find.text('unhealthy'), findsOneWidget); }); testWidgets( 'Client App opens Nodes panel and displays active Nodes and configurations', (WidgetTester tester) async { final fakeClient = FakeClientWireClient(shouldSuccess: true); final fakeStatusRepo = FakeControlPlaneStatusRepository(); await tester.pumpWidget( IopClientApp(testClient: fakeClient, statusRepository: fakeStatusRepo), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); await tester.tap(find.byTooltip('Nodes')); await tester.pumpAndSettle(); // Verify Nodes view header expect(find.text('Nodes View'), findsOneWidget); // Verify nodes listed expect(find.text('Node One'), findsOneWidget); expect(find.text('Label: GPU-T4'), findsOneWidget); expect(find.text('Concurrency Limit: 4'), findsOneWidget); expect(find.text('ollama'), findsOneWidget); expect(find.text('custom'), findsOneWidget); }, ); testWidgets( 'Client App Nodes panel displays Provider Catalog for nodes with snapshots', (WidgetTester tester) async { final fakeClient = FakeClientWireClient(shouldSuccess: true); final fakeStatusRepo = FakeControlPlaneStatusRepository(); await tester.pumpWidget( IopClientApp(testClient: fakeClient, statusRepository: fakeStatusRepo), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); await tester.tap(find.byTooltip('Nodes')); await tester.pumpAndSettle(); // Scroll to reveal second node's provider catalog before assertions await tester.drag(find.byType(ListView), const Offset(0.0, -1500.0)); await tester.pumpAndSettle(); // Verify Provider Catalog sections are present on both nodes expect(find.textContaining('Provider Catalog'), findsNWidgets(2)); // Verify Provider Snapshot display for node-1 (ollama provider) expect(find.text('provider-ollama'), findsOneWidget); expect( find.textContaining('llm / inference'), findsNWidgets(2), ); // both providers on node-1 expect( find.textContaining('HEALTHY'), findsNWidgets(2), ); // both ollama and vllm are healthy expect(find.textContaining('Load: 3/10'), findsOneWidget); expect(find.textContaining('Q: 1'), findsOneWidget); expect(find.textContaining('30.0%'), findsOneWidget); expect(find.textContaining('Models: llama-3.1, mistral'), findsOneWidget); // Verify Provider Snapshot display for node-2 (degraded state) expect(find.text('provider-openai'), findsOneWidget); expect(find.textContaining('Load: 50/100'), findsOneWidget); expect(find.textContaining('Q: 5'), findsOneWidget); expect(find.textContaining('50.0%'), findsOneWidget); }, ); testWidgets( 'Client App opens Execution/Logs panel and displays lifecycle events', (WidgetTester tester) async { final fakeClient = FakeClientWireClient(shouldSuccess: true); final fakeStatusRepo = FakeControlPlaneStatusRepository(); await tester.pumpWidget( IopClientApp(testClient: fakeClient, statusRepository: fakeStatusRepo), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); await tester.tap(find.byTooltip('Execution & Logs')); await tester.pump(); // Verify logs header expect(find.text('Lifecycle Events & Logs'), findsOneWidget); // Verify captured events expect(find.text('ONLINE'), findsOneWidget); expect(find.text('WARN'), findsOneWidget); expect( find.text('Node connected and registered successfully'), findsOneWidget, ); expect(find.text('Execution concurrency limit reached'), findsOneWidget); }, ); testWidgets('Client App refresh behavior when selected edge disappears', ( WidgetTester tester, ) async { final fakeClient = FakeClientWireClient(shouldSuccess: true); final fakeStatusRepo = FakeControlPlaneStatusRepository(); await tester.pumpWidget( IopClientApp(testClient: fakeClient, statusRepository: fakeStatusRepo), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); // 1. Initial State: edge-a is selected, EdgesPanel has Edge Alpha and Edge Beta await tester.tap(find.byTooltip('Edges')); await tester.pump(); expect(find.text('Edge Alpha'), findsNWidgets(2)); // list & details // 2. Change mockEdges so 'edge-a' and 'edge-b' disappear, only 'edge-c' is returned. fakeStatusRepo.mockEdges = [ EdgeRegistryView( edgeId: 'edge-c', edgeName: 'Edge Gamma', version: '1.2.2', capabilities: ['Automation'], protocol: 'iop-wire-v1', connected: true, lastSeen: DateTime.now(), ), ]; // Trigger refresh in overview await tester.tap(find.byTooltip('Overview')); await tester.pump(); await tester.pump(const Duration(seconds: 1)); // Verify route has navigated back to Overview successfully expect(find.text('Operations Overview'), findsOneWidget); // Drag the ListView upward to scroll down and mount the bottom refresh button final listViewFinder = find.byType(ListView); await tester.drag(listViewFinder.first, const Offset(0.0, -400.0)); await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); // Tap the refresh button safely by its text label await tester.tap(find.text('Refresh Connection')); await tester.pump(); await tester.pump(const Duration(seconds: 1)); // 3. Switch to Edges panel, verify edge-a disappears, edge-c appears and auto-selected await tester.tap(find.byTooltip('Edges')); await tester.pump(); expect(find.text('Edge Alpha'), findsNothing); expect( find.text('Edge Gamma'), findsNWidgets(2), ); // list & details (auto-selected) // 4. Switch to Nodes panel, verify dropdown Value works without assert crash await tester.tap(find.byTooltip('Nodes')); await tester.pump(); expect(find.text('Nodes View'), findsOneWidget); // dropdown button value should be edge-c now expect(find.text('Edge Gamma'), findsOneWidget); // 5. Switch to Logs panel, verify dropdown Value works without assert crash await tester.tap(find.byTooltip('Execution & Logs')); await tester.pump(); expect(find.text('Lifecycle Events & Logs'), findsOneWidget); expect(find.text('Edge Gamma'), findsOneWidget); }); testWidgets( 'Client App opens Operations & Domain Agents panel and verifies agents, operations history, and command triggering', (WidgetTester tester) async { final fakeClient = FakeClientWireClient(shouldSuccess: true); final fakeStatusRepo = FakeControlPlaneStatusRepository(); await tester.pumpWidget( IopClientApp(testClient: fakeClient, statusRepository: fakeStatusRepo), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); await tester.tap(find.byTooltip('Runtime')); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); // Verify panel header and sections expect(find.text('Operations & Domain Agents'), findsOneWidget); expect(find.text('Domain Agents'), findsOneWidget); expect(find.text('Operations Execution History'), findsOneWidget); expect(find.text('System Gated Operations'), findsOneWidget); // Verify domain agents summary listed expect(find.text('DEPLOYER'), findsOneWidget); expect(find.text('BUILD-DEPLOY'), findsOneWidget); expect( find.text('READY'), findsNWidgets(2), ); // deployer and build-deploy are both ready // [REVIEW_API-2] Verify domain agents active command summary for Edge Alpha (presence) expect(find.text('Active Command: cmd-alpha-active'), findsOneWidget); // [REVIEW_API-2] Verify beta active command summary is NOT showing for Edge Alpha expect(find.text('Active Command: cmd-beta-active'), findsNothing); expect(find.text('build-deploy.deploy'), findsNothing); expect(find.textContaining('Deploy queued for edge beta'), findsNothing); // Verify operations history item expect(find.text('deployer.sync'), findsOneWidget); expect(find.text('SUCCESS'), findsOneWidget); expect(find.textContaining('Synced 5 models'), findsOneWidget); // Verify Trigger Sync button is not present expect(find.text('Trigger Sync'), findsNothing); // Verify System Gated buttons are present expect(find.text('Health Check'), findsOneWidget); expect(find.text('Agent Status'), findsOneWidget); expect(find.text('Agent Command'), findsOneWidget); // Verify Health Check button works and shows success banner await tester.tap(find.text('Health Check')); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); // Check for success status banner message expect(find.textContaining('Success: Command accepted'), findsOneWidget); // Verify command target edge ID was edge-a expect(fakeStatusRepo.lastCommandEdgeId, equals('edge-a')); // Setup delayed fetch for edge-b fakeStatusRepo.edgeBOperationsCompleter = Completer(); // Switch to Edge Beta via Dropdown await tester.tap(find.byType(DropdownButton)); await tester.pumpAndSettle(); await tester.tap(find.text('Edge Beta').last); // Pump one frame to trigger edge selection but wait before operations fetch resolves await tester.pump(); // [REVIEW_API-1] Verify that before the operations fetch completes: // 1. The domain agents section has updated immediately (so cmd-alpha-active is gone, cmd-beta-active is present) expect(find.text('Active Command: cmd-alpha-active'), findsNothing); expect(find.text('Active Command: cmd-beta-active'), findsOneWidget); // 2. The operations history of the previous edge (edge-a) is cleared immediately upon fetch start. expect(find.text('deployer.sync'), findsNothing); expect(find.textContaining('Synced 5 models'), findsNothing); // 3. The loading indicator is shown since _operations is null and _isLoading is true expect(find.byType(CircularProgressIndicator), findsOneWidget); // Now complete the operations fetch for edge-b fakeStatusRepo.edgeBOperationsCompleter!.complete( EdgeOperationsResponseView( edgeId: 'edge-b', operations: [ EdgeCommandRecordView( edgeId: 'edge-b', commandId: 'cmd-beta-active', operation: 'build-deploy.deploy', targetSelector: '', status: 'success', summary: 'Deploy queued for edge beta', error: '', ), ], ), ); await tester.pumpAndSettle(); // Verify domain agents for Edge Beta (deployer shows BUSY, build-deploy shows READY) expect(find.text('BUSY'), findsOneWidget); expect(find.text('READY'), findsOneWidget); // [REVIEW_API-2] Verify presence of beta active command and operations history expect(find.text('Active Command: cmd-beta-active'), findsOneWidget); expect(find.text('build-deploy.deploy'), findsOneWidget); expect( find.textContaining('Deploy queued for edge beta'), findsOneWidget, ); // [REVIEW_API-2] Verify absence of alpha-only active command and operations history expect(find.text('Active Command: cmd-alpha-active'), findsNothing); expect(find.text('deployer.sync'), findsNothing); expect(find.textContaining('Synced 5 models'), findsNothing); // Verify command triggering on Edge Beta sends command to edge-b await tester.tap(find.text('Health Check')); await tester.pumpAndSettle(); expect(fakeStatusRepo.lastCommandEdgeId, equals('edge-b')); }, ); testWidgets( 'Client App handles unsupported or error command responses and shows error banner', (WidgetTester tester) async { final fakeClient = FakeClientWireClient(shouldSuccess: true); final fakeStatusRepo = FakeControlPlaneStatusRepository(); // Configure repository to simulate a failed/unsupported command response fakeStatusRepo.nextCommandStatus = 'unsupported'; fakeStatusRepo.nextCommandError = 'Operation not supported by Edge'; fakeStatusRepo.nextCommandSummary = 'Error summary'; await tester.pumpWidget( IopClientApp(testClient: fakeClient, statusRepository: fakeStatusRepo), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); await tester.tap(find.byTooltip('Runtime')); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); // Verify Agent Status button works, opens dialog, and then shows error banner await tester.tap(find.text('Agent Status')); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); expect(find.text('Get Agent Status'), findsOneWidget); await tester.enterText(find.byType(TextField), 'test-node'); await tester.tap(find.text('Send')); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); // Check for failure status banner message expect( find.textContaining( 'Failed: unsupported - Operation not supported by Edge', ), findsOneWidget, ); }, ); testWidgets( 'Client App gates agent.status and agent.command without required inputs', (WidgetTester tester) async { final fakeClient = FakeClientWireClient(shouldSuccess: true); final fakeStatusRepo = FakeControlPlaneStatusRepository(); await tester.pumpWidget( IopClientApp(testClient: fakeClient, statusRepository: fakeStatusRepo), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); await tester.tap(find.byTooltip('Runtime')); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); // 1. Verify agent.status gating await tester.tap(find.text('Agent Status')); await tester.pump(); expect(find.text('Get Agent Status'), findsOneWidget); // Try to send with empty target selector await tester.enterText(find.byType(TextField), ''); await tester.tap(find.text('Send')); await tester.pump(); // The dialog should still be open (Send was gated/no-op) expect(find.text('Get Agent Status'), findsOneWidget); expect(fakeStatusRepo.lastCommandOperation, isNull); // Cancel dialog await tester.tap(find.text('Cancel')); await tester.pump(); // 2. Verify agent.command gating await tester.tap(find.text('Agent Command')); await tester.pump(); expect(find.text('Send Agent Command'), findsOneWidget); // Try to send with empty command name and empty selector await tester.tap(find.text('Send')); await tester.pump(); expect(find.text('Send Agent Command'), findsOneWidget); expect(fakeStatusRepo.lastCommandOperation, isNull); // Enter only selector await tester.enterText(find.byType(TextField).at(0), 'node-1'); await tester.tap(find.text('Send')); await tester.pump(); expect(find.text('Send Agent Command'), findsOneWidget); expect(fakeStatusRepo.lastCommandOperation, isNull); // Enter only command name (clear selector) await tester.enterText(find.byType(TextField).at(0), ''); await tester.enterText(find.byType(TextField).at(1), 'sync'); await tester.tap(find.text('Send')); await tester.pump(); expect(find.text('Send Agent Command'), findsOneWidget); expect(fakeStatusRepo.lastCommandOperation, isNull); // Cancel await tester.tap(find.text('Cancel')); await tester.pump(); }, ); // [REVIEW_API-1] Regression: health=degraded, status=active -> yellow state text/color testWidgets( 'Provider with health=degraded and status=active shows DEGRADED text with yellow color', (WidgetTester tester) async { final fakeClient = FakeClientWireClient(shouldSuccess: true); final fakeStatusRepo = FakeControlPlaneStatusRepository(); // Override the OpenAI provider to have health=degraded, status=active // This is already set in the fake repo, but verify via the widget await tester.pumpWidget( IopClientApp(testClient: fakeClient, statusRepository: fakeStatusRepo), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); await tester.tap(find.byTooltip('Nodes')); await tester.pumpAndSettle(); // Scroll to reveal node-2's provider catalog await tester.drag(find.byType(ListView), const Offset(0.0, -1500.0)); await tester.pumpAndSettle(); // The degraded provider should show DEGRADED text (not ACTIVE) expect(find.textContaining('DEGRADED'), findsOneWidget); // Also confirm the provider id is shown expect(find.text('provider-openai'), findsOneWidget); }, ); // [REVIEW_API-2] DTO JSON parsing test testWidgets( 'EdgeStatusResponseView.fromJson parses raw JSON and validates provider_snapshots', (WidgetTester tester) async { // This test verifies the HTTP JSON DTO parsing path used by // HttpControlPlaneStatusRepository.fetchEdgeStatus. final rawJson = { 'request_id': 'test-req-001', 'edge_id': 'edge-test', 'edge_name': 'Edge Test Node', 'observed_time_unix_nano': 1716584400000000000, 'nodes': [ { 'node_id': 'node-1', 'alias': 'Test Node', 'label': 'GPU-A100', 'connected': true, 'config': { 'adapters': [ {'type': 'ollama', 'enabled': true}, ], 'concurrency': 8, }, 'provider_snapshots': [ { 'id': 'provider-ollama', 'adapter': 'ollama', 'type': 'llm', 'category': 'inference', 'status': 'active', 'health': 'degraded', 'capacity': 10, 'in_flight': 3, 'queued': 1, 'load_ratio': 0.3, 'served_models': ['llama-3.1', 'mistral'], 'lifecycle_capabilities': ['start', 'stop', 'restart'], }, { 'id': 'provider-vllm', 'adapter': 'vllm', 'type': 'llm', 'category': 'inference', 'status': 'ready', 'health': 'healthy', 'capacity': 20, 'in_flight': 0, 'queued': 0, 'load_ratio': 0.0, 'served_models': ['vicuna'], 'lifecycle_capabilities': ['start', 'stop'], }, ], }, ], 'capabilities': [ { 'kind': 'Serving', 'available': true, 'status': 'ready', 'summary': 'Active', }, ], 'domain_agents': [], 'metadata': {'region': 'us-west'}, 'error': '', }; // Parse the raw JSON using the DTO from // apps/client/lib/control_plane_status_dto.dart final parsed = EdgeStatusResponseView.fromJson(rawJson); // Validate top-level fields expect(parsed.requestId, equals('test-req-001')); expect(parsed.edgeId, equals('edge-test')); expect(parsed.edgeName, equals('Edge Test Node')); expect(parsed.observedTimeUnixNano, equals(1716584400000000000)); expect(parsed.nodes.length, equals(1)); expect(parsed.error, equals('')); // Validate node fields final node = parsed.nodes.first; expect(node.nodeId, equals('node-1')); expect(node.alias, equals('Test Node')); expect(node.label, equals('GPU-A100')); expect(node.connected, isTrue); expect(node.config, isNotNull); expect(node.config!.adapters.length, equals(1)); expect(node.config!.adapters.first.type, equals('ollama')); expect(node.config!.adapters.first.enabled, isTrue); expect(node.config!.concurrency, equals(8)); // Validate provider_snapshots fields final prov0 = node.providerSnapshots[0]; expect(prov0.id, equals('provider-ollama')); expect(prov0.adapter, equals('ollama')); expect(prov0.type, equals('llm')); expect(prov0.category, equals('inference')); expect(prov0.status, equals('active')); expect(prov0.health, equals('degraded')); expect(prov0.capacity, equals(10)); expect(prov0.inFlight, equals(3)); expect(prov0.queued, equals(1)); expect(prov0.loadRatio, equals(0.3)); expect(prov0.servedModels, equals(['llama-3.1', 'mistral'])); expect(prov0.lifecycleCapabilities, equals(['start', 'stop', 'restart'])); // Validate second provider snapshot final prov1 = node.providerSnapshots[1]; expect(prov1.id, equals('provider-vllm')); expect(prov1.adapter, equals('vllm')); expect(prov1.status, equals('ready')); expect(prov1.health, equals('healthy')); expect(prov1.capacity, equals(20)); expect(prov1.inFlight, equals(0)); expect(prov1.queued, equals(0)); expect(prov1.loadRatio, equals(0.0)); expect(prov1.servedModels, equals(['vicuna'])); expect(prov1.lifecycleCapabilities, equals(['start', 'stop'])); // Validate capabilities and metadata expect(parsed.capabilities.length, equals(1)); expect(parsed.capabilities.first.kind, equals('Serving')); expect(parsed.capabilities.first.available, isTrue); expect(parsed.metadata, equals({'region': 'us-west'})); // Verify that the same raw JSON can also be rendered in the widget. // This connects the DTO parsing path to the UI path. final statusView = EdgeStatusResponseView.fromJson(rawJson); await tester.pumpWidget( MaterialApp( home: Scaffold( body: NodesPanel( edges: [ FleetEdgeView( edgeId: 'edge-test', edgeName: 'Edge Test Node', version: '1.0.0', protocol: 'iop-wire-v1', connected: true, health: 'healthy', lastSeen: DateTime.now(), nodeCount: 1, capabilities: [], domainAgents: [], error: '', ), ], selectedEdgeId: 'edge-test', selectedEdgeStatus: statusView, isLoading: false, error: null, onSelectEdge: (_) {}, onRefresh: () {}, ), ), ), ); await tester.pump(); // Verify degraded provider shows correctly via _stateColor semantics expect(find.textContaining('DEGRADED'), findsOneWidget); expect(find.textContaining('HEALTHY'), findsOneWidget); expect(find.textContaining('provider-ollama'), findsOneWidget); expect(find.textContaining('provider-vllm'), findsOneWidget); }, ); // [G03] Control Plane predecessor values: health=available/status=available testWidgets( 'Provider with health=available and status=available shows AVAILABLE text with green color', (WidgetTester tester) async { // Test uses standalone NodesPanel with available providers. // The DTO test below covers JSON parsing for all predecessor values. final statusView = EdgeStatusResponseView.fromJson({ 'request_id': 'test-req-g03-solo', 'edge_id': 'edge-g03', 'edge_name': 'Edge G03 Test', 'observed_time_unix_nano': 1716584400000000000, 'nodes': [ { 'node_id': 'node-g03', 'alias': 'G03 Test Node', 'label': 'GPU-Test', 'connected': true, 'config': { 'adapters': [ {'type': 'ollama', 'enabled': true}, ], 'concurrency': 4, }, 'provider_snapshots': [ { 'id': 'provider-available', 'adapter': 'ollama', 'type': 'llm', 'category': 'inference', 'status': 'available', 'health': 'available', 'capacity': 10, 'in_flight': 2, 'queued': 0, 'load_ratio': 0.2, 'served_models': ['llama-3.1'], 'lifecycle_capabilities': ['start', 'stop'], }, ], }, ], 'capabilities': [], 'domain_agents': [], 'metadata': {}, 'error': '', }); await tester.pumpWidget( MaterialApp( home: Scaffold( body: NodesPanel( edges: [ FleetEdgeView( edgeId: 'edge-g03', edgeName: 'Edge G03 Test', version: '1.0.0', protocol: 'iop-wire-v1', connected: true, health: 'healthy', lastSeen: DateTime.now(), nodeCount: 1, capabilities: [], domainAgents: [], error: '', ), ], selectedEdgeId: 'edge-g03', selectedEdgeStatus: statusView, isLoading: false, error: null, onSelectEdge: (_) {}, onRefresh: () {}, ), ), ), ); await tester.pump(); // health=available/status=available should show AVAILABLE text in green (#10B981) expect(find.text('AVAILABLE'), findsOneWidget); expect(find.text('provider-available'), findsOneWidget); // [G02] Status label color assertions // Find the AVAILABLE status label Text widget (inside Container) and verify its color final availableLabelFinder = find.byWidgetPredicate((Widget widget) { if (widget is Text) { return widget.data == 'AVAILABLE' && widget.style != null; } return false; }); expect(availableLabelFinder, findsOneWidget); final availableLabel = availableLabelFinder.evaluate().first.widget as Text; expect(availableLabel.style?.color, equals(const Color(0xFF10B981))); }, ); // [G03] DTO JSON parsing test for Control Plane predecessor values testWidgets( 'EdgeStatusResponseView parses health=available/status=available and health=unavailable/status=backlog', (WidgetTester tester) async { final rawJson = { 'request_id': 'test-req-g03', 'edge_id': 'edge-g03', 'edge_name': 'Edge G03 Test', 'observed_time_unix_nano': 1716584400000000000, 'nodes': [ { 'node_id': 'node-g03', 'alias': 'G03 Test Node', 'label': 'GPU-Test', 'connected': true, 'config': { 'adapters': [ {'type': 'ollama', 'enabled': true}, ], 'concurrency': 4, }, 'provider_snapshots': [ { // health=available, status=available -> green 'id': 'provider-available', 'adapter': 'ollama', 'type': 'llm', 'category': 'inference', 'status': 'available', 'health': 'available', 'capacity': 10, 'in_flight': 2, 'queued': 0, 'load_ratio': 0.2, 'served_models': ['llama-3.1'], 'lifecycle_capabilities': ['start', 'stop'], }, { // health=unavailable, status=backlog -> red 'id': 'provider-unavailable', 'adapter': 'vllm', 'type': 'llm', 'category': 'inference', 'status': 'backlog', 'health': 'unavailable', 'capacity': 20, 'in_flight': 0, 'queued': 0, 'load_ratio': 0.0, 'served_models': [], 'lifecycle_capabilities': ['start', 'stop'], }, { // health=unknown, status=unknown -> neutral grey 'id': 'provider-unknown', 'adapter': 'openai', 'type': 'llm', 'category': 'external', 'status': 'unknown', 'health': '', 'capacity': 100, 'in_flight': 0, 'queued': 0, 'load_ratio': 0.0, 'served_models': ['gpt-4'], 'lifecycle_capabilities': [], }, ], }, ], 'capabilities': [], 'domain_agents': [], 'metadata': {}, 'error': '', }; // Parse the raw JSON final parsed = EdgeStatusResponseView.fromJson(rawJson); expect(parsed.requestId, equals('test-req-g03')); expect(parsed.edgeId, equals('edge-g03')); expect(parsed.nodes.length, equals(1)); // Validate provider snapshots final prov0 = parsed.nodes[0].providerSnapshots[0]; expect(prov0.id, equals('provider-available')); expect(prov0.health, equals('available')); expect(prov0.status, equals('available')); final prov1 = parsed.nodes[0].providerSnapshots[1]; expect(prov1.id, equals('provider-unavailable')); expect(prov1.health, equals('unavailable')); expect(prov1.status, equals('backlog')); final prov2 = parsed.nodes[0].providerSnapshots[2]; expect(prov2.id, equals('provider-unknown')); expect(prov2.health, equals('')); expect(prov2.status, equals('unknown')); // Verify that the same raw JSON can be rendered in the widget. final statusView = EdgeStatusResponseView.fromJson(rawJson); await tester.pumpWidget( MaterialApp( home: Scaffold( body: NodesPanel( edges: [ FleetEdgeView( edgeId: 'edge-g03', edgeName: 'Edge G03 Test', version: '1.0.0', protocol: 'iop-wire-v1', connected: true, health: 'healthy', lastSeen: DateTime.now(), nodeCount: 1, capabilities: [], domainAgents: [], error: '', ), ], selectedEdgeId: 'edge-g03', selectedEdgeStatus: statusView, isLoading: false, error: null, onSelectEdge: (_) {}, onRefresh: () {}, ), ), ), ); await tester.pump(); // Verify available provider shows AVAILABLE text expect(find.text('AVAILABLE'), findsWidgets); expect(find.textContaining('provider-available'), findsOneWidget); // [G02] UNAVAILABLE status label color assertion (#EF4444 red) final unavailableLabelFinder = find.byWidgetPredicate((Widget widget) { if (widget is Text) { return widget.data == 'UNAVAILABLE' && widget.style != null; } return false; }); expect(unavailableLabelFinder, findsOneWidget); final unavailableLabel = unavailableLabelFinder.evaluate().first.widget as Text; expect(unavailableLabel.style?.color, equals(const Color(0xFFEF4444))); // [G02] UNKNOWN status label color assertion (#64748B grey) // For provider-unknown, health='' so status='unknown' -> _stateColor returns Color(0xFF64748B) // displayState = p.health.isNotEmpty ? p.health : p.status = 'unknown' // displayState.toUpperCase() = 'UNKNOWN' final unknownLabelFinder = find.byWidgetPredicate((Widget widget) { if (widget is Text) { return widget.data == 'UNKNOWN' && widget.style != null; } return false; }); expect(unknownLabelFinder, findsOneWidget); final unknownLabel = unknownLabelFinder.evaluate().first.widget as Text; expect(unknownLabel.style?.color, equals(const Color(0xFF64748B))); // Verify unavailable provider shows UNAVAILABLE text expect(find.text('UNAVAILABLE'), findsWidgets); expect(find.textContaining('provider-unavailable'), findsOneWidget); // Verify unknown provider shows UNKNOWN text (from status) expect(find.text('UNKNOWN'), findsWidgets); expect(find.textContaining('provider-unknown'), findsOneWidget); }, ); testWidgets( 'Client App mobile screen layout verification for layout and overflow', (WidgetTester tester) async { // Set a small mobile screen size tester.view.physicalSize = const Size(360, 640); tester.view.devicePixelRatio = 1.0; final fakeClient = FakeClientWireClient(shouldSuccess: true); final fakeStatusRepo = FakeControlPlaneStatusRepository(); await tester.pumpWidget( IopClientApp(testClient: fakeClient, statusRepository: fakeStatusRepo), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); // Overview expect(find.text('Operations Overview'), findsOneWidget); // Switch to Edges panel await tester.tap(find.byTooltip('Edges')); await tester.pump(); expect(find.text('Edge Alpha'), findsNWidgets(2)); // Switch to Runtime panel await tester.tap(find.byTooltip('Runtime')); await tester.pump(); expect(find.text('Operations & Domain Agents'), findsOneWidget); // Reset view size after test addTearDown(() { tester.view.resetPhysicalSize(); tester.view.resetDevicePixelRatio(); }); }, ); // [G05] Notification stream → UI(SnackBar) connection verification testWidgets( 'notification stream from NexoNotificationHostIntegration connects to UI snackbar', (WidgetTester tester) async { final fakeClient = FakeClientWireClient(shouldSuccess: true); final fakeStatusRepo = FakeControlPlaneStatusRepository(); // Create a fake NexoNotificationClient that emits a notification. final notificationController = StreamController>(); final fakeNexoClient = _FakeNexoClientsForUiTest(notificationController); final fakeHost = NexoNotificationHostIntegration( pushClient: fakeNexoClient, ); await tester.pumpWidget( IopClientApp( testClient: fakeClient, statusRepository: fakeStatusRepo, notificationHost: fakeHost, ), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); // Verify wire status shows connected. expect(find.text('CONNECTED'), findsOneWidget); // Emit a notification message through the fake client. notificationController.add(const { 'type': 'message', 'message': 'Hello from Nexo', 'channel_name': 'general', 'sender_name': 'test-user', }); await tester.pump(); // Verify the SnackBar appears in the UI. expect(find.textContaining('Hello from Nexo'), findsOneWidget); await notificationController.close(); }, ); // [G05] Notification stream with channel name only (no sender) testWidgets( 'notification stream shows channel-only message when sender is empty', (WidgetTester tester) async { final fakeClient = FakeClientWireClient(shouldSuccess: true); final fakeStatusRepo = FakeControlPlaneStatusRepository(); final notificationController = StreamController>(); final fakeNexoClient = _FakeNexoClientsForUiTest(notificationController); final fakeHost = NexoNotificationHostIntegration( pushClient: fakeNexoClient, ); await tester.pumpWidget( IopClientApp( testClient: fakeClient, statusRepository: fakeStatusRepo, notificationHost: fakeHost, ), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); expect(find.text('CONNECTED'), findsOneWidget); // Emit notification with empty sender — should show just the message. notificationController.add(const { 'type': 'message', 'message': 'Direct message content', 'channel_name': 'alerts', 'sender_name': '', }); await tester.pump(); // When sender is empty, only message content is shown. expect(find.textContaining('Direct message content'), findsOneWidget); await notificationController.close(); }, ); // [G05] Non-message type should not trigger snackbar testWidgets('notification stream ignores non-message events (e.g. system)', ( WidgetTester tester, ) async { final fakeClient = FakeClientWireClient(shouldSuccess: true); final fakeStatusRepo = FakeControlPlaneStatusRepository(); final notificationController = StreamController>(); final fakeNexoClient = _FakeNexoClientsForUiTest(notificationController); final fakeHost = NexoNotificationHostIntegration( pushClient: fakeNexoClient, ); await tester.pumpWidget( IopClientApp( testClient: fakeClient, statusRepository: fakeStatusRepo, notificationHost: fakeHost, ), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); // Emit a non-message type. notificationController.add(const { 'type': 'system', 'message': 'System notification', }); await tester.pump(); // No SnackBar should appear for non-message types. expect(find.textContaining('System notification'), findsNothing); await notificationController.close(); }); } /// Fake NexoNotificationClient for widget tests that broadcasts on a /// provided StreamController. class _FakeNexoClientsForUiTest implements NexoNotificationClient { final StreamController> controller; _FakeNexoClientsForUiTest(this.controller); @override Stream> get onNotification => controller.stream; @override Future initialize() async {} @override Future getDeviceToken() async => null; @override Future setAuthToken( String serverUrl, String token, { String? identifier, }) async {} @override Future setSigningKey(String serverUrl, String signingKey) async {} @override set onDeviceTokenReady(Future Function(String token)? callback) {} @override set onNavigateToChannel( void Function(String serverUrl, String channelId)? callback, ) {} @override set onNavigateToThread( void Function(String serverUrl, String rootId)? callback, ) {} }