TermRemoteCtl/apps/mobile_app/test/widget_test.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;
}
}