TermRemoteCtl/apps/mobile_app/test/widget_test.dart

570 lines
17 KiB
Dart

import 'dart:async';
import 'package:dio/dio.dart';
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/projects/project.dart';
import 'package:term_remote_ctl/features/projects/project_repository.dart';
import 'package:term_remote_ctl/features/sessions/session.dart';
import 'package:term_remote_ctl/features/sessions/session_list_page.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 project-first shell', (tester) async {
await _pumpApp(
tester,
projectRepository: _FakeProjectRepository(),
sessionRepository: _FakeSessionRepository(),
);
final agentUrlField = tester.widget<TextField>(
find.byType(TextField).first,
);
expect(find.text('Projects'), 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('Project requests use this base origin: http://10.0.2.2:5067.'),
findsOneWidget,
);
expect(find.text('codex-main'), findsOneWidget);
expect(find.text(r'C:\repo\codex-main'), findsOneWidget);
expect(find.widgetWithText(FilledButton, 'Open terminal'), findsOneWidget);
});
testWidgets('project card opens a terminal without an extra prompt', (
tester,
) async {
final sessionRepository = _FakeSessionRepository();
await _pumpApp(
tester,
projectRepository: _FakeProjectRepository(),
sessionRepository: sessionRepository,
);
await _openProjectTerminal(tester);
expect(sessionRepository.lastCreatedProjectId, 'project-1');
expect(find.text('codex-main'), findsOneWidget);
expect(find.text(r'C:\repo\codex-main'), findsOneWidget);
expect(
find.byKey(const Key('terminal_toggle_actions_button')),
findsOneWidget,
);
});
testWidgets('project list deletes a project after confirmation', (
tester,
) async {
final projectRepository = _FakeProjectRepository.withProjects([
Project(
projectId: 'project-1',
name: 'codex-main',
workingDirectory: r'C:\repo\codex-main',
createdAtUtc: DateTime.parse('2026-03-30T10:00:00Z'),
updatedAtUtc: DateTime.parse('2026-03-30T10:00:00Z'),
),
]);
await _pumpApp(
tester,
projectRepository: projectRepository,
sessionRepository: _FakeSessionRepository(),
);
expect(find.text('codex-main'), findsOneWidget);
await tester.tap(find.byKey(const Key('project_delete_button_project-1')));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
await tester.pumpAndSettle();
expect(projectRepository.deletedProjectIds, ['project-1']);
expect(find.text('codex-main'), findsNothing);
});
testWidgets(
'terminal page keeps tools hidden until the user opens the tools sheet',
(tester) async {
await _pumpApp(
tester,
projectRepository: _FakeProjectRepository(),
sessionRepository: _FakeSessionRepository(),
);
await _openProjectTerminal(tester);
expect(find.byKey(const Key('terminal_tools_sheet')), findsNothing);
expect(
find.byKey(const Key('terminal_toggle_actions_button')),
findsOneWidget,
);
expect(find.byKey(const Key('terminal_action_bar')), findsOneWidget);
expect(find.byKey(const Key('terminal_send_button')), findsNothing);
expect(find.byKey(const Key('terminal_input_bar')), findsNothing);
await tester.tap(find.byKey(const Key('terminal_toggle_actions_button')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('terminal_tools_sheet')), findsOneWidget);
expect(find.text('Reconnect'), findsOneWidget);
expect(find.text('Latest'), findsOneWidget);
},
);
testWidgets('terminal tools expose quick terminal keys', (tester) async {
final transportFactory = _QueuedTerminalSocketTransportFactory();
await _pumpApp(
tester,
projectRepository: _FakeProjectRepository(),
sessionRepository: _FakeSessionRepository(),
socketFactory: TerminalSocketSessionFactory(
transportFactory: transportFactory.create,
),
);
await _openProjectTerminal(tester);
expect(find.byKey(const Key('terminal_quick_key_esc')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_tab')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_ctrl_c')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_ctrl_d')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_ctrl_l')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_up')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_down')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_left')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_right')), findsOneWidget);
await tester.tap(find.byKey(const Key('terminal_quick_key_esc')));
await tester.pump();
expect(
transportFactory.createdTransports.single.sentMessages.last,
contains('"input":"\\u001b"'),
);
});
testWidgets(
'project launch surfaces a friendly message when the working directory is invalid',
(tester) async {
await _pumpApp(
tester,
projectRepository: _FakeProjectRepository(),
sessionRepository: _FailingSessionRepository(),
);
await tester.tap(find.widgetWithText(FilledButton, 'Open terminal'));
await tester.pumpAndSettle();
expect(
find.text(
'Working directory does not exist. Update the project path and try again.',
),
findsOneWidget,
);
},
);
testWidgets(
'terminal page shows diagnostic entries for input and output activity',
(tester) async {
final transportFactory = _QueuedTerminalSocketTransportFactory();
await _pumpApp(
tester,
projectRepository: _FakeProjectRepository(),
sessionRepository: _FakeSessionRepository(),
socketFactory: TerminalSocketSessionFactory(
transportFactory: transportFactory.create,
),
);
await _openProjectTerminal(tester);
await tester.tap(find.byKey(const Key('terminal_quick_key_ctrl_l')));
await tester.pumpAndSettle();
transportFactory.createdTransports.single.emit('command-output');
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('terminal_toggle_actions_button')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('terminal_diagnostics_button')));
await tester.pumpAndSettle();
expect(find.textContaining('ui.input.quick | Ctrl+L'), findsOneWidget);
expect(find.textContaining('socket.input.tx | '), findsOneWidget);
expect(
find.textContaining('socket.frame.rx | command-output'),
findsOneWidget,
);
},
);
testWidgets('terminal page reconnects after the socket closes', (
tester,
) async {
final transportFactory = _QueuedTerminalSocketTransportFactory();
await _pumpApp(
tester,
projectRepository: _FakeProjectRepository(),
sessionRepository: _FakeSessionRepository(),
socketFactory: TerminalSocketSessionFactory(
transportFactory: transportFactory.create,
),
);
await _openProjectTerminal(tester);
expect(find.text('codex-main'), findsOneWidget);
expect(transportFactory.createCount, 1);
await transportFactory.createdTransports.first.close();
await tester.pump();
await tester.tap(find.byKey(const Key('terminal_toggle_actions_button')));
await tester.pumpAndSettle();
expect(find.text('Connection lost. Reconnecting...'), findsOneWidget);
await tester.pump(const Duration(seconds: 2));
await tester.pumpAndSettle();
expect(transportFactory.createCount, 2);
expect(find.text('codex-main'), findsOneWidget);
});
testWidgets('terminal page can open another terminal for the same project', (
tester,
) async {
final sessionRepository = _FakeSessionRepository();
await _pumpApp(
tester,
projectRepository: _FakeProjectRepository(),
sessionRepository: sessionRepository,
);
await _openProjectTerminal(tester);
expect(sessionRepository.createCount, 1);
await tester.tap(find.byKey(const Key('terminal_toggle_actions_button')));
await tester.pumpAndSettle();
await tester.tap(find.text('New terminal'));
await tester.pumpAndSettle();
expect(sessionRepository.createCount, 2);
expect(find.text('codex-main'), findsOneWidget);
});
testWidgets(
'terminal page lets the user return to live mode and load older history',
(tester) async {
final apiClient = _SequencedHistoryAgentApiClient(
responses: [
<String, dynamic>{
'sessionId': 'session-1',
'lines': <String>['one', 'two'],
'hasMoreAbove': true,
},
<String, dynamic>{
'sessionId': 'session-1',
'lines': <String>['zero', 'one', 'two'],
'hasMoreAbove': false,
},
],
);
await _pumpApp(
tester,
projectRepository: _FakeProjectRepository(),
sessionRepository: _FakeSessionRepository(),
apiClient: apiClient,
);
await _openProjectTerminal(tester);
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('Load older lines'), findsOneWidget);
await tester.ensureVisible(find.text('Load older lines'));
await tester.tap(find.text('Load older lines'));
await tester.pumpAndSettle();
expect(apiClient.requestedLineCounts, [200, 400]);
expect(find.text('3 lines loaded'), findsOneWidget);
},
);
testWidgets('session list deletes a session after confirmation', (
tester,
) async {
final sessionRepository = _FakeSessionRepository.withSessions([
Session(
sessionId: 'session-1',
name: 'codex-main',
status: 'created',
createdAtUtc: DateTime.parse('2026-03-30T10:00:00Z'),
updatedAtUtc: DateTime.parse('2026-03-30T10:00:00Z'),
),
]);
await tester.pumpWidget(
ProviderScope(
overrides: [
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
sessionRepositoryProvider.overrideWithValue(sessionRepository),
],
child: const MaterialApp(home: SessionListPage()),
),
);
await tester.pumpAndSettle();
expect(find.text('codex-main'), findsOneWidget);
await tester.tap(find.byKey(const Key('session_delete_button_session-1')));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
await tester.pumpAndSettle();
expect(sessionRepository.deletedSessionIds, ['session-1']);
expect(find.text('codex-main'), findsNothing);
});
}
Future<void> _pumpApp(
WidgetTester tester, {
required _FakeProjectRepository projectRepository,
required SessionRepository sessionRepository,
AgentApiClient? apiClient,
TerminalSocketSessionFactory? socketFactory,
}) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
agentApiClientProvider.overrideWithValue(
apiClient ?? _FakeAgentApiClient(),
),
projectRepositoryProvider.overrideWithValue(projectRepository),
sessionRepositoryProvider.overrideWithValue(sessionRepository),
terminalSocketSessionFactoryProvider.overrideWithValue(
socketFactory ??
TerminalSocketSessionFactory(
transportFactory: (_) =>
_FakeTerminalSocketTransport(autoAttach: true),
),
),
],
child: const TermRemoteCtlApp(),
),
);
await tester.pumpAndSettle();
}
Future<void> _openProjectTerminal(WidgetTester tester) async {
await tester.tap(find.widgetWithText(FilledButton, 'Open terminal'));
await tester.pumpAndSettle();
}
class _FakeProjectRepository extends ProjectRepository {
_FakeProjectRepository()
: _projects = [
Project(
projectId: 'project-1',
name: 'codex-main',
workingDirectory: r'C:\repo\codex-main',
createdAtUtc: DateTime.parse('2026-03-30T10:00:00Z'),
updatedAtUtc: DateTime.parse('2026-03-30T10:00:00Z'),
),
],
super(_FakeAgentApiClient());
_FakeProjectRepository.withProjects(List<Project> projects)
: _projects = List<Project>.of(projects),
super(_FakeAgentApiClient());
final List<Project> _projects;
final List<String> deletedProjectIds = <String>[];
@override
Future<List<Project>> listProjects() async => List<Project>.of(_projects);
@override
Future<ProjectDetail> getProjectDetail(String projectId) async {
return ProjectDetail(
project: _projects.single,
recentSessions: const <Session>[],
);
}
@override
Future<void> deleteProject(String projectId) async {
deletedProjectIds.add(projectId);
_projects.removeWhere((project) => project.projectId == projectId);
}
}
class _FakeSessionRepository extends SessionRepository {
_FakeSessionRepository()
: _sessions = <Session>[],
super(_FakeAgentApiClient());
_FakeSessionRepository.withSessions(List<Session> sessions)
: _sessions = List<Session>.of(sessions),
super(_FakeAgentApiClient());
String? lastCreatedProjectId;
int createCount = 0;
final List<String> deletedSessionIds = <String>[];
final List<Session> _sessions;
@override
Future<List<Session>> listSessions() async => List<Session>.of(_sessions);
@override
Future<Session> createSession({
String? name,
String? projectId,
String? workingDirectory,
}) async {
createCount += 1;
lastCreatedProjectId = projectId;
return Session(
sessionId: 'session-$createCount',
name: name ?? 'codex-main',
status: 'created',
projectId: projectId,
workingDirectory: workingDirectory ?? r'C:\repo\codex-main',
createdAtUtc: DateTime.parse('2026-03-30T10:00:00Z'),
updatedAtUtc: DateTime.parse('2026-03-30T10:00:00Z'),
);
}
@override
Future<void> deleteSession(String sessionId) async {
deletedSessionIds.add(sessionId);
_sessions.removeWhere((session) => session.sessionId == sessionId);
}
}
class _FailingSessionRepository extends SessionRepository {
_FailingSessionRepository() : super(_FakeAgentApiClient());
@override
Future<List<Session>> listSessions() async => const <Session>[];
@override
Future<Session> createSession({
String? name,
String? projectId,
String? workingDirectory,
}) async {
throw DioException.badResponse(
statusCode: 400,
requestOptions: RequestOptions(path: '/api/sessions'),
response: Response<Map<String, dynamic>>(
requestOptions: RequestOptions(path: '/api/sessions'),
statusCode: 400,
data: const <String, dynamic>{'error': 'invalid_working_directory'},
),
);
}
}
class _FakeAgentApiClient extends AgentApiClient {
_FakeAgentApiClient() : super(Uri.parse('http://10.0.2.2:5067'));
@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>[];
int _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":"session-1"}');
});
}
}
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>[];
int createCount = 0;
TerminalSocketTransport create(Uri _) {
final transport = _FakeTerminalSocketTransport(autoAttach: true);
createdTransports.add(transport);
createCount += 1;
return transport;
}
}