442 lines
15 KiB
Dart
442 lines
15 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:iop_client/control_plane_status_client.dart';
|
|
import 'package:iop_client/main.dart';
|
|
import 'support/client_test_harness.dart';
|
|
|
|
void main() {
|
|
_registerPanelRenderingScenario();
|
|
_registerCommandErrorScenario();
|
|
_registerCommandGatingScenario();
|
|
_registerProviderCommandAllowlistScenario();
|
|
_registerOllamaApiPathScenario();
|
|
_registerPendingEmptyHistoryScenario();
|
|
_registerOperationsFetchStateScenario();
|
|
}
|
|
|
|
void _registerPanelRenderingScenario() {
|
|
testWidgets(
|
|
'Client App opens Operations panel and verifies history and provider commands',
|
|
(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));
|
|
|
|
_verifyPanelHeaderAndSections(tester);
|
|
_verifyOperationsHistoryItem(tester);
|
|
_verifyTriggerSyncAbsent(tester);
|
|
_verifySystemGatedButtons(tester);
|
|
|
|
await _verifyHealthCheckSuccess(tester, fakeStatusRepo);
|
|
|
|
fakeStatusRepo.edgeBOperationsCompleter =
|
|
Completer<EdgeOperationsResponseView>();
|
|
|
|
await tester.tap(find.byType(DropdownButton<String>));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Edge Beta').last);
|
|
await tester.pump();
|
|
|
|
_verifyEdgeSwitchNotLoaded(tester);
|
|
|
|
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();
|
|
|
|
_verifyEdgeBetaActiveCommand(tester);
|
|
_verifyEdgeBetaOperationsHistory(tester);
|
|
_verifyEdgeBetaOperationsAbsence(tester);
|
|
|
|
await tester.tap(find.text('Health Check'));
|
|
await tester.pumpAndSettle();
|
|
expect(fakeStatusRepo.lastCommandEdgeId, equals('edge-b'));
|
|
},
|
|
);
|
|
}
|
|
|
|
void _registerCommandErrorScenario() {
|
|
testWidgets(
|
|
'Client App handles unsupported or error command responses and shows error banner',
|
|
(WidgetTester tester) async {
|
|
final fakeClient = FakeClientWireClient(shouldSuccess: true);
|
|
final fakeStatusRepo = FakeControlPlaneStatusRepository();
|
|
|
|
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));
|
|
|
|
await tester.tap(find.text('Node Status'));
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
|
|
expect(find.text('Get Node 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));
|
|
|
|
expect(
|
|
find.textContaining(
|
|
'Failed: unsupported - Operation not supported by Edge',
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _registerCommandGatingScenario() {
|
|
testWidgets(
|
|
'Client App gates node.status and provider.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));
|
|
|
|
await _verifyNodeStatusGating(tester, fakeStatusRepo);
|
|
await _verifyProviderCommandGating(tester, fakeStatusRepo);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _registerPendingEmptyHistoryScenario() {
|
|
testWidgets(
|
|
'RuntimePanel keeps loaded empty history visible while a command is pending',
|
|
(WidgetTester tester) async {
|
|
final fakeClient = FakeClientWireClient(shouldSuccess: true);
|
|
final fakeStatusRepo = FakeControlPlaneStatusRepository();
|
|
fakeStatusRepo.emptyOperationsEdgeId = 'edge-a';
|
|
|
|
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));
|
|
|
|
expect(find.text('No operation executions recorded.'), findsOneWidget);
|
|
|
|
fakeStatusRepo.commandResponseCompleter =
|
|
Completer<EdgeCommandResponseView>();
|
|
|
|
await tester.tap(find.text('Health Check'));
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
|
|
expect(find.text('No operation executions recorded.'), findsOneWidget);
|
|
expect(find.byType(CircularProgressIndicator), findsNothing);
|
|
expect(fakeStatusRepo.lastCommandOperation, equals('health.check'));
|
|
expect(fakeStatusRepo.lastCommandEdgeId, equals('edge-a'));
|
|
|
|
fakeStatusRepo.commandResponseCompleter!.complete(
|
|
EdgeCommandResponseView(
|
|
requestId: 'req-111',
|
|
commandId: 'cmd-pending-test',
|
|
edgeId: 'edge-a',
|
|
status: 'accepted',
|
|
summary: 'Command accepted by control plane',
|
|
error: '',
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
},
|
|
);
|
|
}
|
|
|
|
void _registerOperationsFetchStateScenario() {
|
|
testWidgets('RuntimePanel renders operations empty and fetch error states', (
|
|
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));
|
|
|
|
fakeStatusRepo.emptyOperationsEdgeId = 'edge-a';
|
|
fakeStatusRepo.fetchOperationsError = null;
|
|
|
|
await _reFetchOperations(tester);
|
|
|
|
expect(find.text('No operation executions recorded.'), findsOneWidget);
|
|
|
|
fakeStatusRepo.fetchOperationsError = Exception('repository failure');
|
|
fakeStatusRepo.emptyOperationsEdgeId = null;
|
|
|
|
await _reFetchOperations(tester);
|
|
|
|
expect(find.textContaining('Error:'), findsOneWidget);
|
|
expect(find.textContaining('repository failure'), findsOneWidget);
|
|
});
|
|
}
|
|
|
|
void _verifyPanelHeaderAndSections(WidgetTester tester) {
|
|
expect(find.text('Operations'), findsOneWidget);
|
|
expect(find.text('Operations Execution History'), findsOneWidget);
|
|
expect(find.text('System Gated Operations'), findsOneWidget);
|
|
}
|
|
|
|
void _verifyOperationsHistoryItem(WidgetTester tester) {
|
|
expect(find.text('deployer.sync'), findsOneWidget);
|
|
expect(find.text('SUCCESS'), findsOneWidget);
|
|
expect(find.textContaining('Synced 5 models'), findsOneWidget);
|
|
}
|
|
|
|
void _verifyTriggerSyncAbsent(WidgetTester tester) {
|
|
expect(find.text('Trigger Sync'), findsNothing);
|
|
}
|
|
|
|
void _verifySystemGatedButtons(WidgetTester tester) {
|
|
expect(find.text('Health Check'), findsOneWidget);
|
|
expect(find.text('Node Status'), findsOneWidget);
|
|
expect(find.text('Provider Command'), findsOneWidget);
|
|
}
|
|
|
|
Future<void> _verifyHealthCheckSuccess(
|
|
WidgetTester tester,
|
|
FakeControlPlaneStatusRepository fakeStatusRepo,
|
|
) async {
|
|
await tester.tap(find.text('Health Check'));
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
|
|
expect(find.textContaining('Success: Command accepted'), findsOneWidget);
|
|
expect(fakeStatusRepo.lastCommandEdgeId, equals('edge-a'));
|
|
}
|
|
|
|
void _verifyEdgeSwitchNotLoaded(WidgetTester tester) {
|
|
expect(find.text('deployer.sync'), findsNothing);
|
|
expect(find.textContaining('Synced 5 models'), findsNothing);
|
|
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
|
}
|
|
|
|
void _verifyEdgeBetaActiveCommand(WidgetTester tester) {
|
|
expect(find.text('build-deploy.deploy'), findsOneWidget);
|
|
expect(find.textContaining('Deploy queued for edge beta'), findsOneWidget);
|
|
}
|
|
|
|
void _verifyEdgeBetaOperationsHistory(WidgetTester tester) {
|
|
expect(find.text('build-deploy.deploy'), findsOneWidget);
|
|
}
|
|
|
|
void _verifyEdgeBetaOperationsAbsence(WidgetTester tester) {
|
|
expect(find.text('deployer.sync'), findsNothing);
|
|
expect(find.textContaining('Synced 5 models'), findsNothing);
|
|
}
|
|
|
|
Future<void> _verifyNodeStatusGating(
|
|
WidgetTester tester,
|
|
FakeControlPlaneStatusRepository fakeStatusRepo,
|
|
) async {
|
|
await tester.tap(find.text('Node Status'));
|
|
await tester.pump();
|
|
expect(find.text('Get Node Status'), findsOneWidget);
|
|
|
|
await tester.enterText(find.byType(TextField), '');
|
|
await tester.tap(find.text('Send'));
|
|
await tester.pump();
|
|
|
|
expect(find.text('Get Node Status'), findsOneWidget);
|
|
expect(fakeStatusRepo.lastCommandOperation, isNull);
|
|
|
|
await tester.tap(find.text('Cancel'));
|
|
await tester.pump();
|
|
}
|
|
|
|
Future<void> _verifyProviderCommandGating(
|
|
WidgetTester tester,
|
|
FakeControlPlaneStatusRepository fakeStatusRepo,
|
|
) async {
|
|
await tester.tap(find.text('Provider Command'));
|
|
await tester.pump();
|
|
expect(find.text('Send Provider Command'), findsOneWidget);
|
|
|
|
// The dialog exposes only the selector as a free-form field; the command is
|
|
// constrained to an allowlisted dropdown, so no arbitrary command can be typed.
|
|
expect(find.byType(TextField), findsOneWidget);
|
|
expect(find.byType(DropdownButtonFormField<String>), findsOneWidget);
|
|
|
|
// No selector and no command selected: Send is inert.
|
|
await tester.tap(find.text('Send'));
|
|
await tester.pump();
|
|
expect(find.text('Send Provider Command'), findsOneWidget);
|
|
expect(fakeStatusRepo.lastCommandOperation, isNull);
|
|
|
|
// Selector present but still no command selected: Send is inert.
|
|
await tester.enterText(find.byType(TextField), 'node-1');
|
|
await tester.tap(find.text('Send'));
|
|
await tester.pump();
|
|
expect(find.text('Send Provider Command'), findsOneWidget);
|
|
expect(fakeStatusRepo.lastCommandOperation, isNull);
|
|
|
|
await tester.tap(find.text('Cancel'));
|
|
await tester.pump();
|
|
}
|
|
|
|
void _registerProviderCommandAllowlistScenario() {
|
|
testWidgets('Client App sends only allowlisted provider commands', (
|
|
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));
|
|
|
|
for (final command in const ['capabilities', 'transport_status']) {
|
|
await _sendAllowlistedCommand(tester, command);
|
|
expect(fakeStatusRepo.lastCommandOperation, equals('provider.command'));
|
|
expect(fakeStatusRepo.lastCommandTargetSelector, equals('node-1'));
|
|
expect(
|
|
fakeStatusRepo.lastCommandParameters,
|
|
equals({'command': command}),
|
|
);
|
|
}
|
|
|
|
// Confirm the dropdown only offers the three allowlisted commands.
|
|
await tester.tap(find.text('Provider Command'));
|
|
await tester.pump();
|
|
await tester.tap(find.byType(DropdownButtonFormField<String>));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('capabilities'), findsWidgets);
|
|
expect(find.text('transport_status'), findsWidgets);
|
|
expect(find.text('ollama_api'), findsWidgets);
|
|
expect(find.text('node.status'), findsNothing);
|
|
expect(find.text('provider.command'), findsNothing);
|
|
await tester.tap(find.text('capabilities').last);
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Cancel'));
|
|
await tester.pump();
|
|
});
|
|
}
|
|
|
|
void _registerOllamaApiPathScenario() {
|
|
testWidgets('Client App requires Ollama API path', (
|
|
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));
|
|
|
|
await tester.tap(find.text('Provider Command'));
|
|
await tester.pump();
|
|
await tester.tap(find.byType(DropdownButtonFormField<String>).first);
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('ollama_api').last);
|
|
await tester.pumpAndSettle();
|
|
|
|
// With ollama_api selected the method dropdown and path field appear.
|
|
expect(find.byType(TextField), findsNWidgets(2));
|
|
|
|
await tester.enterText(find.byType(TextField).at(0), 'node-1');
|
|
// Empty path: Send stays gated and never reaches the repository.
|
|
await tester.tap(find.text('Send'));
|
|
await tester.pump();
|
|
expect(find.text('Send Provider Command'), findsOneWidget);
|
|
expect(fakeStatusRepo.lastCommandOperation, isNull);
|
|
|
|
await tester.enterText(find.byType(TextField).at(1), '/api/tags');
|
|
await tester.tap(find.text('Send'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(fakeStatusRepo.lastCommandOperation, equals('provider.command'));
|
|
expect(fakeStatusRepo.lastCommandTargetSelector, equals('node-1'));
|
|
expect(
|
|
fakeStatusRepo.lastCommandParameters,
|
|
equals({'command': 'ollama_api', 'method': 'GET', 'path': '/api/tags'}),
|
|
);
|
|
});
|
|
}
|
|
|
|
Future<void> _sendAllowlistedCommand(
|
|
WidgetTester tester,
|
|
String command,
|
|
) async {
|
|
await tester.tap(find.text('Provider Command'));
|
|
await tester.pump();
|
|
expect(find.text('Send Provider Command'), findsOneWidget);
|
|
|
|
await tester.enterText(find.byType(TextField), 'node-1');
|
|
await tester.tap(find.byType(DropdownButtonFormField<String>));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text(command).last);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.text('Send'));
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
Future<void> _reFetchOperations(WidgetTester tester) async {
|
|
await tester.tap(find.byIcon(Icons.refresh));
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
}
|