- 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
306 lines
9.3 KiB
Dart
306 lines
9.3 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:nomadcode_app/src/integrations/proto_socket/proto_socket_client.dart';
|
|
import 'package:nomadcode_app/src/integrations/proto_socket/proto_socket_envelope.dart';
|
|
import 'package:nomadcode_app/src/integrations/proto_socket/proto_socket_endpoint_config.dart';
|
|
import 'package:nomadcode_app/src/integrations/proto_socket/proto_socket_lifecycle.dart';
|
|
import 'package:protobuf/well_known_types/google/protobuf/struct.pb.dart';
|
|
|
|
class _FakeTransport implements ProtoSocketTransport {
|
|
final StreamController<ProtoSocketEnvelope> controller =
|
|
StreamController<ProtoSocketEnvelope>.broadcast();
|
|
int sendCalls = 0;
|
|
int closeCalls = 0;
|
|
ProtoSocketEnvelope? response;
|
|
|
|
@override
|
|
Stream<ProtoSocketEnvelope> get events => controller.stream;
|
|
|
|
@override
|
|
Future<ProtoSocketEnvelope> sendRequest(
|
|
ProtoSocketEnvelope envelope, {
|
|
Duration timeout = const Duration(seconds: 30),
|
|
}) async {
|
|
sendCalls += 1;
|
|
return response ?? envelope;
|
|
}
|
|
|
|
@override
|
|
Future<void> close() async {
|
|
closeCalls += 1;
|
|
await controller.close();
|
|
}
|
|
}
|
|
|
|
class _FakeConnector implements ProtoSocketConnector {
|
|
int connectCalls = 0;
|
|
int disconnectCalls = 0;
|
|
Object? throwOnConnect;
|
|
_FakeTransport transport = _FakeTransport();
|
|
|
|
@override
|
|
Future<ProtoSocketTransport> connect(ProtoSocketEndpointConfig config) async {
|
|
connectCalls += 1;
|
|
final err = throwOnConnect;
|
|
if (err != null) throw err;
|
|
return transport;
|
|
}
|
|
|
|
@override
|
|
Future<void> disconnect() async {
|
|
disconnectCalls += 1;
|
|
}
|
|
}
|
|
|
|
class _FakePeer implements NomadProtoSocketPeer {
|
|
final StreamController<ProtoSocketEnvelope> controller =
|
|
StreamController<ProtoSocketEnvelope>.broadcast();
|
|
ProtoSocketEnvelope? lastRequest;
|
|
Duration? lastTimeout;
|
|
int closeCalls = 0;
|
|
|
|
@override
|
|
Stream<ProtoSocketEnvelope> get envelopeEvents => controller.stream;
|
|
|
|
@override
|
|
Future<ProtoSocketEnvelope> sendEnvelopeRequest(
|
|
ProtoSocketEnvelope envelope, {
|
|
Duration timeout = const Duration(seconds: 30),
|
|
}) async {
|
|
lastRequest = envelope;
|
|
lastTimeout = timeout;
|
|
return ProtoSocketEnvelope(
|
|
id: envelope.id,
|
|
correlationId: envelope.correlationId,
|
|
type: 'response',
|
|
channel: envelope.channel,
|
|
action: envelope.action,
|
|
payload: const {'ok': true},
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> close() async {
|
|
closeCalls += 1;
|
|
await controller.close();
|
|
}
|
|
}
|
|
|
|
void main() {
|
|
const config = ProtoSocketEndpointConfig(
|
|
host: 'core.example.com',
|
|
port: 443,
|
|
secure: true,
|
|
);
|
|
|
|
test('happy path emits connecting -> connected -> disconnected', () async {
|
|
final fake = _FakeConnector();
|
|
final lifecycle = ProtoSocketLifecycle(connector: fake);
|
|
final states = <ProtoSocketConnectionState>[];
|
|
final sub = lifecycle.stateStream.listen(states.add);
|
|
|
|
expect(lifecycle.state, ProtoSocketConnectionState.disconnected);
|
|
|
|
await lifecycle.connect(config);
|
|
expect(lifecycle.state, ProtoSocketConnectionState.connected);
|
|
expect(lifecycle.transport, isNotNull);
|
|
expect(fake.connectCalls, equals(1));
|
|
|
|
await lifecycle.disconnect();
|
|
expect(lifecycle.state, ProtoSocketConnectionState.disconnected);
|
|
expect(lifecycle.transport, isNull);
|
|
expect(fake.disconnectCalls, equals(1));
|
|
|
|
await Future<void>.delayed(Duration.zero);
|
|
await sub.cancel();
|
|
await lifecycle.dispose();
|
|
|
|
expect(states, [
|
|
ProtoSocketConnectionState.connecting,
|
|
ProtoSocketConnectionState.connected,
|
|
ProtoSocketConnectionState.disconnected,
|
|
]);
|
|
});
|
|
|
|
test('records response diagnostics from transport requests', () async {
|
|
final fake = _FakeConnector();
|
|
final lifecycle = ProtoSocketLifecycle(connector: fake);
|
|
fake.transport.response = const ProtoSocketEnvelope(
|
|
id: 'resp-1',
|
|
correlationId: 'req-1',
|
|
type: 'response',
|
|
channel: 'task',
|
|
action: 'task.list',
|
|
meta: {
|
|
'connection_id': 'conn-response',
|
|
'timestamp': '2026-05-30T12:00:00Z',
|
|
},
|
|
payload: {'secret': 'not-for-diagnostics'},
|
|
);
|
|
|
|
await lifecycle.connect(config);
|
|
await lifecycle.transport!.sendRequest(
|
|
const ProtoSocketEnvelope(
|
|
id: 'req-1',
|
|
type: 'request',
|
|
channel: 'task',
|
|
action: 'task.list',
|
|
),
|
|
);
|
|
|
|
expect(lifecycle.diagnostics.state, ProtoSocketConnectionState.connected);
|
|
expect(lifecycle.diagnostics.connectionId, equals('conn-response'));
|
|
expect(
|
|
lifecycle.diagnostics.protocolVersion,
|
|
equals(protoSocketProtocolVersion),
|
|
);
|
|
expect(lifecycle.diagnostics.channel, equals('task'));
|
|
expect(lifecycle.diagnostics.action, equals('task.list'));
|
|
expect(lifecycle.diagnostics.errorCode, isNull);
|
|
expect(lifecycle.diagnostics.timestamp, equals('2026-05-30T12:00:00Z'));
|
|
|
|
await lifecycle.dispose();
|
|
});
|
|
|
|
test('records event diagnostics with error codes', () async {
|
|
final fake = _FakeConnector();
|
|
final lifecycle = ProtoSocketLifecycle(connector: fake);
|
|
|
|
await lifecycle.connect(config);
|
|
fake.transport.controller.add(
|
|
const ProtoSocketEnvelope(
|
|
id: 'event-1',
|
|
type: 'event',
|
|
channel: 'task',
|
|
action: 'task.status.changed',
|
|
error: ProtoSocketEnvelopeError(
|
|
code: 'task.conflict',
|
|
message: 'payload detail is private',
|
|
retryable: false,
|
|
),
|
|
meta: {
|
|
'connection_id': 'conn-event',
|
|
'timestamp': '2026-05-30T12:01:00Z',
|
|
},
|
|
payload: {'token': 'do-not-display'},
|
|
),
|
|
);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(lifecycle.diagnostics.connectionId, equals('conn-event'));
|
|
expect(lifecycle.diagnostics.channel, equals('task'));
|
|
expect(lifecycle.diagnostics.action, equals('task.status.changed'));
|
|
expect(lifecycle.diagnostics.errorCode, equals('task.conflict'));
|
|
expect(lifecycle.diagnostics.timestamp, equals('2026-05-30T12:01:00Z'));
|
|
|
|
await lifecycle.dispose();
|
|
});
|
|
|
|
test('connect failure transitions to failed and records error', () async {
|
|
final fake = _FakeConnector()..throwOnConnect = StateError('boom');
|
|
final lifecycle = ProtoSocketLifecycle(connector: fake);
|
|
|
|
await expectLater(lifecycle.connect(config), throwsA(isA<StateError>()));
|
|
expect(lifecycle.state, ProtoSocketConnectionState.failed);
|
|
expect(lifecycle.lastError, isA<StateError>());
|
|
expect(lifecycle.transport, isNull);
|
|
|
|
await lifecycle.dispose();
|
|
});
|
|
|
|
test('connect is a no-op when already connecting or connected', () async {
|
|
final fake = _FakeConnector();
|
|
final lifecycle = ProtoSocketLifecycle(connector: fake);
|
|
|
|
await lifecycle.connect(config);
|
|
await lifecycle.connect(config);
|
|
|
|
expect(fake.connectCalls, equals(1));
|
|
await lifecycle.dispose();
|
|
});
|
|
|
|
test('disconnect from disconnected state is a no-op', () async {
|
|
final fake = _FakeConnector();
|
|
final lifecycle = ProtoSocketLifecycle(connector: fake);
|
|
|
|
await lifecycle.disconnect();
|
|
expect(fake.disconnectCalls, equals(0));
|
|
expect(lifecycle.state, ProtoSocketConnectionState.disconnected);
|
|
|
|
await lifecycle.dispose();
|
|
});
|
|
|
|
test(
|
|
'RealProtoSocketConnector uses injected factories without sockets',
|
|
() async {
|
|
final fakeSocket = Object();
|
|
final fakeTransport = _FakeTransport();
|
|
ProtoSocketEndpointConfig? seenConfig;
|
|
Object? seenSocket;
|
|
final connector = RealProtoSocketConnector(
|
|
socketFactory: (config) async {
|
|
seenConfig = config;
|
|
return fakeSocket;
|
|
},
|
|
transportFactory: (socket, config) {
|
|
seenSocket = socket;
|
|
expect(config, same(seenConfig));
|
|
return fakeTransport;
|
|
},
|
|
);
|
|
|
|
final transport = await connector.connect(config);
|
|
expect(seenConfig, same(config));
|
|
expect(seenSocket, same(fakeSocket));
|
|
expect(transport, same(fakeTransport));
|
|
|
|
await connector.disconnect();
|
|
expect(fakeTransport.closeCalls, equals(1));
|
|
},
|
|
);
|
|
|
|
test('Struct parser map supports short and qualified names', () {
|
|
final parsers = protoSocketStructParserMap();
|
|
final qualifiedName = Struct.getDefault().info_.qualifiedMessageName;
|
|
final struct = ProtoSocketEnvelope(
|
|
id: 'msg-1',
|
|
type: 'event',
|
|
channel: 'task',
|
|
action: 'task.status.changed',
|
|
payload: const {'id': 'task-1'},
|
|
).toStruct();
|
|
|
|
expect(parsers[(Struct).toString()], isNotNull);
|
|
expect(parsers[qualifiedName], isNotNull);
|
|
expect(
|
|
parsers[(Struct).toString()]!(struct.writeToBuffer()),
|
|
isA<Struct>(),
|
|
);
|
|
expect(parsers[qualifiedName]!(struct.writeToBuffer()), isA<Struct>());
|
|
});
|
|
|
|
test('ProtoSocketClientTransport delegates requests and close', () async {
|
|
final peer = _FakePeer();
|
|
final transport = ProtoSocketClientTransport(peer);
|
|
const request = ProtoSocketEnvelope(
|
|
id: 'req-1',
|
|
type: 'request',
|
|
channel: 'task',
|
|
action: 'task.list',
|
|
);
|
|
|
|
final response = await transport.sendRequest(
|
|
request,
|
|
timeout: const Duration(seconds: 5),
|
|
);
|
|
|
|
expect(peer.lastRequest, same(request));
|
|
expect(peer.lastTimeout, equals(const Duration(seconds: 5)));
|
|
expect(response.type, equals('response'));
|
|
expect(response.payload, equals({'ok': true}));
|
|
|
|
await transport.close();
|
|
expect(peer.closeCalls, equals(1));
|
|
});
|
|
}
|