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:protobuf/protobuf.dart'; import 'package:fixnum/fixnum.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()); @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; 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, ), ), EdgeNodeSnapshotView( nodeId: 'node-2', alias: 'Node Two', label: 'CPU-only', connected: false, config: NodeConfigSummaryView( adapters: [ AdapterSummaryView(type: 'python-cli', enabled: true), ], concurrency: 2, ), ), ], capabilities: [ EdgeCapabilitySummaryView(kind: 'Serving', available: true, status: 'ready', summary: 'Active'), EdgeCapabilitySummaryView(kind: 'Automation', available: true, status: 'ready', summary: 'Active'), ], domainAgents: [ EdgeDomainAgentSummaryView( agentKind: 'oto-agent', available: true, lifecycleState: 'ready', activeCommandId: '', summary: 'Active', ), 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: [ EdgeDomainAgentSummaryView( agentKind: 'oto-agent', available: true, lifecycleState: 'ready', activeCommandId: '', summary: 'Active', ), EdgeDomainAgentSummaryView( agentKind: 'build-deploy', available: true, lifecycleState: 'ready', activeCommandId: '', summary: 'Idle', ), ], error: '', )).toList(), ); } @override Future fetchEdgeOperations(String edgeId) async { return EdgeOperationsResponseView( edgeId: edgeId, operations: [ EdgeCommandRecordView( edgeId: edgeId, commandId: 'cmd-1', operation: 'oto-agent.sync', targetSelector: '', status: 'success', summary: 'Synced 5 models', error: '', ), ], ); } @override Future sendEdgeCommand( String edgeId, String operation, { String? targetSelector, Map? parameters, }) async { lastCommandOperation = operation; lastCommandTargetSelector = targetSelector; lastCommandParameters = parameters; 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); }); 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.pump(); // Verify Nodes view header expect(find.text('Nodes View'), findsOneWidget); // Verify nodes listed expect(find.text('Node One'), findsOneWidget); expect(find.text('Node Two'), findsOneWidget); // Verify node properties and adapters 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 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('OTO-AGENT'), findsOneWidget); expect(find.text('BUILD-DEPLOY'), findsOneWidget); expect(find.text('READY'), findsNWidgets(2)); // oto-agent and build-deploy are both ready // Verify operations history item expect(find.text('oto-agent.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); }); 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(); }); 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(); }); }); }