- Move fleet polling logic to control orchestrator - Split client state management into separate controller/repository - Add client bootstrap and home page components - Update HTTP fleet handlers and views - Add unit tests for client bootstrap and status controller - Archive completed subtask documents
874 lines
30 KiB
Dart
874 lines
30 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(), heartbeatIntervalTime: 0);
|
|
|
|
@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 {
|
|
String nextCommandStatus = 'accepted';
|
|
String nextCommandError = '';
|
|
String nextCommandSummary = 'Command accepted by control plane';
|
|
|
|
String? lastCommandOperation;
|
|
String? lastCommandTargetSelector;
|
|
Map<String, String>? lastCommandParameters;
|
|
String? lastCommandEdgeId;
|
|
Completer<EdgeOperationsResponseView>? edgeBOperationsCompleter;
|
|
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
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<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: {},
|
|
),
|
|
];
|
|
}
|
|
|
|
@override
|
|
Future<FleetStatusResponseView> 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<EdgeOperationsResponseView> 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<EdgeCommandResponseView> sendEdgeCommand(
|
|
String edgeId,
|
|
String operation, {
|
|
String? targetSelector,
|
|
Map<String, String>? 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.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('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<EdgeOperationsResponseView>();
|
|
|
|
// Switch to Edge Beta via Dropdown
|
|
await tester.tap(find.byType(DropdownButton<String>));
|
|
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();
|
|
},
|
|
);
|
|
|
|
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();
|
|
});
|
|
},
|
|
);
|
|
}
|