570 lines
17 KiB
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;
|
|
}
|
|
}
|