nomadcode/apps/client/test/widget_test.dart

413 lines
13 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 StreamController<ProtoSocketEnvelope> controller =
StreamController<ProtoSocketEnvelope>.broadcast();
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 => controller.stream;
@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},
meta: const {
'connection_id': 'conn-widget',
'timestamp': '2026-05-30T12:02:00Z',
},
);
}
@override
Future<void> close() => controller.close();
}
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;
}
}
/// Pumps a bounded number of frames so async connect/load work settles without
/// `pumpAndSettle`, which never returns while the loading
/// `CircularProgressIndicator` animation is on screen.
Future<void> _pumpFrames(
WidgetTester tester, {
int frames = 8,
Duration step = const Duration(milliseconds: 50),
}) async {
for (var i = 0; i < frames; i++) {
await tester.pump(step);
}
}
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('WorkspaceHomePage shows proto-socket diagnostics safely', (
WidgetTester tester,
) async {
final connector = _FakeProtoSocketConnector();
final lifecycle = ProtoSocketLifecycle(connector: connector);
await lifecycle.connect(_enabledProtoSocketConfig);
await tester.pumpWidget(
MaterialApp(home: WorkspaceHomePage(protoSocketLifecycle: lifecycle)),
);
await tester.pump();
connector.transport.controller.add(
const ProtoSocketEnvelope(
id: 'event-secret',
type: 'event',
channel: 'task',
action: 'task.status.changed',
error: ProtoSocketEnvelopeError(
code: 'task.conflict',
message: 'secret server detail',
retryable: false,
),
payload: {'secret': 'do-not-render'},
meta: {
'connection_id': 'conn-debug',
'timestamp': '2026-05-30T12:03:00Z',
},
),
);
// The diagnostics event crosses two broadcast streams (transport ->
// lifecycle -> StreamBuilder); pump twice so the delivered event also gets
// a frame to rebuild the debug surface.
await tester.pump();
await tester.pump();
expect(find.text('PROTO-SOCKET'), findsOneWidget);
expect(find.text('connected'), findsOneWidget);
expect(find.text('conn-debug'), findsOneWidget);
expect(find.text(protoSocketProtocolVersion), findsOneWidget);
expect(find.text('task'), findsOneWidget);
expect(find.text('task.status.changed'), findsOneWidget);
expect(find.text('task.conflict'), findsOneWidget);
expect(find.text('2026-05-30T12:03:00Z'), findsOneWidget);
expect(find.textContaining('do-not-render'), findsNothing);
expect(find.textContaining('secret server detail'), findsNothing);
addTearDown(lifecycle.dispose);
});
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);
// Dispose via addTearDown so it runs after the tester has quiesced; a
// manual dispose() in the body races in-flight diagnostics broadcast
// delivery and hangs isolate shutdown.
addTearDown(lifecycle.dispose);
await tester.pumpWidget(
NomadCodeClientApp(
protoSocketLifecycle: lifecycle,
protoSocketConfig: _enabledProtoSocketConfig,
),
);
// Cannot use pumpAndSettle: the loading FutureBuilder shows a
// CircularProgressIndicator whose animation never settles. Pump bounded
// frames so connect -> task load -> render completes deterministically.
await _pumpFrames(tester);
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);
expect(find.text('conn-widget'), findsOneWidget);
expect(find.text('task.list'), findsOneWidget);
});
testWidgets(
'uses already connected lifecycle without disconnecting on dispose',
(WidgetTester tester) async {
final connector = _FakeProtoSocketConnector();
final lifecycle = ProtoSocketLifecycle(connector: connector);
addTearDown(lifecycle.dispose);
await lifecycle.connect(_enabledProtoSocketConfig);
await tester.pumpWidget(
NomadCodeClientApp(
protoSocketLifecycle: lifecycle,
protoSocketConfig: _enabledProtoSocketConfig,
),
);
await _pumpFrames(tester);
expect(connector.connectCalls, equals(1));
expect(find.text('Service task from Core'), findsOneWidget);
// Unmounting the widget must NOT disconnect a lifecycle it did not
// start (the lifecycle was already connected before mount).
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
expect(connector.disconnectCalls, equals(0));
},
);
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);
addTearDown(lifecycle.dispose);
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));
});
});
}