2017 lines
64 KiB
Dart
2017 lines
64 KiB
Dart
import 'dart:io';
|
|
|
|
import 'dart:async';
|
|
|
|
import 'package:dio/dio.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter/services.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_base_uri_storage.dart';
|
|
import 'package:term_remote_ctl/core/network/agent_connection_providers.dart';
|
|
import 'package:term_remote_ctl/features/presets/preset_command.dart';
|
|
import 'package:term_remote_ctl/features/presets/preset_providers.dart';
|
|
import 'package:term_remote_ctl/features/presets/preset_repository.dart';
|
|
import 'package:term_remote_ctl/features/projects/project.dart';
|
|
import 'package:term_remote_ctl/features/projects/project_detail_page.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_page.dart';
|
|
import 'package:term_remote_ctl/features/terminal/terminal_snapshot.dart';
|
|
import 'package:term_remote_ctl/features/terminal/terminal_snapshot_storage.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 materialApp = tester.widget<MaterialApp>(find.byType(MaterialApp));
|
|
final agentUrlField = tester.widget<TextField>(
|
|
find.byType(TextField).first,
|
|
);
|
|
|
|
expect(find.text('Projects'), findsOneWidget);
|
|
expect(materialApp.theme?.brightness, Brightness.dark);
|
|
expect(materialApp.theme?.scaffoldBackgroundColor, isNot(Colors.white));
|
|
expect(materialApp.theme?.scaffoldBackgroundColor, const Color(0xFF05070A));
|
|
expect(materialApp.theme?.colorScheme.primary, const Color(0xFFC2A574));
|
|
expect(find.byKey(const Key('project_page_header')), findsOneWidget);
|
|
expect(find.text('Agent base URL'), findsOneWidget);
|
|
expect(agentUrlField.controller?.text, 'http://100.81.30.82:5067');
|
|
expect(agentUrlField.decoration?.hintText, 'http://100.81.30.82:5067');
|
|
expect(find.byKey(const Key('agent_connection_readout')), findsOneWidget);
|
|
expect(
|
|
find.text(
|
|
'Project requests use this base origin: http://100.81.30.82:5067.',
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
expect(find.text('codex-main'), findsOneWidget);
|
|
expect(find.text(r'C:\repo\codex-main'), findsOneWidget);
|
|
expect(
|
|
find.text('Launch terminals in known working directories.'),
|
|
findsOneWidget,
|
|
);
|
|
expect(find.text('Stored terminal workspaces'), findsOneWidget);
|
|
expect(find.byKey(const Key('project_tile_project-1')), findsOneWidget);
|
|
expect(find.widgetWithText(FilledButton, 'Open terminal'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets(
|
|
'reuses the same agent URL and retries project loading when Use is tapped',
|
|
(tester) async {
|
|
final projectRepository = _RecoveringProjectRepository();
|
|
|
|
await _pumpApp(
|
|
tester,
|
|
projectRepository: projectRepository,
|
|
sessionRepository: _FakeSessionRepository(),
|
|
);
|
|
|
|
expect(find.text('Could not load projects'), findsOneWidget);
|
|
expect(projectRepository.listProjectsCallCount, 1);
|
|
|
|
await tester.tap(find.widgetWithText(FilledButton, 'Use'));
|
|
await tester.pump();
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(projectRepository.listProjectsCallCount, 2);
|
|
expect(find.text('Could not load projects'), findsNothing);
|
|
expect(find.text('codex-main'), findsOneWidget);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'restoring the same persisted agent URL retries project loading once',
|
|
(tester) async {
|
|
final projectRepository = _RecoveringProjectRepository();
|
|
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
|
|
agentBaseUriStorageProvider.overrideWithValue(
|
|
_MemoryAgentBaseUriStorage(Uri.parse('http://100.81.30.82:5067')),
|
|
),
|
|
projectRepositoryProvider.overrideWithValue(projectRepository),
|
|
sessionRepositoryProvider.overrideWithValue(
|
|
_FakeSessionRepository(),
|
|
),
|
|
presetRepositoryProvider.overrideWithValue(
|
|
_MemoryPresetRepository(const <PresetCommand>[]),
|
|
),
|
|
terminalSocketSessionFactoryProvider.overrideWithValue(
|
|
TerminalSocketSessionFactory(
|
|
transportFactory: (_) =>
|
|
_FakeTerminalSocketTransport(autoAttach: true),
|
|
),
|
|
),
|
|
],
|
|
child: const TermRemoteCtlApp(),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(projectRepository.listProjectsCallCount, 2);
|
|
expect(find.text('Could not load projects'), findsNothing);
|
|
expect(find.text('codex-main'), 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_header_panel')), findsOneWidget);
|
|
expect(find.byKey(const Key('terminal_surface_panel')), findsOneWidget);
|
|
expect(find.byKey(const Key('terminal_command_deck')), findsOneWidget);
|
|
expect(find.byKey(const Key('terminal_status_summary')), findsOneWidget);
|
|
expect(
|
|
find.byKey(const Key('terminal_more_controls_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'),
|
|
),
|
|
]);
|
|
final snapshotStorage = _MemoryTerminalSnapshotStorage([
|
|
const TerminalSnapshot(
|
|
sessionId: 'session-1',
|
|
projectId: 'project-1',
|
|
sessionName: 'codex-main',
|
|
bufferText: 'local-cache',
|
|
updatedAtUtc: '2026-04-06T09:00:00Z',
|
|
),
|
|
]);
|
|
|
|
await _pumpApp(
|
|
tester,
|
|
projectRepository: projectRepository,
|
|
sessionRepository: _FakeSessionRepository(),
|
|
snapshotStorage: snapshotStorage,
|
|
);
|
|
|
|
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);
|
|
expect(await snapshotStorage.read('session-1'), isNull);
|
|
});
|
|
|
|
testWidgets(
|
|
'project card shows recent sessions, expands, and deletes a single session',
|
|
(tester) async {
|
|
final projectRepository = _FakeProjectRepository.withRecentSessions({
|
|
'project-1': [
|
|
_session('session-1', 'alpha'),
|
|
_session('session-2', 'beta'),
|
|
_session('session-3', 'gamma'),
|
|
_session('session-4', 'delta'),
|
|
],
|
|
});
|
|
final sessionRepository = _FakeSessionRepository.withSessions([
|
|
_session('session-1', 'alpha'),
|
|
_session('session-2', 'beta'),
|
|
_session('session-3', 'gamma'),
|
|
_session('session-4', 'delta'),
|
|
])..onDeleteSession = projectRepository.removeRecentSession;
|
|
|
|
await _pumpApp(
|
|
tester,
|
|
projectRepository: projectRepository,
|
|
sessionRepository: sessionRepository,
|
|
);
|
|
|
|
expect(find.text('alpha'), findsOneWidget);
|
|
expect(find.text('beta'), findsOneWidget);
|
|
expect(find.text('gamma'), findsOneWidget);
|
|
expect(find.text('delta'), findsNothing);
|
|
expect(find.text('Recent sessions'), findsOneWidget);
|
|
expect(find.text('Show more'), findsOneWidget);
|
|
|
|
await tester.ensureVisible(find.text('Show more'));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Show more'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('delta'), findsOneWidget);
|
|
|
|
await tester.ensureVisible(
|
|
find.byKey(const Key('project_session_delete_session-2')),
|
|
);
|
|
await tester.tap(
|
|
find.byKey(const Key('project_session_delete_session-2')),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(sessionRepository.deletedSessionIds, contains('session-2'));
|
|
expect(find.text('beta'), findsNothing);
|
|
},
|
|
);
|
|
|
|
testWidgets('project detail page avoids overflow on narrow screens', (
|
|
tester,
|
|
) async {
|
|
tester.view.devicePixelRatio = 1;
|
|
tester.view.physicalSize = const Size(393, 852);
|
|
addTearDown(tester.view.resetPhysicalSize);
|
|
addTearDown(tester.view.resetDevicePixelRatio);
|
|
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
|
|
projectRepositoryProvider.overrideWithValue(
|
|
_FakeProjectRepository.withRecentSessions({
|
|
'project-1': [_session('session-1', 'alpha')],
|
|
}),
|
|
),
|
|
sessionRepositoryProvider.overrideWithValue(_FakeSessionRepository()),
|
|
terminalSocketSessionFactoryProvider.overrideWithValue(
|
|
TerminalSocketSessionFactory(
|
|
transportFactory: (_) =>
|
|
_FakeTerminalSocketTransport(autoAttach: true),
|
|
),
|
|
),
|
|
],
|
|
child: MaterialApp(
|
|
home: ProjectDetailPage(
|
|
project: Project(
|
|
projectId: 'project-1',
|
|
name: 'tt',
|
|
workingDirectory: r'D:\App\python\robot_face_rec',
|
|
createdAtUtc: DateTime.parse('2026-03-30T10:00:00Z'),
|
|
updatedAtUtc: DateTime.parse('2026-03-30T10:00:00Z'),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(tester.takeException(), isNull);
|
|
expect(find.text('Recent sessions'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('terminal reconnect applies restore payload before live frames', (
|
|
tester,
|
|
) async {
|
|
final transportFactory = _QueuedTerminalSocketTransportFactory(
|
|
connectionStartupFrames: const [
|
|
[
|
|
_StartupFrame('{"type":"attached","sessionId":"session-1"}'),
|
|
_StartupFrame(
|
|
'{"type":"restore","sessionId":"session-1","sequence":4,"screenText":"PS> gi","pendingInput":"t status"}',
|
|
),
|
|
],
|
|
],
|
|
);
|
|
|
|
await _pumpTerminalPage(
|
|
tester,
|
|
session: _session('session-1', 'codex-main'),
|
|
socketFactory: TerminalSocketSessionFactory(
|
|
transportFactory: transportFactory.create,
|
|
),
|
|
);
|
|
|
|
final terminal = tester
|
|
.widget<TerminalView>(find.byType(TerminalView))
|
|
.terminal;
|
|
expect(terminal.buffer.getText(), contains('PS> git status'));
|
|
});
|
|
|
|
testWidgets(
|
|
'terminal ignores backend screen snapshots by default and keeps legacy restore flow',
|
|
(tester) async {
|
|
final transportFactory = _QueuedTerminalSocketTransportFactory(
|
|
connectionStartupFrames: const [
|
|
[
|
|
_StartupFrame('{"type":"attached","sessionId":"session-1"}'),
|
|
_StartupFrame(
|
|
'{"type":"screen_snapshot","sessionId":"session-1","screenVersion":4,"sourceSequence":3,"rows":24,"columns":80,"cursorRow":0,"cursorColumn":13,"cursorVisible":true,"activeBuffer":"primary","primaryBuffer":{"viewport":[{"index":0,"text":"snapshot-only"}]}}',
|
|
),
|
|
_StartupFrame(
|
|
'{"type":"restore","sessionId":"session-1","sequence":4,"screenText":"restore-path","pendingInput":""}',
|
|
),
|
|
],
|
|
],
|
|
);
|
|
|
|
await _pumpTerminalPage(
|
|
tester,
|
|
session: _session('session-1', 'codex-main'),
|
|
socketFactory: TerminalSocketSessionFactory(
|
|
transportFactory: transportFactory.create,
|
|
),
|
|
);
|
|
|
|
final terminal = tester
|
|
.widget<TerminalView>(find.byType(TerminalView))
|
|
.terminal;
|
|
expect(terminal.buffer.getText(), contains('restore-path'));
|
|
expect(terminal.buffer.getText(), isNot(contains('snapshot-only')));
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'terminal page keeps the command deck above the bottom safe area',
|
|
(tester) async {
|
|
tester.view.devicePixelRatio = 1;
|
|
tester.view.physicalSize = const Size(393, 852);
|
|
addTearDown(tester.view.resetPhysicalSize);
|
|
addTearDown(tester.view.resetDevicePixelRatio);
|
|
|
|
final mediaQueryData = MediaQueryData.fromView(tester.view).copyWith(
|
|
padding: const EdgeInsets.only(bottom: 34),
|
|
viewPadding: const EdgeInsets.only(bottom: 34),
|
|
);
|
|
|
|
await _pumpTerminalPage(
|
|
tester,
|
|
session: _session('session-1', 'codex-main'),
|
|
mediaQueryData: mediaQueryData,
|
|
);
|
|
|
|
final commandDeckRect = tester.getRect(
|
|
find.byKey(const Key('terminal_command_deck')),
|
|
);
|
|
|
|
expect(commandDeckRect.bottom, lessThanOrEqualTo(852 - 34));
|
|
},
|
|
);
|
|
|
|
testWidgets('terminal surface keeps terminal content inset from the border', (
|
|
tester,
|
|
) async {
|
|
await _pumpTerminalPage(
|
|
tester,
|
|
session: _session('session-1', 'codex-main'),
|
|
);
|
|
|
|
final surfaceRect = tester.getRect(
|
|
find.byKey(const Key('terminal_surface_panel')),
|
|
);
|
|
final terminalViewRect = tester.getRect(find.byType(TerminalView));
|
|
|
|
expect(terminalViewRect.left, greaterThan(surfaceRect.left));
|
|
expect(terminalViewRect.top, greaterThan(surfaceRect.top));
|
|
expect(terminalViewRect.right, lessThan(surfaceRect.right));
|
|
expect(terminalViewRect.bottom, lessThan(surfaceRect.bottom));
|
|
});
|
|
|
|
testWidgets(
|
|
'terminal page keeps the action entry in the command deck instead of the app bar',
|
|
(tester) async {
|
|
await _pumpApp(
|
|
tester,
|
|
projectRepository: _FakeProjectRepository(),
|
|
sessionRepository: _FakeSessionRepository(),
|
|
);
|
|
|
|
await _openProjectTerminal(tester);
|
|
|
|
expect(find.byKey(const Key('terminal_action_bar')), findsOneWidget);
|
|
expect(find.byKey(const Key('terminal_send_button')), findsNothing);
|
|
expect(find.byKey(const Key('terminal_command_deck')), findsOneWidget);
|
|
expect(find.byType(TextField), findsNothing);
|
|
expect(
|
|
find.byKey(const Key('terminal_mode_read_button')),
|
|
findsOneWidget,
|
|
);
|
|
expect(
|
|
find.byKey(const Key('terminal_mode_edit_button')),
|
|
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);
|
|
expect(
|
|
find.byKey(const Key('terminal_more_controls_button')),
|
|
findsOneWidget,
|
|
);
|
|
expect(find.byKey(const Key('terminal_actions_button')), findsNothing);
|
|
expect(find.byKey(const Key('terminal_quick_key_ctrl_c')), findsNothing);
|
|
expect(find.byKey(const Key('terminal_quick_key_enter')), findsNothing);
|
|
},
|
|
);
|
|
|
|
testWidgets('terminal more controls button matches arrow key size', (
|
|
tester,
|
|
) async {
|
|
await _pumpApp(
|
|
tester,
|
|
projectRepository: _FakeProjectRepository(),
|
|
sessionRepository: _FakeSessionRepository(),
|
|
);
|
|
|
|
await _openProjectTerminal(tester);
|
|
|
|
final moreButtonSize = tester.getSize(
|
|
find.byKey(const Key('terminal_more_controls_button')),
|
|
);
|
|
final upKeySize = tester.getSize(
|
|
find.byKey(const Key('terminal_quick_key_up')),
|
|
);
|
|
|
|
expect(moreButtonSize.height, upKeySize.height);
|
|
expect(moreButtonSize.width, upKeySize.width);
|
|
});
|
|
|
|
testWidgets(
|
|
'terminal more controls button toggles expanded 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_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);
|
|
expect(find.byKey(const Key('terminal_quick_key_esc')), findsNothing);
|
|
expect(find.byKey(const Key('terminal_quick_key_ctrl_c')), findsNothing);
|
|
|
|
await tester.tap(find.byKey(const Key('terminal_more_controls_button')));
|
|
await tester.pumpAndSettle();
|
|
|
|
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_ctrl_z')),
|
|
findsOneWidget,
|
|
);
|
|
expect(
|
|
find.byKey(const Key('terminal_quick_key_delete')),
|
|
findsOneWidget,
|
|
);
|
|
expect(find.byKey(const Key('terminal_quick_key_home')), findsOneWidget);
|
|
expect(find.byKey(const Key('terminal_quick_key_end')), findsOneWidget);
|
|
expect(
|
|
find.byKey(const Key('terminal_quick_key_page_up')),
|
|
findsOneWidget,
|
|
);
|
|
expect(
|
|
find.byKey(const Key('terminal_quick_key_page_down')),
|
|
findsOneWidget,
|
|
);
|
|
expect(find.byKey(const Key('terminal_quick_key_enter')), findsOneWidget);
|
|
|
|
await tester.tap(find.byKey(const Key('terminal_mode_edit_button')));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.byKey(const Key('terminal_quick_key_esc')));
|
|
await tester.pump();
|
|
|
|
expect(
|
|
transportFactory.createdTransports.single.sentMessages.last,
|
|
contains('"input":"\\u001b"'),
|
|
);
|
|
},
|
|
);
|
|
|
|
testWidgets('terminal page exposes reading and editing mode switching', (
|
|
tester,
|
|
) async {
|
|
await _pumpApp(
|
|
tester,
|
|
projectRepository: _FakeProjectRepository(),
|
|
sessionRepository: _FakeSessionRepository(),
|
|
);
|
|
|
|
await _openProjectTerminal(tester);
|
|
|
|
expect(find.byType(TextField), findsNothing);
|
|
expect(find.byKey(const Key('terminal_mode_button')), findsOneWidget);
|
|
expect(find.text('Read'), findsOneWidget);
|
|
expect(find.text('Edit'), findsOneWidget);
|
|
expect(find.byIcon(Icons.arrow_upward), findsWidgets);
|
|
expect(find.byIcon(Icons.arrow_downward), findsWidgets);
|
|
expect(find.byIcon(Icons.arrow_back), findsWidgets);
|
|
expect(find.byIcon(Icons.arrow_forward), findsWidgets);
|
|
});
|
|
|
|
testWidgets('terminal quick keys stay disabled until edit mode is selected', (
|
|
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_more_controls_button')));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.byKey(const Key('terminal_quick_key_ctrl_l')));
|
|
await tester.pump();
|
|
|
|
expect(
|
|
transportFactory.createdTransports.single.sentMessages,
|
|
isNot(contains(contains('"input":"\\f"'))),
|
|
);
|
|
|
|
await tester.tap(find.byKey(const Key('terminal_mode_edit_button')));
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.byKey(const Key('terminal_quick_key_ctrl_l')));
|
|
await tester.pump();
|
|
|
|
expect(
|
|
transportFactory.createdTransports.single.sentMessages.last,
|
|
contains('"input":"\\f"'),
|
|
);
|
|
});
|
|
|
|
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_mode_edit_button')));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.byKey(const Key('terminal_more_controls_button')));
|
|
await tester.pumpAndSettle();
|
|
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.ensureVisible(
|
|
find.byKey(const Key('terminal_diagnostics_inline_button')),
|
|
);
|
|
await tester.tap(
|
|
find.byKey(const Key('terminal_diagnostics_inline_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 enter quick key writes carriage return', (
|
|
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_mode_edit_button')));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.byKey(const Key('terminal_more_controls_button')));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.byKey(const Key('terminal_quick_key_enter')));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(
|
|
transportFactory.createdTransports.single.sentMessages.last,
|
|
contains(r'"input":"\r"'),
|
|
);
|
|
});
|
|
|
|
testWidgets('read mode copy uses selected terminal text first', (
|
|
tester,
|
|
) async {
|
|
String? copiedText;
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
|
if (call.method == 'Clipboard.setData') {
|
|
copiedText = (call.arguments as Map)['text'] as String?;
|
|
}
|
|
return null;
|
|
});
|
|
addTearDown(() {
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(SystemChannels.platform, null);
|
|
});
|
|
|
|
await _pumpTerminalPage(
|
|
tester,
|
|
session: _session('session-1', 'codex-main'),
|
|
);
|
|
|
|
final terminalView = tester.widget<TerminalView>(find.byType(TerminalView));
|
|
terminalView.controller!.setSelection(
|
|
terminalView.terminal.buffer.createAnchor(0, 0),
|
|
terminalView.terminal.buffer.createAnchor(3, 0),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.byKey(const Key('terminal_more_controls_button')));
|
|
await tester.pumpAndSettle();
|
|
await tester.ensureVisible(
|
|
find.byKey(const Key('terminal_copy_selected_button')),
|
|
);
|
|
await tester.tap(find.byKey(const Key('terminal_copy_selected_button')));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(copiedText, 'one');
|
|
});
|
|
|
|
testWidgets('copy recent output includes recent terminal buffer content', (
|
|
tester,
|
|
) async {
|
|
String? copiedText;
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
|
if (call.method == 'Clipboard.setData') {
|
|
copiedText = (call.arguments as Map)['text'] as String?;
|
|
}
|
|
return null;
|
|
});
|
|
addTearDown(() {
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(SystemChannels.platform, null);
|
|
});
|
|
|
|
final transportFactory = _QueuedTerminalSocketTransportFactory();
|
|
|
|
await _pumpApp(
|
|
tester,
|
|
projectRepository: _FakeProjectRepository(),
|
|
sessionRepository: _FakeSessionRepository(),
|
|
socketFactory: TerminalSocketSessionFactory(
|
|
transportFactory: transportFactory.create,
|
|
),
|
|
);
|
|
|
|
await _openProjectTerminal(tester);
|
|
transportFactory.createdTransports.single.emit('three');
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.byKey(const Key('terminal_more_controls_button')));
|
|
await tester.pumpAndSettle();
|
|
await tester.ensureVisible(
|
|
find.byKey(const Key('terminal_copy_recent_output_button')),
|
|
);
|
|
await tester.tap(
|
|
find.byKey(const Key('terminal_copy_recent_output_button')),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(copiedText, contains('one\ntwo'));
|
|
expect(copiedText, contains('three'));
|
|
});
|
|
|
|
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();
|
|
|
|
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 reconnects when the app resumes', (tester) async {
|
|
final transportFactory = _QueuedTerminalSocketTransportFactory();
|
|
|
|
await _pumpApp(
|
|
tester,
|
|
projectRepository: _FakeProjectRepository(),
|
|
sessionRepository: _FakeSessionRepository(),
|
|
socketFactory: TerminalSocketSessionFactory(
|
|
transportFactory: transportFactory.create,
|
|
),
|
|
);
|
|
|
|
await _openProjectTerminal(tester);
|
|
|
|
expect(transportFactory.createCount, 1);
|
|
|
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
|
|
await tester.pump();
|
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden);
|
|
await tester.pump();
|
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
|
|
await tester.pump();
|
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden);
|
|
await tester.pump();
|
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
|
|
await tester.pump();
|
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
|
await tester.pump();
|
|
await tester.pump(const Duration(seconds: 2));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(transportFactory.createCount, 2);
|
|
expect(find.text('codex-main'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets(
|
|
'terminal page does not reconnect after an inactive-only interruption',
|
|
(tester) async {
|
|
final transportFactory = _QueuedTerminalSocketTransportFactory();
|
|
|
|
await _pumpApp(
|
|
tester,
|
|
projectRepository: _FakeProjectRepository(),
|
|
sessionRepository: _FakeSessionRepository(),
|
|
socketFactory: TerminalSocketSessionFactory(
|
|
transportFactory: transportFactory.create,
|
|
),
|
|
);
|
|
|
|
await _openProjectTerminal(tester);
|
|
|
|
expect(transportFactory.createCount, 1);
|
|
|
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
|
|
await tester.pump();
|
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
|
await tester.pump();
|
|
await tester.pump(const Duration(seconds: 2));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(transportFactory.createCount, 1);
|
|
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_more_controls_button')));
|
|
await tester.pumpAndSettle();
|
|
await tester.ensureVisible(
|
|
find.byKey(const Key('terminal_new_inline_button')),
|
|
);
|
|
await tester.tap(find.byKey(const Key('terminal_new_inline_button')));
|
|
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',
|
|
'items': <Map<String, dynamic>>[
|
|
<String, dynamic>{
|
|
'sessionId': 'session-1',
|
|
'sequence': 201,
|
|
'kind': 'output',
|
|
'payload': 'one\r\n',
|
|
'timestampUtc': '2026-04-07T03:20:01Z',
|
|
},
|
|
<String, dynamic>{
|
|
'sessionId': 'session-1',
|
|
'sequence': 202,
|
|
'kind': 'output',
|
|
'payload': 'two\r\n',
|
|
'timestampUtc': '2026-04-07T03:20:02Z',
|
|
},
|
|
],
|
|
'hasMoreBefore': true,
|
|
'hasMoreAfter': false,
|
|
'currentSequence': 202,
|
|
},
|
|
<String, dynamic>{
|
|
'sessionId': 'session-1',
|
|
'items': <Map<String, dynamic>>[
|
|
<String, dynamic>{
|
|
'sessionId': 'session-1',
|
|
'sequence': 199,
|
|
'kind': 'output',
|
|
'payload': 'zero\r\n',
|
|
'timestampUtc': '2026-04-07T03:19:59Z',
|
|
},
|
|
<String, dynamic>{
|
|
'sessionId': 'session-1',
|
|
'sequence': 200,
|
|
'kind': 'attach',
|
|
'payload': '',
|
|
'timestampUtc': '2026-04-07T03:20:00Z',
|
|
},
|
|
],
|
|
'hasMoreBefore': false,
|
|
'hasMoreAfter': true,
|
|
'currentSequence': 202,
|
|
},
|
|
],
|
|
);
|
|
|
|
await _pumpApp(
|
|
tester,
|
|
projectRepository: _FakeProjectRepository(),
|
|
sessionRepository: _FakeSessionRepository(),
|
|
apiClient: apiClient,
|
|
);
|
|
|
|
await _openProjectTerminal(tester);
|
|
|
|
expect(find.text('Scrollback'), findsNothing);
|
|
|
|
await tester.tap(find.text('Live'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Scrollback'), 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, [null, 201]);
|
|
expect(find.text('4 lines loaded'), findsOneWidget);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'terminal seeds the main buffer from loaded history when attach replay is absent',
|
|
(tester) async {
|
|
final transportFactory = _QueuedTerminalSocketTransportFactory();
|
|
|
|
await _pumpTerminalPage(
|
|
tester,
|
|
session: _session('session-1', 'codex-main'),
|
|
apiClient: _FakeAgentApiClient(),
|
|
socketFactory: TerminalSocketSessionFactory(
|
|
transportFactory: transportFactory.create,
|
|
),
|
|
);
|
|
await tester.pump(const Duration(milliseconds: 200));
|
|
await tester.pumpAndSettle();
|
|
|
|
final terminal = tester
|
|
.widget<TerminalView>(find.byType(TerminalView))
|
|
.terminal;
|
|
|
|
expect(transportFactory.createCount, 1);
|
|
expect(terminal.buffer.getText(), contains('one\ntwo'));
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'terminal ignores attach replay when seeded history already restored the same output',
|
|
(tester) async {
|
|
final transportFactory = _QueuedTerminalSocketTransportFactory(
|
|
connectionStartupFrames: [
|
|
const [
|
|
_StartupFrame('{"type":"attached","sessionId":"session-1"}'),
|
|
_StartupFrame('one\r\ntwo', delay: Duration(milliseconds: 200)),
|
|
],
|
|
],
|
|
);
|
|
|
|
await _pumpTerminalPage(
|
|
tester,
|
|
session: _session('session-1', 'codex-main'),
|
|
apiClient: _FakeAgentApiClient(),
|
|
socketFactory: TerminalSocketSessionFactory(
|
|
transportFactory: transportFactory.create,
|
|
),
|
|
);
|
|
await tester.pump(const Duration(milliseconds: 300));
|
|
await tester.pumpAndSettle();
|
|
|
|
final terminal = tester
|
|
.widget<TerminalView>(find.byType(TerminalView))
|
|
.terminal;
|
|
|
|
expect(_countOccurrences(terminal.buffer.getText(), 'one\ntwo'), 1);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'terminal attach replay keeps the cursor on the last restored line',
|
|
(tester) async {
|
|
final apiClient = _SequencedHistoryAgentApiClient(
|
|
responses: [
|
|
<String, dynamic>{
|
|
'sessionId': 'session-1',
|
|
'items': <Map<String, dynamic>>[
|
|
<String, dynamic>{
|
|
'sessionId': 'session-1',
|
|
'sequence': 201,
|
|
'kind': 'output',
|
|
'payload': 'one\r\n',
|
|
'timestampUtc': '2026-04-07T03:20:01Z',
|
|
},
|
|
<String, dynamic>{
|
|
'sessionId': 'session-1',
|
|
'sequence': 202,
|
|
'kind': 'output',
|
|
'payload': 'two\r\n',
|
|
'timestampUtc': '2026-04-07T03:20:02Z',
|
|
},
|
|
],
|
|
'hasMoreBefore': true,
|
|
'hasMoreAfter': false,
|
|
'currentSequence': 202,
|
|
},
|
|
<String, dynamic>{
|
|
'sessionId': 'session-1',
|
|
'items': <Map<String, dynamic>>[
|
|
<String, dynamic>{
|
|
'sessionId': 'session-1',
|
|
'sequence': 199,
|
|
'kind': 'output',
|
|
'payload': 'zero\r\n',
|
|
'timestampUtc': '2026-04-07T03:19:59Z',
|
|
},
|
|
<String, dynamic>{
|
|
'sessionId': 'session-1',
|
|
'sequence': 200,
|
|
'kind': 'attach',
|
|
'payload': '',
|
|
'timestampUtc': '2026-04-07T03:20:00Z',
|
|
},
|
|
],
|
|
'hasMoreBefore': false,
|
|
'hasMoreAfter': true,
|
|
'currentSequence': 202,
|
|
},
|
|
],
|
|
);
|
|
final transportFactory = _QueuedTerminalSocketTransportFactory(
|
|
startupFrames: const [
|
|
'{"type":"attached","sessionId":"session-1"}',
|
|
'one\r\ntwo',
|
|
],
|
|
);
|
|
|
|
await _pumpApp(
|
|
tester,
|
|
projectRepository: _FakeProjectRepository(),
|
|
sessionRepository: _FakeSessionRepository(),
|
|
apiClient: apiClient,
|
|
socketFactory: TerminalSocketSessionFactory(
|
|
transportFactory: transportFactory.create,
|
|
),
|
|
);
|
|
|
|
await _openProjectTerminal(tester);
|
|
|
|
var terminal = tester
|
|
.widget<TerminalView>(find.byType(TerminalView))
|
|
.terminal;
|
|
|
|
expect(terminal.buffer.cursorY, 1);
|
|
expect(terminal.buffer.cursorX, 3);
|
|
expect(terminal.buffer.getText(), contains('one\ntwo'));
|
|
|
|
await tester.tap(find.text('Live'));
|
|
await tester.pumpAndSettle();
|
|
await tester.ensureVisible(find.text('Load older lines'));
|
|
await tester.tap(find.text('Load older lines'));
|
|
await tester.pumpAndSettle();
|
|
|
|
terminal = tester
|
|
.widget<TerminalView>(find.byType(TerminalView))
|
|
.terminal;
|
|
|
|
expect(terminal.buffer.getText(), contains('one\ntwo'));
|
|
expect(terminal.buffer.getText(), isNot(contains('zero')));
|
|
expect(terminal.buffer.cursorY, 1);
|
|
expect(terminal.buffer.cursorX, 3);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'terminal reconnect replay does not append duplicate visible output',
|
|
(tester) async {
|
|
final transportFactory = _QueuedTerminalSocketTransportFactory(
|
|
connectionStartupFrames: [
|
|
const [
|
|
_StartupFrame('{"type":"attached","sessionId":"session-1"}'),
|
|
_StartupFrame('one\r\ntwo'),
|
|
],
|
|
const [
|
|
_StartupFrame('{"type":"attached","sessionId":"session-1"}'),
|
|
_StartupFrame('one\r\ntwo'),
|
|
],
|
|
],
|
|
);
|
|
|
|
await _pumpTerminalPage(
|
|
tester,
|
|
session: _session('session-1', 'codex-main'),
|
|
socketFactory: TerminalSocketSessionFactory(
|
|
transportFactory: transportFactory.create,
|
|
),
|
|
);
|
|
|
|
var terminal = tester
|
|
.widget<TerminalView>(find.byType(TerminalView))
|
|
.terminal;
|
|
expect(_countOccurrences(terminal.buffer.getText(), 'one\ntwo'), 1);
|
|
|
|
await transportFactory.createdTransports.first.close();
|
|
await tester.pump();
|
|
await tester.pump(const Duration(seconds: 2));
|
|
await tester.pumpAndSettle();
|
|
|
|
terminal = tester
|
|
.widget<TerminalView>(find.byType(TerminalView))
|
|
.terminal;
|
|
expect(transportFactory.createCount, 2);
|
|
expect(_countOccurrences(terminal.buffer.getText(), 'one\ntwo'), 1);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'terminal reconnect restores pending input without history seed fallback',
|
|
(tester) async {
|
|
final transportFactory = _QueuedTerminalSocketTransportFactory(
|
|
connectionStartupFrames: [
|
|
const [
|
|
_StartupFrame('{"type":"attached","sessionId":"session-1"}'),
|
|
_StartupFrame('initial-output'),
|
|
],
|
|
const [
|
|
_StartupFrame('{"type":"attached","sessionId":"session-1"}'),
|
|
_StartupFrame(
|
|
'{"type":"restore","sessionId":"session-1","sequence":7,"screenText":"PS> gi","pendingInput":"t status"}',
|
|
delay: Duration(milliseconds: 220),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
|
|
await _pumpTerminalPage(
|
|
tester,
|
|
session: _session('session-1', 'codex-main'),
|
|
apiClient: _SequencedHistoryAgentApiClient(
|
|
responses: [
|
|
<String, dynamic>{
|
|
'sessionId': 'session-1',
|
|
'lines': <String>['stale-history'],
|
|
'hasMoreAbove': false,
|
|
},
|
|
],
|
|
),
|
|
socketFactory: TerminalSocketSessionFactory(
|
|
transportFactory: transportFactory.create,
|
|
),
|
|
);
|
|
|
|
await transportFactory.createdTransports.first.close();
|
|
await tester.pump();
|
|
await tester.pump(const Duration(seconds: 1));
|
|
await tester.pump(const Duration(milliseconds: 150));
|
|
|
|
var terminal = tester
|
|
.widget<TerminalView>(find.byType(TerminalView))
|
|
.terminal;
|
|
expect(terminal.buffer.getText(), isNot(contains('stale-history')));
|
|
|
|
await tester.pump(const Duration(milliseconds: 120));
|
|
await tester.pumpAndSettle();
|
|
|
|
terminal = tester
|
|
.widget<TerminalView>(find.byType(TerminalView))
|
|
.terminal;
|
|
expect(terminal.buffer.getText(), contains('PS> git status'));
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
're-entering an existing session restores the local terminal snapshot',
|
|
(tester) async {
|
|
final snapshotStorage = _MemoryTerminalSnapshotStorage([
|
|
const TerminalSnapshot(
|
|
sessionId: 'session-1',
|
|
projectId: 'project-1',
|
|
sessionName: 'codex-main',
|
|
bufferText: 'local-snapshot-output',
|
|
updatedAtUtc: '2026-04-06T09:00:00Z',
|
|
),
|
|
]);
|
|
|
|
await _pumpTerminalPage(
|
|
tester,
|
|
session: _session('session-1', 'codex-main'),
|
|
socketFactory: TerminalSocketSessionFactory(
|
|
transportFactory: (_) =>
|
|
_FakeTerminalSocketTransport(autoAttach: true),
|
|
),
|
|
snapshotStorage: snapshotStorage,
|
|
);
|
|
|
|
final terminal = tester
|
|
.widget<TerminalView>(find.byType(TerminalView))
|
|
.terminal;
|
|
expect(terminal.buffer.getText(), contains('local-snapshot-output'));
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'terminal reconnect keeps a richer local snapshot when restore is shorter',
|
|
(tester) async {
|
|
final snapshotStorage = _MemoryTerminalSnapshotStorage([
|
|
const TerminalSnapshot(
|
|
sessionId: 'session-1',
|
|
projectId: 'project-1',
|
|
sessionName: 'codex-main',
|
|
bufferText: 'PS> git status\r\nmodified: file.txt\r\nPS> ',
|
|
updatedAtUtc: '2026-04-06T09:00:00Z',
|
|
),
|
|
]);
|
|
final transportFactory = _QueuedTerminalSocketTransportFactory(
|
|
connectionStartupFrames: const [
|
|
[
|
|
_StartupFrame('{"type":"attached","sessionId":"session-1"}'),
|
|
_StartupFrame(
|
|
'{"type":"restore","sessionId":"session-1","sequence":4,"screenText":"PS> git status\\r\\n","pendingInput":""}',
|
|
),
|
|
],
|
|
],
|
|
);
|
|
|
|
await _pumpTerminalPage(
|
|
tester,
|
|
session: _session('session-1', 'codex-main'),
|
|
socketFactory: TerminalSocketSessionFactory(
|
|
transportFactory: transportFactory.create,
|
|
),
|
|
snapshotStorage: snapshotStorage,
|
|
);
|
|
|
|
final terminal = tester
|
|
.widget<TerminalView>(find.byType(TerminalView))
|
|
.terminal;
|
|
expect(terminal.buffer.getText(), contains('modified: file.txt'));
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'terminal page persists a local snapshot before suspending to background',
|
|
(tester) async {
|
|
final snapshotStorage = _MemoryTerminalSnapshotStorage();
|
|
final transportFactory = _QueuedTerminalSocketTransportFactory(
|
|
startupFrames: const [
|
|
'{"type":"attached","sessionId":"session-1"}',
|
|
'one\r\ntwo',
|
|
],
|
|
);
|
|
|
|
await _pumpTerminalPage(
|
|
tester,
|
|
session: _session('session-1', 'codex-main'),
|
|
socketFactory: TerminalSocketSessionFactory(
|
|
transportFactory: transportFactory.create,
|
|
),
|
|
snapshotStorage: snapshotStorage,
|
|
);
|
|
|
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
|
|
await tester.pump();
|
|
|
|
final snapshot = await snapshotStorage.read('session-1');
|
|
expect(snapshot, isNotNull);
|
|
expect(snapshot!.bufferText, contains('one\ntwo'));
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
're-entering an existing session restores the terminal cursor to the last line',
|
|
(tester) async {
|
|
final transportFactory = _QueuedTerminalSocketTransportFactory(
|
|
startupFrames: const [
|
|
'{"type":"attached","sessionId":"session-1"}',
|
|
'one\r\ntwo',
|
|
],
|
|
);
|
|
final session = _session('session-1', 'codex-main');
|
|
|
|
await _pumpTerminalPage(
|
|
tester,
|
|
session: session,
|
|
socketFactory: TerminalSocketSessionFactory(
|
|
transportFactory: transportFactory.create,
|
|
),
|
|
);
|
|
|
|
var terminal = tester
|
|
.widget<TerminalView>(find.byType(TerminalView))
|
|
.terminal;
|
|
expect(terminal.buffer.getText(), contains('one\ntwo'));
|
|
expect(terminal.buffer.cursorY, 1);
|
|
expect(terminal.buffer.cursorX, 3);
|
|
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
await tester.pumpAndSettle();
|
|
|
|
await _pumpTerminalPage(
|
|
tester,
|
|
session: session,
|
|
socketFactory: TerminalSocketSessionFactory(
|
|
transportFactory: transportFactory.create,
|
|
),
|
|
);
|
|
|
|
terminal = tester
|
|
.widget<TerminalView>(find.byType(TerminalView))
|
|
.terminal;
|
|
expect(transportFactory.createCount, 2);
|
|
expect(terminal.buffer.getText(), contains('one\ntwo'));
|
|
expect(terminal.buffer.cursorY, 1);
|
|
expect(terminal.buffer.cursorX, 3);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'project detail refreshes after returning from an existing session',
|
|
(tester) async {
|
|
final projectRepository = _FakeProjectRepository.withRecentSessions({
|
|
'project-1': [_session('session-1', 'alpha')],
|
|
});
|
|
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
|
|
projectRepositoryProvider.overrideWithValue(projectRepository),
|
|
sessionRepositoryProvider.overrideWithValue(
|
|
_FakeSessionRepository(),
|
|
),
|
|
terminalSocketSessionFactoryProvider.overrideWithValue(
|
|
TerminalSocketSessionFactory(
|
|
transportFactory: (_) =>
|
|
_FakeTerminalSocketTransport(autoAttach: true),
|
|
),
|
|
),
|
|
],
|
|
child: MaterialApp(
|
|
home: ProjectDetailPage(project: projectRepository.firstProject),
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(projectRepository.detailRequestCount, 1);
|
|
|
|
await tester.tap(find.text('alpha'));
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.pageBack();
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(projectRepository.detailRequestCount, 2);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'project detail formats agent connection errors instead of dumping Dio text',
|
|
(tester) async {
|
|
final project = 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 tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
|
|
projectRepositoryProvider.overrideWithValue(
|
|
_FailingProjectRepository(),
|
|
),
|
|
sessionRepositoryProvider.overrideWithValue(
|
|
_FakeSessionRepository(),
|
|
),
|
|
],
|
|
child: MaterialApp(home: ProjectDetailPage(project: project)),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.textContaining('Cannot reach the agent'), findsOneWidget);
|
|
expect(find.textContaining('0:00:00'), findsNothing);
|
|
expect(find.textContaining('DioException'), findsNothing);
|
|
},
|
|
);
|
|
|
|
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.byKey(const Key('session_page_header')), findsOneWidget);
|
|
expect(find.text('codex-main'), findsOneWidget);
|
|
expect(find.text('Reusable terminal sessions'), findsOneWidget);
|
|
expect(find.byKey(const Key('session_tile_session-1')), 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,
|
|
TerminalSnapshotStorage? snapshotStorage,
|
|
}) async {
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
agentApiClientProvider.overrideWithValue(
|
|
apiClient ?? _FakeAgentApiClient(),
|
|
),
|
|
agentBaseUriStorageProvider.overrideWithValue(
|
|
_MemoryAgentBaseUriStorage(),
|
|
),
|
|
projectRepositoryProvider.overrideWithValue(projectRepository),
|
|
sessionRepositoryProvider.overrideWithValue(sessionRepository),
|
|
presetRepositoryProvider.overrideWithValue(
|
|
_MemoryPresetRepository(const <PresetCommand>[]),
|
|
),
|
|
terminalSnapshotStorageProvider.overrideWithValue(
|
|
snapshotStorage ?? _MemoryTerminalSnapshotStorage(),
|
|
),
|
|
terminalSocketSessionFactoryProvider.overrideWithValue(
|
|
socketFactory ??
|
|
TerminalSocketSessionFactory(
|
|
transportFactory: (_) =>
|
|
_FakeTerminalSocketTransport(autoAttach: true),
|
|
),
|
|
),
|
|
],
|
|
child: const TermRemoteCtlApp(),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
Future<void> _pumpTerminalPage(
|
|
WidgetTester tester, {
|
|
required Session session,
|
|
AgentApiClient? apiClient,
|
|
TerminalSocketSessionFactory? socketFactory,
|
|
MediaQueryData? mediaQueryData,
|
|
TerminalSnapshotStorage? snapshotStorage,
|
|
}) async {
|
|
Widget page = TerminalPage(
|
|
session: session,
|
|
agentBaseUri: Uri.parse('http://100.81.30.82:5067'),
|
|
);
|
|
if (mediaQueryData != null) {
|
|
page = MediaQuery(data: mediaQueryData, child: page);
|
|
}
|
|
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
agentApiClientProvider.overrideWithValue(
|
|
apiClient ?? _FakeAgentApiClient(),
|
|
),
|
|
agentBaseUriStorageProvider.overrideWithValue(
|
|
_MemoryAgentBaseUriStorage(),
|
|
),
|
|
presetRepositoryProvider.overrideWithValue(
|
|
_MemoryPresetRepository(const <PresetCommand>[]),
|
|
),
|
|
terminalSnapshotStorageProvider.overrideWithValue(
|
|
snapshotStorage ?? _MemoryTerminalSnapshotStorage(),
|
|
),
|
|
terminalSocketSessionFactoryProvider.overrideWithValue(
|
|
socketFactory ??
|
|
TerminalSocketSessionFactory(
|
|
transportFactory: (_) =>
|
|
_FakeTerminalSocketTransport(autoAttach: true),
|
|
),
|
|
),
|
|
],
|
|
child: MaterialApp(home: page),
|
|
),
|
|
);
|
|
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'),
|
|
),
|
|
],
|
|
_recentSessionsByProject = const {},
|
|
super(_FakeAgentApiClient());
|
|
|
|
_FakeProjectRepository.withProjects(List<Project> projects)
|
|
: _projects = List<Project>.of(projects),
|
|
_recentSessionsByProject = const {},
|
|
super(_FakeAgentApiClient());
|
|
|
|
_FakeProjectRepository.withRecentSessions(
|
|
Map<String, List<Session>> recentSessionsByProject,
|
|
) : _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'),
|
|
),
|
|
],
|
|
_recentSessionsByProject = {
|
|
for (final entry in recentSessionsByProject.entries)
|
|
entry.key: List<Session>.of(entry.value),
|
|
},
|
|
super(_FakeAgentApiClient());
|
|
|
|
final List<Project> _projects;
|
|
final Map<String, List<Session>> _recentSessionsByProject;
|
|
final List<String> deletedProjectIds = <String>[];
|
|
int detailRequestCount = 0;
|
|
|
|
Project get firstProject => _projects.first;
|
|
|
|
@override
|
|
Future<List<Project>> listProjects() async => List<Project>.of(_projects);
|
|
|
|
@override
|
|
Future<ProjectDetail> getProjectDetail(String projectId) async {
|
|
detailRequestCount += 1;
|
|
final project = _projects.singleWhere(
|
|
(entry) => entry.projectId == projectId,
|
|
);
|
|
return ProjectDetail(
|
|
project: project,
|
|
recentSessions: List<Session>.of(
|
|
_recentSessionsByProject[projectId] ?? const <Session>[],
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> deleteProject(String projectId) async {
|
|
deletedProjectIds.add(projectId);
|
|
_projects.removeWhere((project) => project.projectId == projectId);
|
|
}
|
|
|
|
void removeRecentSession(String sessionId) {
|
|
for (final sessions in _recentSessionsByProject.values) {
|
|
sessions.removeWhere((session) => session.sessionId == sessionId);
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
void Function(String sessionId)? onDeleteSession;
|
|
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);
|
|
onDeleteSession?.call(sessionId);
|
|
}
|
|
}
|
|
|
|
Session _session(String sessionId, String name) {
|
|
return Session(
|
|
sessionId: sessionId,
|
|
name: name,
|
|
status: 'created',
|
|
projectId: 'project-1',
|
|
workingDirectory: r'C:\repo\codex-main',
|
|
createdAtUtc: DateTime.parse('2026-03-30T10:00:00Z'),
|
|
updatedAtUtc: DateTime.parse('2026-03-30T10:00:00Z'),
|
|
);
|
|
}
|
|
|
|
class _MemoryPresetRepository extends PresetRepository {
|
|
_MemoryPresetRepository(List<PresetCommand> presets)
|
|
: _presets = List<PresetCommand>.of(presets);
|
|
|
|
final List<PresetCommand> _presets;
|
|
|
|
@override
|
|
Future<List<PresetCommand>> listPresets() async {
|
|
return List<PresetCommand>.of(_presets);
|
|
}
|
|
}
|
|
|
|
class _MemoryAgentBaseUriStorage extends AgentBaseUriStorage {
|
|
_MemoryAgentBaseUriStorage([this._storedUri]);
|
|
|
|
Uri? _storedUri;
|
|
|
|
@override
|
|
Future<Uri?> read() async {
|
|
return _storedUri;
|
|
}
|
|
|
|
@override
|
|
Future<void> write(Uri uri) async {
|
|
_storedUri = uri;
|
|
}
|
|
}
|
|
|
|
class _MemoryTerminalSnapshotStorage extends TerminalSnapshotStorage {
|
|
_MemoryTerminalSnapshotStorage([List<TerminalSnapshot> snapshots = const []])
|
|
: _snapshots = {
|
|
for (final snapshot in snapshots) snapshot.sessionId: snapshot,
|
|
},
|
|
super(storageFileLoader: _unsupportedSnapshotFile);
|
|
|
|
final Map<String, TerminalSnapshot> _snapshots;
|
|
|
|
@override
|
|
Future<TerminalSnapshot?> read(String sessionId) async {
|
|
return _snapshots[sessionId];
|
|
}
|
|
|
|
@override
|
|
Future<void> save(TerminalSnapshot snapshot) async {
|
|
_snapshots[snapshot.sessionId] = snapshot;
|
|
}
|
|
|
|
@override
|
|
Future<void> delete(String sessionId) async {
|
|
_snapshots.remove(sessionId);
|
|
}
|
|
|
|
@override
|
|
Future<void> deleteByProjectId(String projectId) async {
|
|
_snapshots.removeWhere((_, snapshot) => snapshot.projectId == projectId);
|
|
}
|
|
|
|
@override
|
|
Future<void> pruneToSessionIds(Set<String> sessionIds) async {
|
|
_snapshots.removeWhere((sessionId, _) => !sessionIds.contains(sessionId));
|
|
}
|
|
}
|
|
|
|
Future<File> _unsupportedSnapshotFile() {
|
|
throw UnimplementedError('This test does not use file storage.');
|
|
}
|
|
|
|
int _countOccurrences(String source, String pattern) {
|
|
if (pattern.isEmpty) {
|
|
return 0;
|
|
}
|
|
|
|
var count = 0;
|
|
var start = 0;
|
|
while (true) {
|
|
final index = source.indexOf(pattern, start);
|
|
if (index < 0) {
|
|
return count;
|
|
}
|
|
|
|
count += 1;
|
|
start = index + pattern.length;
|
|
}
|
|
}
|
|
|
|
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 _FailingProjectRepository extends ProjectRepository {
|
|
_FailingProjectRepository() : super(_FakeAgentApiClient());
|
|
|
|
@override
|
|
Future<ProjectDetail> getProjectDetail(String projectId) async {
|
|
throw DioException.connectionTimeout(
|
|
timeout: Duration.zero,
|
|
requestOptions: RequestOptions(
|
|
path: '/api/projects/$projectId',
|
|
baseUrl: 'http://100.81.30.82:5067',
|
|
),
|
|
error: SocketException(
|
|
'Operation timed out',
|
|
osError: OSError('Operation timed out', 60),
|
|
port: 5067,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RecoveringProjectRepository extends _FakeProjectRepository {
|
|
int listProjectsCallCount = 0;
|
|
|
|
@override
|
|
Future<List<Project>> listProjects() async {
|
|
listProjectsCallCount += 1;
|
|
if (listProjectsCallCount == 1) {
|
|
throw DioException.connectionError(
|
|
requestOptions: RequestOptions(
|
|
path: '/api/projects',
|
|
baseUrl: 'http://100.81.30.82:5067',
|
|
),
|
|
reason: 'No route to host',
|
|
error: const SocketException(
|
|
'Connection failed',
|
|
osError: OSError('No route to host', 65),
|
|
port: 5067,
|
|
),
|
|
);
|
|
}
|
|
|
|
return super.listProjects();
|
|
}
|
|
}
|
|
|
|
class _FakeAgentApiClient extends AgentApiClient {
|
|
_FakeAgentApiClient() : super(Uri.parse('http://100.81.30.82:5067'));
|
|
|
|
@override
|
|
Future<Map<String, dynamic>> getSessionJournal(
|
|
String sessionId, {
|
|
int limit = 200,
|
|
int? beforeSequence,
|
|
int? afterSequence,
|
|
}) async {
|
|
return <String, dynamic>{
|
|
'sessionId': sessionId,
|
|
'items': <Map<String, dynamic>>[
|
|
<String, dynamic>{
|
|
'sessionId': sessionId,
|
|
'sequence': 1,
|
|
'kind': 'output',
|
|
'payload': 'one\r\n',
|
|
'timestampUtc': '2026-04-07T03:20:01Z',
|
|
},
|
|
<String, dynamic>{
|
|
'sessionId': sessionId,
|
|
'sequence': 2,
|
|
'kind': 'output',
|
|
'payload': 'two\r\n',
|
|
'timestampUtc': '2026-04-07T03:20:02Z',
|
|
},
|
|
],
|
|
'hasMoreBefore': true,
|
|
'hasMoreAfter': false,
|
|
'currentSequence': 2,
|
|
};
|
|
}
|
|
}
|
|
|
|
class _SequencedHistoryAgentApiClient extends _FakeAgentApiClient {
|
|
_SequencedHistoryAgentApiClient({
|
|
required List<Map<String, dynamic>> responses,
|
|
}) : _responses = responses;
|
|
|
|
final List<Map<String, dynamic>> _responses;
|
|
final requestedBeforeSequences = <int?>[];
|
|
List<int?> get requestedLineCounts => requestedBeforeSequences;
|
|
int _index = 0;
|
|
|
|
@override
|
|
Future<Map<String, dynamic>> getSessionJournal(
|
|
String sessionId, {
|
|
int limit = 200,
|
|
int? beforeSequence,
|
|
int? afterSequence,
|
|
}) async {
|
|
requestedBeforeSequences.add(beforeSequence);
|
|
final response = _responses[_index];
|
|
if (_index < _responses.length - 1) {
|
|
_index += 1;
|
|
}
|
|
|
|
return response;
|
|
}
|
|
}
|
|
|
|
class _FakeTerminalSocketTransport implements TerminalSocketTransport {
|
|
_FakeTerminalSocketTransport({
|
|
this.autoAttach = false,
|
|
this.startupFrames = const <String>[],
|
|
this.scheduledStartupFrames = const <_StartupFrame>[],
|
|
this.onMessageSent,
|
|
}) {
|
|
final framesToEmit = scheduledStartupFrames.isNotEmpty
|
|
? scheduledStartupFrames
|
|
: startupFrames.map(_StartupFrame.new).toList(growable: false);
|
|
|
|
if (autoAttach && framesToEmit.isEmpty) {
|
|
Future<void>.microtask(() {
|
|
emit('{"type":"attached","sessionId":"session-1"}');
|
|
});
|
|
} else if (framesToEmit.isNotEmpty) {
|
|
_scheduleFrames(framesToEmit);
|
|
}
|
|
}
|
|
|
|
final bool autoAttach;
|
|
final List<String> startupFrames;
|
|
final List<_StartupFrame> scheduledStartupFrames;
|
|
final void Function(_FakeTerminalSocketTransport transport, String message)?
|
|
onMessageSent;
|
|
final _incoming = StreamController<dynamic>.broadcast();
|
|
final sentMessages = <String>[];
|
|
final List<Timer> _frameTimers = <Timer>[];
|
|
|
|
@override
|
|
Stream<dynamic> get stream => _incoming.stream;
|
|
|
|
@override
|
|
void send(String message) {
|
|
sentMessages.add(message);
|
|
onMessageSent?.call(this, message);
|
|
}
|
|
|
|
@override
|
|
Future<void> close() async {
|
|
for (final timer in _frameTimers) {
|
|
timer.cancel();
|
|
}
|
|
_frameTimers.clear();
|
|
await _incoming.close();
|
|
}
|
|
|
|
void emit(String message) {
|
|
_incoming.add(message);
|
|
}
|
|
|
|
void _scheduleFrames(List<_StartupFrame> frames) {
|
|
var cumulativeDelay = Duration.zero;
|
|
for (final frame in frames) {
|
|
cumulativeDelay += frame.delay;
|
|
if (cumulativeDelay == Duration.zero) {
|
|
Future<void>.microtask(() {
|
|
if (!_incoming.isClosed) {
|
|
emit(frame.message);
|
|
}
|
|
});
|
|
continue;
|
|
}
|
|
|
|
final timer = Timer(cumulativeDelay, () {
|
|
if (!_incoming.isClosed) {
|
|
emit(frame.message);
|
|
}
|
|
});
|
|
_frameTimers.add(timer);
|
|
}
|
|
}
|
|
}
|
|
|
|
class _QueuedTerminalSocketTransportFactory {
|
|
_QueuedTerminalSocketTransportFactory({
|
|
this.startupFrames = const <String>[],
|
|
this.connectionStartupFrames = const <List<_StartupFrame>>[],
|
|
this.onMessageSent,
|
|
});
|
|
|
|
final List<String> startupFrames;
|
|
final List<List<_StartupFrame>> connectionStartupFrames;
|
|
final void Function(_FakeTerminalSocketTransport transport, String message)?
|
|
onMessageSent;
|
|
final createdTransports = <_FakeTerminalSocketTransport>[];
|
|
int createCount = 0;
|
|
|
|
TerminalSocketTransport create(Uri _) {
|
|
final scheduledFrames = createCount < connectionStartupFrames.length
|
|
? connectionStartupFrames[createCount]
|
|
: const <_StartupFrame>[];
|
|
final transport = _FakeTerminalSocketTransport(
|
|
autoAttach: startupFrames.isEmpty && scheduledFrames.isEmpty,
|
|
startupFrames: startupFrames,
|
|
scheduledStartupFrames: scheduledFrames,
|
|
onMessageSent: onMessageSent,
|
|
);
|
|
createdTransports.add(transport);
|
|
createCount += 1;
|
|
return transport;
|
|
}
|
|
}
|
|
|
|
class _StartupFrame {
|
|
const _StartupFrame(this.message, {this.delay = Duration.zero});
|
|
|
|
final String message;
|
|
final Duration delay;
|
|
}
|