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); }); _registerJobCreateTests(); _registerJobsExecutionsTests(); } http.Response _jsonResponse(Map body) { return http.Response( jsonEncode(body), 200, headers: {'content-type': 'application/json'}, ); } 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; OtoConsoleConfig? lastUsedConfig; OtoJobCreateDraft? lastUsedDraft; _FakeCoreWriteClient({this.onCreateJob}); @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'); } } 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(); // Verify Pipelines tab renders the jobs surface await tester.tap(find.byTooltip('Pipelines')); await tester.pumpAndSettle(); expect(find.textContaining('Create New Job'), findsOneWidget); 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); expect(find.textContaining('created-job-1'), 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); // --- Test failed result shows error message --- createCallCount = 0; createResult = const OtoCoreWriteResult.failed(message: 'Bad request'); // Need a fresh Pipelines 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(find.byTooltip('Pipelines')); 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(find.byTooltip('Pipelines')); 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(find.byTooltip('Pipelines')); await tester.pumpAndSettle(); expect(find.textContaining('Create New Job'), findsOneWidget); 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']); // Pipelines tab should have create form visible await tester.tap(find.byTooltip('Pipelines')); await tester.pumpAndSettle(); expect(find.textContaining('Create New Job'), findsOneWidget); }, ); } 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('Logs Preview'), findsOneWidget); 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); }); }