import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:oto_console/oto_console.dart'; import 'package:oto_client/src/app/core_connection_client.dart'; import 'package:oto_client/src/app/oto_client_app.dart'; void main() { testWidgets('OTO client hosts embeddable console widgets', (tester) async { await tester.pumpWidget(OtoClientApp(coreClient: _FakeCoreClient())); await tester.pump(); expect(find.text('Build Operations Overview'), findsOneWidget); expect(find.text('CORE ONLINE'), findsOneWidget); expect(find.text('Health'), findsOneWidget); expect(find.text('Readiness'), findsOneWidget); expect(find.byTooltip('Agent'), findsOneWidget); await tester.tap(find.byTooltip('Agent')); await tester.pumpAndSettle(); expect(find.textContaining('OTO agent surface is ready'), findsOneWidget); expect(find.textContaining('Runner Registry'), findsOneWidget); }); test( 'OtoHttpCoreReadClient maps Core read responses to data states', () async { final requestedPaths = []; final client = OtoHttpCoreReadClient( httpClient: MockClient((request) async { requestedPaths.add(request.url.path); return switch (request.url.path) { '/api/v1/runners/runner-1' => _jsonResponse({ 'runner_id': 'runner-1', 'alias': 'mac-mini', 'protocol_version': 'oto.runner.v1', 'status': 'online', 'accepted_at': '2026-06-17T01:00:00Z', 'first_heartbeat_at': '2026-06-17T01:01:00Z', 'last_heartbeat_at': '2026-06-17T01:02:00Z', 'failure_reason': '', }), '/api/v1/runners/runner-1/status' => _jsonResponse({ 'accepted': true, 'runner_id': 'runner-1', 'status': 'online', 'current_job_id': 'job-1', 'current_execution_id': 'exec-1', 'message': '', }), '/api/v1/jobs/job-1' => _jsonResponse({ 'id': 'job-1', 'name': 'build', 'state': 'running', 'created_at': '2026-06-17T01:00:00Z', 'updated_at': '2026-06-17T01:02:00Z', 'execution_id': 'exec-1', 'run_request': {'pipeline_yaml_path': '/pipelines/build.yaml'}, }), '/api/v1/executions/exec-1' => _jsonResponse({ 'id': 'exec-1', 'job_id': 'job-1', 'state': 'running', 'created_at': '2026-06-17T01:00:00Z', 'updated_at': '2026-06-17T01:02:00Z', 'execution_id': 'exec-1', }), '/api/v1/executions/exec-1/logs' => _jsonResponse({ 'logs': [ {'Timestamp': '2026-06-17T01:01:00Z', 'Line': 'build started'}, ], }), '/api/v1/executions/exec-1/artifacts' => _jsonResponse({ 'artifacts': [ {'Name': 'app.zip', 'Path': '/artifacts/app.zip'}, ], }), _ => http.Response('not found', 404), }; }), ); const config = OtoConsoleConfig( serverHttpUrl: 'http://core.example.test:18020', serverWireUrl: 'ws://core.example.test:18080/runner', ); final runner = await client.fetchRunner(config, 'runner-1'); final status = await client.fetchRunnerStatus(config, 'runner-1'); final job = await client.fetchJob(config, 'job-1'); final execution = await client.fetchExecution(config, 'exec-1'); final logs = await client.fetchLogs(config, 'exec-1'); final artifacts = await client.fetchArtifacts(config, 'exec-1'); expect(runner.state, OtoCoreReadState.data); expect(runner.data!.runnerID, 'runner-1'); expect(status.data!.currentExecutionID, 'exec-1'); expect( job.data!.runRequest!['pipeline_yaml_path'], '/pipelines/build.yaml', ); expect(execution.data!.jobID, 'job-1'); expect(logs.data!.single.line, 'build started'); expect(artifacts.data!.single.name, 'app.zip'); expect(requestedPaths, [ '/api/v1/runners/runner-1', '/api/v1/runners/runner-1/status', '/api/v1/jobs/job-1', '/api/v1/executions/exec-1', '/api/v1/executions/exec-1/logs', '/api/v1/executions/exec-1/artifacts', ]); }, ); test('OtoHttpCoreReadClient maps empty and error states', () async { final client = OtoHttpCoreReadClient( httpClient: MockClient((request) async { return switch (request.url.path) { '/api/v1/runners/missing' => http.Response('not found', 404), '/api/v1/jobs/failing' => http.Response('boom', 500), '/api/v1/executions/exec-empty/logs' => _jsonResponse({'logs': []}), _ => http.Response('not found', 404), }; }), ); const config = OtoConsoleConfig( serverHttpUrl: 'http://core.example.test:18020', serverWireUrl: 'ws://core.example.test:18080/runner', ); final missingRunner = await client.fetchRunner(config, 'missing'); final failingJob = await client.fetchJob(config, 'failing'); final emptyLogs = await client.fetchLogs(config, 'exec-empty'); final invalidConfig = await client.fetchExecution( const OtoConsoleConfig( serverHttpUrl: 'not-a-url', serverWireUrl: 'ws://core.example.test:18080/runner', ), 'exec-1', ); expect(missingRunner.state, OtoCoreReadState.empty); expect(missingRunner.statusCode, 404); expect(failingJob.state, OtoCoreReadState.error); expect(failingJob.statusCode, 500); expect(emptyLogs.state, OtoCoreReadState.empty); expect(emptyLogs.data, isEmpty); expect(invalidConfig.state, OtoCoreReadState.error); expect(invalidConfig.message, 'Invalid OTO server HTTP URL'); }); testWidgets( 'OtoClientApp wires runners loading empty state when no runnerIDs', (tester) async { await tester.pumpWidget( OtoClientApp(coreClient: _FakeCoreClient(), runnerIDs: const []), ); await tester.pumpAndSettle(); await tester.tap(find.byTooltip('Runners')); await tester.pumpAndSettle(); expect(find.text('No runners connected'), findsOneWidget); expect(find.text('Runner registry is empty.'), findsOneWidget); }, ); testWidgets('OtoClientApp wires runners error state on API failure', ( tester, ) async { final fakeReadClient = _FakeCoreReadClient( onFetchRunner: (id) => const OtoCoreReadResult.error( 'Internal Server Error', statusCode: 500, ), onFetchRunnerStatus: (id) => const OtoCoreReadResult.empty(), ); await tester.pumpWidget( OtoClientApp( coreClient: _FakeCoreClient(), readClient: fakeReadClient, runnerIDs: const ['runner-fail'], ), ); await tester.pumpAndSettle(); await tester.tap(find.byTooltip('Runners')); await tester.pumpAndSettle(); expect(find.text('Error loading runners'), findsOneWidget); expect(find.text('Internal Server Error'), findsOneWidget); expect(fakeReadClient.lastUsedConfig, isNotNull); }); testWidgets('OtoClientApp wires runners data state on API success', ( tester, ) async { final fakeReadClient = _FakeCoreReadClient( onFetchRunner: (id) => OtoCoreReadResult.data( OtoRunnerRecord( runnerID: id, alias: 'Agent Mini', protocolVersion: 'v1', status: 'active', acceptedAt: DateTime.now(), firstHeartbeatAt: DateTime.now(), lastHeartbeatAt: DateTime.now(), failureReason: '', ), ), onFetchRunnerStatus: (id) => OtoCoreReadResult.data( OtoRunnerStatus( accepted: true, runnerID: id, status: 'active', currentJobID: 'job-999', currentExecutionID: 'exec-888', message: 'All good', ), ), ); await tester.pumpWidget( OtoClientApp( coreClient: _FakeCoreClient(), readClient: fakeReadClient, runnerIDs: const ['runner-ok'], ), ); await tester.pumpAndSettle(); await tester.tap(find.byTooltip('Runners')); await tester.pumpAndSettle(); expect(find.text('ID: runner-ok'), findsOneWidget); expect(find.text('Alias: Agent Mini'), findsOneWidget); expect(find.text('ACTIVE'), findsOneWidget); expect(find.text('Message: All good'), findsOneWidget); expect(find.textContaining('Job: job-999'), findsOneWidget); expect(find.textContaining('Execution: exec-888'), findsOneWidget); }); _registerRunnerSelfUpdateTests(); _registerExecutionActionTests(); _registerJobCreateTests(); _registerJobsExecutionsTests(); } http.Response _jsonResponse(Map body) { return http.Response( jsonEncode(body), 200, headers: {'content-type': 'application/json'}, ); } Finder _newItemMenuButton() { return find.ancestor( of: find.text('New Item'), matching: find.byType(InkWell), ); } class _FakeCoreClient implements OtoCoreConnectionClient { @override Future check(OtoConsoleConfig config) async { return const OtoCoreConnectionSnapshot( state: OtoCoreConnectionState.online, health: OtoCoreEndpointStatus( label: 'Health', path: '/healthz', state: OtoCoreConnectionState.online, statusCode: 200, message: 'OK', ), readiness: OtoCoreEndpointStatus( label: 'Readiness', path: '/readyz', state: OtoCoreConnectionState.online, statusCode: 200, message: 'OK', ), policyNote: 'CORS headers or a same-origin proxy are required.', ); } } class _FakeCoreReadClient implements OtoCoreReadClient { final OtoCoreReadResult Function(String runnerID)? onFetchRunner; final OtoCoreReadResult Function(String runnerID)? onFetchRunnerStatus; final OtoCoreReadResult Function(String jobID)? onFetchJob; final OtoCoreReadResult Function(String executionID)? onFetchExecution; final OtoCoreReadResult> Function( String executionID, )? onFetchLogs; final OtoCoreReadResult> Function(String executionID)? onFetchArtifacts; OtoConsoleConfig? lastUsedConfig; _FakeCoreReadClient({ this.onFetchRunner, this.onFetchRunnerStatus, this.onFetchJob, this.onFetchExecution, this.onFetchLogs, this.onFetchArtifacts, }); @override Future> fetchRunner( OtoConsoleConfig config, String runnerID, ) async { lastUsedConfig = config; if (onFetchRunner != null) { return onFetchRunner!(runnerID); } return const OtoCoreReadResult.empty(); } @override Future> fetchRunnerStatus( OtoConsoleConfig config, String runnerID, ) async { lastUsedConfig = config; if (onFetchRunnerStatus != null) { return onFetchRunnerStatus!(runnerID); } return const OtoCoreReadResult.empty(); } @override Future> fetchJob( OtoConsoleConfig config, String jobID, ) async { lastUsedConfig = config; if (onFetchJob != null) { return onFetchJob!(jobID); } return const OtoCoreReadResult.empty(); } @override Future> fetchExecution( OtoConsoleConfig config, String executionID, ) async { lastUsedConfig = config; if (onFetchExecution != null) { return onFetchExecution!(executionID); } return const OtoCoreReadResult.empty(); } @override Future>> fetchLogs( OtoConsoleConfig config, String executionID, ) async { lastUsedConfig = config; if (onFetchLogs != null) { return onFetchLogs!(executionID); } return const OtoCoreReadResult.empty(); } @override Future>> fetchArtifacts( OtoConsoleConfig config, String executionID, ) async { lastUsedConfig = config; if (onFetchArtifacts != null) { return onFetchArtifacts!(executionID); } return const OtoCoreReadResult.empty(); } } class _FakeCoreWriteClient implements OtoCoreWriteClient { final Future> Function( OtoConsoleConfig config, OtoJobCreateDraft draft, )? onCreateJob; final Future> Function( OtoConsoleConfig config, OtoExecutionCancelDraft draft, )? onCancelExecution; final Future> Function( OtoConsoleConfig config, OtoExecutionReportDraft draft, )? onReportExecution; final Future> Function( OtoConsoleConfig config, OtoExecutionLogDraft draft, )? onAppendExecutionLog; final Future> Function( OtoConsoleConfig config, OtoExecutionArtifactDraft draft, )? onAppendExecutionArtifact; final Future> Function( OtoConsoleConfig config, OtoRunnerSelfUpdateDraft draft, )? onSelfUpdateRunner; OtoConsoleConfig? lastUsedConfig; OtoJobCreateDraft? lastUsedDraft; _FakeCoreWriteClient({ this.onCreateJob, this.onCancelExecution, this.onReportExecution, this.onAppendExecutionLog, this.onAppendExecutionArtifact, this.onSelfUpdateRunner, }); @override Future> createJob( OtoConsoleConfig config, OtoJobCreateDraft draft, ) async { lastUsedConfig = config; lastUsedDraft = draft; if (onCreateJob != null) { return onCreateJob!(config, draft); } return const OtoCoreWriteResult.failed(message: 'No handler'); } @override Future> cancelExecution( OtoConsoleConfig config, OtoExecutionCancelDraft draft, ) async { lastUsedConfig = config; if (onCancelExecution != null) { return onCancelExecution!(config, draft); } return const OtoCoreWriteResult.failed(message: 'No handler'); } @override Future> reportExecution( OtoConsoleConfig config, OtoExecutionReportDraft draft, ) async { lastUsedConfig = config; if (onReportExecution != null) { return onReportExecution!(config, draft); } return const OtoCoreWriteResult.failed(message: 'No handler'); } @override Future> appendExecutionLog( OtoConsoleConfig config, OtoExecutionLogDraft draft, ) async { lastUsedConfig = config; if (onAppendExecutionLog != null) { return onAppendExecutionLog!(config, draft); } return const OtoCoreWriteResult.failed(message: 'No handler'); } @override Future> appendExecutionArtifact( OtoConsoleConfig config, OtoExecutionArtifactDraft draft, ) async { lastUsedConfig = config; if (onAppendExecutionArtifact != null) { return onAppendExecutionArtifact!(config, draft); } return const OtoCoreWriteResult.failed(message: 'No handler'); } @override Future> selfUpdateRunner( OtoConsoleConfig config, OtoRunnerSelfUpdateDraft draft, ) async { lastUsedConfig = config; if (onSelfUpdateRunner != null) { return onSelfUpdateRunner!(config, draft); } return const OtoCoreWriteResult.failed(message: 'No handler'); } } void _registerRunnerSelfUpdateTests() { test('OtoHttpCoreWriteClient maps runner self-update responses', () async { final requestedPaths = []; final requestedBodies = []; final client = OtoHttpCoreWriteClient( httpClient: MockClient((request) async { requestedPaths.add(request.url.path); requestedBodies.add(request.body); return switch (request.url.path) { '/api/v1/runners/runner-1/self-update' => http.Response( jsonEncode({'accepted': true}), 200, headers: {'content-type': 'application/json'}, ), '/api/v1/runners/runner-deferred/self-update' => http.Response( jsonEncode({ 'deferred': true, 'message': 'Update scheduled for next restart', }), 200, headers: {'content-type': 'application/json'}, ), '/api/v1/runners/runner-error/self-update' => http.Response( jsonEncode({'success': false, 'error_message': 'Runner not found'}), 404, headers: {'content-type': 'application/json'}, ), '/api/v1/runners/runner-bad/self-update' => http.Response( jsonEncode({'error_message': 'Invalid version format'}), 200, headers: {'content-type': 'application/json'}, ), _ => http.Response('not found', 404), }; }), ); const config = OtoConsoleConfig( serverHttpUrl: 'http://core.example.test:18020', serverWireUrl: 'ws://core.example.test:18080/runner', ); // --- Accepted --- final acceptedDraft = const OtoRunnerSelfUpdateDraft( runnerID: 'runner-1', version: 'v1.2.3', downloadUrl: 'https://releases.example.test/oto-agent', ); var result = await client.selfUpdateRunner(config, acceptedDraft); expect(result.state, OtoCoreWriteState.succeeded); expect(result.message, 'Update accepted'); expect(requestedPaths, ['/api/v1/runners/runner-1/self-update']); final body = jsonDecode(requestedBodies.first) as Map; expect(body['runner_id'], 'runner-1'); expect(body['version'], 'v1.2.3'); expect(body['download_url'], 'https://releases.example.test/oto-agent'); // --- Deferred --- final deferredDraft = const OtoRunnerSelfUpdateDraft( runnerID: 'runner-deferred', version: 'v1.2.3', downloadUrl: 'https://releases.example.test/oto-agent', ); result = await client.selfUpdateRunner(config, deferredDraft); expect(result.state, OtoCoreWriteState.deferred); expect(result.message, 'Update scheduled for next restart'); // --- Error (404 with error_message) --- final errorDraft = const OtoRunnerSelfUpdateDraft( runnerID: 'runner-error', version: 'v1.2.3', downloadUrl: 'https://releases.example.test/oto-agent', ); result = await client.selfUpdateRunner(config, errorDraft); expect(result.state, OtoCoreWriteState.failed); expect(result.message, 'Runner not found'); expect(result.statusCode, 404); // --- Error (200 + error_message) --- final badDraft = const OtoRunnerSelfUpdateDraft( runnerID: 'runner-bad', version: 'bad', downloadUrl: 'https://releases.example.test/oto-agent', ); result = await client.selfUpdateRunner(config, badDraft); expect(result.state, OtoCoreWriteState.failed); expect(result.message, 'Invalid version format'); }); testWidgets('OtoClientApp wires runner self-update and refresh', ( tester, ) async { final fetchLog = []; final fakeWriteClient = _FakeCoreWriteClient( onSelfUpdateRunner: (config, draft) async { fetchLog.add('selfUpdate:${draft.runnerID}:${draft.version}'); return const OtoCoreWriteResult.succeeded( null, message: 'Update accepted', ); }, ); final fakeReadClient = _FakeCoreReadClient( onFetchRunner: (id) { fetchLog.add('fetchRunner:$id'); return OtoCoreReadResult.data( OtoRunnerRecord( runnerID: id, alias: 'Test Runner', protocolVersion: 'v1', status: 'idle', acceptedAt: DateTime.now(), firstHeartbeatAt: DateTime.now(), lastHeartbeatAt: DateTime.now(), failureReason: '', ), ); }, onFetchRunnerStatus: (id) { fetchLog.add('fetchRunnerStatus:$id'); return OtoCoreReadResult.data( OtoRunnerStatus( accepted: true, runnerID: id, status: 'idle', currentJobID: '', currentExecutionID: '', message: '', ), ); }, ); await tester.pumpWidget( OtoClientApp( coreClient: _FakeCoreClient(), readClient: fakeReadClient, writeClient: fakeWriteClient, runnerIDs: const ['runner-1'], ), ); await tester.pumpAndSettle(); // Navigate to Runners tab await tester.tap(find.byTooltip('Runners')); await tester.pumpAndSettle(); expect(find.text('ID: runner-1'), findsOneWidget); expect(find.text('Self Update'), findsOneWidget); // Clear fetch log after initial load fetchLog.clear(); // Enter version and HTTPS URL await tester.enterText(find.byType(TextField).at(0), 'v2.0.0'); await tester.pump(); await tester.enterText( find.byType(TextField).at(1), 'https://releases.example.test/oto-agent', ); await tester.pump(); // Tap Update button await tester.tap(find.text('Update')); await tester.pumpAndSettle(); // Verify write client was called with correct args expect(fetchLog, contains('selfUpdate:runner-1:v2.0.0')); // Verify accepted triggers _refreshCoreStatus (fetchRunnerStatus) expect( fetchLog.where((e) => e.startsWith('fetchRunnerStatus:')).toList(), isNotEmpty, reason: 'accepted should trigger _refreshCoreStatus with fetchRunnerStatus', ); // Verify success state is shown expect(find.textContaining('Update accepted'), findsOneWidget); }); testWidgets('OtoClientApp runner self-update deferred triggers refresh', ( tester, ) async { final fetchLog = []; final fakeWriteClient = _FakeCoreWriteClient( onSelfUpdateRunner: (config, draft) async { fetchLog.add('selfUpdate:${draft.runnerID}'); return const OtoCoreWriteResult.deferred( message: 'Scheduled for next restart', ); }, ); final fakeReadClient = _FakeCoreReadClient( onFetchRunner: (id) => OtoCoreReadResult.data( OtoRunnerRecord( runnerID: id, alias: '', protocolVersion: 'v1', status: 'idle', acceptedAt: null, firstHeartbeatAt: null, lastHeartbeatAt: null, failureReason: '', ), ), onFetchRunnerStatus: (id) { fetchLog.add('fetchRunnerStatus:$id'); return OtoCoreReadResult.data( OtoRunnerStatus( accepted: true, runnerID: id, status: 'idle', currentJobID: '', currentExecutionID: '', message: '', ), ); }, ); await tester.pumpWidget( OtoClientApp( coreClient: _FakeCoreClient(), readClient: fakeReadClient, writeClient: fakeWriteClient, runnerIDs: const ['runner-1'], ), ); await tester.pumpAndSettle(); await tester.tap(find.byTooltip('Runners')); await tester.pumpAndSettle(); fetchLog.clear(); await tester.enterText(find.byType(TextField).at(0), 'v2.0.0'); await tester.pump(); await tester.enterText( find.byType(TextField).at(1), 'https://releases.example.test/oto-agent', ); await tester.pump(); await tester.tap(find.text('Update')); await tester.pumpAndSettle(); // Deferred also triggers refresh expect( fetchLog.where((e) => e.startsWith('fetchRunnerStatus:')).toList(), isNotEmpty, reason: 'deferred should trigger _refreshCoreStatus', ); expect( find.textContaining('Deferred: Scheduled for next restart'), findsOneWidget, ); }); testWidgets( 'OtoClientApp runner self-update failed does not trigger refresh', (tester) async { final fetchLog = []; final fakeWriteClient = _FakeCoreWriteClient( onSelfUpdateRunner: (config, draft) async { fetchLog.add('selfUpdate'); return const OtoCoreWriteResult.failed( message: 'Runner not found', ); }, ); final fakeReadClient = _FakeCoreReadClient( onFetchRunner: (id) => OtoCoreReadResult.data( OtoRunnerRecord( runnerID: id, alias: '', protocolVersion: 'v1', status: 'idle', acceptedAt: null, firstHeartbeatAt: null, lastHeartbeatAt: null, failureReason: '', ), ), onFetchRunnerStatus: (id) { fetchLog.add('fetchRunnerStatus:$id'); return const OtoCoreReadResult.empty(); }, ); await tester.pumpWidget( OtoClientApp( coreClient: _FakeCoreClient(), readClient: fakeReadClient, writeClient: fakeWriteClient, runnerIDs: const ['runner-1'], ), ); await tester.pumpAndSettle(); await tester.tap(find.byTooltip('Runners')); await tester.pumpAndSettle(); fetchLog.clear(); await tester.enterText(find.byType(TextField).at(0), 'v2.0.0'); await tester.pump(); await tester.enterText( find.byType(TextField).at(1), 'https://releases.example.test/oto-agent', ); await tester.pump(); await tester.tap(find.text('Update')); await tester.pumpAndSettle(); // Failed does NOT trigger refresh expect( fetchLog.where((e) => e.startsWith('fetchRunnerStatus:')).toList(), isEmpty, reason: 'failed self-update should not trigger _refreshCoreStatus', ); expect(find.textContaining('Runner not found'), findsOneWidget); }, ); } void _registerExecutionActionTests() { testWidgets('OtoHttpCoreWriteClient maps execution action responses', ( tester, ) async { final requestedPaths = []; final client = OtoHttpCoreWriteClient( httpClient: MockClient((request) async { requestedPaths.add(request.url.path); return switch (request.url.path) { '/api/v1/runners/runner-1/executions/exec-1/cancel' => http.Response( jsonEncode({'success': true}), 200, headers: {'content-type': 'application/json'}, ), '/api/v1/runners/runner-1/executions/exec-1/report' => http.Response( jsonEncode({ 'accepted': true, 'job_id': 'job-1', 'execution_id': 'exec-1', 'state': 'succeeded', 'runner_id': 'runner-1', }), 200, headers: {'content-type': 'application/json'}, ), '/api/v1/runners/runner-1/executions/exec-1/logs' => http.Response( jsonEncode({'accepted': true}), 201, headers: {'content-type': 'application/json'}, ), '/api/v1/runners/runner-1/executions/exec-1/artifacts' => http.Response( jsonEncode({'accepted': true}), 201, headers: {'content-type': 'application/json'}, ), _ => http.Response('not found', 404), }; }), ); const config = OtoConsoleConfig( serverHttpUrl: 'http://core.example.test:18020', serverWireUrl: 'ws://core.example.test:18080/runner', ); // --- Cancel success --- final cancelDraft = const OtoExecutionCancelDraft( executionID: 'exec-1', runnerID: 'runner-1', reason: 'Test cancel', ); var result = await client.cancelExecution(config, cancelDraft); expect(result.state, OtoCoreWriteState.succeeded); expect(result.message, 'cancel accepted'); expect(requestedPaths, [ '/api/v1/runners/runner-1/executions/exec-1/cancel', ]); // --- Report success --- final reportDraft = const OtoExecutionReportDraft( executionID: 'exec-1', runnerID: 'runner-1', jobID: 'job-1', success: true, exitCode: 0, message: 'Done', ); result = await client.reportExecution(config, reportDraft); expect(result.state, OtoCoreWriteState.succeeded); expect(result.message, 'report accepted'); // --- Log success --- final logDraft = const OtoExecutionLogDraft( executionID: 'exec-1', runnerID: 'runner-1', line: 'test log line', ); result = await client.appendExecutionLog(config, logDraft); expect(result.state, OtoCoreWriteState.succeeded); expect(result.message, 'log accepted'); // --- Artifact success --- final artifactDraft = const OtoExecutionArtifactDraft( executionID: 'exec-1', runnerID: 'runner-1', name: 'test.txt', path: '/tmp/test.txt', ); result = await client.appendExecutionArtifact(config, artifactDraft); expect(result.state, OtoCoreWriteState.succeeded); expect(result.message, 'artifact accepted'); // --- Cancel without runner id is rejected before hitting a nonexistent Core route --- final cancelErrorDraft = const OtoExecutionCancelDraft( executionID: 'exec-error', ); result = await client.cancelExecution(config, cancelErrorDraft); expect(result.state, OtoCoreWriteState.failed); expect(result.message, 'Runner ID is required'); expect( requestedPaths, isNot(contains('/api/v1/executions/exec-error/cancel')), ); // --- Report without runner id is rejected before hitting a nonexistent Core route --- final reportErrorDraft = const OtoExecutionReportDraft( executionID: 'exec-bad', success: true, ); result = await client.reportExecution(config, reportErrorDraft); expect(result.state, OtoCoreWriteState.failed); expect(result.message, 'Runner ID is required'); expect( requestedPaths, isNot(contains('/api/v1/executions/exec-bad/report')), ); }); testWidgets('OtoClientApp wires execution actions and refresh', ( tester, ) async { // Test that execution actions show on expanded execution final writeCalls = []; final fakeWriteClient = _FakeCoreWriteClient( onCancelExecution: (config, draft) async { writeCalls.add('cancel:${draft.executionID}'); return const OtoCoreWriteResult.failed(message: 'cancel rejected'); }, onReportExecution: (config, draft) async { writeCalls.add('report:${draft.executionID}'); return const OtoCoreWriteResult.failed(message: 'report rejected'); }, onAppendExecutionLog: (config, draft) async { writeCalls.add('log:${draft.executionID}'); return const OtoCoreWriteResult.failed(message: 'log rejected'); }, onAppendExecutionArtifact: (config, draft) async { writeCalls.add('artifact:${draft.executionID}'); return const OtoCoreWriteResult.failed(message: 'artifact rejected'); }, ); final fakeReadClient = _FakeCoreReadClient( onFetchExecution: (id) => OtoCoreReadResult.data( OtoExecutionRecord( id: id, jobID: 'job-test', state: 'running', createdAt: DateTime.now(), updatedAt: DateTime.now(), executionID: id, ), ), onFetchRunnerStatus: (id) => OtoCoreReadResult.data( OtoRunnerStatus( accepted: true, runnerID: id, status: 'online', currentJobID: 'job-1', currentExecutionID: 'exec-action-test', message: 'Running', ), ), ); await tester.pumpWidget( OtoClientApp( coreClient: _FakeCoreClient(), readClient: fakeReadClient, writeClient: fakeWriteClient, runnerIDs: const ['runner-1'], executionIDs: const ['exec-action-test'], ), ); await tester.pumpAndSettle(); // Go to Executions tab and expand await tester.tap(find.byTooltip('Executions')); await tester.pumpAndSettle(); expect(find.text('Exec ID: exec-action-test'), findsOneWidget); await tester.tap(find.text('Exec ID: exec-action-test')); await tester.pumpAndSettle(); // Actions section should be visible expect(find.text('Actions'), findsOneWidget); expect(find.text('Cancel'), findsOneWidget); expect(find.text('Report'), findsOneWidget); expect(find.text('Log'), findsOneWidget); expect(find.text('Artifact'), findsOneWidget); // Tap Report button — should call write client and show failed state await tester.tap(find.text('Report')); await tester.pumpAndSettle(); expect(writeCalls, contains('report:exec-action-test')); expect(find.textContaining('report rejected'), findsOneWidget); // Tap Log and Artifact to verify both write callbacks are wired. await tester.tap(find.text('Log')); await tester.pumpAndSettle(); expect(writeCalls, contains('log:exec-action-test')); expect(find.textContaining('log rejected'), findsOneWidget); await tester.tap(find.text('Artifact')); await tester.pumpAndSettle(); expect(writeCalls, contains('artifact:exec-action-test')); expect(find.textContaining('artifact rejected'), findsOneWidget); // Failed action does not cause read reloads — only action state shows error // (No refresh is triggered on failure) }); testWidgets( 'OtoClientApp execution action success triggers refresh with real runner id', (tester) async { final fetchLog = []; final fakeWriteClient = _FakeCoreWriteClient( onReportExecution: (config, draft) async { fetchLog.add('report:${draft.executionID}:runner:${draft.runnerID}'); // Return success state — use state check, not isSuccess (data is null) return OtoCoreWriteResult.succeeded( null, message: 'report accepted', ); }, ); final fakeReadClient = _FakeCoreReadClient( onFetchExecution: (id) { fetchLog.add('fetchExecution:$id'); return OtoCoreReadResult.data( OtoExecutionRecord( id: id, jobID: 'job-test', state: 'running', createdAt: DateTime.now(), updatedAt: DateTime.now(), executionID: id, ), ); }, onFetchRunnerStatus: (id) { fetchLog.add('fetchRunnerStatus:$id'); return OtoCoreReadResult.data( OtoRunnerStatus( accepted: true, runnerID: id, status: 'online', currentJobID: 'job-1', currentExecutionID: 'exec-action-test', message: 'Running', ), ); }, onFetchLogs: (id) { fetchLog.add('fetchLogs:$id'); return const OtoCoreReadResult.data([]); }, onFetchArtifacts: (id) { fetchLog.add('fetchArtifacts:$id'); return const OtoCoreReadResult.data([]); }, ); await tester.pumpWidget( OtoClientApp( coreClient: _FakeCoreClient(), readClient: fakeReadClient, writeClient: fakeWriteClient, runnerIDs: const ['runner-1'], executionIDs: const ['exec-action-test'], ), ); await tester.pumpAndSettle(); // Go to Executions tab and expand await tester.tap(find.byTooltip('Executions')); await tester.pumpAndSettle(); await tester.tap(find.text('Exec ID: exec-action-test')); await tester.pumpAndSettle(); // Clear fetch log after initial load fetchLog.clear(); // Tap Report button — should call write client and show success state await tester.tap(find.text('Report')); await tester.pumpAndSettle(); // Verify runner id is real, not execution id expect(fetchLog, contains('report:exec-action-test:runner:runner-1')); // Verify success triggers _refreshCoreStatus() which includes fetchRunnerStatus expect( fetchLog.where((e) => e.startsWith('fetchRunnerStatus:')).toList(), isNotEmpty, reason: 'success should trigger _refreshCoreStatus() with fetchRunnerStatus', ); // Verify success triggers _loadExecutionDetail(executionID) which includes fetchExecution, fetchLogs, fetchArtifacts expect( fetchLog.where((e) => e.startsWith('fetchExecution:')).toList(), isNotEmpty, reason: 'success should trigger _loadExecutionDetail() with fetchExecution', ); expect( fetchLog.where((e) => e.startsWith('fetchLogs:')).toList(), isNotEmpty, reason: 'success should trigger _loadExecutionDetail() with fetchLogs', ); expect( fetchLog.where((e) => e.startsWith('fetchArtifacts:')).toList(), isNotEmpty, reason: 'success should trigger _loadExecutionDetail() with fetchArtifacts', ); }, ); testWidgets('OtoClientApp unknown runner id keeps action disabled', ( tester, ) async { final writeCalls = []; final fakeWriteClient = _FakeCoreWriteClient( onReportExecution: (config, draft) async { writeCalls.add('report:${draft.executionID}:runner:${draft.runnerID}'); return const OtoCoreWriteResult.succeeded( null, message: 'report accepted', ); }, ); // Runner status has no currentExecutionID matching our execution final fakeReadClient = _FakeCoreReadClient( onFetchExecution: (id) => OtoCoreReadResult.data( OtoExecutionRecord( id: id, jobID: 'job-test', state: 'running', createdAt: DateTime.now(), updatedAt: DateTime.now(), executionID: id, ), ), onFetchRunnerStatus: (id) => OtoCoreReadResult.data( OtoRunnerStatus( accepted: true, runnerID: id, status: 'online', currentJobID: 'job-other', currentExecutionID: 'exec-different', message: 'Running', ), ), ); await tester.pumpWidget( OtoClientApp( coreClient: _FakeCoreClient(), readClient: fakeReadClient, writeClient: fakeWriteClient, runnerIDs: const ['runner-1'], executionIDs: const ['exec-no-runner'], ), ); await tester.pumpAndSettle(); // Go to Executions tab and expand await tester.tap(find.byTooltip('Executions')); await tester.pumpAndSettle(); await tester.tap(find.text('Exec ID: exec-no-runner')); await tester.pumpAndSettle(); // Actions section should be visible expect(find.text('Actions'), findsOneWidget); // Tap Report button — even though we tap, the widget should not call write client // because runnerID is empty string (disabled) await tester.tap(find.text('Report')); await tester.pumpAndSettle(); // When runner id is unknown (empty), the action callback should NOT be called // because the UI shows disabled buttons expect( writeCalls, isEmpty, reason: 'unknown runner should keep action disabled', ); }); } void _registerJobCreateTests() { testWidgets('OtoHttpCoreWriteClient maps job create responses', ( tester, ) async { final requestedPaths = []; final client = OtoHttpCoreWriteClient( httpClient: MockClient((request) async { requestedPaths.add(request.url.path); return switch (request.url.path) { '/api/v1/jobs' => http.Response( jsonEncode({ 'id': 'new-job-1', 'name': 'Deploy Pipeline', 'state': 'queued', 'created_at': '2026-06-18T01:00:00Z', 'updated_at': '2026-06-18T01:00:00Z', 'execution_id': '', }), 201, headers: {'content-type': 'application/json'}, ), _ => http.Response('not found', 404), }; }), ); const config = OtoConsoleConfig( serverHttpUrl: 'http://core.example.test:18020', serverWireUrl: 'ws://core.example.test:18080/runner', ); final draft = const OtoJobCreateDraft( id: 'new-job-1', name: 'Deploy Pipeline', ); final result = await client.createJob(config, draft); expect(result.state, OtoCoreWriteState.succeeded); expect(result.data!.id, 'new-job-1'); expect(result.statusCode, 201); expect(requestedPaths, ['/api/v1/jobs']); }); testWidgets('OtoHttpCoreWriteClient maps 400 invalid request', ( tester, ) async { final client = OtoHttpCoreWriteClient( httpClient: MockClient((request) async { return http.Response( '{"error":"bad_request","message":"Job ID is required"}', 400, headers: {'content-type': 'application/json'}, ); }), ); const config = OtoConsoleConfig( serverHttpUrl: 'http://core.example.test:18020', serverWireUrl: 'ws://core.example.test:18080/runner', ); final draft = const OtoJobCreateDraft(id: '', name: ''); final result = await client.createJob(config, draft); expect(result.state, OtoCoreWriteState.failed); expect(result.statusCode, 400); expect(result.message, 'Job ID is required'); }); test('OtoHttpCoreWriteClient maps timeout', () async { final client = OtoHttpCoreWriteClient( httpClient: MockClient((request) async { await Future.delayed(const Duration(seconds: 30)); return http.Response('timeout', 504); }), timeout: const Duration(milliseconds: 100), ); const config = OtoConsoleConfig( serverHttpUrl: 'http://core.example.test:18020', serverWireUrl: 'ws://core.example.test:18080/runner', ); final draft = const OtoJobCreateDraft(id: 'job-1', name: 'Test Job'); final result = await client.createJob(config, draft); expect(result.state, OtoCoreWriteState.failed); expect(result.message, contains('Timed out')); }); testWidgets('OtoHttpCoreWriteClient maps 409 conflict', (tester) async { final client = OtoHttpCoreWriteClient( httpClient: MockClient((request) async { return http.Response( '{"message": "Job ID already exists"}', 409, headers: {'content-type': 'application/json'}, ); }), ); const config = OtoConsoleConfig( serverHttpUrl: 'http://core.example.test:18020', serverWireUrl: 'ws://core.example.test:18080/runner', ); final draft = const OtoJobCreateDraft( id: 'existing-job', name: 'Existing Job', ); final result = await client.createJob(config, draft); expect(result.state, OtoCoreWriteState.failed); expect(result.message, 'Job ID already exists'); expect(result.statusCode, 409); }); testWidgets('OtoHttpCoreWriteClient rejects invalid server URL', ( tester, ) async { final client = OtoHttpCoreWriteClient(); const invalidConfig = OtoConsoleConfig( serverHttpUrl: 'not-a-url', serverWireUrl: 'ws://core.example.test:18080/runner', ); final draft = const OtoJobCreateDraft(id: 'job-1', name: 'Test Job'); final result = await client.createJob(invalidConfig, draft); expect(result.state, OtoCoreWriteState.failed); expect(result.message, 'Invalid server URL'); }); testWidgets('OtoHttpCoreWriteClient maps 500 server error', (tester) async { final client = OtoHttpCoreWriteClient( httpClient: MockClient((request) async { return http.Response('Internal Server Error', 500); }), ); const config = OtoConsoleConfig( serverHttpUrl: 'http://core.example.test:18020', serverWireUrl: 'ws://core.example.test:18080/runner', ); final draft = const OtoJobCreateDraft(id: 'job-1', name: 'Test Job'); final result = await client.createJob(config, draft); expect(result.state, OtoCoreWriteState.failed); expect(result.message, 'Server error'); expect(result.statusCode, 500); }); testWidgets('OtoClientApp wires job create action and refresh', ( tester, ) async { var createCallCount = 0; late OtoCoreWriteResult createResult; final createdJobRecord = OtoJobRecord( id: 'created-job-1', name: 'Created Job', state: 'queued', createdAt: DateTime.now(), updatedAt: DateTime.now(), executionID: '', runRequest: null, ); final fakeWriteClient = _FakeCoreWriteClient( onCreateJob: (config, draft) async { createCallCount++; expect(draft.id, 'new-job'); expect(draft.name, 'New Job'); return createResult; }, ); final fakeReadClient = _FakeCoreReadClient( onFetchJob: (id) { if (id == 'created-job-1') { return OtoCoreReadResult.data(createdJobRecord); } return const OtoCoreReadResult.empty(); }, ); createResult = OtoCoreWriteResult.succeeded( createdJobRecord, statusCode: 201, ); await tester.pumpWidget( OtoClientApp( coreClient: _FakeCoreClient(), readClient: fakeReadClient, writeClient: fakeWriteClient, jobIDs: const [], executionIDs: const [], ), ); await tester.pumpAndSettle(); // Pipelines stays a list surface; New Item owns the create form. await tester.tap(find.byTooltip('Pipelines')); await tester.pumpAndSettle(); expect(find.text('No pipeline jobs'), findsOneWidget); expect(find.byType(TextField), findsNothing); await tester.tap(_newItemMenuButton()); await tester.pumpAndSettle(); expect(find.text('New Item section'), findsOneWidget); expect(find.text('Pipelines / Jobs'), findsNothing); expect(find.text('No pipeline jobs'), findsNothing); expect(find.byType(TextField), findsNWidgets(2)); expect(find.text('Create'), findsOneWidget); // Verify fresh form submit button is initially disabled var button = tester.widget( find.ancestor( of: find.text('Create'), matching: find.byType(ElevatedButton), ), ); expect(button.onPressed, isNull); // Fill form and submit await tester.enterText(find.byType(TextField).at(0), 'new-job'); await tester.pump(); await tester.enterText(find.byType(TextField).at(1), 'New Job'); await tester.pump(); // Button should now be enabled button = tester.widget( find.ancestor( of: find.text('Create'), matching: find.byType(ElevatedButton), ), ); expect(button.onPressed, isNotNull); // Tap submit await tester.tap( find.ancestor( of: find.text('Create'), matching: find.byType(ElevatedButton), ), ); await tester.pumpAndSettle(); // Verify write client was called once with correct draft expect(createCallCount, 1); // Verify success state shows message (default message is 'Created') // Use find.byType(Container) + text to distinguish from job row "Created Job" and "Created: ..." expect( find.descendant( of: find.byType(Container), matching: find.text('Created'), ), findsOneWidget, ); // Verify refresh fetched the created job await tester.tap(find.byTooltip('Executions')); await tester.pumpAndSettle(); await tester.tap(find.byTooltip('Pipelines')); await tester.pumpAndSettle(); expect(find.text('Created Job'), findsOneWidget); expect(find.text('Job ID: created-job-1'), findsOneWidget); // --- Test failed result shows error message --- createCallCount = 0; createResult = const OtoCoreWriteResult.failed(message: 'Bad request'); // Need a fresh New Item surface await tester.pumpWidget( OtoClientApp( coreClient: _FakeCoreClient(), readClient: _FakeCoreReadClient(), writeClient: _FakeCoreWriteClient( onCreateJob: (config, draft) async { createCallCount++; return createResult; }, ), jobIDs: const [], executionIDs: const [], ), ); await tester.pumpAndSettle(); await tester.tap(_newItemMenuButton()); await tester.pumpAndSettle(); // Fill and submit again await tester.enterText(find.byType(TextField).at(0), 'fail-job'); await tester.pump(); await tester.enterText(find.byType(TextField).at(1), 'Fail Job'); await tester.pump(); await tester.tap(find.text('Create')); await tester.pumpAndSettle(); expect(createCallCount, 1); expect(find.textContaining('Bad request'), findsOneWidget); // --- Test duplicate submit guard: submitting state ignores second tap --- createCallCount = 0; final pendingResult = Completer>(); await tester.pumpWidget( OtoClientApp( coreClient: _FakeCoreClient(), readClient: _FakeCoreReadClient(), writeClient: _FakeCoreWriteClient( onCreateJob: (config, draft) async { createCallCount++; return await pendingResult.future; }, ), jobIDs: const [], executionIDs: const [], ), ); await tester.pumpAndSettle(); await tester.tap(_newItemMenuButton()); await tester.pumpAndSettle(); await tester.enterText(find.byType(TextField).at(0), 'guard-job'); await tester.pump(); await tester.enterText(find.byType(TextField).at(1), 'Guard Job'); await tester.pump(); await tester.tap(find.text('Create')); await tester.pump(); // First tap called onCreateJob once expect(createCallCount, 1); // Tap submit again while submitting — button now shows spinner, not 'Create' text await tester.tap(find.byType(ElevatedButton)); await tester.pump(); // Second tap was ignored — still only one call expect(createCallCount, 1); // Resolve the pending result pendingResult.complete( OtoCoreWriteResult.succeeded(createdJobRecord, statusCode: 201), ); await tester.pumpAndSettle(); // Should transition to success after resolution expect( find.descendant( of: find.byType(Container), matching: find.text('Created'), ), findsOneWidget, ); }); testWidgets('OtoClientApp job create callback wires correctly', ( tester, ) async { final fakeWriteClient = _FakeCoreWriteClient( onCreateJob: (config, draft) async { return const OtoCoreWriteResult.failed(message: 'Invalid request body'); }, ); final fakeReadClient = _FakeCoreReadClient(); await tester.pumpWidget( OtoClientApp( coreClient: _FakeCoreClient(), readClient: fakeReadClient, writeClient: fakeWriteClient, jobIDs: const [], executionIDs: const [], ), ); await tester.pumpAndSettle(); await tester.tap(_newItemMenuButton()); await tester.pumpAndSettle(); expect(find.text('New Item section'), findsOneWidget); expect(find.text('Pipelines / Jobs'), findsNothing); expect(find.byType(TextField), findsNWidgets(2)); }); testWidgets('OtoClientApp refresh includes created job IDs', (tester) async { final fetchedJobIDs = []; final fakeWriteClient = _FakeCoreWriteClient( onCreateJob: (config, draft) async { return OtoCoreWriteResult.succeeded( OtoJobRecord( id: draft.id, name: draft.name, state: 'queued', createdAt: DateTime.now(), updatedAt: DateTime.now(), executionID: '', runRequest: null, ), statusCode: 201, ); }, ); final fakeReadClient = _FakeCoreReadClient( onFetchJob: (id) { fetchedJobIDs.add(id); return OtoCoreReadResult.data( OtoJobRecord( id: id, name: 'Job $id', state: 'queued', createdAt: DateTime.now(), updatedAt: DateTime.now(), executionID: '', runRequest: null, ), ); }, ); await tester.pumpWidget( OtoClientApp( coreClient: _FakeCoreClient(), readClient: fakeReadClient, writeClient: fakeWriteClient, jobIDs: const ['existing-job'], executionIDs: const [], ), ); await tester.pumpAndSettle(); // Initial refresh should include existing-job expect(fetchedJobIDs, ['existing-job']); // New Item should have create form visible. await tester.tap(_newItemMenuButton()); await tester.pumpAndSettle(); expect(find.text('New Item section'), findsOneWidget); expect(find.text('Pipelines / Jobs'), findsNothing); expect(find.byType(TextField), findsNWidgets(2)); }); } void _registerJobsExecutionsTests() { testWidgets( 'OtoClientApp wires jobs, executions and artifacts data state on API success', (tester) async { final fakeReadClient = _FakeCoreReadClient( onFetchJob: (id) => OtoCoreReadResult.data( OtoJobRecord( id: id, name: 'Deploy Pipeline', state: 'running', createdAt: DateTime.now(), updatedAt: DateTime.now(), executionID: 'exec-99', runRequest: null, ), ), onFetchExecution: (id) => OtoCoreReadResult.data( OtoExecutionRecord( id: id, jobID: 'job-77', state: 'succeeded', createdAt: DateTime.now(), updatedAt: DateTime.now(), executionID: id, ), ), onFetchLogs: (id) => const OtoCoreReadResult.data([ OtoExecutionLogEntry(timestamp: null, line: 'Task 1 done'), ]), onFetchArtifacts: (id) => const OtoCoreReadResult.data([ OtoArtifactRecord( name: 'release.tar.gz', path: '/out/release.tar.gz', ), ]), ); await tester.pumpWidget( OtoClientApp( coreClient: _FakeCoreClient(), readClient: fakeReadClient, jobIDs: const ['job-77'], executionIDs: const ['exec-99'], ), ); await tester.pumpAndSettle(); // 1. Pipelines / Jobs Tab await tester.tap(find.byTooltip('Pipelines')); await tester.pumpAndSettle(); expect(find.text('Deploy Pipeline'), findsOneWidget); expect(find.text('RUNNING'), findsOneWidget); expect(find.text('Job ID: job-77'), findsOneWidget); // 2. Executions Tab await tester.tap(find.byTooltip('Executions')); await tester.pumpAndSettle(); expect(find.text('Exec ID: exec-99'), findsOneWidget); expect(find.text('SUCCEEDED'), findsOneWidget); expect(find.text('Job ID: job-77'), findsOneWidget); // Expand to see logs/artifacts await tester.tap(find.text('Exec ID: exec-99')); await tester.pumpAndSettle(); expect(find.text('Run exec-99'), findsOneWidget); expect(find.text('Stage Graph'), findsOneWidget); expect(find.text('Checkout'), findsOneWidget); expect(find.text('Build'), findsOneWidget); expect(find.text('Package'), findsOneWidget); expect(find.text('Publish'), findsOneWidget); // 'Console Output' appears once in the run-context task menu and once // as the logs pane title. expect(find.text('Console Output'), findsNWidgets(2)); expect(find.textContaining('Task 1 done'), findsOneWidget); expect(find.text('release.tar.gz'), findsOneWidget); // 3. Artifacts Tab await tester.tap(find.byTooltip('Artifacts')); await tester.pumpAndSettle(); expect(find.text('release.tar.gz'), findsOneWidget); expect(find.text('Path: /out/release.tar.gz'), findsOneWidget); }, ); testWidgets( 'OtoClientApp wires jobs, executions and artifacts empty states when no IDs', (tester) async { await tester.pumpWidget( OtoClientApp( coreClient: _FakeCoreClient(), readClient: _FakeCoreReadClient(), jobIDs: const [], executionIDs: const [], ), ); await tester.pumpAndSettle(); // Pipelines Tab empty check await tester.tap(find.byTooltip('Pipelines')); await tester.pumpAndSettle(); expect(find.text('No pipeline jobs'), findsOneWidget); expect(find.text('Job queue is empty.'), findsOneWidget); // Executions Tab empty check await tester.tap(find.byTooltip('Executions')); await tester.pumpAndSettle(); expect(find.text('No executions'), findsOneWidget); expect(find.text('Execution history is empty.'), findsOneWidget); // Artifacts Tab empty check await tester.tap(find.byTooltip('Artifacts')); await tester.pumpAndSettle(); expect(find.text('No artifacts'), findsOneWidget); expect(find.text('Artifact events are empty.'), findsOneWidget); }, ); testWidgets( 'OtoClientApp wires jobs, executions and artifacts error states on API failure', (tester) async { final fakeReadClient = _FakeCoreReadClient( onFetchJob: (id) => const OtoCoreReadResult.error('Failed to fetch job 1'), onFetchExecution: (id) => const OtoCoreReadResult.error('Failed to fetch exec 1'), onFetchArtifacts: (id) => const OtoCoreReadResult.error('Failed to fetch artifacts 1'), ); await tester.pumpWidget( OtoClientApp( coreClient: _FakeCoreClient(), readClient: fakeReadClient, jobIDs: const ['job-err'], executionIDs: const ['exec-err'], ), ); await tester.pumpAndSettle(); // Pipelines Tab error check await tester.tap(find.byTooltip('Pipelines')); await tester.pumpAndSettle(); expect(find.text('Error loading jobs'), findsOneWidget); expect(find.text('Failed to fetch job 1'), findsOneWidget); // Executions Tab error check await tester.tap(find.byTooltip('Executions')); await tester.pumpAndSettle(); expect(find.text('Error loading executions'), findsOneWidget); expect(find.text('Failed to fetch exec 1'), findsOneWidget); // Artifacts Tab error check await tester.tap(find.byTooltip('Artifacts')); await tester.pumpAndSettle(); expect(find.text('Error loading artifacts'), findsOneWidget); expect(find.text('Failed to fetch artifacts 1'), findsOneWidget); }, ); testWidgets( 'OtoClientApp wires expanded execution detail logs and artifacts empty/error states', (tester) async { final executions = [ OtoExecutionRecord( id: 'exec-detail', jobID: 'job-1', state: 'running', createdAt: DateTime.now(), updatedAt: DateTime.now(), executionID: 'exec-detail', ), ]; bool shouldError = false; final fakeReadClient = _FakeCoreReadClient( onFetchExecution: (id) => OtoCoreReadResult.data(executions.first), onFetchLogs: (id) => shouldError ? const OtoCoreReadResult.error('Failed to load logs') : const OtoCoreReadResult.empty(data: []), onFetchArtifacts: (id) => shouldError ? const OtoCoreReadResult.error('Failed to load artifacts') : const OtoCoreReadResult.empty(data: []), ); // 1. Render App and Expand to verify Empty State await tester.pumpWidget( OtoClientApp( coreClient: _FakeCoreClient(), readClient: fakeReadClient, executionIDs: const ['exec-detail'], ), ); await tester.pumpAndSettle(); await tester.tap(find.byTooltip('Executions')); await tester.pumpAndSettle(); // Expand await tester.tap(find.text('Exec ID: exec-detail')); await tester.pumpAndSettle(); expect(find.text('Log is empty.'), findsOneWidget); expect(find.text('No artifacts produced.'), findsOneWidget); // Collapse await tester.tap(find.text('Exec ID: exec-detail')); await tester.pumpAndSettle(); // Change to error state shouldError = true; // Expand again to reload detail in error state await tester.tap(find.text('Exec ID: exec-detail')); await tester.pumpAndSettle(); expect( find.text('Error loading logs: Failed to load logs'), findsOneWidget, ); expect( find.text('Error loading artifacts: Failed to load artifacts'), findsOneWidget, ); }, ); }