nomadcode/apps/client/test/widget_test.dart
toki 03f9e98906 feat: proto socket infrastructure communication rail - client diagnostics & archive M12 core diagnostics
- Archive 12+11_core_diagnostics to agent-task/archive/2026/05
- Update client app initialization with proto socket lifecycle
- Add workspace home page with diagnostic capabilities
- Integrate proto socket lifecycle management
- Update tests for proto socket integration
- Update core server tests
- Update roadmap milestone tracking
2026-05-31 05:50:18 +09:00

396 lines
12 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;
}
}
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.pumpAndSettle();
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',
},
),
);
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);
await tester.pumpWidget(const SizedBox.shrink());
await lifecycle.disconnect();
await 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);
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);
expect(find.text('conn-widget'), findsOneWidget);
expect(find.text('task.list'), 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();
});
});
}