551 lines
18 KiB
Dart
551 lines
18 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:term_remote_ctl/app/app.dart';
|
|
import 'package:term_remote_ctl/core/network/agent_api_client.dart';
|
|
import 'package:term_remote_ctl/core/network/agent_connection_providers.dart';
|
|
import 'package:term_remote_ctl/features/sessions/session.dart';
|
|
import 'package:term_remote_ctl/features/sessions/session_repository.dart';
|
|
import 'package:term_remote_ctl/features/terminal/terminal_socket_session.dart';
|
|
import 'package:xterm/xterm.dart';
|
|
|
|
void main() {
|
|
testWidgets('shows the sessions shell', (tester) async {
|
|
final repository = _FakeSessionRepository();
|
|
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
|
|
sessionRepositoryProvider.overrideWithValue(repository),
|
|
],
|
|
child: const TermRemoteCtlApp(),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
final agentUrlField = tester.widget<TextField>(find.byType(TextField).first);
|
|
|
|
expect(find.text('Sessions'), findsOneWidget);
|
|
expect(find.text('Agent base URL'), findsOneWidget);
|
|
expect(agentUrlField.controller?.text, 'http://10.0.2.2:5067');
|
|
expect(agentUrlField.decoration?.hintText, 'http://10.0.2.2:5067');
|
|
expect(find.text('Session requests use this base origin: http://10.0.2.2:5067.'), findsOneWidget);
|
|
expect(find.text('codex-main'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('surfaces create-session errors in a snackbar', (tester) async {
|
|
final repository = _FakeSessionRepository(shouldThrowOnCreate: true);
|
|
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
agentBaseUriProvider.overrideWith((ref) {
|
|
return Uri.parse('https://host.example:9443');
|
|
}),
|
|
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
|
|
sessionRepositoryProvider.overrideWithValue(repository),
|
|
],
|
|
child: const TermRemoteCtlApp(),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.byIcon(Icons.add));
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.enterText(find.byType(TextField).last, 'broken-session');
|
|
await tester.tap(find.text('Create'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.textContaining('Failed to create session'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('tapping a session opens the terminal page', (tester) async {
|
|
final repository = _FakeSessionRepository();
|
|
final socketFactory = TerminalSocketSessionFactory(
|
|
transportFactory: (_) => _FakeTerminalSocketTransport(autoAttach: true),
|
|
);
|
|
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
agentBaseUriProvider.overrideWith((ref) {
|
|
return Uri.parse('https://host.example:9443');
|
|
}),
|
|
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
|
|
sessionRepositoryProvider.overrideWithValue(repository),
|
|
terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory),
|
|
],
|
|
child: const TermRemoteCtlApp(),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.text('codex-main'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Send input'), findsOneWidget);
|
|
expect(find.text('Attached to codex-main'), findsOneWidget);
|
|
expect(find.text('Live | 2 lines'), findsOneWidget);
|
|
expect(find.text('Terminal controls'), findsOneWidget);
|
|
expect(find.byKey(const Key('terminal_controls_panel')), findsOneWidget);
|
|
expect(find.text('Connected'), findsOneWidget);
|
|
expect(find.text('History ready'), findsOneWidget);
|
|
expect(find.text('Reconnect'), findsOneWidget);
|
|
expect(
|
|
find.text('Live terminal attach is minimal for now. Resize and reconnect are not implemented yet.'),
|
|
findsNothing,
|
|
);
|
|
});
|
|
|
|
testWidgets('terminal page reconnects after the socket closes', (
|
|
tester,
|
|
) async {
|
|
final repository = _FakeSessionRepository();
|
|
final transportFactory = _QueuedTerminalSocketTransportFactory();
|
|
final socketFactory = TerminalSocketSessionFactory(
|
|
transportFactory: transportFactory.create,
|
|
);
|
|
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
agentBaseUriProvider.overrideWith((ref) {
|
|
return Uri.parse('https://host.example:9443');
|
|
}),
|
|
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
|
|
sessionRepositoryProvider.overrideWithValue(repository),
|
|
terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory),
|
|
],
|
|
child: const TermRemoteCtlApp(),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.text('codex-main'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Attached to codex-main'), findsOneWidget);
|
|
expect(transportFactory.createCount, 1);
|
|
|
|
final firstTransport = transportFactory.createdTransports.first;
|
|
await firstTransport.close();
|
|
await tester.pump();
|
|
|
|
expect(find.text('Reconnecting'), findsOneWidget);
|
|
final textField = tester.widget<TextField>(find.byType(TextField).last);
|
|
final sendButton = tester.widget<FilledButton>(find.widgetWithText(FilledButton, 'Send'));
|
|
expect(textField.enabled, isFalse);
|
|
expect(sendButton.onPressed, isNull);
|
|
|
|
await tester.pump(const Duration(seconds: 2));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(transportFactory.createCount, 2);
|
|
expect(find.text('Attached to codex-main'), findsOneWidget);
|
|
final reconnectedField = tester.widget<TextField>(find.byType(TextField).last);
|
|
final reconnectedButton = tester.widget<FilledButton>(find.widgetWithText(FilledButton, 'Send'));
|
|
expect(reconnectedField.enabled, isTrue);
|
|
expect(reconnectedButton.onPressed, isNotNull);
|
|
});
|
|
|
|
testWidgets('terminal page can send input after leaving and reopening a session', (
|
|
tester,
|
|
) async {
|
|
final repository = _FakeSessionRepository();
|
|
final transportFactory = _QueuedTerminalSocketTransportFactory();
|
|
final socketFactory = TerminalSocketSessionFactory(
|
|
transportFactory: transportFactory.create,
|
|
);
|
|
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
agentBaseUriProvider.overrideWith((ref) {
|
|
return Uri.parse('https://host.example:9443');
|
|
}),
|
|
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
|
|
sessionRepositoryProvider.overrideWithValue(repository),
|
|
terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory),
|
|
],
|
|
child: const TermRemoteCtlApp(),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.text('codex-main'));
|
|
await tester.pumpAndSettle();
|
|
expect(transportFactory.createCount, 1);
|
|
|
|
await tester.pageBack();
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.text('codex-main'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(transportFactory.createCount, 2);
|
|
expect(find.text('Attached to codex-main'), findsOneWidget);
|
|
|
|
await tester.enterText(find.byType(TextField).last, 'dir');
|
|
await tester.tap(find.widgetWithText(FilledButton, 'Send'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(
|
|
transportFactory.createdTransports.last.sentMessages,
|
|
contains('{"type":"input","input":"dir\\r"}'),
|
|
);
|
|
});
|
|
|
|
testWidgets('terminal view accepts keyboard input after leaving and reopening a session', (
|
|
tester,
|
|
) async {
|
|
final repository = _FakeSessionRepository();
|
|
final transportFactory = _QueuedTerminalSocketTransportFactory();
|
|
final socketFactory = TerminalSocketSessionFactory(
|
|
transportFactory: transportFactory.create,
|
|
);
|
|
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
agentBaseUriProvider.overrideWith((ref) {
|
|
return Uri.parse('https://host.example:9443');
|
|
}),
|
|
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
|
|
sessionRepositoryProvider.overrideWithValue(repository),
|
|
terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory),
|
|
],
|
|
child: const TermRemoteCtlApp(),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.text('codex-main'));
|
|
await tester.pumpAndSettle();
|
|
await tester.pageBack();
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.text('codex-main'));
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.byType(TerminalView));
|
|
await tester.pump(const Duration(seconds: 1));
|
|
tester.testTextInput.enterText('pwd');
|
|
await tester.idle();
|
|
|
|
expect(
|
|
transportFactory.createdTransports.last.sentMessages,
|
|
contains('{"type":"input","input":"pwd"}'),
|
|
);
|
|
});
|
|
|
|
testWidgets('terminal view regains keyboard input on reopen without an extra tap', (
|
|
tester,
|
|
) async {
|
|
final repository = _FakeSessionRepository();
|
|
final transportFactory = _QueuedTerminalSocketTransportFactory();
|
|
final socketFactory = TerminalSocketSessionFactory(
|
|
transportFactory: transportFactory.create,
|
|
);
|
|
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
agentBaseUriProvider.overrideWith((ref) {
|
|
return Uri.parse('https://host.example:9443');
|
|
}),
|
|
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
|
|
sessionRepositoryProvider.overrideWithValue(repository),
|
|
terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory),
|
|
],
|
|
child: const TermRemoteCtlApp(),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.text('codex-main'));
|
|
await tester.pumpAndSettle();
|
|
await tester.pageBack();
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.text('codex-main'));
|
|
await tester.pumpAndSettle();
|
|
|
|
tester.testTextInput.enterText('whoami');
|
|
await tester.idle();
|
|
|
|
expect(
|
|
transportFactory.createdTransports.last.sentMessages,
|
|
contains('{"type":"input","input":"whoami"}'),
|
|
);
|
|
});
|
|
|
|
testWidgets('terminal page lets the user return to live mode', (
|
|
tester,
|
|
) async {
|
|
final repository = _FakeSessionRepository();
|
|
final socketFactory = TerminalSocketSessionFactory(
|
|
transportFactory: (_) => _FakeTerminalSocketTransport(autoAttach: true),
|
|
);
|
|
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
agentBaseUriProvider.overrideWith((ref) {
|
|
return Uri.parse('https://host.example:9443');
|
|
}),
|
|
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
|
|
sessionRepositoryProvider.overrideWithValue(repository),
|
|
terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory),
|
|
],
|
|
child: const TermRemoteCtlApp(),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.text('codex-main'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Back to live'), findsNothing);
|
|
|
|
await tester.tap(find.text('Live | 2 lines'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Back to live'), findsOneWidget);
|
|
expect(find.text('Scrollback | 2 lines'), findsOneWidget);
|
|
expect(find.text('Recent scrollback'), findsOneWidget);
|
|
expect(find.text('Browsing history. Live output is still arriving.'), findsOneWidget);
|
|
expect(find.text('one'), findsOneWidget);
|
|
expect(find.text('two'), findsOneWidget);
|
|
expect(find.byKey(const Key('terminal_scrollback_list')), findsOneWidget);
|
|
expect(find.byKey(const Key('terminal_scrollback_actions')), findsOneWidget);
|
|
expect(find.text('Recent history is loaded. Older lines are not loaded yet.'), findsOneWidget);
|
|
expect(find.text('Load older lines'), findsOneWidget);
|
|
|
|
await tester.tap(find.text('Back to live'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Back to live'), findsNothing);
|
|
expect(find.text('Live | 2 lines'), findsOneWidget);
|
|
expect(find.text('Recent scrollback'), findsNothing);
|
|
expect(find.text('Browsing history. Live output is still arriving.'), findsNothing);
|
|
});
|
|
|
|
testWidgets('terminal page loads older scrollback lines on demand', (
|
|
tester,
|
|
) async {
|
|
final repository = _FakeSessionRepository();
|
|
final socketFactory = TerminalSocketSessionFactory(
|
|
transportFactory: (_) => _FakeTerminalSocketTransport(autoAttach: true),
|
|
);
|
|
final apiClient = _SequencedHistoryAgentApiClient(
|
|
responses: [
|
|
<String, dynamic>{
|
|
'sessionId': 'abc',
|
|
'lines': <String>['one', 'two'],
|
|
'hasMoreAbove': true,
|
|
},
|
|
<String, dynamic>{
|
|
'sessionId': 'abc',
|
|
'lines': <String>['zero', 'one', 'two'],
|
|
'hasMoreAbove': false,
|
|
},
|
|
],
|
|
);
|
|
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
agentBaseUriProvider.overrideWith((ref) {
|
|
return Uri.parse('https://host.example:9443');
|
|
}),
|
|
agentApiClientProvider.overrideWithValue(apiClient),
|
|
sessionRepositoryProvider.overrideWithValue(repository),
|
|
terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory),
|
|
],
|
|
child: const TermRemoteCtlApp(),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.text('codex-main'));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Live | 2 lines'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Load older lines'), findsOneWidget);
|
|
|
|
await tester.tap(find.text('Load older lines'));
|
|
await tester.pump();
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(apiClient.requestedLineCounts, [1000, 2000]);
|
|
expect(find.text('Scrollback | 3 lines'), findsOneWidget);
|
|
expect(find.text('3 lines loaded'), findsOneWidget);
|
|
expect(find.text('zero'), findsOneWidget);
|
|
expect(find.text('Load older lines'), findsNothing);
|
|
expect(
|
|
find.text('Recent history is loaded. Older lines are not loaded yet.'),
|
|
findsNothing,
|
|
);
|
|
});
|
|
|
|
testWidgets('terminal page surfaces new live output while browsing history', (
|
|
tester,
|
|
) async {
|
|
final repository = _FakeSessionRepository();
|
|
final transport = _FakeTerminalSocketTransport(autoAttach: true);
|
|
final socketFactory = TerminalSocketSessionFactory(
|
|
transportFactory: (_) => transport,
|
|
);
|
|
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
agentBaseUriProvider.overrideWith((ref) {
|
|
return Uri.parse('https://host.example:9443');
|
|
}),
|
|
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
|
|
sessionRepositoryProvider.overrideWithValue(repository),
|
|
terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory),
|
|
],
|
|
child: const TermRemoteCtlApp(),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.text('codex-main'));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Live | 2 lines'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('New output available'), findsNothing);
|
|
|
|
transport.emit('next-line');
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('New output available'), findsOneWidget);
|
|
|
|
await tester.tap(find.text('New output available'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Back to live'), findsNothing);
|
|
expect(find.text('Live | 3 lines'), findsOneWidget);
|
|
expect(find.text('New output available'), findsNothing);
|
|
});
|
|
}
|
|
|
|
class _FakeSessionRepository extends SessionRepository {
|
|
_FakeSessionRepository({this.shouldThrowOnCreate = false})
|
|
: _sessions = [
|
|
Session(
|
|
sessionId: 'abc',
|
|
name: 'codex-main',
|
|
status: 'idle',
|
|
),
|
|
],
|
|
super(_FakeAgentApiClient());
|
|
|
|
final List<Session> _sessions;
|
|
final bool shouldThrowOnCreate;
|
|
|
|
@override
|
|
Future<List<Session>> listSessions() async {
|
|
return List<Session>.of(_sessions);
|
|
}
|
|
|
|
@override
|
|
Future<Session> createSession(String name) async {
|
|
if (shouldThrowOnCreate) {
|
|
throw StateError('boom');
|
|
}
|
|
|
|
return Session(sessionId: 'created', name: name, status: 'idle');
|
|
}
|
|
}
|
|
|
|
class _FakeAgentApiClient extends AgentApiClient {
|
|
_FakeAgentApiClient() : super(Uri.parse('https://host:9443'));
|
|
|
|
@override
|
|
Future<Map<String, dynamic>> getSessionHistory(
|
|
String sessionId, {
|
|
int lineCount = 200,
|
|
}) async {
|
|
return <String, dynamic>{
|
|
'sessionId': sessionId,
|
|
'lines': <String>['one', 'two'],
|
|
'hasMoreAbove': true,
|
|
};
|
|
}
|
|
}
|
|
|
|
class _SequencedHistoryAgentApiClient extends _FakeAgentApiClient {
|
|
_SequencedHistoryAgentApiClient({required List<Map<String, dynamic>> responses})
|
|
: _responses = responses;
|
|
|
|
final List<Map<String, dynamic>> _responses;
|
|
final requestedLineCounts = <int>[];
|
|
var _index = 0;
|
|
|
|
@override
|
|
Future<Map<String, dynamic>> getSessionHistory(
|
|
String sessionId, {
|
|
int lineCount = 200,
|
|
}) async {
|
|
requestedLineCounts.add(lineCount);
|
|
final response = _responses[_index];
|
|
if (_index < _responses.length - 1) {
|
|
_index += 1;
|
|
}
|
|
|
|
return response;
|
|
}
|
|
}
|
|
|
|
class _FakeTerminalSocketTransport implements TerminalSocketTransport {
|
|
_FakeTerminalSocketTransport({this.autoAttach = false}) {
|
|
if (autoAttach) {
|
|
Future<void>.microtask(() {
|
|
emit('{"type":"attached","sessionId":"abc"}');
|
|
});
|
|
}
|
|
}
|
|
|
|
final bool autoAttach;
|
|
final _incoming = StreamController<dynamic>.broadcast();
|
|
final sentMessages = <String>[];
|
|
|
|
@override
|
|
Stream<dynamic> get stream => _incoming.stream;
|
|
|
|
@override
|
|
void send(String message) {
|
|
sentMessages.add(message);
|
|
}
|
|
|
|
@override
|
|
Future<void> close() async {
|
|
await _incoming.close();
|
|
}
|
|
|
|
void emit(String message) {
|
|
_incoming.add(message);
|
|
}
|
|
}
|
|
|
|
class _QueuedTerminalSocketTransportFactory {
|
|
final createdTransports = <_FakeTerminalSocketTransport>[];
|
|
var createCount = 0;
|
|
|
|
TerminalSocketTransport create(Uri _) {
|
|
final transport = _FakeTerminalSocketTransport(autoAttach: true);
|
|
createdTransports.add(transport);
|
|
createCount += 1;
|
|
return transport;
|
|
}
|
|
}
|