339 lines
9.9 KiB
Dart
339 lines
9.9 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:nomadcode_app/src/app/nomadcode_client_app.dart';
|
|
import 'package:nomadcode_app/src/features/workspaces/domain/project_workspace.dart';
|
|
import 'package:nomadcode_app/src/features/workspaces/domain/workspace_task.dart';
|
|
import 'package:nomadcode_app/src/features/workspaces/presentation/workspace_home_page.dart';
|
|
import 'package:nomadcode_app/src/integrations/proto_socket/proto_socket_endpoint_config.dart';
|
|
import 'package:nomadcode_app/src/integrations/proto_socket/proto_socket_envelope.dart';
|
|
import 'package:nomadcode_app/src/integrations/proto_socket/proto_socket_lifecycle.dart';
|
|
|
|
const _enabledProtoSocketConfig = ProtoSocketEndpointConfig(
|
|
host: 'core.example.com',
|
|
port: 443,
|
|
secure: true,
|
|
enabled: true,
|
|
);
|
|
|
|
const _disabledProtoSocketConfig = ProtoSocketEndpointConfig(
|
|
host: 'core.example.com',
|
|
port: 443,
|
|
secure: true,
|
|
);
|
|
|
|
class _FakeProtoSocketTransport implements ProtoSocketTransport {
|
|
final List<Map<String, Object?>> taskMaps = const [
|
|
{
|
|
'id': 'task-service-1',
|
|
'title': 'Service task from Core',
|
|
'status': 'running',
|
|
'source': 'core',
|
|
},
|
|
];
|
|
final List<ProtoSocketEnvelope> sent = [];
|
|
|
|
@override
|
|
Stream<ProtoSocketEnvelope> get events => const Stream.empty();
|
|
|
|
@override
|
|
Future<ProtoSocketEnvelope> sendRequest(
|
|
ProtoSocketEnvelope envelope, {
|
|
Duration timeout = const Duration(seconds: 30),
|
|
}) async {
|
|
sent.add(envelope);
|
|
if (envelope.action != 'task.list') {
|
|
throw StateError('unexpected action ${envelope.action}');
|
|
}
|
|
return ProtoSocketEnvelope(
|
|
id: 'response-${sent.length}',
|
|
correlationId: envelope.id,
|
|
type: 'response',
|
|
channel: 'task',
|
|
action: envelope.action,
|
|
payload: {'tasks': taskMaps},
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> close() async {}
|
|
}
|
|
|
|
class _FakeProtoSocketConnector implements ProtoSocketConnector {
|
|
final _FakeProtoSocketTransport transport;
|
|
final Completer<ProtoSocketTransport>? pendingConnect;
|
|
int connectCalls = 0;
|
|
int disconnectCalls = 0;
|
|
|
|
_FakeProtoSocketConnector({
|
|
_FakeProtoSocketTransport? transport,
|
|
this.pendingConnect,
|
|
}) : transport = transport ?? _FakeProtoSocketTransport();
|
|
|
|
@override
|
|
Future<ProtoSocketTransport> connect(ProtoSocketEndpointConfig config) {
|
|
connectCalls += 1;
|
|
final pending = pendingConnect;
|
|
if (pending != null) return pending.future;
|
|
return Future.value(transport);
|
|
}
|
|
|
|
@override
|
|
Future<void> disconnect() async {
|
|
disconnectCalls += 1;
|
|
}
|
|
}
|
|
|
|
void main() {
|
|
testWidgets('App renders console dashboard title', (
|
|
WidgetTester tester,
|
|
) async {
|
|
await tester.pumpWidget(const NomadCodeClientApp());
|
|
|
|
expect(find.text('NomadCode Console'), findsOneWidget);
|
|
expect(find.text('NomadCode Core'), findsNWidgets(2));
|
|
expect(find.text('IOP Bridge'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('WorkspaceHomePage triggers launcher callback on button tap', (
|
|
WidgetTester tester,
|
|
) async {
|
|
ProjectWorkspace? launchedProject;
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: WorkspaceHomePage(
|
|
onLaunchCodeServer: (project) async {
|
|
launchedProject = project;
|
|
return true;
|
|
},
|
|
),
|
|
),
|
|
);
|
|
|
|
final buttonFinder = find.widgetWithText(ElevatedButton, 'Open Workspace');
|
|
expect(buttonFinder, findsOneWidget);
|
|
|
|
await tester.tap(buttonFinder);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(launchedProject, isNotNull);
|
|
expect(launchedProject!.id, equals('nomadcode'));
|
|
});
|
|
|
|
testWidgets('WorkspaceHomePage renders tasks from the loader', (
|
|
WidgetTester tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: WorkspaceHomePage(
|
|
loadTasks: (project) async => const [
|
|
WorkspaceTask(
|
|
id: 't1',
|
|
title: 'Build pipeline',
|
|
status: 'running',
|
|
source: 'core',
|
|
),
|
|
WorkspaceTask(
|
|
id: 't2',
|
|
title: 'Sync contracts',
|
|
status: 'queued',
|
|
source: 'core',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Build pipeline'), findsOneWidget);
|
|
expect(find.text('Sync contracts'), findsOneWidget);
|
|
expect(find.text('running'), findsOneWidget);
|
|
expect(find.text('queued'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('WorkspaceHomePage renders empty task state from the loader', (
|
|
WidgetTester tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: WorkspaceHomePage(
|
|
loadTasks: (project) async => const <WorkspaceTask>[],
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('No active tasks. All tasks are idle.'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('WorkspaceHomePage renders error state with retry on failure', (
|
|
WidgetTester tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: WorkspaceHomePage(
|
|
loadTasks: (project) async => throw Exception('secret transport boom'),
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Failed to load tasks.'), findsOneWidget);
|
|
expect(find.widgetWithText(TextButton, 'Retry'), findsOneWidget);
|
|
// The underlying exception detail must not leak into the UI.
|
|
expect(find.textContaining('secret transport boom'), findsNothing);
|
|
});
|
|
|
|
testWidgets('reloads tasks when loader is attached after initial build', (
|
|
WidgetTester tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
const MaterialApp(home: WorkspaceHomePage(loadTasks: null)),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Attached loader task'), findsNothing);
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: WorkspaceHomePage(
|
|
loadTasks: (project) async => const [
|
|
WorkspaceTask(
|
|
id: 'attached-1',
|
|
title: 'Attached loader task',
|
|
status: 'queued',
|
|
source: 'core',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Attached loader task'), findsOneWidget);
|
|
expect(find.text('queued'), findsOneWidget);
|
|
});
|
|
|
|
group('NomadCodeClientApp', () {
|
|
testWidgets('does not connect when proto-socket config is absent', (
|
|
WidgetTester tester,
|
|
) async {
|
|
final connector = _FakeProtoSocketConnector();
|
|
final lifecycle = ProtoSocketLifecycle(connector: connector);
|
|
|
|
await tester.pumpWidget(
|
|
NomadCodeClientApp(protoSocketLifecycle: lifecycle),
|
|
);
|
|
await tester.pump();
|
|
|
|
expect(connector.connectCalls, equals(0));
|
|
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
await lifecycle.dispose();
|
|
});
|
|
|
|
testWidgets('does not connect when proto-socket config is disabled', (
|
|
WidgetTester tester,
|
|
) async {
|
|
final connector = _FakeProtoSocketConnector();
|
|
final lifecycle = ProtoSocketLifecycle(connector: connector);
|
|
|
|
await tester.pumpWidget(
|
|
NomadCodeClientApp(
|
|
protoSocketLifecycle: lifecycle,
|
|
protoSocketConfig: _disabledProtoSocketConfig,
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
expect(connector.connectCalls, equals(0));
|
|
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
await lifecycle.dispose();
|
|
});
|
|
|
|
testWidgets('connects once and renders service task data', (
|
|
WidgetTester tester,
|
|
) async {
|
|
final connector = _FakeProtoSocketConnector();
|
|
final lifecycle = ProtoSocketLifecycle(connector: connector);
|
|
|
|
await tester.pumpWidget(
|
|
NomadCodeClientApp(
|
|
protoSocketLifecycle: lifecycle,
|
|
protoSocketConfig: _enabledProtoSocketConfig,
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(connector.connectCalls, equals(1));
|
|
expect(connector.transport.sent.single.action, equals('task.list'));
|
|
expect(find.text('Service task from Core'), findsOneWidget);
|
|
expect(find.text('running'), findsOneWidget);
|
|
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
await tester.pump();
|
|
await lifecycle.dispose();
|
|
});
|
|
|
|
testWidgets(
|
|
'uses already connected lifecycle without disconnecting on dispose',
|
|
(WidgetTester tester) async {
|
|
final connector = _FakeProtoSocketConnector();
|
|
final lifecycle = ProtoSocketLifecycle(connector: connector);
|
|
await lifecycle.connect(_enabledProtoSocketConfig);
|
|
|
|
await tester.pumpWidget(
|
|
NomadCodeClientApp(
|
|
protoSocketLifecycle: lifecycle,
|
|
protoSocketConfig: _enabledProtoSocketConfig,
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(connector.connectCalls, equals(1));
|
|
expect(find.text('Service task from Core'), findsOneWidget);
|
|
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
await tester.pump();
|
|
|
|
expect(connector.disconnectCalls, equals(0));
|
|
|
|
await lifecycle.disconnect();
|
|
await lifecycle.dispose();
|
|
},
|
|
);
|
|
|
|
testWidgets('cleans up late app-started connects after dispose', (
|
|
WidgetTester tester,
|
|
) async {
|
|
final pendingConnect = Completer<ProtoSocketTransport>();
|
|
final connector = _FakeProtoSocketConnector(
|
|
pendingConnect: pendingConnect,
|
|
);
|
|
final lifecycle = ProtoSocketLifecycle(connector: connector);
|
|
|
|
await tester.pumpWidget(
|
|
NomadCodeClientApp(
|
|
protoSocketLifecycle: lifecycle,
|
|
protoSocketConfig: _enabledProtoSocketConfig,
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
expect(connector.connectCalls, equals(1));
|
|
expect(lifecycle.state, ProtoSocketConnectionState.connecting);
|
|
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
pendingConnect.complete(connector.transport);
|
|
await tester.pump();
|
|
await tester.pump();
|
|
|
|
expect(connector.disconnectCalls, equals(1));
|
|
|
|
await lifecycle.dispose();
|
|
});
|
|
});
|
|
}
|