import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:http/testing.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); }); _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(); } } 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); }); }