iop/apps/client/test/widget_test.dart
toki b77f44a706 feat: control plane status client and UI components
- Add ControlPlaneStatusClient for querying control plane state
- Add control plane status widgets for displaying status in client
- Update main.dart to integrate control plane status features
- Update widget_test.dart
- Archive completed task documents
2026-06-02 19:56:42 +09:00

387 lines
12 KiB
Dart

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<dynamic>();
@override
StreamSubscription<dynamic> 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<Res> 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 {
List<EdgeRegistryView> 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<List<EdgeRegistryView>> fetchEdges() async {
return mockEdges;
}
@override
Future<EdgeStatusResponseView> 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,
),
),
],
metadata: {'region': 'us-west'},
error: '',
);
}
@override
Future<List<EdgeNodeEventView>> 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: {},
),
];
}
}
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);
});
}