feat: add session deletion flow
This commit is contained in:
parent
1cec285c09
commit
4ed8525209
@ -6,14 +6,17 @@ class AgentApiClient {
|
||||
final Uri baseUri;
|
||||
final Dio _dio;
|
||||
|
||||
Uri get projectsUri => baseUri.resolve('/api/projects');
|
||||
Uri get sessionsUri => baseUri.resolve('/api/sessions');
|
||||
Uri get pairingCodeUri => baseUri.resolve('/api/pairing/code');
|
||||
Uri get pairingRedeemUri => baseUri.resolve('/api/pairing/redeem');
|
||||
Uri sessionUri(String sessionId) =>
|
||||
baseUri.resolve('/api/sessions/$sessionId');
|
||||
|
||||
Uri sessionHistoryUri(String sessionId, {int lineCount = 200}) {
|
||||
return baseUri.resolve('/api/sessions/$sessionId/history').replace(
|
||||
queryParameters: <String, String>{'lineCount': '$lineCount'},
|
||||
);
|
||||
return baseUri
|
||||
.resolve('/api/sessions/$sessionId/history')
|
||||
.replace(queryParameters: <String, String>{'lineCount': '$lineCount'});
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> listSessions() async {
|
||||
@ -21,14 +24,72 @@ class AgentApiClient {
|
||||
return _readJsonList(response.data, 'sessions');
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> createSession(String name) async {
|
||||
Uri projectUri(String projectId) =>
|
||||
baseUri.resolve('/api/projects/$projectId');
|
||||
|
||||
Future<List<Map<String, dynamic>>> listProjects() async {
|
||||
final response = await _dio.getUri(projectsUri);
|
||||
return _readJsonList(response.data, 'projects');
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getProjectDetail(String projectId) async {
|
||||
final response = await _dio.getUri(projectUri(projectId));
|
||||
return _readJsonMap(response.data, 'project detail');
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> createProject({
|
||||
required String name,
|
||||
required String workingDirectory,
|
||||
}) async {
|
||||
final response = await _dio.postUri(
|
||||
sessionsUri,
|
||||
data: <String, String>{'name': name},
|
||||
projectsUri,
|
||||
data: <String, String>{
|
||||
'name': name,
|
||||
'workingDirectory': workingDirectory,
|
||||
},
|
||||
);
|
||||
return _readJsonMap(response.data, 'project');
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> updateProject({
|
||||
required String projectId,
|
||||
required String name,
|
||||
required String workingDirectory,
|
||||
}) async {
|
||||
final response = await _dio.putUri(
|
||||
projectUri(projectId),
|
||||
data: <String, String>{
|
||||
'name': name,
|
||||
'workingDirectory': workingDirectory,
|
||||
},
|
||||
);
|
||||
return _readJsonMap(response.data, 'project');
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> createSession({
|
||||
String? name,
|
||||
String? projectId,
|
||||
String? workingDirectory,
|
||||
}) async {
|
||||
final data = <String, String>{};
|
||||
if (name != null && name.isNotEmpty) {
|
||||
data['name'] = name;
|
||||
}
|
||||
if (projectId != null && projectId.isNotEmpty) {
|
||||
data['projectId'] = projectId;
|
||||
}
|
||||
if (workingDirectory != null && workingDirectory.isNotEmpty) {
|
||||
data['workingDirectory'] = workingDirectory;
|
||||
}
|
||||
|
||||
final response = await _dio.postUri(sessionsUri, data: data);
|
||||
return _readJsonMap(response.data, 'session');
|
||||
}
|
||||
|
||||
Future<void> deleteSession(String sessionId) async {
|
||||
await _dio.deleteUri(sessionUri(sessionId));
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getSessionHistory(
|
||||
String sessionId, {
|
||||
int lineCount = 200,
|
||||
@ -45,10 +106,7 @@ class AgentApiClient {
|
||||
}) {
|
||||
return _dio.postUri(
|
||||
pairingRedeemUri,
|
||||
data: <String, String>{
|
||||
'code': code,
|
||||
'deviceName': deviceName,
|
||||
},
|
||||
data: <String, String>{'code': code, 'deviceName': deviceName},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -30,9 +30,9 @@ class _SessionListPageState extends ConsumerState<SessionListPage> {
|
||||
}
|
||||
|
||||
void _showMessage(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
Future<void> _refreshSessions() async {
|
||||
@ -82,7 +82,7 @@ class _SessionListPageState extends ConsumerState<SessionListPage> {
|
||||
}
|
||||
|
||||
try {
|
||||
await repository.createSession(trimmedName);
|
||||
await repository.createSession(name: trimmedName);
|
||||
await _reloadSessions();
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
@ -93,6 +93,47 @@ class _SessionListPageState extends ConsumerState<SessionListPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteSession(Session session) async {
|
||||
final shouldDelete = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Delete session'),
|
||||
content: Text(
|
||||
'Delete "${session.name}"? This removes the saved session and its terminal history.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (shouldDelete != true) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ref
|
||||
.read(sessionRepositoryProvider)
|
||||
.deleteSession(session.sessionId);
|
||||
await _reloadSessions();
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
_showMessage('Failed to delete session: $error');
|
||||
}
|
||||
}
|
||||
|
||||
void _openSession(Session session) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
@ -108,7 +149,9 @@ class _SessionListPageState extends ConsumerState<SessionListPage> {
|
||||
|
||||
Future<void> _applyAgentUrl() async {
|
||||
final parsedUri = Uri.tryParse(_agentUrlController.text.trim());
|
||||
if (parsedUri == null || parsedUri.scheme.isEmpty || parsedUri.host.isEmpty) {
|
||||
if (parsedUri == null ||
|
||||
parsedUri.scheme.isEmpty ||
|
||||
parsedUri.host.isEmpty) {
|
||||
_showMessage('Enter a valid agent base URL.');
|
||||
return;
|
||||
}
|
||||
@ -201,10 +244,7 @@ class _SessionListPageState extends ConsumerState<SessionListPage> {
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'$error',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Text('$error', textAlign: TextAlign.center),
|
||||
],
|
||||
),
|
||||
);
|
||||
@ -251,9 +291,23 @@ class _SessionListPageState extends ConsumerState<SessionListPage> {
|
||||
leading: const Icon(Icons.terminal),
|
||||
title: Text(session.name),
|
||||
subtitle: Text('Status: ${session.status}'),
|
||||
trailing: Text(
|
||||
session.sessionId,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
session.sessionId,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
key: Key(
|
||||
'session_delete_button_${session.sessionId}',
|
||||
),
|
||||
tooltip: 'Delete session',
|
||||
onPressed: () => _deleteSession(session),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -11,8 +11,20 @@ class SessionRepository {
|
||||
return sessions.map(Session.fromJson).toList(growable: false);
|
||||
}
|
||||
|
||||
Future<Session> createSession(String name) async {
|
||||
final session = await _client.createSession(name);
|
||||
Future<Session> createSession({
|
||||
String? name,
|
||||
String? projectId,
|
||||
String? workingDirectory,
|
||||
}) async {
|
||||
final session = await _client.createSession(
|
||||
name: name,
|
||||
projectId: projectId,
|
||||
workingDirectory: workingDirectory,
|
||||
);
|
||||
return Session.fromJson(session);
|
||||
}
|
||||
|
||||
Future<void> deleteSession(String sessionId) {
|
||||
return _client.deleteSession(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,7 +14,10 @@ void main() {
|
||||
test('builds pairing urls from base uri', () {
|
||||
final client = AgentApiClient(Uri.parse('https://host:9443'));
|
||||
|
||||
expect(client.pairingCodeUri.toString(), 'https://host:9443/api/pairing/code');
|
||||
expect(
|
||||
client.pairingCodeUri.toString(),
|
||||
'https://host:9443/api/pairing/code',
|
||||
);
|
||||
expect(
|
||||
client.pairingRedeemUri.toString(),
|
||||
'https://host:9443/api/pairing/redeem',
|
||||
@ -24,16 +27,8 @@ void main() {
|
||||
test('lists sessions from the sessions endpoint', () async {
|
||||
final adapter = _FakeHttpClientAdapter(
|
||||
jsonEncode([
|
||||
{
|
||||
'sessionId': 'abc',
|
||||
'name': 'codex-main',
|
||||
'status': 'idle',
|
||||
},
|
||||
{
|
||||
'sessionId': 'def',
|
||||
'name': 'cloud-code',
|
||||
'status': 'active',
|
||||
},
|
||||
{'sessionId': 'abc', 'name': 'codex-main', 'status': 'idle'},
|
||||
{'sessionId': 'def', 'name': 'cloud-code', 'status': 'active'},
|
||||
]),
|
||||
);
|
||||
final dio = Dio()..httpClientAdapter = adapter;
|
||||
@ -53,16 +48,12 @@ void main() {
|
||||
|
||||
test('creates a session with the provided name', () async {
|
||||
final adapter = _FakeHttpClientAdapter(
|
||||
jsonEncode({
|
||||
'sessionId': 'abc',
|
||||
'name': 'codex-main',
|
||||
'status': 'idle',
|
||||
}),
|
||||
jsonEncode({'sessionId': 'abc', 'name': 'codex-main', 'status': 'idle'}),
|
||||
);
|
||||
final dio = Dio()..httpClientAdapter = adapter;
|
||||
final client = AgentApiClient(Uri.parse('https://host:9443'), dio: dio);
|
||||
|
||||
final session = await client.createSession('codex-main');
|
||||
final session = await client.createSession(name: 'codex-main');
|
||||
|
||||
expect(adapter.lastOptions?.method, 'POST');
|
||||
expect(
|
||||
@ -74,6 +65,30 @@ void main() {
|
||||
expect(session['name'], 'codex-main');
|
||||
});
|
||||
|
||||
test('deletes a session by id', () async {
|
||||
final adapter = _FakeHttpClientAdapter();
|
||||
final dio = Dio()..httpClientAdapter = adapter;
|
||||
final client = AgentApiClient(Uri.parse('https://host:9443'), dio: dio);
|
||||
|
||||
await client.deleteSession('abc');
|
||||
|
||||
expect(adapter.lastOptions?.method, 'DELETE');
|
||||
expect(
|
||||
adapter.lastOptions?.uri.toString(),
|
||||
'https://host:9443/api/sessions/abc',
|
||||
);
|
||||
});
|
||||
|
||||
test('builds project urls from base uri', () {
|
||||
final client = AgentApiClient(Uri.parse('https://host:9443'));
|
||||
|
||||
expect(client.projectsUri.toString(), 'https://host:9443/api/projects');
|
||||
expect(
|
||||
client.projectUri('abc').toString(),
|
||||
'https://host:9443/api/projects/abc',
|
||||
);
|
||||
});
|
||||
|
||||
test('posts pairing redeem payload to the redeem endpoint', () async {
|
||||
final adapter = _FakeHttpClientAdapter();
|
||||
final dio = Dio()..httpClientAdapter = adapter;
|
||||
@ -86,10 +101,10 @@ void main() {
|
||||
adapter.lastOptions?.uri.toString(),
|
||||
'https://host:9443/api/pairing/redeem',
|
||||
);
|
||||
expect(
|
||||
adapter.lastOptions?.data,
|
||||
<String, String>{'code': '123456', 'deviceName': 'tablet'},
|
||||
);
|
||||
expect(adapter.lastOptions?.data, <String, String>{
|
||||
'code': '123456',
|
||||
'deviceName': 'tablet',
|
||||
});
|
||||
});
|
||||
|
||||
test('fetches session history from the history endpoint', () async {
|
||||
|
||||
@ -1,474 +1,418 @@
|
||||
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 sessions shell', (tester) async {
|
||||
final repository = _FakeSessionRepository();
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
|
||||
sessionRepositoryProvider.overrideWithValue(repository),
|
||||
],
|
||||
child: const TermRemoteCtlApp(),
|
||||
),
|
||||
testWidgets('shows the project-first shell', (tester) async {
|
||||
await _pumpApp(
|
||||
tester,
|
||||
projectRepository: _FakeProjectRepository(),
|
||||
sessionRepository: _FakeSessionRepository(),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final agentUrlField = tester.widget<TextField>(find.byType(TextField).first);
|
||||
final agentUrlField = tester.widget<TextField>(
|
||||
find.byType(TextField).first,
|
||||
);
|
||||
|
||||
expect(find.text('Sessions'), findsOneWidget);
|
||||
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('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,
|
||||
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(
|
||||
'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_input_bar')), findsOneWidget);
|
||||
expect(find.byKey(const Key('terminal_send_button')), findsOneWidget);
|
||||
|
||||
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(
|
||||
'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.enterText(find.byType(TextField).last, 'dir');
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Send'));
|
||||
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.send | dir'), findsOneWidget);
|
||||
expect(find.textContaining(r'socket.input.tx | dir\r'), findsOneWidget);
|
||||
expect(
|
||||
find.textContaining('socket.frame.rx | command-output'),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
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 _pumpApp(
|
||||
tester,
|
||||
projectRepository: _FakeProjectRepository(),
|
||||
sessionRepository: _FakeSessionRepository(),
|
||||
socketFactory: TerminalSocketSessionFactory(
|
||||
transportFactory: transportFactory.create,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('codex-main'));
|
||||
await tester.pumpAndSettle();
|
||||
await _openProjectTerminal(tester);
|
||||
|
||||
expect(find.text('Attached to codex-main'), findsOneWidget);
|
||||
expect(find.text('codex-main'), findsOneWidget);
|
||||
expect(transportFactory.createCount, 1);
|
||||
|
||||
final firstTransport = transportFactory.createdTransports.first;
|
||||
await firstTransport.close();
|
||||
await transportFactory.createdTransports.first.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.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('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);
|
||||
expect(find.text('codex-main'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('terminal page can send input after leaving and reopening a session', (
|
||||
testWidgets('terminal page can open another terminal for the same project', (
|
||||
tester,
|
||||
) async {
|
||||
final repository = _FakeSessionRepository();
|
||||
final transportFactory = _QueuedTerminalSocketTransportFactory();
|
||||
final socketFactory = TerminalSocketSessionFactory(
|
||||
transportFactory: transportFactory.create,
|
||||
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: [
|
||||
agentBaseUriProvider.overrideWith((ref) {
|
||||
return Uri.parse('https://host.example:9443');
|
||||
}),
|
||||
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
|
||||
sessionRepositoryProvider.overrideWithValue(repository),
|
||||
terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory),
|
||||
sessionRepositoryProvider.overrideWithValue(sessionRepository),
|
||||
],
|
||||
child: const TermRemoteCtlApp(),
|
||||
child: const MaterialApp(home: SessionListPage()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('codex-main'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(transportFactory.createCount, 1);
|
||||
expect(find.text('codex-main'), findsOneWidget);
|
||||
|
||||
await tester.pageBack();
|
||||
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();
|
||||
|
||||
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);
|
||||
expect(sessionRepository.deletedSessionIds, ['session-1']);
|
||||
expect(find.text('codex-main'), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeSessionRepository extends SessionRepository {
|
||||
_FakeSessionRepository({this.shouldThrowOnCreate = false})
|
||||
: _sessions = [
|
||||
Session(
|
||||
sessionId: 'abc',
|
||||
name: 'codex-main',
|
||||
status: 'idle',
|
||||
),
|
||||
],
|
||||
super(_FakeAgentApiClient());
|
||||
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();
|
||||
}
|
||||
|
||||
final List<Session> _sessions;
|
||||
final bool shouldThrowOnCreate;
|
||||
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());
|
||||
|
||||
final List<Project> _projects;
|
||||
|
||||
@override
|
||||
Future<List<Session>> listSessions() async {
|
||||
return List<Session>.of(_sessions);
|
||||
Future<List<Project>> listProjects() async => List<Project>.of(_projects);
|
||||
|
||||
@override
|
||||
Future<ProjectDetail> getProjectDetail(String projectId) async {
|
||||
return ProjectDetail(
|
||||
project: _projects.single,
|
||||
recentSessions: const <Session>[],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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<Session> createSession(String name) async {
|
||||
if (shouldThrowOnCreate) {
|
||||
throw StateError('boom');
|
||||
}
|
||||
Future<void> deleteSession(String sessionId) async {
|
||||
deletedSessionIds.add(sessionId);
|
||||
_sessions.removeWhere((session) => session.sessionId == sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
return Session(sessionId: 'created', name: name, status: 'idle');
|
||||
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('https://host:9443'));
|
||||
_FakeAgentApiClient() : super(Uri.parse('http://10.0.2.2:5067'));
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> getSessionHistory(
|
||||
@ -484,12 +428,13 @@ class _FakeAgentApiClient extends AgentApiClient {
|
||||
}
|
||||
|
||||
class _SequencedHistoryAgentApiClient extends _FakeAgentApiClient {
|
||||
_SequencedHistoryAgentApiClient({required List<Map<String, dynamic>> responses})
|
||||
: _responses = responses;
|
||||
_SequencedHistoryAgentApiClient({
|
||||
required List<Map<String, dynamic>> responses,
|
||||
}) : _responses = responses;
|
||||
|
||||
final List<Map<String, dynamic>> _responses;
|
||||
final requestedLineCounts = <int>[];
|
||||
var _index = 0;
|
||||
int _index = 0;
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> getSessionHistory(
|
||||
@ -510,7 +455,7 @@ class _FakeTerminalSocketTransport implements TerminalSocketTransport {
|
||||
_FakeTerminalSocketTransport({this.autoAttach = false}) {
|
||||
if (autoAttach) {
|
||||
Future<void>.microtask(() {
|
||||
emit('{"type":"attached","sessionId":"abc"}');
|
||||
emit('{"type":"attached","sessionId":"session-1"}');
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -539,7 +484,7 @@ class _FakeTerminalSocketTransport implements TerminalSocketTransport {
|
||||
|
||||
class _QueuedTerminalSocketTransportFactory {
|
||||
final createdTransports = <_FakeTerminalSocketTransport>[];
|
||||
var createCount = 0;
|
||||
int createCount = 0;
|
||||
|
||||
TerminalSocketTransport create(Uri _) {
|
||||
final transport = _FakeTerminalSocketTransport(autoAttach: true);
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
using System.Text.Json;
|
||||
using TermRemoteCtl.Agent.Projects;
|
||||
using TermRemoteCtl.Agent.History;
|
||||
using TermRemoteCtl.Agent.Security;
|
||||
using TermRemoteCtl.Agent.Sessions;
|
||||
using TermRemoteCtl.Agent.Terminal;
|
||||
|
||||
namespace TermRemoteCtl.Agent.Api;
|
||||
|
||||
@ -27,20 +29,69 @@ public static class SessionEndpoints
|
||||
}
|
||||
});
|
||||
|
||||
group.MapDelete("/{sessionId}", async (
|
||||
string sessionId,
|
||||
ISessionHost host,
|
||||
SessionRegistry registry,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!registry.TryGet(sessionId, out _))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
await host.StopAsync(sessionId, cancellationToken).ConfigureAwait(false);
|
||||
await registry.DeleteAsync(sessionId, cancellationToken).ConfigureAwait(false);
|
||||
return Results.NoContent();
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
});
|
||||
|
||||
group.MapPost(string.Empty, async (
|
||||
HttpRequest httpRequest,
|
||||
ProjectRegistry projectRegistry,
|
||||
SessionRegistry registry,
|
||||
SessionHistoryStore historyStore,
|
||||
IClock clock,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var request = await ReadCreateSessionRequestAsync(httpRequest, cancellationToken);
|
||||
if (request is null || string.IsNullOrWhiteSpace(request.Name))
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "invalid_request" });
|
||||
}
|
||||
|
||||
var record = registry.Create(request.Name, clock.UtcNow);
|
||||
ProjectRecord? project = null;
|
||||
if (!string.IsNullOrWhiteSpace(request.ProjectId))
|
||||
{
|
||||
if (!projectRegistry.TryGet(request.ProjectId, out project) || project is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "project_not_found" });
|
||||
}
|
||||
}
|
||||
|
||||
var resolvedName = (request.Name ?? project?.Name)?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(resolvedName))
|
||||
{
|
||||
return Results.BadRequest(new { error = "invalid_request" });
|
||||
}
|
||||
|
||||
var workingDirectory = (request.WorkingDirectory ?? project?.WorkingDirectory)?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(workingDirectory) && !Directory.Exists(workingDirectory))
|
||||
{
|
||||
return Results.BadRequest(new { error = "invalid_working_directory" });
|
||||
}
|
||||
|
||||
var record = registry.Create(
|
||||
resolvedName,
|
||||
clock.UtcNow,
|
||||
project?.ProjectId,
|
||||
workingDirectory);
|
||||
await historyStore.AppendAsync(record.SessionId, string.Empty, cancellationToken);
|
||||
|
||||
return Results.Ok(record);
|
||||
@ -68,4 +119,4 @@ public static class SessionEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record CreateSessionRequest(string Name);
|
||||
public sealed record CreateSessionRequest(string? Name, string? ProjectId, string? WorkingDirectory);
|
||||
|
||||
@ -31,4 +31,18 @@ public sealed class SessionHistoryStore
|
||||
await writer.WriteAsync(chunk.AsMemory(), cancellationToken);
|
||||
await writer.FlushAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string sessionId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var filePath = Path.Combine(_historyRootPath, $"{sessionId}.log");
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,7 +18,11 @@ public sealed class SessionRegistry
|
||||
_ringBufferLineLimit = options.Value.RingBufferLineLimit;
|
||||
}
|
||||
|
||||
public SessionRecord Create(string name, DateTimeOffset now)
|
||||
public SessionRecord Create(
|
||||
string name,
|
||||
DateTimeOffset now,
|
||||
string? projectId = null,
|
||||
string? workingDirectory = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
|
||||
@ -26,6 +30,8 @@ public sealed class SessionRegistry
|
||||
Guid.NewGuid().ToString("N"),
|
||||
name,
|
||||
"created",
|
||||
projectId,
|
||||
workingDirectory,
|
||||
now,
|
||||
now);
|
||||
|
||||
@ -41,6 +47,18 @@ public sealed class SessionRegistry
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public IReadOnlyList<SessionRecord> ListRecentForProject(string projectId, int maxCount)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(projectId);
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxCount);
|
||||
|
||||
return _records.Values
|
||||
.Where(record => string.Equals(record.ProjectId, projectId, StringComparison.Ordinal))
|
||||
.OrderByDescending(record => record.UpdatedAtUtc)
|
||||
.Take(maxCount)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public bool TryGet(string sessionId, out SessionRecord? record)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
|
||||
@ -89,4 +107,17 @@ public sealed class SessionRegistry
|
||||
lines.Skip(skipCount).ToArray(),
|
||||
skipCount > 0);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string sessionId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
|
||||
|
||||
if (!_records.TryRemove(sessionId, out _))
|
||||
{
|
||||
throw new KeyNotFoundException($"Session '{sessionId}' was not found.");
|
||||
}
|
||||
|
||||
_historyBySession.TryRemove(sessionId, out _);
|
||||
await _historyStore.DeleteAsync(sessionId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,8 @@ public interface ISessionHost
|
||||
|
||||
Task StartAsync(string sessionId, CancellationToken cancellationToken);
|
||||
|
||||
Task StopAsync(string sessionId, CancellationToken cancellationToken);
|
||||
|
||||
Task WriteInputAsync(string sessionId, string input, CancellationToken cancellationToken);
|
||||
|
||||
Task ResizeAsync(string sessionId, int columns, int rows, CancellationToken cancellationToken);
|
||||
|
||||
@ -29,7 +29,12 @@ internal sealed class PowerShellSessionHost : ISessionHost, IAsyncDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
var session = _sessionFactory.Create(sessionId);
|
||||
if (!_sessionRegistry.TryGet(sessionId, out var record) || record is null)
|
||||
{
|
||||
throw new KeyNotFoundException($"Session '{sessionId}' is not registered.");
|
||||
}
|
||||
|
||||
var session = _sessionFactory.Create(sessionId, record.WorkingDirectory);
|
||||
if (!_sessions.TryAdd(sessionId, session))
|
||||
{
|
||||
await session.DisposeAsync().ConfigureAwait(false);
|
||||
@ -76,6 +81,20 @@ internal sealed class PowerShellSessionHost : ISessionHost, IAsyncDisposable
|
||||
await session.ResizeAsync(columns, rows, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task StopAsync(string sessionId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!_sessions.TryRemove(sessionId, out var session))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
session.OutputReceived -= HandleSessionOutput;
|
||||
await session.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (var session in _sessions.Values)
|
||||
|
||||
@ -44,6 +44,9 @@ public sealed class TerminalWebSocketHandlerTests
|
||||
await socket.SendAsync(Encoding.UTF8.GetBytes(inputMessage), WebSocketMessageType.Text, true, CancellationToken.None);
|
||||
|
||||
await WaitForConditionAsync(() => fixture.TerminalHost.Inputs.Contains(("input", session.SessionId, "dir")), TimeSpan.FromSeconds(2));
|
||||
await WaitForConditionAsync(
|
||||
() => fixture.Diagnostics.Events.Contains(("backend.input.received", session.SessionId, "dir")),
|
||||
TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
private static async Task<string> ReceiveTextAsync(WebSocket socket, CancellationToken cancellationToken)
|
||||
@ -87,9 +90,12 @@ public sealed class TerminalWebSocketHandlerTests
|
||||
{
|
||||
private readonly string _dataRoot = Path.Combine(Path.GetTempPath(), "TermRemoteCtl.Tests", Guid.NewGuid().ToString("N"));
|
||||
private readonly TestTerminalSessionHost _terminalHost = new();
|
||||
private readonly RecordingTerminalDiagnosticsSink _diagnostics = new();
|
||||
|
||||
public TestTerminalSessionHost TerminalHost => _terminalHost;
|
||||
|
||||
public RecordingTerminalDiagnosticsSink Diagnostics => _diagnostics;
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Development");
|
||||
@ -108,6 +114,7 @@ public sealed class TerminalWebSocketHandlerTests
|
||||
{
|
||||
services.RemoveAll<ISessionHost>();
|
||||
services.AddSingleton<ISessionHost>(_terminalHost);
|
||||
services.AddSingleton<ITerminalDiagnosticsSink>(_diagnostics);
|
||||
});
|
||||
}
|
||||
|
||||
@ -121,6 +128,18 @@ public sealed class TerminalWebSocketHandlerTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingTerminalDiagnosticsSink : ITerminalDiagnosticsSink
|
||||
{
|
||||
private readonly List<(string EventName, string SessionId, string Detail)> _events = [];
|
||||
|
||||
public IReadOnlyList<(string EventName, string SessionId, string Detail)> Events => _events;
|
||||
|
||||
public void Record(string eventName, string sessionId, string detail)
|
||||
{
|
||||
_events.Add((eventName, sessionId, detail));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestTerminalSessionHost : ISessionHost
|
||||
{
|
||||
private readonly List<(string Kind, string SessionId, string Value)> _inputs = new();
|
||||
@ -134,6 +153,11 @@ public sealed class TerminalWebSocketHandlerTests
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(string sessionId, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task WriteInputAsync(string sessionId, string input, CancellationToken cancellationToken)
|
||||
{
|
||||
_inputs.Add(("input", sessionId, input));
|
||||
|
||||
@ -60,6 +60,32 @@ public sealed class SessionFlowTests
|
||||
Assert.Equal(1, fixture.SessionHost.StartCountFor(initialSnapshot.SessionId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_Removes_Session_From_List_And_Stops_Host()
|
||||
{
|
||||
await using var fixture = new AgentFixture();
|
||||
using var client = fixture.CreateClient();
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync("/api/sessions", new { name = "codex-main" });
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<SessionResponse>(JsonOptions);
|
||||
Assert.NotNull(created);
|
||||
|
||||
using (var socket = await fixture.ConnectTerminalAsync(created!.SessionId))
|
||||
{
|
||||
_ = await ReceiveTextAsync(socket);
|
||||
}
|
||||
|
||||
var deleteResponse = await client.DeleteAsync($"/api/sessions/{created.SessionId}");
|
||||
deleteResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var listedSessions = await client.GetFromJsonAsync<List<SessionResponse>>("/api/sessions", JsonOptions);
|
||||
Assert.NotNull(listedSessions);
|
||||
Assert.DoesNotContain(listedSessions!, session => session.SessionId == created.SessionId);
|
||||
Assert.Equal(1, fixture.SessionHost.StopCountFor(created.SessionId));
|
||||
}
|
||||
|
||||
private static async Task<string> ReceiveTextAsync(WebSocket socket)
|
||||
{
|
||||
var buffer = new byte[4096];
|
||||
@ -128,6 +154,7 @@ public sealed class SessionFlowTests
|
||||
private sealed class RecordingSessionHost : ISessionHost
|
||||
{
|
||||
private readonly Dictionary<string, int> _startCounts = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, int> _stopCounts = new(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> _startedSessions = new(StringComparer.Ordinal);
|
||||
|
||||
public event EventHandler<TerminalOutputEventArgs>? OutputReceived
|
||||
@ -156,10 +183,30 @@ public sealed class SessionFlowTests
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(string sessionId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_stopCounts.TryGetValue(sessionId, out var count))
|
||||
{
|
||||
_stopCounts[sessionId] = count + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
_stopCounts[sessionId] = 1;
|
||||
}
|
||||
|
||||
_startedSessions.Remove(sessionId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public int StartCountFor(string sessionId)
|
||||
{
|
||||
return _startCounts.TryGetValue(sessionId, out var count) ? count : 0;
|
||||
}
|
||||
|
||||
public int StopCountFor(string sessionId)
|
||||
{
|
||||
return _stopCounts.TryGetValue(sessionId, out var count) ? count : 0;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record SessionResponse(
|
||||
|
||||
@ -68,6 +68,22 @@ public class SessionRegistryTests
|
||||
Assert.Equal("dir\r\n", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_Removes_Session_Record_And_History_Log()
|
||||
{
|
||||
using var harness = SessionRegistryHarness.Create();
|
||||
var registry = harness.Registry;
|
||||
var session = registry.Create("Shell", DateTimeOffset.UtcNow);
|
||||
await registry.AppendOutputAsync(session.SessionId, "dir\r\n", CancellationToken.None);
|
||||
|
||||
await registry.DeleteAsync(session.SessionId, CancellationToken.None);
|
||||
|
||||
Assert.Empty(registry.List());
|
||||
Assert.False(registry.TryGet(session.SessionId, out _));
|
||||
Assert.False(File.Exists(Path.Combine(harness.DataRoot, "sessions", $"{session.SessionId}.log")));
|
||||
Assert.Throws<KeyNotFoundException>(() => registry.GetHistory(session.SessionId, 10));
|
||||
}
|
||||
|
||||
private sealed class SessionRegistryHarness : IDisposable
|
||||
{
|
||||
private SessionRegistryHarness(string dataRoot, SessionRegistry registry)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user