479 lines
15 KiB
Dart
479 lines
15 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('Workbench opens agent dock and IOP section from right rail', (
|
|
WidgetTester tester,
|
|
) async {
|
|
await tester.pumpWidget(const NomadCodeClientApp());
|
|
|
|
await tester.tap(find.byIcon(Icons.smart_toy_outlined));
|
|
await tester.pump();
|
|
|
|
expect(find.text('Ask the workspace agent'), findsOneWidget);
|
|
expect(find.textContaining('NomadCode agent is docked'), findsOneWidget);
|
|
|
|
await tester.tap(find.byIcon(Icons.hub_outlined));
|
|
await tester.pump();
|
|
|
|
expect(find.text('IOP'), findsOneWidget);
|
|
expect(find.textContaining('Optional IOP console package'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('Workbench separates titlebar, right rail, and content', (
|
|
WidgetTester tester,
|
|
) async {
|
|
addTearDown(() {
|
|
tester.view.resetPhysicalSize();
|
|
tester.view.resetDevicePixelRatio();
|
|
});
|
|
|
|
for (final size in const [Size(390, 800), Size(1280, 900)]) {
|
|
tester.view.physicalSize = size;
|
|
tester.view.devicePixelRatio = 1;
|
|
await tester.pumpWidget(const NomadCodeClientApp());
|
|
await tester.pump();
|
|
|
|
final titleRect = tester.getRect(find.text('NomadCode Console'));
|
|
final workflowRailRect = tester.getRect(find.byTooltip('Workflow'));
|
|
|
|
expect(titleRect.bottom, lessThanOrEqualTo(workflowRailRect.top));
|
|
|
|
await tester.tap(find.byTooltip('Workflow'));
|
|
await tester.pump();
|
|
|
|
final workflowContentRect = tester.getRect(
|
|
find.text('Workflow management surface will mount here.'),
|
|
);
|
|
|
|
expect(workflowContentRect.top, greaterThan(titleRect.bottom));
|
|
expect(
|
|
workflowContentRect.right,
|
|
lessThanOrEqualTo(workflowRailRect.left),
|
|
);
|
|
|
|
await tester.tap(find.byTooltip('Agent'));
|
|
await tester.pump();
|
|
|
|
final agentRailRect = tester.getRect(find.byTooltip('Agent'));
|
|
final agentContentRect = tester.getRect(
|
|
find.text('Ask the workspace agent'),
|
|
);
|
|
|
|
expect(agentContentRect.top, greaterThan(titleRect.bottom));
|
|
expect(agentContentRect.right, lessThanOrEqualTo(agentRailRect.left));
|
|
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
await tester.pump();
|
|
}
|
|
});
|
|
|
|
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));
|
|
});
|
|
});
|
|
}
|