import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:oto_console/oto_console.dart'; void main() { const onlineConnection = 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.', ); test('exports console contract models without shell dependency', () { expect( OtoConsoleSection.values.map((section) => section.name), containsAllInOrder([ 'overview', 'runners', 'pipelines', 'executions', 'artifacts', 'agent', 'settings', ]), ); final contractFile = File('lib/src/oto_console_contract.dart'); final contractSource = (contractFile.existsSync() ? contractFile : File( 'packages/flutter/oto_console/lib/src/oto_console_contract.dart', )) .readAsStringSync(); expect(contractSource, isNot(contains('oto_console_shell.dart'))); }); testWidgets('renders embeddable OTO console shell', (tester) async { const config = OtoConsoleConfig( serverHttpUrl: 'http://localhost:8080', serverWireUrl: 'ws://localhost:18080/runner', ); await tester.pumpWidget( const MaterialApp( home: OtoConsoleShell( config: config, capabilities: OtoCapabilityPack( capabilities: [ OtoCapability( name: 'Remote Run', description: 'Dispatch remote build runs.', ), ], ), overview: OtoConsoleOverview( config: config, connection: onlineConnection, ), ), ), ); 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); // Jenkins IA shell: breadcrumb and Dashboard-level left menu labels. expect(find.text('OTO'), findsOneWidget); expect(find.text('New Item'), findsOneWidget); expect(find.text('Pipelines'), findsOneWidget); expect(find.text('Build History'), findsOneWidget); await tester.tap(find.byTooltip('Agent')); await tester.pumpAndSettle(); expect(find.textContaining('OTO agent surface is ready'), findsOneWidget); expect(find.textContaining('Remote Run'), findsOneWidget); }); testWidgets('renders section surfaces with empty states', (tester) async { const config = OtoConsoleConfig( serverHttpUrl: 'http://localhost:8080', serverWireUrl: 'ws://localhost:18080/runner', ); await tester.pumpWidget( const MaterialApp( home: OtoConsoleShell( config: config, overview: OtoConsoleOverview( config: config, connection: onlineConnection, ), ), ), ); final sections = { 'Runners': 'No runners connected', 'Pipelines': 'No pipeline jobs', 'Executions': 'No executions', 'Artifacts': 'No artifacts', }; for (final entry in sections.entries) { await tester.tap(find.byTooltip(entry.key)); await tester.pumpAndSettle(); expect(find.text(entry.value), findsOneWidget); } // Settings is no longer a generic empty-state fallback: it renders the // Manage OTO category surface (System, Security -> Browser policy). await tester.tap(find.byTooltip('Settings')); await tester.pumpAndSettle(); expect(find.text('Manage OTO'), findsOneWidget); expect(find.text('System'), findsOneWidget); expect(find.text('Security'), findsOneWidget); expect(find.text('Browser policy'), findsOneWidget); expect(find.text('Browser API policy'), findsOneWidget); expect(find.textContaining('same-origin proxy'), findsOneWidget); }); testWidgets('OtoRunnersSurface renders loading state', (tester) async { await tester.pumpWidget( const MaterialApp( home: Scaffold( body: OtoRunnersSurface(snapshot: OtoSurfaceSnapshot.loading()), ), ), ); expect(find.byType(CircularProgressIndicator), findsOneWidget); }); testWidgets('OtoRunnersSurface renders empty state', (tester) async { await tester.pumpWidget( const MaterialApp( home: Scaffold( body: OtoRunnersSurface(snapshot: OtoSurfaceSnapshot.empty()), ), ), ); expect(find.text('No runners connected'), findsOneWidget); expect(find.text('Runner registry is empty.'), findsOneWidget); }); testWidgets('OtoRunnersSurface renders error state', (tester) async { await tester.pumpWidget( const MaterialApp( home: Scaffold( body: OtoRunnersSurface( snapshot: OtoSurfaceSnapshot.error('Failed to fetch runners'), ), ), ), ); expect(find.text('Error loading runners'), findsOneWidget); expect(find.text('Failed to fetch runners'), findsOneWidget); }); testWidgets('OtoRunnersSurface renders data state with runners list', ( tester, ) async { final runners = [ const OtoRunnerViewModel( runnerID: 'runner-1', alias: 'Main Runner', status: 'active', currentJobID: 'job-101', currentExecutionID: 'exec-202', message: 'Running jobs', ), const OtoRunnerViewModel( runnerID: 'runner-2', alias: 'Backup Runner', status: 'offline', currentJobID: '', currentExecutionID: '', message: 'Connection lost', ), ]; await tester.pumpWidget( MaterialApp( home: Scaffold( body: OtoRunnersSurface(snapshot: OtoSurfaceSnapshot.data(runners)), ), ), ); // Jenkins Nodes/Agents-style table header. expect(find.text('Nodes'), findsOneWidget); expect(find.text('Executor'), findsOneWidget); expect(find.text('Current job'), findsOneWidget); expect(find.text('Current run'), findsOneWidget); expect(find.text('Manage'), findsOneWidget); expect(find.text('ID: runner-1'), findsOneWidget); expect(find.text('Alias: Main Runner'), findsOneWidget); expect(find.text('ACTIVE'), findsOneWidget); expect(find.text('Message: Running jobs'), findsOneWidget); expect(find.textContaining('Job: job-101'), findsOneWidget); expect(find.textContaining('Execution: exec-202'), findsOneWidget); expect(find.text('ID: runner-2'), findsOneWidget); expect(find.text('Alias: Backup Runner'), findsOneWidget); expect(find.text('OFFLINE'), findsOneWidget); expect(find.text('Message: Connection lost'), findsOneWidget); }); testWidgets('OtoRunnersSurface renders runner action states', (tester) async { final runners = [ const OtoRunnerViewModel( runnerID: 'runner-1', alias: 'Test Runner', status: 'idle', currentJobID: '', currentExecutionID: '', message: '', ), ]; OtoRunnerSelfUpdateDraft? capturedDraft; // --- Idle state: form visible, submit disabled (empty fields) --- await tester.pumpWidget( MaterialApp( home: Scaffold( body: OtoRunnersSurface( snapshot: OtoSurfaceSnapshot.data(runners), onSelfUpdateRunner: (draft) { capturedDraft = draft; }, ), ), ), ); expect(find.text('Self Update'), findsOneWidget); expect(find.text('Update'), findsOneWidget); final disabledBtn = tester.widget( find.ancestor( of: find.text('Update'), matching: find.byType(ElevatedButton), ), ); expect(disabledBtn.onPressed, isNull); // --- HTTPS URL validation: non-HTTPS keeps button disabled --- await tester.enterText(find.byType(TextField).at(0), 'v1.2.3'); await tester.pump(); await tester.enterText( find.byType(TextField).at(1), 'http://not-https.example.com/oto', ); await tester.pump(); final httpBtn = tester.widget( find.ancestor( of: find.text('Update'), matching: find.byType(ElevatedButton), ), ); expect(httpBtn.onPressed, isNull); // --- HTTPS URL: button enabled --- await tester.enterText( find.byType(TextField).at(1), 'https://releases.example.test/oto-agent', ); await tester.pump(); final enabledBtn = tester.widget( find.ancestor( of: find.text('Update'), matching: find.byType(ElevatedButton), ), ); expect(enabledBtn.onPressed, isNotNull); // --- Tap submit: callback receives correct draft --- await tester.tap(find.text('Update')); await tester.pump(); expect(capturedDraft, isNotNull); expect(capturedDraft!.runnerID, 'runner-1'); expect(capturedDraft!.version, 'v1.2.3'); expect( capturedDraft!.downloadUrl, 'https://releases.example.test/oto-agent', ); // --- Submitting state: spinner shown, button disabled --- await tester.pumpWidget( MaterialApp( home: Scaffold( body: OtoRunnersSurface( snapshot: OtoSurfaceSnapshot.data(runners), actionStates: {'runner-1': const OtoActionViewState.submitting()}, onSelfUpdateRunner: (draft) {}, ), ), ), ); expect(find.byType(CircularProgressIndicator), findsAtLeast(1)); final submittingBtn = tester.widget( find.ancestor( of: find.byType(CircularProgressIndicator).first, matching: find.byType(ElevatedButton), ), ); expect(submittingBtn.onPressed, isNull); // --- Deferred state: deferred message shown --- await tester.pumpWidget( MaterialApp( home: Scaffold( body: OtoRunnersSurface( snapshot: OtoSurfaceSnapshot.data(runners), actionStates: { 'runner-1': const OtoActionViewState.deferred( message: 'Update scheduled', ), }, onSelfUpdateRunner: (draft) {}, ), ), ), ); expect(find.textContaining('Deferred: Update scheduled'), findsOneWidget); // --- Error state: error message shown --- await tester.pumpWidget( MaterialApp( home: Scaffold( body: OtoRunnersSurface( snapshot: OtoSurfaceSnapshot.data(runners), actionStates: { 'runner-1': const OtoActionViewState.failed( message: 'Runner not found', ), }, onSelfUpdateRunner: (draft) {}, ), ), ), ); expect(find.textContaining('Runner not found'), findsOneWidget); }); testWidgets('OtoJobsSurface renders loading/empty/error/data states', ( tester, ) async { // Loading await tester.pumpWidget( const MaterialApp( home: Scaffold( body: OtoJobsSurface(snapshot: OtoSurfaceSnapshot.loading()), ), ), ); expect(find.byType(CircularProgressIndicator), findsOneWidget); // Empty await tester.pumpWidget( const MaterialApp( home: Scaffold( body: OtoJobsSurface(snapshot: OtoSurfaceSnapshot.empty()), ), ), ); expect(find.text('No pipeline jobs'), findsOneWidget); expect(find.text('Job queue is empty.'), findsOneWidget); // Error await tester.pumpWidget( const MaterialApp( home: Scaffold( body: OtoJobsSurface( snapshot: OtoSurfaceSnapshot.error('Failed to fetch jobs'), ), ), ), ); expect(find.text('Error loading jobs'), findsOneWidget); expect(find.text('Failed to fetch jobs'), findsOneWidget); // Data final jobs = [ const OtoJobViewModel( jobID: 'job-1', name: 'Build Job', state: 'running', executionID: 'exec-1', createdAt: '2026-06-15T00:00:00Z', updatedAt: '2026-06-15T00:05:00Z', ), ]; String? tappedExecution; await tester.pumpWidget( MaterialApp( home: Scaffold( body: OtoJobsSurface( snapshot: OtoSurfaceSnapshot.data(jobs), onExecutionTap: (id) => tappedExecution = id, ), ), ), ); expect(find.text('Build Job'), findsOneWidget); expect(find.text('RUNNING'), findsOneWidget); expect(find.text('Job ID: job-1'), findsOneWidget); expect(find.textContaining('Execution: exec-1'), findsOneWidget); // Jenkins dashboard job table: header columns and job-context task menu. expect(find.text('Name'), findsOneWidget); expect(find.text('Last Run'), findsOneWidget); expect(find.text('Updated'), findsOneWidget); expect(find.text('Actions'), findsOneWidget); // 'Status' appears as both a table header and a job-context task menu // chip; 'Build History' appears in the context strip and the task menu. expect(find.text('Status'), findsNWidgets(2)); expect(find.text('Build History'), findsNWidgets(2)); expect(find.text('Stages'), findsOneWidget); expect(find.text('Build Queue'), findsOneWidget); await tester.tap(find.textContaining('Execution: exec-1')); await tester.pumpAndSettle(); expect(tappedExecution, 'exec-1'); }); testWidgets( 'OtoExecutionsSurface renders loading/empty/error/data states and expanded detail', (tester) async { // Loading await tester.pumpWidget( const MaterialApp( home: Scaffold( body: OtoExecutionsSurface(snapshot: OtoSurfaceSnapshot.loading()), ), ), ); expect(find.byType(CircularProgressIndicator), findsOneWidget); // Empty await tester.pumpWidget( const MaterialApp( home: Scaffold( body: OtoExecutionsSurface(snapshot: OtoSurfaceSnapshot.empty()), ), ), ); expect(find.text('No executions'), findsOneWidget); expect(find.text('Execution history is empty.'), findsOneWidget); // Error await tester.pumpWidget( const MaterialApp( home: Scaffold( body: OtoExecutionsSurface( snapshot: OtoSurfaceSnapshot.error('Failed to fetch executions'), ), ), ), ); expect(find.text('Error loading executions'), findsOneWidget); expect(find.text('Failed to fetch executions'), findsOneWidget); // Data final executions = [ const OtoExecutionViewModel( executionID: 'exec-1', jobID: 'job-1', state: 'succeeded', runnerID: 'runner-1', createdAt: '2026-06-15T00:01:00Z', updatedAt: '2026-06-15T00:05:00Z', ), ]; String? expandedId; await tester.pumpWidget( MaterialApp( home: Scaffold( body: OtoExecutionsSurface( snapshot: OtoSurfaceSnapshot.data(executions), onExpandExecution: (id) => expandedId = id, ), ), ), ); expect(find.text('Exec ID: exec-1'), findsOneWidget); expect(find.text('SUCCEEDED'), findsOneWidget); expect(find.text('Job ID: job-1'), findsOneWidget); expect(find.text('Runner ID: runner-1'), findsOneWidget); // No run selected yet: detail region shows the placeholder. expect(find.text('Select a run to view details.'), findsOneWidget); await tester.tap(find.text('Exec ID: exec-1')); await tester.pumpAndSettle(); expect(expandedId, 'exec-1'); // Expanded detail rendering final logs = [ const OtoLogEntryViewModel( timestamp: '12:00:00', line: 'Starting build...', ), const OtoLogEntryViewModel( timestamp: '12:00:01', line: 'Finished successfully', ), ]; final artifacts = [ const OtoArtifactViewModel( name: 'binary.exe', path: '/build/binary.exe', ), ]; await tester.pumpWidget( MaterialApp( home: Scaffold( body: OtoExecutionsSurface( snapshot: OtoSurfaceSnapshot.data(executions), expandedExecutionID: 'exec-1', logsSnapshot: OtoSurfaceSnapshot.data(logs), artifactsSnapshot: OtoSurfaceSnapshot.data(artifacts), ), ), ), ); // Selected run detail: run header + run-context task menu + stage // graph + console output/artifacts panes. expect(find.text('Run exec-1'), findsOneWidget); // 'Console Output', 'Pipeline Overview', and 'Artifacts' each appear // once in the run-context task menu and once as a detail pane title. expect(find.text('Console Output'), findsNWidgets(2)); expect(find.text('Pipeline Overview'), findsNWidgets(2)); expect(find.text('Build History'), 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); expect(find.textContaining('Starting build...'), findsOneWidget); expect(find.text('Artifacts'), findsNWidgets(2)); expect(find.text('binary.exe'), findsOneWidget); }, ); testWidgets('OtoArtifactsSurface renders loading/empty/error/data states', ( tester, ) async { // Loading await tester.pumpWidget( const MaterialApp( home: Scaffold( body: OtoArtifactsSurface(snapshot: OtoSurfaceSnapshot.loading()), ), ), ); expect(find.byType(CircularProgressIndicator), findsOneWidget); // Empty await tester.pumpWidget( const MaterialApp( home: Scaffold( body: OtoArtifactsSurface(snapshot: OtoSurfaceSnapshot.empty()), ), ), ); expect(find.text('No artifacts'), findsOneWidget); expect(find.text('Artifact events are empty.'), findsOneWidget); // Error await tester.pumpWidget( const MaterialApp( home: Scaffold( body: OtoArtifactsSurface( snapshot: OtoSurfaceSnapshot.error('Failed to fetch artifacts'), ), ), ), ); expect(find.text('Error loading artifacts'), findsOneWidget); expect(find.text('Failed to fetch artifacts'), findsOneWidget); // Data final artifacts = [ const OtoArtifactViewModel(name: 'app.apk', path: '/build/app.apk'), ]; await tester.pumpWidget( MaterialApp( home: Scaffold( body: OtoArtifactsSurface( snapshot: OtoSurfaceSnapshot.data(artifacts), ), ), ), ); expect(find.text('app.apk'), findsOneWidget); expect(find.text('Path: /build/app.apk'), findsOneWidget); }); testWidgets('OtoExecutionsSurface renders execution action states', ( tester, ) async { final executionsWithRunner = [ const OtoExecutionViewModel( executionID: 'exec-1', jobID: 'job-1', state: 'running', runnerID: 'runner-1', createdAt: '2026-06-15T00:01:00Z', updatedAt: '2026-06-15T00:05:00Z', ), ]; String? capturedReportExecID; bool capturedReportSuccess = false; String? capturedLogExecID; String? capturedArtifactExecID; await tester.pumpWidget( MaterialApp( home: Scaffold( body: OtoExecutionsSurface( snapshot: OtoSurfaceSnapshot.data(executionsWithRunner), expandedExecutionID: 'exec-1', onReportExecution: (draft) { capturedReportExecID = draft.executionID; capturedReportSuccess = draft.success; }, onAppendLog: (draft) { capturedLogExecID = draft.executionID; }, onAppendArtifact: (draft) { capturedArtifactExecID = draft.executionID; }, ), ), ), ); 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); // Test Report callback await tester.tap(find.text('Report')); await tester.pumpAndSettle(); expect(capturedReportExecID, 'exec-1'); expect(capturedReportSuccess, true); // Test Log callback capturedLogExecID = null; await tester.tap(find.text('Log')); await tester.pumpAndSettle(); expect(capturedLogExecID, 'exec-1'); // Test Artifact callback capturedArtifactExecID = null; await tester.tap(find.text('Artifact')); await tester.pumpAndSettle(); expect(capturedArtifactExecID, 'exec-1'); // --- Submitting state shows spinner --- await tester.pumpWidget( MaterialApp( home: Scaffold( body: OtoExecutionsSurface( snapshot: OtoSurfaceSnapshot.data(executionsWithRunner), expandedExecutionID: 'exec-1', actionStates: {'exec-1': const OtoActionViewState.submitting()}, onReportExecution: (draft) {}, onAppendLog: (draft) {}, onAppendArtifact: (draft) {}, ), ), ), ); expect(find.byType(CircularProgressIndicator), findsAtLeast(1)); // --- Success state shows feedback text --- await tester.pumpWidget( MaterialApp( home: Scaffold( body: OtoExecutionsSurface( snapshot: OtoSurfaceSnapshot.data(executionsWithRunner), expandedExecutionID: 'exec-1', actionStates: { 'exec-1': const OtoActionViewState.succeeded( message: 'Report accepted', ), }, onReportExecution: (draft) {}, onAppendLog: (draft) {}, onAppendArtifact: (draft) {}, ), ), ), ); expect(find.textContaining('Report accepted'), findsOneWidget); // --- Failed state shows error text --- await tester.pumpWidget( MaterialApp( home: Scaffold( body: OtoExecutionsSurface( snapshot: OtoSurfaceSnapshot.data(executionsWithRunner), expandedExecutionID: 'exec-1', actionStates: { 'exec-1': const OtoActionViewState.failed( message: 'Cancel rejected', ), }, onReportExecution: (draft) {}, onAppendLog: (draft) {}, onAppendArtifact: (draft) {}, ), ), ), ); expect(find.textContaining('Cancel rejected'), findsOneWidget); }); testWidgets('OtoExecutionsSurface shows Actions section when expanded', ( tester, ) async { final executionsWithRunner = [ const OtoExecutionViewModel( executionID: 'exec-actions', jobID: 'job-1', state: 'running', runnerID: 'runner-1', createdAt: '2026-06-15T00:01:00Z', updatedAt: '2026-06-15T00:05:00Z', ), ]; await tester.pumpWidget( MaterialApp( home: Scaffold( body: OtoExecutionsSurface( snapshot: OtoSurfaceSnapshot.data(executionsWithRunner), expandedExecutionID: 'exec-actions', ), ), ), ); expect(find.text('Actions'), findsOneWidget); expect(find.byIcon(Icons.stop_circle), findsOneWidget); expect(find.byIcon(Icons.check_circle), findsOneWidget); expect(find.byIcon(Icons.bug_report), findsOneWidget); expect(find.byIcon(Icons.insert_drive_file), findsOneWidget); }); testWidgets('OtoJobsSurface renders job create action states', ( tester, ) async { await tester.pumpWidget( MaterialApp( home: Scaffold( body: OtoJobsSurface( snapshot: const OtoSurfaceSnapshot.empty(), jobCreateState: const OtoActionViewState.idle(), onCreateJob: (draft) {}, ), ), ), ); expect(find.textContaining('New Item'), findsOneWidget); expect(find.byType(TextField), findsNWidgets(2)); final createButton = find.ancestor( of: find.text('Create'), matching: find.byType(ElevatedButton), ); expect(createButton, findsOneWidget); final disabledButton = tester.widget(createButton); expect(disabledButton.onPressed, isNull); await tester.enterText(find.byType(TextField).at(0), 'my-job-id'); await tester.pump(); var button = tester.widget( find.ancestor( of: find.text('Create'), matching: find.byType(ElevatedButton), ), ); expect(button.onPressed, isNull); await tester.enterText(find.byType(TextField).at(1), 'My Job Name'); await tester.pump(); button = tester.widget( find.ancestor( of: find.text('Create'), matching: find.byType(ElevatedButton), ), ); expect(button.onPressed, isNotNull); OtoJobCreateDraft? capturedDraft; await tester.pumpWidget( MaterialApp( home: Scaffold( body: OtoJobsSurface( snapshot: const OtoSurfaceSnapshot.empty(), jobCreateState: const OtoActionViewState.idle(), onCreateJob: (draft) { capturedDraft = draft; }, ), ), ), ); await tester.enterText(find.byType(TextField).at(0), 'draft-id'); await tester.pump(); await tester.enterText(find.byType(TextField).at(1), 'Draft Name'); await tester.pump(); await tester.tap(find.text('Create')); await tester.pump(); expect(capturedDraft, isNotNull); expect(capturedDraft!.id, 'draft-id'); expect(capturedDraft!.name, 'Draft Name'); await tester.pumpWidget( MaterialApp( home: Scaffold( body: OtoJobsSurface( snapshot: const OtoSurfaceSnapshot.empty(), jobCreateState: const OtoActionViewState.submitting(), onCreateJob: (draft) {}, ), ), ), ); expect(find.byType(CircularProgressIndicator), findsOneWidget); expect(find.byType(ElevatedButton), findsOneWidget); await tester.pumpWidget( MaterialApp( home: Scaffold( body: OtoJobsSurface( snapshot: const OtoSurfaceSnapshot.empty(), jobCreateState: const OtoActionViewState.succeeded( message: 'Job created: job-123', ), onCreateJob: (draft) {}, ), ), ), ); expect(find.textContaining('Job created'), findsOneWidget); expect(find.textContaining('job-123'), findsOneWidget); await tester.pumpWidget( MaterialApp( home: Scaffold( body: OtoJobsSurface( snapshot: const OtoSurfaceSnapshot.empty(), jobCreateState: const OtoActionViewState.failed( message: 'Invalid request', ), onCreateJob: (draft) {}, ), ), ), ); expect(find.textContaining('Invalid request'), findsOneWidget); }); }